Skip to main content

gam_models/inference/
model.rs

1use gam_terms::basis::BasisOptions;
2use gam_solve::estimate::{BlockRole, FittedLinkState, UnifiedFitResult};
3use crate::bms::{
4    LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
5};
6use crate::survival::construction::{
7    SurvivalBaselineConfig, SurvivalTimeBasisConfig, parse_survival_baseline_config,
8};
9use crate::survival::location_scale::ResidualDistribution;
10use crate::survival::lognormal_kernel::FrailtySpec;
11use crate::wiggle::{
12    monotone_wiggle_basis_with_derivative_order, validate_monotone_wiggle_beta_nonnegative,
13};
14use gam_terms::inference::formula_dsl::{
15    inverse_link_supports_joint_wiggle, joint_wiggle_unsupported_link_message, parse_formula,
16    parse_surv_interval_response, parse_surv_response, parsed_term_column_names,
17};
18use gam_solve::mixture_link::{state_from_beta_logisticspec, state_from_sasspec};
19use gam_terms::smooth::{AdaptiveRegularizationDiagnostics, TermCollectionSpec};
20use gam_problem::types::{
21    InverseLink, LatentCLogLogState, LikelihoodSpec, MixtureLinkState, ResponseFamily, SasLinkSpec,
22    SasLinkState, StandardLink,
23};
24use gam_runtime::span::span_index_for_breakpoints;
25// The data-schema value types live in the `gam-data` foundation crate; they
26// were previously authored here and are still named `gam::inference::model::{
27// ColumnKindTag, DataSchema, SchemaColumn}` by a broad set of integration tests
28// and by saved-payload consumers. Re-export them so that public path stays
29// valid rather than forcing every caller onto the relocated crate path.
30pub use gam_data::{ColumnKindTag, DataSchema, SchemaColumn};
31use ndarray::{Array1, Array2};
32use serde::{Deserialize, Serialize};
33use serde_json::Value as JsonValue;
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::fs;
36use std::ops::{Deref, DerefMut};
37use std::path::Path;
38
39/// Canonical saved-model payload schema version.
40///
41/// Every `FittedModelPayload` written by any binary (CLI `gam`, gam-pyffi,
42/// downstream library users) must set this as its `version` field, and every
43/// load path asserts equality via `validate_for_persistence`. Bump this when:
44///   - A required field is added to `FittedModelPayload` and the set of
45///     Option<T> fields that must be `Some(...)` for a given `family_state`
46///     changes (otherwise the `#[serde(default)]` decode would silently fill
47///     the new field with `None` when loading an older model and the CLI
48///     predict path would run with stale metadata).
49///   - The on-wire shape of any `serde`-tagged enum variant changes such that
50///     older payloads no longer round-trip losslessly.
51///   - The semantics of an existing field change (e.g. sign convention,
52///     coordinate frame) in a way that predict output would silently diverge
53///     between old and new readers.
54///
55/// Do NOT bump for purely additive `Option<T>` fields that the save-time
56/// invariant (`validate_for_persistence`) does not yet require. Those are
57/// forward-compatible.
58pub const MODEL_PAYLOAD_VERSION: u32 = 7;
59
60/// Schema-free saved-model metadata keyed by stable group id.
61///
62/// The values are JSON rather than a typed enum because group provenance is
63/// supplied by caller-owned catalogs. `FittedModelPayload::group_metadata`
64/// wraps this in `Option` with `#[serde(default)]`, so model files written
65/// before the field existed deserialize as `None`.
66pub type GroupMetadata = BTreeMap<String, JsonValue>;
67
68/// Saved exact spline-scan fit (#1030/#1034): the predict-time feature column
69/// plus the lossless smoother state the Gaussian-bridge `predict` replays.
70#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct SavedSplineScan {
72    /// Training column name feeding the single 1-D smooth at predict time.
73    pub feature_column: String,
74    pub state: gam_solve::spline_scan::SplineScanState,
75}
76
77/// Saved multiresolution residual-cascade fit (#1032): the predict-time feature
78/// columns (d ∈ {2, 3}) plus the serializable cascade state that `from_state`
79/// rebuilds a predict-capable `ResidualCascadeFit` from. The cascade is a
80/// DIFFERENT posterior from the dense Duchon/Matérn term — never a silent swap.
81#[derive(Clone, Debug, Serialize, Deserialize)]
82pub struct SavedResidualCascade {
83    /// Training column names for the d ∈ {2, 3} scattered-smooth coordinates.
84    pub feature_columns: Vec<String>,
85    pub state: gam_solve::residual_cascade::ResidualCascadeState,
86}
87
88/// Typed error surface for `src/inference/model.rs` saved-model code.
89///
90/// Every variant carries a free-form `reason: String` payload; `Display`
91/// emits exactly that payload, so converting a `FittedModelError` into
92/// `String` (via the `From` impl below) is byte-equivalent to the pre-
93/// refactor `Err(format!(...))` / `Err("...".to_string())` strings that
94/// the same call sites produced. This lets external callers keep using
95/// `?` against `Result<_, String>` without source changes — the typed
96/// enum is purely an in-module discipline gain.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub enum FittedModelError {
99    /// Saved payload structure / shape / version disagrees with what the
100    /// current binary expects (e.g. covariance shape, block ordering,
101    /// schema version, C2 continuity, out-of-range span/basis indices).
102    SchemaMismatch { reason: String },
103    /// Saved payload bytes / numeric content are corrupt or unreadable
104    /// (non-finite scalars, invalid JSON, IO failure, malformed stateful
105    /// link state).
106    PayloadCorrupt { reason: String },
107    /// A required field that the current code path needs is absent from
108    /// the payload (typically `..; refit` errors).
109    MissingField { reason: String },
110    /// A combination of saved-model options is not supported by the
111    /// current binary (unsupported deployment-extension kind, unsupported
112    /// kernel marker, unsupported survival_time_basis variant, etc.).
113    IncompatibleConfig { reason: String },
114    /// An input value rejected by a save-time sanity gate (e.g. negative
115    /// ridge alpha).
116    InvalidInput { reason: String },
117}
118
119impl_reason_error_boilerplate! {
120    FittedModelError {
121        SchemaMismatch,
122        PayloadCorrupt,
123        MissingField,
124        IncompatibleConfig,
125        InvalidInput,
126    }
127}
128
129// Boundary conversions so external `Result<_, EstimationError>` /
130// `Result<_, SurvivalPredictError>` call sites can propagate with `?`.
131// Survival prediction keeps the model-layer source so the chain identifies
132// the payload/schema failure that triggered the prediction error.
133impl From<FittedModelError> for gam_solve::model_types::EstimationError {
134    fn from(err: FittedModelError) -> Self {
135        gam_solve::model_types::EstimationError::InvalidInput(err.to_string())
136    }
137}
138
139impl From<FittedModelError> for crate::survival::predict::SurvivalPredictError {
140    fn from(err: FittedModelError) -> Self {
141        crate::survival::predict::SurvivalPredictError::ModelPayload {
142            context: "saved-model survival prediction payload",
143            source: err,
144        }
145    }
146}
147
148#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
149pub struct SavedLatentZNormalization {
150    pub mean: f64,
151    pub sd: f64,
152}
153
154impl SavedLatentZNormalization {
155    pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
156        if !self.mean.is_finite() {
157            return Err(FittedModelError::PayloadCorrupt {
158                reason: format!("{context} latent z mean must be finite"),
159            });
160        }
161        if !(self.sd.is_finite() && self.sd > 1e-12) {
162            return Err(FittedModelError::PayloadCorrupt {
163                reason: format!(
164                    "{context} latent z sd must be finite and > 1e-12; got {}",
165                    self.sd
166                ),
167            });
168        }
169        Ok::<(), _>(())
170    }
171
172    pub fn apply(&self, z: &Array1<f64>, context: &str) -> Result<Array1<f64>, FittedModelError> {
173        self.validate(context)?;
174        if z.iter().any(|value| !value.is_finite()) {
175            return Err(FittedModelError::PayloadCorrupt {
176                reason: format!("{context} requires finite z values"),
177            });
178        }
179        Ok(z.mapv(|zi| (zi - self.mean) / self.sd))
180    }
181}
182
183pub const TRANSFORMATION_SCORE_PIT_CLIP_EPS: f64 = 1.0e-12;
184
185#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
186#[serde(rename_all = "kebab-case")]
187#[derive(Default)]
188pub enum TransformationScoreKind {
189    #[default]
190    FiniteSupportPit,
191}
192
193#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
194pub struct TransformationScoreCalibration {
195    #[serde(default)]
196    pub score_kind: TransformationScoreKind,
197    #[serde(default = "default_transformation_score_pit_clip_eps")]
198    pub clip_eps: f64,
199}
200
201const fn default_transformation_score_pit_clip_eps() -> f64 {
202    TRANSFORMATION_SCORE_PIT_CLIP_EPS
203}
204
205impl TransformationScoreCalibration {
206    pub fn finite_support_pit() -> Self {
207        Self {
208            score_kind: TransformationScoreKind::FiniteSupportPit,
209            clip_eps: TRANSFORMATION_SCORE_PIT_CLIP_EPS,
210        }
211    }
212
213    pub fn validate(&self, context: &str) -> Result<(), FittedModelError> {
214        if self.score_kind != TransformationScoreKind::FiniteSupportPit {
215            return Err(FittedModelError::IncompatibleConfig {
216                reason: format!("{context} supports only finite-support CTN PIT score semantics"),
217            });
218        }
219        if !(self.clip_eps.is_finite() && self.clip_eps > 0.0 && self.clip_eps < 0.5) {
220            return Err(FittedModelError::IncompatibleConfig {
221                reason: format!(
222                    "{context} requires PIT clip_eps in (0, 0.5), got {}",
223                    self.clip_eps
224                ),
225            });
226        }
227        Ok::<(), _>(())
228    }
229}
230
231#[derive(Clone, Serialize, Deserialize)]
232pub struct FittedModelPayload {
233    pub version: u32,
234    pub formula: String,
235    pub model_kind: ModelKind,
236    pub family_state: FittedFamily,
237    pub family: String,
238    /// Human-readable advisories produced while materializing this model —
239    /// e.g. an mgcv-style "k was reduced to the data support" note when a
240    /// cubic-regression marginal is capped, or a basis-degradation note. These
241    /// are surfaced to CLI users via `print_inference_summary`; persisting them
242    /// here lets the Python (gamfit) interface surface the SAME advisories as
243    /// warnings / `model.notes` instead of silently dropping them at the FFI
244    /// boundary (#1543). `#[serde(default)]` keeps older payloads (which had no
245    /// such field) deserializing cleanly as "no notes".
246    #[serde(default)]
247    pub inference_notes: Vec<String>,
248    #[serde(default)]
249    pub used_device: bool,
250    #[serde(default)]
251    pub fit_result: Option<UnifiedFitResult>,
252    /// Unified (family-agnostic) representation of the fit result.
253    #[serde(default)]
254    pub unified: Option<UnifiedFitResult>,
255    /// Exact O(n) spline-scan fit representation (#1030/#1034): the
256    /// state-space smoothing-spline posterior of a single 1-D Gaussian
257    /// smooth. When `Some`, this standard Gaussian model's predictions
258    /// replay the Gaussian bridge from this state and the model carries no
259    /// dense `fit_result` (the representations are mutually exclusive —
260    /// enforced by `validate_for_persistence`). `#[serde(default)]` so older
261    /// payloads read as: not a scan model.
262    #[serde(default)]
263    pub spline_scan: Option<SavedSplineScan>,
264    /// O(n log n) multiresolution residual-cascade fit (#1032): the persisted
265    /// multilevel Wendland-frame state for a single scattered 2–3D Gaussian
266    /// smooth past the dense-kernel cliff. When `Some`, predictions replay the
267    /// cascade posterior; mutually exclusive with `spline_scan`/`fit_result`.
268    /// `#[serde(default)]` keeps forward-compatibility with older payloads.
269    #[serde(default)]
270    pub residual_cascade: Option<SavedResidualCascade>,
271    #[serde(default)]
272    pub data_schema: Option<DataSchema>,
273    pub link: Option<InverseLink>,
274    #[serde(default)]
275    pub mixture_link_param_covariance: Option<Vec<Vec<f64>>>,
276    #[serde(default)]
277    pub sas_param_covariance: Option<Vec<Vec<f64>>>,
278    #[serde(default)]
279    pub formula_noise: Option<String>,
280    #[serde(default)]
281    pub formula_logslope: Option<String>,
282    #[serde(default)]
283    pub formula_logslopes: Option<Vec<String>>,
284    #[serde(default)]
285    pub offset_column: Option<String>,
286    #[serde(default)]
287    pub noise_offset_column: Option<String>,
288    /// Name of the analytic prior-weights column used at fit time (`weights=`),
289    /// persisted so replicate/generative sampling can re-resolve the per-row
290    /// weights and draw heteroskedastic Gaussian observation noise
291    /// `sigma_i = sigma_hat / sqrt(w_i)` (#2025). `None` for an unweighted fit.
292    #[serde(default)]
293    pub weight_column: Option<String>,
294    #[serde(default)]
295    pub beta_noise: Option<Vec<f64>>,
296    #[serde(default)]
297    pub noise_projection: Option<Vec<Vec<f64>>>,
298    #[serde(default)]
299    pub noise_center: Option<Vec<f64>>,
300    #[serde(default)]
301    pub noise_scale: Option<Vec<f64>>,
302    #[serde(default)]
303    pub noise_non_intercept_start: Option<usize>,
304    /// Tikhonov ridge alpha used by `solve_scale_projection` when fitting
305    /// `noise_projection`.  Persisted so prediction-time replay is identical
306    /// to fit-time projection.
307    #[serde(default)]
308    pub noise_projection_ridge_alpha: Option<f64>,
309    #[serde(default)]
310    pub gaussian_response_scale: Option<f64>,
311    #[serde(default)]
312    pub linkwiggle_knots: Option<Vec<f64>>,
313    #[serde(default)]
314    pub linkwiggle_degree: Option<usize>,
315    #[serde(default)]
316    pub beta_link_wiggle: Option<Vec<f64>>,
317    #[serde(default)]
318    pub baseline_timewiggle_knots: Option<Vec<f64>>,
319    #[serde(default)]
320    pub baseline_timewiggle_degree: Option<usize>,
321    #[serde(default)]
322    pub baseline_timewiggle_penalty_orders: Option<Vec<usize>>,
323    #[serde(default)]
324    pub baseline_timewiggle_double_penalty: Option<bool>,
325    #[serde(default)]
326    pub beta_baseline_timewiggle: Option<Vec<f64>>,
327    #[serde(default)]
328    pub beta_baseline_timewiggle_by_cause: Option<Vec<Vec<f64>>>,
329    #[serde(default)]
330    pub z_column: Option<String>,
331    #[serde(default)]
332    pub z_columns: Option<Vec<String>>,
333    #[serde(default)]
334    pub latent_z_normalization: Option<SavedLatentZNormalization>,
335    #[serde(default)]
336    pub latent_score_contract: Option<SavedLatentScoreContract>,
337    #[serde(default)]
338    pub latent_measure: Option<LatentMeasureKind>,
339    /// Optional rank-INT calibration for the latent score (BMS family).
340    /// When `Some`, the marginal-slope predictor routes the input `z`
341    /// through [`LatentZRankIntCalibration::apply_at_predict`] before the
342    /// closed-form standard-normal kernel, matching fit-time semantics.
343    /// `#[serde(default)]` so models persisted before this field existed
344    /// continue to deserialize cleanly (interpreted as: no calibration).
345    #[serde(default)]
346    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
347    /// Optional conditional location-scale calibration of the latent score
348    /// (#905, BMS family). When `Some`, the marginal-slope predictor replaces
349    /// the (normalized) input `z` by `ζ = (z − m(C))/√v(C)` — rebuilding the
350    /// conditioning span `a(C)` from the marginal prediction design — before
351    /// the closed-form standard-normal kernel, matching fit-time semantics.
352    /// Mutually exclusive with `latent_z_rank_int_calibration`. `#[serde(default)]`
353    /// so pre-existing models deserialize cleanly (interpreted as: no
354    /// conditional calibration).
355    #[serde(default)]
356    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
357    #[serde(default)]
358    pub marginal_baseline: Option<f64>,
359    #[serde(default)]
360    pub logslope_baseline: Option<f64>,
361    #[serde(default)]
362    pub logslope_baselines: Option<Vec<f64>>,
363    #[serde(default)]
364    pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
365    #[serde(default)]
366    pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
367    /// Width `p₁` of the survival marginal-slope absorbed Stage-1 influence block
368    /// (#461) when present (the dedicated trailing absorber block). Predict drops
369    /// its `γ`; this records the column count so the predictor can account for
370    /// the extra block and slice `γ` out of the joint covariance.
371    #[serde(default)]
372    pub influence_absorber_width: Option<usize>,
373    #[serde(default)]
374    pub survival_entry: Option<String>,
375    #[serde(default)]
376    pub survival_exit: Option<String>,
377    #[serde(default)]
378    pub survival_event: Option<String>,
379    #[serde(default)]
380    pub survivalspec: Option<String>,
381    #[serde(default)]
382    pub survival_cause_count: Option<usize>,
383    #[serde(default)]
384    pub survival_endpoint_names: Option<Vec<String>>,
385    #[serde(default)]
386    pub survival_baseline_target: Option<String>,
387    #[serde(default)]
388    pub survival_baseline_scale: Option<f64>,
389    #[serde(default)]
390    pub survival_baseline_shape: Option<f64>,
391    #[serde(default)]
392    pub survival_baseline_rate: Option<f64>,
393    #[serde(default)]
394    pub survival_baseline_makeham: Option<f64>,
395    #[serde(default)]
396    pub survival_time_basis: Option<String>,
397    #[serde(default)]
398    pub survival_time_degree: Option<usize>,
399    #[serde(default)]
400    pub survival_time_knots: Option<Vec<f64>>,
401    #[serde(default)]
402    pub survival_time_keep_cols: Option<Vec<usize>>,
403    #[serde(default)]
404    pub survival_time_smooth_lambda: Option<f64>,
405    #[serde(default)]
406    pub survival_time_anchor: Option<f64>,
407    #[serde(default)]
408    pub survivalridge_lambda: Option<f64>,
409    #[serde(default)]
410    pub survival_likelihood: Option<String>,
411    #[serde(default)]
412    pub survival_beta_time: Option<Vec<f64>>,
413    #[serde(default)]
414    pub survival_beta_threshold: Option<Vec<f64>>,
415    #[serde(default)]
416    pub survival_beta_log_sigma: Option<Vec<f64>>,
417    #[serde(default)]
418    pub survival_noise_projection: Option<Vec<Vec<f64>>>,
419    #[serde(default)]
420    pub survival_noise_center: Option<Vec<f64>>,
421    #[serde(default)]
422    pub survival_noise_scale: Option<Vec<f64>>,
423    #[serde(default)]
424    pub survival_noise_non_intercept_start: Option<usize>,
425    /// Survival analog of `noise_projection_ridge_alpha`: the Tikhonov ridge
426    /// used when fitting the survival log-sigma projection.
427    #[serde(default)]
428    pub survival_noise_projection_ridge_alpha: Option<f64>,
429    #[serde(default)]
430    pub survival_distribution: Option<ResidualDistribution>,
431    #[serde(default)]
432    pub training_headers: Option<Vec<String>>,
433    /// Container type of the table the model was fitted on, as detected by the
434    /// Python binding (`"pandas"`, `"polars"`, `"pyarrow"`, `"numpy"`, or an
435    /// ambiguous tag such as `"unknown"`). This is presentation provenance, not
436    /// math: `gamfit.Model.predict` uses it as the output-container fallback
437    /// when the *prediction input* is itself container-ambiguous (a `dict` of
438    /// columns or a `list` of record dicts). Persisting it in the model payload
439    /// makes the fallback survive `save`/`load` and `dumps`/`loads`, so a
440    /// reloaded model predicts into the same container as the in-memory one.
441    /// `None` for older payloads (and for fits that never recorded a kind), in
442    /// which case the fallback degrades to `"dict"`, matching pre-persistence
443    /// behaviour for unknown-kind training tables.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub training_table_kind: Option<String>,
446    /// Per-column (min, max) of the training input matrix, parallel to
447    /// `training_headers`. At predict time, inputs are axis-clipped to these
448    /// ranges so that out-of-distribution points evaluate at the nearest face
449    /// of the training bounding box rather than extrapolating polynomial
450    /// trends from polyharmonic / spline bases beyond the data envelope. Old
451    /// model JSONs that pre-date this field load with `None`, in which case
452    /// the predict path falls through unchanged (no clipping).
453    #[serde(default)]
454    pub training_feature_ranges: Option<Vec<(f64, f64)>>,
455    /// User-supplied per-group metadata, keyed by stable group identifier.
456    ///
457    /// This is intentionally schema-free JSON so provenance maps can carry
458    /// mixed scalar/list/object values. Missing in older payloads means no
459    /// group metadata was persisted.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub group_metadata: Option<GroupMetadata>,
462    /// Deployment-time no-refit group extensions applied after fitting.
463    ///
464    /// Each entry records the requested group coordinate, caller metadata, and
465    /// prior used to initialize the inserted coefficient. The active
466    /// prediction contract lives in `data_schema` + `resolved_termspec`; this
467    /// ledger preserves provenance without requiring a refit.
468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
469    pub deployment_extensions: Vec<SavedDeploymentExtension>,
470    /// Transformation-normal: B-spline knots for the response-direction basis.
471    #[serde(default)]
472    pub transformation_response_knots: Option<Vec<f64>>,
473    /// Transformation-normal: deviation nullspace transform matrix (row-major).
474    #[serde(default)]
475    pub transformation_response_transform: Option<Vec<Vec<f64>>>,
476    /// Transformation-normal: B-spline degree for the response basis.
477    #[serde(default)]
478    pub transformation_response_degree: Option<usize>,
479    /// Transformation-normal: median of the response used for anchoring.
480    #[serde(default)]
481    pub transformation_response_median: Option<f64>,
482    /// Transformation-normal saved score contract. The score is the exact
483    /// finite-support PIT:
484    /// z = Phi^{-1}((Phi(h) - Phi(h_L)) / (Phi(h_U) - Phi(h_L))).
485    #[serde(default)]
486    pub transformation_score_calibration: Option<TransformationScoreCalibration>,
487    #[serde(default)]
488    pub resolved_termspec: Option<TermCollectionSpec>,
489    #[serde(default)]
490    pub resolved_termspec_noise: Option<TermCollectionSpec>,
491    #[serde(default)]
492    pub resolved_termspec_logslope: Option<TermCollectionSpec>,
493    #[serde(default)]
494    pub resolved_termspec_logslopes: Option<Vec<TermCollectionSpec>>,
495    #[serde(default)]
496    pub adaptive_regularization_diagnostics: Option<AdaptiveRegularizationDiagnostics>,
497    /// Precomputed exact Gaussian-identity jackknife+ statistics (#942).
498    ///
499    /// Populated *only* for a standard Gaussian-identity model fit with unit
500    /// prior weights, where the closed-form Sherman–Morrison leave-one-out
501    /// substrate gives a distribution-free finite-sample (≥ level) prediction
502    /// interval with no held-out fold. When `Some`, `predict(interval=level)`
503    /// auto-routes through it (the MAGIC default); when `None` — any other
504    /// family/link, reweighted rows, or an older payload — predict falls back
505    /// to the model-based posterior band and labels the provenance honestly.
506    /// `#[serde(default)]` so pre-existing models deserialize as: no jackknife+
507    /// substrate available.
508    #[serde(default)]
509    pub gaussian_jackknife_plus:
510        Option<crate::inference::full_conformal::GaussianJackknifePlusStats>,
511    /// Precomputed substrate for the EXACT Gaussian-identity full-conformal set
512    /// (#942 Layer 1 + the frozen-ρ self-diagnostic).
513    ///
514    /// Populated under the SAME eligibility as `gaussian_jackknife_plus`
515    /// (Gaussian-identity, unit prior weights, offset-free, no link wiggle). It
516    /// persists the training design + response + frozen penalty `Sλ` so the
517    /// distribution-free EXACT prediction set (a union of intervals, valid for
518    /// any penalized smooth) can be replayed per test point — one Cholesky each,
519    /// zero refits — and surfaces the frozen-ρ certificate flag. `None` for any
520    /// ineligible model or an older payload, in which case the exact-set predict
521    /// path errors with a clear message and the caller uses jackknife+ or the
522    /// posterior band. `#[serde(default)]` so pre-existing models deserialize as
523    /// no exact substrate available.
524    #[serde(default)]
525    pub full_conformal: Option<crate::inference::full_conformal::ExactFullConformalSubstrate>,
526}
527
528#[derive(Clone, Debug, Serialize, Deserialize)]
529pub struct SavedDeploymentExtension {
530    pub name: String,
531    pub kind: String,
532    pub term: String,
533    pub level: JsonValue,
534    pub level_bits: u64,
535    pub coefficient_index: usize,
536    pub coefficient_mean: f64,
537    pub coefficient_variance: f64,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub metadata: Option<JsonValue>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub prior: Option<JsonValue>,
542}
543
544/// Append deployment-only extension columns to the fitted design coordinate system.
545///
546/// No-refit group extension adds a new coefficient block after the fitted
547/// coefficient vector:
548///
549///   beta_ext = [beta_old, beta_new],    beta_new = mu_new.
550///
551/// For a new random-effect level g, the appended basis is the indicator
552/// e_g(x_i) = 1{x_i == g}.  The old fitted basis X_old is not rebuilt or
553/// reordered, so rows that do not exercise g have
554///
555///   eta_ext = X_old beta_old + 0 * beta_new = eta_old.
556///
557/// Rows at the new level get the exact prior-mean shift e_g beta_new.  This
558/// helper enforces the coordinate identity by requiring extension coefficient
559/// indices to be the consecutive tail columns of the base design.
560pub fn append_deployment_extension_columns(
561    model: &FittedModelPayload,
562    data: ndarray::ArrayView2<'_, f64>,
563    col_map: &HashMap<String, usize>,
564    training_headers: Option<&Vec<String>>,
565    base_design: Array2<f64>,
566) -> Result<Array2<f64>, FittedModelError> {
567    if model.deployment_extensions.is_empty() {
568        return Ok(base_design);
569    }
570    if base_design.nrows() != data.nrows() {
571        return Err(FittedModelError::SchemaMismatch {
572            reason: format!(
573                "deployment extension design row mismatch: base design has {} rows but data has {}",
574                base_design.nrows(),
575                data.nrows()
576            ),
577        });
578    }
579    let spec = model
580        .resolved_termspec
581        .as_ref()
582        .ok_or_else(|| FittedModelError::MissingField {
583            reason: "deployment extension prediction requires saved resolved_termspec; refit"
584                .to_string(),
585        })?;
586    let n = base_design.nrows();
587    let p_old = base_design.ncols();
588    let mut extensions: Vec<&SavedDeploymentExtension> =
589        model.deployment_extensions.iter().collect();
590    extensions.sort_by_key(|extension| extension.coefficient_index);
591    for (tail_idx, extension) in extensions.iter().enumerate() {
592        let expected = p_old + tail_idx;
593        if extension.coefficient_index != expected {
594            return Err(FittedModelError::SchemaMismatch {
595                reason: format!(
596                    "deployment extension '{}' has coefficient index {}, expected append-only index {}",
597                    extension.name, extension.coefficient_index, expected
598                ),
599            });
600        }
601    }
602
603    let mut out = Array2::<f64>::zeros((n, p_old + extensions.len()));
604    out.slice_mut(ndarray::s![.., ..p_old]).assign(&base_design);
605    for (tail_idx, extension) in extensions.into_iter().enumerate() {
606        if extension.kind != "random-effect-level" {
607            return Err(FittedModelError::IncompatibleConfig {
608                reason: format!(
609                    "unsupported deployment extension kind '{}' for '{}'",
610                    extension.kind, extension.name
611                ),
612            });
613        }
614        let term = spec
615            .random_effect_terms
616            .iter()
617            .find(|term| term.name == extension.term)
618            .ok_or_else(|| FittedModelError::MissingField {
619                reason: format!(
620                    "deployment extension '{}' references unknown random-effect term '{}'",
621                    extension.name, extension.term
622                ),
623            })?;
624        let prediction_col = training_headers
625            .and_then(|headers| headers.get(term.feature_col))
626            .and_then(|name| col_map.get(name))
627            .copied()
628            .unwrap_or(term.feature_col);
629        if prediction_col >= data.ncols() {
630            return Err(FittedModelError::SchemaMismatch {
631                reason: format!(
632                    "deployment extension '{}' feature column {} out of bounds for {} prediction columns",
633                    extension.name,
634                    prediction_col,
635                    data.ncols()
636                ),
637            });
638        }
639        let col = p_old + tail_idx;
640        let level_bits = gam_data::canonical_level_bits(f64::from_bits(extension.level_bits));
641        for row in 0..n {
642            if gam_data::canonical_level_bits(data[[row, prediction_col]]) == level_bits {
643                out[[row, col]] = 1.0;
644            }
645        }
646    }
647    Ok(out)
648}
649
650#[derive(Clone, Debug, Serialize, Deserialize)]
651pub struct SavedLatentScoreContract {
652    pub semantics: String,
653    pub source_transform_id: Option<String>,
654    pub normalization_mean: f64,
655    pub normalization_sd: f64,
656    pub clip_eps: Option<f64>,
657    pub conditioning_columns: Vec<String>,
658}
659
660impl FittedModelPayload {
661    pub fn new(
662        version: u32,
663        formula: String,
664        model_kind: ModelKind,
665        family_state: FittedFamily,
666        family: String,
667    ) -> Self {
668        Self {
669            version,
670            formula,
671            model_kind,
672            family_state,
673            family,
674            inference_notes: Vec::new(),
675            used_device: false,
676            fit_result: None,
677            unified: None,
678            spline_scan: None,
679            residual_cascade: None,
680            data_schema: None,
681            link: None,
682            mixture_link_param_covariance: None,
683            sas_param_covariance: None,
684            formula_noise: None,
685            formula_logslope: None,
686            formula_logslopes: None,
687            offset_column: None,
688            noise_offset_column: None,
689            weight_column: None,
690            beta_noise: None,
691            noise_projection: None,
692            noise_center: None,
693            noise_scale: None,
694            noise_non_intercept_start: None,
695            noise_projection_ridge_alpha: None,
696            gaussian_response_scale: None,
697            linkwiggle_knots: None,
698            linkwiggle_degree: None,
699            beta_link_wiggle: None,
700            baseline_timewiggle_knots: None,
701            baseline_timewiggle_degree: None,
702            baseline_timewiggle_penalty_orders: None,
703            baseline_timewiggle_double_penalty: None,
704            beta_baseline_timewiggle: None,
705            beta_baseline_timewiggle_by_cause: None,
706            z_column: None,
707            z_columns: None,
708            latent_z_normalization: None,
709            latent_score_contract: None,
710            latent_measure: None,
711            latent_z_rank_int_calibration: None,
712            latent_z_conditional_calibration: None,
713            marginal_baseline: None,
714            logslope_baseline: None,
715            logslope_baselines: None,
716            score_warp_runtime: None,
717            link_deviation_runtime: None,
718            influence_absorber_width: None,
719            survival_entry: None,
720            survival_exit: None,
721            survival_event: None,
722            survivalspec: None,
723            survival_cause_count: None,
724            survival_endpoint_names: None,
725            survival_baseline_target: None,
726            survival_baseline_scale: None,
727            survival_baseline_shape: None,
728            survival_baseline_rate: None,
729            survival_baseline_makeham: None,
730            survival_time_basis: None,
731            survival_time_degree: None,
732            survival_time_knots: None,
733            survival_time_keep_cols: None,
734            survival_time_smooth_lambda: None,
735            survival_time_anchor: None,
736            survivalridge_lambda: None,
737            survival_likelihood: None,
738            survival_beta_time: None,
739            survival_beta_threshold: None,
740            survival_beta_log_sigma: None,
741            survival_noise_projection: None,
742            survival_noise_center: None,
743            survival_noise_scale: None,
744            survival_noise_non_intercept_start: None,
745            survival_noise_projection_ridge_alpha: None,
746            survival_distribution: None,
747            training_headers: None,
748            training_table_kind: None,
749            training_feature_ranges: None,
750            group_metadata: None,
751            deployment_extensions: Vec::new(),
752            transformation_response_knots: None,
753            transformation_response_transform: None,
754            transformation_response_degree: None,
755            transformation_response_median: None,
756            transformation_score_calibration: None,
757            resolved_termspec: None,
758            resolved_termspec_noise: None,
759            resolved_termspec_logslope: None,
760            resolved_termspec_logslopes: None,
761            adaptive_regularization_diagnostics: None,
762            gaussian_jackknife_plus: None,
763            full_conformal: None,
764        }
765    }
766
767    pub fn set_training_feature_metadata(
768        &mut self,
769        headers: Vec<String>,
770        feature_ranges: Vec<(f64, f64)>,
771    ) {
772        self.training_headers = Some(headers);
773        self.training_feature_ranges = Some(feature_ranges);
774    }
775
776    fn synchronize_empty_feature_contract(&mut self) {
777        if self.fit_result.is_none() {
778            return;
779        }
780        let Some(schema) = self.data_schema.as_ref() else {
781            return;
782        };
783        if !schema.columns.is_empty() {
784            return;
785        }
786        self.training_headers.get_or_insert_with(Vec::new);
787        self.resolved_termspec
788            .get_or_insert_with(|| TermCollectionSpec {
789                linear_terms: Vec::new(),
790                smooth_terms: Vec::new(),
791                random_effect_terms: Vec::new(),
792            });
793    }
794
795    /// Write the persistable time-basis snapshot for a survival model.
796    ///
797    /// This is the only path that should populate the `survival_time_*`
798    /// fields used by the loader. Routing every FFI builder through this
799    /// helper guarantees no builder can silently drop a field — the
800    /// marginal-slope save→load bug was a builder that
801    /// missed `survival_time_basis`.
802    pub fn apply_survival_time_basis(
803        &mut self,
804        snapshot: &crate::survival::construction::SavedSurvivalTimeBasis,
805    ) {
806        self.survival_time_basis = Some(snapshot.basisname.clone());
807        self.survival_time_degree = snapshot.degree;
808        self.survival_time_knots = snapshot.knots.clone();
809        self.survival_time_keep_cols = snapshot.keep_cols.clone();
810        self.survival_time_smooth_lambda = snapshot.smooth_lambda;
811        self.survival_time_anchor = Some(snapshot.anchor);
812    }
813
814    fn validate_payload_version(&self) -> Result<(), FittedModelError> {
815        if self.version != MODEL_PAYLOAD_VERSION {
816            return Err(FittedModelError::SchemaMismatch {
817                reason: format!(
818                    "saved model payload schema mismatch: file has version={}, \
819                 this binary expects MODEL_PAYLOAD_VERSION={}. \
820                 Refit with the current CLI, or rebuild the reader at the same \
821                 version the model was written with.",
822                    self.version, MODEL_PAYLOAD_VERSION
823                ),
824            });
825        }
826        Ok(())
827    }
828}
829
830#[derive(Clone, Serialize, Deserialize)]
831#[serde(tag = "model_type", rename_all = "kebab-case")]
832pub enum FittedModel {
833    Standard { payload: FittedModelPayload },
834    LocationScale { payload: FittedModelPayload },
835    MarginalSlope { payload: FittedModelPayload },
836    Survival { payload: FittedModelPayload },
837    TransformationNormal { payload: FittedModelPayload },
838}
839
840#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
841#[serde(rename_all = "kebab-case")]
842pub enum ModelKind {
843    Standard,
844    LocationScale,
845    MarginalSlope,
846    Survival,
847    TransformationNormal,
848}
849
850#[derive(Clone, Debug, Serialize, Deserialize)]
851#[serde(tag = "family_kind", rename_all = "kebab-case")]
852pub enum FittedFamily {
853    Standard {
854        likelihood: LikelihoodSpec,
855        #[serde(default)]
856        link: Option<StandardLink>,
857        #[serde(default)]
858        latent_cloglog_state: Option<LatentCLogLogState>,
859        #[serde(default)]
860        mixture_state: Option<MixtureLinkState>,
861        #[serde(default)]
862        sas_state: Option<SasLinkState>,
863    },
864    LocationScale {
865        likelihood: LikelihoodSpec,
866        #[serde(default)]
867        base_link: Option<InverseLink>,
868    },
869    MarginalSlope {
870        likelihood: LikelihoodSpec,
871        base_link: InverseLink,
872        frailty: FrailtySpec,
873    },
874    Survival {
875        likelihood: LikelihoodSpec,
876        #[serde(default)]
877        survival_likelihood: Option<String>,
878        #[serde(default)]
879        survival_distribution: Option<ResidualDistribution>,
880        frailty: FrailtySpec,
881    },
882    LatentSurvival {
883        frailty: FrailtySpec,
884    },
885    LatentBinary {
886        frailty: FrailtySpec,
887    },
888    TransformationNormal {
889        likelihood: LikelihoodSpec,
890    },
891}
892
893#[derive(Clone, Copy, Debug, Eq, PartialEq)]
894pub enum PredictModelClass {
895    Standard,
896    GaussianLocationScale,
897    BinomialLocationScale,
898    /// Genuine-dispersion location-scale (#913): NegativeBinomial / Gamma / Beta
899    /// / Tweedie mean families fitted with a `noise_formula` overdispersion
900    /// channel. Predicted through the GLM mean inverse link (not the binomial
901    /// threshold-scale predictor).
902    DispersionLocationScale,
903    BernoulliMarginalSlope,
904    Survival,
905    TransformationNormal,
906}
907
908impl PredictModelClass {
909    #[inline]
910    pub const fn name(self) -> &'static str {
911        match self {
912            Self::Standard => "standard",
913            Self::GaussianLocationScale => "gaussian location-scale",
914            Self::BinomialLocationScale => "binomial location-scale",
915            Self::DispersionLocationScale => "dispersion location-scale",
916            Self::BernoulliMarginalSlope => "bernoulli marginal-slope",
917            Self::Survival => "survival",
918            Self::TransformationNormal => "transformation-normal",
919        }
920    }
921}
922
923#[derive(Clone, Debug)]
924pub struct SavedLinkWiggleRuntime {
925    pub knots: Vec<f64>,
926    pub degree: usize,
927    pub beta: Vec<f64>,
928}
929
930#[derive(Clone, Debug)]
931pub struct SavedBaselineTimeWiggleRuntime {
932    pub knots: Vec<f64>,
933    pub degree: usize,
934    pub penalty_orders: Vec<usize>,
935    pub double_penalty: bool,
936    pub beta: Vec<f64>,
937}
938
939// Re-export so saved-model consumers can refer to the anchor-block tag
940// without reaching across module boundaries.
941pub use crate::bms::deviation_runtime::ParametricAnchorBlock;
942
943#[derive(Clone, Debug, Serialize, Deserialize)]
944pub struct SavedCompiledFlexBlock {
945    pub kernel: String,
946    pub breakpoints: Vec<f64>,
947    pub basis_dim: usize,
948    pub span_c0: Vec<Vec<f64>>,
949    pub span_c1: Vec<Vec<f64>>,
950    pub span_c2: Vec<Vec<f64>>,
951    pub span_c3: Vec<Vec<f64>>,
952    /// Cross-block anchor-residual coefficient matrix `M` of shape
953    /// `d × basis_dim`. When present, predict-time evaluation subtracts
954    /// `n_row · M` from each cubic-span row (where `n_row` stacks the
955    /// per-row parametric anchor values in the order given by
956    /// `anchor_components`).
957    #[serde(default)]
958    pub anchor_correction: Option<Vec<Vec<f64>>>,
959    /// Ordered list of parametric anchor components whose stacked row
960    /// values combine into `n_row`. Empty unless
961    /// `anchor_correction` is `Some`.
962    #[serde(default)]
963    pub anchor_components: Vec<SavedAnchorComponent>,
964}
965
966#[derive(Clone, Debug, Serialize, Deserialize)]
967pub struct SavedAnchorComponent {
968    pub kind: SavedAnchorKind,
969}
970
971#[derive(Clone, Debug, Serialize, Deserialize)]
972pub enum SavedAnchorKind {
973    Parametric {
974        block: ParametricAnchorBlock,
975        ncols: usize,
976    },
977    /// Flex-evaluation anchor (sibling flex block's reparameterised basis,
978    /// evaluated at training rows at fit time and at predict rows at
979    /// predict time). The predictor stacks `ncols` columns from the
980    /// sibling runtime's `design(arg)` into `n_row`.
981    FlexEvaluation { ncols: usize },
982}
983
984#[derive(Clone, Debug)]
985pub struct SavedPredictionRuntime {
986    pub model_class: PredictModelClass,
987    pub likelihood: LikelihoodSpec,
988    pub inverse_link: Option<InverseLink>,
989    pub link_wiggle: Option<SavedLinkWiggleRuntime>,
990    pub baseline_time_wiggle: Option<SavedBaselineTimeWiggleRuntime>,
991    pub score_warp: Option<SavedCompiledFlexBlock>,
992    pub link_deviation: Option<SavedCompiledFlexBlock>,
993    /// Rank-INT latent-z calibration carried into the predictor build.
994    /// `None` for non-BMS models and for BMS fits whose latent measure
995    /// did not require rank-INT calibration.
996    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
997    /// Conditional location-scale latent-z calibration (#905) carried into the
998    /// predictor build. `None` for non-BMS models and for BMS fits whose Auto
999    /// path did not detect a conditional `E[z|C]`/`Var(z|C)` shift. When
1000    /// `Some`, the predictor replaces the normalized `z` by `ζ = (z−m(C))/√v(C)`
1001    /// using the marginal prediction design as the conditioning span.
1002    pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
1003    /// Width `p₁` of the absorbed Stage-1 influence block (#461) when the
1004    /// survival marginal-slope fit hosted a dedicated additive absorber (the
1005    /// trailing block). `None` when no CTN Stage-1 chain produced an influence
1006    /// Jacobian. At predict the absorber's `γ` is DROPPED (the orthogonalized
1007    /// β̂ is a training-fit property), so the predictor uses this width only to
1008    /// (a) account for the extra trailing block in the saved block count and
1009    /// (b) slice `γ`'s rows/cols out of the joint covariance. Survival hosts the
1010    /// absorber as its own block (unlike the BMS A2 widened-marginal design),
1011    /// so it never widens any persisted prediction design.
1012    pub influence_absorber_width: Option<usize>,
1013}
1014
1015pub fn gaussian_location_scale_mean_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1016    fit.block_by_role(BlockRole::Location)
1017        .or_else(|| fit.block_by_role(BlockRole::Mean))
1018        .map(|block| block.beta.clone())
1019}
1020
1021pub fn binomial_location_scale_threshold_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1022    fit.block_by_role(BlockRole::Threshold)
1023        .or_else(|| fit.block_by_role(BlockRole::Location))
1024        .or_else(|| fit.block_by_role(BlockRole::Mean))
1025        .map(|block| block.beta.clone())
1026}
1027
1028pub fn location_scale_noise_beta(fit: &UnifiedFitResult) -> Option<Array1<f64>> {
1029    fit.block_by_role(BlockRole::Scale)
1030        .map(|block| block.beta.clone())
1031}
1032
1033/// Whether a `ModelKind::LocationScale` likelihood's response is one of the
1034/// genuine-dispersion mean families (#913) — NegativeBinomial, Gamma, Beta or
1035/// Tweedie. These carry a `noise_formula` overdispersion channel and must be
1036/// predicted through the GLM mean inverse link (the
1037/// [`PredictModelClass::DispersionLocationScale`] path), NOT the binomial
1038/// threshold-scale predictor. The binomial location-scale (BMS ordinal) path is
1039/// the only other non-Gaussian location-scale family, with a `Binomial`
1040/// response.
1041fn is_dispersion_location_scale_response(response: &gam_problem::types::ResponseFamily) -> bool {
1042    use gam_problem::types::ResponseFamily;
1043    matches!(
1044        response,
1045        ResponseFamily::NegativeBinomial { .. }
1046            | ResponseFamily::Gamma
1047            | ResponseFamily::Beta { .. }
1048            | ResponseFamily::Tweedie { .. }
1049    )
1050}
1051
1052fn validate_location_scale_saved_fit(
1053    fit: &UnifiedFitResult,
1054    model_class: PredictModelClass,
1055    link_wiggle: Option<&SavedLinkWiggleRuntime>,
1056) -> Result<(), FittedModelError> {
1057    let primary = match model_class {
1058        // Gaussian and dispersion (#913) location-scale both predict the mean
1059        // through the Location block; the binomial threshold-scale class reads
1060        // the Threshold block instead.
1061        PredictModelClass::GaussianLocationScale | PredictModelClass::DispersionLocationScale => {
1062            gaussian_location_scale_mean_beta(fit)
1063        }
1064        PredictModelClass::BinomialLocationScale => binomial_location_scale_threshold_beta(fit),
1065        _ => None,
1066    }
1067    .ok_or_else(|| FittedModelError::MissingField {
1068        reason: match model_class {
1069            PredictModelClass::GaussianLocationScale => {
1070                "gaussian-location-scale saved fit is missing mean/location block".to_string()
1071            }
1072            PredictModelClass::DispersionLocationScale => {
1073                "dispersion-location-scale saved fit is missing mean/location block".to_string()
1074            }
1075            PredictModelClass::BinomialLocationScale => {
1076                "binomial-location-scale saved fit is missing threshold/location block".to_string()
1077            }
1078            _ => "location-scale saved fit is missing primary block".to_string(),
1079        },
1080    })?;
1081
1082    let scale = location_scale_noise_beta(fit).ok_or_else(|| FittedModelError::MissingField {
1083        reason: "location-scale saved fit is missing scale block".to_string(),
1084    })?;
1085    let expected =
1086        primary.len() + scale.len() + link_wiggle.map_or(0, |runtime| runtime.beta.len());
1087
1088    if let Some(cov) = fit.beta_covariance()
1089        && (cov.nrows() != expected || cov.ncols() != expected)
1090    {
1091        return Err(FittedModelError::SchemaMismatch {
1092            reason: format!(
1093                "location-scale saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1094                cov.nrows(),
1095                cov.ncols(),
1096                expected,
1097                expected
1098            ),
1099        });
1100    }
1101    if let Some(cov) = fit.beta_covariance_corrected()
1102        && (cov.nrows() != expected || cov.ncols() != expected)
1103    {
1104        return Err(FittedModelError::SchemaMismatch {
1105            reason: format!(
1106                "location-scale saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1107                cov.nrows(),
1108                cov.ncols(),
1109                expected,
1110                expected
1111            ),
1112        });
1113    }
1114    Ok(())
1115}
1116
1117fn validate_survival_saved_block_matches_payload(
1118    fit: &UnifiedFitResult,
1119    role: BlockRole,
1120    payload_beta: Option<&Vec<f64>>,
1121    label: &str,
1122) -> Result<usize, FittedModelError> {
1123    let block = fit
1124        .block_by_role(role)
1125        .ok_or_else(|| FittedModelError::MissingField {
1126            reason: format!("location-scale survival saved fit is missing {label} block"),
1127        })?;
1128    if let Some(saved) = payload_beta
1129        && block.beta.to_vec() != *saved
1130    {
1131        return Err(FittedModelError::SchemaMismatch {
1132            reason: format!(
1133                "location-scale survival saved {label} coefficients disagree with fit_result"
1134            ),
1135        });
1136    }
1137    Ok(block.beta.len())
1138}
1139
1140fn validate_survival_location_scale_saved_fit(
1141    payload: &FittedModelPayload,
1142    link_wiggle: Option<&SavedLinkWiggleRuntime>,
1143) -> Result<(), FittedModelError> {
1144    let fit = payload
1145        .fit_result
1146        .as_ref()
1147        .ok_or_else(|| FittedModelError::MissingField {
1148            reason: "location-scale survival model is missing canonical fit_result payload"
1149                .to_string(),
1150        })?;
1151    let p_time = validate_survival_saved_block_matches_payload(
1152        fit,
1153        BlockRole::Time,
1154        payload.survival_beta_time.as_ref(),
1155        "time",
1156    )?;
1157    let p_threshold = validate_survival_saved_block_matches_payload(
1158        fit,
1159        BlockRole::Threshold,
1160        payload.survival_beta_threshold.as_ref(),
1161        "threshold",
1162    )?;
1163    let p_log_sigma = validate_survival_saved_block_matches_payload(
1164        fit,
1165        BlockRole::Scale,
1166        payload.survival_beta_log_sigma.as_ref(),
1167        "log-sigma",
1168    )?;
1169    let p_wiggle = match link_wiggle {
1170        Some(runtime) => {
1171            let block = fit.block_by_role(BlockRole::LinkWiggle).ok_or_else(|| {
1172                FittedModelError::MissingField {
1173                    reason: "location-scale survival saved fit is missing link-wiggle block"
1174                        .to_string(),
1175                }
1176            })?;
1177            if block.beta.to_vec() != runtime.beta {
1178                return Err(FittedModelError::SchemaMismatch {
1179                    reason:
1180                        "location-scale survival saved link-wiggle coefficients disagree with fit_result"
1181                            .to_string(),
1182                });
1183            }
1184            runtime.beta.len()
1185        }
1186        None => {
1187            if fit.block_by_role(BlockRole::LinkWiggle).is_some() {
1188                return Err(FittedModelError::SchemaMismatch {
1189                    reason:
1190                        "location-scale survival saved fit has a LinkWiggle block without payload metadata"
1191                            .to_string(),
1192                });
1193            }
1194            0
1195        }
1196    };
1197    let expected = p_time + p_threshold + p_log_sigma + p_wiggle;
1198
1199    if let Some(cov) = fit.beta_covariance()
1200        && (cov.nrows() != expected || cov.ncols() != expected)
1201    {
1202        return Err(FittedModelError::SchemaMismatch {
1203            reason: format!(
1204                "location-scale survival saved conditional covariance shape mismatch: got {}x{}, expected {}x{}",
1205                cov.nrows(),
1206                cov.ncols(),
1207                expected,
1208                expected
1209            ),
1210        });
1211    }
1212    if let Some(cov) = fit.beta_covariance_corrected()
1213        && (cov.nrows() != expected || cov.ncols() != expected)
1214    {
1215        return Err(FittedModelError::SchemaMismatch {
1216            reason: format!(
1217                "location-scale survival saved corrected covariance shape mismatch: got {}x{}, expected {}x{}",
1218                cov.nrows(),
1219                cov.ncols(),
1220                expected,
1221                expected
1222            ),
1223        });
1224    }
1225    Ok(())
1226}
1227
1228fn validate_marginal_slope_saved_fit(
1229    fit: &UnifiedFitResult,
1230    score_warp: Option<&SavedCompiledFlexBlock>,
1231    link_deviation: Option<&SavedCompiledFlexBlock>,
1232    fit_label: &str,
1233) -> Result<(), FittedModelError> {
1234    validate_marginal_slope_saved_fit_impl(
1235        fit,
1236        score_warp,
1237        link_deviation,
1238        fit_label,
1239        "bernoulli",
1240        2,
1241        "marginal, logslope",
1242    )
1243}
1244
1245fn validate_survival_marginal_slope_saved_fit(
1246    fit: &UnifiedFitResult,
1247    score_warp: Option<&SavedCompiledFlexBlock>,
1248    link_deviation: Option<&SavedCompiledFlexBlock>,
1249    fit_label: &str,
1250) -> Result<(), FittedModelError> {
1251    validate_marginal_slope_saved_fit_impl(
1252        fit,
1253        score_warp,
1254        link_deviation,
1255        fit_label,
1256        "survival",
1257        3,
1258        "time, marginal, slope",
1259    )
1260}
1261
1262/// Shared block-count + coefficient-dimension validation for the bernoulli
1263/// and survival marginal-slope saved-fit gates. The only family-specific
1264/// inputs are the family kind string ("bernoulli" / "survival"), the base
1265/// block count (2 for bernoulli, 3 for survival — the survival path has an
1266/// extra time block), and the base-block role list rendered in the error
1267/// message ("marginal, logslope" / "time, marginal, slope"). The score-warp
1268/// / link-deviation tail follows the same shape in both families.
1269fn validate_marginal_slope_saved_fit_impl(
1270    fit: &UnifiedFitResult,
1271    score_warp: Option<&SavedCompiledFlexBlock>,
1272    link_deviation: Option<&SavedCompiledFlexBlock>,
1273    fit_label: &str,
1274    family_kind: &str,
1275    base_block_count: usize,
1276    base_block_role_list: &str,
1277) -> Result<(), FittedModelError> {
1278    let expected_blocks = base_block_count
1279        + usize::from(score_warp.is_some())
1280        + usize::from(link_deviation.is_some());
1281    if fit.blocks.len() != expected_blocks {
1282        let score_warp_suffix = if score_warp.is_some() {
1283            ", score-warp"
1284        } else {
1285            ""
1286        };
1287        let link_deviation_suffix = if link_deviation.is_some() {
1288            ", link-deviation"
1289        } else {
1290            ""
1291        };
1292        return Err(FittedModelError::SchemaMismatch {
1293            reason: format!(
1294                "{family_kind} marginal-slope saved {fit_label} requires {expected_blocks} blocks [{base_block_role_list}{score_warp_suffix}{link_deviation_suffix}], got {}",
1295                fit.blocks.len(),
1296            ),
1297        });
1298    }
1299    if let Some(runtime) = score_warp {
1300        let beta = &fit.blocks[base_block_count].beta;
1301        if beta.len() != runtime.basis_dim {
1302            return Err(FittedModelError::SchemaMismatch {
1303                reason: format!(
1304                    "{family_kind} marginal-slope saved {fit_label} score-warp coefficient mismatch: beta has {} entries but runtime expects {}",
1305                    beta.len(),
1306                    runtime.basis_dim
1307                ),
1308            });
1309        }
1310    }
1311    if let Some(runtime) = link_deviation {
1312        let idx = base_block_count + usize::from(score_warp.is_some());
1313        let beta = &fit.blocks[idx].beta;
1314        if beta.len() != runtime.basis_dim {
1315            return Err(FittedModelError::SchemaMismatch {
1316                reason: format!(
1317                    "{family_kind} marginal-slope saved {fit_label} link-deviation coefficient mismatch: beta has {} entries but runtime expects {}",
1318                    beta.len(),
1319                    runtime.basis_dim
1320                ),
1321            });
1322        }
1323    }
1324    Ok(())
1325}
1326
1327impl SavedLinkWiggleRuntime {
1328    fn validate_monotone_derivative(
1329        &self,
1330        q0: &Array1<f64>,
1331    ) -> Result<Array1<f64>, FittedModelError> {
1332        // Monotonicity is verified pointwise at the actual evaluation grid `q0`
1333        // (the predict η). The fit guarantees a strictly-increasing warped link
1334        // across the training η range (#1596); checking at `q0` here flags an
1335        // extrapolation point where the learnable link genuinely turns
1336        // non-invertible, without rejecting the whole model for a sign dip in the
1337        // basis tail far outside any data or evaluation point.
1338        let d_constrained = self.constrained_basis(q0, BasisOptions::first_derivative())?;
1339        let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1340        let dq_dq0 = d_constrained.dot(&beta_link_wiggle) + 1.0;
1341        if let Some((idx, value)) = dq_dq0.iter().copied().enumerate().find(|(_, v)| *v <= 0.0) {
1342            return Err(FittedModelError::PayloadCorrupt {
1343                reason: format!(
1344                    "saved link-wiggle is not monotone at row {idx}: dq/dq0={value:.3e} <= 0"
1345                ),
1346            });
1347        }
1348        Ok(dq_dq0)
1349    }
1350
1351    pub fn constrained_basis(
1352        &self,
1353        q0: &Array1<f64>,
1354        basis_options: BasisOptions,
1355    ) -> Result<Array2<f64>, FittedModelError> {
1356        let knot_arr = Array1::from_vec(self.knots.clone());
1357        let constrained = monotone_wiggle_basis_with_derivative_order(
1358            q0.view(),
1359            &knot_arr,
1360            self.degree,
1361            basis_options.derivative_order,
1362        )
1363        .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
1364        if constrained.ncols() != self.beta.len() {
1365            return Err(FittedModelError::SchemaMismatch {
1366                reason: format!(
1367                    "saved link-wiggle dimension mismatch: coefficients have {} entries but basis has {} columns",
1368                    self.beta.len(),
1369                    constrained.ncols()
1370                ),
1371            });
1372        }
1373        Ok(constrained)
1374    }
1375
1376    pub fn design(&self, q0: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
1377        self.validate_monotone_derivative(q0)?;
1378        self.constrained_basis(q0, BasisOptions::value())
1379    }
1380
1381    pub fn basis_row_scalar(&self, q0: f64) -> Result<Array1<f64>, FittedModelError> {
1382        let q = Array1::from_vec(vec![q0]);
1383        let x = self.design(&q)?;
1384        if x.nrows() != 1 {
1385            return Err(FittedModelError::SchemaMismatch {
1386                reason: format!(
1387                    "saved link-wiggle scalar evaluation expected 1 row, got {}",
1388                    x.nrows()
1389                ),
1390            });
1391        }
1392        Ok(x.row(0).to_owned())
1393    }
1394
1395    pub fn apply(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1396        self.validate_monotone_derivative(q0)?;
1397        let xwiggle = self.constrained_basis(q0, BasisOptions::value())?;
1398        let beta_link_wiggle = Array1::from_vec(self.beta.clone());
1399        Ok(q0 + &xwiggle.dot(&beta_link_wiggle))
1400    }
1401
1402    pub fn derivative_q0(&self, q0: &Array1<f64>) -> Result<Array1<f64>, FittedModelError> {
1403        self.validate_monotone_derivative(q0)
1404    }
1405}
1406
1407impl SavedBaselineTimeWiggleRuntime {
1408    pub fn validate_global_monotonicity(&self) -> Result<(), FittedModelError> {
1409        validate_monotone_wiggle_beta_nonnegative(&self.beta, "saved baseline-timewiggle")
1410            .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
1411    }
1412}
1413
1414impl SavedCompiledFlexBlock {
1415    pub(crate) fn validate_exact_replay_contract(&self) -> Result<(), FittedModelError> {
1416        if self.kernel.is_empty() {
1417            return Err(FittedModelError::SchemaMismatch {
1418                reason: "saved anchored deviation runtime is missing the exact kernel marker"
1419                    .to_string(),
1420            });
1421        }
1422        if self.kernel != crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL {
1423            return Err(FittedModelError::IncompatibleConfig {
1424                reason: format!(
1425                    "saved anchored deviation runtime uses unsupported kernel '{}'; expected {}",
1426                    self.kernel,
1427                    crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL
1428                ),
1429            });
1430        }
1431        if self.basis_dim == 0 {
1432            return Err(FittedModelError::SchemaMismatch {
1433                reason: format!(
1434                    "saved anchored deviation runtime basis_dim must be positive, got {}",
1435                    self.basis_dim
1436                ),
1437            });
1438        }
1439        if self.breakpoints.len() < 2 {
1440            return Err(FittedModelError::SchemaMismatch {
1441                reason: format!(
1442                    "saved anchored deviation runtime requires at least two breakpoints, got {}",
1443                    self.breakpoints.len()
1444                ),
1445            });
1446        }
1447        for window in self.breakpoints.windows(2) {
1448            let left = window[0];
1449            let right = window[1];
1450            if !left.is_finite() || !right.is_finite() || right <= left {
1451                return Err(FittedModelError::PayloadCorrupt {
1452                    reason: format!(
1453                        "saved anchored deviation runtime breakpoints must be finite and strictly increasing, got [{left}, {right}]"
1454                    ),
1455                });
1456            }
1457        }
1458        let span_count = self.breakpoints.len() - 1;
1459        self.validate_coefficient_matrix(&self.span_c0, "c0", span_count)?;
1460        self.validate_coefficient_matrix(&self.span_c1, "c1", span_count)?;
1461        self.validate_coefficient_matrix(&self.span_c2, "c2", span_count)?;
1462        self.validate_coefficient_matrix(&self.span_c3, "c3", span_count)?;
1463        self.validate_c2_span_continuity()?;
1464        self.validate_anchor_residual_shape()?;
1465        Ok(())
1466    }
1467
1468    fn validate_anchor_residual_shape(&self) -> Result<(), FittedModelError> {
1469        let coeffs = match self.anchor_correction.as_ref() {
1470            Some(c) => c,
1471            None => {
1472                if !self.anchor_components.is_empty() {
1473                    return Err(FittedModelError::SchemaMismatch {
1474                        reason:
1475                            "saved anchored deviation runtime has anchor_components but no anchor_correction"
1476                                .to_string(),
1477                    });
1478                }
1479                return Ok(());
1480            }
1481        };
1482        let d: usize = self
1483            .anchor_components
1484            .iter()
1485            .map(|c| match &c.kind {
1486                SavedAnchorKind::Parametric { ncols, .. } => *ncols,
1487                SavedAnchorKind::FlexEvaluation { ncols } => *ncols,
1488            })
1489            .sum();
1490        if coeffs.len() != d {
1491            return Err(FittedModelError::SchemaMismatch {
1492                reason: format!(
1493                    "saved anchored deviation runtime anchor_correction has {} rows; expected {} (sum of component ncols)",
1494                    coeffs.len(),
1495                    d,
1496                ),
1497            });
1498        }
1499        for (i, row) in coeffs.iter().enumerate() {
1500            if row.len() != self.basis_dim {
1501                return Err(FittedModelError::SchemaMismatch {
1502                    reason: format!(
1503                        "saved anchored deviation runtime anchor_correction row {} has width {}, expected basis_dim {}",
1504                        i,
1505                        row.len(),
1506                        self.basis_dim,
1507                    ),
1508                });
1509            }
1510            for (j, &v) in row.iter().enumerate() {
1511                if !v.is_finite() {
1512                    return Err(FittedModelError::PayloadCorrupt {
1513                        reason: format!(
1514                            "saved anchored deviation runtime anchor_correction ({i},{j}) is non-finite"
1515                        ),
1516                    });
1517                }
1518            }
1519        }
1520        Ok(())
1521    }
1522
1523    fn validate_c2_span_continuity(&self) -> Result<(), FittedModelError> {
1524        const TOL: f64 = 1e-8;
1525        for span_idx in 1..self.breakpoints.len() - 1 {
1526            let left_span = span_idx - 1;
1527            let right_span = span_idx;
1528            let width = self.breakpoints[span_idx] - self.breakpoints[left_span];
1529            for basis_idx in 0..self.basis_dim {
1530                let left_value = self.span_c0[left_span][basis_idx]
1531                    + self.span_c1[left_span][basis_idx] * width
1532                    + self.span_c2[left_span][basis_idx] * width * width
1533                    + self.span_c3[left_span][basis_idx] * width * width * width;
1534                let left_d1 = self.span_c1[left_span][basis_idx]
1535                    + 2.0 * self.span_c2[left_span][basis_idx] * width
1536                    + 3.0 * self.span_c3[left_span][basis_idx] * width * width;
1537                let left_d2 = 2.0 * self.span_c2[left_span][basis_idx]
1538                    + 6.0 * self.span_c3[left_span][basis_idx] * width;
1539                let right_value = self.span_c0[right_span][basis_idx];
1540                let right_d1 = self.span_c1[right_span][basis_idx];
1541                let right_d2 = 2.0 * self.span_c2[right_span][basis_idx];
1542                if (left_value - right_value).abs() > TOL
1543                    || (left_d1 - right_d1).abs() > TOL
1544                    || (left_d2 - right_d2).abs() > TOL
1545                {
1546                    return Err(FittedModelError::SchemaMismatch {
1547                        reason: format!(
1548                            "saved anchored deviation runtime must be C2 cubic at breakpoint {span_idx}, basis {basis_idx}: value jump={:.3e}, d1 jump={:.3e}, d2 jump={:.3e}",
1549                            left_value - right_value,
1550                            left_d1 - right_d1,
1551                            left_d2 - right_d2
1552                        ),
1553                    });
1554                }
1555            }
1556        }
1557        Ok(())
1558    }
1559
1560    fn validate_coefficient_matrix(
1561        &self,
1562        matrix: &[Vec<f64>],
1563        label: &str,
1564        expected_rows: usize,
1565    ) -> Result<(), FittedModelError> {
1566        if matrix.len() != expected_rows {
1567            return Err(FittedModelError::SchemaMismatch {
1568                reason: format!(
1569                    "saved anchored deviation runtime {label} row count mismatch: got {}, expected {}",
1570                    matrix.len(),
1571                    expected_rows
1572                ),
1573            });
1574        }
1575        for (row_idx, row) in matrix.iter().enumerate() {
1576            if row.len() != self.basis_dim {
1577                return Err(FittedModelError::SchemaMismatch {
1578                    reason: format!(
1579                        "saved anchored deviation runtime {label} row {} has width {}, expected {}",
1580                        row_idx,
1581                        row.len(),
1582                        self.basis_dim
1583                    ),
1584                });
1585            }
1586            for (j, &value) in row.iter().enumerate() {
1587                if !value.is_finite() {
1588                    return Err(FittedModelError::PayloadCorrupt {
1589                        reason: format!(
1590                            "saved anchored deviation runtime {label} entry ({row_idx},{j}) is non-finite"
1591                        ),
1592                    });
1593                }
1594            }
1595        }
1596        Ok(())
1597    }
1598
1599    fn right_boundary_basis_value(&self, basis_idx: usize) -> f64 {
1600        let last_span = self.breakpoints.len() - 2;
1601        let width = self.breakpoints[last_span + 1] - self.breakpoints[last_span];
1602        self.span_c0[last_span][basis_idx]
1603            + self.span_c1[last_span][basis_idx] * width
1604            + self.span_c2[last_span][basis_idx] * width * width
1605            + self.span_c3[last_span][basis_idx] * width * width * width
1606    }
1607
1608    fn evaluate_span_polynomial_design(
1609        &self,
1610        values: &Array1<f64>,
1611        derivative_order: usize,
1612    ) -> Result<Array2<f64>, FittedModelError> {
1613        self.validate_exact_replay_contract()?;
1614        let (left_ep, right_ep) = self.support_interval()?;
1615        let mut out = Array2::<f64>::zeros((values.len(), self.basis_dim));
1616        for (row_idx, &value) in values.iter().enumerate() {
1617            if !value.is_finite() {
1618                return Err(FittedModelError::PayloadCorrupt {
1619                    reason: format!(
1620                        "saved anchored deviation runtime design value at row {row_idx} is non-finite ({value})"
1621                    ),
1622                });
1623            }
1624            if value < left_ep {
1625                if derivative_order == 0 {
1626                    for basis_idx in 0..self.basis_dim {
1627                        out[[row_idx, basis_idx]] = self.span_c0[0][basis_idx];
1628                    }
1629                }
1630                continue;
1631            }
1632            if value > right_ep {
1633                if derivative_order == 0 {
1634                    for basis_idx in 0..self.basis_dim {
1635                        out[[row_idx, basis_idx]] = self.right_boundary_basis_value(basis_idx);
1636                    }
1637                }
1638                continue;
1639            }
1640            let span_idx = self.left_biased_span_index_for(value)?;
1641            let t = value - self.breakpoints[span_idx];
1642            for basis_idx in 0..self.basis_dim {
1643                let c0 = self.span_c0[span_idx][basis_idx];
1644                let c1 = self.span_c1[span_idx][basis_idx];
1645                let c2 = self.span_c2[span_idx][basis_idx];
1646                let c3 = self.span_c3[span_idx][basis_idx];
1647                out[[row_idx, basis_idx]] = match derivative_order {
1648                    0 => c0 + c1 * t + c2 * t * t + c3 * t * t * t,
1649                    1 => c1 + 2.0 * c2 * t + 3.0 * c3 * t * t,
1650                    2 => 2.0 * c2 + 6.0 * c3 * t,
1651                    3 => 6.0 * c3,
1652                    4 => 0.0,
1653                    other => {
1654                        return Err(FittedModelError::IncompatibleConfig {
1655                            reason: format!(
1656                                "saved anchored deviation runtime only supports derivative orders up to 4, got {other}"
1657                            ),
1658                        });
1659                    }
1660                };
1661            }
1662        }
1663        Ok(out)
1664    }
1665
1666    pub fn breakpoints(&self) -> Result<Vec<f64>, FittedModelError> {
1667        self.validate_exact_replay_contract()?;
1668        Ok(self.breakpoints.clone())
1669    }
1670
1671    pub fn span_count(&self) -> Result<usize, FittedModelError> {
1672        Ok(self.breakpoints()?.windows(2).count())
1673    }
1674
1675    pub fn span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
1676        let points = self.breakpoints()?;
1677        span_index_for_breakpoints(&points, value, "saved anchored deviation span lookup")
1678            .map_err(|reason| FittedModelError::PayloadCorrupt { reason })
1679    }
1680
1681    fn left_biased_span_index_for(&self, value: f64) -> Result<usize, FittedModelError> {
1682        let mut span_idx = span_index_for_breakpoints(
1683            &self.breakpoints,
1684            value,
1685            "saved anchored deviation span lookup",
1686        )
1687        .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
1688        // LEFT-bias at interior breakpoints mirrors DeviationRuntime. The
1689        // saved cubic basis is C2, but d3 remains span-local.
1690        if span_idx > 0 && value == self.breakpoints[span_idx] {
1691            span_idx -= 1;
1692        }
1693        Ok(span_idx)
1694    }
1695
1696    pub fn local_cubic_on_span(
1697        &self,
1698        beta: &Array1<f64>,
1699        span_idx: usize,
1700    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1701        self.validate_exact_replay_contract()?;
1702        if beta.len() != self.basis_dim {
1703            return Err(FittedModelError::SchemaMismatch {
1704                reason: format!(
1705                    "saved anchored deviation coefficient length mismatch: got {}, expected {}",
1706                    beta.len(),
1707                    self.basis_dim
1708                ),
1709            });
1710        }
1711        self.local_cubic_on_span_validated(beta, span_idx)
1712    }
1713
1714    fn local_cubic_on_span_validated(
1715        &self,
1716        beta: &Array1<f64>,
1717        span_idx: usize,
1718    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1719        let points = &self.breakpoints;
1720        if span_idx + 1 >= points.len() {
1721            return Err(FittedModelError::SchemaMismatch {
1722                reason: format!(
1723                    "saved anchored deviation span index {} out of range for {} spans",
1724                    span_idx,
1725                    points.len() - 1
1726                ),
1727            });
1728        }
1729        let left = points[span_idx];
1730        let right = points[span_idx + 1];
1731        Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1732            left,
1733            right,
1734            c0: self.span_c0[span_idx]
1735                .iter()
1736                .zip(beta.iter())
1737                .map(|(coeff, weight)| coeff * weight)
1738                .sum(),
1739            c1: self.span_c1[span_idx]
1740                .iter()
1741                .zip(beta.iter())
1742                .map(|(coeff, weight)| coeff * weight)
1743                .sum(),
1744            c2: self.span_c2[span_idx]
1745                .iter()
1746                .zip(beta.iter())
1747                .map(|(coeff, weight)| coeff * weight)
1748                .sum(),
1749            c3: self.span_c3[span_idx]
1750                .iter()
1751                .zip(beta.iter())
1752                .map(|(coeff, weight)| coeff * weight)
1753                .sum(),
1754        })
1755    }
1756
1757    pub fn basis_span_cubic(
1758        &self,
1759        span_idx: usize,
1760        basis_idx: usize,
1761    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1762        self.validate_exact_replay_contract()?;
1763        if basis_idx >= self.basis_dim {
1764            return Err(FittedModelError::SchemaMismatch {
1765                reason: format!(
1766                    "saved anchored deviation basis index {} out of range for {} coefficients",
1767                    basis_idx, self.basis_dim
1768                ),
1769            });
1770        }
1771        self.basis_span_cubic_validated(span_idx, basis_idx)
1772    }
1773
1774    fn basis_span_cubic_validated(
1775        &self,
1776        span_idx: usize,
1777        basis_idx: usize,
1778    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1779        let points = &self.breakpoints;
1780        if span_idx + 1 >= points.len() {
1781            return Err(FittedModelError::SchemaMismatch {
1782                reason: format!(
1783                    "saved anchored deviation span index {} out of range for {} spans",
1784                    span_idx,
1785                    points.len() - 1
1786                ),
1787            });
1788        }
1789        Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1790            left: points[span_idx],
1791            right: points[span_idx + 1],
1792            c0: self.span_c0[span_idx][basis_idx],
1793            c1: self.span_c1[span_idx][basis_idx],
1794            c2: self.span_c2[span_idx][basis_idx],
1795            c3: self.span_c3[span_idx][basis_idx],
1796        })
1797    }
1798
1799    pub fn basis_cubic_at(
1800        &self,
1801        basis_idx: usize,
1802        value: f64,
1803    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1804        self.validate_exact_replay_contract()?;
1805        if basis_idx >= self.basis_dim {
1806            return Err(FittedModelError::SchemaMismatch {
1807                reason: format!(
1808                    "saved anchored deviation basis index {} out of range for {} coefficients",
1809                    basis_idx, self.basis_dim
1810                ),
1811            });
1812        }
1813        let (left_ep, right_ep) = self.support_interval()?;
1814        if value < left_ep {
1815            return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1816                left: left_ep,
1817                right: left_ep + 1.0,
1818                c0: self.span_c0[0][basis_idx],
1819                c1: 0.0,
1820                c2: 0.0,
1821                c3: 0.0,
1822            });
1823        }
1824        if value > right_ep {
1825            return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1826                left: right_ep,
1827                right: right_ep + 1.0,
1828                c0: self.right_boundary_basis_value(basis_idx),
1829                c1: 0.0,
1830                c2: 0.0,
1831                c3: 0.0,
1832            });
1833        }
1834        let span_idx = self.left_biased_span_index_for(value)?;
1835        self.basis_span_cubic_validated(span_idx, basis_idx)
1836    }
1837
1838    pub fn local_cubic_at(
1839        &self,
1840        beta: &Array1<f64>,
1841        value: f64,
1842    ) -> Result<crate::cubic_cell_kernel::LocalSpanCubic, FittedModelError> {
1843        self.validate_exact_replay_contract()?;
1844        if beta.len() != self.basis_dim {
1845            return Err(FittedModelError::SchemaMismatch {
1846                reason: format!(
1847                    "saved anchored deviation coefficient length mismatch: got {}, expected {}",
1848                    beta.len(),
1849                    self.basis_dim
1850                ),
1851            });
1852        }
1853        let (left_ep, right_ep) = self.support_interval()?;
1854        if value < left_ep {
1855            return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1856                left: left_ep,
1857                right: left_ep + 1.0,
1858                c0: self.span_c0[0]
1859                    .iter()
1860                    .zip(beta.iter())
1861                    .map(|(coeff, weight)| coeff * weight)
1862                    .sum(),
1863                c1: 0.0,
1864                c2: 0.0,
1865                c3: 0.0,
1866            });
1867        }
1868        if value > right_ep {
1869            return Ok(crate::cubic_cell_kernel::LocalSpanCubic {
1870                left: right_ep,
1871                right: right_ep + 1.0,
1872                c0: (0..self.basis_dim)
1873                    .map(|basis_idx| self.right_boundary_basis_value(basis_idx) * beta[basis_idx])
1874                    .sum(),
1875                c1: 0.0,
1876                c2: 0.0,
1877                c3: 0.0,
1878            });
1879        }
1880        let span_idx = self.left_biased_span_index_for(value)?;
1881        self.local_cubic_on_span_validated(beta, span_idx)
1882    }
1883
1884    fn support_interval(&self) -> Result<(f64, f64), FittedModelError> {
1885        let points = self.breakpoints()?;
1886        match (points.first(), points.last()) {
1887            (Some(&left), Some(&right)) => Ok((left, right)),
1888            _ => Err(FittedModelError::MissingField {
1889                reason: "saved anchored deviation runtime is missing support breakpoints"
1890                    .to_string(),
1891            }),
1892        }
1893    }
1894
1895    pub fn design(&self, values: &Array1<f64>) -> Result<Array2<f64>, FittedModelError> {
1896        // Note: when the saved runtime carries an anchor residual
1897        // (cross-block orthogonalisation), the value `design()` returns
1898        // is the raw cubic span output *without* the per-row `n_row · M`
1899        // subtraction. Callers used inside BMS prediction must either
1900        // switch to `design_with_anchor_rows` (when the per-row anchor
1901        // rows are available) or call `design_uncorrected` explicitly and
1902        // apply the subtraction at the call site. For runtimes without a
1903        // residual the two paths coincide.
1904        self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
1905    }
1906
1907    /// Raw cubic-span design without any anchor-residual subtraction.
1908    ///
1909    /// Exposed for callers that intend to apply the `n_row · M` correction
1910    /// post-hoc (e.g., BMS `link_terms_value_d1` subtracts a precomputed
1911    /// `correction.dot(beta)` scalar from the linear-predictor contribution
1912    /// rather than building a full anchor-row matrix). Equivalent to
1913    /// `design()` when no residual is present.
1914    pub fn design_uncorrected(
1915        &self,
1916        values: &Array1<f64>,
1917    ) -> Result<Array2<f64>, FittedModelError> {
1918        self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)
1919    }
1920
1921    /// Evaluate the residual-corrected design at the supplied values.
1922    ///
1923    /// `anchor_rows` must be an `n × d` matrix where `n == values.len()`
1924    /// and `d == sum of anchor_components ncols`. Each row holds
1925    /// the concatenated parametric anchor design at the same prediction
1926    /// row as the corresponding `values[i]`. When the runtime has no
1927    /// anchor residual, `anchor_rows` must have zero columns (or be
1928    /// `Array2::zeros((n, 0))`).
1929    pub fn design_with_anchor_rows(
1930        &self,
1931        values: &Array1<f64>,
1932        anchor_rows: ndarray::ArrayView2<f64>,
1933    ) -> Result<Array2<f64>, FittedModelError> {
1934        let mut out =
1935            self.evaluate_span_polynomial_design(values, BasisOptions::value().derivative_order)?;
1936        if let Some(m_rows) = self.anchor_correction.as_ref() {
1937            let d = m_rows.len();
1938            if anchor_rows.nrows() != values.len() {
1939                return Err(FittedModelError::SchemaMismatch {
1940                    reason: format!(
1941                        "design_with_anchor_rows: anchor_rows has {} rows, expected {} (matching values)",
1942                        anchor_rows.nrows(),
1943                        values.len(),
1944                    ),
1945                });
1946            }
1947            if anchor_rows.ncols() != d {
1948                return Err(FittedModelError::SchemaMismatch {
1949                    reason: format!(
1950                        "design_with_anchor_rows: anchor_rows has {} cols, expected {} (sum of component ncols)",
1951                        anchor_rows.ncols(),
1952                        d,
1953                    ),
1954                });
1955            }
1956            // Materialise M (d × basis_dim) once.
1957            let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
1958            for (i, row) in m_rows.iter().enumerate() {
1959                if row.len() != self.basis_dim {
1960                    return Err(FittedModelError::SchemaMismatch {
1961                        reason: format!(
1962                            "design_with_anchor_rows: anchor_correction row {} has length {}, expected basis_dim {}",
1963                            i,
1964                            row.len(),
1965                            self.basis_dim,
1966                        ),
1967                    });
1968                }
1969                for (j, &v) in row.iter().enumerate() {
1970                    m_dense[[i, j]] = v;
1971                }
1972            }
1973            // The compiler bakes the orthonormalising rotation into M, so
1974            // the predict-time subtraction is simply `n_anchor_rows · M`.
1975            let subtract = anchor_rows.dot(&m_dense);
1976            out = out - subtract;
1977        } else if anchor_rows.ncols() != 0 {
1978            return Err(FittedModelError::SchemaMismatch {
1979                reason: format!(
1980                    "design_with_anchor_rows: runtime has no anchor residual but anchor_rows has {} cols",
1981                    anchor_rows.ncols(),
1982                ),
1983            });
1984        }
1985        Ok(out)
1986    }
1987
1988    /// Build the n × basis_dim per-row, per-basis correction matrix
1989    /// `N · M` for a batch of predict rows.
1990    ///
1991    /// `n_anchor_rows` is the n × d matrix of stacked parametric anchor
1992    /// rows at the prediction rows (concatenation of the marginal and
1993    /// logslope design rows in component order). Returns `None` when the
1994    /// runtime has no anchor residual (zero-cost path).
1995    pub fn anchor_correction_matrix(
1996        &self,
1997        n_anchor_rows: ndarray::ArrayView2<f64>,
1998    ) -> Result<Option<Array2<f64>>, FittedModelError> {
1999        let Some(m_rows) = self.anchor_correction.as_ref() else {
2000            return Ok(None);
2001        };
2002        let d = m_rows.len();
2003        if n_anchor_rows.ncols() != d {
2004            return Err(FittedModelError::SchemaMismatch {
2005                reason: format!(
2006                    "anchor_correction_matrix: anchor_rows has {} cols, expected {} (sum of component ncols)",
2007                    n_anchor_rows.ncols(),
2008                    d,
2009                ),
2010            });
2011        }
2012        let mut m_dense = Array2::<f64>::zeros((d, self.basis_dim));
2013        for (i, row) in m_rows.iter().enumerate() {
2014            if row.len() != self.basis_dim {
2015                return Err(FittedModelError::SchemaMismatch {
2016                    reason: format!(
2017                        "anchor_correction_matrix: M row {} has length {}, expected basis_dim {}",
2018                        i,
2019                        row.len(),
2020                        self.basis_dim,
2021                    ),
2022                });
2023            }
2024            for (j, &v) in row.iter().enumerate() {
2025                m_dense[[i, j]] = v;
2026            }
2027        }
2028        // The compiler bakes the orthonormalising rotation into M, so
2029        // the predict-time correction is simply `n_anchor_rows · M`.
2030        Ok(Some(n_anchor_rows.dot(&m_dense)))
2031    }
2032
2033    pub fn first_derivative_design(
2034        &self,
2035        values: &Array1<f64>,
2036    ) -> Result<Array2<f64>, FittedModelError> {
2037        self.evaluate_span_polynomial_design(
2038            values,
2039            BasisOptions::first_derivative().derivative_order,
2040        )
2041    }
2042
2043    pub fn second_derivative_design(
2044        &self,
2045        values: &Array1<f64>,
2046    ) -> Result<Array2<f64>, FittedModelError> {
2047        self.evaluate_span_polynomial_design(
2048            values,
2049            BasisOptions::second_derivative().derivative_order,
2050        )
2051    }
2052}
2053
2054impl FittedFamily {
2055    #[inline]
2056    pub fn likelihood(&self) -> LikelihoodSpec {
2057        let spec = match self {
2058            Self::Standard { likelihood, .. }
2059            | Self::LocationScale { likelihood, .. }
2060            | Self::MarginalSlope { likelihood, .. }
2061            | Self::Survival { likelihood, .. }
2062            | Self::TransformationNormal { likelihood, .. } => likelihood,
2063            Self::LatentSurvival { .. } | Self::LatentBinary { .. } => {
2064                return LikelihoodSpec::royston_parmar();
2065            }
2066        };
2067        spec.clone()
2068    }
2069
2070    #[inline]
2071    pub fn frailty(&self) -> Option<&FrailtySpec> {
2072        match self {
2073            Self::MarginalSlope { frailty, .. }
2074            | Self::Survival { frailty, .. }
2075            | Self::LatentSurvival { frailty }
2076            | Self::LatentBinary { frailty } => Some(frailty),
2077            _ => None,
2078        }
2079    }
2080}
2081
2082/// Recursively collect the feature columns of a smooth basis whose out-of-hull
2083/// evaluation is bounded, so they can be exempted from the predict-time axis
2084/// clip (see [`FittedModel::training_smooth_extrapolation_axes`]). Wrapper bases
2085/// (`by=`, factor-smooth, sum-to-zero) delegate to their inner smooth; `Sphere`
2086/// and `Pca` are intentionally not collected.
2087fn collect_smooth_extrapolation_axes(
2088    basis: &gam_terms::smooth::SmoothBasisSpec,
2089    n_training_headers: usize,
2090    out: &mut std::collections::HashSet<usize>,
2091) {
2092    use gam_terms::smooth::SmoothBasisSpec;
2093    let push = |col: usize, out: &mut std::collections::HashSet<usize>| {
2094        if col < n_training_headers {
2095            out.insert(col);
2096        }
2097    };
2098    match basis {
2099        // 1D B-spline: first-order linear extension off the boundary slope.
2100        SmoothBasisSpec::BSpline1D { feature_col, .. } => push(*feature_col, out),
2101        // Tensor B-spline: each margin linear-extends independently. Periodic
2102        // margins are additionally (and harmlessly) exempted via the periodic set.
2103        SmoothBasisSpec::TensorBSpline { feature_cols, .. } => {
2104            for &c in feature_cols {
2105                push(c, out);
2106            }
2107        }
2108        // Radial bases with a bounded out-of-hull contract: Duchon / thin-plate
2109        // are linear outside the data span (natural-spline boundary conditions),
2110        // Matérn reverts to its mean as the kernel decays. Measure-jet shares
2111        // the Matérn contract (Gaussian representers decay to the parametric
2112        // layer off the data support) — and off-web queries are exactly the
2113        // ones its support diagnostic must see unclipped.
2114        SmoothBasisSpec::ThinPlate { feature_cols, .. }
2115        | SmoothBasisSpec::Matern { feature_cols, .. }
2116        | SmoothBasisSpec::MeasureJet { feature_cols, .. }
2117        | SmoothBasisSpec::Duchon { feature_cols, .. } => {
2118            for &c in feature_cols {
2119                push(c, out);
2120            }
2121        }
2122        // Factor-smooth: the continuous marginal axes are B-splines that
2123        // linear-extend; the group column is categorical and is left to the
2124        // random-effect / level-lookup machinery.
2125        SmoothBasisSpec::FactorSmooth { spec } => {
2126            for &c in &spec.continuous_cols {
2127                push(c, out);
2128            }
2129        }
2130        // Wrappers delegate to the inner smooth they modulate / replicate.
2131        SmoothBasisSpec::ByVariable { inner, .. }
2132        | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2133            collect_smooth_extrapolation_axes(inner, n_training_headers, out)
2134        }
2135        SmoothBasisSpec::BySmooth { smooth, .. } => {
2136            collect_smooth_extrapolation_axes(smooth, n_training_headers, out)
2137        }
2138        // Sphere: latitude is clipped to manifold bounds, longitude is periodic —
2139        // both handled elsewhere with non-plateau semantics. Pca: no extrapolation
2140        // contract, stays clipped. ConstantCurvature: chart coordinates must stay
2141        // inside the κ-stereographic chart (open ball for κ < 0), so clipping new
2142        // data to the training range is the safe out-of-hull behavior.
2143        SmoothBasisSpec::Sphere { .. }
2144        | SmoothBasisSpec::ConstantCurvature { .. }
2145        | SmoothBasisSpec::Pca { .. } => {}
2146    }
2147}
2148
2149/// Collect training-column indices that feed a *numeric* `by=` multiplier of a
2150/// varying-coefficient smooth (`s(x, by=z)`).
2151///
2152/// A numeric by-variable enters the model as a linear multiplier of the centred
2153/// smooth basis, so the prediction is exactly affine in `z` for any fixed `x`:
2154/// `pred(x, z) = intercept + z·f(x)`. The by-multiplier is therefore an
2155/// *unbounded linear* extrapolant — exactly like a parametric `linear` axis
2156/// (already exempt from the clip via `training_linear_axes`), not a
2157/// bounded-basis axis. Clamping it to the training range `[min(z), max(z)]`
2158/// before the design is built would make the varying-coefficient effect plateau
2159/// outside the sampled range (slope 0 above the max) and silently replace the
2160/// natural `z == 0` baseline with the `z == min` prediction — the prediction is
2161/// no longer affine in `z`. Exempt it from the predict-time axis clip.
2162///
2163/// Factor `by=` columns are categorical group labels handled by the
2164/// level-lookup / random-effect machinery and are deliberately *not* exempted
2165/// here. Returned indices reference `self.training_headers`, matching the
2166/// iteration in `axis_clip_to_training_ranges`.
2167fn collect_by_variable_numeric_axes(
2168    basis: &gam_terms::smooth::SmoothBasisSpec,
2169    n_training_headers: usize,
2170    out: &mut std::collections::HashSet<usize>,
2171) {
2172    use gam_terms::smooth::{BySmoothKind, ByVarKind, SmoothBasisSpec};
2173    match basis {
2174        SmoothBasisSpec::ByVariable {
2175            inner,
2176            by_col,
2177            kind,
2178            ..
2179        } => {
2180            if matches!(kind, BySmoothKind::Numeric) && *by_col < n_training_headers {
2181                out.insert(*by_col);
2182            }
2183            collect_by_variable_numeric_axes(inner, n_training_headers, out);
2184        }
2185        SmoothBasisSpec::BySmooth { smooth, by_kind } => {
2186            if let ByVarKind::Numeric { feature_col } = by_kind
2187                && *feature_col < n_training_headers
2188            {
2189                out.insert(*feature_col);
2190            }
2191            collect_by_variable_numeric_axes(smooth, n_training_headers, out);
2192        }
2193        SmoothBasisSpec::FactorSumToZero { inner, .. } => {
2194            collect_by_variable_numeric_axes(inner, n_training_headers, out);
2195        }
2196        _ => {}
2197    }
2198}
2199
2200impl FittedModel {
2201    /// Axis-clip each continuous new-data column to the (min, max) range
2202    /// observed in training. Categorical and binary columns are left
2203    /// untouched so unseen levels surface rather than being silently remapped
2204    /// onto seen ones. Returns `Some(clipped_copy)` only if at least one
2205    /// value was actually clipped; otherwise `None` so callers can avoid
2206    /// owning a redundant copy. Pre-2026-04-29 model JSONs that lack the
2207    /// `training_feature_ranges` field deserialize to `None` and pass through
2208    /// unchanged.
2209    pub fn axis_clip_to_training_ranges(
2210        &self,
2211        data: ndarray::ArrayView2<'_, f64>,
2212        col_map: &std::collections::HashMap<String, usize>,
2213    ) -> Option<ndarray::Array2<f64>> {
2214        let training_headers = self.training_headers.as_ref()?;
2215        let ranges = self.training_feature_ranges.as_ref()?;
2216        if training_headers.len() != ranges.len() {
2217            return None;
2218        }
2219        let mut kind_by_header: std::collections::HashMap<&str, ColumnKindTag> =
2220            std::collections::HashMap::new();
2221        if let Some(schema) = self.data_schema.as_ref() {
2222            for col in &schema.columns {
2223                kind_by_header.insert(col.name.as_str(), col.kind);
2224            }
2225        }
2226        // Periodic axes (sphere longitude, periodic-B-spline 1D, periodic
2227        // tensor margins) must never be clipped to the training range:
2228        // clamping a value just past the seam to the training extreme breaks
2229        // the cyclic invariant f(x₀) = f(x₀ + period) at predict time and
2230        // shows up as a visible seam in surface plots.
2231        let periodic_axes = self.training_periodic_axes(training_headers);
2232        // Parametric/linear-term axes must never be clipped either: a linear
2233        // term's contract is η = β0 + β1·x, i.e. genuine linear extrapolation.
2234        // Clamping its input to the training extreme turns predict into a
2235        // piecewise-constant plateau outside the training hull and freezes the
2236        // prediction SE at the boundary (the clamped x feeds xᵀ Var(β) x), so
2237        // credible intervals stop widening with distance from the data. This
2238        // mirrors how periodic axes are exempted just above.
2239        let linear_axes = self.training_linear_axes(training_headers.len());
2240        // Random-effect grouping axes are categorical even when their source
2241        // column is numeric. Clipping them would remap an unseen group label to
2242        // a boundary training level instead of letting the random-effect block
2243        // encode it as the prior-mean zero effect.
2244        let random_effect_axes = self.training_random_effect_axes(training_headers.len());
2245        // Non-parametric smooth axes whose basis extrapolates boundedly on its
2246        // own (B-spline linear extension, Duchon/thin-plate natural-spline linear
2247        // tail, Matérn kernel decay). Clamping their input to the training extreme
2248        // hands the basis an already-clamped coordinate, so its extrapolation
2249        // never fires and predict freezes at a boundary plateau — diverging from
2250        // the raw design path, which does not clip. Exempt them so both paths go
2251        // through the single basis-layer extrapolation. See the method doc.
2252        let smooth_extrapolation_axes =
2253            self.training_smooth_extrapolation_axes(training_headers.len());
2254        // Numeric `by=` multipliers of a varying-coefficient smooth `s(x, by=z)`
2255        // are linear multipliers of the centred basis (prediction affine in z),
2256        // so — like parametric linear axes — clipping them to the training range
2257        // turns the varying-coefficient effect into a boundary plateau and
2258        // destroys the z==0 baseline. Exempt them from the clip.
2259        let by_variable_axes = self.training_by_variable_numeric_axes(training_headers.len());
2260        // Sphere latitude is a closed-manifold coordinate: its clip bounds are
2261        // the manifold's intrinsic domain ([-π/2, π/2] or [-90, 90]), not the
2262        // sampled range, so a pole prediction reaches the true pole instead of
2263        // being clamped to a near-pole latitude (see the method doc).
2264        let sphere_lat_bounds = self.training_sphere_latitude_bounds(training_headers);
2265        let mut clipped = data.to_owned();
2266        let mut any_clipped = false;
2267        for (col_in_training, (header, &(lo, hi))) in
2268            training_headers.iter().zip(ranges.iter()).enumerate()
2269        {
2270            let (lo, hi) = sphere_lat_bounds
2271                .get(&col_in_training)
2272                .copied()
2273                .unwrap_or((lo, hi));
2274            if !(lo.is_finite() && hi.is_finite()) || hi <= lo {
2275                continue;
2276            }
2277            if !matches!(
2278                kind_by_header.get(header.as_str()).copied(),
2279                Some(ColumnKindTag::Continuous)
2280            ) {
2281                continue;
2282            }
2283            if periodic_axes.contains(&col_in_training) {
2284                continue;
2285            }
2286            if linear_axes.contains(&col_in_training) {
2287                continue;
2288            }
2289            if random_effect_axes.contains(&col_in_training) {
2290                continue;
2291            }
2292            if smooth_extrapolation_axes.contains(&col_in_training) {
2293                continue;
2294            }
2295            if by_variable_axes.contains(&col_in_training) {
2296                continue;
2297            }
2298            let Some(&col_idx) = col_map.get(header) else {
2299                continue;
2300            };
2301            if col_idx >= clipped.ncols() {
2302                continue;
2303            }
2304            let mut col = clipped.column_mut(col_idx);
2305            for v in col.iter_mut() {
2306                if v.is_finite() {
2307                    if *v < lo {
2308                        *v = lo;
2309                        any_clipped = true;
2310                    } else if *v > hi {
2311                        *v = hi;
2312                        any_clipped = true;
2313                    }
2314                }
2315            }
2316        }
2317        if any_clipped { Some(clipped) } else { None }
2318    }
2319
2320    fn saved_term_specs(&self) -> Vec<&TermCollectionSpec> {
2321        let mut specs: Vec<&TermCollectionSpec> = [
2322            self.resolved_termspec.as_ref(),
2323            self.resolved_termspec_noise.as_ref(),
2324            self.resolved_termspec_logslope.as_ref(),
2325        ]
2326        .into_iter()
2327        .flatten()
2328        .collect();
2329        if let Some(logslopes) = self.resolved_termspec_logslopes.as_ref() {
2330            specs.extend(logslopes.iter());
2331        }
2332        specs
2333    }
2334
2335    /// Collect the set of training-column indices that are periodic axes —
2336    /// i.e. features for which a periodic basis (sphere longitude, periodic
2337    /// B-spline 1D, periodic tensor margin) must be allowed to take any
2338    /// real value at predict time and not be clamped to the training range.
2339    /// Returned indices reference `self.training_headers` (training-time
2340    /// layout), matching the iteration in `axis_clip_to_training_ranges`.
2341    fn training_periodic_axes(
2342        &self,
2343        training_headers: &[String],
2344    ) -> std::collections::HashSet<usize> {
2345        use gam_terms::basis::BSplineKnotSpec;
2346        use gam_terms::smooth::SmoothBasisSpec;
2347        let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2348        let Some(spec) = self.resolved_termspec.as_ref() else {
2349            return out;
2350        };
2351        for term in &spec.smooth_terms {
2352            match &term.basis {
2353                // Sphere terms: longitude (second feature col) is always
2354                // periodic and exempt from clipping. Latitude is not periodic
2355                // but is a closed-manifold coordinate, so it is clipped to the
2356                // manifold's intrinsic bounds rather than the sampled range —
2357                // see `training_sphere_latitude_bounds`.
2358                SmoothBasisSpec::Sphere { feature_cols, .. } => {
2359                    if let Some(&lon_col) = feature_cols.get(1)
2360                        && lon_col < training_headers.len()
2361                    {
2362                        out.insert(lon_col);
2363                    }
2364                }
2365                // 1D periodic B-spline: the single feature column is periodic.
2366                SmoothBasisSpec::BSpline1D { feature_col, spec } => {
2367                    if matches!(spec.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2368                        && *feature_col < training_headers.len()
2369                    {
2370                        out.insert(*feature_col);
2371                    }
2372                }
2373                // Tensor B-spline: each axis whose marginal knotspec is
2374                // PeriodicUniform is periodic; mark those columns.
2375                SmoothBasisSpec::TensorBSpline { feature_cols, spec } => {
2376                    for (i, marginal) in spec.marginalspecs.iter().enumerate() {
2377                        if matches!(marginal.knotspec, BSplineKnotSpec::PeriodicUniform { .. })
2378                            && let Some(&col) = feature_cols.get(i)
2379                            && col < training_headers.len()
2380                        {
2381                            out.insert(col);
2382                        }
2383                    }
2384                }
2385                _ => {}
2386            }
2387        }
2388        out
2389    }
2390
2391    /// Collect the set of training-column indices that feed a parametric/linear
2392    /// term — on *any* modelled surface (mean, noise/scale, log-slope). A
2393    /// linear term realises the design column `∏ feature_cols` and contributes
2394    /// `β·(that product)` to the linear predictor, so its inputs must be allowed
2395    /// to take any real value at predict time: clamping them to the training
2396    /// range would replace genuine linear extrapolation with a boundary plateau
2397    /// (and freeze the prediction SE at the hull edge). Returned indices
2398    /// reference `self.training_headers` (training-time layout), matching the
2399    /// iteration in `axis_clip_to_training_ranges`. Wilkinson-Rogers `:`
2400    /// interactions contribute every column in their `feature_cols` product.
2401    fn training_linear_axes(&self, n_training_headers: usize) -> std::collections::HashSet<usize> {
2402        let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2403        for spec in self.saved_term_specs() {
2404            for term in &spec.linear_terms {
2405                for col in term.effective_feature_cols() {
2406                    if col < n_training_headers {
2407                        out.insert(col);
2408                    }
2409                }
2410            }
2411        }
2412        out
2413    }
2414
2415    /// Collect the set of training-column indices that feed random-effect
2416    /// grouping terms. These columns are categorical model axes regardless of
2417    /// the ingest schema's scalar storage type, so prediction must leave them
2418    /// untouched and let the frozen random-effect levels decide whether a row is
2419    /// a seen level or the zero-effect unseen-level fallback.
2420    fn training_random_effect_axes(
2421        &self,
2422        n_training_headers: usize,
2423    ) -> std::collections::HashSet<usize> {
2424        let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2425        for spec in self.saved_term_specs() {
2426            for term in &spec.random_effect_terms {
2427                if term.feature_col < n_training_headers {
2428                    out.insert(term.feature_col);
2429                }
2430            }
2431        }
2432        out
2433    }
2434
2435    /// Collect the set of training-column indices that feed a non-parametric
2436    /// smooth whose basis performs its own *bounded* extrapolation outside the
2437    /// training hull — on any modelled surface (mean, noise/scale, log-slope).
2438    ///
2439    /// These columns must be exempt from the predict-time axis clip for the same
2440    /// reason periodic/linear/random-effect axes are: the clip clamps a new value
2441    /// to the training extreme *before* the design is built, so the basis is
2442    /// handed an already-clamped coordinate and its extrapolation machinery never
2443    /// fires. The result is a piecewise-constant plateau frozen at the boundary
2444    /// fitted value (with a prediction SE frozen at the hull edge), and — worse —
2445    /// a model that yields *different* predictions through the `FittedModel`
2446    /// predict pipeline than through the raw `build_term_collection_design` path,
2447    /// which does not clip. Exempting these axes routes both entry points through
2448    /// the single basis-layer extrapolation, restoring internal consistency.
2449    ///
2450    /// Only bases with a *bounded* out-of-hull contract are listed, so removing
2451    /// the clip cannot reintroduce the wild basis blow-up the clip guards against:
2452    ///   - B-spline 1D / tensor margins: first-order linear extension off the
2453    ///     boundary slope (`apply_linear_extension_from_first_derivative`) — grows
2454    ///     at most linearly.
2455    ///   - Duchon / thin-plate: the natural-spline boundary conditions make the
2456    ///     fit linear outside the data span — also at most linear growth.
2457    ///   - Matérn: the kernel decays with distance, so the fit reverts smoothly to
2458    ///     its (low-order polynomial / constant) mean far from the data — bounded.
2459    /// `sphere()` axes are deliberately *not* listed: latitude is a closed-manifold
2460    /// coordinate clipped to its intrinsic bounds (`training_sphere_latitude_bounds`)
2461    /// and longitude is periodic (`training_periodic_axes`); both already have the
2462    /// correct, non-plateau handling. `Pca` projections have no extrapolation
2463    /// contract and stay clipped.
2464    ///
2465    /// Returned indices reference `self.training_headers` (training-time layout),
2466    /// matching the iteration in `axis_clip_to_training_ranges`.
2467    fn training_smooth_extrapolation_axes(
2468        &self,
2469        n_training_headers: usize,
2470    ) -> std::collections::HashSet<usize> {
2471        let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2472        for spec in self.saved_term_specs() {
2473            for term in &spec.smooth_terms {
2474                collect_smooth_extrapolation_axes(&term.basis, n_training_headers, &mut out);
2475            }
2476        }
2477        out
2478    }
2479
2480    /// Collect the set of training-column indices that feed a *numeric* `by=`
2481    /// multiplier of a varying-coefficient smooth. These columns are linear
2482    /// multipliers (`pred = intercept + z·f(x)`), so they must be exempt from
2483    /// the predict-time axis clip for the same reason parametric linear axes
2484    /// are — see `collect_by_variable_numeric_axes` for the full rationale.
2485    fn training_by_variable_numeric_axes(
2486        &self,
2487        n_training_headers: usize,
2488    ) -> std::collections::HashSet<usize> {
2489        let mut out: std::collections::HashSet<usize> = std::collections::HashSet::new();
2490        for spec in self.saved_term_specs() {
2491            for term in &spec.smooth_terms {
2492                collect_by_variable_numeric_axes(&term.basis, n_training_headers, &mut out);
2493            }
2494        }
2495        out
2496    }
2497
2498    /// Manifold-intrinsic clip bounds for sphere *latitude* columns.
2499    ///
2500    /// A `sphere(lat, lon)` smooth charts the closed manifold S²: the poles
2501    /// (lat = ±π/2, or ±90°) are interior limit points of that manifold, not
2502    /// endpoints of an unbounded axis to extrapolate along. A finite training
2503    /// sample never reaches the pole exactly, so clamping a pole prediction to
2504    /// the *observed* latitude extreme lands at a near-pole latitude where the
2505    /// Wahba SOS kernel's `cos(lat)·cos(lat_c)·cos(Δlon)` term has not yet
2506    /// damped — and the predictor then sweeps a spurious `cos(lon)` profile at
2507    /// what is physically a single point, reintroducing the pole artefact the
2508    /// SOS basis exists to remove.
2509    ///
2510    /// The correct clip bound for this coordinate is therefore the manifold's
2511    /// intrinsic domain — `[-π/2, π/2]` radians or `[-90, 90]` degrees — not
2512    /// the sampled range. Clamping to those bounds keeps the pole reachable
2513    /// (single-valued in longitude) while still mapping any out-of-domain
2514    /// latitude onto the manifold boundary. Longitude needs no entry here: it
2515    /// is periodic and already exempted from clipping entirely
2516    /// (`training_periodic_axes`). Returned indices reference
2517    /// `self.training_headers`, matching the iteration in
2518    /// `axis_clip_to_training_ranges`.
2519    fn training_sphere_latitude_bounds(
2520        &self,
2521        training_headers: &[String],
2522    ) -> std::collections::HashMap<usize, (f64, f64)> {
2523        use gam_terms::smooth::SmoothBasisSpec;
2524        let mut out: std::collections::HashMap<usize, (f64, f64)> =
2525            std::collections::HashMap::new();
2526        let Some(spec) = self.resolved_termspec.as_ref() else {
2527            return out;
2528        };
2529        for term in &spec.smooth_terms {
2530            if let SmoothBasisSpec::Sphere { feature_cols, spec } = &term.basis
2531                && let Some(&lat_col) = feature_cols.first()
2532                && lat_col < training_headers.len()
2533            {
2534                let bound = if spec.radians {
2535                    std::f64::consts::FRAC_PI_2
2536                } else {
2537                    90.0
2538                };
2539                out.insert(lat_col, (-bound, bound));
2540            }
2541        }
2542        out
2543    }
2544
2545    pub fn from_payload(mut payload: FittedModelPayload) -> Self {
2546        let likelihood = payload.family_state.likelihood();
2547        let class = match payload.model_kind {
2548            ModelKind::Survival => PredictModelClass::Survival,
2549            ModelKind::MarginalSlope => PredictModelClass::BernoulliMarginalSlope,
2550            ModelKind::TransformationNormal => PredictModelClass::TransformationNormal,
2551            ModelKind::LocationScale => {
2552                if likelihood == LikelihoodSpec::gaussian_identity() {
2553                    PredictModelClass::GaussianLocationScale
2554                } else if is_dispersion_location_scale_response(&likelihood.response) {
2555                    PredictModelClass::DispersionLocationScale
2556                } else {
2557                    PredictModelClass::BinomialLocationScale
2558                }
2559            }
2560            ModelKind::Standard => PredictModelClass::Standard,
2561        };
2562        match class {
2563            PredictModelClass::Survival => {
2564                payload.model_kind = ModelKind::Survival;
2565                Self::Survival { payload }
2566            }
2567            PredictModelClass::BernoulliMarginalSlope => {
2568                payload.model_kind = ModelKind::MarginalSlope;
2569                Self::MarginalSlope { payload }
2570            }
2571            PredictModelClass::TransformationNormal => {
2572                payload.model_kind = ModelKind::TransformationNormal;
2573                Self::TransformationNormal { payload }
2574            }
2575            PredictModelClass::GaussianLocationScale
2576            | PredictModelClass::BinomialLocationScale
2577            | PredictModelClass::DispersionLocationScale => {
2578                payload.model_kind = ModelKind::LocationScale;
2579                Self::LocationScale { payload }
2580            }
2581            PredictModelClass::Standard => {
2582                payload.model_kind = ModelKind::Standard;
2583                Self::Standard { payload }
2584            }
2585        }
2586        .with_synchronized_stateful_link_metadata()
2587    }
2588
2589    #[inline]
2590    pub fn payload(&self) -> &FittedModelPayload {
2591        match self {
2592            Self::Standard { payload }
2593            | Self::LocationScale { payload }
2594            | Self::MarginalSlope { payload }
2595            | Self::Survival { payload }
2596            | Self::TransformationNormal { payload } => payload,
2597        }
2598    }
2599
2600    #[inline]
2601    fn payload_mut(&mut self) -> &mut FittedModelPayload {
2602        match self {
2603            Self::Standard { payload }
2604            | Self::LocationScale { payload }
2605            | Self::MarginalSlope { payload }
2606            | Self::Survival { payload }
2607            | Self::TransformationNormal { payload } => payload,
2608        }
2609    }
2610
2611    fn with_synchronized_stateful_link_metadata(mut self) -> Self {
2612        self.synchronize_stateful_link_metadata();
2613        self
2614    }
2615
2616    fn synchronize_stateful_link_metadata(&mut self) {
2617        let payload = self.payload_mut();
2618        payload.used_device = payload
2619            .fit_result
2620            .as_ref()
2621            .or(payload.unified.as_ref())
2622            .is_some_and(|fit| fit.used_device);
2623        payload.synchronize_empty_feature_contract();
2624        let Some(fit) = payload.fit_result.as_ref().or(payload.unified.as_ref()) else {
2625            return;
2626        };
2627        match (&mut payload.family_state, &fit.fitted_link) {
2628            (
2629                FittedFamily::Standard {
2630                    likelihood,
2631                    latent_cloglog_state,
2632                    ..
2633                },
2634                FittedLinkState::LatentCLogLog { state },
2635            ) if likelihood.is_latent_cloglog() => {
2636                *latent_cloglog_state = Some(*state);
2637            }
2638            (
2639                FittedFamily::Standard {
2640                    likelihood,
2641                    sas_state,
2642                    ..
2643                },
2644                FittedLinkState::Sas { state, covariance },
2645            ) if likelihood.is_binomial_sas() => {
2646                *sas_state = Some(*state);
2647                payload.sas_param_covariance = covariance.as_ref().map(array2_to_nestedvec);
2648            }
2649            (
2650                FittedFamily::Standard {
2651                    likelihood,
2652                    sas_state,
2653                    ..
2654                },
2655                FittedLinkState::BetaLogistic { state, covariance },
2656            ) if likelihood.is_binomial_beta_logistic() => {
2657                *sas_state = Some(*state);
2658                payload.sas_param_covariance = covariance.as_ref().map(array2_to_nestedvec);
2659            }
2660            (
2661                FittedFamily::Standard {
2662                    likelihood,
2663                    mixture_state,
2664                    ..
2665                },
2666                FittedLinkState::Mixture { state, covariance },
2667            ) if likelihood.is_binomial_mixture() => {
2668                *mixture_state = Some(state.clone());
2669                payload.mixture_link_param_covariance =
2670                    covariance.as_ref().map(array2_to_nestedvec);
2671            }
2672            _ => {}
2673        }
2674    }
2675
2676    #[inline]
2677    pub fn likelihood(&self) -> LikelihoodSpec {
2678        self.payload().family_state.likelihood()
2679    }
2680
2681    /// Columns this model consumes from a prediction frame — its *input
2682    /// contract*.
2683    ///
2684    /// Every variable named by the main formula (features, interaction margins,
2685    /// random-effect groups, and a smooth's `by=` column), the survival
2686    /// entry/exit columns or the transformation-normal response, the auxiliary
2687    /// noise / logslope formula columns, and the offset / noise-offset /
2688    /// latent-`z` columns. The event-indicator and the plain response of a
2689    /// standard model are deliberately excluded: they are not needed to *form*
2690    /// a prediction (the conformal-calibration fold layers the response back on
2691    /// separately).
2692    ///
2693    /// This is the single authority shared by the CLI and PyFFI predict paths.
2694    /// A prediction frame column that is *not* in this set is irrelevant to the
2695    /// model and must be ignored rather than strict-encoded against the
2696    /// training schema — otherwise an unrelated ID/label column with a held-out
2697    /// categorical level aborts predict (#840).
2698    pub fn prediction_required_columns(
2699        &self,
2700    ) -> Result<std::collections::BTreeSet<String>, String> {
2701        let payload = self.payload();
2702        let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
2703        let mut required = std::collections::BTreeSet::<String>::new();
2704        parsed_term_column_names(&parsed.terms, &mut required);
2705
2706        if let Some((entry, exit, _event)) =
2707            parse_surv_response(parsed.response.as_str()).map_err(|e| e.to_string())?
2708        {
2709            if let Some(entry) = entry {
2710                required.insert(entry);
2711            }
2712            required.insert(exit);
2713        } else if let Some((left, right, _event)) =
2714            parse_surv_interval_response(parsed.response.as_str()).map_err(|e| e.to_string())?
2715        {
2716            required.insert(left);
2717            required.insert(right);
2718        }
2719        // A transformation-normal (CTM) prediction returns the response-scale
2720        // conditional mean E[Y|x], a function of the covariates alone (issue
2721        // #1612). The earlier implementation precomputed the PIT h(y|x) of the
2722        // supplied response, which made the outcome column mandatory at predict
2723        // time; the response is no longer required, so a covariate-only frame
2724        // must predict without it.
2725
2726        if let Some(offset) = payload.offset_column.as_ref() {
2727            required.insert(offset.clone());
2728        }
2729        if let Some(noise_offset) = payload.noise_offset_column.as_ref() {
2730            required.insert(noise_offset.clone());
2731        }
2732        if matches!(
2733            self.predict_model_class(),
2734            PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
2735        ) {
2736            if let Some(z_column) = payload.z_column.as_ref() {
2737                required.remove("z");
2738                required.insert(z_column.clone());
2739            }
2740        }
2741        if let Some(noise_formula) = payload.formula_noise.as_ref() {
2742            self.add_auxiliary_formula_columns(
2743                &mut required,
2744                noise_formula,
2745                parsed.response.as_str(),
2746            )?;
2747        }
2748        if let Some(logslope_formula) = payload.formula_logslope.as_ref() {
2749            if logslope_formula != "same-as-main" {
2750                self.add_auxiliary_formula_columns(
2751                    &mut required,
2752                    logslope_formula,
2753                    parsed.response.as_str(),
2754                )?;
2755            }
2756        }
2757        Ok(required)
2758    }
2759
2760    /// Columns a *post-fit diagnostic* command (diagnose / sample / report)
2761    /// needs **beyond** [`Self::prediction_required_columns`].
2762    ///
2763    /// Prediction deliberately drops a standard GAM's bare response so a
2764    /// prediction frame may omit it (#840 / #864). Diagnostics are statements
2765    /// *about* that observed response — residuals, R², posterior likelihoods,
2766    /// leave-one-out — so the response must be present. This returns the bare
2767    /// response column when the prediction projection would otherwise drop it,
2768    /// and nothing when the response is already prediction-required (survival
2769    /// `Surv(...)` time/event columns, the transformation-normal response) or
2770    /// is not a plain data column.
2771    ///
2772    /// Centralising the intent here is what makes it *structurally impossible*
2773    /// for a diagnostic command to silently drop the response: callers use
2774    /// `load_dataset…_for_diagnostics`, which always folds these in, instead of
2775    /// each remembering to thread an `extra_required` response by hand.
2776    pub fn diagnostic_extra_columns(&self) -> Result<Vec<String>, String> {
2777        let payload = self.payload();
2778        let parsed = parse_formula(payload.formula.as_str()).map_err(|e| e.to_string())?;
2779        // Prior (case) weights never enter the linear predictor, so
2780        // `prediction_required_columns` deliberately omits the weight column and
2781        // a prediction frame may drop it. Diagnostics are weight-aware, though:
2782        // `diagnose` reconstructs the ALO working weights `w_i = prior_i ·
2783        // Fisher_i` (and the refit fallback re-weights the same way), so the
2784        // weight column must be loaded. Fold it in here — the single seam that
2785        // makes it structurally impossible for a diagnostic command to silently
2786        // drop a needed column — regardless of the response-shape early-outs
2787        // below, since it is orthogonal to the response.
2788        let mut extras: Vec<String> = Vec::new();
2789        if let Some(weight_column) = payload.weight_column.as_ref() {
2790            extras.push(weight_column.clone());
2791        }
2792        // Survival responses are `Surv(...)` expressions, not bare columns; the
2793        // underlying entry/exit columns are already prediction-required.
2794        if parse_surv_response(parsed.response.as_str())
2795            .map_err(|e| e.to_string())?
2796            .is_some()
2797            || parse_surv_interval_response(parsed.response.as_str())
2798                .map_err(|e| e.to_string())?
2799                .is_some()
2800        {
2801            return Ok(extras);
2802        }
2803        let response = parsed.response.trim();
2804        // A response that is empty, or a function-call expression rather than a
2805        // plain data column, has no bare column to re-add.
2806        if response.is_empty() || response.contains('(') {
2807            return Ok(extras);
2808        }
2809        // Already prediction-required (e.g. transformation-normal re-adds it):
2810        // nothing extra to fold in.
2811        if self.prediction_required_columns()?.contains(response) {
2812            return Ok(extras);
2813        }
2814        extras.push(response.to_string());
2815        Ok(extras)
2816    }
2817
2818    /// Add the columns referenced by an auxiliary (noise / logslope) formula,
2819    /// which may be supplied as a full `lhs ~ rhs` formula or as a bare RHS.
2820    fn add_auxiliary_formula_columns(
2821        &self,
2822        required: &mut std::collections::BTreeSet<String>,
2823        formula_or_rhs: &str,
2824        response: &str,
2825    ) -> Result<(), String> {
2826        let trimmed = formula_or_rhs.trim();
2827        if trimmed.is_empty() || trimmed == "1" {
2828            return Ok(());
2829        }
2830        let formula = if trimmed.contains('~') {
2831            trimmed.to_string()
2832        } else {
2833            format!("{response} ~ {trimmed}")
2834        };
2835        let parsed = parse_formula(formula.as_str()).map_err(|e| e.to_string())?;
2836        parsed_term_column_names(&parsed.terms, required);
2837        Ok(())
2838    }
2839
2840    #[inline]
2841    pub fn predict_model_class(&self) -> PredictModelClass {
2842        match &self.payload().family_state {
2843            FittedFamily::Survival { .. }
2844            | FittedFamily::LatentSurvival { .. }
2845            | FittedFamily::LatentBinary { .. } => PredictModelClass::Survival,
2846            FittedFamily::MarginalSlope { .. } => PredictModelClass::BernoulliMarginalSlope,
2847            FittedFamily::TransformationNormal { .. } => PredictModelClass::TransformationNormal,
2848            FittedFamily::LocationScale { likelihood, .. } if likelihood.is_gaussian_identity() => {
2849                PredictModelClass::GaussianLocationScale
2850            }
2851            FittedFamily::LocationScale { likelihood, .. }
2852                if is_dispersion_location_scale_response(&likelihood.response) =>
2853            {
2854                PredictModelClass::DispersionLocationScale
2855            }
2856            FittedFamily::LocationScale { .. } => PredictModelClass::BinomialLocationScale,
2857            FittedFamily::Standard { .. } => PredictModelClass::Standard,
2858        }
2859    }
2860
2861    pub fn saved_link_wiggle(&self) -> Result<Option<SavedLinkWiggleRuntime>, FittedModelError> {
2862        let payload = self.payload();
2863        let (knots, degree) = match (
2864            payload.linkwiggle_knots.as_ref(),
2865            payload.linkwiggle_degree,
2866        ) {
2867            (None, None) => return Ok(None),
2868            (Some(knots), Some(degree)) => (knots.clone(), degree),
2869            _ => {
2870                return Err(FittedModelError::SchemaMismatch {
2871                    reason:
2872                        "saved model has partial link-wiggle metadata; expected linkwiggle_knots and linkwiggle_degree together"
2873                            .to_string(),
2874                })
2875            }
2876        };
2877        let resolved_link = self.resolved_inverse_link()?;
2878        let saved_link_disallows_wiggle = resolved_link
2879            .as_ref()
2880            .is_some_and(|link| !inverse_link_supports_joint_wiggle(link))
2881            || payload
2882                .link
2883                .as_ref()
2884                .is_some_and(|link| !inverse_link_supports_joint_wiggle(link));
2885        if saved_link_disallows_wiggle {
2886            return Err(FittedModelError::IncompatibleConfig {
2887                reason: joint_wiggle_unsupported_link_message("link wiggle"),
2888            });
2889        }
2890        let beta = match self.predict_model_class() {
2891            // #1596: the frozen-basis de-aliased standard link-warp is fit in a
2892            // reduced, identifiable coordinate `γ` and the fit_result LinkWiggle
2893            // block stores `γ` (its true free parameters). The full-width
2894            // standard-basis lift `β_w = Z·γ`, the coefficients the predict-time
2895            // I-spline basis multiplies, is persisted in `payload.beta_link_wiggle`
2896            // — prefer it when present. Without it (the dynamic-basis path) the
2897            // block coefficients ARE the standard-basis warp, read directly.
2898            PredictModelClass::Standard if payload.beta_link_wiggle.is_some() => {
2899                payload.beta_link_wiggle.clone().expect("checked is_some")
2900            }
2901            PredictModelClass::Standard => {
2902                let fit = payload.fit_result.as_ref().ok_or_else(|| {
2903                    FittedModelError::MissingField {
2904                        reason:
2905                            "standard link-wiggle model is missing canonical fit_result payload"
2906                                .to_string(),
2907                    }
2908                })?;
2909                if fit.blocks.len() != 2
2910                    || fit.blocks[0].role != BlockRole::Mean
2911                    || fit.blocks[1].role != BlockRole::LinkWiggle
2912                {
2913                    return Err(FittedModelError::SchemaMismatch {
2914                        reason:
2915                            "standard link-wiggle models must store blocks in [Mean, LinkWiggle] order"
2916                                .to_string(),
2917                    });
2918                }
2919                fit.block_by_role(BlockRole::LinkWiggle)
2920                    .ok_or_else(|| FittedModelError::MissingField {
2921                        reason:
2922                            "standard link-wiggle model is missing LinkWiggle coefficient block"
2923                                .to_string(),
2924                    })?
2925                    .beta
2926                    .to_vec()
2927            }
2928            _ => payload
2929                .beta_link_wiggle
2930                .clone()
2931                .ok_or_else(|| FittedModelError::MissingField {
2932                    reason:
2933                        "saved model has link-wiggle metadata but is missing payload.beta_link_wiggle"
2934                            .to_string(),
2935                })?,
2936        };
2937        Ok(Some(SavedLinkWiggleRuntime {
2938            knots,
2939            degree,
2940            beta,
2941        }))
2942    }
2943
2944    pub fn saved_baseline_time_wiggle(
2945        &self,
2946    ) -> Result<Option<SavedBaselineTimeWiggleRuntime>, FittedModelError> {
2947        let payload = self.payload();
2948        if payload
2949            .survival_cause_count
2950            .is_some_and(|cause_count| cause_count > 1)
2951            && payload.beta_baseline_timewiggle.is_none()
2952            && payload.beta_baseline_timewiggle_by_cause.is_some()
2953        {
2954            return Err(FittedModelError::SchemaMismatch {
2955                reason:
2956                    "joint cause-specific survival stores baseline-timewiggle coefficients per cause"
2957                        .to_string(),
2958            });
2959        }
2960        match (
2961            payload.baseline_timewiggle_knots.as_ref(),
2962            payload.baseline_timewiggle_degree,
2963            payload.baseline_timewiggle_penalty_orders.as_ref(),
2964            payload.baseline_timewiggle_double_penalty,
2965            payload.beta_baseline_timewiggle.as_ref(),
2966        ) {
2967            (None, None, None, None, None) => Ok(None),
2968            (Some(knots), Some(degree), Some(penalty_orders), Some(double_penalty), Some(beta)) => {
2969                Ok(Some(SavedBaselineTimeWiggleRuntime {
2970                    knots: knots.clone(),
2971                    degree,
2972                    penalty_orders: penalty_orders.clone(),
2973                    double_penalty,
2974                    beta: beta.clone(),
2975                }))
2976            }
2977            _ => Err(FittedModelError::SchemaMismatch {
2978                reason:
2979                    "saved model has partial baseline-timewiggle metadata; expected knots+degree+penalty_order+double_penalty+beta_baseline_timewiggle together"
2980                        .to_string(),
2981            }),
2982        }
2983    }
2984
2985    /// Whether this model has a link wiggle component with complete metadata.
2986    #[inline]
2987    pub fn has_link_wiggle(&self) -> bool {
2988        self.saved_link_wiggle()
2989            .map(|runtime| runtime.is_some())
2990            .unwrap_or(false)
2991    }
2992
2993    /// Whether this model has a baseline-time wiggle component with complete metadata.
2994    #[inline]
2995    pub fn has_baseline_time_wiggle(&self) -> bool {
2996        let payload = self.payload();
2997        if payload
2998            .survival_cause_count
2999            .is_some_and(|cause_count| cause_count > 1)
3000        {
3001            return payload.baseline_timewiggle_knots.is_some()
3002                && payload.baseline_timewiggle_degree.is_some()
3003                && payload.baseline_timewiggle_penalty_orders.is_some()
3004                && payload.baseline_timewiggle_double_penalty.is_some()
3005                && payload.beta_baseline_timewiggle_by_cause.is_some();
3006        }
3007        self.saved_baseline_time_wiggle()
3008            .map(|runtime| runtime.is_some())
3009            .unwrap_or(false)
3010    }
3011
3012    /// Whether the default point prediction must integrate the inverse link
3013    /// over the coefficient posterior — reporting the posterior mean
3014    /// `E[g⁻¹(Xβ)]` — rather than plugging in the posterior mode `g⁻¹(Xβ̂)`.
3015    ///
3016    /// SPEC (issue #960): the posterior mean is *always* the default point
3017    /// estimate (never MAP). It is observably distinct from the plug-in exactly
3018    /// when the inverse link is *curved* over the posterior's uncertainty, so
3019    /// `E[g⁻¹(η)] ≠ g⁻¹(E[η])` by Jensen. The curvature-based classification is:
3020    ///   * all log-link families (Poisson / Gamma / Tweedie / NegativeBinomial):
3021    ///     `E[exp η] = exp(η + se²/2) ≠ exp(η)` (log-normal MGF);
3022    ///   * all Binomial links (logit / probit / cloglog / SAS / BetaLogistic /
3023    ///     Mixture / LatentCLogLog): bounded sigmoidal inverse links;
3024    ///   * Beta (logit link): `E[σ(η)] ≠ σ(E[η])`;
3025    ///   * Royston–Parmar (curved survival-probability inverse link).
3026    /// The integral collapses to the plug-in (so the cheaper plug-in path is
3027    /// exact and taken instead) only for the effectively-linear identity-link
3028    /// Gaussian. Any model carrying a link wiggle or baseline-time wiggle is
3029    /// curved regardless of family. This curvature partition mirrors
3030    /// `families::family_runtime::posterior_mean`, the compute path that produces the
3031    /// corrected mean for each of these families.
3032    ///
3033    /// This is the single source of truth shared by the CLI (`gam predict`)
3034    /// and the Python FFI prediction path so the two can never drift on which
3035    /// models receive the posterior-mean correction.
3036    #[inline]
3037    pub fn prediction_uses_posterior_mean(&self) -> bool {
3038        let family = self.likelihood();
3039        let curved_family = match &family.response {
3040            // Identity-link Gaussian: inverse link is linear, so the posterior
3041            // mean equals the plug-in and the cheaper exact path is taken.
3042            ResponseFamily::Gaussian => false,
3043            // Log-link families: E[exp η] = exp(η + se²/2) ≠ exp(η).
3044            ResponseFamily::Poisson
3045            | ResponseFamily::Gamma
3046            | ResponseFamily::Tweedie { .. }
3047            | ResponseFamily::NegativeBinomial { .. } => true,
3048            // Beta (logit link): E[σ(η)] ≠ σ(E[η]).
3049            ResponseFamily::Beta { .. } => true,
3050            // Royston–Parmar: curved survival-probability inverse link.
3051            ResponseFamily::RoystonParmar => true,
3052            // Binomial: every link variant (logit / probit / cloglog / SAS /
3053            // BetaLogistic / Mixture / LatentCLogLog) is a curved sigmoid.
3054            ResponseFamily::Binomial => matches!(
3055                &family.link,
3056                InverseLink::Standard(_)
3057                    | InverseLink::Sas(_)
3058                    | InverseLink::BetaLogistic(_)
3059                    | InverseLink::Mixture(_)
3060                    | InverseLink::LatentCLogLog(_)
3061            ),
3062        };
3063        curved_family || self.has_link_wiggle() || self.has_baseline_time_wiggle()
3064    }
3065
3066    pub fn saved_prediction_runtime(&self) -> Result<SavedPredictionRuntime, FittedModelError> {
3067        self.payload().validate_payload_version()?;
3068        if matches!(
3069            self.predict_model_class(),
3070            PredictModelClass::BernoulliMarginalSlope | PredictModelClass::Survival
3071        ) {
3072            if let Some(runtime) = self.payload().score_warp_runtime.as_ref() {
3073                runtime.validate_exact_replay_contract().map_err(|err| {
3074                    FittedModelError::PayloadCorrupt {
3075                        reason: format!("saved anchored score-warp runtime is invalid: {err}"),
3076                    }
3077                })?;
3078            }
3079            if let Some(runtime) = self.payload().link_deviation_runtime.as_ref() {
3080                runtime.validate_exact_replay_contract().map_err(|err| {
3081                    FittedModelError::PayloadCorrupt {
3082                        reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
3083                    }
3084                })?;
3085            }
3086        }
3087        let runtime = SavedPredictionRuntime {
3088            model_class: self.predict_model_class(),
3089            likelihood: self.likelihood(),
3090            inverse_link: self.resolved_inverse_link()?,
3091            link_wiggle: self.saved_link_wiggle()?,
3092            baseline_time_wiggle: self.saved_baseline_time_wiggle()?,
3093            score_warp: self.payload().score_warp_runtime.clone(),
3094            link_deviation: self.payload().link_deviation_runtime.clone(),
3095            latent_z_rank_int_calibration: self.payload().latent_z_rank_int_calibration.clone(),
3096            latent_z_conditional_calibration: self
3097                .payload()
3098                .latent_z_conditional_calibration
3099                .clone(),
3100            influence_absorber_width: self.payload().influence_absorber_width,
3101        };
3102        if matches!(
3103            runtime.model_class,
3104            PredictModelClass::GaussianLocationScale
3105                | PredictModelClass::BinomialLocationScale
3106                | PredictModelClass::DispersionLocationScale
3107        ) {
3108            let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3109                FittedModelError::MissingField {
3110                    reason: "location-scale model is missing canonical fit_result payload"
3111                        .to_string(),
3112                }
3113            })?;
3114            validate_location_scale_saved_fit(
3115                fit,
3116                runtime.model_class,
3117                runtime.link_wiggle.as_ref(),
3118            )?;
3119        } else if matches!(runtime.model_class, PredictModelClass::Survival)
3120            && self
3121                .payload()
3122                .survival_likelihood
3123                .as_deref()
3124                .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"))
3125        {
3126            validate_survival_location_scale_saved_fit(
3127                self.payload(),
3128                runtime.link_wiggle.as_ref(),
3129            )?;
3130        } else if matches!(
3131            runtime.model_class,
3132            PredictModelClass::BernoulliMarginalSlope
3133        ) {
3134            let unified =
3135                self.payload()
3136                    .unified
3137                    .as_ref()
3138                    .ok_or_else(|| FittedModelError::MissingField {
3139                        reason: "marginal-slope model is missing unified fit payload; refit"
3140                            .to_string(),
3141                    })?;
3142            validate_marginal_slope_saved_fit(
3143                unified,
3144                runtime.score_warp.as_ref(),
3145                runtime.link_deviation.as_ref(),
3146                "unified",
3147            )?;
3148        } else if matches!(runtime.model_class, PredictModelClass::Survival)
3149            && self
3150                .payload()
3151                .survival_likelihood
3152                .as_deref()
3153                .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
3154        {
3155            let fit = self.payload().fit_result.as_ref().ok_or_else(|| {
3156                FittedModelError::MissingField {
3157                    reason: "survival marginal-slope model is missing canonical fit_result payload"
3158                        .to_string(),
3159                }
3160            })?;
3161            validate_survival_marginal_slope_saved_fit(
3162                fit,
3163                runtime.score_warp.as_ref(),
3164                runtime.link_deviation.as_ref(),
3165                "fit_result",
3166            )?;
3167        }
3168        Ok(runtime)
3169    }
3170
3171    pub fn saved_sas_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3172        let payload = self.payload();
3173        let raw = match &payload.family_state {
3174            FittedFamily::Standard {
3175                likelihood,
3176                sas_state,
3177                ..
3178            } if likelihood.is_binomial_sas() => {
3179                (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3180                    reason: "binomial-sas model is missing state in family_state.sas_state"
3181                        .to_string(),
3182                })?
3183            }
3184            FittedFamily::LocationScale {
3185                likelihood,
3186                base_link,
3187            } if likelihood.is_binomial_sas() => match base_link {
3188                Some(InverseLink::Sas(state)) => *state,
3189                _ => {
3190                    return Err(FittedModelError::MissingField {
3191                        reason: "binomial-sas location-scale model is missing SAS base_link state"
3192                            .to_string(),
3193                    });
3194                }
3195            },
3196            _ => return Ok(None),
3197        };
3198        state_from_sasspec(SasLinkSpec {
3199            initial_epsilon: raw.epsilon,
3200            initial_log_delta: raw.log_delta,
3201        })
3202        .map(Some)
3203        .map_err(|e| FittedModelError::PayloadCorrupt {
3204            reason: format!("invalid saved SAS link state: {e}"),
3205        })
3206    }
3207
3208    pub fn saved_beta_logistic_state(&self) -> Result<Option<SasLinkState>, FittedModelError> {
3209        let payload = self.payload();
3210        let raw = match &payload.family_state {
3211            FittedFamily::Standard {
3212                likelihood,
3213                sas_state,
3214                ..
3215            } if likelihood.is_binomial_beta_logistic() => {
3216                (*sas_state).ok_or_else(|| FittedModelError::MissingField {
3217                    reason:
3218                        "binomial-beta-logistic model is missing state in family_state.sas_state"
3219                            .to_string(),
3220                })?
3221            }
3222            FittedFamily::LocationScale {
3223                likelihood,
3224                base_link,
3225            } if likelihood.is_binomial_beta_logistic() => match base_link {
3226                Some(InverseLink::BetaLogistic(state)) => *state,
3227                _ => {
3228                    return Err(FittedModelError::MissingField {
3229                        reason:
3230                            "binomial-beta-logistic location-scale model is missing beta-logistic base_link state"
3231                                .to_string(),
3232                    });
3233                }
3234            },
3235            _ => return Ok(None),
3236        };
3237        state_from_beta_logisticspec(SasLinkSpec {
3238            initial_epsilon: raw.epsilon,
3239            initial_log_delta: raw.log_delta,
3240        })
3241        .map(Some)
3242        .map_err(|e| FittedModelError::PayloadCorrupt {
3243            reason: format!("invalid saved Beta-Logistic link state: {e}"),
3244        })
3245    }
3246
3247    pub fn saved_mixture_state(&self) -> Result<Option<MixtureLinkState>, FittedModelError> {
3248        let payload = self.payload();
3249        match &payload.family_state {
3250            FittedFamily::Standard {
3251                likelihood,
3252                mixture_state,
3253                ..
3254            } if likelihood.is_binomial_mixture() => mixture_state
3255                .clone()
3256                .ok_or_else(|| FittedModelError::MissingField {
3257                    reason: "binomial-mixture model is missing state in family_state.mixture_state"
3258                        .to_string(),
3259                })
3260                .map(Some),
3261            FittedFamily::LocationScale {
3262                likelihood,
3263                base_link,
3264            } if likelihood.is_binomial_mixture() => match base_link {
3265                Some(InverseLink::Mixture(state)) => Ok(Some(state.clone())),
3266                _ => Err(FittedModelError::MissingField {
3267                    reason:
3268                        "binomial-mixture location-scale model is missing mixture base_link state"
3269                            .to_string(),
3270                }),
3271            },
3272            _ => Ok(None),
3273        }
3274    }
3275
3276    pub fn saved_latent_cloglog_state(
3277        &self,
3278    ) -> Result<Option<LatentCLogLogState>, FittedModelError> {
3279        let payload = self.payload();
3280        match &payload.family_state {
3281            FittedFamily::Standard {
3282                likelihood,
3283                latent_cloglog_state,
3284                ..
3285            } if likelihood.is_latent_cloglog() => latent_cloglog_state
3286                .ok_or_else(|| FittedModelError::MissingField {
3287                    reason:
3288                        "latent-cloglog-binomial model is missing state in family_state.latent_cloglog_state"
3289                            .to_string(),
3290                })
3291                .map(Some),
3292            _ => Ok(None),
3293        }
3294    }
3295
3296    pub fn resolved_inverse_link(&self) -> Result<Option<InverseLink>, FittedModelError> {
3297        let stateful = if let Some(state) = self.saved_mixture_state()? {
3298            Some(InverseLink::Mixture(state))
3299        } else if let Some(state) = self.saved_latent_cloglog_state()? {
3300            Some(InverseLink::LatentCLogLog(state))
3301        } else if let Some(state) = self.saved_beta_logistic_state()? {
3302            Some(InverseLink::BetaLogistic(state))
3303        } else {
3304            self.saved_sas_state()?.map(InverseLink::Sas)
3305        };
3306        match &self.payload().family_state {
3307            FittedFamily::LocationScale { base_link, .. } => Ok(base_link.clone().or(stateful)),
3308            FittedFamily::Standard { link, .. } => {
3309                Ok(stateful.or_else(|| link.map(InverseLink::Standard)))
3310            }
3311            FittedFamily::MarginalSlope { base_link, .. } => Ok(Some(base_link.clone())),
3312            FittedFamily::Survival { .. }
3313            | FittedFamily::LatentSurvival { .. }
3314            | FittedFamily::LatentBinary { .. } => Ok(None),
3315            FittedFamily::TransformationNormal { .. } => Ok(None),
3316        }
3317    }
3318
3319    /// V∞ §5 coverage floor for the measure-jet extrapolation variance: a
3320    /// band level "covers" a query once its kernel mass reaches this fraction
3321    /// of that level's web-averaged support. Magic-by-default (no dial):
3322    /// 0.05 keeps the ε★ gate's bounded discontinuity at ≤ 5 % of the
3323    /// spectrum's total prior ignorance (see the monotonicity theorem in
3324    /// `terms/basis/measure_jet_predict.rs`) while still refusing credit
3325    /// for stray sub-floor kernel mass at levels finer than the first
3326    /// covering scale.
3327    const MEASURE_JET_COVERAGE_FLOOR: f64 = 0.05;
3328
3329    /// V∞ §5 producer: per-row measure-jet extrapolation variance on the η
3330    /// scale for a prediction batch (`docs/measure_jet_v_infinity.md`).
3331    ///
3332    /// For every frozen measure-jet term in `resolved_termspec` this prices
3333    /// the off-support ignorance of the fitted multiscale spectrum at each
3334    /// query row: support curve from the frozen nodes/masses/band
3335    /// ([`gam_terms::basis::measure_jet_support_curve`]), fitted per-scale
3336    /// amplitudes λ̂_ℓ read from the fit's `lambdas` through the replayed
3337    /// design's penalty layout, folded through
3338    /// [`gam_terms::basis::measure_jet_extrapolation_variance`] and scaled by
3339    /// the fit's coefficient-covariance scale φ̂ so the result sits on Vp's
3340    /// η-variance scale. Terms not yet frozen (no `frozen_quadrature` or
3341    /// non-`UserProvided` centers) are skipped with a warning. Returns
3342    /// `Ok(None)` when no measure-jet term contributes, so callers leave
3343    /// `PredictUncertaintyOptions::extrapolation_variance` untouched.
3344    ///
3345    /// `data` must be the RAW (unclipped) prediction matrix in prediction
3346    /// column order — clipping to the training ranges would freeze the
3347    /// distance signal at the hull and defeat the honesty contract — and
3348    /// `col_map` the prediction header → column map (the same map handed to
3349    /// the design builder). This is the minimal-plumbing producer seam: the
3350    /// option-building callers (CLI predict, FFI) hold exactly
3351    /// `(model, data, col_map)` at the point where they assemble
3352    /// `PredictUncertaintyOptions`, and the fusion in
3353    /// `predict_gamwith_uncertainty` adds the array AFTER its multiplicative
3354    /// inflations: `Var_total = Var_Vp·inflation + Var_extrap`.
3355    pub fn measure_jet_extrapolation_variance(
3356        &self,
3357        data: ndarray::ArrayView2<'_, f64>,
3358        col_map: &HashMap<String, usize>,
3359    ) -> Result<Option<Array1<f64>>, FittedModelError> {
3360        use gam_terms::basis::{CenterStrategy, MeasureJetExtrapolationSpectrum, PenaltySource};
3361        use gam_terms::smooth::build_term_collection_design;
3362        use gam_terms::smooth::SmoothBasisSpec;
3363        let Some(saved_spec) = self.resolved_termspec.as_ref() else {
3364            return Ok(None);
3365        };
3366        if data.nrows() == 0
3367            || !saved_spec
3368                .smooth_terms
3369                .iter()
3370                .any(|t| matches!(t.basis, SmoothBasisSpec::MeasureJet { .. }))
3371        {
3372            return Ok(None);
3373        }
3374        let fit = self
3375            .fit_result
3376            .as_ref()
3377            .ok_or_else(|| FittedModelError::MissingField {
3378                reason: "measure-jet extrapolation variance requires the canonical \
3379                    fit_result payload; refit"
3380                    .to_string(),
3381            })?;
3382        let spec = crate::survival::predict::resolve_termspec_for_prediction(
3383            &self.resolved_termspec,
3384            self.training_headers.as_ref(),
3385            col_map,
3386            "resolved_termspec",
3387        )
3388        .map_err(|e| FittedModelError::SchemaMismatch {
3389            reason: format!("measure-jet extrapolation variance: {e}"),
3390        })?;
3391        // Penalty layout replay: the global penalty indices (→ `fit.lambdas`)
3392        // come from the SAME design builder the predict pipeline uses. One
3393        // probe row suffices — for a frozen spec the penalty layout is
3394        // row-count-invariant (centers, masses, band, and identifiability
3395        // transforms all replay verbatim) — keeping this O(centers²) instead
3396        // of duplicating the full O(rows·centers) prediction design build.
3397        let probe = data.slice(ndarray::s![0..1, ..]);
3398        let design = build_term_collection_design(probe, &spec).map_err(|e| {
3399            FittedModelError::SchemaMismatch {
3400                reason: format!(
3401                    "measure-jet extrapolation variance: penalty-layout replay failed: {e}"
3402                ),
3403            }
3404        })?;
3405        let lambdas = &fit.lambdas;
3406        // λ̂ are fitted on Frobenius-normalized penalties. The term loop
3407        // unnormalizes them to physical precisions before pricing; multiplying
3408        // by the coefficient-covariance scale puts Var_extrap on the same
3409        // η-variance scale as Vp.
3410        let phi_scale = fit.coefficient_covariance_scale();
3411        let mut total = Array1::<f64>::zeros(data.nrows());
3412        let mut contributed = false;
3413        for term in &spec.smooth_terms {
3414            let SmoothBasisSpec::MeasureJet {
3415                feature_cols,
3416                spec: mj,
3417                input_scales,
3418            } = &term.basis
3419            else {
3420                continue;
3421            };
3422            let (Some(frozen), CenterStrategy::UserProvided(centers)) =
3423                (mj.frozen_quadrature.as_ref(), &mj.center_strategy)
3424            else {
3425                log::warn!(
3426                    "measure-jet term '{}' is not frozen (UserProvided centers + frozen \
3427                    quadrature); skipping its extrapolation variance",
3428                    term.name
3429                );
3430                continue;
3431            };
3432            let n_levels = frozen.eps_band.len();
3433            // λ̂ per level from the replayed layout: per-scale candidates carry
3434            // `PenaltySource::Other("measure_jet_scale_ℓ")`; fused
3435            // (pinned-order) mode carries one Primary charged once for the
3436            // whole band. The DoublePenaltyNullspace ridge is EXCLUDED — it shrinks
3437            // coefficients, it is not a scale amplitude, and counting it would
3438            // double-charge the spectrum.
3439            let read_lambda = |global_index: usize| -> Result<f64, FittedModelError> {
3440                lambdas
3441                    .get(global_index)
3442                    .copied()
3443                    .ok_or_else(|| FittedModelError::SchemaMismatch {
3444                        reason: format!(
3445                            "measure-jet term '{}': penalty global index {global_index} out \
3446                            of bounds for {} fitted lambdas",
3447                            term.name,
3448                            lambdas.len()
3449                        ),
3450                    })
3451            };
3452            let mut per_scale: Vec<(usize, f64)> = Vec::new();
3453            let mut fused: Option<f64> = None;
3454            for info in &design.penaltyinfo {
3455                if info.termname.as_deref() != Some(term.name.as_str()) {
3456                    continue;
3457                }
3458                match &info.penalty.source {
3459                    PenaltySource::Other(label) => {
3460                        if let Some(level_txt) = label.strip_prefix("measure_jet_scale_") {
3461                            let level: usize = level_txt.parse().map_err(|_| {
3462                                FittedModelError::SchemaMismatch {
3463                                    reason: format!(
3464                                        "measure-jet term '{}': unparseable penalty label \
3465                                        '{label}'",
3466                                        term.name
3467                                    ),
3468                                }
3469                            })?;
3470                            per_scale.push((level, read_lambda(info.global_index)?));
3471                        }
3472                    }
3473                    PenaltySource::Primary => {
3474                        fused = Some(read_lambda(info.global_index)?);
3475                    }
3476                    _ => {}
3477                }
3478            }
3479            let mut lambda_phys = Vec::with_capacity(n_levels);
3480            let spectrum = if per_scale.is_empty() {
3481                let Some(lam) = fused else {
3482                    log::warn!(
3483                        "measure-jet term '{}' has no fitted amplitude in the penalty \
3484                        layout; skipping its extrapolation variance",
3485                        term.name
3486                    );
3487                    continue;
3488                };
3489                let Some(c) = frozen.fused_penalty_normalization_scale else {
3490                    log::warn!(
3491                        "measure-jet term '{}' is missing the fused penalty normalization scale; \
3492                        skipping its extrapolation variance",
3493                        term.name
3494                    );
3495                    continue;
3496                };
3497                MeasureJetExtrapolationSpectrum::Fused(lam / c)
3498            } else {
3499                per_scale.sort_by_key(|&(level, _)| level);
3500                let levels_complete = per_scale.len() == n_levels
3501                    && per_scale
3502                        .iter()
3503                        .enumerate()
3504                        .all(|(i, &(level, _))| level == i);
3505                if !levels_complete {
3506                    log::warn!(
3507                        "measure-jet term '{}': {} fitted per-scale amplitudes for {} band \
3508                        scales; skipping its extrapolation variance",
3509                        term.name,
3510                        per_scale.len(),
3511                        n_levels
3512                    );
3513                    continue;
3514                }
3515                if frozen.penalty_normalization_scales.len() != n_levels {
3516                    log::warn!(
3517                        "measure-jet term '{}': {} frozen penalty normalization scales for {} \
3518                        band scales; skipping its extrapolation variance",
3519                        term.name,
3520                        frozen.penalty_normalization_scales.len(),
3521                        n_levels
3522                    );
3523                    continue;
3524                }
3525                lambda_phys.extend(
3526                    per_scale
3527                        .iter()
3528                        .map(|&(level, lam)| lam / frozen.penalty_normalization_scales[level]),
3529                );
3530                MeasureJetExtrapolationSpectrum::PerLevel(&lambda_phys)
3531            };
3532            // Query rows in the frozen geometry's coordinates: select the
3533            // term's axes and replay the per-axis standardization exactly as
3534            // the build dispatch does (divide by σ_a when input_scales is
3535            // Some; the persisted centers are already post-standardization).
3536            let mut queries = Array2::<f64>::zeros((data.nrows(), feature_cols.len()));
3537            for (j, &col) in feature_cols.iter().enumerate() {
3538                if col >= data.ncols() {
3539                    return Err(FittedModelError::SchemaMismatch {
3540                        reason: format!(
3541                            "measure-jet term '{}': prediction column {col} out of bounds \
3542                            for {} data columns",
3543                            term.name,
3544                            data.ncols()
3545                        ),
3546                    });
3547                }
3548                queries.column_mut(j).assign(&data.column(col));
3549            }
3550            if let Some(scales) = input_scales {
3551                if scales.len() != feature_cols.len() {
3552                    return Err(FittedModelError::SchemaMismatch {
3553                        reason: format!(
3554                            "measure-jet term '{}': {} input scales for {} axes",
3555                            term.name,
3556                            scales.len(),
3557                            feature_cols.len()
3558                        ),
3559                    });
3560                }
3561                for (j, &scale) in scales.iter().enumerate() {
3562                    queries.column_mut(j).mapv_inplace(|v| v / scale);
3563                }
3564            }
3565            let support = gam_terms::basis::measure_jet_support_curve(
3566                queries.view(),
3567                centers.view(),
3568                frozen.masses.view(),
3569                &frozen.eps_band,
3570            )
3571            .map_err(|e| FittedModelError::SchemaMismatch {
3572                reason: format!(
3573                    "measure-jet term '{}': support curve failed: {e}",
3574                    term.name
3575                ),
3576            })?;
3577            for i in 0..data.nrows() {
3578                let v = gam_terms::basis::measure_jet_extrapolation_variance(
3579                    support.row(i),
3580                    &frozen.eps_band,
3581                    &frozen.support_means,
3582                    spectrum,
3583                    Self::MEASURE_JET_COVERAGE_FLOOR,
3584                )
3585                .map_err(|e| FittedModelError::SchemaMismatch {
3586                    reason: format!(
3587                        "measure-jet term '{}': extrapolation variance failed: {e}",
3588                        term.name
3589                    ),
3590                })?;
3591                total[i] += phi_scale * v;
3592            }
3593            contributed = true;
3594        }
3595        Ok(contributed.then_some(total))
3596    }
3597
3598    /// Access the unified fit result, if stored.
3599    pub fn unified(&self) -> Option<&UnifiedFitResult> {
3600        self.payload().unified.as_ref()
3601    }
3602
3603    pub fn load_from_path(path: &Path) -> Result<Self, FittedModelError> {
3604        let payload = fs::read_to_string(path).map_err(|e| FittedModelError::PayloadCorrupt {
3605            reason: format!("failed to read model '{}': {e}", path.display()),
3606        })?;
3607        let model: Self =
3608            serde_json::from_str(&payload).map_err(|e| FittedModelError::PayloadCorrupt {
3609                reason: format!("failed to parse model json: {e}"),
3610            })?;
3611        let model = model.with_synchronized_stateful_link_metadata();
3612        model.validate_for_persistence()?;
3613        model.validate_numeric_finiteness()?;
3614        Ok(model)
3615    }
3616
3617    pub fn save_to_path(&self, path: &Path) -> Result<(), FittedModelError> {
3618        let normalized = self.clone().with_synchronized_stateful_link_metadata();
3619        normalized.validate_for_persistence()?;
3620        normalized.validate_numeric_finiteness()?;
3621        // Write to a sibling temp file, fsync, then rename into place so a
3622        // crash mid-write never corrupts the user's existing saved fit.
3623        // Concurrent writers to the same path each have a distinct temp
3624        // suffix (pid + nanos), so neither stomps the other's in-flight
3625        // bytes; the rename winner is last-rename-wins, which is the
3626        // expected last-write-wins semantics for a single canonical path.
3627        let parent = path.parent().unwrap_or_else(|| Path::new("."));
3628        let file_name = path
3629            .file_name()
3630            .and_then(|s| s.to_str())
3631            .unwrap_or("model.json");
3632        let pid = std::process::id();
3633        let nanos = std::time::SystemTime::now()
3634            .duration_since(std::time::UNIX_EPOCH)
3635            .map(|d| d.as_nanos())
3636            .unwrap_or(0);
3637        let tmp = parent.join(format!(".{file_name}.tmp.{pid}.{nanos:x}"));
3638        let file = fs::File::create(&tmp).map_err(|e| FittedModelError::PayloadCorrupt {
3639            reason: format!("failed to write model '{}': {e}", tmp.display()),
3640        })?;
3641        let mut writer = std::io::BufWriter::new(file);
3642        let ser_result = serde_json::to_writer(&mut writer, &normalized);
3643        if let Err(e) = ser_result {
3644            // Best-effort temp cleanup on serialization failure. flush
3645            // returns io::Result<()>; discarding via `.ok()` is enough.
3646            std::io::Write::flush(&mut writer).ok();
3647            drop(writer);
3648            fs::remove_file(&tmp).ok();
3649            return Err(FittedModelError::PayloadCorrupt {
3650                reason: format!("failed to serialize model: {e}"),
3651            });
3652        }
3653        std::io::Write::flush(&mut writer).map_err(|e| FittedModelError::PayloadCorrupt {
3654            reason: format!("failed to write model '{}': {e}", tmp.display()),
3655        })?;
3656        // Recover the underlying File to fsync its contents before rename.
3657        let inner = writer
3658            .into_inner()
3659            .map_err(|e| FittedModelError::PayloadCorrupt {
3660                reason: format!("failed to flush model '{}': {}", tmp.display(), e.error()),
3661            })?;
3662        inner.sync_all().ok();
3663        drop(inner);
3664        if let Err(e) = fs::rename(&tmp, path) {
3665            fs::remove_file(&tmp).ok();
3666            return Err(FittedModelError::PayloadCorrupt {
3667                reason: format!("failed to publish model '{}': {e}", path.display()),
3668            });
3669        }
3670        // fsync the parent directory so the rename itself is durable
3671        // across a crash; without this, the rename can be lost even though
3672        // file contents reached disk. Best-effort on platforms that don't
3673        // support opening a directory for fsync.
3674        if let Ok(d) = fs::File::open(parent) {
3675            d.sync_all().ok();
3676        }
3677        Ok(())
3678    }
3679
3680    pub fn require_data_schema(&self) -> Result<&DataSchema, FittedModelError> {
3681        self.data_schema
3682            .as_ref()
3683            .ok_or_else(|| FittedModelError::MissingField {
3684                reason: "model is missing data_schema; refit".to_string(),
3685            })
3686    }
3687
3688    /// Restore the exact in-memory spline-scan fit from a scan-bearing
3689    /// payload (#1030/#1034). `Ok(None)` for dense models; the returned
3690    /// `predict` replays the training Gaussian bridge bit-for-bit.
3691    pub fn saved_spline_scan(
3692        &self,
3693    ) -> Result<Option<(&str, gam_solve::spline_scan::SplineScanFit)>, FittedModelError> {
3694        let Some(saved) = self.spline_scan.as_ref() else {
3695            return Ok(None);
3696        };
3697        let fit = gam_solve::spline_scan::SplineScanFit::from_state(&saved.state)
3698            .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3699        Ok(Some((saved.feature_column.as_str(), fit)))
3700    }
3701
3702    /// Restore the in-memory residual-cascade fit from a cascade-bearing
3703    /// payload (#1032). `Ok(None)` for non-cascade models; the returned fit
3704    /// replays the multilevel Wendland-frame posterior for the d ∈ {2, 3}
3705    /// feature columns at each predict point.
3706    pub fn saved_residual_cascade(
3707        &self,
3708    ) -> Result<
3709        Option<(
3710            &[String],
3711            gam_solve::residual_cascade::ResidualCascadeFit,
3712        )>,
3713        FittedModelError,
3714    > {
3715        let Some(saved) = self.residual_cascade.as_ref() else {
3716            return Ok(None);
3717        };
3718        let fit = gam_solve::residual_cascade::ResidualCascadeFit::from_state(&saved.state)
3719            .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3720        Ok(Some((saved.feature_columns.as_slice(), fit)))
3721    }
3722
3723    /// Grouping columns eligible for LENIENT unseen-level encoding at predict
3724    /// time (the held-out-group policy: an unseen group is encoded as an
3725    /// out-of-vocabulary code and shrunk toward the population mean).
3726    ///
3727    /// This is the whitelist the predict/`check` encode paths pass to
3728    /// [`UnseenCategoryPolicy::encode_unknown_for_columns`]. It intentionally
3729    /// covers ONLY genuine random effects (`group(g)`/`re(g)`/`s(g, bs="re")`).
3730    /// A FIXED categorical factor — a bare `+ g` OR an explicit `factor(g)` —
3731    /// is auto-promoted to a penalized random block internally but is still a
3732    /// fixed parametric factor: an unseen level of it must reach the strict
3733    /// schema encode and raise a `SchemaMismatchError` rather than be silently
3734    /// averaged to the factor's centering point (#2102/#2137). Such terms carry
3735    /// `lenient_unseen == false` and are excluded here so they hit the strict
3736    /// `UnseenCategoryPolicy::Error` arm.
3737    pub fn random_effect_group_columns(&self) -> HashSet<String> {
3738        let Some(training_headers) = self.training_headers.as_ref() else {
3739            return HashSet::new();
3740        };
3741        let mut out = HashSet::<String>::new();
3742        for spec in self.saved_term_specs() {
3743            for term in &spec.random_effect_terms {
3744                if !term.lenient_unseen {
3745                    continue;
3746                }
3747                if let Some(name) = training_headers.get(term.feature_col) {
3748                    out.insert(name.clone());
3749                }
3750            }
3751        }
3752        out
3753    }
3754
3755    /// Frozen level vocabularies for FIXED-factor terms (`factor(g)` or a bare
3756    /// `+ g`, i.e. `lenient_unseen == false`) whose feature column is *numeric*
3757    /// in the data schema.
3758    ///
3759    /// A string factor is a `Categorical` schema column, so the strict schema
3760    /// re-encode already rejects (and `check` reports) an out-of-vocabulary
3761    /// label. A numeric-coded `factor(year)`, however, reaches the model as a
3762    /// `Continuous`/`Binary` column with no categorical schema, so the encode
3763    /// path has no level set to validate against — the unseen-level guard is
3764    /// silently skipped (#2137). This exposes each such column's frozen numeric
3765    /// vocabulary (canonical `f64` bit patterns, signed-zero/NaN normalized) so
3766    /// the `check`/`predict` schema layer can enforce the same fixed-factor
3767    /// contract the design operator (`build_random_effect_block`) enforces.
3768    ///
3769    /// Only terms with concrete `frozen_levels` (captured at fit) and the full
3770    /// one-hot block (`!drop_first_level`, so the frozen set is the complete
3771    /// training vocabulary) are returned, matching the operator's strict gate.
3772    pub fn numeric_fixed_factor_vocabularies(&self) -> Vec<(String, HashSet<u64>)> {
3773        let Some(training_headers) = self.training_headers.as_ref() else {
3774            return Vec::new();
3775        };
3776        let Some(schema) = self.data_schema.as_ref() else {
3777            return Vec::new();
3778        };
3779        let mut out = Vec::<(String, HashSet<u64>)>::new();
3780        for spec in self.saved_term_specs() {
3781            for term in &spec.random_effect_terms {
3782                if term.lenient_unseen || term.drop_first_level {
3783                    continue;
3784                }
3785                let Some(levels) = term.frozen_levels.as_ref() else {
3786                    continue;
3787                };
3788                let Some(name) = training_headers.get(term.feature_col) else {
3789                    continue;
3790                };
3791                // Skip string factors: they are Categorical in the schema and
3792                // are already validated by the typed encode.
3793                let is_numeric = schema
3794                    .columns
3795                    .iter()
3796                    .find(|c| &c.name == name)
3797                    .map(|c| {
3798                        matches!(
3799                            c.kind,
3800                            ColumnKindTag::Continuous | ColumnKindTag::Binary
3801                        )
3802                    })
3803                    .unwrap_or(false);
3804                if !is_numeric {
3805                    continue;
3806                }
3807                let vocab: HashSet<u64> = levels
3808                    .iter()
3809                    .map(|&b| gam_data::canonical_level_bits(f64::from_bits(b)))
3810                    .collect();
3811                out.push((name.clone(), vocab));
3812            }
3813        }
3814        out
3815    }
3816
3817    pub fn validate_for_persistence(&self) -> Result<(), FittedModelError> {
3818        // Hard version gate. The struct's ~40 Option<T> fields carry
3819        // `#[serde(default)]`, which is by design forward-compatible: old
3820        // payloads missing a new optional field decode with `None`. BUT:
3821        // when a new CLI release adds a required field for some family_state
3822        // (enforced below), an older model loaded by the newer CLI would have
3823        // `None` in that slot and the family-specific branch below would
3824        // correctly reject it — unless the new field also happens to slot
3825        // under a branch that hasn't been touched. Conversely, a newer model
3826        // loaded by an older CLI silently drops fields the older struct
3827        // doesn't know about. Both directions are silent-drift hazards. We
3828        // close them with an exact-version check anchored to the canonical
3829        // MODEL_PAYLOAD_VERSION constant — every payload must round-trip
3830        // identically between writers and readers running the same schema.
3831        self.validate_payload_version()?;
3832        if let Some(scan) = self.spline_scan.as_ref() {
3833            // Spline-scan representation (#1030/#1034): the smoother state IS
3834            // the fit. It is exclusive with the dense representation, only
3835            // standard Gaussian-identity models can carry it, and the state
3836            // must restore cleanly so predict never sees a corrupt snapshot.
3837            if self.fit_result.is_some() || self.unified.is_some() {
3838                return Err(FittedModelError::SchemaMismatch {
3839                    reason: "spline-scan model must not also carry a dense fit_result/unified \
3840                             payload; the representations are mutually exclusive"
3841                        .to_string(),
3842                });
3843            }
3844            if self.model_kind != ModelKind::Standard
3845                || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
3846            {
3847                return Err(FittedModelError::SchemaMismatch {
3848                    reason: format!(
3849                        "spline-scan representation requires a standard Gaussian-identity model; \
3850                         got model_kind={:?}, likelihood={:?}",
3851                        self.model_kind,
3852                        self.family_state.likelihood()
3853                    ),
3854                });
3855            }
3856            if scan.feature_column.is_empty() {
3857                return Err(FittedModelError::MissingField {
3858                    reason: "spline-scan model is missing its feature column name; refit"
3859                        .to_string(),
3860                });
3861            }
3862            gam_solve::spline_scan::SplineScanFit::from_state(&scan.state)
3863                .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3864            // A scan model carries NO dense design, so the dense-path
3865            // requirements below (resolved_termspec, fit_result finiteness,
3866            // family-specific blocks) do not apply. Enforce only the metadata
3867            // predict actually consumes — the feature column resolves against
3868            // training_headers / data_schema — then accept.
3869            if self.data_schema.is_none() {
3870                return Err(FittedModelError::MissingField {
3871                    reason: "spline-scan model is missing data_schema; refit".to_string(),
3872                });
3873            }
3874            if self.training_headers.is_none() {
3875                return Err(FittedModelError::MissingField {
3876                    reason: "spline-scan model is missing training_headers; refit".to_string(),
3877                });
3878            }
3879            return Ok(());
3880        } else if let Some(cascade) = self.residual_cascade.as_ref() {
3881            // Residual-cascade representation (#1032): a multilevel
3882            // Wendland-frame model for a scattered d ∈ {2,3} Gaussian smooth.
3883            // Exclusive with the dense representation and with the scan.
3884            if self.spline_scan.is_some() || self.fit_result.is_some() || self.unified.is_some() {
3885                return Err(FittedModelError::SchemaMismatch {
3886                    reason: "residual-cascade model must not also carry spline_scan / \
3887                             fit_result / unified payloads; the representations are \
3888                             mutually exclusive"
3889                        .to_string(),
3890                });
3891            }
3892            if self.model_kind != ModelKind::Standard
3893                || self.family_state.likelihood() != LikelihoodSpec::gaussian_identity()
3894            {
3895                return Err(FittedModelError::SchemaMismatch {
3896                    reason: format!(
3897                        "residual-cascade representation requires a standard Gaussian-identity \
3898                         model; got model_kind={:?}, likelihood={:?}",
3899                        self.model_kind,
3900                        self.family_state.likelihood()
3901                    ),
3902                });
3903            }
3904            if cascade.feature_columns.is_empty()
3905                || !(2..=3).contains(&cascade.feature_columns.len())
3906            {
3907                return Err(FittedModelError::MissingField {
3908                    reason: format!(
3909                        "residual-cascade model needs 2 or 3 feature columns; got {}; refit",
3910                        cascade.feature_columns.len()
3911                    ),
3912                });
3913            }
3914            gam_solve::residual_cascade::ResidualCascadeFit::from_state(&cascade.state)
3915                .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3916            if self.data_schema.is_none() {
3917                return Err(FittedModelError::MissingField {
3918                    reason: "residual-cascade model is missing data_schema; refit".to_string(),
3919                });
3920            }
3921            if self.training_headers.is_none() {
3922                return Err(FittedModelError::MissingField {
3923                    reason: "residual-cascade model is missing training_headers; refit".to_string(),
3924                });
3925            }
3926            return Ok(());
3927        } else if self.fit_result.is_none() {
3928            return Err(FittedModelError::MissingField {
3929                reason: "model is missing canonical fit_result payload; refit".to_string(),
3930            });
3931        }
3932        if self.data_schema.is_none() {
3933            return Err(FittedModelError::MissingField {
3934                reason: "model is missing data_schema; refit".to_string(),
3935            });
3936        }
3937        if self.training_headers.is_none() {
3938            return Err(FittedModelError::MissingField {
3939                reason: "model is missing training_headers; refit to guarantee stable feature mapping at prediction time"
3940                    .to_string(),
3941            });
3942        }
3943        let spec = self.resolved_termspec.as_ref().ok_or_else(|| {
3944            FittedModelError::MissingField {
3945                reason: "model is missing resolved_termspec; refit to guarantee train/predict design consistency"
3946                    .to_string(),
3947            }
3948        })?;
3949        validate_frozen_term_collectionspec(spec, "resolved_termspec")?;
3950
3951        if self.formula_noise.is_some() && self.resolved_termspec_noise.is_none() {
3952            return Err(FittedModelError::MissingField {
3953                reason: "model defines formula_noise but is missing resolved_termspec_noise; refit"
3954                    .to_string(),
3955            });
3956        }
3957        if let Some(spec_noise) = self.resolved_termspec_noise.as_ref() {
3958            validate_frozen_term_collectionspec(spec_noise, "resolved_termspec_noise")?;
3959        }
3960        if matches!(self.family_state, FittedFamily::TransformationNormal { .. }) {
3961            let score = self.transformation_score_calibration.ok_or_else(|| {
3962                FittedModelError::MissingField {
3963                    reason: "transformation-normal model is missing transformation_score_calibration; refit"
3964                        .to_string(),
3965                }
3966            })?;
3967            score.validate("transformation-normal model")?;
3968        }
3969        if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
3970            if self.formula_logslope.is_none() {
3971                return Err(FittedModelError::MissingField {
3972                    reason: "marginal-slope model is missing formula_logslope; refit".to_string(),
3973                });
3974            }
3975            if self.z_column.is_none() {
3976                return Err(FittedModelError::MissingField {
3977                    reason: "marginal-slope model is missing z_column; refit".to_string(),
3978                });
3979            }
3980            let z_normalization =
3981                self.latent_z_normalization
3982                    .ok_or_else(|| FittedModelError::MissingField {
3983                        reason: "marginal-slope model is missing latent_z_normalization; refit"
3984                            .to_string(),
3985                    })?;
3986            z_normalization.validate("marginal-slope model")?;
3987            let latent_measure =
3988                self.latent_measure
3989                    .as_ref()
3990                    .ok_or_else(|| FittedModelError::MissingField {
3991                        reason: "marginal-slope model is missing latent_measure; refit".to_string(),
3992                    })?;
3993            latent_measure
3994                .validate("marginal-slope model latent_measure")
3995                .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
3996            if self.marginal_baseline.is_none() || self.logslope_baseline.is_none() {
3997                return Err(FittedModelError::MissingField {
3998                    reason: "marginal-slope model is missing baseline offsets; refit".to_string(),
3999                });
4000            }
4001            if self.resolved_termspec_logslope.as_ref().is_none() {
4002                return Err(FittedModelError::MissingField {
4003                    reason: "marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
4004                        .to_string(),
4005                });
4006            }
4007            match self.family_state.frailty() {
4008                Some(FrailtySpec::None)
4009                | Some(FrailtySpec::GaussianShift {
4010                    sigma_fixed: Some(_),
4011                }) => {}
4012                Some(FrailtySpec::GaussianShift { sigma_fixed: None }) => {
4013                    return Err(FittedModelError::IncompatibleConfig {
4014                        reason: "marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
4015                            .to_string(),
4016                    });
4017                }
4018                Some(FrailtySpec::HazardMultiplier { .. }) => {
4019                    return Err(FittedModelError::IncompatibleConfig {
4020                        reason: "marginal-slope model does not support HazardMultiplier frailty"
4021                            .to_string(),
4022                    });
4023                }
4024                None => {
4025                    return Err(FittedModelError::MissingField {
4026                        reason: "marginal-slope model is missing family_state.frailty; refit"
4027                            .to_string(),
4028                    });
4029                }
4030            }
4031        }
4032
4033        if let FittedFamily::Survival {
4034            survival_likelihood,
4035            frailty,
4036            ..
4037        } = &self.family_state
4038        {
4039            if matches!(
4040                survival_likelihood.as_deref(),
4041                Some("latent") | Some("latent-binary")
4042            ) {
4043                return Err(FittedModelError::SchemaMismatch {
4044                    reason: "latent hazard-window models must persist explicit family_state metadata, not generic survival metadata"
4045                        .to_string(),
4046                });
4047            }
4048            if survival_likelihood.as_deref() == Some("marginal-slope") {
4049                if self.formula_logslope.is_none() {
4050                    return Err(FittedModelError::MissingField {
4051                        reason: "survival marginal-slope model is missing formula_logslope; refit"
4052                            .to_string(),
4053                    });
4054                }
4055                if self.z_column.is_none() {
4056                    return Err(FittedModelError::MissingField {
4057                        reason: "survival marginal-slope model is missing z_column; refit"
4058                            .to_string(),
4059                    });
4060                }
4061                let z_normalization =
4062                    self.latent_z_normalization
4063                        .ok_or_else(|| {
4064                            FittedModelError::MissingField {
4065                        reason:
4066                            "survival marginal-slope model is missing latent_z_normalization; refit"
4067                                .to_string(),
4068                    }
4069                        })?;
4070                z_normalization.validate("survival marginal-slope model")?;
4071                let latent_measure =
4072                    self.latent_measure
4073                        .as_ref()
4074                        .ok_or_else(|| FittedModelError::MissingField {
4075                            reason:
4076                                "survival marginal-slope model is missing latent_measure; refit"
4077                                    .to_string(),
4078                        })?;
4079                latent_measure
4080                    .validate("survival marginal-slope model latent_measure")
4081                    .map_err(|reason| FittedModelError::PayloadCorrupt { reason })?;
4082                if self.logslope_baseline.is_none() {
4083                    return Err(FittedModelError::MissingField {
4084                        reason: "survival marginal-slope model is missing logslope_baseline; refit"
4085                            .to_string(),
4086                    });
4087                }
4088                if self.resolved_termspec_logslope.as_ref().is_none() {
4089                    return Err(FittedModelError::MissingField {
4090                        reason: "survival marginal-slope model is missing resolved_termspec_logslope for the logslope surface"
4091                            .to_string(),
4092                    });
4093                }
4094                match frailty {
4095                    FrailtySpec::None
4096                    | FrailtySpec::GaussianShift {
4097                        sigma_fixed: Some(_),
4098                    } => {}
4099                    FrailtySpec::GaussianShift { sigma_fixed: None } => {
4100                        return Err(FittedModelError::IncompatibleConfig {
4101                            reason: "survival marginal-slope model requires a fixed GaussianShift sigma in family_state.frailty"
4102                                .to_string(),
4103                        });
4104                    }
4105                    FrailtySpec::HazardMultiplier { .. } => {
4106                        return Err(FittedModelError::IncompatibleConfig {
4107                            reason: "survival marginal-slope model does not support HazardMultiplier frailty"
4108                                .to_string(),
4109                        });
4110                    }
4111                }
4112            } else if !matches!(frailty, FrailtySpec::None) {
4113                return Err(FittedModelError::IncompatibleConfig {
4114                    reason:
4115                        "non-marginal survival models do not currently persist a frailty modifier"
4116                            .to_string(),
4117                });
4118            }
4119            // Non-latent survival predict reconstructs the baseline-time
4120            // basis via `load_survival_time_basis_config_from_model` and
4121            // anchors that basis at `survival_time_anchor`; both are
4122            // required for the saved model to be loadable. The CLI's
4123            // marginal-slope+time-wiggle save path previously dropped one or
4124            // the other on partial-write, producing models that loaded but
4125            // would panic at the first predict. Enforce both before persisting.
4126            if self.survival_time_basis.is_none() {
4127                return Err(FittedModelError::MissingField {
4128                    reason: "survival model is missing survival_time_basis; refit to persist the baseline-time basis configuration".to_string(),
4129                });
4130            }
4131            if self.survival_time_anchor.is_none() {
4132                return Err(FittedModelError::MissingField {
4133                    reason: "survival model is missing survival_time_anchor; refit to persist the baseline-time anchor".to_string(),
4134                });
4135            }
4136        }
4137        if let FittedFamily::LatentSurvival { frailty } = &self.family_state {
4138            match frailty {
4139                FrailtySpec::HazardMultiplier {
4140                    sigma_fixed: Some(_),
4141                    ..
4142                } => {}
4143                FrailtySpec::HazardMultiplier {
4144                    sigma_fixed: None, ..
4145                } => {
4146                    return Err(FittedModelError::IncompatibleConfig {
4147                        reason: "latent survival model requires a fixed HazardMultiplier sigma in family_state.frailty"
4148                            .to_string(),
4149                    });
4150                }
4151                FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
4152                    return Err(FittedModelError::IncompatibleConfig {
4153                        reason: "latent survival model requires a fixed HazardMultiplier frailty specification"
4154                            .to_string(),
4155                    });
4156                }
4157            }
4158            if self.survival_likelihood.as_deref() != Some("latent") {
4159                return Err(FittedModelError::SchemaMismatch {
4160                    reason: "latent survival model must persist survival_likelihood=latent"
4161                        .to_string(),
4162                });
4163            }
4164        }
4165        if let FittedFamily::LatentBinary { frailty } = &self.family_state {
4166            match frailty {
4167                FrailtySpec::HazardMultiplier {
4168                    sigma_fixed: Some(_),
4169                    ..
4170                } => {}
4171                FrailtySpec::HazardMultiplier {
4172                    sigma_fixed: None, ..
4173                } => {
4174                    return Err(FittedModelError::IncompatibleConfig {
4175                        reason: "latent binary model requires a fixed HazardMultiplier sigma in family_state.frailty"
4176                            .to_string(),
4177                    });
4178                }
4179                FrailtySpec::GaussianShift { .. } | FrailtySpec::None => {
4180                    return Err(FittedModelError::IncompatibleConfig {
4181                        reason: "latent binary model requires a fixed HazardMultiplier frailty specification"
4182                            .to_string(),
4183                    });
4184                }
4185            }
4186            if self.survival_likelihood.as_deref() != Some("latent-binary") {
4187                return Err(FittedModelError::SchemaMismatch {
4188                    reason: "latent binary model must persist survival_likelihood=latent-binary"
4189                        .to_string(),
4190                });
4191            }
4192        }
4193
4194        let family_likelihood = match &self.family_state {
4195            FittedFamily::Standard { likelihood, .. }
4196            | FittedFamily::LocationScale { likelihood, .. }
4197            | FittedFamily::MarginalSlope { likelihood, .. }
4198            | FittedFamily::Survival { likelihood, .. }
4199            | FittedFamily::TransformationNormal { likelihood, .. } => Some(likelihood),
4200            FittedFamily::LatentSurvival { .. } | FittedFamily::LatentBinary { .. } => None,
4201        };
4202        let is_standard_or_location_scale = matches!(
4203            self.family_state,
4204            FittedFamily::Standard { .. } | FittedFamily::LocationScale { .. }
4205        );
4206        if is_standard_or_location_scale
4207            && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_sas)
4208        {
4209            self.saved_sas_state()?;
4210        }
4211        if is_standard_or_location_scale
4212            && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_beta_logistic)
4213        {
4214            self.saved_beta_logistic_state()?;
4215        }
4216        if is_standard_or_location_scale
4217            && family_likelihood.is_some_and(LikelihoodSpec::is_binomial_mixture)
4218        {
4219            self.saved_mixture_state()?;
4220        }
4221        if matches!(self.family_state, FittedFamily::Standard { .. })
4222            && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
4223        {
4224            self.saved_latent_cloglog_state()?;
4225        }
4226        if matches!(self.family_state, FittedFamily::LocationScale { .. })
4227            && family_likelihood.is_some_and(LikelihoodSpec::is_latent_cloglog)
4228        {
4229            return Err(FittedModelError::IncompatibleConfig {
4230                reason: "latent-cloglog-binomial is not supported for location-scale saved models"
4231                    .to_string(),
4232            });
4233        }
4234        if matches!(self.family_state, FittedFamily::Survival { .. })
4235            && self.survival_likelihood.is_none()
4236        {
4237            return Err(FittedModelError::MissingField {
4238                reason: "saved survival model is missing survival_likelihood metadata; refit"
4239                    .to_string(),
4240            });
4241        }
4242        let has_any_saved_link_wiggle = self.linkwiggle_knots.is_some()
4243            || self.linkwiggle_degree.is_some()
4244            || self.beta_link_wiggle.is_some()
4245            || self
4246                .fit_result
4247                .as_ref()
4248                .and_then(|fit| fit.block_by_role(BlockRole::LinkWiggle))
4249                .is_some();
4250        let saved_link_wiggle = self.saved_link_wiggle()?;
4251        if has_any_saved_link_wiggle && saved_link_wiggle.is_none() {
4252            return Err(FittedModelError::SchemaMismatch {
4253                reason: "saved model has incomplete link-wiggle state; expected metadata and coefficients"
4254                    .to_string(),
4255            });
4256        }
4257        let has_any_saved_baseline_time_wiggle = self.baseline_timewiggle_knots.is_some()
4258            || self.baseline_timewiggle_degree.is_some()
4259            || self.baseline_timewiggle_penalty_orders.is_some()
4260            || self.baseline_timewiggle_double_penalty.is_some()
4261            || self.beta_baseline_timewiggle.is_some()
4262            || self.beta_baseline_timewiggle_by_cause.is_some();
4263        let is_joint_cause_specific = self
4264            .survival_cause_count
4265            .is_some_and(|cause_count| cause_count > 1);
4266        if has_any_saved_baseline_time_wiggle {
4267            if is_joint_cause_specific {
4268                let complete = self.baseline_timewiggle_knots.is_some()
4269                    && self.baseline_timewiggle_degree.is_some()
4270                    && self.baseline_timewiggle_penalty_orders.is_some()
4271                    && self.baseline_timewiggle_double_penalty.is_some()
4272                    && self.beta_baseline_timewiggle_by_cause.is_some();
4273                if !complete {
4274                    return Err(FittedModelError::SchemaMismatch {
4275                        reason: "saved joint cause-specific survival model has incomplete baseline-timewiggle state; expected metadata and per-cause coefficients"
4276                            .to_string(),
4277                    });
4278                }
4279            } else if self.saved_baseline_time_wiggle()?.is_none() {
4280                return Err(FittedModelError::SchemaMismatch {
4281                    reason: "saved model has incomplete baseline-timewiggle state; expected metadata and coefficients"
4282                        .to_string(),
4283                });
4284            }
4285        }
4286        if self
4287            .survival_likelihood
4288            .as_deref()
4289            .is_some_and(|value| value.eq_ignore_ascii_case("location-scale"))
4290        {
4291            validate_survival_location_scale_saved_fit(self.payload(), saved_link_wiggle.as_ref())?;
4292        }
4293
4294        // Validate anchored-deviation replay contracts at LOAD/SAVE time rather
4295        // than waiting for first predict call. Previously these contracts
4296        // (span table dimensions, coefficient matrices, etc.) were only
4297        // asserted inside `saved_prediction_runtime`, which runs on the first
4298        // predict invocation. A corrupted runtime would therefore pass
4299        // `load_from_path` silently and fail later under a different error
4300        // surface. Enforcing the same check here makes the model self-
4301        // diagnostic: `gam fit` catches its own bad output at save, and
4302        // `gam predict` catches bad input at load rather than mid-pipeline.
4303        if let Some(runtime) = self.score_warp_runtime.as_ref() {
4304            runtime.validate_exact_replay_contract().map_err(|err| {
4305                FittedModelError::PayloadCorrupt {
4306                    reason: format!("saved anchored score-warp runtime is invalid: {err}"),
4307                }
4308            })?;
4309        }
4310        if let Some(runtime) = self.link_deviation_runtime.as_ref() {
4311            runtime.validate_exact_replay_contract().map_err(|err| {
4312                FittedModelError::PayloadCorrupt {
4313                    reason: format!("saved anchored link-deviation runtime is invalid: {err}"),
4314                }
4315            })?;
4316        }
4317        if matches!(self.family_state, FittedFamily::MarginalSlope { .. }) {
4318            validate_marginal_slope_saved_fit(
4319                self.fit_result.as_ref().expect("checked above"),
4320                self.score_warp_runtime.as_ref(),
4321                self.link_deviation_runtime.as_ref(),
4322                "fit_result",
4323            )?;
4324            let unified = self
4325                .unified
4326                .as_ref()
4327                .ok_or_else(|| FittedModelError::MissingField {
4328                    reason: "marginal-slope model is missing unified fit payload; refit"
4329                        .to_string(),
4330                })?;
4331            validate_marginal_slope_saved_fit(
4332                unified,
4333                self.score_warp_runtime.as_ref(),
4334                self.link_deviation_runtime.as_ref(),
4335                "unified",
4336            )?;
4337        }
4338        if self
4339            .survival_likelihood
4340            .as_deref()
4341            .is_some_and(|value| value.eq_ignore_ascii_case("marginal-slope"))
4342        {
4343            validate_survival_marginal_slope_saved_fit(
4344                self.fit_result.as_ref().expect("checked above"),
4345                self.score_warp_runtime.as_ref(),
4346                self.link_deviation_runtime.as_ref(),
4347                "fit_result",
4348            )?;
4349            if let Some(unified) = self.unified.as_ref() {
4350                validate_survival_marginal_slope_saved_fit(
4351                    unified,
4352                    self.score_warp_runtime.as_ref(),
4353                    self.link_deviation_runtime.as_ref(),
4354                    "unified",
4355                )?;
4356            }
4357        }
4358
4359        // Posterior-mean / uncertainty backends are validated at predict time
4360        // by `prediction_backend_from_model`, which has access to the actual
4361        // requested mode and emits the canonical "nonlinear posterior-mean
4362        // prediction requires either covariance or a saved penalized Hessian"
4363        // error.  Save-time we deliberately do NOT enforce that gate: a fit
4364        // produced for MAP / plug-in scoring can be persisted and replayed
4365        // without ever needing a covariance backend, and gating it here would
4366        // refuse legitimate MAP-only saves whose `UnifiedFitResult` carries
4367        // beta + lambdas without a stabilized Hessian.
4368
4369        Ok(())
4370    }
4371
4372    pub fn validate_numeric_finiteness(&self) -> Result<(), FittedModelError> {
4373        let corrupt = |reason: String| FittedModelError::PayloadCorrupt { reason };
4374        if let Some(fit) = self.fit_result.as_ref() {
4375            fit.validate_numeric_finiteness()
4376                .map_err(|e| corrupt(e.to_string()))?;
4377        }
4378
4379        for (name, opt) in [
4380            ("survival_baseline_scale", self.survival_baseline_scale),
4381            ("survival_baseline_shape", self.survival_baseline_shape),
4382            ("survival_baseline_rate", self.survival_baseline_rate),
4383            ("survival_baseline_makeham", self.survival_baseline_makeham),
4384            (
4385                "survival_time_smooth_lambda",
4386                self.survival_time_smooth_lambda,
4387            ),
4388            ("survival_time_anchor", self.survival_time_anchor),
4389            ("survivalridge_lambda", self.survivalridge_lambda),
4390        ] {
4391            if let Some(v) = opt {
4392                ensure_finite_scalar(name, v).map_err(corrupt)?;
4393            }
4394        }
4395
4396        if let Some(v) = self.beta_noise.as_ref() {
4397            validate_all_finite("beta_noise", v.iter().copied()).map_err(corrupt)?;
4398        }
4399        if let Some(v) = self.noise_projection.as_ref() {
4400            validate_all_finite("noise_projection", v.iter().flatten().copied())
4401                .map_err(corrupt)?;
4402            if self.noise_projection_ridge_alpha.is_none() {
4403                return Err(FittedModelError::MissingField {
4404                    reason:
4405                        "model has noise_projection but is missing noise_projection_ridge_alpha; refit"
4406                            .to_string(),
4407                });
4408            }
4409        }
4410        if let Some(v) = self.noise_center.as_ref() {
4411            validate_all_finite("noise_center", v.iter().copied()).map_err(corrupt)?;
4412        }
4413        if let Some(v) = self.noise_scale.as_ref() {
4414            validate_all_finite("noise_scale", v.iter().copied()).map_err(corrupt)?;
4415        }
4416        if let Some(v) = self.noise_projection_ridge_alpha {
4417            ensure_finite_scalar("noise_projection_ridge_alpha", v).map_err(corrupt)?;
4418            if v < 0.0 {
4419                return Err(FittedModelError::InvalidInput {
4420                    reason: format!("noise_projection_ridge_alpha must be non-negative, got {v}"),
4421                });
4422            }
4423        }
4424        if let Some(v) = self.gaussian_response_scale {
4425            ensure_finite_scalar("gaussian_response_scale", v).map_err(corrupt)?;
4426        }
4427        if let Some(v) = self.beta_link_wiggle.as_ref() {
4428            validate_all_finite("beta_link_wiggle", v.iter().copied()).map_err(corrupt)?;
4429        }
4430        if let Some(v) = self.beta_baseline_timewiggle.as_ref() {
4431            validate_all_finite("beta_baseline_timewiggle", v.iter().copied()).map_err(corrupt)?;
4432        }
4433        if let Some(v) = self.beta_baseline_timewiggle_by_cause.as_ref() {
4434            validate_all_finite(
4435                "beta_baseline_timewiggle_by_cause",
4436                v.iter().flatten().copied(),
4437            )
4438            .map_err(corrupt)?;
4439        }
4440        if let Some(v) = self.latent_z_normalization {
4441            v.validate("latent_z_normalization")?;
4442        }
4443        if let Some(v) = self.latent_measure.as_ref() {
4444            v.validate("latent_measure").map_err(corrupt)?;
4445        }
4446        if let Some(v) = self.survival_beta_time.as_ref() {
4447            validate_all_finite("survival_beta_time", v.iter().copied()).map_err(corrupt)?;
4448        }
4449        if let Some(v) = self.survival_beta_threshold.as_ref() {
4450            validate_all_finite("survival_beta_threshold", v.iter().copied()).map_err(corrupt)?;
4451        }
4452        if let Some(v) = self.survival_beta_log_sigma.as_ref() {
4453            validate_all_finite("survival_beta_log_sigma", v.iter().copied()).map_err(corrupt)?;
4454        }
4455        if let Some(v) = self.survival_noise_projection.as_ref() {
4456            validate_all_finite("survival_noise_projection", v.iter().flatten().copied())
4457                .map_err(corrupt)?;
4458            if self.survival_noise_projection_ridge_alpha.is_none() {
4459                return Err(FittedModelError::MissingField {
4460                    reason:
4461                        "model has survival_noise_projection but is missing survival_noise_projection_ridge_alpha; refit"
4462                            .to_string(),
4463                });
4464            }
4465        }
4466        if let Some(v) = self.survival_noise_center.as_ref() {
4467            validate_all_finite("survival_noise_center", v.iter().copied()).map_err(corrupt)?;
4468        }
4469        if let Some(v) = self.survival_noise_projection_ridge_alpha {
4470            ensure_finite_scalar("survival_noise_projection_ridge_alpha", v).map_err(corrupt)?;
4471            if v < 0.0 {
4472                return Err(FittedModelError::InvalidInput {
4473                    reason: format!(
4474                        "survival_noise_projection_ridge_alpha must be non-negative, got {v}"
4475                    ),
4476                });
4477            }
4478        }
4479        if let Some(v) = self.survival_noise_scale.as_ref() {
4480            validate_all_finite("survival_noise_scale", v.iter().copied()).map_err(corrupt)?;
4481        }
4482        if let Some(v) = self.mixture_link_param_covariance.as_ref() {
4483            validate_all_finite("mixture_link_param_covariance", v.iter().flatten().copied())
4484                .map_err(corrupt)?;
4485        }
4486        if let Some(v) = self.sas_param_covariance.as_ref() {
4487            validate_all_finite("sas_param_covariance", v.iter().flatten().copied())
4488                .map_err(corrupt)?;
4489        }
4490        Ok(())
4491    }
4492}
4493
4494fn array2_to_nestedvec(a: &ndarray::Array2<f64>) -> Vec<Vec<f64>> {
4495    a.rows().into_iter().map(|row| row.to_vec()).collect()
4496}
4497
4498use gam_solve::estimate::{ensure_finite_scalar, validate_all_finite};
4499
4500fn validate_frozen_term_collectionspec(
4501    spec: &TermCollectionSpec,
4502    label: &str,
4503) -> Result<(), FittedModelError> {
4504    spec.validate_frozen(label)
4505        .map_err(|reason| FittedModelError::SchemaMismatch { reason })
4506}
4507
4508impl Deref for FittedModel {
4509    type Target = FittedModelPayload;
4510
4511    fn deref(&self) -> &Self::Target {
4512        self.payload()
4513    }
4514}
4515
4516impl DerefMut for FittedModel {
4517    fn deref_mut(&mut self) -> &mut Self::Target {
4518        self.payload_mut()
4519    }
4520}
4521
4522// ---------------------------------------------------------------------------
4523// Reconstruct library types from saved models
4524// ---------------------------------------------------------------------------
4525
4526pub fn survival_baseline_config_from_model(
4527    model: &FittedModel,
4528) -> Result<SurvivalBaselineConfig, FittedModelError> {
4529    let target = model.survival_baseline_target.as_deref().ok_or_else(|| {
4530        FittedModelError::MissingField {
4531            reason: "saved survival model missing survival_baseline_target; refit".to_string(),
4532        }
4533    })?;
4534    parse_survival_baseline_config(
4535        target,
4536        model.survival_baseline_scale,
4537        model.survival_baseline_shape,
4538        model.survival_baseline_rate,
4539        model.survival_baseline_makeham,
4540    )
4541    .map_err(|reason| FittedModelError::IncompatibleConfig { reason })
4542}
4543
4544pub fn load_survival_time_basis_config_from_model(
4545    model: &FittedModel,
4546) -> Result<SurvivalTimeBasisConfig, FittedModelError> {
4547    match model
4548        .survival_time_basis
4549        .as_deref()
4550        .ok_or_else(|| FittedModelError::MissingField {
4551            reason: "saved survival model missing survival_time_basis".to_string(),
4552        })?
4553        .to_ascii_lowercase()
4554        .as_str()
4555    {
4556        "none" => Ok(SurvivalTimeBasisConfig::None),
4557        "linear" => Ok(SurvivalTimeBasisConfig::Linear),
4558        "bspline" => {
4559            let degree =
4560                model
4561                    .survival_time_degree
4562                    .ok_or_else(|| FittedModelError::MissingField {
4563                        reason: "saved survival bspline model missing survival_time_degree"
4564                            .to_string(),
4565                    })?;
4566            let knots = model.survival_time_knots.clone().ok_or_else(|| {
4567                FittedModelError::MissingField {
4568                    reason: "saved survival bspline model missing survival_time_knots".to_string(),
4569                }
4570            })?;
4571            let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
4572            if degree < 1 || knots.is_empty() {
4573                return Err(FittedModelError::SchemaMismatch {
4574                    reason: "saved survival bspline time basis metadata is invalid".to_string(),
4575                });
4576            }
4577            Ok(SurvivalTimeBasisConfig::BSpline {
4578                degree,
4579                knots: Array1::from_vec(knots),
4580                smooth_lambda,
4581            })
4582        }
4583        "ispline" => {
4584            let degree =
4585                model
4586                    .survival_time_degree
4587                    .ok_or_else(|| FittedModelError::MissingField {
4588                        reason: "saved survival ispline model missing survival_time_degree"
4589                            .to_string(),
4590                    })?;
4591            let knots = model.survival_time_knots.clone().ok_or_else(|| {
4592                FittedModelError::MissingField {
4593                    reason: "saved survival ispline model missing survival_time_knots".to_string(),
4594                }
4595            })?;
4596            let keep_cols = model.survival_time_keep_cols.clone().ok_or_else(|| {
4597                FittedModelError::MissingField {
4598                    reason: "saved survival ispline model missing survival_time_keep_cols"
4599                        .to_string(),
4600                }
4601            })?;
4602            let smooth_lambda = model.survival_time_smooth_lambda.unwrap_or(1e-2);
4603            if degree < 1 || knots.is_empty() || keep_cols.is_empty() {
4604                return Err(FittedModelError::SchemaMismatch {
4605                    reason: "saved survival ispline time basis metadata is invalid".to_string(),
4606                });
4607            }
4608            Ok(SurvivalTimeBasisConfig::ISpline {
4609                degree,
4610                knots: Array1::from_vec(knots),
4611                keep_cols,
4612                smooth_lambda,
4613            })
4614        }
4615        other => Err(FittedModelError::IncompatibleConfig {
4616            reason: format!("unsupported saved survival_time_basis '{other}'"),
4617        }),
4618    }
4619}
4620
4621#[cfg(test)]
4622mod tests {
4623    use super::*;
4624    use crate::cubic_cell_kernel::ANCHORED_DEVIATION_KERNEL;
4625    use crate::survival::lognormal_kernel::FrailtySpec;
4626    use gam_solve::pirls::PirlsStatus;
4627    use gam_solve::estimate::{FitArtifacts, FittedBlock, FittedLinkState};
4628    use gam_problem::types::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
4629    use gam_data::SchemaColumn;
4630    use ndarray::{Array1, Array2, array};
4631
4632    fn empty_termspec() -> TermCollectionSpec {
4633        TermCollectionSpec {
4634            linear_terms: vec![],
4635            random_effect_terms: vec![],
4636            smooth_terms: vec![],
4637        }
4638    }
4639
4640    /// #1030/#1034: a scan-bearing payload must round-trip through JSON +
4641    /// `validate_for_persistence` and replay the training Gaussian bridge
4642    /// bit-for-bit; structural corruption must fail loudly at validation.
4643    #[test]
4644    fn spline_scan_payload_round_trips_and_validates() {
4645        let x: Vec<f64> = (0..40).map(|i| i as f64 / 39.0).collect();
4646        let y: Vec<f64> = x.iter().map(|&v| (4.0 * v).sin() + 0.1 * v).collect();
4647        let w = vec![1.0_f64; x.len()];
4648        let fit = gam_solve::spline_scan::fit_spline_scan(&x, &y, &w, 2).expect("scan fit");
4649        let make_payload = || {
4650            crate::inference::model_payload_builders::assemble_spline_scan_payload(
4651                "y ~ s(x)".to_string(),
4652                "x".to_string(),
4653                &fit,
4654                DataSchema {
4655                    columns: vec![
4656                        SchemaColumn {
4657                            name: "y".to_string(),
4658                            kind: ColumnKindTag::Continuous,
4659                            levels: vec![],
4660                        },
4661                        SchemaColumn {
4662                            name: "x".to_string(),
4663                            kind: ColumnKindTag::Continuous,
4664                            levels: vec![],
4665                        },
4666                    ],
4667                },
4668                vec!["x".to_string()],
4669                vec![(0.0, 1.0)],
4670            )
4671        };
4672        // The on-disk form is the FittedModel tagged enum; validation and the
4673        // scan accessor live on FittedModel (Deref only goes Model -> Payload).
4674        let model = FittedModel::from_payload(make_payload());
4675        model
4676            .validate_for_persistence()
4677            .expect("scan model validates");
4678        model
4679            .validate_numeric_finiteness()
4680            .expect("scan model is finite");
4681
4682        let json = serde_json::to_string(&model).expect("serialize model");
4683        let restored: FittedModel = serde_json::from_str(&json).expect("parse model");
4684        restored
4685            .validate_for_persistence()
4686            .expect("restored scan model validates");
4687        let (column, replay) = restored
4688            .saved_spline_scan()
4689            .expect("restore scan fit")
4690            .expect("payload carries the scan representation");
4691        assert_eq!(column, "x");
4692        for &xq in &[-0.1, 0.0, 0.31, 0.5, 0.77, 1.0, 1.4] {
4693            let (m0, v0) = fit.predict(xq).expect("predict original");
4694            let (m1, v1) = replay.predict(xq).expect("predict replayed");
4695            assert_eq!(m0.to_bits(), m1.to_bits(), "mean drift at x={xq}");
4696            assert_eq!(v0.to_bits(), v1.to_bits(), "variance drift at x={xq}");
4697        }
4698
4699        // A dense model without the scan channel still requires fit_result.
4700        let mut dense = make_payload();
4701        dense.spline_scan = None;
4702        let err = FittedModel::from_payload(dense)
4703            .validate_for_persistence()
4704            .expect_err("dense payload without fit_result must be rejected");
4705        assert!(err.to_string().contains("fit_result"));
4706
4707        // Structural corruption fails at validation, not inside predict.
4708        let mut corrupt = make_payload();
4709        corrupt
4710            .spline_scan
4711            .as_mut()
4712            .expect("scan channel present")
4713            .state
4714            .knots
4715            .truncate(2);
4716        FittedModel::from_payload(corrupt)
4717            .validate_for_persistence()
4718            .expect_err("corrupt scan state must be rejected");
4719        let mut unnamed = make_payload();
4720        unnamed
4721            .spline_scan
4722            .as_mut()
4723            .expect("scan channel present")
4724            .feature_column
4725            .clear();
4726        FittedModel::from_payload(unnamed)
4727            .validate_for_persistence()
4728            .expect_err("missing feature column must be rejected");
4729    }
4730
4731    fn standard_gaussian_payload() -> FittedModelPayload {
4732        FittedModelPayload::new(
4733            MODEL_PAYLOAD_VERSION,
4734            "y ~ 1".to_string(),
4735            ModelKind::Standard,
4736            FittedFamily::Standard {
4737                likelihood: LikelihoodSpec::gaussian_identity(),
4738                link: Some(StandardLink::Identity),
4739                latent_cloglog_state: None,
4740                mixture_state: None,
4741                sas_state: None,
4742            },
4743            "gaussian".to_string(),
4744        )
4745    }
4746
4747    fn anchored_runtime(basis_dim: usize) -> SavedCompiledFlexBlock {
4748        SavedCompiledFlexBlock {
4749            kernel: ANCHORED_DEVIATION_KERNEL.to_string(),
4750            breakpoints: vec![-1.0, 1.0],
4751            basis_dim,
4752            span_c0: vec![vec![0.0; basis_dim]],
4753            span_c1: vec![vec![0.0; basis_dim]],
4754            span_c2: vec![vec![0.0; basis_dim]],
4755            span_c3: vec![vec![0.0; basis_dim]],
4756            anchor_correction: None,
4757            anchor_components: Vec::new(),
4758        }
4759    }
4760
4761    fn saved_fit(blocks: Vec<FittedBlock>) -> UnifiedFitResult {
4762        let beta = Array1::from_vec(
4763            blocks
4764                .iter()
4765                .flat_map(|block| block.beta.iter().copied())
4766                .collect(),
4767        );
4768        let p = beta.len();
4769        UnifiedFitResult {
4770            blocks,
4771            log_lambdas: Array1::zeros(0),
4772            lambdas: Array1::zeros(0),
4773            likelihood_family: Some(LikelihoodSpec::binomial_probit()),
4774            likelihood_scale: LikelihoodScaleMetadata::Unspecified,
4775            log_likelihood_normalization: LogLikelihoodNormalization::Full,
4776            log_likelihood: 0.0,
4777            deviance: 0.0,
4778            reml_score: 0.0,
4779            stable_penalty_term: 0.0,
4780            penalized_objective: 0.0,
4781            used_device: false,
4782            outer_iterations: 0,
4783            outer_cost_evals: 0,
4784            inner_pirls_solves: 0,
4785            outer_converged: true,
4786            outer_gradient_norm: None,
4787            standard_deviation: 1.0,
4788            covariance_conditional: Some(Array2::zeros((p, p))),
4789            covariance_corrected: Some(Array2::zeros((p, p))),
4790            inference: None,
4791            fitted_link: FittedLinkState::Standard(None),
4792            geometry: None,
4793            block_states: vec![],
4794            beta,
4795            pirls_status: PirlsStatus::Converged,
4796            max_abs_eta: 0.0,
4797            constraint_kkt: None,
4798            artifacts: FitArtifacts {
4799                pirls: None,
4800                null_space_logdet: None,
4801                null_space_dim: None,
4802                survival_link_wiggle_knots: None,
4803                survival_link_wiggle_degree: None,
4804                criterion_certificate: None,
4805                rho_posterior_certificate: None,
4806                rho_posterior_escalation: None,
4807                rho_covariance: None,
4808                joint_log_lambdas: None,
4809            },
4810            inner_cycles: 0,
4811        }
4812    }
4813
4814    fn marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
4815        let mut payload = FittedModelPayload::new(
4816            version,
4817            "y ~ 1".to_string(),
4818            ModelKind::MarginalSlope,
4819            FittedFamily::MarginalSlope {
4820                likelihood: LikelihoodSpec::binomial_probit(),
4821                base_link: InverseLink::Standard(StandardLink::Probit),
4822                frailty: FrailtySpec::None,
4823            },
4824            "bernoulli-marginal-slope".to_string(),
4825        );
4826        payload.fit_result = Some(fit.clone());
4827        payload.unified = Some(fit);
4828        payload.data_schema = Some(DataSchema {
4829            columns: vec![SchemaColumn {
4830                name: "z".to_string(),
4831                kind: ColumnKindTag::Continuous,
4832                levels: vec![],
4833            }],
4834        });
4835        payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
4836        payload.resolved_termspec = Some(empty_termspec());
4837        payload.resolved_termspec_logslope = Some(empty_termspec());
4838        payload.formula_logslope = Some("1".to_string());
4839        payload.z_column = Some("z".to_string());
4840        payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
4841        payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
4842        payload.marginal_baseline = Some(0.0);
4843        payload.logslope_baseline = Some(0.0);
4844        payload.link = Some(InverseLink::Standard(StandardLink::Probit));
4845        payload
4846    }
4847
4848    #[test]
4849    fn from_payload_synchronizes_used_device_from_saved_fit() {
4850        let mut fit = saved_fit(vec![
4851            FittedBlock {
4852                beta: Array1::from_vec(vec![0.25]),
4853                role: BlockRole::Mean,
4854                edf: 1.0,
4855                lambdas: Array1::zeros(0),
4856            },
4857            FittedBlock {
4858                beta: Array1::from_vec(vec![0.5]),
4859                role: BlockRole::Scale,
4860                edf: 1.0,
4861                lambdas: Array1::zeros(0),
4862            },
4863        ]);
4864        fit.used_device = true;
4865        let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
4866        payload.used_device = false;
4867
4868        let model = FittedModel::from_payload(payload);
4869
4870        assert!(model.payload().used_device);
4871    }
4872
4873    fn survival_marginal_slope_payload(version: u32, fit: UnifiedFitResult) -> FittedModelPayload {
4874        let mut payload = FittedModelPayload::new(
4875            version,
4876            "Surv(entry, exit, event) ~ 1".to_string(),
4877            ModelKind::Survival,
4878            FittedFamily::Survival {
4879                likelihood: LikelihoodSpec::royston_parmar(),
4880                survival_likelihood: Some("marginal-slope".to_string()),
4881                survival_distribution: Some(ResidualDistribution::Gaussian),
4882                frailty: FrailtySpec::None,
4883            },
4884            "survival".to_string(),
4885        );
4886        payload.fit_result = Some(fit.clone());
4887        payload.unified = Some(fit);
4888        payload.survival_likelihood = Some("marginal-slope".to_string());
4889        payload.survival_distribution = Some(ResidualDistribution::Gaussian);
4890        payload.latent_measure = Some(LatentMeasureKind::StandardNormal);
4891        payload.data_schema = Some(DataSchema {
4892            columns: vec![SchemaColumn {
4893                name: "z".to_string(),
4894                kind: ColumnKindTag::Continuous,
4895                levels: vec![],
4896            }],
4897        });
4898        payload.set_training_feature_metadata(vec!["z".to_string()], vec![(0.0, 0.0)]);
4899        payload.resolved_termspec = Some(empty_termspec());
4900        payload.resolved_termspec_logslope = Some(empty_termspec());
4901        payload.formula_logslope = Some("1".to_string());
4902        payload.z_column = Some("z".to_string());
4903        payload.latent_z_normalization = Some(SavedLatentZNormalization { mean: 0.0, sd: 1.0 });
4904        payload.logslope_baseline = Some(0.0);
4905        payload.link = Some(InverseLink::Standard(StandardLink::Probit));
4906        payload
4907    }
4908
4909    fn gamma_dispersion_location_scale_payload() -> FittedModelPayload {
4910        // A #913 genuine-dispersion location-scale model: Gamma mean family with
4911        // a log-precision `noise_formula` channel. Its likelihood response is
4912        // non-Gaussian and non-Binomial, so the predict-path classifier must
4913        // route it to `DispersionLocationScale`, NOT the binomial threshold-scale
4914        // class (issue #1064).
4915        let mut payload = FittedModelPayload::new(
4916            MODEL_PAYLOAD_VERSION,
4917            "y ~ x".to_string(),
4918            ModelKind::LocationScale,
4919            FittedFamily::LocationScale {
4920                likelihood: LikelihoodSpec::gamma_log(),
4921                base_link: Some(InverseLink::Standard(StandardLink::Log)),
4922            },
4923            "gamma-location-scale".to_string(),
4924        );
4925        payload.data_schema = Some(DataSchema {
4926            columns: vec![
4927                SchemaColumn {
4928                    name: "y".to_string(),
4929                    kind: ColumnKindTag::Continuous,
4930                    levels: vec![],
4931                },
4932                SchemaColumn {
4933                    name: "x".to_string(),
4934                    kind: ColumnKindTag::Continuous,
4935                    levels: vec![],
4936                },
4937            ],
4938        });
4939        payload.set_training_feature_metadata(vec!["x".to_string()], vec![(-1.0, 1.0)]);
4940        payload.resolved_termspec = Some(empty_termspec());
4941        payload.resolved_termspec_noise = Some(empty_termspec());
4942        payload.formula_noise = Some("x".to_string());
4943        payload.beta_noise = Some(vec![0.0]);
4944        payload.link = Some(InverseLink::Standard(StandardLink::Log));
4945        payload
4946    }
4947
4948    /// #1064 regression: a dispersion location-scale (#913) payload must be
4949    /// classified as `DispersionLocationScale` at every predict-path entry —
4950    /// both `from_payload` (load) and `predict_model_class` (runtime) — and never
4951    /// fall through to the binomial threshold-scale class. Before the fix the
4952    /// non-Gaussian `else` arm mis-routed every dispersion model to
4953    /// `BinomialLocationScale`, predicting the wrong family/link.
4954    #[test]
4955    fn dispersion_location_scale_payload_is_not_classified_binomial() {
4956        let model = FittedModel::from_payload(gamma_dispersion_location_scale_payload());
4957        assert_eq!(
4958            model.predict_model_class(),
4959            PredictModelClass::DispersionLocationScale,
4960            "Gamma dispersion location-scale must route through the dispersion \
4961             predictor, not the binomial threshold-scale class",
4962        );
4963        assert!(
4964            !matches!(
4965                model.predict_model_class(),
4966                PredictModelClass::BinomialLocationScale
4967            ),
4968            "dispersion location-scale must never be classified as binomial",
4969        );
4970
4971        // Each of the four #913 dispersion mean families classifies the same way.
4972        for likelihood in [
4973            LikelihoodSpec::gamma_log(),
4974            LikelihoodSpec::new(
4975                ResponseFamily::NegativeBinomial {
4976                    theta: 1.0,
4977                    theta_fixed: false,
4978                },
4979                InverseLink::Standard(StandardLink::Log),
4980            ),
4981            LikelihoodSpec::new(
4982                ResponseFamily::Beta { phi: 1.0 },
4983                InverseLink::Standard(StandardLink::Logit),
4984            ),
4985            LikelihoodSpec::new(
4986                ResponseFamily::Tweedie { p: 1.5 },
4987                InverseLink::Standard(StandardLink::Log),
4988            ),
4989        ] {
4990            let mut payload = gamma_dispersion_location_scale_payload();
4991            payload.family_state = FittedFamily::LocationScale {
4992                base_link: Some(likelihood.link.clone()),
4993                likelihood: likelihood.clone(),
4994            };
4995            let model = FittedModel::from_payload(payload);
4996            assert_eq!(
4997                model.predict_model_class(),
4998                PredictModelClass::DispersionLocationScale,
4999                "dispersion family {:?} mis-classified",
5000                likelihood.response,
5001            );
5002        }
5003    }
5004
5005    #[test]
5006    fn axis_clip_leaves_numeric_random_effect_group_axis_unclipped() {
5007        let data = array![[100.0], [-100.0]];
5008        let col_map = HashMap::from([("g".to_string(), 0usize)]);
5009
5010        let mut plain_payload = standard_gaussian_payload();
5011        plain_payload.data_schema = Some(DataSchema {
5012            columns: vec![SchemaColumn {
5013                name: "g".to_string(),
5014                kind: ColumnKindTag::Continuous,
5015                levels: vec![],
5016            }],
5017        });
5018        plain_payload.set_training_feature_metadata(vec!["g".to_string()], vec![(0.0, 7.0)]);
5019        plain_payload.resolved_termspec = Some(empty_termspec());
5020        let plain = FittedModel::from_payload(plain_payload.clone());
5021        let clipped = plain
5022            .axis_clip_to_training_ranges(data.view(), &col_map)
5023            .expect("ordinary continuous axis should clip outside the training range");
5024        assert_eq!(clipped.column(0).to_vec(), vec![7.0, 0.0]);
5025
5026        let mut group_payload = plain_payload;
5027        let mut group_spec = empty_termspec();
5028        group_spec
5029            .random_effect_terms
5030            .push(gam_terms::smooth::RandomEffectTermSpec {
5031                name: "g".to_string(),
5032                feature_col: 0,
5033                drop_first_level: false,
5034                penalized: true,
5035                frozen_levels: Some(vec![0.0_f64.to_bits(), 7.0_f64.to_bits()]),
5036                lenient_unseen: true,
5037            });
5038        group_payload.resolved_termspec = Some(group_spec);
5039        let group_model = FittedModel::from_payload(group_payload);
5040
5041        assert_eq!(
5042            group_model.random_effect_group_columns(),
5043            HashSet::from(["g".to_string()])
5044        );
5045
5046        assert_eq!(
5047            group_model.axis_clip_to_training_ranges(data.view(), &col_map),
5048            None,
5049            "numeric group labels must reach RandomEffectOperator as unseen levels, not be clipped to boundary seen levels"
5050        );
5051    }
5052
5053    /// #2102/#2137: a FIXED categorical factor — a bare `y ~ g` or an explicit
5054    /// `y ~ factor(g)` — must reach the strict schema encode and raise a
5055    /// `SchemaMismatch` on an unseen level; it must NOT be silently mapped to
5056    /// the factor's centering point (the across-level average). Only a genuine
5057    /// random effect (`group(g)`/`re(g)`/`s(g, bs="re")`) is eligible for the
5058    /// lenient held-out-group policy, and it must stay lenient. `factor(g)`
5059    /// shared the `group()`/`re()` parse arm and so wrongly inherited the
5060    /// lenient policy (#2137); it is now lowered as the fixed factor it is.
5061    ///
5062    /// This drives the real predict/`check` encode contract: it derives the
5063    /// lenient whitelist from `random_effect_group_columns()` exactly as the
5064    /// predict and `schema_check` FFI paths do, then encodes a frame carrying an
5065    /// out-of-vocabulary level.
5066    #[test]
5067    fn bare_categorical_fixed_factor_unseen_level_rejected_by_predict_encode() {
5068        use csv::StringRecord;
5069        use gam_data::{EncodedDataset, UnseenCategoryPolicy, encode_recordswith_schema};
5070        use gam_runtime::resource::ResourcePolicy;
5071        use gam_terms::inference::formula_dsl::parse_formula;
5072        use gam_terms::term_builder::build_termspec;
5073
5074        // Training frame: response `y` + categorical `g` with levels {a,b,c}.
5075        let train_schema = DataSchema {
5076            columns: vec![
5077                SchemaColumn {
5078                    name: "y".to_string(),
5079                    kind: ColumnKindTag::Continuous,
5080                    levels: vec![],
5081                },
5082                SchemaColumn {
5083                    name: "g".to_string(),
5084                    kind: ColumnKindTag::Categorical,
5085                    levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
5086                },
5087            ],
5088        };
5089        let train = EncodedDataset {
5090            headers: vec!["y".to_string(), "g".to_string()],
5091            values: Array2::from_shape_vec(
5092                (6, 2),
5093                vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0],
5094            )
5095            .expect("rectangular training frame"),
5096            schema: train_schema.clone(),
5097            column_kinds: vec![ColumnKindTag::Continuous, ColumnKindTag::Categorical],
5098        };
5099        let build_col_map = train.column_map();
5100
5101        let model_for = |formula: &str| -> FittedModel {
5102            let parsed = parse_formula(formula).expect("formula parses");
5103            let mut notes = Vec::new();
5104            let spec = build_termspec(
5105                &parsed.terms,
5106                &train,
5107                &build_col_map,
5108                &mut notes,
5109                &ResourcePolicy::default_library(),
5110            )
5111            .unwrap_or_else(|err| panic!("`{formula}` must build a term spec, got: {err:?}"));
5112            let mut payload = standard_gaussian_payload();
5113            payload.data_schema = Some(train_schema.clone());
5114            payload.set_training_feature_metadata(
5115                vec!["y".to_string(), "g".to_string()],
5116                vec![(0.0, 2.0), (0.0, 2.0)],
5117            );
5118            payload.resolved_termspec = Some(spec);
5119            FittedModel::from_payload(payload)
5120        };
5121
5122        // Predict frame with an out-of-vocabulary level for `g`.
5123        let g_schema = DataSchema {
5124            columns: vec![SchemaColumn {
5125                name: "g".to_string(),
5126                kind: ColumnKindTag::Categorical,
5127                levels: vec!["a".to_string(), "b".to_string(), "c".to_string()],
5128            }],
5129        };
5130        let encode_level = |model: &FittedModel, level: &str| -> Result<EncodedDataset, String> {
5131            let policy = UnseenCategoryPolicy::encode_unknown_for_columns(
5132                model.random_effect_group_columns(),
5133            );
5134            encode_recordswith_schema(
5135                vec!["g".to_string()],
5136                vec![StringRecord::from(vec![level])],
5137                &g_schema,
5138                policy,
5139            )
5140        };
5141
5142        // Fixed factor: `g` is NOT a lenient group column, so the strict encode
5143        // must reject the unseen level while still accepting a seen one.
5144        let bare = model_for("y ~ g");
5145        assert!(
5146            !bare.random_effect_group_columns().contains("g"),
5147            "bare `+ g` is a fixed parametric factor; it must NOT be whitelisted for lenient \
5148             unseen-level encoding (#2102)"
5149        );
5150        encode_level(&bare, "a").expect("a seen level must still encode for the fixed factor");
5151        let err = encode_level(&bare, "TYPO")
5152            .expect_err("an unseen fixed-factor level must raise a schema mismatch (#2102)");
5153        assert!(
5154            err.contains("unseen level"),
5155            "expected an unseen-level schema mismatch naming the level, got: {err}"
5156        );
5157
5158        // Explicit `factor(g)` is a FIXED categorical factor (#2137): it must
5159        // behave exactly like the bare `+ g` above — strict on unseen levels,
5160        // NOT whitelisted for lenient encoding — even though it shares the
5161        // penalized-categorical materialization with `group(g)`.
5162        let factor = model_for("y ~ factor(g)");
5163        assert!(
5164            !factor.random_effect_group_columns().contains("g"),
5165            "factor(g) is a FIXED categorical factor; it must NOT be whitelisted for lenient \
5166             unseen-level encoding (#2137)"
5167        );
5168        encode_level(&factor, "a").expect("a seen level must still encode for factor(g)");
5169        let factor_err = encode_level(&factor, "TYPO")
5170            .expect_err("an unseen factor(g) level must raise a schema mismatch (#2137)");
5171        assert!(
5172            factor_err.contains("unseen level"),
5173            "expected an unseen-level schema mismatch naming the level, got: {factor_err}"
5174        );
5175
5176        // Genuine random effects stay lenient (held-out group → population mean):
5177        // group(g), its re(g) alias, and the mgcv s(g, bs="re") spelling.
5178        for formula in ["y ~ group(g)", "y ~ re(g)", "y ~ s(g, bs=\"re\")"] {
5179            let grouped = model_for(formula);
5180            assert!(
5181                grouped.random_effect_group_columns().contains("g"),
5182                "`{formula}` is a random effect; it must remain lenient on unseen levels \
5183                 (held-out-group policy)"
5184            );
5185            encode_level(&grouped, "TYPO")
5186                .unwrap_or_else(|err| panic!("`{formula}` must tolerate an unseen level, got: {err}"));
5187        }
5188    }
5189
5190    #[test]
5191    fn validate_for_persistence_rejects_marginal_slope_score_warp_basis_mismatch() {
5192        let fit = saved_fit(vec![
5193            FittedBlock {
5194                beta: array![0.1],
5195                role: BlockRole::Mean,
5196                edf: 1.0,
5197                lambdas: Array1::zeros(0),
5198            },
5199            FittedBlock {
5200                beta: array![0.2],
5201                role: BlockRole::Scale,
5202                edf: 1.0,
5203                lambdas: Array1::zeros(0),
5204            },
5205            FittedBlock {
5206                beta: array![0.3],
5207                role: BlockRole::Mean,
5208                edf: 1.0,
5209                lambdas: Array1::zeros(0),
5210            },
5211        ]);
5212        let mut payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5213        payload.score_warp_runtime = Some(anchored_runtime(2));
5214
5215        let err = FittedModel::from_payload(payload)
5216            .validate_for_persistence()
5217            .expect_err("marginal-slope score-warp basis mismatch should fail validation");
5218        assert!(err.to_string().contains("score-warp coefficient mismatch"));
5219    }
5220
5221    #[test]
5222    fn saved_prediction_runtime_rejects_survival_marginal_slope_link_basis_mismatch() {
5223        let fit = saved_fit(vec![
5224            FittedBlock {
5225                beta: array![0.1],
5226                role: BlockRole::Time,
5227                edf: 1.0,
5228                lambdas: Array1::zeros(0),
5229            },
5230            FittedBlock {
5231                beta: array![0.2],
5232                role: BlockRole::Mean,
5233                edf: 1.0,
5234                lambdas: Array1::zeros(0),
5235            },
5236            FittedBlock {
5237                beta: array![0.3],
5238                role: BlockRole::Scale,
5239                edf: 1.0,
5240                lambdas: Array1::zeros(0),
5241            },
5242            FittedBlock {
5243                beta: array![0.4],
5244                role: BlockRole::LinkWiggle,
5245                edf: 1.0,
5246                lambdas: Array1::zeros(0),
5247            },
5248        ]);
5249        let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5250        payload.link_deviation_runtime = Some(anchored_runtime(2));
5251
5252        let err = FittedModel::from_payload(payload)
5253            .saved_prediction_runtime()
5254            .expect_err(
5255                "survival marginal-slope link basis mismatch should fail runtime validation",
5256            );
5257        assert!(
5258            err.to_string()
5259                .contains("link-deviation coefficient mismatch")
5260        );
5261    }
5262
5263    #[test]
5264    fn apply_survival_time_basis_writes_all_required_fields() {
5265        use crate::survival::construction::SavedSurvivalTimeBasis;
5266
5267        let fit = saved_fit(vec![
5268            FittedBlock {
5269                beta: array![0.1],
5270                role: BlockRole::Time,
5271                edf: 1.0,
5272                lambdas: Array1::zeros(0),
5273            },
5274            FittedBlock {
5275                beta: array![0.2],
5276                role: BlockRole::Mean,
5277                edf: 1.0,
5278                lambdas: Array1::zeros(0),
5279            },
5280            FittedBlock {
5281                beta: array![0.3],
5282                role: BlockRole::Scale,
5283                edf: 1.0,
5284                lambdas: Array1::zeros(0),
5285            },
5286        ]);
5287        let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5288
5289        // Snapshot writes must match every persisted survival_time_* field —
5290        // forgetting one is exactly the marginal-slope save
5291        // regression. Routing through `apply_survival_time_basis` is the
5292        // structural contract that prevents that recurrence.
5293        let snapshot = SavedSurvivalTimeBasis {
5294            basisname: "royston-parmar".to_string(),
5295            degree: Some(3),
5296            knots: Some(vec![0.0, 1.0, 2.0]),
5297            keep_cols: Some(vec![0, 2]),
5298            smooth_lambda: Some(0.5),
5299            anchor: 0.25,
5300        };
5301        payload.apply_survival_time_basis(&snapshot);
5302
5303        assert_eq!(
5304            payload.survival_time_basis.as_deref(),
5305            Some("royston-parmar")
5306        );
5307        assert_eq!(payload.survival_time_degree, Some(3));
5308        assert_eq!(payload.survival_time_knots, Some(vec![0.0, 1.0, 2.0]));
5309        assert_eq!(payload.survival_time_keep_cols, Some(vec![0, 2]));
5310        assert_eq!(payload.survival_time_smooth_lambda, Some(0.5));
5311        assert_eq!(payload.survival_time_anchor, Some(0.25));
5312    }
5313
5314    #[test]
5315    fn validate_for_persistence_rejects_survival_without_time_anchor_metadata() {
5316        let fit = saved_fit(vec![
5317            FittedBlock {
5318                beta: array![0.1],
5319                role: BlockRole::Time,
5320                edf: 1.0,
5321                lambdas: Array1::zeros(0),
5322            },
5323            FittedBlock {
5324                beta: array![0.2],
5325                role: BlockRole::Mean,
5326                edf: 1.0,
5327                lambdas: Array1::zeros(0),
5328            },
5329            FittedBlock {
5330                beta: array![0.3],
5331                role: BlockRole::Scale,
5332                edf: 1.0,
5333                lambdas: Array1::zeros(0),
5334            },
5335        ]);
5336        let mut payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5337        // Pass the time_basis presence check but deliberately omit the
5338        // anchor — this is exactly the partial-write shape that the CLI's
5339        // marginal-slope+time-wiggle save path had before the structural
5340        // refactor (main.rs previously set basis/degree/knots/keep_cols/
5341        // smooth_lambda but forgot the anchor).
5342        payload.survival_time_basis = Some("ispline".to_string());
5343
5344        let err = FittedModel::from_payload(payload)
5345            .validate_for_persistence()
5346            .expect_err("survival model without time-anchor metadata should fail validation");
5347        assert!(err.to_string().contains("missing survival_time_anchor"));
5348    }
5349
5350    #[test]
5351    fn validate_for_persistence_rejects_survival_without_time_basis_metadata() {
5352        let fit = saved_fit(vec![
5353            FittedBlock {
5354                beta: array![0.1],
5355                role: BlockRole::Time,
5356                edf: 1.0,
5357                lambdas: Array1::zeros(0),
5358            },
5359            FittedBlock {
5360                beta: array![0.2],
5361                role: BlockRole::Mean,
5362                edf: 1.0,
5363                lambdas: Array1::zeros(0),
5364            },
5365            FittedBlock {
5366                beta: array![0.3],
5367                role: BlockRole::Scale,
5368                edf: 1.0,
5369                lambdas: Array1::zeros(0),
5370            },
5371        ]);
5372        let payload = survival_marginal_slope_payload(MODEL_PAYLOAD_VERSION, fit);
5373
5374        let err = FittedModel::from_payload(payload)
5375            .validate_for_persistence()
5376            .expect_err("survival model without time-basis metadata should fail validation");
5377        assert!(err.to_string().contains("missing survival_time_basis"));
5378    }
5379
5380    #[test]
5381    fn saved_prediction_runtime_rejects_stale_payload_version() {
5382        let fit = saved_fit(vec![
5383            FittedBlock {
5384                beta: array![0.1],
5385                role: BlockRole::Mean,
5386                edf: 1.0,
5387                lambdas: Array1::zeros(0),
5388            },
5389            FittedBlock {
5390                beta: array![0.2],
5391                role: BlockRole::Scale,
5392                edf: 1.0,
5393                lambdas: Array1::zeros(0),
5394            },
5395        ]);
5396        let payload = marginal_slope_payload(MODEL_PAYLOAD_VERSION - 1, fit);
5397
5398        let err = FittedModel::from_payload(payload)
5399            .saved_prediction_runtime()
5400            .expect_err("stale payload version should fail before runtime assembly");
5401        assert!(err.to_string().contains("payload schema mismatch"));
5402    }
5403}