gam_models/fit_orchestration/entry.rs
1use super::*;
2
3/// Request-specific inputs to the canonical standard-fit `FitOptions`.
4///
5/// Everything in here varies per call (the link state extracted from the
6/// formula/config, the linear constraints synthesized from `bounded()` /
7/// shape-constrained terms, the Firth / adaptive-regularization toggles read
8/// off the `FitConfig`). Every *policy* field of `FitOptions` — the ones that
9/// decide HOW the outer REML optimization behaves (`compute_inference`,
10/// `skip_rho_posterior_inference`, `tol`, the `max_iter` default, the penalty
11/// shrinkage floor) — is filled in by [`canonical_standard_fit_options`] and is
12/// NOT settable here, so the CLI binary and the Python/PyO3 path cannot resolve
13/// a different optimization policy for the same model (#1196). Before this seam
14/// existed the CLI hand-built `FitOptions` with `tol: 1e-6` /
15/// `skip_rho_posterior_inference: false` while the formula path used
16/// `tol: 1e-10` / `skip_rho_posterior_inference: true`, so the identical model
17/// fit *differently* depending on which entry point you called it from — the
18/// exact class of divergence #1191 surfaced.
19#[derive(Default)]
20pub struct StandardFitOptionsInputs {
21 pub latent_cloglog: Option<LatentCLogLogState>,
22 pub mixture_link: Option<MixtureLinkSpec>,
23 pub optimize_mixture: bool,
24 pub sas_link: Option<SasLinkSpec>,
25 pub optimize_sas: bool,
26 pub linear_constraints: Option<gam_solve::pirls::LinearInequalityConstraints>,
27 pub firth_bias_reduction: bool,
28 pub adaptive_regularization: Option<AdaptiveRegularizationOptions>,
29 /// `Some` only when a caller (the forced-Firth CLI branch) overrides the
30 /// canonical default. `None` keeps the single-source default `Some(1e-6)`.
31 pub penalty_shrinkage_floor_override: Option<Option<f64>>,
32}
33
34/// The single source of truth for standard-fit `FitOptions` *policy*.
35///
36/// Both standard-fit entry points — `materialize_standard` (the formula /
37/// Python / PyO3 path) and the `gam` CLI's `run_fit` — construct their
38/// `StandardFitRequest` options through this function, so the outer REML
39/// optimization policy (`compute_inference`, `skip_rho_posterior_inference`,
40/// `tol`, `max_iter` default, `penalty_shrinkage_floor`) is identical by
41/// construction. New policy fields must be set HERE, never re-derived at a call
42/// site, which is what makes Python/CLI behavioral divergence structurally
43/// impossible rather than enforced by parallel-but-equal code (#1196).
44pub fn canonical_standard_fit_options(
45 config: &FitConfig,
46 inputs: StandardFitOptionsInputs,
47) -> FitOptions {
48 FitOptions {
49 resource_policy: resolved_resource_policy(
50 config,
51 gam_runtime::resource::ProblemHints::default(),
52 ),
53 latent_cloglog: inputs.latent_cloglog,
54 mixture_link: inputs.mixture_link,
55 optimize_mixture: inputs.optimize_mixture,
56 sas_link: inputs.sas_link,
57 optimize_sas: inputs.optimize_sas,
58 // Posterior covariance is always computed so `predict --uncertainty`
59 // works for every family (the `COV_MAX_P` diagonal fallback caps cost).
60 compute_inference: true,
61 // Formula/CLI fits are the interactive/default path: keep coefficient
62 // covariance and the smoothing correction, and emit the CHEAP Tier-0
63 // live-rho posterior certificate (a handful of outer-criterion
64 // evaluations), which the optimizer surfaces regardless of this flag
65 // whenever it is cheaply available (#1810). This flag only suppresses the
66 // EXPENSIVE escalation tiers (Tier-1 quadrature / Tier-2 NUTS over rho),
67 // which could otherwise launch NUTS and turn ordinary fits into sampler
68 // benchmarks. Lower-level callers that explicitly need the escalation opt
69 // in elsewhere (`skip_rho_posterior_inference: false`).
70 skip_rho_posterior_inference: true,
71 max_iter: config.outer_max_iter.unwrap_or(200),
72 // Outer REML/LAML smoothing-selection tolerance. `1e-10` (effective
73 // projected-gradient threshold ≈ 1e-7) resolves λ̂ to optimiser
74 // precision and restores the `w=c ⇔ c-fold replication` invariance in
75 // smoothing selection (gam#893). The CLI previously used the stale
76 // `1e-6`, which over-smoothed relative to the formula path.
77 tol: 1e-10,
78 nullspace_dims: vec![],
79 linear_constraints: inputs.linear_constraints,
80 firth_bias_reduction: inputs.firth_bias_reduction,
81 adaptive_regularization: inputs.adaptive_regularization,
82 penalty_shrinkage_floor: inputs
83 .penalty_shrinkage_floor_override
84 .unwrap_or(Some(1e-6)),
85 rho_prior: Default::default(),
86 kronecker_penalty_system: None,
87 kronecker_factored: None,
88 // A formula fit is recoverable across process/wall interruptions by
89 // default. The model/data fingerprinting and checkpoint cadence live
90 // in gam-solve; this canonical seam only owns the high-level policy.
91 persist_warm_start_disk: config.persist_warm_start_disk,
92 }
93}
94
95pub fn fit_model(request: FitRequest<'_>) -> Result<FitResult, WorkflowError> {
96 let request = request;
97 // Each `fit_*_model` helper still returns `Result<_, String>` internally;
98 // the boundary conversion happens here so the public API returns
99 // `WorkflowError::IntegrationFailed` carrying the underlying solver text.
100 let wrap_solver_err =
101 |reason: String| -> WorkflowError { WorkflowError::IntegrationFailed { reason } };
102 match request {
103 FitRequest::Standard(request) => fit_standard_model(request)
104 .map(FitResult::Standard)
105 .map_err(wrap_solver_err),
106 FitRequest::GaussianLocationScale(request) => fit_gaussian_location_scale_model(request)
107 .map(FitResult::GaussianLocationScale)
108 .map_err(wrap_solver_err),
109 FitRequest::BinomialLocationScale(request) => fit_binomial_location_scale_model(request)
110 .map(FitResult::BinomialLocationScale)
111 .map_err(wrap_solver_err),
112 FitRequest::DispersionLocationScale(request) => {
113 fit_dispersion_location_scale_model(request)
114 .map(FitResult::DispersionLocationScale)
115 .map_err(wrap_solver_err)
116 }
117 FitRequest::SurvivalLocationScale(request) => fit_survival_location_scale_model(request)
118 .map(FitResult::SurvivalLocationScale)
119 .map_err(wrap_solver_err),
120 FitRequest::SurvivalTransformation(request) => fit_survival_transformation_model(request)
121 .map(FitResult::SurvivalTransformation)
122 .map_err(wrap_solver_err),
123 FitRequest::BernoulliMarginalSlope(request) => fit_bernoulli_marginal_slope_model(request)
124 .map(FitResult::BernoulliMarginalSlope)
125 .map_err(wrap_solver_err),
126 FitRequest::SurvivalMarginalSlope(request) => fit_survival_marginal_slope_model(request)
127 .map(FitResult::SurvivalMarginalSlope)
128 .map_err(wrap_solver_err),
129 FitRequest::LatentSurvival(request) => fit_latent_survival_model(request)
130 .map(FitResult::LatentSurvival)
131 .map_err(wrap_solver_err),
132 FitRequest::LatentBinary(request) => fit_latent_binary_model(request)
133 .map(FitResult::LatentBinary)
134 .map_err(wrap_solver_err),
135 FitRequest::TransformationNormal(request) => fit_transformation_normal_model(request)
136 .map(FitResult::TransformationNormal)
137 .map_err(wrap_solver_err),
138 }
139}
140/// Resolve the [`gam_runtime::resource::ResourcePolicy`] backing term construction
141/// for a given [`FitConfig`] + dataset.
142///
143/// If the caller hasn't supplied an explicit policy override, delegate to
144/// [`gam_runtime::resource::ResourcePolicy::for_problem`]. Non-structural paths
145/// no longer switch mode at row/column thresholds: each planned allocation is
146/// admitted from its checked live-byte footprint against the process-wide
147/// memory governor. Consequently there is no speculative pre-spec coefficient
148/// estimate to compute here (and no small-n/large-p classification cliff);
149/// `ProblemHints` remains the structural signal for operator-only estimators.
150pub(crate) fn resolved_resource_policy(
151 config: &FitConfig,
152 hints: gam_runtime::resource::ProblemHints,
153) -> gam_runtime::resource::ResourcePolicy {
154 if let Some(p) = config.resource_policy.clone() {
155 return p;
156 }
157 gam_runtime::resource::ResourcePolicy::for_problem(hints)
158}
159
160pub(crate) fn marginal_slope_hints(config: &FitConfig) -> gam_runtime::resource::ProblemHints {
161 gam_runtime::resource::ProblemHints {
162 marginal_slope_large_scale_active: requests_bernoulli_marginal_slope(config),
163 }
164}
165/// Parse, materialize, and fit a model in one call.
166/// Resolve the expectile asymmetry `τ` requested by `config`, if any.
167///
168/// Returns `Ok(Some(τ))` when `config.family` is `"expectile"` (optionally with
169/// an inline asymmetry, `"expectile(0.9)"`), `Ok(None)` for every other family,
170/// and `Err` when an expectile request carries an out-of-range `τ`. The inline
171/// form takes precedence over the explicit [`FitConfig::expectile_tau`] field
172/// only when both are present and disagree is rejected as a contradiction; when
173/// neither pins `τ`, the median expectile `τ = 0.5` (the ordinary mean fit) is
174/// the default.
175fn expectile_tau_for_config(config: &FitConfig) -> Result<Option<f64>, WorkflowError> {
176 let Some(raw) = config.family.as_deref() else {
177 return Ok(None);
178 };
179 let trimmed = raw.trim();
180 let lower = trimmed.to_ascii_lowercase();
181 if !(lower == "expectile" || lower.starts_with("expectile(")) {
182 return Ok(None);
183 }
184 let invalid = |reason: String| WorkflowError::InvalidConfig { reason };
185 // Optional inline asymmetry: `expectile(0.9)`.
186 let inline_tau = if let Some(rest) = lower.strip_prefix("expectile(") {
187 let inner = rest.strip_suffix(')').ok_or_else(|| {
188 invalid(format!(
189 "expectile family asymmetry must be written as `expectile(τ)`; got `{trimmed}`"
190 ))
191 })?;
192 let value: f64 = inner.trim().parse().map_err(|_| {
193 invalid(format!(
194 "expectile asymmetry `{}` is not a finite number",
195 inner.trim()
196 ))
197 })?;
198 Some(value)
199 } else {
200 None
201 };
202 let tau = match (inline_tau, config.expectile_tau) {
203 (Some(a), Some(b)) if (a - b).abs() > 0.0 => {
204 return Err(invalid(format!(
205 "expectile asymmetry given both inline (`expectile({a})`) and via expectile_tau \
206 ({b}); supply exactly one"
207 )));
208 }
209 (Some(a), _) => a,
210 (None, Some(b)) => b,
211 (None, None) => 0.5,
212 };
213 if !(tau.is_finite() && tau > 0.0 && tau < 1.0) {
214 return Err(invalid(format!(
215 "expectile asymmetry τ must be finite and strictly in (0, 1); got {tau}"
216 )));
217 }
218 Ok(Some(tau))
219}
220
221/// Per-row asymmetric LAWS weight `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`, scaled
222/// by the base prior weight. At the boundary `yᵢ = μᵢ` the two half-weights
223/// agree in the limit only at `τ = 0.5`; the convention `yᵢ > μᵢ ⇒ τ` (strict)
224/// matches Newey–Powell's lower-closed asymmetric loss and is what `expectreg`
225/// uses. The fixed point is independent of the tie convention because ties form
226/// a measure-zero set under any continuous response.
227fn expectile_row_weights(
228 y: ArrayView1<f64>,
229 mu: ArrayView1<f64>,
230 base: ArrayView1<f64>,
231 tau: f64,
232) -> Array1<f64> {
233 Array1::from_shape_fn(y.len(), |i| {
234 let asym = if y[i] > mu[i] { tau } else { 1.0 - tau };
235 base[i] * asym
236 })
237}
238
239/// Constant-history cycle detector for the deterministic LAWS sign map.
240///
241/// Brent's power-of-two schedule detects a cycle of any length while retaining
242/// one `Vec<bool>` checkpoint, rather than one sign vector per iteration. That
243/// keeps cycle detection O(n) in the number of observations even when a caller
244/// grants a large iteration budget.
245#[derive(Debug, Default)]
246struct ExpectileSignCycle {
247 anchor: Option<Vec<bool>>,
248 power: usize,
249 span: usize,
250}
251
252impl ExpectileSignCycle {
253 /// Observe the next sign state. Returns the detected cycle length once the
254 /// current state revisits Brent's anchor.
255 fn observe(&mut self, sign: &[bool]) -> Option<usize> {
256 let Some(anchor) = self.anchor.as_deref() else {
257 self.anchor = Some(sign.to_vec());
258 self.power = 1;
259 return None;
260 };
261
262 self.span += 1;
263 if anchor == sign {
264 return Some(self.span);
265 }
266 if self.span == self.power {
267 self.anchor = Some(sign.to_vec());
268 self.power = self.power.saturating_mul(2);
269 self.span = 0;
270 }
271 None
272 }
273}
274
275/// Dimensionless KKT residual for the asymmetric objective at a frozen-weight
276/// WLS solution.
277///
278/// For coefficient `j`, `d_j = x_j'((w_frozen - w_target) ⊙ r)` is the
279/// gradient defect introduced by using the old residual signs. Normalize it
280/// by `sqrt((x_j' W_audit x_j) (r' W_audit r))`, its Cauchy–Schwarz scale with
281/// `W_audit = max(W_frozen, W_target)`. The maximum coordinate residual is
282/// invariant to response scale, column scale, and a common rescaling of prior
283/// weights; unlike a score-relative ratio, it remains meaningful when the
284/// frozen unpenalized score cancels to zero.
285fn expectile_kkt_residual(
286 design: &gam_linalg::matrix::DesignMatrix,
287 residual: ArrayView1<'_, f64>,
288 frozen_weights: ArrayView1<'_, f64>,
289 target_weights: ArrayView1<'_, f64>,
290) -> Result<f64, String> {
291 use gam_linalg::matrix::LinearOperator;
292
293 let n = design.nrows();
294 if residual.len() != n || frozen_weights.len() != n || target_weights.len() != n {
295 return Err(format!(
296 "expectile KKT dimension mismatch: design rows={n}, residual={}, frozen weights={}, \
297 target weights={}",
298 residual.len(),
299 frozen_weights.len(),
300 target_weights.len(),
301 ));
302 }
303 if residual.iter().any(|v| !v.is_finite())
304 || frozen_weights
305 .iter()
306 .chain(target_weights.iter())
307 .any(|v| !v.is_finite() || *v < 0.0)
308 {
309 return Err(
310 "expectile KKT audit requires finite residuals and finite non-negative weights"
311 .to_string(),
312 );
313 }
314
315 let mut row_scratch =
316 Array1::from_shape_fn(n, |i| (frozen_weights[i] - target_weights[i]) * residual[i]);
317 let defect = design.apply_transpose(&row_scratch);
318 for i in 0..n {
319 row_scratch[i] = frozen_weights[i].max(target_weights[i]);
320 }
321 let energy = (0..n)
322 .map(|i| row_scratch[i] * residual[i] * residual[i])
323 .sum::<f64>();
324 if !energy.is_finite() || energy < 0.0 {
325 return Err(format!(
326 "expectile KKT audit produced invalid residual energy {energy:?}"
327 ));
328 }
329 let gram_diag = design.diag_gram(&row_scratch)?;
330 if defect.len() != gram_diag.len()
331 || defect.iter().any(|v| !v.is_finite())
332 || gram_diag.iter().any(|v| !v.is_finite() || *v < 0.0)
333 {
334 return Err("expectile KKT audit produced invalid score/Gram evidence".to_string());
335 }
336
337 let mut max_scaled = 0.0_f64;
338 for (&d, &q) in defect.iter().zip(gram_diag.iter()) {
339 let denominator_squared = q * energy;
340 let scaled = if denominator_squared > 0.0 {
341 d.abs() / denominator_squared.sqrt()
342 } else if d == 0.0 {
343 0.0
344 } else {
345 f64::INFINITY
346 };
347 max_scaled = max_scaled.max(scaled);
348 }
349 Ok(max_scaled)
350}
351
352#[cfg(test)]
353mod expectile_convergence_tests {
354 use super::{ExpectileSignCycle, expectile_kkt_residual};
355 use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
356 use ndarray::array;
357
358 #[test]
359 fn brent_detector_finds_fixed_sign_state() {
360 let mut detector = ExpectileSignCycle::default();
361 let sign = vec![true, false, true, true];
362 assert_eq!(detector.observe(&sign), None);
363 assert_eq!(detector.observe(&sign), Some(1));
364 }
365
366 #[test]
367 fn brent_detector_finds_longer_cycle_without_storing_history() {
368 let mut detector = ExpectileSignCycle::default();
369 let cycle = [
370 vec![true, false, false],
371 vec![false, true, false],
372 vec![false, false, true],
373 ];
374 let mut detected = None;
375 for sign in cycle.iter().cycle().take(9) {
376 detected = detector.observe(sign);
377 if detected.is_some() {
378 break;
379 }
380 }
381 assert_eq!(detected, Some(3));
382 assert_eq!(detector.anchor.as_ref().map(Vec::len), Some(3));
383 }
384
385 #[test]
386 fn normalized_kkt_residual_handles_a_cancelling_frozen_score() {
387 let design = DesignMatrix::Dense(DenseDesignMatrix::from(array![[1.0], [1.0]]));
388 // The frozen intercept score is exactly zero. A score-relative ratio
389 // would divide the tiny target defect by itself and report O(1); the
390 // Cauchy–Schwarz normalization correctly recognizes a near-tie.
391 let residual = array![-1.0, 1.0];
392 let frozen = array![1.0, 1.0];
393 let target = array![1.0, 1.0 + 1.0e-12];
394 let kkt = expectile_kkt_residual(&design, residual.view(), frozen.view(), target.view())
395 .expect("finite KKT audit");
396 assert!(kkt < 1.0e-10, "normalized residual was {kkt:.3e}");
397 }
398
399 #[test]
400 fn normalized_kkt_residual_is_column_and_weight_scale_invariant() {
401 let residual = array![-2.0, 1.0, 1.0];
402 let frozen = array![1.0, 1.0, 1.0];
403 let target = array![1.0, 1.25, 0.75];
404 let x = array![[1.0], [2.0], [-1.0]];
405 let base = DesignMatrix::Dense(DenseDesignMatrix::from(x.clone()));
406 let scaled = DesignMatrix::Dense(DenseDesignMatrix::from(x * 1.0e6));
407 let base_kkt = expectile_kkt_residual(&base, residual.view(), frozen.view(), target.view())
408 .expect("base KKT audit");
409 let scaled_kkt = expectile_kkt_residual(
410 &scaled,
411 residual.view(),
412 (frozen.clone() * 1.0e4).view(),
413 (target.clone() * 1.0e4).view(),
414 )
415 .expect("scaled KKT audit");
416 assert!((base_kkt - scaled_kkt).abs() <= f64::EPSILON.sqrt());
417 }
418}
419
420fn constant_gaussian_standard_fit(
421 request: &StandardFitRequest<'_>,
422) -> Result<StandardFitResult, WorkflowError> {
423 if !request.family.is_gaussian_identity() || request.y.is_empty() {
424 return Err(WorkflowError::InvalidConfig {
425 reason: "constant Gaussian shortcut requires a non-empty Gaussian identity request"
426 .to_string(),
427 });
428 }
429 if request.y.iter().any(|value| !value.is_finite())
430 || request.offset.iter().any(|value| !value.is_finite())
431 || request
432 .weights
433 .iter()
434 .any(|value| !value.is_finite() || *value < 0.0)
435 {
436 return Err(WorkflowError::InvalidConfig {
437 reason: "constant Gaussian shortcut requires finite response, offset, and non-negative weights"
438 .to_string(),
439 });
440 }
441 let weight_sum = request.weights.sum();
442 if !(weight_sum.is_finite() && weight_sum > 0.0) {
443 return Err(WorkflowError::InvalidConfig {
444 reason: "constant Gaussian shortcut requires positive total weight".to_string(),
445 });
446 }
447 // Dispatch proved every represented `y - offset` value is identical. Use
448 // that exact value instead of recomputing it as a weighted mean: the latter
449 // can introduce summation round-off and contradict the shortcut's defining
450 // residual≡0 invariant even though the mathematical mean is unchanged.
451 let intercept = request.y[0] - request.offset[0];
452 let design =
453 build_term_collection_design(request.data.view(), &request.spec).map_err(|err| {
454 WorkflowError::InvalidConfig {
455 reason: format!("constant Gaussian shortcut could not rebuild design: {err}"),
456 }
457 })?;
458 let p = design.design.ncols();
459 let mut beta = Array1::<f64>::zeros(p);
460 for col in design.intercept_range.clone() {
461 if col < p {
462 beta[col] = intercept;
463 }
464 }
465
466 // A constant response is fit EXACTLY by the intercept (residual ≡ 0), so the
467 // fitted β is invariant to the smoothing parameters: every penalized wiggle
468 // is unsupported and shrinks out. But a fit is only usable if it carries a
469 // complete inference bundle — the penalized Hessian, EDF, dispersion, and
470 // covariance that null-space metadata, `edf_total()`, prediction bands, and
471 // the persistence payload all read. The prior shortcut returned `inference:
472 // None`/`geometry: None`, so the model builder then hard-failed with
473 // "null-space Hessian logdet requires fitted penalized Hessian" (#2254) even
474 // for `y ~ 1`. We assemble that bundle here at a fully-smoothed λ. Because the
475 // residual is exactly zero the estimated dispersion φ̂ = 0, so every
476 // coefficient covariance is exactly zero (no ill-conditioned inverse needed).
477 let x_dense = design.design.to_dense();
478 let weights = request.weights.as_ref().clone();
479 let xtwx = gam_linalg::faer_ndarray::fast_xt_diag_x(&x_dense, &weights);
480 let n_penalties = design.penalties.len();
481 let mut unit_penalty = Array2::<f64>::zeros((p, p));
482 for (penalty_index, block) in design.penalties.iter().enumerate() {
483 let r = block.col_range.clone();
484 if r.is_empty()
485 || r.end > p
486 || block.local.nrows() != r.len()
487 || block.local.ncols() != r.len()
488 {
489 return Err(WorkflowError::IntegrationFailed {
490 reason: format!(
491 "constant Gaussian shortcut received malformed penalty {penalty_index}: \
492 range={r:?}, local={}x{}, design width={p}",
493 block.local.nrows(),
494 block.local.ncols()
495 ),
496 });
497 }
498 if block.local.iter().any(|value| !value.is_finite()) {
499 return Err(WorkflowError::IntegrationFailed {
500 reason: format!(
501 "constant Gaussian shortcut received non-finite penalty {penalty_index}"
502 ),
503 });
504 }
505 unit_penalty
506 .slice_mut(ndarray::s![r.clone(), r])
507 .scaled_add(1.0, &block.local);
508 }
509
510 // This fit is the analytic λ→∞ boundary: every direction in range(S) is a
511 // hard constraint and only null(S) carries EDF. Arrays cannot store an
512 // infinite precision because `∞·0` is NaN, so represent that boundary at
513 // floating-point resolution. Choose λ from the ACTUAL penalty spectrum:
514 // the weakest numerically non-null penalty direction must dominate the
515 // largest data-information scale by 1/sqrt(ε). Unlike the former `1e10`
516 // multiplier, this is invariant to rescaling either X'WX or S and contains
517 // no model-specific tuning knob.
518 let lambda_full = if n_penalties == 0 {
519 0.0
520 } else {
521 use gam_linalg::faer_ndarray::FaerEigh;
522 let symmetric_penalty = (&unit_penalty + &unit_penalty.t().to_owned()) * 0.5;
523 let (penalty_eigenvalues, _) =
524 symmetric_penalty.eigh(faer::Side::Lower).map_err(|error| {
525 WorkflowError::IntegrationFailed {
526 reason: format!(
527 "constant Gaussian shortcut could not resolve the penalty spectrum: {error}"
528 ),
529 }
530 })?;
531 let largest_penalty = penalty_eigenvalues
532 .iter()
533 .fold(0.0_f64, |largest, &value| largest.max(value.abs()));
534 if !(largest_penalty.is_finite() && largest_penalty > 0.0) {
535 return Err(WorkflowError::IntegrationFailed {
536 reason: "constant Gaussian shortcut received penalties with zero numerical rank"
537 .to_string(),
538 });
539 }
540 let rank_floor = f64::EPSILON * (p.max(1) as f64) * largest_penalty;
541 if let Some(&negative) = penalty_eigenvalues
542 .iter()
543 .filter(|&&value| value < -rank_floor)
544 .min_by(|left, right| left.total_cmp(right))
545 {
546 return Err(WorkflowError::IntegrationFailed {
547 reason: format!(
548 "constant Gaussian shortcut received a non-PSD penalty \
549 (minimum eigenvalue {negative:.6e}, numerical floor {rank_floor:.6e})"
550 ),
551 });
552 }
553 let weakest_penalty = penalty_eigenvalues
554 .iter()
555 .copied()
556 .filter(|&value| value > rank_floor)
557 .min_by(|left, right| left.total_cmp(right))
558 .ok_or_else(|| WorkflowError::IntegrationFailed {
559 reason: "constant Gaussian shortcut could not identify a penalized direction"
560 .to_string(),
561 })?;
562 // The induced infinity norm bounds the spectral norm of symmetric
563 // X'WX. A diagonal-only scale can underestimate a highly correlated
564 // design by O(p), leaving some data-informed direction insufficiently
565 // constrained at the purported λ→∞ boundary.
566 let information_scale = xtwx
567 .rows()
568 .into_iter()
569 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
570 .fold(0.0_f64, f64::max)
571 .max(f64::MIN_POSITIVE);
572 let lambda = information_scale / (f64::EPSILON.sqrt() * weakest_penalty);
573 if !(lambda.is_finite() && lambda > 0.0) {
574 return Err(WorkflowError::IntegrationFailed {
575 reason: format!(
576 "constant Gaussian shortcut produced invalid boundary precision {lambda}"
577 ),
578 });
579 }
580 lambda
581 };
582 let mut penalized_hessian = xtwx.clone();
583 penalized_hessian.scaled_add(lambda_full, &unit_penalty);
584 // Symmetrize defensively against accumulated round-off before the Cholesky.
585 penalized_hessian = (&penalized_hessian + &penalized_hessian.t()) * 0.5;
586 // Effective degrees of freedom from the influence matrix `F = H⁻¹ XᵀWX`,
587 // decomposed per penalty by the SAME trace formula the standard REML path
588 // (`estimate.rs`) and the survival fast-path (`survival_transformation_edf`)
589 // use: `tr_k = λ·tr(H⁻¹ S_k)`, `edf_k = block_cols_k − tr_k`, and
590 // `edf_total = p − Σ_k tr_k = tr(F)`. Producing the WHOLE bundle here — not
591 // just the scalar total — is what makes the fit self-consistent: `edf_by_block`
592 // aligns 1:1 with `lambdas` (a length the constructor validates), the raw
593 // shrinkage traces feed per-term EDF, and `coefficient_influence = F` is the
594 // authoritative leverage matrix every downstream EDF consumer prefers. At the
595 // fully-smoothed λ each penalized direction is absorbed (`tr_k → rank(S_k)`),
596 // so every block collapses onto its own penalty null space — the honest
597 // complexity of a wiggle-free fit, and exactly the λ→∞ limit of the
598 // near-constant fit that already works.
599 let (edf_total, edf_by_block, penalty_block_trace, coefficient_influence) = {
600 use gam_linalg::faer_ndarray::FaerCholesky;
601 let chol = penalized_hessian
602 .cholesky(faer::Side::Lower)
603 .map_err(|error| WorkflowError::IntegrationFailed {
604 reason: format!(
605 "constant Gaussian boundary precision is not positive definite: {error}"
606 ),
607 })?;
608 {
609 // F = H⁻¹ XᵀWX. Generally NOT symmetric (a product of two
610 // symmetric matrices); it must be stored as-is so `H·F = XᵀWX`
611 // and per-term `tr(F_jj)` stay exact (see estimate.rs / #1027).
612 let influence = chol.solve_mat(&xtwx);
613 let mut edf_by_block = vec![0.0_f64; n_penalties];
614 let mut penalty_block_trace = vec![0.0_f64; n_penalties];
615 for (kk, block) in design.penalties.iter().enumerate() {
616 let r = block.col_range.clone();
617 let block_cols = r.len();
618 // tr(H⁻¹ S_k): solve `H Z = S_k` (embedded in the full p×block
619 // layout) and read the block diagonal of the solution.
620 let mut rhs = Array2::<f64>::zeros((p, block_cols));
621 for c in 0..block_cols {
622 for rr in 0..block_cols {
623 rhs[[r.start + rr, c]] = block.local[[rr, c]];
624 }
625 }
626 let sol = chol.solve_mat(&rhs);
627 let mut trace = 0.0_f64;
628 for j in 0..block_cols {
629 trace += sol[[r.start + j, j]];
630 }
631 let lam_trace = (lambda_full * trace).clamp(0.0, block_cols as f64);
632 penalty_block_trace[kk] = lam_trace;
633 edf_by_block[kk] = (block_cols as f64 - lam_trace).clamp(0.0, block_cols as f64);
634 }
635 let edf_total = influence
636 .diag()
637 .iter()
638 .copied()
639 .sum::<f64>()
640 .clamp(0.0, p as f64);
641 (
642 edf_total,
643 edf_by_block,
644 penalty_block_trace,
645 Some(influence),
646 )
647 }
648 };
649 // IRLS working response for the identity link is the raw response y (η
650 // absorbs the offset); the working weights are the prior weights.
651 let working_response = request.y.as_ref().clone();
652 let lambdas = Array1::<f64>::from_elem(n_penalties, lambda_full);
653 let log_lambdas = lambdas.mapv(|v| v.max(f64::MIN_POSITIVE).ln());
654 let penalized_hessian_precision =
655 gam_problem::dispersion_cov::UnscaledPrecision::wrap(penalized_hessian.clone());
656 let inference = gam_solve::estimate::FitInference {
657 edf_by_block,
658 penalty_block_trace,
659 edf_total,
660 smoothing_correction: None,
661 penalized_hessian: penalized_hessian_precision.clone(),
662 working_weights: weights.clone(),
663 working_response: working_response.clone(),
664 reparam_qs: None,
665 // Exact fit ⇒ residual variance is exactly zero.
666 dispersion: gam_solve::estimate::Dispersion::Estimated(0.0),
667 beta_covariance: Some(gam_problem::dispersion_cov::PhiScaledCovariance::wrap(
668 ndarray::Array2::<f64>::zeros((p, p)),
669 )),
670 beta_standard_errors: Some(Array1::<f64>::zeros(p)),
671 beta_covariance_corrected: None,
672 beta_standard_errors_corrected: None,
673 beta_covariance_frequentist: None,
674 coefficient_influence,
675 weighted_gram: Some(xtwx),
676 bias_correction_beta: None,
677 bias_correction_jacobian: None,
678 };
679 let geometry = Some(gam_solve::estimate::FitGeometry {
680 penalized_hessian: penalized_hessian_precision,
681 working_weights: weights,
682 working_response,
683 });
684 let fit = gam_solve::estimate::UnifiedFitResult::try_from_parts(
685 gam_solve::estimate::UnifiedFitResultParts {
686 blocks: vec![gam_solve::estimate::FittedBlock {
687 beta: beta.clone(),
688 role: gam_problem::BlockRole::Mean,
689 edf: edf_total,
690 lambdas: lambdas.clone(),
691 }],
692 log_lambdas,
693 lambdas,
694 likelihood_family: Some(request.family.clone()),
695 likelihood_scale: gam_problem::LikelihoodScaleMetadata::ProfiledGaussian,
696 log_likelihood_normalization: gam_problem::LogLikelihoodNormalization::UserProvided,
697 log_likelihood: 0.0,
698 deviance: 0.0,
699 reml_score: 0.0,
700 stable_penalty_term: 0.0,
701 penalized_objective: 0.0,
702 used_device: false,
703 outer_iterations: 0,
704 outer_converged: true,
705 outer_gradient_norm: Some(0.0),
706 standard_deviation: 0.0,
707 covariance_conditional: Some(ndarray::Array2::<f64>::zeros((p, p))),
708 covariance_corrected: None,
709 inference: Some(inference),
710 fitted_link: gam_solve::estimate::FittedLinkState::Standard(None),
711 geometry,
712 block_states: Vec::new(),
713 pirls_status: gam_solve::pirls::PirlsStatus::Converged,
714 max_abs_eta: intercept.abs(),
715 constraint_kkt: None,
716 artifacts: gam_solve::estimate::FitArtifacts {
717 pirls: None,
718 ..Default::default()
719 },
720 inner_cycles: 0,
721 },
722 )
723 .map_err(|err| WorkflowError::IntegrationFailed {
724 reason: format!("constant Gaussian shortcut produced invalid fit: {err}"),
725 })?;
726 let resolvedspec =
727 freeze_term_collection_from_design(&request.spec, &design).map_err(|err| {
728 WorkflowError::InvalidConfig {
729 reason: format!("constant Gaussian shortcut could not freeze design: {err}"),
730 }
731 })?;
732 Ok(StandardFitResult {
733 fit,
734 design,
735 resolvedspec,
736 adaptive_spatial_terms: adaptive_spatial_term_mask(&request.spec),
737 adaptive_spatial_center_counts: adaptive_spatial_center_counts(&request.spec),
738 adaptive_diagnostics: None,
739 kappa_timing: None,
740 saved_link_state: gam_solve::estimate::FittedLinkState::Standard(None),
741 wiggle_knots: None,
742 wiggle_degree: None,
743 wiggle_saved_warp_beta: None,
744 wiggle_saved_index_shift: None,
745 })
746}
747
748fn gaussian_response_is_constant(request: &StandardFitRequest<'_>) -> bool {
749 if !request.family.is_gaussian_identity() || request.y.is_empty() {
750 return false;
751 }
752 // The intercept-only shortcut is exact — residual ≡ 0 — precisely when the
753 // OFFSET-ADJUSTED response `y − offset` is constant: then `η = offset +
754 // intercept = y` at every row. Testing the raw `y` alone would (a) miss an
755 // exact fit where a varying offset cancels a varying `y`, and (b) wrongly
756 // fire on a constant `y` under a varying offset, where the fit is NOT exact
757 // and the zero-dispersion inference the shortcut mints would be invalid.
758 if request.y.len() != request.offset.len() {
759 return false;
760 }
761 let mut adjusted = request.y.iter().zip(request.offset.iter());
762 let Some((&first_y, &first_offset)) = adjusted.next() else {
763 return false;
764 };
765 let first = first_y - first_offset;
766 if !first.is_finite() {
767 return false;
768 }
769 for (&yi, &oi) in adjusted {
770 let value = yi - oi;
771 if !value.is_finite() || value != first {
772 return false;
773 }
774 }
775 true
776}
777
778pub fn fit_from_formula(
779 formula: &str,
780 data: &Dataset,
781 config: &FitConfig,
782) -> Result<FitResult, WorkflowError> {
783 fit_from_formula_with_notes(formula, data, config).map(|outcome| outcome.result)
784}
785
786/// A fitted formula result together with advisories emitted by its one
787/// authoritative materialization pass.
788pub struct FormulaFitResult {
789 pub result: FitResult,
790 pub inference_notes: Vec<String>,
791}
792
793/// Resolve, materialize, and fit a formula without making front ends repeat any
794/// model construction. Unlike `fit_from_formula`, this service also returns the
795/// materializer's user-facing advisories for CLI/Python presentation.
796pub fn fit_from_formula_with_notes(
797 formula: &str,
798 data: &Dataset,
799 config: &FitConfig,
800) -> Result<FormulaFitResult, WorkflowError> {
801 let mut config = config
802 .clone()
803 .resolve()
804 .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
805 // Only this entry point owns the fit→measure→expand loop. Raw public
806 // `materialize()` callers receive the ordinary fully provisioned basis;
807 // activating the structural start without an owner would strand them in an
808 // under-resolved function space.
809 config.spatial_center_counts = Some(Vec::new());
810 let current = fit_from_formula_once_with_notes(formula, data, &config)?;
811 finish_adaptive_spatial_fit(formula, data, config, current)
812}
813
814/// Fit an already-materialized standard request, then continue through the
815/// canonical saturation-driven spatial-resolution loop.
816///
817/// Front ends that must inspect the request variant for payload dispatch use
818/// this seam so the dispatch materialization is also the first estimator
819/// materialization. Re-entering [`fit_from_formula_with_notes`] after matching a
820/// `Standard` request would build and discard one complete spatial basis before
821/// the real fit (#1689), duplicating construction work and peak memory on the
822/// Python path.
823pub fn fit_materialized_standard_with_notes(
824 formula: &str,
825 data: &Dataset,
826 config: &FitConfig,
827 request: StandardFitRequest<'_>,
828 inference_notes: Vec<String>,
829) -> Result<FormulaFitResult, WorkflowError> {
830 let mut config = config
831 .clone()
832 .resolve()
833 .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
834 config.spatial_center_counts = Some(Vec::new());
835 let current = fit_materialized_once_with_notes(MaterializedModel {
836 request: FitRequest::Standard(request),
837 inference_notes,
838 })?;
839 finish_adaptive_spatial_fit(formula, data, config, current)
840}
841
842fn finish_adaptive_spatial_fit(
843 formula: &str,
844 data: &Dataset,
845 mut config: FitConfig,
846 mut current: FormulaFitResult,
847) -> Result<FormulaFitResult, WorkflowError> {
848 loop {
849 let Some(current_standard) = standard_result(¤t) else {
850 return Ok(current);
851 };
852 // Saturation is assessed at the same outer-optimization tolerance that
853 // certified this formula fit. `canonical_standard_fit_options` is the
854 // single policy source for that tolerance, so the expansion decision
855 // cannot drift between the CLI and library entry points.
856 let standard_options =
857 canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
858 // A rho-independent shrinkage floor prevents EDF from approaching the
859 // algebraic ceiling more closely than that floor even when lambda tends
860 // to zero. Include it in the resolution tolerance; otherwise the
861 // canonical 1e-6 floor would make a 1e-10 saturation predicate
862 // unreachable and the grow loop would remain dormant in production.
863 let resolution_tol = standard_options
864 .tol
865 .max(standard_options.penalty_shrinkage_floor.unwrap_or(0.0));
866 let candidates =
867 adaptive_spatial_candidates(current_standard, data.values.nrows(), resolution_tol)?;
868 if candidates.is_empty() {
869 return Ok(current);
870 }
871
872 // Grow one saturated term at a time in stable formula order. The next
873 // loop iteration re-fits and re-measures every term, so interactions
874 // between smooths are handled from a converged joint optimum instead
875 // of applying several decisions made against stale EDF evidence.
876 let term_count = candidates.term_count;
877 let candidate = candidates
878 .terms
879 .into_iter()
880 .next()
881 .expect("non-empty adaptive candidate set");
882 // Expansion is mandatory once a certified fit is saturated, so the
883 // old design/covariance can be released before constructing the larger
884 // one. Keeping both complete fits alive would make adaptive resolution
885 // itself an avoidable peak-memory multiplier.
886 drop(current);
887 let mut candidate_config = config.clone();
888 let center_counts = candidate_config
889 .spatial_center_counts
890 .get_or_insert_with(Vec::new);
891 if center_counts.len() < term_count {
892 center_counts.resize(term_count, None);
893 }
894 center_counts[candidate.term_index] = Some(candidate.proposed_centers);
895 let candidate_outcome = fit_from_formula_once_with_notes(formula, data, &candidate_config)
896 .map_err(|error| WorkflowError::SpatialUnderresolved {
897 term: candidate.term_name.clone(),
898 current_centers: candidate.current_centers,
899 attempted_centers: candidate.proposed_centers,
900 reason: error.to_string(),
901 })?;
902 if standard_result(&candidate_outcome).is_none() {
903 return Err(WorkflowError::SpatialUnderresolved {
904 term: candidate.term_name.clone(),
905 current_centers: candidate.current_centers,
906 attempted_centers: candidate.proposed_centers,
907 reason: "the certification refit changed estimator representation".to_string(),
908 });
909 }
910
911 // The current fit's EDF reached its realizable function-space ceiling;
912 // once the larger fit is certified it is the estimator state to resume
913 // from. Comparing raw REML/LAML values across different center charts
914 // is not a valid rejection gate (and a strict `<` accepts numerical
915 // noise), so resolution growth is controlled solely by the next
916 // converged fit's saturation evidence.
917 config = candidate_config;
918 current = candidate_outcome;
919 }
920}
921
922struct AdaptiveSpatialCandidates {
923 term_count: usize,
924 terms: Vec<AdaptiveSpatialCandidate>,
925}
926
927impl AdaptiveSpatialCandidates {
928 fn is_empty(&self) -> bool {
929 self.terms.is_empty()
930 }
931}
932
933struct AdaptiveSpatialCandidate {
934 term_index: usize,
935 term_name: String,
936 current_centers: usize,
937 proposed_centers: usize,
938}
939
940#[derive(Clone, Copy, Debug, PartialEq, Eq)]
941enum AdaptiveCenterDecision {
942 Certified,
943 Expand(usize),
944 Exhausted,
945}
946
947fn adaptive_center_decision(
948 current_centers: usize,
949 ceiling_centers: usize,
950 edf: f64,
951 realized_width: usize,
952 nullspace_dim: usize,
953 resolution_tol: f64,
954) -> AdaptiveCenterDecision {
955 if !gam_terms::basis::basis_is_saturated(edf, realized_width, nullspace_dim, resolution_tol) {
956 return AdaptiveCenterDecision::Certified;
957 }
958 match gam_terms::basis::expanded_num_centers(current_centers, ceiling_centers) {
959 Some(proposed) => AdaptiveCenterDecision::Expand(proposed),
960 None => AdaptiveCenterDecision::Exhausted,
961 }
962}
963
964fn standard_result(outcome: &FormulaFitResult) -> Option<&StandardFitResult> {
965 match &outcome.result {
966 FitResult::Standard(result) => Some(result),
967 _ => None,
968 }
969}
970
971fn adaptive_spatial_candidates(
972 result: &StandardFitResult,
973 n_rows: usize,
974 resolution_tol: f64,
975) -> Result<AdaptiveSpatialCandidates, WorkflowError> {
976 let term_count = result.resolvedspec.smooth_terms.len();
977 if result.adaptive_spatial_terms.len() != term_count
978 || result.adaptive_spatial_center_counts.len() != term_count
979 || result.design.smooth.terms.len() != term_count
980 {
981 return Err(WorkflowError::IntegrationFailed {
982 reason: format!(
983 "adaptive spatial provenance mismatch: resolved terms={term_count}, mask={}, \
984 requested counts={}, realized terms={}",
985 result.adaptive_spatial_terms.len(),
986 result.adaptive_spatial_center_counts.len(),
987 result.design.smooth.terms.len(),
988 ),
989 });
990 }
991
992 let smooth_offset = result
993 .design
994 .design
995 .ncols()
996 .saturating_sub(result.design.smooth.total_smooth_cols());
997 let mut penalty_cursor = result.design.leading_penalty_blocks_before_smooth();
998 let mut candidates = Vec::new();
999 for term_index in 0..term_count {
1000 let realized = &result.design.smooth.terms[term_index];
1001 let penalty_count = realized.penalties_local.len();
1002 if result.adaptive_spatial_terms[term_index]
1003 && let Some(current_centers) = result.adaptive_spatial_center_counts[term_index]
1004 {
1005 let spatial_dimension = result.resolvedspec.smooth_terms[term_index]
1006 .basis
1007 .structural_feature_cols()
1008 .len();
1009 if spatial_dimension == 0 {
1010 return Err(WorkflowError::IntegrationFailed {
1011 reason: format!(
1012 "adaptive spatial term '{}' has no structural feature columns",
1013 result.resolvedspec.smooth_terms[term_index].name,
1014 ),
1015 });
1016 }
1017 // Tiny samples can force the materializer's exact polynomial floor
1018 // above the generic `n / 4` conditioning ceiling. The realized
1019 // request is already the smallest admissible basis in that case, so
1020 // it is also the ceiling; never report a nonsensical attempted
1021 // center count below the basis that just converged.
1022 let ceiling_centers = gam_terms::basis::default_num_centers(n_rows, spatial_dimension)
1023 .max(current_centers);
1024 let global_range = (smooth_offset + realized.coeff_range.start)
1025 ..(smooth_offset + realized.coeff_range.end);
1026 let edf = result
1027 .fit
1028 .per_term_edf(global_range, penalty_cursor, penalty_count);
1029 let nullspace_dim = realized.wald_unpenalized_dim();
1030 match adaptive_center_decision(
1031 current_centers,
1032 ceiling_centers,
1033 edf,
1034 realized.coeff_range.len(),
1035 nullspace_dim,
1036 resolution_tol,
1037 ) {
1038 AdaptiveCenterDecision::Certified => {}
1039 AdaptiveCenterDecision::Expand(proposed_centers) => {
1040 candidates.push(AdaptiveSpatialCandidate {
1041 term_index,
1042 term_name: result.resolvedspec.smooth_terms[term_index].name.clone(),
1043 current_centers,
1044 proposed_centers,
1045 });
1046 }
1047 AdaptiveCenterDecision::Exhausted => {
1048 return Err(WorkflowError::SpatialUnderresolved {
1049 term: result.resolvedspec.smooth_terms[term_index].name.clone(),
1050 current_centers,
1051 attempted_centers: ceiling_centers,
1052 reason: format!(
1053 "term EDF {edf:.6} remains at its realized basis ceiling with all \
1054 {ceiling_centers} validated default centers already requested"
1055 ),
1056 });
1057 }
1058 }
1059 }
1060 penalty_cursor = penalty_cursor.saturating_add(penalty_count);
1061 }
1062 Ok(AdaptiveSpatialCandidates {
1063 term_count,
1064 terms: candidates,
1065 })
1066}
1067
1068#[cfg(test)]
1069mod adaptive_spatial_resolution_tests {
1070 use super::{AdaptiveCenterDecision, adaptive_center_decision};
1071
1072 #[test]
1073 fn unsaturated_basis_is_certified_without_a_probe_refit() {
1074 assert_eq!(
1075 adaptive_center_decision(8, 100, 5.0, 10, 2, 1.0e-6),
1076 AdaptiveCenterDecision::Certified
1077 );
1078 }
1079
1080 #[test]
1081 fn saturated_basis_expands_geometrically_and_respects_validated_ceiling() {
1082 assert_eq!(
1083 adaptive_center_decision(8, 100, 10.0, 10, 2, 1.0e-6),
1084 AdaptiveCenterDecision::Expand(16)
1085 );
1086 assert_eq!(
1087 adaptive_center_decision(64, 100, 10.0, 10, 2, 1.0e-6),
1088 AdaptiveCenterDecision::Expand(100)
1089 );
1090 }
1091
1092 #[test]
1093 fn saturated_basis_at_validated_ceiling_is_typed_exhaustion() {
1094 assert_eq!(
1095 adaptive_center_decision(100, 100, 10.0, 10, 2, 1.0e-6),
1096 AdaptiveCenterDecision::Exhausted
1097 );
1098 }
1099}
1100
1101fn fit_from_formula_once_with_notes(
1102 formula: &str,
1103 data: &Dataset,
1104 config: &FitConfig,
1105) -> Result<FormulaFitResult, WorkflowError> {
1106 // Expectile regression (Newey–Powell asymmetric least squares): when the
1107 // family resolves to "expectile", the τ-expectile of `y | x` is the
1108 // minimizer of `Σ wᵢ(τ)·(yᵢ − μᵢ)²`, `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`
1109 // — the smooth analogue of the τ-quantile. The minimizer is a Least
1110 // Asymmetrically Weighted Squares (LAWS) fixed point: iterate the penalized
1111 // Gaussian-identity GAM with `wᵢ(τ)` recomputed from the current `μᵢ` until
1112 // the residual-sign pattern stabilizes. REML λ-selection runs inside each
1113 // inner Gaussian solve, so every gam smooth/tensor/spatial basis becomes a
1114 // penalized expectile smooth with data-driven smoothing for free. This is a
1115 // genuine estimator route, not a silent swap: it fires only on the explicit
1116 // `family = "expectile"`. Every other family falls through unchanged.
1117 if let Some(result) = fit_expectile_if_requested(formula, data, &config)? {
1118 return Ok(FormulaFitResult {
1119 result: FitResult::Standard(result),
1120 inference_notes: Vec::new(),
1121 });
1122 }
1123 let mat = materialize(formula, data, &config)?;
1124 fit_materialized_once_with_notes(mat)
1125}
1126
1127fn fit_materialized_once_with_notes(
1128 mat: MaterializedModel<'_>,
1129) -> Result<FormulaFitResult, WorkflowError> {
1130 let inference_notes = mat.inference_notes;
1131 // Exact O(n) spline-scan fast path (#1030): when the materialized request
1132 // is the single 1-D Gaussian-identity penalized-smooth shape the
1133 // state-space scan solves exactly, route through it and return the
1134 // scan-bearing model directly — the same penalized posterior at O(n) per
1135 // λ-trial instead of the dense design/Gram route. Detection is structural
1136 // and conservative (see `spline_scan_fast_path`); every other shape falls
1137 // through to the dense `fit_model` path unchanged. Mirrors the CLI
1138 // (main.rs run_fit) and FFI consumers, which build the persistence payload
1139 // from this same `SplineScanFit`.
1140 if let FitRequest::Standard(request) = &mat.request {
1141 if gaussian_response_is_constant(request) {
1142 return constant_gaussian_standard_fit(request).map(|result| FormulaFitResult {
1143 result: FitResult::Standard(result),
1144 inference_notes,
1145 });
1146 }
1147 if let Some(inputs) = spline_scan_fast_path(request) {
1148 let scan = gam_solve::spline_scan::fit_spline_scan(
1149 &inputs.x,
1150 &inputs.y,
1151 &inputs.w,
1152 inputs.order,
1153 )
1154 .map_err(|reason| WorkflowError::IntegrationFailed { reason })?;
1155 return Ok(FormulaFitResult {
1156 result: FitResult::SplineScan(scan),
1157 inference_notes,
1158 });
1159 }
1160 // O(n log n) multiresolution residual-cascade fast path (#1032): a
1161 // scattered low-d Gaussian-identity Duchon/Matérn smooth past the
1162 // dense-kernel cliff. UNLIKE the scan, the cascade is a DIFFERENT
1163 // posterior from the dense radial term, so it only ever fires as an
1164 // explicit alternative estimator on the exact structural signature
1165 // (`residual_cascade_fast_path`) AND when the in-cascade quasi-uniformity
1166 // guard certifies the metric — a rejected metric or any ineligible shape
1167 // falls through to the dense `fit_model` path (a genuine estimator
1168 // choice, never a silent swap). The save paths build the persistence
1169 // payload from this `ResidualCascadeFit`'s `to_state` snapshot.
1170 if let Some(inputs) = residual_cascade_fast_path(request) {
1171 let coord_refs: Vec<&[f64]> = inputs.coords.iter().map(Vec::as_slice).collect();
1172 if let Ok(fit) = gam_solve::residual_cascade::fit_residual_cascade(
1173 &coord_refs,
1174 &inputs.y,
1175 &inputs.w,
1176 &inputs.metric,
1177 inputs.sobolev_s,
1178 ) {
1179 return Ok(FormulaFitResult {
1180 result: FitResult::ResidualCascade(fit),
1181 inference_notes,
1182 });
1183 }
1184 // The quasi-uniformity guard (caveat 2) or any degenerate-design
1185 // signal surfaces as a build/solve error; fall through to the dense
1186 // kernel path rather than failing the fit outright.
1187 }
1188 }
1189 // `fit_model` already returns `WorkflowError` end-to-end; propagate it
1190 // directly instead of stringifying then re-wrapping.
1191 fit_model(mat.request).map(|result| FormulaFitResult {
1192 result,
1193 inference_notes,
1194 })
1195}
1196
1197/// THE single dispatch seam for the expectile (Newey–Powell LAWS) family.
1198///
1199/// Returns `Ok(Some(result))` with the converged τ-expectile as an ordinary
1200/// [`StandardFitResult`] when `config.family` selects the expectile family
1201/// (`"expectile"` or `"expectile(τ)"`, optionally pinned by
1202/// [`FitConfig::expectile_tau`]), `Ok(None)` for every other family — in which
1203/// case the caller runs its normal materialize/`fit_model` path — and `Err` on a
1204/// malformed expectile request or an inner-fit failure.
1205///
1206/// Every public entry point that resolves a family routes through this seam
1207/// *before* materializing: the in-process [`fit_from_formula`], the Python FFI
1208/// (`gam-pyffi`), and the `gam` CLI. Centralizing the dispatch here is what makes
1209/// the estimator reachable from every interface instead of only the library
1210/// call — and what prevents the class of bug where a newly-added outer estimator
1211/// is wired into one entry point and silently bypassed by the others (#1777).
1212/// The returned [`StandardFitResult`] carries the full design / resolved spec /
1213/// fit, so each caller builds its persistence payload from it exactly as it does
1214/// for any other standard fit.
1215pub fn fit_expectile_if_requested(
1216 formula: &str,
1217 data: &Dataset,
1218 config: &FitConfig,
1219) -> Result<Option<StandardFitResult>, WorkflowError> {
1220 match expectile_tau_for_config(config)? {
1221 Some(tau) => Ok(Some(fit_expectile_laws(formula, data, config, tau)?)),
1222 None => Ok(None),
1223 }
1224}
1225
1226/// Least Asymmetrically Weighted Squares (LAWS) driver for expectile GAMs.
1227///
1228/// The τ-expectile surface minimizes `Σ wᵢ(τ)·(yᵢ − μᵢ)²` with the residual-
1229/// sign asymmetric weight `wᵢ(τ)`. The asymmetric loss is convex and
1230/// continuously differentiable: each side of zero is a positive quadratic and
1231/// both one-sided derivatives agree at zero. LAWS solves the penalized WLS
1232/// problem with weights frozen at the current sign pattern, then recomputes the
1233/// pattern. A returned estimator must satisfy the KKT residual of the original
1234/// asymmetric objective; a repeated sign state or an iteration cap is only
1235/// termination evidence, never an estimator-selection rule.
1236///
1237/// Each inner solve is the FULL standard Gaussian-identity GAM: any basis,
1238/// tensor, spatial smooth, by-variable, random effect, plus REML λ-selection on
1239/// the current asymmetric weights. The returned fit is an ordinary
1240/// [`FitResult::Standard`] whose coefficients ARE the penalized τ-expectile —
1241/// every downstream consumer (predict, posterior bands, persistence) works
1242/// unchanged. The reported scale is the asymmetric working variance, so
1243/// expectile standard errors are the sandwich-free Gaussian-form bands of the
1244/// converged weighted problem (a deliberate first-rung choice; see #1100).
1245fn fit_expectile_laws(
1246 formula: &str,
1247 data: &Dataset,
1248 config: &FitConfig,
1249 tau: f64,
1250) -> Result<StandardFitResult, WorkflowError> {
1251 use gam_linalg::matrix::LinearOperator;
1252
1253 if config.frailty.is_active() {
1254 return Err(WorkflowError::InvalidConfig {
1255 reason: "expectile regression does not support frailty; use a survival/frailty-aware family instead"
1256 .to_string(),
1257 });
1258 }
1259
1260 // Inner fits are ordinary Gaussian-identity GAMs; the τ asymmetry lives
1261 // entirely in the per-iteration prior weights this driver injects.
1262 let gaussian_config = FitConfig {
1263 family: Some("gaussian".to_string()),
1264 link: Some("identity".to_string()),
1265 expectile_tau: None,
1266 // The inner Gaussian-identity design carries no frailty.
1267 frailty: FrailtySpec::None,
1268 ..config.clone()
1269 };
1270
1271 // Materialize once to capture the fixed training design, response, offset,
1272 // and base prior weights. The design (basis, penalties, identifiability
1273 // transforms) does not depend on the prior weights, so it is reused across
1274 // every LAWS iteration; only the weight vector and the resulting β change.
1275 let base_mat = materialize(formula, data, &gaussian_config)?;
1276 let FitRequest::Standard(base_request) = base_mat.request else {
1277 return Err(WorkflowError::InvalidConfig {
1278 reason: "expectile regression is only defined for standard (non-survival, \
1279 non-location-scale) responses"
1280 .to_string(),
1281 });
1282 };
1283 let StandardFitRequest {
1284 data: design_data,
1285 y,
1286 weights: base_weights,
1287 offset,
1288 spec,
1289 family: materialized_family,
1290 estimate_tweedie_p: _,
1291 options,
1292 kappa_options,
1293 wiggle,
1294 coefficient_groups,
1295 penalty_block_gamma_priors,
1296 latent_coord,
1297 } = base_request;
1298 // The materializer already resolved the inner family to Gaussian-identity
1299 // from `gaussian_config`; assert it so a future materializer change that
1300 // silently picked a different family for `"gaussian"` is caught here rather
1301 // than producing a non-expectile fit.
1302 if !materialized_family.is_gaussian_identity() {
1303 return Err(WorkflowError::InvalidConfig {
1304 reason: format!(
1305 "expectile LAWS requires a Gaussian-identity inner family; materializer produced {}",
1306 materialized_family.name()
1307 ),
1308 });
1309 }
1310
1311 if wiggle.is_some() || latent_coord.is_some() {
1312 return Err(WorkflowError::InvalidConfig {
1313 reason: "expectile regression does not support flexible-link wiggle or latent \
1314 coordinates"
1315 .to_string(),
1316 });
1317 }
1318
1319 let n = y.len();
1320 let gaussian_family = LikelihoodSpec::gaussian_identity();
1321 // Cold start: unweighted base weights ⇒ the first inner fit is the OLS
1322 // mean GAM, the natural warm start for any τ.
1323 let mut weights = Arc::clone(&base_weights);
1324 // The LAWS map is deterministic given a sign pattern. Brent detection
1325 // proves recurrence using one O(n) sign checkpoint; no iteration-count
1326 // multiple of the training data is retained.
1327 let mut sign_cycle = ExpectileSignCycle::default();
1328 // Evidence for the typed exhaustion error: (dimensionless KKT residual,
1329 // configured KKT bound) of the final uncertified iterate.
1330 let mut last_kkt = (f64::NAN, f64::NAN);
1331 let mut last_rho_checkpoint = Vec::new();
1332
1333 // Reuse the request's explicit outer-work budget; LAWS does not introduce a
1334 // second hidden iteration knob. The budget is a safety guard only: hitting
1335 // it without the certificate below is typed nonconvergence (SPEC rule 20).
1336 let max_laws_iters = options.max_iter;
1337 if max_laws_iters == 0 || !(options.tol.is_finite() && options.tol > 0.0) {
1338 return Err(WorkflowError::InvalidConfig {
1339 reason: format!(
1340 "expectile LAWS requires a positive iteration budget and finite positive KKT \
1341 tolerance; got max_iter={max_laws_iters}, tol={}",
1342 options.tol,
1343 ),
1344 });
1345 }
1346
1347 for iteration in 1..=max_laws_iters {
1348 let request = StandardFitRequest {
1349 data: design_data.clone(),
1350 y: Arc::clone(&y),
1351 weights: Arc::clone(&weights),
1352 offset: Arc::clone(&offset),
1353 spec: spec.clone(),
1354 family: gaussian_family.clone(),
1355 // Expectile LAWS fits a Gaussian-identity inner family; no Tweedie
1356 // power to estimate (#2026).
1357 estimate_tweedie_p: false,
1358 options: options.clone(),
1359 kappa_options: kappa_options.clone(),
1360 wiggle: None,
1361 coefficient_groups: coefficient_groups.clone(),
1362 penalty_block_gamma_priors: penalty_block_gamma_priors.clone(),
1363 latent_coord: None,
1364 };
1365 let result = fit_standard_model(request)
1366 .map_err(|reason| WorkflowError::IntegrationFailed { reason })?;
1367 // Training-scale fitted mean μ = X·β (identity link, zero-checked
1368 // offset folded by the design path). The design columns match the
1369 // combined coefficient vector exactly (the same contract `predict`
1370 // and the safety tests rely on).
1371 let mu = result.design.design.apply(&result.fit.beta);
1372 if mu.len() != n {
1373 return Err(WorkflowError::IntegrationFailed {
1374 reason: format!(
1375 "expectile LAWS: fitted mean length {} disagrees with response length {n}",
1376 mu.len()
1377 ),
1378 });
1379 }
1380 let mut mu_off = mu;
1381 mu_off += offset.as_ref();
1382
1383 let sign: Vec<bool> = (0..n).map(|i| y[i] > mu_off[i]).collect();
1384 let next_weights = expectile_row_weights(y.view(), mu_off.view(), base_weights.view(), tau);
1385
1386 // KKT certificate for the CONVEX penalized asymmetric-least-squares
1387 // problem at the fit's own selected λ. The asymmetric loss
1388 // ρ_τ(r) = |τ − 1[r<0]|·r² is convex and continuously differentiable
1389 // (its derivative vanishes at r = 0 from both sides), so the true
1390 // penalized objective J(β) = Σ wᵢ(τ)·rᵢ² + βᵀS_λβ has a checkable
1391 // gradient at the returned β. The inner solve certifies stationarity
1392 // of the FROZEN-weight problem, Xᵀ(w_used ∘ r) = S_λ β, hence
1393 // ∇J(β)/2 = Xᵀ((w_used − w_new) ∘ r),
1394 // supported exactly on rows whose residual sign disagrees with the
1395 // pattern the weights were frozen at. The production audit normalizes
1396 // each coefficient defect by its Cauchy–Schwarz score scale, making the
1397 // result invariant to column, response, and prior-weight scale while
1398 // remaining defined when an unpenalized frozen score cancels to zero.
1399 let residual = y.as_ref() - &mu_off;
1400 let kkt = expectile_kkt_residual(
1401 &result.design.design,
1402 residual.view(),
1403 weights.view(),
1404 next_weights.view(),
1405 )
1406 .map_err(|reason| WorkflowError::IntegrationFailed {
1407 reason: format!(
1408 "expectile LAWS KKT audit failed at iteration {iteration} \
1409 (rho_checkpoint={:?}): {reason}",
1410 result.fit.log_lambdas.to_vec(),
1411 ),
1412 })?;
1413 let kkt_bound = options.tol;
1414 if kkt <= kkt_bound {
1415 return Ok(result);
1416 }
1417 last_kkt = (kkt, kkt_bound);
1418 last_rho_checkpoint = result.fit.log_lambdas.to_vec();
1419 if let Some(cycle_length) = sign_cycle.observe(&sign) {
1420 return Err(WorkflowError::IntegrationFailed {
1421 reason: format!(
1422 "expectile LAWS entered a deterministic sign-pattern cycle without \
1423 reaching the KKT fixed point of the convex asymmetric least-squares \
1424 problem (tau={tau}, iterations={iteration}, cycle_length={cycle_length}, \
1425 KKT residual={:.3e} vs scaled tolerance {:.3e}, \
1426 rho_checkpoint={:?}); non-convergence is a typed error, never a \
1427 best-effort fit",
1428 kkt,
1429 kkt_bound,
1430 result.fit.log_lambdas.to_vec(),
1431 ),
1432 });
1433 }
1434 weights = Arc::new(next_weights);
1435 }
1436
1437 Err(WorkflowError::IntegrationFailed {
1438 reason: format!(
1439 "expectile LAWS exhausted its {max_laws_iters}-iteration safety cap without a \
1440 KKT certificate for the convex asymmetric least-squares problem (tau={tau}, \
1441 final KKT residual={:.3e} vs scaled tolerance {:.3e}, \
1442 rho_checkpoint={last_rho_checkpoint:?}); the iteration cap \
1443 never selects the estimator — non-convergence is a typed error",
1444 last_kkt.0, last_kkt.1,
1445 ),
1446 })
1447}
1448/// Detection seam for the exact O(n) cubic-smoothing-spline fast path.
1449///
1450/// This is the EARLIEST point in the standard workflow where a materialized
1451/// fit request carries everything needed to prove the model is exactly the
1452/// problem the scan solves: a Gaussian likelihood with identity link over
1453/// `intercept + one 1-D cubic-class penalized smooth` — i.e. the penalized
1454/// least-squares problem `min Σ w_i (y_i − f(x_i))² + λ∫f″²` with an
1455/// unpenalized `{1, x}` null space. The Kalman/RTS scan computes that
1456/// posterior (mean, pointwise variance, exact diffuse REML for λ) in O(n) per
1457/// λ-trial instead of the dense design/Gram O(n·k²) + O(k³) route.
1458///
1459/// Returns `Some` only when ALL of the following hold; everything else falls
1460/// through to the dense path:
1461/// - family is Gaussian + identity link;
1462/// - no link wiggle, no latent coordinates, no coefficient groups, no penalty
1463/// hyperpriors, no linear/box constraints, no Firth, no adaptive
1464/// regularization, no Kronecker systems, no externally injected null-space
1465/// dims;
1466/// - the term collection is exactly one smooth term — no linear terms, no
1467/// random effects, no by-variables / factor interactions;
1468/// - that smooth is a plain 1-D B-spline whose penalty order is compatible
1469/// with the exact scan and whose null space is unshrunk
1470/// (`double_penalty=false`). `double_penalty` (mgcv `select = TRUE`) on a free
1471/// B-spline emits a second REML coordinate — the Marra & Wood (2011) null-space
1472/// shrinkage block — that the scan cannot represent (its polynomial null space
1473/// is an improper diffuse prior it can never shrink); routing such a fit
1474/// through the scan would silently drop that penalty and select λ from the
1475/// bending penalty alone, which is exactly the EDF inflation #1266 reports.
1476/// Those fits fall through to the dense two-rho path, which owns both penalties
1477/// jointly. Natural cubic regression (`bs="cr"`/`"cs"`) terms also fall
1478/// through: their knot-value parameterization is a finite-rank regression
1479/// spline, not the scan's full smoothing-spline state-space posterior;
1480/// - the offset is identically zero and every weight is finite and positive;
1481/// - at least 3 distinct finite abscissae (the scan's diffuse rank plus one).
1482///
1483/// λ-mapping note: the scan's penalty is exactly `λ∫f″²` (state-space
1484/// `q = 1/λ` at unit σ²). The dense 1-D B-spline path penalizes the same
1485/// cubic class through a reduced-rank discrete-difference Gram whose
1486/// normalization differs by a basis-dependent constant, so a λ selected by
1487/// one parameterization does not transfer numerically to the other. The scan
1488/// therefore always re-selects λ by its own exact diffuse REML criterion
1489/// (the optimizer of the same restricted likelihood, expressed in the scan's
1490/// parameterization); user-pinned smoothing parameters are not representable
1491/// at this seam (the formula DSL exposes none for this term class), so no
1492/// pinned-λ mapping arises.
1493///
1494/// Identifiability transforms on the smooth (centering / linear-trend
1495/// removal / orthogonality-to-intercept) are accepted as eligible: they only
1496/// re-coordinate the unpenalized null space against the implicit intercept
1497/// and do not change the fitted posterior of `E[y|x]`, which is what the
1498/// scan returns directly.
1499pub fn spline_scan_fast_path(request: &StandardFitRequest<'_>) -> Option<SplineScanInputs> {
1500 if !request.family.is_gaussian_identity() {
1501 return None;
1502 }
1503 if request.wiggle.is_some()
1504 || request.latent_coord.is_some()
1505 || !request.coefficient_groups.is_empty()
1506 || !request.penalty_block_gamma_priors.is_empty()
1507 {
1508 return None;
1509 }
1510 let options = &request.options;
1511 if options.latent_cloglog.is_some()
1512 || options.mixture_link.is_some()
1513 || options.sas_link.is_some()
1514 || options.linear_constraints.is_some()
1515 || options.adaptive_regularization.is_some()
1516 || options.kronecker_penalty_system.is_some()
1517 || options.kronecker_factored.is_some()
1518 || options.firth_bias_reduction
1519 || !options.nullspace_dims.is_empty()
1520 {
1521 return None;
1522 }
1523 let spec = &request.spec;
1524 if !spec.linear_terms.is_empty()
1525 || !spec.random_effect_terms.is_empty()
1526 || spec.smooth_terms.len() != 1
1527 {
1528 return None;
1529 }
1530 let term = &spec.smooth_terms[0];
1531 if !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None)
1532 || term.joint_null_rotation.is_some()
1533 {
1534 return None;
1535 }
1536 let gam_terms::smooth::SmoothBasisSpec::BSpline1D {
1537 feature_col,
1538 spec: bspec,
1539 } = &term.basis
1540 else {
1541 return None;
1542 };
1543 // Smoothing-spline order m = penalty_order ∈ {1, 2, 3}. The exact scan
1544 // integrates the order-m integrated-Wiener prior whose natural spline has
1545 // degree 2m−1 (m=1 → linear, m=2 → cubic, m=3 → quintic), so require that
1546 // degree to match user intent. The de Jong exact diffuse leading-block
1547 // smoother (#1044) handles the m−1 partially-diffuse leading nodes for all
1548 // m ≤ MAX_ORDER; m > MAX_ORDER falls through to the dense path.
1549 let order = bspec.penalty_order;
1550 // Double-penalty (mgcv `select = TRUE`) is NOT representable by the scan and
1551 // must fall through to the dense two-rho path (#1266). On a free B-spline the
1552 // double penalty emits a *second* REML coordinate — the Marra & Wood (2011)
1553 // null-space shrinkage block `Z Zᵀ` (see `bspline_penalty_candidates`) —
1554 // whose entire purpose is to let REML shrink the unpenalized `{1, x, …}`
1555 // polynomial null space toward `EDF → 0` for an unsupported term. The scan,
1556 // by construction, carries that null space as an *improper diffuse* prior it
1557 // can never shrink (its EDF floor is the null-space dimension `order`), so
1558 // routing a `double_penalty` fit through it silently DROPS the second penalty
1559 // and selects λ from the single bending penalty alone. The scan's own exact
1560 // diffuse REML then genuinely prefers a mildly wiggly fit at finite λ for
1561 // some noise realizations (an interior REML optimum, EDF ≈ 3–4), which is the
1562 // EDF inflation #1266 reports. The dense path owns both penalties jointly and
1563 // its outer REML, seeded into the over-smoothing basin, drives the null space
1564 // out (EDF → null-space dim) when the data are truly polynomial. Excluding
1565 // `double_penalty` here keeps such a fit on the dense path; single-penalty
1566 // and boundary-conditioned single-penalty B-splines keep the exact O(n) scan.
1567 if !(1..=3).contains(&order)
1568 || bspec.degree != 2 * order - 1
1569 || bspec.double_penalty
1570 || !bspec.boundary_conditions.is_free()
1571 || !matches!(bspec.boundary, gam_terms::basis::OneDimensionalBoundary::Open)
1572 || matches!(
1573 bspec.knotspec,
1574 gam_terms::basis::BSplineKnotSpec::PeriodicUniform { .. }
1575 | gam_terms::basis::BSplineKnotSpec::NaturalCubicRegression { .. }
1576 )
1577 // mgcv `bs="cr"`/`"cs"` materialise a `NaturalCubicRegression` value-knot
1578 // spec: a Lancaster–Salkauskas cubic-regression basis whose columns
1579 // index `f(x*_i)` at `k` quantile knots — a genuinely DIFFERENT finite
1580 // basis (and hence a different penalized posterior) from the free
1581 // integrated-Wiener natural spline the exact scan solves on the raw data
1582 // points. The scan builds its own knots from `x` and ignores this spec,
1583 // so routing a cr fit through it would silently solve the wrong model and
1584 // (per #1844) return a non-`Standard` `SplineScan` result the predict-time
1585 // design replay cannot reconstruct. Keep cr/cs on the dense path.
1586 || matches!(
1587 bspec.knotspec,
1588 gam_terms::basis::BSplineKnotSpec::NaturalCubicRegression { .. }
1589 )
1590 {
1591 return None;
1592 }
1593 if request.offset.iter().any(|&v| v != 0.0) {
1594 return None;
1595 }
1596 if request.weights.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1597 return None;
1598 }
1599 if *feature_col >= request.data.ncols() || request.y.len() != request.data.nrows() {
1600 return None;
1601 }
1602 let x: Vec<f64> = request.data.column(*feature_col).iter().copied().collect();
1603 let y: Vec<f64> = request.y.iter().copied().collect();
1604 let w: Vec<f64> = request.weights.iter().copied().collect();
1605 if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
1606 return None;
1607 }
1608 // The diffuse polynomial null space consumes `order` innovations; the scan
1609 // needs at least one proper innovation beyond them to profile σ².
1610 let mut sorted = x.clone();
1611 sorted.sort_by(f64::total_cmp);
1612 sorted.dedup();
1613 if sorted.len() < order + 1 {
1614 return None;
1615 }
1616 Some(SplineScanInputs { x, y, w, order })
1617}
1618
1619/// Formula-level entry for the exact O(n) cubic-smoothing-spline fast path.
1620///
1621/// Materializes the formula exactly like [`fit_from_formula`], then runs the
1622/// [`spline_scan_fast_path`] detection on the resulting standard request.
1623/// When detection fires the fit is routed through
1624/// [`gam_solve::spline_scan::fit_spline_scan`] — the exact diffuse
1625/// REML Kalman/RTS scan — and the full in-memory posterior
1626/// ([`gam_solve::spline_scan::SplineScanFit`]: knots, smoothed
1627/// states, pointwise variances, lag-one gains, σ², log λ, exact EDF, and an
1628/// exact `predict`) is returned. `Ok(None)` means the model is not the
1629/// scan-eligible shape and the caller should use the dense
1630/// [`fit_from_formula`] path; this keeps every persistence-bearing consumer
1631/// (model save, CLI, FFI) transparently on the dense fit, whose saved payload
1632/// the scan does not yet have a schema for.
1633pub fn fit_spline_scan_from_formula(
1634 formula: &str,
1635 data: &Dataset,
1636 config: &FitConfig,
1637) -> Result<Option<gam_solve::spline_scan::SplineScanFit>, WorkflowError> {
1638 let mat = materialize(formula, data, config)?;
1639 let FitRequest::Standard(request) = mat.request else {
1640 return Ok(None);
1641 };
1642 let Some(inputs) = spline_scan_fast_path(&request) else {
1643 return Ok(None);
1644 };
1645 gam_solve::spline_scan::fit_spline_scan(&inputs.x, &inputs.y, &inputs.w, inputs.order)
1646 .map(Some)
1647 .map_err(|reason| WorkflowError::IntegrationFailed { reason })
1648}
1649
1650/// #1464 diagnostic entry point: evaluate the EXACT production fixed-κ
1651/// profiled-REML criterion (`fixed_kappa_profiled_reml_score`, the same one the
1652/// joint-fit κ-sign scan uses) at a list of pinned κ values for the first
1653/// constant-curvature term of `formula`, materialised from `data`/`config`
1654/// exactly like [`fit_from_formula`]. Returns `(κ, V_p(κ))` pairs.
1655///
1656/// This settles solver-vs-criterion for the railing bug: if `V_p(+κ) < V_p(−κ)`
1657/// for a genuinely HYPERBOLIC dataset, the criterion itself prefers the collapsed
1658/// +κ corner — the bug is in the constant-curvature REML/Occam term, not the
1659/// optimiser. If `V_p(−κ) < V_p(+κ)` yet the full fit still returns +κ, the bug
1660/// is in the solver/readback. The profiled fit pins κ and profiles only ρ
1661/// (κ-optimisation disabled), so each returned score is the negative-log-evidence
1662/// the outer loop minimises.
1663pub fn constant_curvature_profiled_reml_scores(
1664 formula: &str,
1665 data: &Dataset,
1666 config: &FitConfig,
1667 kappas: &[f64],
1668) -> Result<Vec<(f64, f64)>, WorkflowError> {
1669 let mat = materialize(formula, data, config)?;
1670 let FitRequest::Standard(request) = mat.request else {
1671 return Err(WorkflowError::IntegrationFailed {
1672 reason: "constant_curvature_profiled_reml_scores: formula did not materialise to a \
1673 standard fit request"
1674 .to_string(),
1675 });
1676 };
1677 let term_idx =
1678 *crate::fit_orchestration::drivers::constant_curvature_term_indices(&request.spec)
1679 .first()
1680 .ok_or_else(|| WorkflowError::IntegrationFailed {
1681 reason:
1682 "constant_curvature_profiled_reml_scores: formula has no constant-curvature \
1683 curv() term"
1684 .to_string(),
1685 })?;
1686 let mut out = Vec::with_capacity(kappas.len());
1687 for &kappa in kappas {
1688 let score = crate::fit_orchestration::drivers::fixed_kappa_profiled_reml_score(
1689 request.data.view(),
1690 request.y.view(),
1691 request.weights.view(),
1692 request.offset.view(),
1693 &request.spec,
1694 term_idx,
1695 kappa,
1696 request.family.clone(),
1697 &request.options,
1698 )
1699 .map_err(|e| WorkflowError::IntegrationFailed {
1700 reason: format!(
1701 "constant_curvature_profiled_reml_scores: fixed-κ fit at κ={kappa} failed: {e}"
1702 ),
1703 })?;
1704 out.push((kappa, score));
1705 }
1706 Ok(out)
1707}
1708
1709/// Derived dense-kernel cliff: the cascade auto-route fires only once the dense
1710/// radial basis the smooth would otherwise use has SATURATED at its center cap
1711/// (`default_num_centers == K_MAX`), so the dense `O(n·K² + K³)` kernel solve
1712/// can no longer grow resolution with `n` and the streaming cascade's
1713/// `O(n·polylog)` is the only path that keeps improving. This is the structural
1714/// "past the dense-kernel cliff" condition the issue names — derived from the
1715/// dense sizing rule, NOT a magic n constant or a user flag.
1716fn past_dense_kernel_cliff(n: usize, d: usize) -> bool {
1717 // `default_num_centers` clamps to K_MAX = 2000; equality means the dense
1718 // basis is pinned at the cap and cannot densify further with n.
1719 const DENSE_CENTER_CAP: usize = 2000;
1720 gam_terms::basis::default_num_centers(n, d) >= DENSE_CENTER_CAP
1721}
1722
1723/// Map a Duchon/Matérn smoothness order onto the cascade's Sobolev order,
1724/// clamped into the Wendland-(3,1) native window `(d/2, (d+3)/2]` (issue
1725/// caveat 1: the multilevel frame can only represent up to `H^{(d+3)/2}`).
1726fn cascade_sobolev_order(requested: f64, d: usize) -> f64 {
1727 let lo = d as f64 / 2.0;
1728 let hi = (d as f64 + 3.0) / 2.0;
1729 // Nudge strictly inside the open lower bound when the request lands on it.
1730 let eps = 1e-6 * (hi - lo);
1731 requested.clamp(lo + eps, hi)
1732}
1733
1734/// Detection seam for the O(n log n) multiresolution residual-cascade fast path
1735/// (issue #1032).
1736///
1737/// This mirrors [`spline_scan_fast_path`] in shape but carries one CRITICAL
1738/// difference dictated by the issue: the cascade is **not** the same posterior
1739/// as the Duchon/Matérn term it stands in for (a different finite basis — the
1740/// multilevel Wendland frame, not the reduced-rank radial kernel). So unlike
1741/// the 1-D scan, which silently swaps an identical posterior, this path must
1742/// only fire as an explicit alternative estimator on the structural signature
1743/// the issue names, never as a transparent replacement. It returns `Some` only
1744/// when ALL of the following hold:
1745/// - family is Gaussian + identity link (the scattered low-d smooth the
1746/// cascade solves);
1747/// - none of the exotic-link / constraint / Firth / Kronecker / coefficient-
1748/// group / hyperprior machinery is engaged;
1749/// - the model is exactly one smooth term — no linear terms, no random
1750/// effects, no by-variables;
1751/// - that smooth is a scattered radial spatial smooth (`Duchon` or `Matern`)
1752/// over `d ∈ {2, 3}` coordinates with no shape constraint;
1753/// - the offset is identically zero and every weight is finite and positive;
1754/// - `n` is past the derived dense-kernel cliff
1755/// ([`past_dense_kernel_cliff`]) — below it the dense radial path is both
1756/// exact-posterior and cheap, so there is no reason to change estimators.
1757///
1758/// The returned [`ResidualCascadeInputs`] carry a unit per-axis metric (the
1759/// spec's isotropic radial distance); the quasi-uniformity guard inside
1760/// [`gam_solve::residual_cascade::fit_residual_cascade`] (issue caveat 2)
1761/// is the no-regression gate that refuses the iterative solve — and forces the
1762/// caller back to the dense path — when a near-degenerate metric would break
1763/// the BPX iteration bound.
1764pub fn residual_cascade_fast_path(
1765 request: &StandardFitRequest<'_>,
1766) -> Option<ResidualCascadeInputs> {
1767 if !request.family.is_gaussian_identity() {
1768 return None;
1769 }
1770 if request.wiggle.is_some()
1771 || request.latent_coord.is_some()
1772 || !request.coefficient_groups.is_empty()
1773 || !request.penalty_block_gamma_priors.is_empty()
1774 {
1775 return None;
1776 }
1777 let options = &request.options;
1778 if options.latent_cloglog.is_some()
1779 || options.mixture_link.is_some()
1780 || options.sas_link.is_some()
1781 || options.linear_constraints.is_some()
1782 || options.adaptive_regularization.is_some()
1783 || options.kronecker_penalty_system.is_some()
1784 || options.kronecker_factored.is_some()
1785 || options.firth_bias_reduction
1786 || !options.nullspace_dims.is_empty()
1787 {
1788 return None;
1789 }
1790 let spec = &request.spec;
1791 if !spec.linear_terms.is_empty()
1792 || !spec.random_effect_terms.is_empty()
1793 || spec.smooth_terms.len() != 1
1794 {
1795 return None;
1796 }
1797 let term = &spec.smooth_terms[0];
1798 if !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None)
1799 || term.joint_null_rotation.is_some()
1800 {
1801 return None;
1802 }
1803 // Only scattered radial spatial smooths (Duchon / Matérn) over 2–3 axes.
1804 // The Duchon spectral power `p + s` and the Matérn order set the requested
1805 // Sobolev smoothness; both clamp into the Wendland native window.
1806 let (feature_cols, requested_s) = match &term.basis {
1807 gam_terms::smooth::SmoothBasisSpec::Duchon {
1808 feature_cols, spec, ..
1809 } => {
1810 // Pure-Duchon native order is `p + s` (kernel exponent 2(p+s)−d);
1811 // the multilevel frame targets the same continuum smoothness. `p`
1812 // is the polynomial nullspace degree, `s` the spectral power.
1813 let p = match spec.nullspace_order {
1814 gam_terms::basis::DuchonNullspaceOrder::Zero => 0.0,
1815 gam_terms::basis::DuchonNullspaceOrder::Linear => 1.0,
1816 gam_terms::basis::DuchonNullspaceOrder::Degree(k) => k as f64,
1817 };
1818 (feature_cols, spec.power + p)
1819 }
1820 gam_terms::smooth::SmoothBasisSpec::Matern {
1821 feature_cols, spec, ..
1822 } => {
1823 // Matérn smoothness ν sets native Sobolev order ν + d/2; the cascade
1824 // frame represents up to (d+3)/2, so the clamp below applies the
1825 // ceiling. (d is known just below from feature_cols.)
1826 let nu = spec.nu.half_integer_value();
1827 (feature_cols, nu + feature_cols.len() as f64 / 2.0)
1828 }
1829 _ => return None,
1830 };
1831 let d = feature_cols.len();
1832 if !(2..=3).contains(&d) {
1833 return None;
1834 }
1835 if request.offset.iter().any(|&v| v != 0.0) {
1836 return None;
1837 }
1838 if request.weights.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1839 return None;
1840 }
1841 let n = request.y.len();
1842 if n != request.data.nrows() || feature_cols.iter().any(|&c| c >= request.data.ncols()) {
1843 return None;
1844 }
1845 if !past_dense_kernel_cliff(n, d) {
1846 return None;
1847 }
1848 let coords: Vec<Vec<f64>> = feature_cols
1849 .iter()
1850 .map(|&c| request.data.column(c).iter().copied().collect())
1851 .collect();
1852 let y: Vec<f64> = request.y.iter().copied().collect();
1853 let w: Vec<f64> = request.weights.iter().copied().collect();
1854 if coords
1855 .iter()
1856 .any(|axis| axis.iter().any(|v| !v.is_finite()))
1857 || y.iter().any(|v| !v.is_finite())
1858 {
1859 return None;
1860 }
1861 let metric = vec![1.0_f64; d];
1862 let sobolev_s = cascade_sobolev_order(requested_s, d);
1863 Some(ResidualCascadeInputs {
1864 coords,
1865 y,
1866 w,
1867 metric,
1868 sobolev_s,
1869 })
1870}
1871
1872/// Formula-level library entry for the O(n log n) residual-cascade fast path
1873/// (issue #1032).
1874///
1875/// Materializes the formula exactly like [`fit_from_formula`], runs the
1876/// [`residual_cascade_fast_path`] detection, and — when it fires AND the
1877/// quasi-uniformity guard inside the cascade certifies the metric — returns the
1878/// certified [`ResidualCascadeFit`](gam_solve::residual_cascade::ResidualCascadeFit).
1879/// `Ok(None)` means EITHER the model is not the cascade-eligible shape OR the
1880/// quasi-uniformity guard rejected the metric; in both cases the caller falls
1881/// back to the dense [`fit_from_formula`] path (the cascade is a different
1882/// posterior, so the fallback is a genuine estimator choice, never a silent
1883/// swap). This keeps every persistence-bearing consumer on the dense fit until
1884/// the cascade payload schema lands.
1885pub fn fit_residual_cascade_from_formula(
1886 formula: &str,
1887 data: &Dataset,
1888 config: &FitConfig,
1889) -> Result<Option<gam_solve::residual_cascade::ResidualCascadeFit>, WorkflowError> {
1890 let mat = materialize(formula, data, config)?;
1891 let FitRequest::Standard(request) = mat.request else {
1892 return Ok(None);
1893 };
1894 let Some(inputs) = residual_cascade_fast_path(&request) else {
1895 return Ok(None);
1896 };
1897 let coord_refs: Vec<&[f64]> = inputs.coords.iter().map(Vec::as_slice).collect();
1898 match gam_solve::residual_cascade::fit_residual_cascade(
1899 &coord_refs,
1900 &inputs.y,
1901 &inputs.w,
1902 &inputs.metric,
1903 inputs.sobolev_s,
1904 ) {
1905 Ok(fit) => Ok(Some(fit)),
1906 // The quasi-uniformity guard (caveat 2) and any degenerate-design
1907 // signal both surface as a build/solve error; treat them as "not
1908 // cascade-eligible" so the caller falls back to the dense kernel path
1909 // rather than failing the fit outright.
1910 Err(_) => Ok(None),
1911 }
1912}
1913
1914/// Parse a formula, resolve it against a dataset, and produce a ready-to-fit `FitRequest`.
1915fn family_requests_transformation_normal(family: Option<&str>) -> bool {
1916 family
1917 .map(|name| name.trim().to_ascii_lowercase().replace('_', "-"))
1918 .as_deref()
1919 == Some("transformation-normal")
1920}
1921
1922pub fn materialize<'a>(
1923 formula: &str,
1924 data: &'a Dataset,
1925 config: &FitConfig,
1926) -> Result<MaterializedModel<'a>, WorkflowError> {
1927 let config = config
1928 .clone()
1929 .resolve()
1930 .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
1931 let config = &config;
1932 gam_gpu::configure_global_policy(config.gpu_policy);
1933 let parsed = parse_formula(formula)?;
1934 let col_map = data.column_map();
1935 let family_transformation_normal =
1936 family_requests_transformation_normal(config.family.as_deref());
1937 let transformation_normal_config;
1938 let effective_config = if family_transformation_normal && !config.transformation_normal {
1939 // `family="transformation-normal"` is a documented spelling of the CTN
1940 // model class, not a Gaussian identity likelihood. Normalize it into the
1941 // same orchestration flag used by `transformation_normal=true` before any
1942 // dispatch/validation branch can silently treat the request as standard.
1943 transformation_normal_config = FitConfig {
1944 transformation_normal: true,
1945 ..config.clone()
1946 };
1947 &transformation_normal_config
1948 } else {
1949 config
1950 };
1951
1952 if let Some((left_col, right_col, event_col)) = parse_surv_interval_response(&parsed.response)?
1953 {
1954 if effective_config.transformation_normal {
1955 return Err(WorkflowError::InvalidConfig {
1956 reason:
1957 "transformation_normal cannot be combined with a SurvInterval(...) response"
1958 .to_string(),
1959 });
1960 }
1961 // Interval censoring `T ∈ (L, R]` is only defined for the latent
1962 // hazard-window survival likelihood, whose kernel carries the
1963 // `log[S(L) − S(R)]` interval contribution. Route the left boundary `L`
1964 // through the standard exit channel and the right boundary `R` through
1965 // the dedicated interval-right channel; `event_col` distinguishes
1966 // bracketed (interval) rows from right-censored rows beyond the last
1967 // inspection (which carry an infinite/sentinel `R`).
1968 materialize_survival(
1969 &parsed,
1970 data,
1971 &col_map,
1972 effective_config,
1973 None,
1974 &left_col,
1975 &event_col,
1976 Some(&right_col),
1977 )
1978 } else if let Some((entry_col, exit_col, event_col)) = parse_surv_response(&parsed.response)? {
1979 if effective_config.transformation_normal {
1980 return Err(WorkflowError::InvalidConfig {
1981 reason: "transformation_normal cannot be combined with a Surv(...) response"
1982 .to_string(),
1983 });
1984 }
1985 // `materialize_*` now return `WorkflowError` directly so the typed
1986 // `ColumnNotFound` payload (and any future variant-typed leaf
1987 // errors) survive the dispatcher hop instead of being flattened
1988 // into `IntegrationFailed { reason: String }`.
1989 materialize_survival(
1990 &parsed,
1991 data,
1992 &col_map,
1993 effective_config,
1994 entry_col.as_deref(),
1995 &exit_col,
1996 &event_col,
1997 None,
1998 )
1999 } else {
2000 // Non-survival response: `timewiggle(...)` and `survmodel(...)` are
2001 // structurally meaningless (there is no baseline hazard / time axis to
2002 // wiggle and no survival likelihood to configure). They are parsed into
2003 // `ParsedFormula` but consumed *only* by `materialize_survival`; without
2004 // this guard every non-survival materializer below would silently drop
2005 // them, fitting an ordinary GAM while the user believes they requested a
2006 // time-varying / survival model (#371). Reject here — the single
2007 // chokepoint for all non-survival paths — mirroring the symmetric
2008 // auxiliary-formula rejection in `validate_auxiliary_formula_controls`.
2009 reject_survival_only_terms_for_nonsurvival(&parsed)?;
2010 // Symmetrically, the `config.survival_likelihood` *knob* selects a
2011 // survival likelihood mode read only by `materialize_survival`. On this
2012 // non-survival branch a non-default value (e.g. "weibull") would be
2013 // discarded and the fit would silently degrade to an ordinary GAM
2014 // (#1767). Reject it at the same chokepoint.
2015 reject_survival_likelihood_for_nonsurvival(effective_config)?;
2016 if effective_config.transformation_normal {
2017 // Issue #789A: a Bernoulli marginal-slope request with
2018 // `transformation_normal=true` used to dispatch as a CTN fit while
2019 // retaining marginal-slope controls, leaving the transformation path
2020 // in a non-advancing loop. CTN score calibration now uses the
2021 // explicit `ctn_stage1` recipe instead, so the legacy boolean is a
2022 // hard configuration error for marginal-slope requests.
2023 reject_marginal_slope_controls_for_transformation_normal(effective_config)?;
2024 if effective_config.noise_formula.is_some() {
2025 return Err(WorkflowError::InvalidConfig {
2026 reason: "transformation_normal cannot be combined with noise_formula"
2027 .to_string(),
2028 });
2029 }
2030 materialize_transformation_normal(&parsed, data, &col_map, effective_config)
2031 } else if requests_bernoulli_marginal_slope(effective_config) {
2032 materialize_bernoulli_marginal_slope(&parsed, data, &col_map, effective_config)
2033 } else if effective_config.noise_formula.is_some() {
2034 materialize_location_scale(&parsed, data, &col_map, effective_config)
2035 } else {
2036 materialize_standard(&parsed, data, &col_map, effective_config)
2037 }
2038 }
2039}
2040
2041#[cfg(test)]
2042mod sz_factor_smooth_recovery_tests {
2043 // `super::*` brings in `Dataset` (= gam_data::EncodedDataset), `FitConfig`,
2044 // `FitResult`, `StandardFitResult`, and `fit_from_formula`.
2045 use super::*;
2046
2047 const NOISE_SD: f64 = 0.20;
2048 const N: usize = 4000;
2049 const N_GROUPS: usize = 4;
2050
2051 /// A simple deterministic LCG so the dataset is reproducible without pulling
2052 /// an RNG dependency into the test.
2053 struct Lcg(u64);
2054 impl Lcg {
2055 fn next_u64(&mut self) -> u64 {
2056 // Numerical Recipes LCG constants.
2057 self.0 = self
2058 .0
2059 .wrapping_mul(6364136223846793005)
2060 .wrapping_add(1442695040888963407);
2061 self.0
2062 }
2063 /// Uniform in [0, 1).
2064 fn unif(&mut self) -> f64 {
2065 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
2066 }
2067 /// Standard normal via Box–Muller (one of the pair).
2068 fn normal(&mut self) -> f64 {
2069 let u1 = (self.unif()).max(1e-12);
2070 let u2 = self.unif();
2071 (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
2072 }
2073 }
2074
2075 /// Data drawn from EXACTLY the `sz` model class: a shared smooth `f0(x)` plus
2076 /// zero-sum per-group deviations `d_g(x)` (phase-shifted sinusoids whose
2077 /// cross-group mean is removed at every `x`), plus observation noise. This
2078 /// mirrors the (blocked) Python bug-hunt test `tests/bug_hunt_sz_factor_
2079 /// smooth_underfits_own_model_class_test.py`.
2080 ///
2081 /// Written to a CSV and loaded through the real `load_dataset_projected`
2082 /// inferer so the grouping column `g` (string levels) is encoded as a genuine
2083 /// categorical exactly as production does — hand-built `EncodedDataset`s do
2084 /// not carry the categorical level map the factor-smooth level resolver needs.
2085 fn sz_class_dataset() -> (Dataset, tempfile::TempDir) {
2086 let mut rng = Lcg(0x5326_2026_0628_1605);
2087 let phases: Vec<f64> = (0..N_GROUPS)
2088 .map(|k| 1.2 * k as f64 / (N_GROUPS as f64 - 1.0))
2089 .collect();
2090 let deviations = |xi: f64| -> Vec<f64> {
2091 let vals: Vec<f64> = phases
2092 .iter()
2093 .map(|p| 0.6 * (std::f64::consts::TAU * xi + std::f64::consts::TAU * p).sin())
2094 .collect();
2095 let mean = vals.iter().sum::<f64>() / vals.len() as f64;
2096 vals.iter().map(|v| v - mean).collect()
2097 };
2098
2099 let mut csv = String::from("y,x,g\n");
2100 for _ in 0..N {
2101 let x = rng.unif();
2102 // Use the HIGH bits (via `unif`) for the group draw — an LCG's low
2103 // bits have a tiny period and would collapse `% N_GROUPS` to a near
2104 // constant.
2105 let g = ((rng.unif() * N_GROUPS as f64) as usize).min(N_GROUPS - 1);
2106 let f0 = (std::f64::consts::TAU * x).sin();
2107 let mu = f0 + deviations(x)[g];
2108 let y = mu + NOISE_SD * rng.normal();
2109 csv.push_str(&format!("{y},{x},g{g}\n"));
2110 }
2111 let td = tempfile::tempdir().expect("tempdir");
2112 let path = td.path().join("sz_class.csv");
2113 std::fs::write(&path, csv).expect("write sz-class csv");
2114 // Force `g` into a categorical role exactly as the formula intends so the
2115 // factor-smooth level resolver sees all `N_GROUPS` distinct levels.
2116 let mut roles = std::collections::HashSet::new();
2117 roles.insert("g");
2118 let data = gam_data::load_dataset_projected_with_categorical_roles(
2119 &path,
2120 &["y".to_string(), "x".to_string(), "g".to_string()],
2121 &roles,
2122 )
2123 .expect("load sz-class dataset");
2124 (data, td)
2125 }
2126
2127 fn gaussian_config() -> FitConfig {
2128 FitConfig {
2129 family: Some("gaussian".to_string()),
2130 ..FitConfig::default()
2131 }
2132 }
2133
2134 /// In-sample residual sd of a fitted standard GAM: `sd(y − Xβ̂)`.
2135 fn residual_sd(fit: &StandardFitResult, data: &Dataset) -> f64 {
2136 let beta = &fit.fit.beta;
2137 let design = &fit.design.design;
2138 let n = design.nrows();
2139 assert_eq!(design.ncols(), beta.len(), "design/beta width mismatch");
2140 let mut fitted = vec![0.0f64; n];
2141 // `try_row_chunk` materializes contiguous row blocks of whatever design
2142 // storage the fit used (dense or block-lazy) — robust to the storage kind.
2143 const CHUNK: usize = 512;
2144 let mut start = 0usize;
2145 while start < n {
2146 let end = (start + CHUNK).min(n);
2147 let block = design
2148 .try_row_chunk(start..end)
2149 .expect("materialize design row chunk");
2150 for (r, row) in block.rows().into_iter().enumerate() {
2151 let mut acc = 0.0;
2152 for (c, &xv) in row.iter().enumerate() {
2153 acc += xv * beta[c];
2154 }
2155 fitted[start + r] = acc;
2156 }
2157 start = end;
2158 }
2159 let y = data.values.column(0);
2160 let resid: Vec<f64> = y
2161 .iter()
2162 .zip(fitted.iter())
2163 .map(|(&yi, &fi)| yi - fi)
2164 .collect();
2165 let mean = resid.iter().sum::<f64>() / resid.len() as f64;
2166 let var = resid.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / resid.len() as f64;
2167 var.sqrt()
2168 }
2169
2170 fn fit_standard(formula: &str, data: &Dataset) -> StandardFitResult {
2171 match fit_from_formula(formula, data, &gaussian_config())
2172 .unwrap_or_else(|e| panic!("fit `{formula}` failed: {e:?}"))
2173 {
2174 FitResult::Standard(r) => r,
2175 other => panic!(
2176 "expected Standard fit for `{formula}`, got a different variant: {}",
2177 std::any::type_name_of_val(&other)
2178 ),
2179 }
2180 }
2181
2182 /// #1605 (gold standard, end-to-end REML fit): the sum-to-zero factor smooth
2183 /// `s(x) + s(g, x, bs="sz")` must RECOVER data drawn from its own model class
2184 /// to the observation-noise floor, exactly as the strictly-more-general
2185 /// `s(x, g, bs="fs")` superset provably does.
2186 ///
2187 /// The recovery gap (`sz` resid ≈ 0.43 ≈ 2.1× the 0.20 floor while `fs`
2188 /// reaches the floor) was closed by THREE mgcv-faithful corrections, each
2189 /// necessary, that this end-to-end fit jointly exercises:
2190 /// 1. marginal basis (baef17e): cr → curvature-capable B-spline, so a
2191 /// deviation with non-zero boundary curvature is representable;
2192 /// 2. ownership/overlap residualization (b49bb5c): the `sz` deviation is
2193 /// sum-to-zero ACROSS the grouping factor, hence orthogonal to a
2194 /// factor-independent owner like the shared `s(x)`. Residualizing it
2195 /// against `s(x)`'s realized span (the #978 chart) collapsed every
2196 /// group's curve to a flat per-group contrast; skipping that ownership
2197 /// (same family as the #1276 factor-`by` level gate) restores the curve
2198 /// shape and stops REML railing the shared `s(x)` wiggliness λ;
2199 /// 3. null-space ridge (this change): the `sz` deviation blocks now carry
2200 /// the per-null-dimension ridge structure of `fs`, mapped into the
2201 /// zero-sum contrast space, so the {const, linear} null space is
2202 /// shrinkable per dimension (the #700/#712/#713 partial-pooling form)
2203 /// rather than left free — without breaking the zero-sum constraint.
2204 ///
2205 /// This is the gold-standard verification: it drives the real
2206 /// `fit_from_formula` REML λ-selection on data drawn from exactly the `sz`
2207 /// model class and asserts `sz` reaches the floor (and a `fs` control does
2208 /// too). It failed before the fixes and passes after.
2209 #[test]
2210 fn sz_factor_smooth_recovers_its_own_model_class_end_to_end() {
2211 let (data, _td) = sz_class_dataset();
2212
2213 // Control: bs="fs", a strict superset of the sz span, must reach the
2214 // noise floor — proves the data is well-posed and pins the floor.
2215 let fs_fit = fit_standard("y ~ s(x, g, bs='fs')", &data);
2216 let fs_resid = residual_sd(&fs_fit, &data);
2217 assert!(
2218 fs_resid < 1.2 * NOISE_SD,
2219 "control bs='fs' did not reach the noise floor: resid_sd={fs_resid:.4} \
2220 vs noise_sd={NOISE_SD} (data/floor sanity check)",
2221 );
2222
2223 // The documented sz idiom on data drawn from the sz model class.
2224 let sz_fit = fit_standard("y ~ s(x) + s(g, x, bs='sz')", &data);
2225 let sz_resid = residual_sd(&sz_fit, &data);
2226
2227 // A smoother whose span contains the truth, fit at large n, must explain
2228 // the systematic structure and leave ~only observation noise.
2229 assert!(
2230 sz_resid < 1.4 * NOISE_SD,
2231 "bs='sz' under-fits its own model class: resid_sd={sz_resid:.4} \
2232 ({:.2}x the noise floor {NOISE_SD}); the bs='fs' superset reached \
2233 {fs_resid:.4}. The sz fit leaves systematic signal in the residual.",
2234 sz_resid / NOISE_SD,
2235 );
2236
2237 // Comparative guard: sz must not be dramatically worse than the fs
2238 // superset that recovers the same data.
2239 assert!(
2240 sz_resid < 1.5 * fs_resid,
2241 "bs='sz' residual {sz_resid:.4} is {:.2}x the bs='fs' residual \
2242 {fs_resid:.4} on identical sz-class data",
2243 sz_resid / fs_resid,
2244 );
2245 }
2246}