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