Skip to main content

gam_models/inference/
model.rs

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