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    /// See [`gam_custom_family::BlockwiseFitOptions::cache_session`].
135    /// Threaded into the internally constructed `BlockwiseFitOptions` by
136    /// `fit_survival_location_scale_model`.
137    pub cache_session: Option<std::sync::Arc<gam_runtime::warm_start::Session>>,
138}
139
140pub struct SurvivalTransformationFitRequest<'a> {
141    pub data: ArrayView2<'a, f64>,
142    pub spec: SurvivalTransformationTermSpec,
143    /// See [`gam_custom_family::BlockwiseFitOptions::cache_session`].
144    /// Threaded into the internally constructed `BlockwiseFitOptions` by
145    /// `fit_survival_transformation_model`.
146    pub cache_session: Option<std::sync::Arc<gam_runtime::warm_start::Session>>,
147}
148
149#[derive(Clone)]
150pub struct SurvivalTransformationTermSpec {
151    pub age_entry: Array1<f64>,
152    pub age_exit: Array1<f64>,
153    pub event_target: Array1<u8>,
154    pub weights: Array1<f64>,
155    pub covariate_spec: TermCollectionSpec,
156    pub covariate_offset: Array1<f64>,
157    pub baseline_cfg: crate::survival::SurvivalBaselineConfig,
158    pub likelihood_mode: crate::survival::SurvivalLikelihoodMode,
159    pub time_anchor: f64,
160    pub time_build: crate::survival::SurvivalTimeBuildOutput,
161    pub timewiggle: Option<LinkWiggleFormulaSpec>,
162    pub weibull_seed: Option<(f64, f64)>,
163    pub ridge_lambda: f64,
164    pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
165}
166pub struct BernoulliMarginalSlopeFitRequest<'a> {
167    pub data: ArrayView2<'a, f64>,
168    pub spec: BernoulliMarginalSlopeTermSpec,
169    pub options: BlockwiseFitOptions,
170    pub kappa_options: SpatialLengthScaleOptimizationOptions,
171    pub policy: gam_runtime::resource::ResourcePolicy,
172}
173
174pub struct SurvivalMarginalSlopeFitRequest<'a> {
175    pub data: ArrayView2<'a, f64>,
176    pub spec: SurvivalMarginalSlopeTermSpec,
177    pub options: BlockwiseFitOptions,
178    pub kappa_options: SpatialLengthScaleOptimizationOptions,
179}
180pub struct LatentSurvivalFitRequest<'a> {
181    pub data: ArrayView2<'a, f64>,
182    pub spec: LatentSurvivalTermSpec,
183    pub frailty: FrailtySpec,
184    pub options: BlockwiseFitOptions,
185}
186
187pub struct LatentBinaryFitRequest<'a> {
188    pub data: ArrayView2<'a, f64>,
189    pub spec: LatentBinaryTermSpec,
190    pub frailty: FrailtySpec,
191    pub options: BlockwiseFitOptions,
192}
193
194pub struct TransformationNormalFitRequest<'a> {
195    pub data: ArrayView2<'a, f64>,
196    pub response: Array1<f64>,
197    pub weights: Array1<f64>,
198    pub offset: Array1<f64>,
199    pub covariate_spec: TermCollectionSpec,
200    pub config: TransformationNormalConfig,
201    pub options: BlockwiseFitOptions,
202    pub kappa_options: SpatialLengthScaleOptimizationOptions,
203    pub warm_start: Option<TransformationWarmStart>,
204}
205pub enum FitRequest<'a> {
206    Standard(StandardFitRequest<'a>),
207    GaussianLocationScale(GaussianLocationScaleFitRequest<'a>),
208    BinomialLocationScale(BinomialLocationScaleFitRequest<'a>),
209    DispersionLocationScale(DispersionLocationScaleFitRequest<'a>),
210    SurvivalLocationScale(SurvivalLocationScaleFitRequest<'a>),
211    SurvivalTransformation(SurvivalTransformationFitRequest<'a>),
212    BernoulliMarginalSlope(BernoulliMarginalSlopeFitRequest<'a>),
213    SurvivalMarginalSlope(SurvivalMarginalSlopeFitRequest<'a>),
214    LatentSurvival(LatentSurvivalFitRequest<'a>),
215    LatentBinary(LatentBinaryFitRequest<'a>),
216    TransformationNormal(TransformationNormalFitRequest<'a>),
217}
218
219pub struct StandardFitResult {
220    pub fit: UnifiedFitResult,
221    pub design: TermCollectionDesign,
222    pub resolvedspec: TermCollectionSpec,
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    /// Standard-basis link-warp coefficients `β_w = Z·γ` for the saved-model
239    /// predict runtime when the frozen-basis de-aliasing engaged (#1596). The
240    /// fit's coefficients stay in the reduced `γ` coordinate; this lift is
241    /// persisted into the payload's `beta_link_wiggle`.
242    pub wiggle_saved_warp_beta: Option<Vec<f64>>,
243    /// Frozen-index mean-coordinate shift for the predict runtime (#2141),
244    /// persisted into the payload's `link_wiggle_index_shift`. Lets predict
245    /// evaluate the warp basis at the frozen index `η̂` the fit pinned it at,
246    /// rather than at the de-aliased base predictor.
247    pub wiggle_saved_index_shift: Option<Vec<f64>>,
248}
249
250pub(crate) fn adaptive_spatial_term_mask(spec: &TermCollectionSpec) -> Vec<bool> {
251    fn auto_spatial(basis: &gam_terms::smooth::SmoothBasisSpec) -> bool {
252        use gam_terms::smooth::SmoothBasisSpec as B;
253        match basis {
254            B::ByVariable { inner, .. } | B::FactorSumToZero { inner, .. } => auto_spatial(inner),
255            B::BySmooth { smooth, .. } => auto_spatial(smooth),
256            B::ThinPlate {
257                feature_cols, spec, ..
258            } => {
259                !feature_cols.is_empty()
260                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
261            }
262            B::Duchon {
263                feature_cols, spec, ..
264            } => {
265                !feature_cols.is_empty()
266                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
267            }
268            // Matérn's learned range changes both its basin and realized kernel
269            // rank as centers move. It has no validated EDF-saturation growth
270            // theorem yet, so the generic radial grow loop must not claim it.
271            B::Matern { .. } => false,
272            B::ConstantCurvature { feature_cols, spec } => {
273                !feature_cols.is_empty()
274                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
275            }
276            B::MeasureJet {
277                feature_cols, spec, ..
278            } => {
279                !feature_cols.is_empty()
280                    && gam_terms::basis::center_strategy_is_auto(&spec.center_strategy)
281            }
282            _ => false,
283        }
284    }
285
286    spec.smooth_terms
287        .iter()
288        .map(|term| auto_spatial(&term.basis))
289        .collect()
290}
291
292pub(crate) fn adaptive_spatial_center_counts(spec: &TermCollectionSpec) -> Vec<Option<usize>> {
293    fn center_count(basis: &gam_terms::smooth::SmoothBasisSpec) -> Option<usize> {
294        use gam_terms::smooth::SmoothBasisSpec as B;
295        match basis {
296            B::ByVariable { inner, .. } | B::FactorSumToZero { inner, .. } => center_count(inner),
297            B::BySmooth { smooth, .. } => center_count(smooth),
298            B::ThinPlate {
299                feature_cols, spec, ..
300            } if !feature_cols.is_empty() => {
301                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
302            }
303            B::Duchon {
304                feature_cols, spec, ..
305            } if !feature_cols.is_empty() => {
306                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
307            }
308            B::Matern { .. } => None,
309            B::ConstantCurvature { feature_cols, spec } if !feature_cols.is_empty() => {
310                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
311            }
312            B::MeasureJet {
313                feature_cols, spec, ..
314            } if !feature_cols.is_empty() => {
315                Some(spec.center_strategy.planned_num_centers(feature_cols.len()))
316            }
317            _ => None,
318        }
319    }
320
321    spec.smooth_terms
322        .iter()
323        .map(|term| center_count(&term.basis))
324        .collect()
325}
326
327pub struct SurvivalLocationScaleFitResult {
328    pub fit: SurvivalLocationScaleTermFitResult,
329    pub inverse_link: InverseLink,
330    pub wiggle_knots: Option<Array1<f64>>,
331    pub wiggle_degree: Option<usize>,
332}
333
334pub struct SurvivalTransformationFitResult {
335    pub fit: UnifiedFitResult,
336    pub resolvedspec: TermCollectionSpec,
337    pub baseline_cfg: crate::survival::SurvivalBaselineConfig,
338    pub likelihood_mode: crate::survival::SurvivalLikelihoodMode,
339    /// Persistable snapshot of the time basis used during the fit. Replaces
340    /// six previously flat fields (basisname / degree / knots / keep_cols /
341    /// smooth_lambda / anchor) so the FFI save path consumes a single
342    /// source-of-truth value rather than threading siblings independently.
343    pub time_basis: crate::survival::SavedSurvivalTimeBasis,
344    pub time_base_ncols: usize,
345    pub baseline_timewiggle: Option<TimeWiggleBlockInput>,
346}
347
348pub enum FitResult {
349    Standard(StandardFitResult),
350    GaussianLocationScale(GaussianLocationScaleFitResult),
351    BinomialLocationScale(BinomialLocationScaleFitResult),
352    DispersionLocationScale(DispersionLocationScaleFitResult),
353    SurvivalLocationScale(SurvivalLocationScaleFitResult),
354    SurvivalTransformation(SurvivalTransformationFitResult),
355    BernoulliMarginalSlope(BernoulliMarginalSlopeFitResult),
356    SurvivalMarginalSlope(SurvivalMarginalSlopeFitResult),
357    LatentSurvival(LatentSurvivalTermFitResult),
358    LatentBinary(LatentBinaryTermFitResult),
359    TransformationNormal(TransformationNormalFitResult),
360    /// Exact O(n) state-space cubic/linear/quintic smoothing-spline scan
361    /// (#1030/#1034). A scan-bearing model IS a Gaussian-identity model with a
362    /// different (exact) representation: rather than a dense design + coefficient
363    /// vector it carries the Durbin–Koopman smoother posterior directly (knots,
364    /// smoothed states, pointwise variances, σ², log λ, exact diffuse-REML EDF,
365    /// and an exact per-row `predict`). Library callers that want the fitted
366    /// posterior get it here without paying the dense O(n·k²)+O(k³) route; the
367    /// CLI/FFI save paths build the persistence payload from the same
368    /// `SplineScanFit` via `assemble_spline_scan_payload`.
369    SplineScan(gam_solve::spline_scan::SplineScanFit),
370    /// O(n log n) multiresolution residual-cascade smooth (#1032). UNLIKE the
371    /// 1-D scan, the cascade is NOT the same posterior as the Duchon/Matérn term
372    /// it stands in for (a different finite basis — the multilevel Wendland
373    /// frame), so it is never a silent swap: this variant is produced only when
374    /// the structural detector [`residual_cascade_fast_path`] fires on an
375    /// eligible scattered-low-d Gaussian fit past the dense-kernel cliff AND the
376    /// in-cascade quasi-uniformity guard certifies the metric; every other shape
377    /// (and a rejected metric) falls through to the dense `fit_model` path. The
378    /// cascade-bearing model carries the
379    /// [`ResidualCascadeFit`](gam_solve::residual_cascade::ResidualCascadeFit)
380    /// directly — knots-free nested geometry, coefficients, the factored
381    /// precision, and an exact per-row `predict`; the CLI/FFI save paths build
382    /// the persistence payload from its `to_state` snapshot.
383    ResidualCascade(gam_solve::residual_cascade::ResidualCascadeFit),
384}
385
386/// Result of a dispersion-channel GAMLSS location-scale fit (#913). Wraps the
387/// shared two-block [`BlockwiseTermFitResult`] (mean + log-precision designs
388/// and coefficients) plus the family kind so the save path can stamp the right
389/// likelihood. These families have no link-wiggle and no response
390/// standardization, so the result is a thin wrapper.
391pub struct DispersionLocationScaleFitResult {
392    pub fit: BlockwiseTermFitResult,
393    pub kind: DispersionFamilyKind,
394}
395
396/// Out-of-fold Stage-1 latent score and its score-influence Jacobian for a
397/// CTN → marginal-slope chain. `z_oof` (length n) replaces the in-sample `z`
398/// the Stage-2 model consumes; `jac_oof` (n × p₁) is fed to the Stage-2 spec's
399/// `score_influence_jacobian` so the joint solve absorbs the realized leakage
400/// directions `Z_infl = diag(s_f·β̂₀)·J`.
401pub struct CrossFitScoreCalibration {
402    pub z_oof: Array1<f64>,
403    pub jac_oof: Array2<f64>,
404}
405
406/// Internal recipe describing the CTN Stage-1 fit that produced a Stage-2 `z`
407/// column. This is in-process plumbing — never a CLI flag, env var, or feature
408/// gate. The orchestration layer populates [`FitConfig::ctn_stage1`] when (and
409/// only when) the marginal-slope `z` was generated by a transformation-normal
410/// Stage-1 fit; its presence is the sole auto-enable signal for cross-fitted
411/// orthogonalization (design §5). When absent, Stage-2 falls back to the free
412/// 1-D `score_warp` spline (which spans only the x-free leakage column).
413#[derive(Clone, Debug)]
414pub struct CtnStage1Recipe {
415    /// Stage-1 response column name (the `y` the CTN transforms).
416    pub response_column: String,
417    /// Stage-1 covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"`),
418    /// with no `~` and no response symbol. [`crossfit_score_calibration`] parses
419    /// it and builds the CTN covariate basis exactly as
420    /// `materialize_transformation_normal` does, then FREEZES that basis once on
421    /// the full data and reuses the frozen spec for every fold's refit — so the
422    /// rebuilt covariate design has an identical column geometry across folds,
423    /// keeping `J`'s `p₁ = p_resp · p_cov` columns aligned (design §3).
424    ///
425    /// The recipe carries the formula RHS (a primitive string) rather than a
426    /// resolved [`TermCollectionSpec`] because this struct is populated both via
427    /// [`CtnStage1Recipe::new`] (set on [`FitConfig::ctn_stage1`], then
428    /// [`fit_from_formula`]) and by the gamfit FFI marshaller
429    /// (`gamfit/_calibrated_slope.py`), which can only serialize primitives over
430    /// the JSON boundary — a `TermCollectionSpec` is not serializable. Freezing on
431    /// the full Stage-2 data is equivalent to
432    /// freezing on the Stage-1 data whenever the two stages share a frame (the
433    /// calibrated-chain contract), so the column geometry still matches Stage-1.
434    pub covariate_formula_rhs: String,
435    /// Stage-1 CTN config (response basis degree / knot count / penalties).
436    /// Its `response_num_internal_knots` is the FIXED response-basis size; the
437    /// cross-fit pins it across folds so `p_resp` (and hence `p₁`) is
438    /// fold-invariant (design §3).
439    pub config: TransformationNormalConfig,
440    /// Optional Stage-1 weight column name.
441    pub weight_column: Option<String>,
442    /// Optional Stage-1 offset column name.
443    pub offset_column: Option<String>,
444}
445
446impl CtnStage1Recipe {
447    /// Build a Stage-1 CTN recipe from the Stage-1 description. This is the public
448    /// way to populate [`FitConfig::ctn_stage1`] — set it on a marginal-slope
449    /// config and run [`fit_from_formula`] (the entry IS `fit_from_formula` with
450    /// `ctn_stage1` set; there is no separate combined entry function). The
451    /// materializer then cross-fits the CTN and installs the leakage-projection
452    /// block; supplying the recipe *is* the request for orthogonalization.
453    ///
454    /// `response` is the Stage-1 CTN response column; `covariates` is the
455    /// covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"` — no `~`,
456    /// no response symbol). Validates both are non-empty and that `covariates`
457    /// is an RHS only.
458    pub fn new(
459        response: &str,
460        covariates: &str,
461        config: TransformationNormalConfig,
462        weight_column: Option<&str>,
463        offset_column: Option<&str>,
464    ) -> Result<Self, String> {
465        let response_column = response.trim().to_string();
466        if response_column.is_empty() {
467            return Err("CtnStage1Recipe requires a non-empty Stage-1 response column".to_string());
468        }
469        let covariate_formula_rhs = covariates.trim().to_string();
470        if covariate_formula_rhs.is_empty() {
471            return Err(
472                "CtnStage1Recipe requires a non-empty Stage-1 covariate formula RHS".to_string(),
473            );
474        }
475        if covariate_formula_rhs.contains('~') {
476            return Err(
477                "CtnStage1Recipe covariates is a right-hand side only; pass 's(pc1) + s(pc2)', \
478                 not 'score ~ s(pc1) + s(pc2)'"
479                    .to_string(),
480            );
481        }
482        Ok(Self {
483            response_column,
484            covariate_formula_rhs,
485            config,
486            weight_column: weight_column
487                .map(str::to_string)
488                .filter(|s| !s.trim().is_empty()),
489            offset_column: offset_column
490                .map(str::to_string)
491                .filter(|s| !s.trim().is_empty()),
492        })
493    }
494}
495#[derive(Clone, Debug)]
496pub struct FitConfig {
497    /// Family: "gaussian", "binomial", "poisson", "negative-binomial",
498    /// "gamma", "tweedie" (alias "tw"; variance power fixed at p = 1.5), or
499    /// None for auto-detect.
500    pub family: Option<String>,
501    /// Fixed size/overdispersion parameter for `family="negative-binomial"`.
502    pub negative_binomial_theta: Option<f64>,
503    /// Link: "identity", "logit", "probit", "cloglog", "sas", "beta-logistic", or None.
504    pub link: Option<String>,
505    /// Whether to use flexible (wiggle-augmented) link.
506    pub flexible_link: bool,
507    /// Optional additive offset column for the primary linear predictor.
508    pub offset_column: Option<String>,
509    /// Optional additive offset column for the noise/log-scale predictor.
510    pub noise_offset_column: Option<String>,
511    /// Family-level frailty. `None` is represented only by
512    /// [`FrailtySpec::None`]; an outer `Option` would create two null states.
513    pub frailty: FrailtySpec,
514
515    // Survival-specific
516    /// Baseline target: "linear", "weibull", "gompertz", "gompertz-makeham".
517    pub baseline_target: String,
518    pub baseline_scale: Option<f64>,
519    pub baseline_shape: Option<f64>,
520    pub baseline_rate: Option<f64>,
521    pub baseline_makeham: Option<f64>,
522    /// Time basis: "ispline" or "none".
523    pub time_basis: String,
524    pub time_degree: usize,
525    pub time_num_internal_knots: usize,
526    pub time_smooth_lambda: f64,
527    /// Survival likelihood mode: "location-scale", "transformation", "weibull",
528    /// "marginal-slope", "latent", or "latent-binary".
529    pub survival_likelihood: String,
530    /// Residual distribution: "gaussian", "logistic", "gumbel".
531    pub survival_distribution: String,
532    pub threshold_time_k: Option<usize>,
533    pub threshold_time_degree: usize,
534    pub sigma_time_k: Option<usize>,
535    pub sigma_time_degree: usize,
536
537    // Location-scale (GAMLSS)
538    /// If set, fit a location-scale model with this formula for the noise parameter.
539    pub noise_formula: Option<String>,
540
541    // Marginal-slope
542    /// Formula for the log-slope model (survival marginal-slope or Bernoulli marginal-slope).
543    pub logslope_formula: Option<String>,
544    /// Column name for the z (exposure/dose) variable in marginal-slope models.
545    pub z_column: Option<String>,
546    /// Optional non-negative per-row training weights column.
547    pub weight_column: Option<String>,
548    /// Expectile asymmetry `τ ∈ (0, 1)` for `family = "expectile"`.
549    ///
550    /// When `family` resolves to `"expectile"` the fit minimizes the
551    /// Newey–Powell asymmetric squared loss `Σ wᵢ(τ)·(yᵢ − μᵢ)²` with
552    /// `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`, tracing the conditional
553    /// `τ`-expectile — the smooth analogue of the `τ`-quantile. `τ = 0.5`
554    /// reduces exactly to the Gaussian-identity mean fit. The whole penalized
555    /// smooth + REML `λ`-selection machinery is reused via a Least
556    /// Asymmetrically Weighted Squares (LAWS) outer loop. `None` defaults to
557    /// the median expectile `τ = 0.5` when the family is `"expectile"`; it is
558    /// ignored for every other family. The asymmetry may also be written inline
559    /// as `family = "expectile(0.9)"`, which fills this field at resolve time.
560    pub expectile_tau: Option<f64>,
561    /// Internal CTN Stage-1 provenance for the marginal-slope `z` column.
562    ///
563    /// When the marginal-slope `z` was generated by a transformation-normal
564    /// Stage-1 fit, the orchestration layer fills this with the Stage-1 recipe.
565    /// Its presence is the sole auto-enable signal for cross-fitted, Neyman-
566    /// orthogonal score calibration (#461): the materializer cross-fits the CTN
567    /// to produce out-of-fold `z` and the score-influence Jacobian `J`, replaces
568    /// the raw `z` with `z_oof`, and absorbs `J` as a leakage-projection block in
569    /// Stage-2. This is in-process plumbing only — there is no CLI flag, env var,
570    /// or feature gate. `None` ⇒ raw `z` with the free-warp `score_warp`
571    /// fallback. See [`CtnStage1Recipe`].
572    pub ctn_stage1: Option<CtnStage1Recipe>,
573
574    // Fitting options
575    pub scale_dimensions: bool,
576    /// Spatial length-scale/anisotropy optimization policy shared by every
577    /// formula family. Front ends must set model-wide spatial knobs here rather
578    /// than mutating a request after materialization.
579    pub spatial_optimization: SpatialLengthScaleOptimizationOptions,
580    /// Enable exact spatial adaptive regularization for standard formula fits.
581    /// `None` uses the quality-first automatic policy. The current automatic
582    /// policy leaves LAREG off unless explicitly requested because the
583    /// optimizer's REML-selected local weights can over-regularize small
584    /// high-yield spatial signals.
585    pub adaptive_regularization: Option<bool>,
586    pub ridge_lambda: f64,
587
588    /// Route the fit through the transformation-normal family.  When set, the
589    /// formula terms are treated as the covariate side of the transformation
590    /// model and the response basis is built internally.  Incompatible with
591    /// `noise_formula` and with `Surv(...)` responses.
592    pub transformation_normal: bool,
593
594    /// Enable Firth bias reduction for standard single-parameter families.
595    pub firth: bool,
596    /// Optional cap on the REML/LAML outer smoothing-parameter iterations for
597    /// standard formula fits. `None` uses the production default.
598    pub outer_max_iter: Option<usize>,
599
600    /// GPU backend selection policy. `Auto` uses supported device kernels for
601    /// large workloads, `Off` pins execution to CPU kernels, and `Required` fails
602    /// loudly when a requested GPU kernel has no compiled backend.
603    pub gpu_policy: gam_gpu::GpuPolicy,
604    /// Optional override of the [`gam_runtime::resource::ResourcePolicy`] used when
605    /// planning spatial bases (TPS / Matern / Duchon) during term construction.
606    /// When `None`, the default-library policy is used.
607    pub resource_policy: Option<gam_runtime::resource::ResourcePolicy>,
608
609    /// Optional per-group metadata supplied by the caller. Fitting ignores this
610    /// field; saved-model builders pass it through so deployment consumers can
611    /// recover group provenance.
612    pub group_metadata: Option<BTreeMap<String, JsonValue>>,
613
614    /// Container type of the caller's training table (`"pandas"`, `"polars"`,
615    /// `"pyarrow"`, `"numpy"`, or `"unknown"` outside a typed table frontend).
616    /// Fitting ignores this field; saved-model builders persist it so every
617    /// current frontend writes the same complete model schema.
618    pub training_table_kind: String,
619
620    /// Optional user-defined coefficient groups with separate precision
621    /// parameters. Group-local priors, including catalog-metadata-informed
622    /// Gamma precision hyperpriors, are resolved during design setup.
623    pub coefficient_groups: Vec<CoefficientGroupSpec>,
624
625    /// Optional per-existing-penalty-block Gamma(shape, rate) precision
626    /// hyperpriors keyed by penalty-block label. This is the
627    /// catalog-metadata-informed-prior hook for models that do not need a new
628    /// user-defined coefficient group.
629    pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
630
631    /// Python `gamfit.fit(..., latents={...})` configuration. This reaches
632    /// the standard formula workflow as an owned latent-coordinate block:
633    /// the named smooth's synthetic covariates are rebuilt from `t`, and
634    /// joint REML optimizes `[rho, vec(t)]` through latent design hyper-dirs.
635    pub latents: Option<JsonValue>,
636    /// Python `gamfit.fit(..., penalties=[...])` analytic-penalty descriptors,
637    /// validated against the declared latent-coordinate blocks before a
638    /// standard latent fit starts.
639    pub analytic_penalties: Option<JsonValue>,
640    /// Formula-path latent topology selector descriptor. The selector itself
641    /// fits candidates through the ordinary workflow; this slot lets callers
642    /// request and validate that path from the same config registry.
643    pub topology_auto_selector: Option<gam_solve::topology_selector::TopologyAutoSelector>,
644    /// `gamfit.fit(..., smooths={...})` Python kwarg routed through the FFI
645    /// bridge. JSON object keyed by formula symbol (single column name or
646    /// comma-joined tuple) → smooth descriptor (`{"kind": "duchon",
647    /// "centers": [[...], ...], ...}`). Applied as a post-processing step on
648    /// the [`TermCollectionSpec`] produced by the formula DSL: each smooth
649    /// term whose `feature_cols` match a registry key has its kind-specific
650    /// tunables (centers, knots, kernel hyperparameters) overridden with the
651    /// user-supplied values. The single canonical lowering path guarantees
652    /// `smooths={"x": Duchon(centers=K)}` (integer) produces a bit-identical
653    /// block spec to writing `duchon(x, centers=K)` in the formula; only
654    /// explicit array-valued `centers=` differs, routing through
655    /// `CenterStrategy::UserProvided` instead of `FarthestPoint`/`EqualMass`.
656    pub smooth_overrides: Option<JsonValue>,
657    /// Engage the cross-process ON-DISK persistent checkpoint layer (#1082).
658    ///
659    /// Default `true`: formula fits survive process and wall interruptions.
660    /// The flag threads
661    /// `FitConfig → FitOptions → ExternalOptimOptions` down to the standard
662    /// `RemlState`, which then calls `enable_persistent_warm_start_disk()`.
663    /// Low-level embedding code may disable it explicitly when it owns a
664    /// stronger external checkpoint transaction.
665    pub persist_warm_start_disk: bool,
666    /// Per-smooth spatial center requests maintained by the adaptive
667    /// fit→expand→refit loop. Outer `None` means no loop owns this request, so
668    /// raw materialization keeps the ordinary full basis. `Some` activates the
669    /// canonical formula workflow: missing inner entries select the structural
670    /// identifiable start and `Some(k)` requests the next evidence-backed
671    /// resolution for that smooth only. This is in-process orchestration state,
672    /// never a user knob or environment setting.
673    pub spatial_center_counts: Option<Vec<Option<usize>>>,
674}
675
676impl Default for FitConfig {
677    fn default() -> Self {
678        Self {
679            family: None,
680            negative_binomial_theta: None,
681            link: None,
682            flexible_link: false,
683            offset_column: None,
684            noise_offset_column: None,
685            frailty: FrailtySpec::None,
686            baseline_target: "linear".into(),
687            baseline_scale: None,
688            baseline_shape: None,
689            baseline_rate: None,
690            baseline_makeham: None,
691            time_basis: "ispline".into(),
692            time_degree: 3,
693            time_num_internal_knots: 8,
694            time_smooth_lambda: 1e-2,
695            survival_likelihood: "location-scale".into(),
696            survival_distribution: "gaussian".into(),
697            threshold_time_k: None,
698            threshold_time_degree: 3,
699            sigma_time_k: None,
700            sigma_time_degree: 3,
701            noise_formula: None,
702            logslope_formula: None,
703            z_column: None,
704            weight_column: None,
705            expectile_tau: None,
706            ctn_stage1: None,
707            scale_dimensions: false,
708            spatial_optimization: SpatialLengthScaleOptimizationOptions::default(),
709            adaptive_regularization: None,
710            ridge_lambda: 1e-6,
711            transformation_normal: false,
712            firth: false,
713            outer_max_iter: None,
714            gpu_policy: gam_gpu::GpuPolicy::Auto,
715            resource_policy: None,
716            group_metadata: None,
717            training_table_kind: "unknown".to_string(),
718            coefficient_groups: Vec::new(),
719            penalty_block_gamma_priors: Vec::new(),
720            latents: None,
721            analytic_penalties: None,
722            topology_auto_selector: None,
723            smooth_overrides: None,
724            persist_warm_start_disk: true,
725            spatial_center_counts: None,
726        }
727    }
728}
729/// The result of materializing a formula + config against a dataset.
730pub struct MaterializedModel<'a> {
731    pub request: FitRequest<'a>,
732    pub inference_notes: Vec<String>,
733}
734pub struct SplineScanInputs {
735    /// Abscissae of the single 1-D smooth (training rows of its feature column).
736    pub x: Vec<f64>,
737    /// Gaussian response.
738    pub y: Vec<f64>,
739    /// Observation weights (variance is `σ²/w`).
740    pub w: Vec<f64>,
741    /// Smoothing-spline order `m = penalty_order ∈ {1, 2, 3}`: `m = 1` the
742    /// random-walk/linear smoother (penalty `λ∫f′²`), `m = 2` the cubic
743    /// smoother (penalty `λ∫f″²`), `m = 3` the quintic smoother (penalty
744    /// `λ∫(f‴)²`).
745    pub order: usize,
746}
747pub struct ResidualCascadeInputs {
748    /// One slice per coordinate axis (2 or 3) of the single scattered smooth.
749    pub coords: Vec<Vec<f64>>,
750    /// Gaussian response.
751    pub y: Vec<f64>,
752    /// Observation weights (variance is `σ²/w`).
753    pub w: Vec<f64>,
754    /// Per-axis positive metric scaling `diag(metric)` of `z = diag(metric)·x`.
755    pub metric: Vec<f64>,
756    /// Sobolev smoothness order `s` of the multilevel Wendland-(3,1) prior,
757    /// clamped into the native-space window `(d/2, (d+3)/2]` (issue caveat 1).
758    pub sobolev_s: f64,
759}
760
761#[cfg(test)]
762mod default_workflow_policy_tests {
763    use super::*;
764
765    #[test]
766    fn formula_fits_checkpoint_durably_by_default() {
767        let config = FitConfig::default();
768        assert!(config.persist_warm_start_disk);
769        let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
770        assert!(options.persist_warm_start_disk);
771    }
772
773    #[test]
774    fn raw_materialization_does_not_activate_adaptive_spatial_resolution() {
775        assert!(
776            FitConfig::default().spatial_center_counts.is_none(),
777            "raw materialization must not activate a grow loop it does not own"
778        );
779    }
780
781    #[test]
782    fn explicit_external_checkpoint_owner_can_disable_disk_layer() {
783        let config = FitConfig {
784            persist_warm_start_disk: false,
785            ..FitConfig::default()
786        };
787        let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
788        assert!(!options.persist_warm_start_disk);
789    }
790}