Skip to main content

gam_models/fit_orchestration/
request.rs

1use super::*;
2
3#[derive(Clone, Debug)]
4pub struct LinkWiggleConfig {
5    pub degree: usize,
6    pub num_internal_knots: usize,
7    pub penalty_orders: Vec<usize>,
8    pub double_penalty: bool,
9}
10
11/// Configuration for the second-stage binomial-mean wiggle fit appended to a
12/// standard pilot. The blockwise refit options live inside this struct so the
13/// pilot config (`link_kind` + `wiggle`) and its required `refit_options` can
14/// never disagree: either the whole standard-wiggle request is `Some`, or it
15/// is `None`. The previous shape had two sibling `Option` fields on
16/// `StandardFitRequest`, which allowed the materialize path to construct an
17/// inconsistent state (#320: linkwiggle config without blockwise options).
18#[derive(Clone)]
19pub struct StandardBinomialWiggleConfig {
20    pub link_kind: InverseLink,
21    pub wiggle: LinkWiggleConfig,
22    pub refit_options: BlockwiseFitOptions,
23}
24
25/// Clone-cheap training-matrix backing for a standard fit.
26///
27/// Ordinary formula fits borrow the projected [`Dataset`] matrix all the way
28/// through fitting. A latent-coordinate fit has to augment that matrix during
29/// materialization, so it moves the augmented allocation into an [`Arc`]. In
30/// both cases cloning this handle aliases the same storage; outer estimators
31/// such as expectile LAWS can therefore issue repeated fit requests without
32/// copying the complete `n x p` dataset on every iteration.
33#[derive(Clone)]
34pub enum StandardFitData<'a> {
35    Borrowed(ArrayView2<'a, f64>),
36    Shared(Arc<Array2<f64>>),
37}
38
39impl<'a> StandardFitData<'a> {
40    pub fn borrowed(data: ArrayView2<'a, f64>) -> Self {
41        Self::Borrowed(data)
42    }
43
44    pub fn shared(data: Array2<f64>) -> Self {
45        Self::Shared(Arc::new(data))
46    }
47
48    pub fn view(&self) -> ArrayView2<'_, f64> {
49        match self {
50            Self::Borrowed(data) => data.view(),
51            Self::Shared(data) => data.view(),
52        }
53    }
54
55    pub fn nrows(&self) -> usize {
56        match self {
57            Self::Borrowed(data) => data.nrows(),
58            Self::Shared(data) => data.nrows(),
59        }
60    }
61
62    pub fn ncols(&self) -> usize {
63        match self {
64            Self::Borrowed(data) => data.ncols(),
65            Self::Shared(data) => data.ncols(),
66        }
67    }
68
69    pub fn column(&self, index: usize) -> ArrayView1<'_, f64> {
70        match self {
71            Self::Borrowed(data) => data.column(index),
72            Self::Shared(data) => data.column(index),
73        }
74    }
75}
76
77pub struct StandardFitRequest<'a> {
78    pub data: StandardFitData<'a>,
79    /// Clone-cheap immutable response backing. Iterative estimators retain one
80    /// allocation while issuing multiple standard-fit requests.
81    pub y: Arc<Array1<f64>>,
82    /// Clone-cheap prior/working-weight backing. A new allocation is made only
83    /// when an estimator actually changes the weights.
84    pub weights: Arc<Array1<f64>>,
85    /// Clone-cheap immutable offset backing.
86    pub offset: Arc<Array1<f64>>,
87    pub spec: TermCollectionSpec,
88    pub family: LikelihoodSpec,
89    /// #2026: estimate the Tweedie variance power `p` by profile likelihood
90    /// (mgcv `tw()` semantics) before the final fit, rather than trusting the
91    /// `p` baked into `family`. Set only for a bare `family="tweedie"`/`"tw"`
92    /// request that named no explicit power; an explicit `tweedie(1.6)` pins `p`
93    /// and leaves this `false`. When `true`, `family` must carry
94    /// `ResponseFamily::Tweedie` on a log link (the placeholder power is
95    /// overwritten with the estimate).
96    pub estimate_tweedie_p: bool,
97    pub options: FitOptions,
98    pub kappa_options: SpatialLengthScaleOptimizationOptions,
99    pub wiggle: Option<StandardBinomialWiggleConfig>,
100    pub coefficient_groups: Vec<CoefficientGroupSpec>,
101    pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
102    pub latent_coord: Option<StandardLatentCoordConfig>,
103}
104
105pub struct GaussianLocationScaleFitRequest<'a> {
106    pub data: ArrayView2<'a, f64>,
107    pub spec: GaussianLocationScaleTermSpec,
108    pub wiggle: Option<LinkWiggleConfig>,
109    pub options: BlockwiseFitOptions,
110    pub kappa_options: SpatialLengthScaleOptimizationOptions,
111}
112
113pub struct BinomialLocationScaleFitRequest<'a> {
114    pub data: ArrayView2<'a, f64>,
115    pub spec: BinomialLocationScaleTermSpec,
116    pub wiggle: Option<LinkWiggleConfig>,
117    pub options: BlockwiseFitOptions,
118    pub kappa_options: SpatialLengthScaleOptimizationOptions,
119}
120
121pub struct DispersionLocationScaleFitRequest<'a> {
122    pub data: ArrayView2<'a, f64>,
123    pub spec: DispersionGlmLocationScaleTermSpec,
124    pub options: BlockwiseFitOptions,
125    pub kappa_options: SpatialLengthScaleOptimizationOptions,
126}
127
128pub struct SurvivalLocationScaleFitRequest<'a> {
129    pub data: ArrayView2<'a, f64>,
130    pub spec: SurvivalLocationScaleTermSpec,
131    pub wiggle: Option<LinkWiggleConfig>,
132    pub kappa_options: SpatialLengthScaleOptimizationOptions,
133    pub optimize_inverse_link: bool,
134}
135
136pub struct SurvivalTransformationFitRequest<'a> {
137    pub data: ArrayView2<'a, f64>,
138    pub spec: SurvivalTransformationTermSpec,
139    pub persistent_warm_start_store: Option<gam_runtime::warm_start::ConfiguredWarmStartStore>,
140}
141
142#[derive(Clone)]
143pub struct SurvivalTransformationTermSpec {
144    pub age_entry: Array1<f64>,
145    pub age_exit: Array1<f64>,
146    pub event_target: Array1<u8>,
147    pub weights: Array1<f64>,
148    pub covariate_spec: TermCollectionSpec,
149    pub covariate_offset: Array1<f64>,
150    pub baseline_cfg: crate::survival::SurvivalBaselineConfig,
151    pub likelihood_mode: crate::survival::SurvivalLikelihoodMode,
152    pub time_anchor: f64,
153    pub time_build: crate::survival::SurvivalTimeBuildOutput,
154    pub timewiggle: Option<LinkWiggleFormulaSpec>,
155    pub weibull_seed: Option<(f64, f64)>,
156    pub ridge_lambda: f64,
157    pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
158}
159pub struct BernoulliMarginalSlopeFitRequest<'a> {
160    pub data: ArrayView2<'a, f64>,
161    pub spec: BernoulliMarginalSlopeTermSpec,
162    pub options: BlockwiseFitOptions,
163    pub kappa_options: SpatialLengthScaleOptimizationOptions,
164    pub policy: gam_runtime::resource::ResourcePolicy,
165}
166
167pub struct SurvivalMarginalSlopeFitRequest<'a> {
168    pub data: ArrayView2<'a, f64>,
169    pub spec: SurvivalMarginalSlopeTermSpec,
170    pub options: BlockwiseFitOptions,
171    pub kappa_options: SpatialLengthScaleOptimizationOptions,
172}
173pub struct LatentSurvivalFitRequest<'a> {
174    pub data: ArrayView2<'a, f64>,
175    pub spec: LatentSurvivalTermSpec,
176    pub frailty: FrailtySpec,
177    pub options: BlockwiseFitOptions,
178}
179
180pub struct LatentBinaryFitRequest<'a> {
181    pub data: ArrayView2<'a, f64>,
182    pub spec: LatentBinaryTermSpec,
183    pub frailty: FrailtySpec,
184    pub options: BlockwiseFitOptions,
185}
186
187pub struct TransformationNormalFitRequest<'a> {
188    pub data: ArrayView2<'a, f64>,
189    pub response: Array1<f64>,
190    pub weights: Array1<f64>,
191    pub offset: Array1<f64>,
192    pub covariate_spec: TermCollectionSpec,
193    pub config: TransformationNormalConfig,
194    pub options: BlockwiseFitOptions,
195    pub kappa_options: SpatialLengthScaleOptimizationOptions,
196    pub warm_start: Option<TransformationWarmStart>,
197}
198pub enum FitRequest<'a> {
199    Standard(StandardFitRequest<'a>),
200    GaussianLocationScale(GaussianLocationScaleFitRequest<'a>),
201    BinomialLocationScale(BinomialLocationScaleFitRequest<'a>),
202    DispersionLocationScale(DispersionLocationScaleFitRequest<'a>),
203    SurvivalLocationScale(SurvivalLocationScaleFitRequest<'a>),
204    SurvivalTransformation(SurvivalTransformationFitRequest<'a>),
205    BernoulliMarginalSlope(BernoulliMarginalSlopeFitRequest<'a>),
206    SurvivalMarginalSlope(SurvivalMarginalSlopeFitRequest<'a>),
207    LatentSurvival(LatentSurvivalFitRequest<'a>),
208    LatentBinary(LatentBinaryFitRequest<'a>),
209    TransformationNormal(TransformationNormalFitRequest<'a>),
210}
211
212pub struct StandardFitResult {
213    pub fit: UnifiedFitResult,
214    pub design: TermCollectionDesign,
215    pub resolvedspec: TermCollectionSpec,
216    /// Per-smooth basis-adequacy evidence (#2774): the residual lack-of-fit
217    /// verdict for each smooth term, or a typed reason it could not be
218    /// measured. Empty for a result assembled before the report ran — the
219    /// report is attached by the single formula-fit seam that owns the
220    /// materialized covariate frame it needs, not by `fit_model`, which does
221    /// not know which numeric columns a smooth's covariates are.
222    pub basis_adequacy: Vec<crate::fit_orchestration::drivers::BasisAdequacyRow>,
223    /// Which resolved smooth positions originated from an auto-sized radial
224    /// spatial basis. Freeze replaces center strategies with explicit center
225    /// matrices, so this provenance must travel beside the result for the
226    /// adaptive resolution loop.
227    pub adaptive_spatial_terms: Vec<bool>,
228    /// Requested (pre-freeze) center counts aligned with
229    /// `adaptive_spatial_terms`. Frozen specs store realized center matrices,
230    /// whose row count can include periodic image expansion and is therefore
231    /// not the next request size.
232    pub adaptive_spatial_center_counts: Vec<Option<usize>>,
233    pub adaptive_diagnostics: Option<AdaptiveRegularizationDiagnostics>,
234    pub kappa_timing: Option<SpatialLengthScaleOptimizationTiming>,
235    pub saved_link_state: FittedLinkState,
236    pub wiggle_knots: Option<Array1<f64>>,
237    pub wiggle_degree: Option<usize>,
238    /// Exact canonical function-penalty semantics and smoothing-parameter
239    /// order used by the fitted link-wiggle block.
240    pub wiggle_penalty_metadata: Option<WigglePenaltyMetadata>,
241    /// Standard-basis link-warp coefficients `β_w = Z·γ` for the saved-model
242    /// predict runtime when the frozen-basis de-aliasing engaged (#1596). The
243    /// fit's coefficients stay in the reduced `γ` coordinate; this lift is
244    /// persisted into the payload's `beta_link_wiggle`.
245    pub wiggle_saved_warp_beta: Option<Vec<f64>>,
246    /// Frozen-index mean-coordinate shift for the predict runtime (#2141),
247    /// persisted into the payload's `link_wiggle_index_shift`. Lets predict
248    /// evaluate the warp basis at the frozen index `η̂` the fit pinned it at,
249    /// rather than at the de-aliased base predictor.
250    pub wiggle_saved_index_shift: Option<Vec<f64>>,
251}
252
253pub(crate) fn adaptive_spatial_term_mask(spec: &TermCollectionSpec) -> Vec<bool> {
254    fn auto_spatial(basis: &gam_terms::smooth::SmoothBasisSpec) -> bool {
255        use gam_terms::smooth::SmoothBasisSpec as B;
256        match basis {
257            B::ByVariable { inner, .. } | B::FactorSumToZero { inner, .. } => auto_spatial(inner),
258            B::BySmooth { smooth, .. } => auto_spatial(smooth),
259            B::ThinPlate {
260                feature_cols, spec, ..
261            } => {
262                !feature_cols.is_empty()
263                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
264            }
265            B::Duchon {
266                feature_cols, spec, ..
267            } => {
268                !feature_cols.is_empty()
269                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
270            }
271            // Matérn's learned range changes both its basin and realized kernel
272            // rank as centers move. It has no validated EDF-saturation growth
273            // theorem yet, so the generic radial grow loop must not claim it.
274            B::Matern { .. } => false,
275            B::ConstantCurvature { feature_cols, spec } => {
276                !feature_cols.is_empty()
277                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
278            }
279            B::MeasureJet {
280                feature_cols, spec, ..
281            } => {
282                !feature_cols.is_empty()
283                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
284            }
285            _ => false,
286        }
287    }
288
289    spec.smooth_terms
290        .iter()
291        .map(|term| auto_spatial(&term.basis))
292        .collect()
293}
294
295pub(crate) fn adaptive_spatial_center_counts(spec: &TermCollectionSpec) -> Vec<Option<usize>> {
296    fn center_count(basis: &gam_terms::smooth::SmoothBasisSpec) -> Option<usize> {
297        use gam_terms::smooth::SmoothBasisSpec as B;
298        match basis {
299            B::ByVariable { inner, .. } | B::FactorSumToZero { inner, .. } => center_count(inner),
300            B::BySmooth { smooth, .. } => center_count(smooth),
301            B::ThinPlate {
302                feature_cols, spec, ..
303            } if !feature_cols.is_empty() => {
304                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
305            }
306            B::Duchon {
307                feature_cols, spec, ..
308            } if !feature_cols.is_empty() => {
309                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
310            }
311            B::Matern { .. } => None,
312            B::ConstantCurvature { feature_cols, spec } if !feature_cols.is_empty() => {
313                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
314            }
315            B::MeasureJet {
316                feature_cols, spec, ..
317            } if !feature_cols.is_empty() => {
318                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
319            }
320            _ => None,
321        }
322    }
323
324    spec.smooth_terms
325        .iter()
326        .map(|term| center_count(&term.basis))
327        .collect()
328}
329
330pub struct SurvivalLocationScaleFitResult {
331    pub fit: SurvivalLocationScaleTermFitResult,
332    pub inverse_link: InverseLink,
333    pub wiggle_knots: Option<Array1<f64>>,
334    pub wiggle_degree: Option<usize>,
335    /// Distinct proof for outer inverse-link profiling. The nested unified fit
336    /// retains its own smoothing/spatial certificate; optimized-link results
337    /// retain this sealed carrier instead of overwriting or dropping either
338    /// optimization layer's evidence. `None` means the inverse link was fixed.
339    pub(crate) inverse_link_outer: Option<gam_solve::rho_optimizer::CertifiedOuterResult>,
340}
341
342impl SurvivalLocationScaleFitResult {
343    pub fn inverse_link_outer(&self) -> Option<&gam_solve::rho_optimizer::CertifiedOuterResult> {
344        self.inverse_link_outer.as_ref()
345    }
346}
347
348pub struct SurvivalTransformationFitResult {
349    pub fit: UnifiedFitResult,
350    pub resolvedspec: TermCollectionSpec,
351    pub baseline_cfg: crate::survival::SurvivalBaselineConfig,
352    pub likelihood_mode: crate::survival::SurvivalLikelihoodMode,
353    /// Persistable snapshot of the time basis used during the fit. Replaces
354    /// six previously flat fields (basisname / degree / knots / keep_cols /
355    /// smooth_lambda / anchor) so the FFI save path consumes a single
356    /// source-of-truth value rather than threading siblings independently.
357    pub time_basis: crate::survival::SavedSurvivalTimeBasis,
358    pub time_base_ncols: usize,
359    pub baseline_timewiggle: Option<TimeWiggleBlockInput>,
360}
361
362pub enum FitResult {
363    Standard(StandardFitResult),
364    GaussianLocationScale(GaussianLocationScaleFitResult),
365    BinomialLocationScale(BinomialLocationScaleFitResult),
366    DispersionLocationScale(DispersionLocationScaleFitResult),
367    SurvivalLocationScale(SurvivalLocationScaleFitResult),
368    SurvivalTransformation(SurvivalTransformationFitResult),
369    BernoulliMarginalSlope(BernoulliMarginalSlopeFitResult),
370    SurvivalMarginalSlope(SurvivalMarginalSlopeFitResult),
371    LatentSurvival(LatentSurvivalTermFitResult),
372    LatentBinary(LatentBinaryTermFitResult),
373    TransformationNormal(TransformationNormalFitResult),
374    /// Exact O(n) state-space cubic/linear/quintic smoothing-spline scan
375    /// (#1030/#1034). A scan-bearing model IS a Gaussian-identity model with a
376    /// different (exact) representation: rather than a dense design + coefficient
377    /// vector it carries the Durbin–Koopman smoother posterior directly (knots,
378    /// smoothed states, pointwise variances, σ², log λ, exact diffuse-REML EDF,
379    /// and an exact per-row `predict`). Library callers that want the fitted
380    /// posterior get it here without paying the dense O(n·k²)+O(k³) route; the
381    /// CLI/FFI save paths build the persistence payload from the same
382    /// `SplineScanFit` via `assemble_spline_scan_payload`.
383    SplineScan(gam_solve::spline_scan::SplineScanFit),
384    /// O(n log n) multiresolution residual-cascade smooth (#1032). UNLIKE the
385    /// 1-D scan, the cascade is NOT the same posterior as the Duchon/Matérn term
386    /// it stands in for (a different finite basis — the multilevel Wendland
387    /// frame), so it is never a silent swap: this variant is produced only when
388    /// the structural detector [`residual_cascade_fast_path`] fires on an
389    /// eligible scattered-low-d Gaussian fit past the dense-kernel cliff and
390    /// every in-cascade proof succeeds. Structurally ineligible shapes stay on
391    /// the dense `fit_model` path; after the cascade route is selected, a
392    /// quasi-uniformity, automatic-REML, or convergence refusal propagates
393    /// instead of silently changing estimators. The cascade-bearing model carries the
394    /// [`ResidualCascadeFit`](gam_solve::residual_cascade::ResidualCascadeFit)
395    /// directly — knots-free nested geometry, coefficients, the factored
396    /// precision, and an exact per-row `predict`; the CLI/FFI save paths build
397    /// the persistence payload from its `to_state` snapshot.
398    ResidualCascade(gam_solve::residual_cascade::ResidualCascadeFit),
399}
400
401/// Result of a dispersion-channel GAMLSS location-scale fit (#913). Wraps the
402/// shared two-block [`BlockwiseTermFitResult`] (mean + log-precision designs
403/// and coefficients) plus the family kind so the save path can stamp the right
404/// likelihood. These families have no link-wiggle and no response
405/// standardization, so the result is a thin wrapper.
406pub struct DispersionLocationScaleFitResult {
407    pub fit: BlockwiseTermFitResult,
408    pub kind: DispersionFamilyKind,
409}
410
411/// Out-of-fold Stage-1 latent score and its score-influence Jacobian for a
412/// CTN → marginal-slope chain. `z_oof` (length n) replaces the in-sample `z`
413/// the Stage-2 model consumes; `jac_oof` (n × p₁) is fed to the Stage-2 spec's
414/// `score_influence_jacobian` so the joint solve absorbs the realized leakage
415/// directions `Z_infl = diag(s_f·β̂₀)·J`.
416pub struct CrossFitScoreCalibration {
417    pub z_oof: Array1<f64>,
418    pub jac_oof: Array2<f64>,
419}
420
421/// Internal recipe describing the CTN Stage-1 fit that produced a Stage-2 `z`
422/// column. This is in-process plumbing — never a CLI flag, env var, or feature
423/// gate. The orchestration layer populates [`FitConfig::ctn_stage1`] when (and
424/// only when) the marginal-slope `z` was generated by a transformation-normal
425/// Stage-1 fit; its presence is the sole auto-enable signal for cross-fitted
426/// orthogonalization (design §5). When absent, Stage-2 falls back to the free
427/// 1-D `score_warp` spline (which spans only the x-free leakage column).
428#[derive(Clone, Debug)]
429pub struct CtnStage1Recipe {
430    /// Stage-1 response column name (the `y` the CTN transforms).
431    pub response_column: String,
432    /// Stage-1 covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"`),
433    /// with no `~` and no response symbol. `crossfit_score_calibration` parses
434    /// it and builds the CTN covariate basis exactly as
435    /// `materialize_transformation_normal` does, then FREEZES that basis once on
436    /// the full data and reuses the frozen spec for every fold's refit — so the
437    /// rebuilt covariate design has an identical column geometry across folds,
438    /// keeping `J`'s `p₁ = p_resp · p_cov` columns aligned (design §3).
439    ///
440    /// The recipe carries the formula RHS (a primitive string) rather than a
441    /// resolved [`TermCollectionSpec`] because this struct is populated both via
442    /// [`CtnStage1Recipe::new`] (set on [`FitConfig::ctn_stage1`], then
443    /// [`fit_from_formula`]) and by the gamfit FFI marshaller
444    /// (`gamfit/_calibrated_slope.py`), which can only serialize primitives over
445    /// the JSON boundary — a `TermCollectionSpec` is not serializable. Freezing on
446    /// the full Stage-2 data is equivalent to
447    /// freezing on the Stage-1 data whenever the two stages share a frame (the
448    /// calibrated-chain contract), so the column geometry still matches Stage-1.
449    pub covariate_formula_rhs: String,
450    /// Stage-1 CTN config (response basis degree / knot count / penalties).
451    /// Its `response_num_internal_knots` is the FIXED response-basis size; the
452    /// cross-fit pins it across folds so `p_resp` (and hence `p₁`) is
453    /// fold-invariant (design §3).
454    pub config: TransformationNormalConfig,
455    /// Optional Stage-1 weight column name.
456    pub weight_column: Option<String>,
457    /// Optional Stage-1 offset column name.
458    pub offset_column: Option<String>,
459}
460
461impl CtnStage1Recipe {
462    /// Build a Stage-1 CTN recipe from the Stage-1 description. This is the public
463    /// way to populate [`FitConfig::ctn_stage1`] — set it on a marginal-slope
464    /// config and run [`fit_from_formula`] (the entry IS `fit_from_formula` with
465    /// `ctn_stage1` set; there is no separate combined entry function). The
466    /// materializer then cross-fits the CTN and installs the leakage-projection
467    /// block; supplying the recipe *is* the request for orthogonalization.
468    ///
469    /// `response` is the Stage-1 CTN response column; `covariates` is the
470    /// covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"` — no `~`,
471    /// no response symbol). Validates both are non-empty and that `covariates`
472    /// is an RHS only.
473    pub fn new(
474        response: &str,
475        covariates: &str,
476        config: TransformationNormalConfig,
477        weight_column: Option<&str>,
478        offset_column: Option<&str>,
479    ) -> Result<Self, String> {
480        let response_column = response.trim().to_string();
481        if response_column.is_empty() {
482            return Err("CtnStage1Recipe requires a non-empty Stage-1 response column".to_string());
483        }
484        let covariate_formula_rhs = covariates.trim().to_string();
485        if covariate_formula_rhs.is_empty() {
486            return Err(
487                "CtnStage1Recipe requires a non-empty Stage-1 covariate formula RHS".to_string(),
488            );
489        }
490        if covariate_formula_rhs.contains('~') {
491            return Err(
492                "CtnStage1Recipe covariates is a right-hand side only; pass 's(pc1) + s(pc2)', \
493                 not 'score ~ s(pc1) + s(pc2)'"
494                    .to_string(),
495            );
496        }
497        Ok(Self {
498            response_column,
499            covariate_formula_rhs,
500            config,
501            weight_column: weight_column
502                .map(str::to_string)
503                .filter(|s| !s.trim().is_empty()),
504            offset_column: offset_column
505                .map(str::to_string)
506                .filter(|s| !s.trim().is_empty()),
507        })
508    }
509}
510#[derive(Clone, Debug)]
511pub struct FitConfig {
512    /// Family: "gaussian", "binomial", "poisson", "negative-binomial",
513    /// "gamma", "tweedie" (alias "tw"; variance power fixed at p = 1.5), or
514    /// None for auto-detect.
515    pub family: Option<String>,
516    /// Fixed size/overdispersion parameter for `family="negative-binomial"`.
517    pub negative_binomial_theta: Option<f64>,
518    /// Link: "identity", "logit", "probit", "cloglog", "sas", "beta-logistic", or None.
519    pub link: Option<String>,
520    /// Whether to use flexible (wiggle-augmented) link.
521    pub flexible_link: bool,
522    /// Optional additive offset column for the primary linear predictor.
523    pub offset_column: Option<String>,
524    /// Optional additive offset column for the noise/log-scale predictor.
525    pub noise_offset_column: Option<String>,
526    /// Family-level frailty. `None` is represented only by
527    /// [`FrailtySpec::None`]; an outer `Option` would create two null states.
528    pub frailty: FrailtySpec,
529
530    // Survival-specific
531    /// Baseline target: "linear", "weibull", "gompertz", "gompertz-makeham".
532    pub baseline_target: String,
533    pub baseline_scale: Option<f64>,
534    pub baseline_shape: Option<f64>,
535    pub baseline_rate: Option<f64>,
536    pub baseline_makeham: Option<f64>,
537    /// Time basis: "ispline" or "none".
538    pub time_basis: String,
539    pub time_degree: usize,
540    pub time_num_internal_knots: usize,
541    pub time_smooth_lambda: f64,
542    /// Survival likelihood mode: `Some("transformation" | "location-scale" |
543    /// "weibull" | "marginal-slope" | "latent" | "latent-binary")`, or `None`
544    /// (the default), which resolves to `"transformation"` at the `Surv(...)`
545    /// materialization seam via [`FitConfig::resolved_survival_likelihood`]
546    /// (#2301 — no library-side string default). `Some(_)` on a non-survival
547    /// response is a typed configuration error.
548    pub survival_likelihood: Option<String>,
549    /// Explicit centering anchor for the baseline time basis, in the data's own
550    /// time units. `None` (the default) lets
551    /// [`resolve_survival_time_anchor_for_mode`] pick it from the likelihood mode
552    /// and the truncation shape of the data: the robust interior median exit for
553    /// marginal-slope and for any genuinely left-truncated dataset (#751/#1790),
554    /// the earliest entry age otherwise.
555    ///
556    /// This is model configuration, not front-end transport (#2631). It used to
557    /// exist only as the CLI's `--survival-time-anchor`, which meant the flag was
558    /// silently dropped on the CLI's own default (transformation / Weibull)
559    /// route — that route delegates to `fit_from_formula`, which had nowhere to
560    /// receive it — and a `gam.fit-request` document could not express the anchor
561    /// even though the flag declares a conflict with `--request`.
562    ///
563    /// [`resolve_survival_time_anchor_for_mode`]: crate::survival::resolve_survival_time_anchor_for_mode
564    pub survival_time_anchor: Option<f64>,
565    /// Residual distribution: "gaussian", "logistic", "gumbel".
566    pub survival_distribution: String,
567    pub threshold_time_k: Option<usize>,
568    pub threshold_time_degree: usize,
569    pub sigma_time_k: Option<usize>,
570    pub sigma_time_degree: usize,
571    /// Number of B-spline basis functions on the `log t` margin of the
572    /// **log-slope** block for the survival marginal-slope family (gam#2765,
573    /// gam#2767). `None` — the default — is a slope that does not move along
574    /// follow-up. Any `Some(k)` makes `b` a fitted surface in `(x, t)`: the
575    /// log-slope covariate design is tensored against the time margin and the
576    /// row program carries `b` at the row's entry time, at its exit time, and
577    /// the exit-time rate, so the event density picks up the `q₁·c′₁ + ḃᵀz`
578    /// terms a constant slope zeroes out.
579    pub logslope_time_k: Option<usize>,
580    /// Polynomial degree of that margin. Shares the default (`3`) and the
581    /// `k >= degree + 1` admission rule with the threshold and sigma margins.
582    pub logslope_time_degree: usize,
583
584    // Location-scale (GAMLSS)
585    /// If set, fit a location-scale model with this formula for the noise parameter.
586    pub noise_formula: Option<String>,
587
588    // Marginal-slope
589    /// Formula for the log-slope model (survival marginal-slope or Bernoulli marginal-slope).
590    pub logslope_formula: Option<String>,
591    /// Column name for the z (exposure/dose) variable in marginal-slope models.
592    pub z_column: Option<String>,
593    /// Optional non-negative per-row training weights column.
594    pub weight_column: Option<String>,
595    /// Expectile asymmetry `τ ∈ (0, 1)` for `family = "expectile"`.
596    ///
597    /// When `family` resolves to `"expectile"` the fit minimizes the
598    /// Newey–Powell asymmetric squared loss `Σ wᵢ(τ)·(yᵢ − μᵢ)²` with
599    /// `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`, tracing the conditional
600    /// `τ`-expectile — the smooth analogue of the `τ`-quantile. `τ = 0.5`
601    /// reduces exactly to the Gaussian-identity mean fit. The whole penalized
602    /// smooth + REML `λ`-selection machinery is reused via a Least
603    /// Asymmetrically Weighted Squares (LAWS) outer loop. `None` defaults to
604    /// the median expectile `τ = 0.5` when the family is `"expectile"`; it is
605    /// ignored for every other family. The asymmetry may also be written inline
606    /// as `family = "expectile(0.9)"`, which fills this field at resolve time.
607    pub expectile_tau: Option<f64>,
608    /// Internal CTN Stage-1 provenance for the marginal-slope `z` column.
609    ///
610    /// When the marginal-slope `z` was generated by a transformation-normal
611    /// Stage-1 fit, the orchestration layer fills this with the Stage-1 recipe.
612    /// Its presence is the sole auto-enable signal for cross-fitted, Neyman-
613    /// orthogonal score calibration (#461): the materializer cross-fits the CTN
614    /// to produce out-of-fold `z` and the score-influence Jacobian `J`, replaces
615    /// the raw `z` with `z_oof`, and absorbs `J` as a leakage-projection block in
616    /// Stage-2. This is in-process plumbing only — there is no CLI flag, env var,
617    /// or feature gate. `None` ⇒ raw `z` with the free-warp `score_warp`
618    /// fallback. See [`CtnStage1Recipe`].
619    pub ctn_stage1: Option<CtnStage1Recipe>,
620
621    // Fitting options
622    pub scale_dimensions: bool,
623    /// Spatial length-scale/anisotropy optimization policy shared by every
624    /// formula family. Front ends must set model-wide spatial knobs here rather
625    /// than mutating a request after materialization.
626    pub spatial_optimization: SpatialLengthScaleOptimizationOptions,
627    /// Enable exact spatial adaptive regularization for standard formula fits.
628    /// `None` uses the quality-first automatic policy. The current automatic
629    /// policy leaves LAREG off unless explicitly requested because the
630    /// optimizer's REML-selected local weights can over-regularize small
631    /// high-yield spatial signals.
632    pub adaptive_regularization: Option<bool>,
633    pub ridge_lambda: f64,
634
635    /// Route the fit through the transformation-normal family.  When set, the
636    /// formula terms are treated as the covariate side of the transformation
637    /// model and the response basis is built internally.  Incompatible with
638    /// `noise_formula` and with `Surv(...)` responses.
639    pub transformation_normal: bool,
640
641    /// Enable Firth bias reduction for standard single-parameter families.
642    pub firth: bool,
643    /// Optional cap on the REML/LAML outer smoothing-parameter iterations for
644    /// standard formula fits. `None` uses the production default.
645    pub outer_max_iter: Option<usize>,
646
647    /// GPU backend selection policy. `Auto` uses supported device kernels for
648    /// large workloads, `Off` pins execution to CPU kernels, and `Required` fails
649    /// loudly when a requested GPU kernel has no compiled backend.
650    pub gpu_policy: gam_gpu::GpuPolicy,
651    /// Optional override of the [`gam_runtime::resource::ResourcePolicy`] used when
652    /// planning spatial bases (TPS / Matern / Duchon) during term construction.
653    /// When `None`, the default-library policy is used.
654    pub resource_policy: Option<gam_runtime::resource::ResourcePolicy>,
655
656    /// Optional per-group metadata supplied by the caller. Fitting ignores this
657    /// field; saved-model builders pass it through so deployment consumers can
658    /// recover group provenance.
659    pub group_metadata: Option<BTreeMap<String, JsonValue>>,
660
661    /// Container type of the caller's training table (`"pandas"`, `"polars"`,
662    /// `"pyarrow"`, `"numpy"`, or `"unknown"` outside a typed table frontend).
663    /// Fitting ignores this field; saved-model builders persist it so every
664    /// current frontend writes the same complete model schema.
665    pub training_table_kind: String,
666
667    /// Optional user-defined coefficient groups with separate precision
668    /// parameters. Group-local priors, including catalog-metadata-informed
669    /// Gamma precision hyperpriors, are resolved during design setup.
670    pub coefficient_groups: Vec<CoefficientGroupSpec>,
671
672    /// Optional per-existing-penalty-block Gamma(shape, rate) precision
673    /// hyperpriors keyed by penalty-block label. This is the
674    /// catalog-metadata-informed-prior hook for models that do not need a new
675    /// user-defined coefficient group.
676    pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
677
678    /// Python `gamfit.fit(..., latents={...})` configuration. This reaches
679    /// the standard formula workflow as an owned latent-coordinate block:
680    /// the named smooth's synthetic covariates are rebuilt from `t`, and
681    /// joint REML optimizes `[rho, vec(t)]` through latent design hyper-dirs.
682    pub latents: Option<JsonValue>,
683    /// Python `gamfit.fit(..., penalties=[...])` analytic-penalty descriptors,
684    /// validated against the declared latent-coordinate blocks before a
685    /// standard latent fit starts.
686    pub analytic_penalties: Option<JsonValue>,
687    /// `gamfit.fit(..., smooths={...})` Python kwarg routed through the FFI
688    /// bridge. JSON object keyed by formula symbol (single column name or
689    /// comma-joined tuple) → smooth descriptor (`{"kind": "duchon",
690    /// "centers": [[...], ...], ...}`). Applied as a post-processing step on
691    /// the [`TermCollectionSpec`] produced by the formula DSL: each smooth
692    /// term whose `feature_cols` match a registry key has its kind-specific
693    /// tunables (centers, knots, kernel hyperparameters) overridden with the
694    /// user-supplied values. The single canonical lowering path guarantees
695    /// `smooths={"x": Duchon(centers=K)}` (integer) produces a bit-identical
696    /// block spec to writing `duchon(x, centers=K)` in the formula; only
697    /// explicit array-valued `centers=` differs, routing through
698    /// `CenterStrategy::UserProvided` instead of `FarthestPoint`/`EqualMass`.
699    pub smooth_overrides: Option<JsonValue>,
700    /// Explicit cross-process warm-start capability.
701    ///
702    /// Default `None`: ordinary fits never consult or write an ambient
703    /// machine-global cache. Call
704    /// [`FitConfig::with_persistent_warm_start_root`] to opt in with a
705    /// caller-owned root. The configured capability is lazy and clone-shared,
706    /// so validation creates no directories and every standard, survival, and
707    /// custom-family owner uses one opened store handle.
708    pub persistent_warm_start_store: Option<gam_runtime::warm_start::ConfiguredWarmStartStore>,
709    /// Per-smooth spatial center requests maintained by the adaptive
710    /// fit→expand→refit loop. Outer `None` means no loop owns this request, so
711    /// raw materialization keeps the ordinary full basis. `Some` activates the
712    /// canonical formula workflow: missing inner entries select the structural
713    /// identifiable start and `Some(k)` requests the next evidence-backed
714    /// resolution for that smooth only. This is in-process orchestration state,
715    /// never a user knob or environment setting.
716    pub spatial_center_counts: Option<Vec<Option<usize>>>,
717    /// Whether to precompute the distribution-free conformal substrates (#942
718    /// jackknife+, #1098 exact full-conformal) at fit time and persist them on
719    /// the saved model. `None` keeps the historical behaviour of precomputing
720    /// whenever the fit is eligible; `Some(false)` skips both.
721    ///
722    /// The trade-off, measured on `y ~ s(x1,k=6) + s(x2,k=6)` (#2633): the two
723    /// substrates are **94% of a saved Gaussian model at n=20,000** (10.2 MB of
724    /// 10.85 MB) and grow linearly with the training rows, because they are
725    /// per-row. Rebuilding both costs **~5.6 ms**, 0.3% of the fit that produced
726    /// them. So keeping them buys single-digit milliseconds at roughly half a
727    /// kilobyte per training row, forever — turning the flag off yields a **~16x
728    /// smaller** model (10.85 MB -> ~0.65 MB at n=20,000).
729    ///
730    /// It is opt-OUT rather than opt-in for one reason: rebuilding a substrate
731    /// needs the training design AND response back, and a saved model
732    /// deliberately does not carry the training rows. So a model that will be
733    /// shipped to a host that never sees the training data must keep them, or it
734    /// cannot produce a conformal interval at all. Turn this off when the caller
735    /// retains its training data, fits in batch, or never asks for conformal
736    /// intervals; leave it alone when the model has to stand on its own.
737    pub precompute_conformal: Option<bool>,
738    /// Whether the fit computes and publishes a coefficient covariance (and the
739    /// standard errors derived from it). `None` keeps each family's own
740    /// default, which for every path that reaches this field today is "yes";
741    /// `Some(false)` asks for point estimates only.
742    ///
743    /// This exists because it was advice nobody could take (gam#2718). The
744    /// bernoulli marginal-slope refusal for a non-StandardNormal latent measure
745    /// told callers to "fit without inference if only point estimates are
746    /// needed", while `materialize/marginal_slope.rs` set
747    /// `compute_covariance = true` unconditionally, so there was no way to
748    /// comply. The mechanism was never missing — the latent survival/binary CLI
749    /// path has been passing `compute_covariance: false` in production all
750    /// along — only a way for a caller to reach it.
751    ///
752    /// Declining inference is not a way to make a bad covariance acceptable: a
753    /// fit that WOULD have withheld its covariance still withholds it and still
754    /// declares why (see `CovarianceDeclined`). This only avoids paying for one
755    /// that is never read.
756    pub compute_covariance: Option<bool>,
757}
758
759impl Default for FitConfig {
760    fn default() -> Self {
761        Self {
762            precompute_conformal: None,
763            compute_covariance: None,
764            family: None,
765            negative_binomial_theta: None,
766            link: None,
767            flexible_link: false,
768            offset_column: None,
769            noise_offset_column: None,
770            frailty: FrailtySpec::None,
771            baseline_target: "linear".into(),
772            baseline_scale: None,
773            baseline_shape: None,
774            baseline_rate: None,
775            baseline_makeham: None,
776            time_basis: "ispline".into(),
777            time_degree: 3,
778            time_num_internal_knots: 8,
779            time_smooth_lambda: 1e-2,
780            survival_likelihood: None,
781            survival_time_anchor: None,
782            survival_distribution: "gaussian".into(),
783            threshold_time_k: None,
784            threshold_time_degree: 3,
785            sigma_time_k: None,
786            sigma_time_degree: 3,
787            logslope_time_k: None,
788            logslope_time_degree: 3,
789            noise_formula: None,
790            logslope_formula: None,
791            z_column: None,
792            weight_column: None,
793            expectile_tau: None,
794            ctn_stage1: None,
795            scale_dimensions: false,
796            spatial_optimization: SpatialLengthScaleOptimizationOptions::default(),
797            adaptive_regularization: None,
798            ridge_lambda: 1e-6,
799            transformation_normal: false,
800            firth: false,
801            outer_max_iter: None,
802            gpu_policy: gam_gpu::GpuPolicy::Auto,
803            resource_policy: None,
804            group_metadata: None,
805            training_table_kind: "unknown".to_string(),
806            coefficient_groups: Vec::new(),
807            penalty_block_gamma_priors: Vec::new(),
808            latents: None,
809            analytic_penalties: None,
810            smooth_overrides: None,
811            persistent_warm_start_store: None,
812            spatial_center_counts: None,
813        }
814    }
815}
816/// The result of materializing a formula + config against a dataset.
817pub struct MaterializedModel<'a> {
818    pub request: FitRequest<'a>,
819    pub inference_notes: Vec<String>,
820    /// The survival time basis THIS materialization built, including the time
821    /// anchor it centered at. Persistence must record the basis the fit
822    /// actually used; re-deriving it downstream from the `FitConfig` silently
823    /// diverged whenever the two derivations disagreed — the left-truncation
824    /// anchor switch in `materialize_survival` had no counterpart in the save
825    /// path, so a left-truncated location-scale model persisted an anchor its
826    /// own fit never used (#2470). `None` for every non-survival request.
827    pub survival_time_basis: Option<crate::survival::SavedSurvivalTimeBasis>,
828}
829pub struct SplineScanInputs {
830    /// Abscissae of the single 1-D smooth (training rows of its feature column).
831    pub x: Vec<f64>,
832    /// Gaussian response.
833    pub y: Vec<f64>,
834    /// Observation weights (variance is `σ²/w`).
835    pub w: Vec<f64>,
836    /// Smoothing-spline order `m = penalty_order ∈ {1, 2, 3}`: `m = 1` the
837    /// random-walk/linear smoother (penalty `λ∫f′²`), `m = 2` the cubic
838    /// smoother (penalty `λ∫f″²`), `m = 3` the quintic smoother (penalty
839    /// `λ∫(f‴)²`).
840    pub order: usize,
841}
842pub struct ResidualCascadeInputs {
843    /// One slice per coordinate axis (2 or 3) of the single scattered smooth.
844    pub coords: Vec<Vec<f64>>,
845    /// Gaussian response.
846    pub y: Vec<f64>,
847    /// Observation weights (variance is `σ²/w`).
848    pub w: Vec<f64>,
849    /// Per-axis positive metric scaling `diag(metric)` of `z = diag(metric)·x`.
850    pub metric: Vec<f64>,
851    /// Sobolev smoothness order `s` of the multilevel Wendland-(3,1) prior,
852    /// clamped into the native-space window `(d/2, (d+3)/2]` (issue caveat 1).
853    pub sobolev_s: f64,
854}
855
856#[cfg(test)]
857mod default_workflow_policy_tests {
858    use super::*;
859
860    #[test]
861    fn formula_fits_are_disk_silent_by_default() {
862        let config = FitConfig::default();
863        assert!(config.persistent_warm_start_store.is_none());
864        let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
865        assert!(options.persistent_warm_start_store.is_none());
866    }
867
868    #[test]
869    fn raw_materialization_does_not_activate_adaptive_spatial_resolution() {
870        assert!(
871            FitConfig::default().spatial_center_counts.is_none(),
872            "raw materialization must not activate a grow loop it does not own"
873        );
874    }
875
876    #[test]
877    fn explicit_root_threads_one_lazy_store_capability() {
878        let directory = tempfile::tempdir().expect("create explicit store parent");
879        let root = directory.path().join("chosen-root");
880        let config = FitConfig::default().with_persistent_warm_start_root(root.clone());
881        let configured = config
882            .persistent_warm_start_store
883            .as_ref()
884            .expect("explicit root must configure persistence");
885        assert_eq!(configured.root(), root);
886        assert!(!root.exists(), "configuration must remain lazy");
887
888        let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
889        let threaded = options
890            .persistent_warm_start_store
891            .as_ref()
892            .expect("canonical options must retain the configured store");
893        assert_eq!(threaded.root(), root);
894    }
895}