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 /// 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 the
390 /// in-cascade quasi-uniformity guard certifies the metric; every other shape
391 /// (and a rejected metric) falls through to the dense `fit_model` path. The
392 /// cascade-bearing model carries the
393 /// [`ResidualCascadeFit`](gam_solve::residual_cascade::ResidualCascadeFit)
394 /// directly — knots-free nested geometry, coefficients, the factored
395 /// precision, and an exact per-row `predict`; the CLI/FFI save paths build
396 /// the persistence payload from its `to_state` snapshot.
397 ResidualCascade(gam_solve::residual_cascade::ResidualCascadeFit),
398}
399
400/// Result of a dispersion-channel GAMLSS location-scale fit (#913). Wraps the
401/// shared two-block [`BlockwiseTermFitResult`] (mean + log-precision designs
402/// and coefficients) plus the family kind so the save path can stamp the right
403/// likelihood. These families have no link-wiggle and no response
404/// standardization, so the result is a thin wrapper.
405pub struct DispersionLocationScaleFitResult {
406 pub fit: BlockwiseTermFitResult,
407 pub kind: DispersionFamilyKind,
408}
409
410/// Out-of-fold Stage-1 latent score and its score-influence Jacobian for a
411/// CTN → marginal-slope chain. `z_oof` (length n) replaces the in-sample `z`
412/// the Stage-2 model consumes; `jac_oof` (n × p₁) is fed to the Stage-2 spec's
413/// `score_influence_jacobian` so the joint solve absorbs the realized leakage
414/// directions `Z_infl = diag(s_f·β̂₀)·J`.
415pub struct CrossFitScoreCalibration {
416 pub z_oof: Array1<f64>,
417 pub jac_oof: Array2<f64>,
418}
419
420/// Internal recipe describing the CTN Stage-1 fit that produced a Stage-2 `z`
421/// column. This is in-process plumbing — never a CLI flag, env var, or feature
422/// gate. The orchestration layer populates [`FitConfig::ctn_stage1`] when (and
423/// only when) the marginal-slope `z` was generated by a transformation-normal
424/// Stage-1 fit; its presence is the sole auto-enable signal for cross-fitted
425/// orthogonalization (design §5). When absent, Stage-2 falls back to the free
426/// 1-D `score_warp` spline (which spans only the x-free leakage column).
427#[derive(Clone, Debug)]
428pub struct CtnStage1Recipe {
429 /// Stage-1 response column name (the `y` the CTN transforms).
430 pub response_column: String,
431 /// Stage-1 covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"`),
432 /// with no `~` and no response symbol. [`crossfit_score_calibration`] parses
433 /// it and builds the CTN covariate basis exactly as
434 /// `materialize_transformation_normal` does, then FREEZES that basis once on
435 /// the full data and reuses the frozen spec for every fold's refit — so the
436 /// rebuilt covariate design has an identical column geometry across folds,
437 /// keeping `J`'s `p₁ = p_resp · p_cov` columns aligned (design §3).
438 ///
439 /// The recipe carries the formula RHS (a primitive string) rather than a
440 /// resolved [`TermCollectionSpec`] because this struct is populated both via
441 /// [`CtnStage1Recipe::new`] (set on [`FitConfig::ctn_stage1`], then
442 /// [`fit_from_formula`]) and by the gamfit FFI marshaller
443 /// (`gamfit/_calibrated_slope.py`), which can only serialize primitives over
444 /// the JSON boundary — a `TermCollectionSpec` is not serializable. Freezing on
445 /// the full Stage-2 data is equivalent to
446 /// freezing on the Stage-1 data whenever the two stages share a frame (the
447 /// calibrated-chain contract), so the column geometry still matches Stage-1.
448 pub covariate_formula_rhs: String,
449 /// Stage-1 CTN config (response basis degree / knot count / penalties).
450 /// Its `response_num_internal_knots` is the FIXED response-basis size; the
451 /// cross-fit pins it across folds so `p_resp` (and hence `p₁`) is
452 /// fold-invariant (design §3).
453 pub config: TransformationNormalConfig,
454 /// Optional Stage-1 weight column name.
455 pub weight_column: Option<String>,
456 /// Optional Stage-1 offset column name.
457 pub offset_column: Option<String>,
458}
459
460impl CtnStage1Recipe {
461 /// Build a Stage-1 CTN recipe from the Stage-1 description. This is the public
462 /// way to populate [`FitConfig::ctn_stage1`] — set it on a marginal-slope
463 /// config and run [`fit_from_formula`] (the entry IS `fit_from_formula` with
464 /// `ctn_stage1` set; there is no separate combined entry function). The
465 /// materializer then cross-fits the CTN and installs the leakage-projection
466 /// block; supplying the recipe *is* the request for orthogonalization.
467 ///
468 /// `response` is the Stage-1 CTN response column; `covariates` is the
469 /// covariate-side formula right-hand side (e.g. `"s(pc1) + s(pc2)"` — no `~`,
470 /// no response symbol). Validates both are non-empty and that `covariates`
471 /// is an RHS only.
472 pub fn new(
473 response: &str,
474 covariates: &str,
475 config: TransformationNormalConfig,
476 weight_column: Option<&str>,
477 offset_column: Option<&str>,
478 ) -> Result<Self, String> {
479 let response_column = response.trim().to_string();
480 if response_column.is_empty() {
481 return Err("CtnStage1Recipe requires a non-empty Stage-1 response column".to_string());
482 }
483 let covariate_formula_rhs = covariates.trim().to_string();
484 if covariate_formula_rhs.is_empty() {
485 return Err(
486 "CtnStage1Recipe requires a non-empty Stage-1 covariate formula RHS".to_string(),
487 );
488 }
489 if covariate_formula_rhs.contains('~') {
490 return Err(
491 "CtnStage1Recipe covariates is a right-hand side only; pass 's(pc1) + s(pc2)', \
492 not 'score ~ s(pc1) + s(pc2)'"
493 .to_string(),
494 );
495 }
496 Ok(Self {
497 response_column,
498 covariate_formula_rhs,
499 config,
500 weight_column: weight_column
501 .map(str::to_string)
502 .filter(|s| !s.trim().is_empty()),
503 offset_column: offset_column
504 .map(str::to_string)
505 .filter(|s| !s.trim().is_empty()),
506 })
507 }
508}
509#[derive(Clone, Debug)]
510pub struct FitConfig {
511 /// Family: "gaussian", "binomial", "poisson", "negative-binomial",
512 /// "gamma", "tweedie" (alias "tw"; variance power fixed at p = 1.5), or
513 /// None for auto-detect.
514 pub family: Option<String>,
515 /// Fixed size/overdispersion parameter for `family="negative-binomial"`.
516 pub negative_binomial_theta: Option<f64>,
517 /// Link: "identity", "logit", "probit", "cloglog", "sas", "beta-logistic", or None.
518 pub link: Option<String>,
519 /// Whether to use flexible (wiggle-augmented) link.
520 pub flexible_link: bool,
521 /// Optional additive offset column for the primary linear predictor.
522 pub offset_column: Option<String>,
523 /// Optional additive offset column for the noise/log-scale predictor.
524 pub noise_offset_column: Option<String>,
525 /// Family-level frailty. `None` is represented only by
526 /// [`FrailtySpec::None`]; an outer `Option` would create two null states.
527 pub frailty: FrailtySpec,
528
529 // Survival-specific
530 /// Baseline target: "linear", "weibull", "gompertz", "gompertz-makeham".
531 pub baseline_target: String,
532 pub baseline_scale: Option<f64>,
533 pub baseline_shape: Option<f64>,
534 pub baseline_rate: Option<f64>,
535 pub baseline_makeham: Option<f64>,
536 /// Time basis: "ispline" or "none".
537 pub time_basis: String,
538 pub time_degree: usize,
539 pub time_num_internal_knots: usize,
540 pub time_smooth_lambda: f64,
541 /// Survival likelihood mode: `Some("transformation" | "location-scale" |
542 /// "weibull" | "marginal-slope" | "latent" | "latent-binary")`, or `None`
543 /// (the default), which resolves to `"transformation"` at the `Surv(...)`
544 /// materialization seam via [`FitConfig::resolved_survival_likelihood`]
545 /// (#2301 — no library-side string default). `Some(_)` on a non-survival
546 /// response is a typed configuration error.
547 pub survival_likelihood: Option<String>,
548 /// Residual distribution: "gaussian", "logistic", "gumbel".
549 pub survival_distribution: String,
550 pub threshold_time_k: Option<usize>,
551 pub threshold_time_degree: usize,
552 pub sigma_time_k: Option<usize>,
553 pub sigma_time_degree: usize,
554
555 // Location-scale (GAMLSS)
556 /// If set, fit a location-scale model with this formula for the noise parameter.
557 pub noise_formula: Option<String>,
558
559 // Marginal-slope
560 /// Formula for the log-slope model (survival marginal-slope or Bernoulli marginal-slope).
561 pub logslope_formula: Option<String>,
562 /// Column name for the z (exposure/dose) variable in marginal-slope models.
563 pub z_column: Option<String>,
564 /// Optional non-negative per-row training weights column.
565 pub weight_column: Option<String>,
566 /// Expectile asymmetry `τ ∈ (0, 1)` for `family = "expectile"`.
567 ///
568 /// When `family` resolves to `"expectile"` the fit minimizes the
569 /// Newey–Powell asymmetric squared loss `Σ wᵢ(τ)·(yᵢ − μᵢ)²` with
570 /// `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`, tracing the conditional
571 /// `τ`-expectile — the smooth analogue of the `τ`-quantile. `τ = 0.5`
572 /// reduces exactly to the Gaussian-identity mean fit. The whole penalized
573 /// smooth + REML `λ`-selection machinery is reused via a Least
574 /// Asymmetrically Weighted Squares (LAWS) outer loop. `None` defaults to
575 /// the median expectile `τ = 0.5` when the family is `"expectile"`; it is
576 /// ignored for every other family. The asymmetry may also be written inline
577 /// as `family = "expectile(0.9)"`, which fills this field at resolve time.
578 pub expectile_tau: Option<f64>,
579 /// Internal CTN Stage-1 provenance for the marginal-slope `z` column.
580 ///
581 /// When the marginal-slope `z` was generated by a transformation-normal
582 /// Stage-1 fit, the orchestration layer fills this with the Stage-1 recipe.
583 /// Its presence is the sole auto-enable signal for cross-fitted, Neyman-
584 /// orthogonal score calibration (#461): the materializer cross-fits the CTN
585 /// to produce out-of-fold `z` and the score-influence Jacobian `J`, replaces
586 /// the raw `z` with `z_oof`, and absorbs `J` as a leakage-projection block in
587 /// Stage-2. This is in-process plumbing only — there is no CLI flag, env var,
588 /// or feature gate. `None` ⇒ raw `z` with the free-warp `score_warp`
589 /// fallback. See [`CtnStage1Recipe`].
590 pub ctn_stage1: Option<CtnStage1Recipe>,
591
592 // Fitting options
593 pub scale_dimensions: bool,
594 /// Spatial length-scale/anisotropy optimization policy shared by every
595 /// formula family. Front ends must set model-wide spatial knobs here rather
596 /// than mutating a request after materialization.
597 pub spatial_optimization: SpatialLengthScaleOptimizationOptions,
598 /// Enable exact spatial adaptive regularization for standard formula fits.
599 /// `None` uses the quality-first automatic policy. The current automatic
600 /// policy leaves LAREG off unless explicitly requested because the
601 /// optimizer's REML-selected local weights can over-regularize small
602 /// high-yield spatial signals.
603 pub adaptive_regularization: Option<bool>,
604 pub ridge_lambda: f64,
605
606 /// Route the fit through the transformation-normal family. When set, the
607 /// formula terms are treated as the covariate side of the transformation
608 /// model and the response basis is built internally. Incompatible with
609 /// `noise_formula` and with `Surv(...)` responses.
610 pub transformation_normal: bool,
611
612 /// Enable Firth bias reduction for standard single-parameter families.
613 pub firth: bool,
614 /// Optional cap on the REML/LAML outer smoothing-parameter iterations for
615 /// standard formula fits. `None` uses the production default.
616 pub outer_max_iter: Option<usize>,
617
618 /// GPU backend selection policy. `Auto` uses supported device kernels for
619 /// large workloads, `Off` pins execution to CPU kernels, and `Required` fails
620 /// loudly when a requested GPU kernel has no compiled backend.
621 pub gpu_policy: gam_gpu::GpuPolicy,
622 /// Optional override of the [`gam_runtime::resource::ResourcePolicy`] used when
623 /// planning spatial bases (TPS / Matern / Duchon) during term construction.
624 /// When `None`, the default-library policy is used.
625 pub resource_policy: Option<gam_runtime::resource::ResourcePolicy>,
626
627 /// Optional per-group metadata supplied by the caller. Fitting ignores this
628 /// field; saved-model builders pass it through so deployment consumers can
629 /// recover group provenance.
630 pub group_metadata: Option<BTreeMap<String, JsonValue>>,
631
632 /// Container type of the caller's training table (`"pandas"`, `"polars"`,
633 /// `"pyarrow"`, `"numpy"`, or `"unknown"` outside a typed table frontend).
634 /// Fitting ignores this field; saved-model builders persist it so every
635 /// current frontend writes the same complete model schema.
636 pub training_table_kind: String,
637
638 /// Optional user-defined coefficient groups with separate precision
639 /// parameters. Group-local priors, including catalog-metadata-informed
640 /// Gamma precision hyperpriors, are resolved during design setup.
641 pub coefficient_groups: Vec<CoefficientGroupSpec>,
642
643 /// Optional per-existing-penalty-block Gamma(shape, rate) precision
644 /// hyperpriors keyed by penalty-block label. This is the
645 /// catalog-metadata-informed-prior hook for models that do not need a new
646 /// user-defined coefficient group.
647 pub penalty_block_gamma_priors: Vec<(String, f64, f64)>,
648
649 /// Python `gamfit.fit(..., latents={...})` configuration. This reaches
650 /// the standard formula workflow as an owned latent-coordinate block:
651 /// the named smooth's synthetic covariates are rebuilt from `t`, and
652 /// joint REML optimizes `[rho, vec(t)]` through latent design hyper-dirs.
653 pub latents: Option<JsonValue>,
654 /// Python `gamfit.fit(..., penalties=[...])` analytic-penalty descriptors,
655 /// validated against the declared latent-coordinate blocks before a
656 /// standard latent fit starts.
657 pub analytic_penalties: Option<JsonValue>,
658 /// `gamfit.fit(..., smooths={...})` Python kwarg routed through the FFI
659 /// bridge. JSON object keyed by formula symbol (single column name or
660 /// comma-joined tuple) → smooth descriptor (`{"kind": "duchon",
661 /// "centers": [[...], ...], ...}`). Applied as a post-processing step on
662 /// the [`TermCollectionSpec`] produced by the formula DSL: each smooth
663 /// term whose `feature_cols` match a registry key has its kind-specific
664 /// tunables (centers, knots, kernel hyperparameters) overridden with the
665 /// user-supplied values. The single canonical lowering path guarantees
666 /// `smooths={"x": Duchon(centers=K)}` (integer) produces a bit-identical
667 /// block spec to writing `duchon(x, centers=K)` in the formula; only
668 /// explicit array-valued `centers=` differs, routing through
669 /// `CenterStrategy::UserProvided` instead of `FarthestPoint`/`EqualMass`.
670 pub smooth_overrides: Option<JsonValue>,
671 /// Engage the cross-process ON-DISK persistent checkpoint layer (#1082).
672 ///
673 /// Default `true`: formula fits survive process and wall interruptions.
674 /// The flag threads
675 /// `FitConfig → FitOptions → ExternalOptimOptions` down to the standard
676 /// `RemlState`, which then calls `enable_persistent_warm_start_disk()`.
677 /// Low-level embedding code may disable it explicitly when it owns a
678 /// stronger external checkpoint transaction.
679 pub persist_warm_start_disk: bool,
680 /// Per-smooth spatial center requests maintained by the adaptive
681 /// fit→expand→refit loop. Outer `None` means no loop owns this request, so
682 /// raw materialization keeps the ordinary full basis. `Some` activates the
683 /// canonical formula workflow: missing inner entries select the structural
684 /// identifiable start and `Some(k)` requests the next evidence-backed
685 /// resolution for that smooth only. This is in-process orchestration state,
686 /// never a user knob or environment setting.
687 pub spatial_center_counts: Option<Vec<Option<usize>>>,
688}
689
690impl Default for FitConfig {
691 fn default() -> Self {
692 Self {
693 family: None,
694 negative_binomial_theta: None,
695 link: None,
696 flexible_link: false,
697 offset_column: None,
698 noise_offset_column: None,
699 frailty: FrailtySpec::None,
700 baseline_target: "linear".into(),
701 baseline_scale: None,
702 baseline_shape: None,
703 baseline_rate: None,
704 baseline_makeham: None,
705 time_basis: "ispline".into(),
706 time_degree: 3,
707 time_num_internal_knots: 8,
708 time_smooth_lambda: 1e-2,
709 survival_likelihood: None,
710 survival_distribution: "gaussian".into(),
711 threshold_time_k: None,
712 threshold_time_degree: 3,
713 sigma_time_k: None,
714 sigma_time_degree: 3,
715 noise_formula: None,
716 logslope_formula: None,
717 z_column: None,
718 weight_column: None,
719 expectile_tau: None,
720 ctn_stage1: None,
721 scale_dimensions: false,
722 spatial_optimization: SpatialLengthScaleOptimizationOptions::default(),
723 adaptive_regularization: None,
724 ridge_lambda: 1e-6,
725 transformation_normal: false,
726 firth: false,
727 outer_max_iter: None,
728 gpu_policy: gam_gpu::GpuPolicy::Auto,
729 resource_policy: None,
730 group_metadata: None,
731 training_table_kind: "unknown".to_string(),
732 coefficient_groups: Vec::new(),
733 penalty_block_gamma_priors: Vec::new(),
734 latents: None,
735 analytic_penalties: None,
736 smooth_overrides: None,
737 persist_warm_start_disk: true,
738 spatial_center_counts: None,
739 }
740 }
741}
742/// The result of materializing a formula + config against a dataset.
743pub struct MaterializedModel<'a> {
744 pub request: FitRequest<'a>,
745 pub inference_notes: Vec<String>,
746}
747pub struct SplineScanInputs {
748 /// Abscissae of the single 1-D smooth (training rows of its feature column).
749 pub x: Vec<f64>,
750 /// Gaussian response.
751 pub y: Vec<f64>,
752 /// Observation weights (variance is `σ²/w`).
753 pub w: Vec<f64>,
754 /// Smoothing-spline order `m = penalty_order ∈ {1, 2, 3}`: `m = 1` the
755 /// random-walk/linear smoother (penalty `λ∫f′²`), `m = 2` the cubic
756 /// smoother (penalty `λ∫f″²`), `m = 3` the quintic smoother (penalty
757 /// `λ∫(f‴)²`).
758 pub order: usize,
759}
760pub struct ResidualCascadeInputs {
761 /// One slice per coordinate axis (2 or 3) of the single scattered smooth.
762 pub coords: Vec<Vec<f64>>,
763 /// Gaussian response.
764 pub y: Vec<f64>,
765 /// Observation weights (variance is `σ²/w`).
766 pub w: Vec<f64>,
767 /// Per-axis positive metric scaling `diag(metric)` of `z = diag(metric)·x`.
768 pub metric: Vec<f64>,
769 /// Sobolev smoothness order `s` of the multilevel Wendland-(3,1) prior,
770 /// clamped into the native-space window `(d/2, (d+3)/2]` (issue caveat 1).
771 pub sobolev_s: f64,
772}
773
774#[cfg(test)]
775mod default_workflow_policy_tests {
776 use super::*;
777
778 #[test]
779 fn formula_fits_checkpoint_durably_by_default() {
780 let config = FitConfig::default();
781 assert!(config.persist_warm_start_disk);
782 let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
783 assert!(options.persist_warm_start_disk);
784 }
785
786 #[test]
787 fn raw_materialization_does_not_activate_adaptive_spatial_resolution() {
788 assert!(
789 FitConfig::default().spatial_center_counts.is_none(),
790 "raw materialization must not activate a grow loop it does not own"
791 );
792 }
793
794 #[test]
795 fn explicit_external_checkpoint_owner_can_disable_disk_layer() {
796 let config = FitConfig {
797 persist_warm_start_disk: false,
798 ..FitConfig::default()
799 };
800 let options = canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
801 assert!(!options.persist_warm_start_disk);
802 }
803}