Skip to main content

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.
175pub fn 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        smoothing_correction_method: None,
662        smoothing_correction_first_order: None,
663        smoothing_correction_method_first_order: None,
664        penalized_hessian: penalized_hessian_precision.clone(),
665        reparam_qs: None,
666        // Exact fit ⇒ residual variance is exactly zero.
667        dispersion: gam_solve::estimate::Dispersion::ZERO_ESTIMATE,
668        beta_covariance: Some(gam_problem::dispersion_cov::PhiScaledCovariance::wrap(
669            ndarray::Array2::<f64>::zeros((p, p)),
670        )),
671        beta_standard_errors: Some(Array1::<f64>::zeros(p)),
672        beta_covariance_corrected: None,
673        beta_standard_errors_corrected: None,
674        beta_covariance_frequentist: None,
675        coefficient_influence,
676        weighted_gram: Some(xtwx),
677        bias_correction_beta: None,
678        bias_correction_jacobian: None,
679    };
680    let geometry = Some(gam_solve::estimate::FitGeometry {
681        coefficient_gauge: gam_problem::gauge::Gauge::identity(&[beta.len()]),
682        penalized_hessian: penalized_hessian_precision,
683        working: Some(gam_solve::estimate::WorkingGeometry {
684            weights,
685            response: working_response,
686        }),
687    });
688    let fit = gam_solve::estimate::UnifiedFitResult::try_from_parts(
689        gam_solve::estimate::UnifiedFitResultParts {
690            blocks: vec![gam_solve::estimate::FittedBlock {
691                beta: beta.clone(),
692                role: gam_problem::BlockRole::Mean,
693                edf: edf_total,
694                lambdas: lambdas.clone(),
695            }],
696            log_lambdas,
697            lambdas,
698            likelihood_family: Some(request.family.clone()),
699            likelihood_scale: gam_problem::LikelihoodScaleMetadata::ProfiledGaussian,
700            log_likelihood_normalization: gam_problem::LogLikelihoodNormalization::UserProvided,
701            log_likelihood: 0.0,
702            deviance: 0.0,
703            reml_score: 0.0,
704            stable_penalty_term: 0.0,
705            penalized_objective: 0.0,
706            used_device: false,
707            outer_iterations: 0,
708            outer_converged: true,
709            outer_gradient_norm: Some(0.0),
710            standard_deviation: 0.0,
711            covariance_conditional: Some(ndarray::Array2::<f64>::zeros((p, p))),
712            covariance_corrected: None,
713            inference: Some(inference),
714            fitted_link: gam_solve::estimate::FittedLinkState::Standard(None),
715            geometry,
716            block_states: Vec::new(),
717            pirls_status: gam_solve::pirls::PirlsStatus::Converged,
718            max_abs_eta: intercept.abs(),
719            constraint_kkt: None,
720            artifacts: gam_solve::estimate::FitArtifacts {
721                pirls: None,
722                ..Default::default()
723            },
724            inner_cycles: 0,
725        },
726    )
727    .map_err(|err| WorkflowError::IntegrationFailed {
728        reason: format!("constant Gaussian shortcut produced invalid fit: {err}"),
729    })?;
730    let resolvedspec =
731        freeze_term_collection_from_design(&request.spec, &design).map_err(|err| {
732            WorkflowError::InvalidConfig {
733                reason: format!("constant Gaussian shortcut could not freeze design: {err}"),
734            }
735        })?;
736    Ok(StandardFitResult {
737        fit,
738        design,
739        resolvedspec,
740        adaptive_spatial_terms: adaptive_spatial_term_mask(&request.spec),
741        adaptive_spatial_center_counts: adaptive_spatial_center_counts(&request.spec),
742        adaptive_diagnostics: None,
743        kappa_timing: None,
744        saved_link_state: gam_solve::estimate::FittedLinkState::Standard(None),
745        wiggle_knots: None,
746        wiggle_degree: None,
747        wiggle_penalty_metadata: None,
748        wiggle_saved_warp_beta: None,
749        wiggle_saved_index_shift: None,
750    })
751}
752
753fn gaussian_response_is_constant(request: &StandardFitRequest<'_>) -> bool {
754    if !request.family.is_gaussian_identity() || request.y.is_empty() {
755        return false;
756    }
757    // An inhomogeneous anchor adds a data-dependent affine channel only when
758    // the term collection is realized. The shortcut predicate intentionally
759    // does not build that design, so it cannot prove `y - user_offset -
760    // anchor_offset` is constant. Keep such models on the ordinary exact fit
761    // path; treating the user offset alone as complete would mint a false
762    // zero-residual fit.
763    if gam_terms::smooth::term_collection_has_nonzero_anchor(&request.spec) {
764        return false;
765    }
766    // The intercept-only shortcut is exact — residual ≡ 0 — precisely when the
767    // OFFSET-ADJUSTED response `y − offset` is constant: then `η = offset +
768    // intercept = y` at every row. Testing the raw `y` alone would (a) miss an
769    // exact fit where a varying offset cancels a varying `y`, and (b) wrongly
770    // fire on a constant `y` under a varying offset, where the fit is NOT exact
771    // and the zero-dispersion inference the shortcut mints would be invalid.
772    if request.y.len() != request.offset.len() {
773        return false;
774    }
775    let mut adjusted = request.y.iter().zip(request.offset.iter());
776    let Some((&first_y, &first_offset)) = adjusted.next() else {
777        return false;
778    };
779    let first = first_y - first_offset;
780    if !first.is_finite() {
781        return false;
782    }
783    for (&yi, &oi) in adjusted {
784        let value = yi - oi;
785        if !value.is_finite() || value != first {
786            return false;
787        }
788    }
789    true
790}
791
792pub fn fit_from_formula(
793    formula: &str,
794    data: &Dataset,
795    config: &FitConfig,
796) -> Result<FitResult, WorkflowError> {
797    fit_from_formula_with_notes(formula, data, config).map(|outcome| outcome.result)
798}
799
800/// A fitted formula result together with advisories emitted by its one
801/// authoritative materialization pass.
802pub struct FormulaFitResult {
803    pub result: FitResult,
804    pub inference_notes: Vec<String>,
805}
806
807/// Resolve, materialize, and fit a formula without making front ends repeat any
808/// model construction. Unlike `fit_from_formula`, this service also returns the
809/// materializer's user-facing advisories for CLI/Python presentation.
810pub fn fit_from_formula_with_notes(
811    formula: &str,
812    data: &Dataset,
813    config: &FitConfig,
814) -> Result<FormulaFitResult, WorkflowError> {
815    let mut config = config
816        .clone()
817        .resolve()
818        .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
819    // Only this entry point owns the fit→measure→expand loop. Raw public
820    // `materialize()` callers receive the ordinary fully provisioned basis;
821    // activating the structural start without an owner would strand them in an
822    // under-resolved function space.
823    config.spatial_center_counts = Some(Vec::new());
824    let current = fit_from_formula_once_with_notes(formula, data, &config)?;
825    finish_adaptive_spatial_fit(formula, data, config, current)
826}
827
828/// Fit an already-materialized standard request, then continue through the
829/// canonical saturation-driven spatial-resolution loop.
830///
831/// Front ends that must inspect the request variant for payload dispatch use
832/// this seam so the dispatch materialization is also the first estimator
833/// materialization. Re-entering [`fit_from_formula_with_notes`] after matching a
834/// `Standard` request would build and discard one complete spatial basis before
835/// the real fit (#1689), duplicating construction work and peak memory on the
836/// Python path.
837pub fn fit_materialized_standard_with_notes(
838    formula: &str,
839    data: &Dataset,
840    config: &FitConfig,
841    request: StandardFitRequest<'_>,
842    inference_notes: Vec<String>,
843) -> Result<FormulaFitResult, WorkflowError> {
844    let mut config = config
845        .clone()
846        .resolve()
847        .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
848    config.spatial_center_counts = Some(Vec::new());
849    let current = fit_materialized_once_with_notes(MaterializedModel {
850        request: FitRequest::Standard(request),
851        inference_notes,
852    })?;
853    finish_adaptive_spatial_fit(formula, data, config, current)
854}
855
856fn finish_adaptive_spatial_fit(
857    formula: &str,
858    data: &Dataset,
859    mut config: FitConfig,
860    mut current: FormulaFitResult,
861) -> Result<FormulaFitResult, WorkflowError> {
862    loop {
863        let Some(current_standard) = standard_result(&current) else {
864            return Ok(current);
865        };
866        // Saturation is assessed at the same outer-optimization tolerance that
867        // certified this formula fit. `canonical_standard_fit_options` is the
868        // single policy source for that tolerance, so the expansion decision
869        // cannot drift between the CLI and library entry points.
870        let standard_options =
871            canonical_standard_fit_options(&config, StandardFitOptionsInputs::default());
872        // A rho-independent shrinkage floor prevents EDF from approaching the
873        // algebraic ceiling more closely than that floor even when lambda tends
874        // to zero. Include it in the resolution tolerance; otherwise the
875        // canonical 1e-6 floor would make a 1e-10 saturation predicate
876        // unreachable and the grow loop would remain dormant in production.
877        let resolution_tol = standard_options
878            .tol
879            .max(standard_options.penalty_shrinkage_floor.unwrap_or(0.0));
880        let candidates =
881            adaptive_spatial_candidates(current_standard, data.values.nrows(), resolution_tol)?;
882        if candidates.is_empty() {
883            return Ok(current);
884        }
885
886        // Grow one saturated term at a time in stable formula order. The next
887        // loop iteration re-fits and re-measures every term, so interactions
888        // between smooths are handled from a converged joint optimum instead
889        // of applying several decisions made against stale EDF evidence.
890        let term_count = candidates.term_count;
891        let candidate = candidates
892            .terms
893            .into_iter()
894            .next()
895            .expect("non-empty adaptive candidate set");
896        // Expansion is mandatory once a certified fit is saturated, so the
897        // old design/covariance can be released before constructing the larger
898        // one. Keeping both complete fits alive would make adaptive resolution
899        // itself an avoidable peak-memory multiplier.
900        drop(current);
901        let mut candidate_config = config.clone();
902        let center_counts = candidate_config
903            .spatial_center_counts
904            .get_or_insert_with(Vec::new);
905        if center_counts.len() < term_count {
906            center_counts.resize(term_count, None);
907        }
908        center_counts[candidate.term_index] = Some(candidate.proposed_centers);
909        let candidate_outcome = fit_from_formula_once_with_notes(formula, data, &candidate_config)
910            .map_err(|error| WorkflowError::SpatialUnderresolved {
911                term: candidate.term_name.clone(),
912                current_centers: candidate.current_centers,
913                attempted_centers: candidate.proposed_centers,
914                reason: error.to_string(),
915            })?;
916        if standard_result(&candidate_outcome).is_none() {
917            return Err(WorkflowError::SpatialUnderresolved {
918                term: candidate.term_name.clone(),
919                current_centers: candidate.current_centers,
920                attempted_centers: candidate.proposed_centers,
921                reason: "the certification refit changed estimator representation".to_string(),
922            });
923        }
924
925        // The current fit's EDF reached its realizable function-space ceiling;
926        // once the larger fit is certified it is the estimator state to resume
927        // from. Comparing raw REML/LAML values across different center charts
928        // is not a valid rejection gate (and a strict `<` accepts numerical
929        // noise), so resolution growth is controlled solely by the next
930        // converged fit's saturation evidence.
931        config = candidate_config;
932        current = candidate_outcome;
933    }
934}
935
936struct AdaptiveSpatialCandidates {
937    term_count: usize,
938    terms: Vec<AdaptiveSpatialCandidate>,
939}
940
941impl AdaptiveSpatialCandidates {
942    fn is_empty(&self) -> bool {
943        self.terms.is_empty()
944    }
945}
946
947struct AdaptiveSpatialCandidate {
948    term_index: usize,
949    term_name: String,
950    current_centers: usize,
951    proposed_centers: usize,
952}
953
954#[derive(Clone, Copy, Debug, PartialEq, Eq)]
955enum AdaptiveCenterDecision {
956    Certified,
957    Expand(usize),
958    Exhausted,
959}
960
961fn adaptive_center_decision(
962    current_centers: usize,
963    ceiling_centers: usize,
964    edf: f64,
965    realized_width: usize,
966    nullspace_dim: usize,
967    resolution_tol: f64,
968) -> AdaptiveCenterDecision {
969    if !gam_terms::basis::basis_is_saturated(edf, realized_width, nullspace_dim, resolution_tol) {
970        return AdaptiveCenterDecision::Certified;
971    }
972    match gam_terms::basis::expanded_num_centers(current_centers, ceiling_centers) {
973        Some(proposed) => AdaptiveCenterDecision::Expand(proposed),
974        None => AdaptiveCenterDecision::Exhausted,
975    }
976}
977
978fn standard_result(outcome: &FormulaFitResult) -> Option<&StandardFitResult> {
979    match &outcome.result {
980        FitResult::Standard(result) => Some(result),
981        _ => None,
982    }
983}
984
985fn adaptive_spatial_candidates(
986    result: &StandardFitResult,
987    n_rows: usize,
988    resolution_tol: f64,
989) -> Result<AdaptiveSpatialCandidates, WorkflowError> {
990    let term_count = result.resolvedspec.smooth_terms.len();
991    if result.adaptive_spatial_terms.len() != term_count
992        || result.adaptive_spatial_center_counts.len() != term_count
993        || result.design.smooth.terms.len() != term_count
994    {
995        return Err(WorkflowError::IntegrationFailed {
996            reason: format!(
997                "adaptive spatial provenance mismatch: resolved terms={term_count}, mask={}, \
998                 requested counts={}, realized terms={}",
999                result.adaptive_spatial_terms.len(),
1000                result.adaptive_spatial_center_counts.len(),
1001                result.design.smooth.terms.len(),
1002            ),
1003        });
1004    }
1005
1006    let smooth_offset = result
1007        .design
1008        .design
1009        .ncols()
1010        .saturating_sub(result.design.smooth.total_smooth_cols());
1011    let mut candidates = Vec::new();
1012    for term_index in 0..term_count {
1013        let realized = &result.design.smooth.terms[term_index];
1014        if result.adaptive_spatial_terms[term_index]
1015            && let Some(current_centers) = result.adaptive_spatial_center_counts[term_index]
1016        {
1017            let penalty_range = result
1018                .design
1019                .smooth_term_penalty_range(term_index)
1020                .map_err(|reason| WorkflowError::IntegrationFailed { reason })?
1021                .ok_or_else(|| WorkflowError::IntegrationFailed {
1022                    reason: format!(
1023                        "adaptive spatial term '{}' emitted no penalty block",
1024                        result.resolvedspec.smooth_terms[term_index].name,
1025                    ),
1026                })?;
1027            let spatial_dimension = result.resolvedspec.smooth_terms[term_index]
1028                .basis
1029                .structural_feature_cols()
1030                .len();
1031            if spatial_dimension == 0 {
1032                return Err(WorkflowError::IntegrationFailed {
1033                    reason: format!(
1034                        "adaptive spatial term '{}' has no structural feature columns",
1035                        result.resolvedspec.smooth_terms[term_index].name,
1036                    ),
1037                });
1038            }
1039            // Tiny samples can force the materializer's exact polynomial floor
1040            // above the generic `n / 4` conditioning ceiling. The realized
1041            // request is already the smallest admissible basis in that case, so
1042            // it is also the ceiling; never report a nonsensical attempted
1043            // center count below the basis that just converged.
1044            let ceiling_centers = gam_terms::basis::default_num_centers(n_rows, spatial_dimension)
1045                .max(current_centers);
1046            let global_range = (smooth_offset + realized.coeff_range.start)
1047                ..(smooth_offset + realized.coeff_range.end);
1048            let edf =
1049                result
1050                    .fit
1051                    .per_term_edf(global_range, penalty_range.start, penalty_range.len());
1052            let nullspace_dim = realized.wald_unpenalized_dim();
1053            match adaptive_center_decision(
1054                current_centers,
1055                ceiling_centers,
1056                edf,
1057                realized.coeff_range.len(),
1058                nullspace_dim,
1059                resolution_tol,
1060            ) {
1061                AdaptiveCenterDecision::Certified => {}
1062                AdaptiveCenterDecision::Expand(proposed_centers) => {
1063                    candidates.push(AdaptiveSpatialCandidate {
1064                        term_index,
1065                        term_name: result.resolvedspec.smooth_terms[term_index].name.clone(),
1066                        current_centers,
1067                        proposed_centers,
1068                    });
1069                }
1070                AdaptiveCenterDecision::Exhausted => {
1071                    return Err(WorkflowError::SpatialUnderresolved {
1072                        term: result.resolvedspec.smooth_terms[term_index].name.clone(),
1073                        current_centers,
1074                        attempted_centers: ceiling_centers,
1075                        reason: format!(
1076                            "term EDF {edf:.6} remains at its realized basis ceiling with all \
1077                             {ceiling_centers} validated default centers already requested"
1078                        ),
1079                    });
1080                }
1081            }
1082        }
1083    }
1084    Ok(AdaptiveSpatialCandidates {
1085        term_count,
1086        terms: candidates,
1087    })
1088}
1089
1090#[cfg(test)]
1091mod adaptive_spatial_resolution_tests {
1092    use super::{AdaptiveCenterDecision, adaptive_center_decision};
1093
1094    #[test]
1095    fn unsaturated_basis_is_certified_without_a_probe_refit() {
1096        assert_eq!(
1097            adaptive_center_decision(8, 100, 5.0, 10, 2, 1.0e-6),
1098            AdaptiveCenterDecision::Certified
1099        );
1100    }
1101
1102    #[test]
1103    fn saturated_basis_expands_geometrically_and_respects_validated_ceiling() {
1104        assert_eq!(
1105            adaptive_center_decision(8, 100, 10.0, 10, 2, 1.0e-6),
1106            AdaptiveCenterDecision::Expand(16)
1107        );
1108        assert_eq!(
1109            adaptive_center_decision(64, 100, 10.0, 10, 2, 1.0e-6),
1110            AdaptiveCenterDecision::Expand(100)
1111        );
1112    }
1113
1114    #[test]
1115    fn saturated_basis_at_validated_ceiling_is_typed_exhaustion() {
1116        assert_eq!(
1117            adaptive_center_decision(100, 100, 10.0, 10, 2, 1.0e-6),
1118            AdaptiveCenterDecision::Exhausted
1119        );
1120    }
1121}
1122
1123fn fit_from_formula_once_with_notes(
1124    formula: &str,
1125    data: &Dataset,
1126    config: &FitConfig,
1127) -> Result<FormulaFitResult, WorkflowError> {
1128    // Expectile regression (Newey–Powell asymmetric least squares): when the
1129    // family resolves to "expectile", the τ-expectile of `y | x` is the
1130    // minimizer of `Σ wᵢ(τ)·(yᵢ − μᵢ)²`, `wᵢ(τ) = τ` if `yᵢ > μᵢ` else `1 − τ`
1131    // — the smooth analogue of the τ-quantile. The minimizer is a Least
1132    // Asymmetrically Weighted Squares (LAWS) fixed point: iterate the penalized
1133    // Gaussian-identity GAM with `wᵢ(τ)` recomputed from the current `μᵢ` until
1134    // the residual-sign pattern stabilizes. REML λ-selection runs inside each
1135    // inner Gaussian solve, so every gam smooth/tensor/spatial basis becomes a
1136    // penalized expectile smooth with data-driven smoothing for free. This is a
1137    // genuine estimator route, not a silent swap: it fires only on the explicit
1138    // `family = "expectile"`. Every other family falls through unchanged.
1139    if let Some(result) = fit_expectile_if_requested(formula, data, &config)? {
1140        return Ok(FormulaFitResult {
1141            result: FitResult::Standard(result),
1142            inference_notes: Vec::new(),
1143        });
1144    }
1145    let mat = materialize(formula, data, &config)?;
1146    fit_materialized_once_with_notes(mat)
1147}
1148
1149fn fit_materialized_once_with_notes(
1150    mat: MaterializedModel<'_>,
1151) -> Result<FormulaFitResult, WorkflowError> {
1152    let inference_notes = mat.inference_notes;
1153    // Exact O(n) spline-scan fast path (#1030): when the materialized request
1154    // is the single 1-D Gaussian-identity penalized-smooth shape the
1155    // state-space scan solves exactly, route through it and return the
1156    // scan-bearing model directly — the same penalized posterior at O(n) per
1157    // λ-trial instead of the dense design/Gram route. Detection is structural
1158    // and conservative (see `spline_scan_fast_path`); every other shape falls
1159    // through to the dense `fit_model` path unchanged. Mirrors the CLI
1160    // (main.rs run_fit) and FFI consumers, which build the persistence payload
1161    // from this same `SplineScanFit`.
1162    if let FitRequest::Standard(request) = &mat.request {
1163        if gaussian_response_is_constant(request) {
1164            return constant_gaussian_standard_fit(request).map(|result| FormulaFitResult {
1165                result: FitResult::Standard(result),
1166                inference_notes,
1167            });
1168        }
1169        if let Some(inputs) = spline_scan_fast_path(request) {
1170            let scan = gam_solve::spline_scan::fit_spline_scan(
1171                &inputs.x,
1172                &inputs.y,
1173                &inputs.w,
1174                inputs.order,
1175            )
1176            .map_err(|reason| WorkflowError::IntegrationFailed { reason })?;
1177            return Ok(FormulaFitResult {
1178                result: FitResult::SplineScan(scan),
1179                inference_notes,
1180            });
1181        }
1182        // O(n log n) multiresolution residual-cascade fast path (#1032): a
1183        // scattered low-d Gaussian-identity Duchon/Matérn smooth past the
1184        // dense-kernel cliff. UNLIKE the scan, the cascade is a DIFFERENT
1185        // posterior from the dense radial term, so it only ever fires as an
1186        // explicit alternative estimator on the exact structural signature
1187        // (`residual_cascade_fast_path`) AND when the in-cascade quasi-uniformity
1188        // guard certifies the metric — a rejected metric or any ineligible shape
1189        // falls through to the dense `fit_model` path (a genuine estimator
1190        // choice, never a silent swap). The save paths build the persistence
1191        // payload from this `ResidualCascadeFit`'s `to_state` snapshot.
1192        if let Some(inputs) = residual_cascade_fast_path(request) {
1193            let coord_refs: Vec<&[f64]> = inputs.coords.iter().map(Vec::as_slice).collect();
1194            if let Ok(fit) = gam_solve::residual_cascade::fit_residual_cascade(
1195                &coord_refs,
1196                &inputs.y,
1197                &inputs.w,
1198                &inputs.metric,
1199                inputs.sobolev_s,
1200            ) {
1201                return Ok(FormulaFitResult {
1202                    result: FitResult::ResidualCascade(fit),
1203                    inference_notes,
1204                });
1205            }
1206            // The quasi-uniformity guard (caveat 2) or any degenerate-design
1207            // signal surfaces as a build/solve error; fall through to the dense
1208            // kernel path rather than failing the fit outright.
1209        }
1210    }
1211    // `fit_model` already returns `WorkflowError` end-to-end; propagate it
1212    // directly instead of stringifying then re-wrapping.
1213    fit_model(mat.request).map(|result| FormulaFitResult {
1214        result,
1215        inference_notes,
1216    })
1217}
1218
1219/// THE single dispatch seam for the expectile (Newey–Powell LAWS) family.
1220///
1221/// Returns `Ok(Some(result))` with the converged τ-expectile as an ordinary
1222/// [`StandardFitResult`] when `config.family` selects the expectile family
1223/// (`"expectile"` or `"expectile(τ)"`, optionally pinned by
1224/// [`FitConfig::expectile_tau`]), `Ok(None)` for every other family — in which
1225/// case the caller runs its normal materialize/`fit_model` path — and `Err` on a
1226/// malformed expectile request or an inner-fit failure.
1227///
1228/// Every public entry point that resolves a family routes through this seam
1229/// *before* materializing: the in-process [`fit_from_formula`], the Python FFI
1230/// (`gam-pyffi`), and the `gam` CLI. Centralizing the dispatch here is what makes
1231/// the estimator reachable from every interface instead of only the library
1232/// call — and what prevents the class of bug where a newly-added outer estimator
1233/// is wired into one entry point and silently bypassed by the others (#1777).
1234/// The returned [`StandardFitResult`] carries the full design / resolved spec /
1235/// fit, so each caller builds its persistence payload from it exactly as it does
1236/// for any other standard fit.
1237pub fn fit_expectile_if_requested(
1238    formula: &str,
1239    data: &Dataset,
1240    config: &FitConfig,
1241) -> Result<Option<StandardFitResult>, WorkflowError> {
1242    match expectile_tau_for_config(config)? {
1243        Some(tau) => Ok(Some(fit_expectile_laws(formula, data, config, tau)?)),
1244        None => Ok(None),
1245    }
1246}
1247
1248/// Least Asymmetrically Weighted Squares (LAWS) driver for expectile GAMs.
1249///
1250/// The τ-expectile surface minimizes `Σ wᵢ(τ)·(yᵢ − μᵢ)²` with the residual-
1251/// sign asymmetric weight `wᵢ(τ)`. The asymmetric loss is convex and
1252/// continuously differentiable: each side of zero is a positive quadratic and
1253/// both one-sided derivatives agree at zero. LAWS solves the penalized WLS
1254/// problem with weights frozen at the current sign pattern, then recomputes the
1255/// pattern. A returned estimator must satisfy the KKT residual of the original
1256/// asymmetric objective; a repeated sign state or an iteration cap is only
1257/// termination evidence, never an estimator-selection rule.
1258///
1259/// Each inner solve is the FULL standard Gaussian-identity GAM: any basis,
1260/// tensor, spatial smooth, by-variable, random effect, plus REML λ-selection on
1261/// the current asymmetric weights. The returned fit is an ordinary
1262/// [`FitResult::Standard`] whose coefficients ARE the penalized τ-expectile —
1263/// every downstream consumer (predict, posterior bands, persistence) works
1264/// unchanged. The reported scale is the asymmetric working variance, so
1265/// expectile standard errors are the sandwich-free Gaussian-form bands of the
1266/// converged weighted problem (a deliberate first-rung choice; see #1100).
1267fn fit_expectile_laws(
1268    formula: &str,
1269    data: &Dataset,
1270    config: &FitConfig,
1271    tau: f64,
1272) -> Result<StandardFitResult, WorkflowError> {
1273    if config.frailty.is_active() {
1274        return Err(WorkflowError::InvalidConfig {
1275            reason: "expectile regression does not support frailty; use a survival/frailty-aware family instead"
1276                .to_string(),
1277        });
1278    }
1279
1280    // Inner fits are ordinary Gaussian-identity GAMs; the τ asymmetry lives
1281    // entirely in the per-iteration prior weights this driver injects.
1282    let gaussian_config = FitConfig {
1283        family: Some("gaussian".to_string()),
1284        link: Some("identity".to_string()),
1285        expectile_tau: None,
1286        // The inner Gaussian-identity design carries no frailty.
1287        frailty: FrailtySpec::None,
1288        ..config.clone()
1289    };
1290
1291    // Materialize once to capture the fixed training design, response, offset,
1292    // and base prior weights. The design (basis, penalties, identifiability
1293    // transforms) does not depend on the prior weights, so it is reused across
1294    // every LAWS iteration; only the weight vector and the resulting β change.
1295    let base_mat = materialize(formula, data, &gaussian_config)?;
1296    let FitRequest::Standard(base_request) = base_mat.request else {
1297        return Err(WorkflowError::InvalidConfig {
1298            reason: "expectile regression is only defined for standard (non-survival, \
1299                     non-location-scale) responses"
1300                .to_string(),
1301        });
1302    };
1303    let StandardFitRequest {
1304        data: design_data,
1305        y,
1306        weights: base_weights,
1307        offset,
1308        spec,
1309        family: materialized_family,
1310        estimate_tweedie_p: _,
1311        options,
1312        kappa_options,
1313        wiggle,
1314        coefficient_groups,
1315        penalty_block_gamma_priors,
1316        latent_coord,
1317    } = base_request;
1318    // The materializer already resolved the inner family to Gaussian-identity
1319    // from `gaussian_config`; assert it so a future materializer change that
1320    // silently picked a different family for `"gaussian"` is caught here rather
1321    // than producing a non-expectile fit.
1322    if !materialized_family.is_gaussian_identity() {
1323        return Err(WorkflowError::InvalidConfig {
1324            reason: format!(
1325                "expectile LAWS requires a Gaussian-identity inner family; materializer produced {}",
1326                materialized_family.name()
1327            ),
1328        });
1329    }
1330
1331    if wiggle.is_some() || latent_coord.is_some() {
1332        return Err(WorkflowError::InvalidConfig {
1333            reason: "expectile regression does not support flexible-link wiggle or latent \
1334                     coordinates"
1335                .to_string(),
1336        });
1337    }
1338
1339    let n = y.len();
1340    let gaussian_family = LikelihoodSpec::gaussian_identity();
1341    // Cold start: unweighted base weights ⇒ the first inner fit is the OLS
1342    // mean GAM, the natural warm start for any τ.
1343    let mut weights = Arc::clone(&base_weights);
1344    // The LAWS map is deterministic given a sign pattern. Brent detection
1345    // proves recurrence using one O(n) sign checkpoint; no iteration-count
1346    // multiple of the training data is retained.
1347    let mut sign_cycle = ExpectileSignCycle::default();
1348    // Evidence for the typed exhaustion error: (dimensionless KKT residual,
1349    // configured KKT bound) of the final uncertified iterate.
1350    let mut last_kkt = (f64::NAN, f64::NAN);
1351    let mut last_rho_checkpoint = Vec::new();
1352
1353    // Reuse the request's explicit outer-work budget; LAWS does not introduce a
1354    // second hidden iteration knob. The budget is a safety guard only: hitting
1355    // it without the certificate below is typed nonconvergence (SPEC rule 20).
1356    let max_laws_iters = options.max_iter;
1357    if max_laws_iters == 0 || !(options.tol.is_finite() && options.tol > 0.0) {
1358        return Err(WorkflowError::InvalidConfig {
1359            reason: format!(
1360                "expectile LAWS requires a positive iteration budget and finite positive KKT \
1361                 tolerance; got max_iter={max_laws_iters}, tol={}",
1362                options.tol,
1363            ),
1364        });
1365    }
1366
1367    for iteration in 1..=max_laws_iters {
1368        let request = StandardFitRequest {
1369            data: design_data.clone(),
1370            y: Arc::clone(&y),
1371            weights: Arc::clone(&weights),
1372            offset: Arc::clone(&offset),
1373            spec: spec.clone(),
1374            family: gaussian_family.clone(),
1375            // Expectile LAWS fits a Gaussian-identity inner family; no Tweedie
1376            // power to estimate (#2026).
1377            estimate_tweedie_p: false,
1378            options: options.clone(),
1379            kappa_options: kappa_options.clone(),
1380            wiggle: None,
1381            coefficient_groups: coefficient_groups.clone(),
1382            penalty_block_gamma_priors: penalty_block_gamma_priors.clone(),
1383            latent_coord: None,
1384        };
1385        let result = fit_standard_model(request)
1386            .map_err(|reason| WorkflowError::IntegrationFailed { reason })?;
1387        // Training-scale fitted mean μ = X·β (identity link, zero-checked
1388        // offset folded by the design path). The design columns match the
1389        // combined coefficient vector exactly (the same contract `predict`
1390        // and the safety tests rely on).
1391        let mu = result
1392            .design
1393            .apply(result.fit.beta.view())
1394            .map_err(|error| WorkflowError::IntegrationFailed {
1395                reason: format!("expectile LAWS could not evaluate fitted design: {error}"),
1396            })?;
1397        if mu.len() != n {
1398            return Err(WorkflowError::IntegrationFailed {
1399                reason: format!(
1400                    "expectile LAWS: fitted mean length {} disagrees with response length {n}",
1401                    mu.len()
1402                ),
1403            });
1404        }
1405        // `design.apply` already folds the design's fixed affine channel
1406        // (non-zero endpoint anchor, #2297) into `X·β`, so only the user offset
1407        // is added; adding `affine_offset` again would double-count the pin and
1408        // bias every expectile working weight for an anchored smooth.
1409        let mut mu_off = mu;
1410        mu_off += offset.as_ref();
1411
1412        let sign: Vec<bool> = (0..n).map(|i| y[i] > mu_off[i]).collect();
1413        let next_weights = expectile_row_weights(y.view(), mu_off.view(), base_weights.view(), tau);
1414
1415        // KKT certificate for the CONVEX penalized asymmetric-least-squares
1416        // problem at the fit's own selected λ. The asymmetric loss
1417        // ρ_τ(r) = |τ − 1[r<0]|·r² is convex and continuously differentiable
1418        // (its derivative vanishes at r = 0 from both sides), so the true
1419        // penalized objective J(β) = Σ wᵢ(τ)·rᵢ² + βᵀS_λβ has a checkable
1420        // gradient at the returned β. The inner solve certifies stationarity
1421        // of the FROZEN-weight problem, Xᵀ(w_used ∘ r) = S_λ β, hence
1422        //   ∇J(β)/2 = Xᵀ((w_used − w_new) ∘ r),
1423        // supported exactly on rows whose residual sign disagrees with the
1424        // pattern the weights were frozen at. The production audit normalizes
1425        // each coefficient defect by its Cauchy–Schwarz score scale, making the
1426        // result invariant to column, response, and prior-weight scale while
1427        // remaining defined when an unpenalized frozen score cancels to zero.
1428        let residual = y.as_ref() - &mu_off;
1429        let kkt = expectile_kkt_residual(
1430            &result.design.design,
1431            residual.view(),
1432            weights.view(),
1433            next_weights.view(),
1434        )
1435        .map_err(|reason| WorkflowError::IntegrationFailed {
1436            reason: format!(
1437                "expectile LAWS KKT audit failed at iteration {iteration} \
1438                 (rho_checkpoint={:?}): {reason}",
1439                result.fit.log_lambdas.to_vec(),
1440            ),
1441        })?;
1442        let kkt_bound = options.tol;
1443        if kkt <= kkt_bound {
1444            return Ok(result);
1445        }
1446        last_kkt = (kkt, kkt_bound);
1447        last_rho_checkpoint = result.fit.log_lambdas.to_vec();
1448        if let Some(cycle_length) = sign_cycle.observe(&sign) {
1449            return Err(WorkflowError::IntegrationFailed {
1450                reason: format!(
1451                    "expectile LAWS entered a deterministic sign-pattern cycle without \
1452                     reaching the KKT fixed point of the convex asymmetric least-squares \
1453                     problem (tau={tau}, iterations={iteration}, cycle_length={cycle_length}, \
1454                     KKT residual={:.3e} vs scaled tolerance {:.3e}, \
1455                     rho_checkpoint={:?}); non-convergence is a typed error, never a \
1456                     best-effort fit",
1457                    kkt,
1458                    kkt_bound,
1459                    result.fit.log_lambdas.to_vec(),
1460                ),
1461            });
1462        }
1463        weights = Arc::new(next_weights);
1464    }
1465
1466    Err(WorkflowError::IntegrationFailed {
1467        reason: format!(
1468            "expectile LAWS exhausted its {max_laws_iters}-iteration safety cap without a \
1469             KKT certificate for the convex asymmetric least-squares problem (tau={tau}, \
1470             final KKT residual={:.3e} vs scaled tolerance {:.3e}, \
1471             rho_checkpoint={last_rho_checkpoint:?}); the iteration cap \
1472             never selects the estimator — non-convergence is a typed error",
1473            last_kkt.0, last_kkt.1,
1474        ),
1475    })
1476}
1477/// Detection seam for the exact O(n) cubic-smoothing-spline fast path.
1478///
1479/// This is the EARLIEST point in the standard workflow where a materialized
1480/// fit request carries everything needed to prove the model is exactly the
1481/// problem the scan solves: a Gaussian likelihood with identity link over
1482/// `intercept + one 1-D cubic-class penalized smooth` — i.e. the penalized
1483/// least-squares problem `min Σ w_i (y_i − f(x_i))² + λ∫f″²` with an
1484/// unpenalized `{1, x}` null space. The Kalman/RTS scan computes that
1485/// posterior (mean, pointwise variance, exact diffuse REML for λ) in O(n) per
1486/// λ-trial instead of the dense design/Gram O(n·k²) + O(k³) route.
1487///
1488/// Returns `Some` only when ALL of the following hold; everything else falls
1489/// through to the dense path:
1490/// - family is Gaussian + identity link;
1491/// - no link wiggle, no latent coordinates, no coefficient groups, no penalty
1492///   hyperpriors, no linear/box constraints, no Firth, no adaptive
1493///   regularization, no Kronecker systems, no externally injected null-space
1494///   dims;
1495/// - the term collection is exactly one smooth term — no linear terms, no
1496///   random effects, no by-variables / factor interactions;
1497/// - that smooth is a plain 1-D B-spline whose penalty order is compatible
1498///   with the exact scan and whose null space is unshrunk
1499///   (`double_penalty=false`). `double_penalty` (mgcv `select = TRUE`) on a free
1500///   B-spline emits a second REML coordinate — the Marra & Wood (2011) null-space
1501///   shrinkage block — that the scan cannot represent (its polynomial null space
1502///   is an improper diffuse prior it can never shrink); routing such a fit
1503///   through the scan would silently drop that penalty and select λ from the
1504///   bending penalty alone, which is exactly the EDF inflation #1266 reports.
1505///   Those fits fall through to the dense two-rho path, which owns both penalties
1506///   jointly. Natural cubic regression (`bs="cr"`/`"cs"`) terms also fall
1507///   through: their knot-value parameterization is a finite-rank regression
1508///   spline, not the scan's full smoothing-spline state-space posterior;
1509/// - the offset is identically zero and every weight is finite and positive;
1510/// - at least 3 distinct finite abscissae (the scan's diffuse rank plus one).
1511///
1512/// λ-mapping note: the scan's penalty is exactly `λ∫f″²` (state-space
1513/// `q = 1/λ` at unit σ²). The dense 1-D B-spline path penalizes the same
1514/// cubic class through a reduced-rank discrete-difference Gram whose
1515/// normalization differs by a basis-dependent constant, so a λ selected by
1516/// one parameterization does not transfer numerically to the other. The scan
1517/// therefore always re-selects λ by its own exact diffuse REML criterion
1518/// (the optimizer of the same restricted likelihood, expressed in the scan's
1519/// parameterization); user-pinned smoothing parameters are not representable
1520/// at this seam (the formula DSL exposes none for this term class), so no
1521/// pinned-λ mapping arises.
1522///
1523/// Identifiability transforms on the smooth (centering / linear-trend
1524/// removal / orthogonality-to-intercept) are accepted as eligible: they only
1525/// re-coordinate the unpenalized null space against the implicit intercept
1526/// and do not change the fitted posterior of `E[y|x]`, which is what the
1527/// scan returns directly.
1528pub fn spline_scan_fast_path(request: &StandardFitRequest<'_>) -> Option<SplineScanInputs> {
1529    if !request.family.is_gaussian_identity() {
1530        return None;
1531    }
1532    if request.wiggle.is_some()
1533        || request.latent_coord.is_some()
1534        || !request.coefficient_groups.is_empty()
1535        || !request.penalty_block_gamma_priors.is_empty()
1536    {
1537        return None;
1538    }
1539    let options = &request.options;
1540    if options.latent_cloglog.is_some()
1541        || options.mixture_link.is_some()
1542        || options.sas_link.is_some()
1543        || options.linear_constraints.is_some()
1544        || options.adaptive_regularization.is_some()
1545        || options.kronecker_penalty_system.is_some()
1546        || options.kronecker_factored.is_some()
1547        || options.firth_bias_reduction
1548        || !options.nullspace_dims.is_empty()
1549    {
1550        return None;
1551    }
1552    let spec = &request.spec;
1553    if !spec.linear_terms.is_empty()
1554        || !spec.random_effect_terms.is_empty()
1555        || spec.smooth_terms.len() != 1
1556    {
1557        return None;
1558    }
1559    let term = &spec.smooth_terms[0];
1560    if !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None)
1561        || term.joint_null_rotation.is_some()
1562    {
1563        return None;
1564    }
1565    let gam_terms::smooth::SmoothBasisSpec::BSpline1D {
1566        feature_col,
1567        spec: bspec,
1568    } = &term.basis
1569    else {
1570        return None;
1571    };
1572    // Smoothing-spline order m = penalty_order ∈ {1, 2, 3}. The exact scan
1573    // integrates the order-m integrated-Wiener prior whose natural spline has
1574    // degree 2m−1 (m=1 → linear, m=2 → cubic, m=3 → quintic), so require that
1575    // degree to match user intent. The de Jong exact diffuse leading-block
1576    // smoother (#1044) handles the m−1 partially-diffuse leading nodes for all
1577    // m ≤ MAX_ORDER; m > MAX_ORDER falls through to the dense path.
1578    let order = bspec.penalty_order;
1579    // Double-penalty (mgcv `select = TRUE`) is NOT representable by the scan and
1580    // must fall through to the dense two-rho path (#1266). On a free B-spline the
1581    // double penalty emits a *second* REML coordinate — the Marra & Wood (2011)
1582    // null-space shrinkage block `Z Zᵀ` (see `bspline_penalty_candidates`) —
1583    // whose entire purpose is to let REML shrink the unpenalized `{1, x, …}`
1584    // polynomial null space toward `EDF → 0` for an unsupported term. The scan,
1585    // by construction, carries that null space as an *improper diffuse* prior it
1586    // can never shrink (its EDF floor is the null-space dimension `order`), so
1587    // routing a `double_penalty` fit through it silently DROPS the second penalty
1588    // and selects λ from the single bending penalty alone. The scan's own exact
1589    // diffuse REML then genuinely prefers a mildly wiggly fit at finite λ for
1590    // some noise realizations (an interior REML optimum, EDF ≈ 3–4), which is the
1591    // EDF inflation #1266 reports. The dense path owns both penalties jointly and
1592    // its outer REML, seeded into the over-smoothing basin, drives the null space
1593    // out (EDF → null-space dim) when the data are truly polynomial. Excluding
1594    // `double_penalty` here keeps such a fit on the dense path; single-penalty
1595    // and boundary-conditioned single-penalty B-splines keep the exact O(n) scan.
1596    if !(1..=3).contains(&order)
1597        || bspec.degree != 2 * order - 1
1598        || bspec.double_penalty
1599        || !bspec.boundary_conditions.is_free()
1600        || !matches!(bspec.boundary, gam_terms::basis::OneDimensionalBoundary::Open)
1601        || matches!(
1602            bspec.knotspec,
1603            gam_terms::basis::BSplineKnotSpec::PeriodicUniform { .. }
1604                | gam_terms::basis::BSplineKnotSpec::NaturalCubicRegression { .. }
1605        )
1606        // mgcv `bs="cr"`/`"cs"` materialise a `NaturalCubicRegression` value-knot
1607        // spec: a Lancaster–Salkauskas cubic-regression basis whose columns
1608        // index `f(x*_i)` at `k` quantile knots — a genuinely DIFFERENT finite
1609        // basis (and hence a different penalized posterior) from the free
1610        // integrated-Wiener natural spline the exact scan solves on the raw data
1611        // points. The scan builds its own knots from `x` and ignores this spec,
1612        // so routing a cr fit through it would silently solve the wrong model and
1613        // (per #1844) return a non-`Standard` `SplineScan` result the predict-time
1614        // design replay cannot reconstruct. Keep cr/cs on the dense path.
1615        || matches!(
1616            bspec.knotspec,
1617            gam_terms::basis::BSplineKnotSpec::NaturalCubicRegression { .. }
1618        )
1619    {
1620        return None;
1621    }
1622    if request.offset.iter().any(|&v| v != 0.0) {
1623        return None;
1624    }
1625    if request.weights.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1626        return None;
1627    }
1628    if *feature_col >= request.data.ncols() || request.y.len() != request.data.nrows() {
1629        return None;
1630    }
1631    let x: Vec<f64> = request.data.column(*feature_col).iter().copied().collect();
1632    let y: Vec<f64> = request.y.iter().copied().collect();
1633    let w: Vec<f64> = request.weights.iter().copied().collect();
1634    if x.iter().any(|v| !v.is_finite()) || y.iter().any(|v| !v.is_finite()) {
1635        return None;
1636    }
1637    // The diffuse polynomial null space consumes `order` innovations; the scan
1638    // needs at least one proper innovation beyond them to profile σ².
1639    let mut sorted = x.clone();
1640    sorted.sort_by(f64::total_cmp);
1641    sorted.dedup();
1642    if sorted.len() < order + 1 {
1643        return None;
1644    }
1645    Some(SplineScanInputs { x, y, w, order })
1646}
1647
1648/// Formula-level direct entry for the exact O(n) smoothing-spline scan.
1649///
1650/// Materializes the formula exactly like [`fit_from_formula`], then runs the
1651/// [`spline_scan_fast_path`] detection on the resulting standard request.
1652/// This public entry point is for library callers that specifically need the
1653/// specialized [`gam_solve::spline_scan::SplineScanFit`] rather than the
1654/// [`FitResult::SplineScan`] sum-type returned by the canonical workflow. When
1655/// detection fires the fit is routed through
1656/// [`gam_solve::spline_scan::fit_spline_scan`] — the exact diffuse
1657/// REML Kalman/RTS scan — and the full in-memory posterior
1658/// ([`gam_solve::spline_scan::SplineScanFit`]: knots, smoothed
1659/// states, pointwise variances, lag-one gains, σ², log λ, exact EDF, and an
1660/// exact `predict`) is returned. `Ok(None)` means the model is not the
1661/// scan-eligible shape; the direct caller then chooses another estimator.
1662/// Persistence-bearing workflows do not call this probe: [`fit_from_formula`]
1663/// returns [`FitResult::SplineScan`], and the shared
1664/// [`crate::inference::model_payload_builders::assemble_spline_scan_payload`]
1665/// authority writes the exact scan state for both CLI and FFI consumers.
1666pub fn fit_spline_scan_from_formula(
1667    formula: &str,
1668    data: &Dataset,
1669    config: &FitConfig,
1670) -> Result<Option<gam_solve::spline_scan::SplineScanFit>, WorkflowError> {
1671    let mat = materialize(formula, data, config)?;
1672    let FitRequest::Standard(request) = mat.request else {
1673        return Ok(None);
1674    };
1675    let Some(inputs) = spline_scan_fast_path(&request) else {
1676        return Ok(None);
1677    };
1678    gam_solve::spline_scan::fit_spline_scan(&inputs.x, &inputs.y, &inputs.w, inputs.order)
1679        .map(Some)
1680        .map_err(|reason| WorkflowError::IntegrationFailed { reason })
1681}
1682
1683/// #1464 diagnostic entry point: evaluate the EXACT production fixed-κ
1684/// profiled-REML criterion (`fixed_kappa_profiled_reml_score`, the same one the
1685/// joint-fit κ-sign scan uses) at a list of pinned κ values for the first
1686/// constant-curvature term of `formula`, materialised from `data`/`config`
1687/// exactly like [`fit_from_formula`]. Returns `(κ, V_p(κ))` pairs.
1688///
1689/// This settles solver-vs-criterion for the railing bug: if `V_p(+κ) < V_p(−κ)`
1690/// for a genuinely HYPERBOLIC dataset, the criterion itself prefers the collapsed
1691/// +κ corner — the bug is in the constant-curvature REML/Occam term, not the
1692/// optimiser. If `V_p(−κ) < V_p(+κ)` yet the full fit still returns +κ, the bug
1693/// is in the solver/readback. The profiled fit pins κ and profiles only ρ
1694/// (κ-optimisation disabled), so each returned score is the negative-log-evidence
1695/// the outer loop minimises.
1696pub fn constant_curvature_profiled_reml_scores(
1697    formula: &str,
1698    data: &Dataset,
1699    config: &FitConfig,
1700    kappas: &[f64],
1701) -> Result<Vec<(f64, f64)>, WorkflowError> {
1702    let mat = materialize(formula, data, config)?;
1703    let FitRequest::Standard(request) = mat.request else {
1704        return Err(WorkflowError::IntegrationFailed {
1705            reason: "constant_curvature_profiled_reml_scores: formula did not materialise to a \
1706                     standard fit request"
1707                .to_string(),
1708        });
1709    };
1710    let term_idx =
1711        *crate::fit_orchestration::drivers::constant_curvature_term_indices(&request.spec)
1712            .first()
1713            .ok_or_else(|| WorkflowError::IntegrationFailed {
1714                reason:
1715                    "constant_curvature_profiled_reml_scores: formula has no constant-curvature \
1716                     curv() term"
1717                        .to_string(),
1718            })?;
1719    let mut out = Vec::with_capacity(kappas.len());
1720    for &kappa in kappas {
1721        let score = crate::fit_orchestration::drivers::fixed_kappa_profiled_reml_score(
1722            request.data.view(),
1723            request.y.view(),
1724            request.weights.view(),
1725            request.offset.view(),
1726            &request.spec,
1727            term_idx,
1728            kappa,
1729            request.family.clone(),
1730            &request.options,
1731        )
1732        .map_err(|e| WorkflowError::IntegrationFailed {
1733            reason: format!(
1734                "constant_curvature_profiled_reml_scores: fixed-κ fit at κ={kappa} failed: {e}"
1735            ),
1736        })?;
1737        out.push((kappa, score));
1738    }
1739    Ok(out)
1740}
1741
1742/// Derived dense-kernel cliff: the cascade auto-route fires only once the dense
1743/// radial basis the smooth would otherwise use has SATURATED at its center cap
1744/// (`default_num_centers == K_MAX`), so the dense `O(n·K² + K³)` kernel solve
1745/// can no longer grow resolution with `n` and the streaming cascade's
1746/// `O(n·polylog)` is the only path that keeps improving. This is the structural
1747/// "past the dense-kernel cliff" condition the issue names — derived from the
1748/// dense sizing rule, NOT a magic n constant or a user flag.
1749fn past_dense_kernel_cliff(n: usize, d: usize) -> bool {
1750    // `default_num_centers` clamps to K_MAX = 2000; equality means the dense
1751    // basis is pinned at the cap and cannot densify further with n.
1752    const DENSE_CENTER_CAP: usize = 2000;
1753    gam_terms::basis::default_num_centers(n, d) >= DENSE_CENTER_CAP
1754}
1755
1756/// Map a Duchon/Matérn smoothness order onto the cascade's Sobolev order,
1757/// clamped into the Wendland-(3,1) native window `(d/2, (d+3)/2]` (issue
1758/// caveat 1: the multilevel frame can only represent up to `H^{(d+3)/2}`).
1759fn cascade_sobolev_order(requested: f64, d: usize) -> f64 {
1760    let lo = d as f64 / 2.0;
1761    let hi = (d as f64 + 3.0) / 2.0;
1762    // Nudge strictly inside the open lower bound when the request lands on it.
1763    let eps = 1e-6 * (hi - lo);
1764    requested.clamp(lo + eps, hi)
1765}
1766
1767/// Detection seam for the O(n log n) multiresolution residual-cascade fast path
1768/// (issue #1032).
1769///
1770/// This mirrors [`spline_scan_fast_path`] in shape but carries one CRITICAL
1771/// difference dictated by the issue: the cascade is **not** the same posterior
1772/// as the Duchon/Matérn term it stands in for (a different finite basis — the
1773/// multilevel Wendland frame, not the reduced-rank radial kernel). So unlike
1774/// the 1-D scan, which silently swaps an identical posterior, this path must
1775/// only fire as an explicit alternative estimator on the structural signature
1776/// the issue names, never as a transparent replacement. It returns `Some` only
1777/// when ALL of the following hold:
1778/// - family is Gaussian + identity link (the scattered low-d smooth the
1779///   cascade solves);
1780/// - none of the exotic-link / constraint / Firth / Kronecker / coefficient-
1781///   group / hyperprior machinery is engaged;
1782/// - the model is exactly one smooth term — no linear terms, no random
1783///   effects, no by-variables;
1784/// - that smooth is a scattered radial spatial smooth (`Duchon` or `Matern`)
1785///   over `d ∈ {2, 3}` coordinates with no shape constraint;
1786/// - the offset is identically zero and every weight is finite and positive;
1787/// - `n` is past the derived dense-kernel cliff
1788///   ([`past_dense_kernel_cliff`]) — below it the dense radial path is both
1789///   exact-posterior and cheap, so there is no reason to change estimators.
1790///
1791/// The returned [`ResidualCascadeInputs`] carry a unit per-axis metric (the
1792/// spec's isotropic radial distance); the quasi-uniformity guard inside
1793/// [`gam_solve::residual_cascade::fit_residual_cascade`] (issue caveat 2)
1794/// is the no-regression gate that refuses the iterative solve — and forces the
1795/// caller back to the dense path — when a near-degenerate metric would break
1796/// the BPX iteration bound.
1797pub fn residual_cascade_fast_path(
1798    request: &StandardFitRequest<'_>,
1799) -> Option<ResidualCascadeInputs> {
1800    if !request.family.is_gaussian_identity() {
1801        return None;
1802    }
1803    if request.wiggle.is_some()
1804        || request.latent_coord.is_some()
1805        || !request.coefficient_groups.is_empty()
1806        || !request.penalty_block_gamma_priors.is_empty()
1807    {
1808        return None;
1809    }
1810    let options = &request.options;
1811    if options.latent_cloglog.is_some()
1812        || options.mixture_link.is_some()
1813        || options.sas_link.is_some()
1814        || options.linear_constraints.is_some()
1815        || options.adaptive_regularization.is_some()
1816        || options.kronecker_penalty_system.is_some()
1817        || options.kronecker_factored.is_some()
1818        || options.firth_bias_reduction
1819        || !options.nullspace_dims.is_empty()
1820    {
1821        return None;
1822    }
1823    let spec = &request.spec;
1824    if !spec.linear_terms.is_empty()
1825        || !spec.random_effect_terms.is_empty()
1826        || spec.smooth_terms.len() != 1
1827    {
1828        return None;
1829    }
1830    let term = &spec.smooth_terms[0];
1831    if !matches!(term.shape, gam_terms::smooth::ShapeConstraint::None)
1832        || term.joint_null_rotation.is_some()
1833    {
1834        return None;
1835    }
1836    // Only scattered radial spatial smooths (Duchon / Matérn) over 2–3 axes.
1837    // The Duchon spectral power `p + s` and the Matérn order set the requested
1838    // Sobolev smoothness; both clamp into the Wendland native window.
1839    let (feature_cols, requested_s) = match &term.basis {
1840        gam_terms::smooth::SmoothBasisSpec::Duchon {
1841            feature_cols, spec, ..
1842        } => {
1843            // Pure-Duchon native order is `p + s` (kernel exponent 2(p+s)−d);
1844            // the multilevel frame targets the same continuum smoothness. `p`
1845            // is the polynomial nullspace degree, `s` the spectral power.
1846            let p = match spec.nullspace_order {
1847                gam_terms::basis::DuchonNullspaceOrder::Zero => 0.0,
1848                gam_terms::basis::DuchonNullspaceOrder::Linear => 1.0,
1849                gam_terms::basis::DuchonNullspaceOrder::Degree(k) => k as f64,
1850            };
1851            (feature_cols, spec.power + p)
1852        }
1853        gam_terms::smooth::SmoothBasisSpec::Matern {
1854            feature_cols, spec, ..
1855        } => {
1856            // Matérn smoothness ν sets native Sobolev order ν + d/2; the cascade
1857            // frame represents up to (d+3)/2, so the clamp below applies the
1858            // ceiling. (d is known just below from feature_cols.)
1859            let nu = spec.nu.half_integer_value();
1860            (feature_cols, nu + feature_cols.len() as f64 / 2.0)
1861        }
1862        _ => return None,
1863    };
1864    let d = feature_cols.len();
1865    if !(2..=3).contains(&d) {
1866        return None;
1867    }
1868    if request.offset.iter().any(|&v| v != 0.0) {
1869        return None;
1870    }
1871    if request.weights.iter().any(|&v| !(v.is_finite() && v > 0.0)) {
1872        return None;
1873    }
1874    let n = request.y.len();
1875    if n != request.data.nrows() || feature_cols.iter().any(|&c| c >= request.data.ncols()) {
1876        return None;
1877    }
1878    if !past_dense_kernel_cliff(n, d) {
1879        return None;
1880    }
1881    let coords: Vec<Vec<f64>> = feature_cols
1882        .iter()
1883        .map(|&c| request.data.column(c).iter().copied().collect())
1884        .collect();
1885    let y: Vec<f64> = request.y.iter().copied().collect();
1886    let w: Vec<f64> = request.weights.iter().copied().collect();
1887    if coords
1888        .iter()
1889        .any(|axis| axis.iter().any(|v| !v.is_finite()))
1890        || y.iter().any(|v| !v.is_finite())
1891    {
1892        return None;
1893    }
1894    let metric = vec![1.0_f64; d];
1895    let sobolev_s = cascade_sobolev_order(requested_s, d);
1896    Some(ResidualCascadeInputs {
1897        coords,
1898        y,
1899        w,
1900        metric,
1901        sobolev_s,
1902    })
1903}
1904
1905/// Formula-level library entry for the O(n log n) residual-cascade fast path
1906/// (issue #1032).
1907///
1908/// Materializes the formula exactly like [`fit_from_formula`], runs the
1909/// [`residual_cascade_fast_path`] detection, and — when it fires AND the
1910/// quasi-uniformity guard inside the cascade certifies the metric — returns the
1911/// certified [`ResidualCascadeFit`](gam_solve::residual_cascade::ResidualCascadeFit).
1912/// `Ok(None)` means EITHER the model is not the cascade-eligible shape OR the
1913/// quasi-uniformity guard rejected the metric; in both cases the caller falls
1914/// back to the dense [`fit_from_formula`] path (the cascade is a different
1915/// posterior, so the fallback is a genuine estimator choice, never a silent
1916/// swap). This keeps every persistence-bearing consumer on the dense fit until
1917/// the cascade payload schema lands.
1918pub fn fit_residual_cascade_from_formula(
1919    formula: &str,
1920    data: &Dataset,
1921    config: &FitConfig,
1922) -> Result<Option<gam_solve::residual_cascade::ResidualCascadeFit>, WorkflowError> {
1923    let mat = materialize(formula, data, config)?;
1924    let FitRequest::Standard(request) = mat.request else {
1925        return Ok(None);
1926    };
1927    let Some(inputs) = residual_cascade_fast_path(&request) else {
1928        return Ok(None);
1929    };
1930    let coord_refs: Vec<&[f64]> = inputs.coords.iter().map(Vec::as_slice).collect();
1931    match gam_solve::residual_cascade::fit_residual_cascade(
1932        &coord_refs,
1933        &inputs.y,
1934        &inputs.w,
1935        &inputs.metric,
1936        inputs.sobolev_s,
1937    ) {
1938        Ok(fit) => Ok(Some(fit)),
1939        // The quasi-uniformity guard (caveat 2) and any degenerate-design
1940        // signal both surface as a build/solve error; treat them as "not
1941        // cascade-eligible" so the caller falls back to the dense kernel path
1942        // rather than failing the fit outright.
1943        Err(_) => Ok(None),
1944    }
1945}
1946
1947/// Parse a formula, resolve it against a dataset, and produce a ready-to-fit `FitRequest`.
1948fn family_requests_transformation_normal(family: Option<&str>) -> bool {
1949    family
1950        .map(|name| name.trim().to_ascii_lowercase().replace('_', "-"))
1951        .as_deref()
1952        == Some("transformation-normal")
1953}
1954
1955/// Build the design/request geometry for a formula against a dataset. This is the
1956/// FIT path: for survival location-scale / latent modes it resolves the baseline
1957/// θ via a real inner fit. Use [`materialize_structural`] for formula validation,
1958/// which must not fit.
1959pub fn materialize<'a>(
1960    formula: &str,
1961    data: &'a Dataset,
1962    config: &FitConfig,
1963) -> Result<MaterializedModel<'a>, WorkflowError> {
1964    materialize_impl(formula, data, config, false)
1965}
1966
1967/// Structural-only materialization for `validate_formula`: builds the same
1968/// request geometry/metadata but skips every inner fit (notably the survival
1969/// baseline-θ resolution), honoring validation's "without fitting" contract.
1970pub fn materialize_structural<'a>(
1971    formula: &str,
1972    data: &'a Dataset,
1973    config: &FitConfig,
1974) -> Result<MaterializedModel<'a>, WorkflowError> {
1975    materialize_impl(formula, data, config, true)
1976}
1977
1978fn materialize_impl<'a>(
1979    formula: &str,
1980    data: &'a Dataset,
1981    config: &FitConfig,
1982    structural_only: bool,
1983) -> Result<MaterializedModel<'a>, WorkflowError> {
1984    let config = config
1985        .clone()
1986        .resolve()
1987        .map_err(|reason| WorkflowError::InvalidConfig { reason })?;
1988    let config = &config;
1989    gam_gpu::configure_global_policy(config.gpu_policy);
1990    let parsed = parse_formula(formula)?;
1991    let col_map = data.column_map();
1992    let family_transformation_normal =
1993        family_requests_transformation_normal(config.family.as_deref());
1994    let transformation_normal_config;
1995    let effective_config = if family_transformation_normal && !config.transformation_normal {
1996        // `family="transformation-normal"` is a documented spelling of the CTN
1997        // model class, not a Gaussian identity likelihood. Normalize it into the
1998        // same orchestration flag used by `transformation_normal=true` before any
1999        // dispatch/validation branch can silently treat the request as standard.
2000        transformation_normal_config = FitConfig {
2001            transformation_normal: true,
2002            ..config.clone()
2003        };
2004        &transformation_normal_config
2005    } else {
2006        config
2007    };
2008
2009    if let Some((left_col, right_col, event_col)) = parse_surv_interval_response(&parsed.response)?
2010    {
2011        if effective_config.transformation_normal {
2012            return Err(WorkflowError::InvalidConfig {
2013                reason:
2014                    "transformation_normal cannot be combined with a SurvInterval(...) response"
2015                        .to_string(),
2016            });
2017        }
2018        // Interval censoring `T ∈ (L, R]` is only defined for the latent
2019        // hazard-window survival likelihood, whose kernel carries the
2020        // `log[S(L) − S(R)]` interval contribution. Route the left boundary `L`
2021        // through the standard exit channel and the right boundary `R` through
2022        // the dedicated interval-right channel; `event_col` distinguishes
2023        // bracketed (interval) rows from right-censored rows beyond the last
2024        // inspection (which carry an infinite/sentinel `R`).
2025        materialize_survival(
2026            &parsed,
2027            data,
2028            &col_map,
2029            effective_config,
2030            None,
2031            &left_col,
2032            &event_col,
2033            Some(&right_col),
2034            structural_only,
2035        )
2036    } else if let Some((entry_col, exit_col, event_col)) = parse_surv_response(&parsed.response)? {
2037        if effective_config.transformation_normal {
2038            return Err(WorkflowError::InvalidConfig {
2039                reason: "transformation_normal cannot be combined with a Surv(...) response"
2040                    .to_string(),
2041            });
2042        }
2043        // `materialize_*` now return `WorkflowError` directly so the typed
2044        // `ColumnNotFound` payload (and any future variant-typed leaf
2045        // errors) survive the dispatcher hop instead of being flattened
2046        // into `IntegrationFailed { reason: String }`.
2047        materialize_survival(
2048            &parsed,
2049            data,
2050            &col_map,
2051            effective_config,
2052            entry_col.as_deref(),
2053            &exit_col,
2054            &event_col,
2055            None,
2056            structural_only,
2057        )
2058    } else {
2059        // Non-survival response: `timewiggle(...)` and `survmodel(...)` are
2060        // structurally meaningless (there is no baseline hazard / time axis to
2061        // wiggle and no survival likelihood to configure). They are parsed into
2062        // `ParsedFormula` but consumed *only* by `materialize_survival`; without
2063        // this guard every non-survival materializer below would silently drop
2064        // them, fitting an ordinary GAM while the user believes they requested a
2065        // time-varying / survival model (#371). Reject here — the single
2066        // chokepoint for all non-survival paths — mirroring the symmetric
2067        // auxiliary-formula rejection in `validate_auxiliary_formula_controls`.
2068        reject_survival_only_terms_for_nonsurvival(&parsed)?;
2069        // Symmetrically, the `config.survival_likelihood` *knob* selects a
2070        // survival likelihood mode read only by `materialize_survival`. On this
2071        // non-survival branch a non-default value (e.g. "weibull") would be
2072        // discarded and the fit would silently degrade to an ordinary GAM
2073        // (#1767). Reject it at the same chokepoint.
2074        reject_survival_likelihood_for_nonsurvival(effective_config)?;
2075        if effective_config.transformation_normal {
2076            // Issue #789A: a Bernoulli marginal-slope request with
2077            // `transformation_normal=true` used to dispatch as a CTN fit while
2078            // retaining marginal-slope controls, leaving the transformation path
2079            // in a non-advancing loop. CTN score calibration now uses the
2080            // explicit `ctn_stage1` recipe instead, so the legacy boolean is a
2081            // hard configuration error for marginal-slope requests.
2082            reject_marginal_slope_controls_for_transformation_normal(effective_config)?;
2083            if effective_config.noise_formula.is_some() {
2084                return Err(WorkflowError::InvalidConfig {
2085                    reason: "transformation_normal cannot be combined with noise_formula"
2086                        .to_string(),
2087                });
2088            }
2089            materialize_transformation_normal(&parsed, data, &col_map, effective_config)
2090        } else if requests_bernoulli_marginal_slope(effective_config) {
2091            materialize_bernoulli_marginal_slope(&parsed, data, &col_map, effective_config)
2092        } else if effective_config.noise_formula.is_some() {
2093            materialize_location_scale(&parsed, data, &col_map, effective_config)
2094        } else {
2095            materialize_standard(&parsed, data, &col_map, effective_config)
2096        }
2097    }
2098}
2099
2100#[cfg(test)]
2101mod sz_factor_smooth_recovery_tests {
2102    // `super::*` brings in `Dataset` (= gam_data::EncodedDataset), `FitConfig`,
2103    // `FitResult`, `StandardFitResult`, and `fit_from_formula`.
2104    use super::*;
2105
2106    const NOISE_SD: f64 = 0.20;
2107    const N: usize = 4000;
2108    const N_GROUPS: usize = 4;
2109
2110    /// A simple deterministic LCG so the dataset is reproducible without pulling
2111    /// an RNG dependency into the test.
2112    struct Lcg(u64);
2113    impl Lcg {
2114        fn next_u64(&mut self) -> u64 {
2115            // Numerical Recipes LCG constants.
2116            self.0 = self
2117                .0
2118                .wrapping_mul(6364136223846793005)
2119                .wrapping_add(1442695040888963407);
2120            self.0
2121        }
2122        /// Uniform in [0, 1).
2123        fn unif(&mut self) -> f64 {
2124            (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
2125        }
2126        /// Standard normal via Box–Muller (one of the pair).
2127        fn normal(&mut self) -> f64 {
2128            let u1 = (self.unif()).max(1e-12);
2129            let u2 = self.unif();
2130            (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
2131        }
2132    }
2133
2134    /// Data drawn from EXACTLY the `sz` model class: a shared smooth `f0(x)` plus
2135    /// zero-sum per-group deviations `d_g(x)` (phase-shifted sinusoids whose
2136    /// cross-group mean is removed at every `x`), plus observation noise. This
2137    /// mirrors the (blocked) Python bug-hunt test `tests/bug_hunt_sz_factor_
2138    /// smooth_underfits_own_model_class_test.py`.
2139    ///
2140    /// Written to a CSV and loaded through the real `load_dataset_projected`
2141    /// inferer so the grouping column `g` (string levels) is encoded as a genuine
2142    /// categorical exactly as production does — hand-built `EncodedDataset`s do
2143    /// not carry the categorical level map the factor-smooth level resolver needs.
2144    fn sz_class_dataset() -> (Dataset, tempfile::TempDir) {
2145        let mut rng = Lcg(0x5326_2026_0628_1605);
2146        let phases: Vec<f64> = (0..N_GROUPS)
2147            .map(|k| 1.2 * k as f64 / (N_GROUPS as f64 - 1.0))
2148            .collect();
2149        let deviations = |xi: f64| -> Vec<f64> {
2150            let vals: Vec<f64> = phases
2151                .iter()
2152                .map(|p| 0.6 * (std::f64::consts::TAU * xi + std::f64::consts::TAU * p).sin())
2153                .collect();
2154            let mean = vals.iter().sum::<f64>() / vals.len() as f64;
2155            vals.iter().map(|v| v - mean).collect()
2156        };
2157
2158        let mut csv = String::from("y,x,g\n");
2159        for _ in 0..N {
2160            let x = rng.unif();
2161            // Use the HIGH bits (via `unif`) for the group draw — an LCG's low
2162            // bits have a tiny period and would collapse `% N_GROUPS` to a near
2163            // constant.
2164            let g = ((rng.unif() * N_GROUPS as f64) as usize).min(N_GROUPS - 1);
2165            let f0 = (std::f64::consts::TAU * x).sin();
2166            let mu = f0 + deviations(x)[g];
2167            let y = mu + NOISE_SD * rng.normal();
2168            csv.push_str(&format!("{y},{x},g{g}\n"));
2169        }
2170        let td = tempfile::tempdir().expect("tempdir");
2171        let path = td.path().join("sz_class.csv");
2172        std::fs::write(&path, csv).expect("write sz-class csv");
2173        // Force `g` into a categorical role exactly as the formula intends so the
2174        // factor-smooth level resolver sees all `N_GROUPS` distinct levels.
2175        let mut roles = std::collections::HashSet::new();
2176        roles.insert("g");
2177        let data = gam_data::load_dataset_projected_with_categorical_roles(
2178            &path,
2179            &["y".to_string(), "x".to_string(), "g".to_string()],
2180            &roles,
2181        )
2182        .expect("load sz-class dataset");
2183        (data, td)
2184    }
2185
2186    fn gaussian_config() -> FitConfig {
2187        FitConfig {
2188            family: Some("gaussian".to_string()),
2189            ..FitConfig::default()
2190        }
2191    }
2192
2193    /// In-sample residual sd of a fitted standard GAM: `sd(y − Xβ̂)`.
2194    fn residual_sd(fit: &StandardFitResult, data: &Dataset) -> f64 {
2195        let beta = &fit.fit.beta;
2196        let design = &fit.design.design;
2197        let n = design.nrows();
2198        assert_eq!(design.ncols(), beta.len(), "design/beta width mismatch");
2199        let mut fitted = vec![0.0f64; n];
2200        // `try_row_chunk` materializes contiguous row blocks of whatever design
2201        // storage the fit used (dense or block-lazy) — robust to the storage kind.
2202        const CHUNK: usize = 512;
2203        let mut start = 0usize;
2204        while start < n {
2205            let end = (start + CHUNK).min(n);
2206            let block = design
2207                .try_row_chunk(start..end)
2208                .expect("materialize design row chunk");
2209            for (r, row) in block.rows().into_iter().enumerate() {
2210                let mut acc = 0.0;
2211                for (c, &xv) in row.iter().enumerate() {
2212                    acc += xv * beta[c];
2213                }
2214                fitted[start + r] = acc;
2215            }
2216            start = end;
2217        }
2218        let y = data.values.column(0);
2219        let resid: Vec<f64> = y
2220            .iter()
2221            .zip(fitted.iter())
2222            .map(|(&yi, &fi)| yi - fi)
2223            .collect();
2224        let mean = resid.iter().sum::<f64>() / resid.len() as f64;
2225        let var = resid.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / resid.len() as f64;
2226        var.sqrt()
2227    }
2228
2229    fn fit_standard(formula: &str, data: &Dataset) -> StandardFitResult {
2230        match fit_from_formula(formula, data, &gaussian_config())
2231            .unwrap_or_else(|e| panic!("fit `{formula}` failed: {e:?}"))
2232        {
2233            FitResult::Standard(r) => r,
2234            other => panic!(
2235                "expected Standard fit for `{formula}`, got a different variant: {}",
2236                std::any::type_name_of_val(&other)
2237            ),
2238        }
2239    }
2240
2241    /// #1605 (gold standard, end-to-end REML fit): the sum-to-zero factor smooth
2242    /// `s(x) + s(g, x, bs="sz")` must RECOVER data drawn from its own model class
2243    /// to the observation-noise floor, exactly as the strictly-more-general
2244    /// `s(x, g, bs="fs")` superset provably does.
2245    ///
2246    /// The recovery gap (`sz` resid ≈ 0.43 ≈ 2.1× the 0.20 floor while `fs`
2247    /// reaches the floor) was closed by THREE mgcv-faithful corrections, each
2248    /// necessary, that this end-to-end fit jointly exercises:
2249    ///   1. marginal basis (baef17e): cr → curvature-capable B-spline, so a
2250    ///      deviation with non-zero boundary curvature is representable;
2251    ///   2. ownership/overlap residualization (b49bb5c): the `sz` deviation is
2252    ///      sum-to-zero ACROSS the grouping factor, hence orthogonal to a
2253    ///      factor-independent owner like the shared `s(x)`. Residualizing it
2254    ///      against `s(x)`'s realized span (the #978 chart) collapsed every
2255    ///      group's curve to a flat per-group contrast; skipping that ownership
2256    ///      (same family as the #1276 factor-`by` level gate) restores the curve
2257    ///      shape and stops REML railing the shared `s(x)` wiggliness λ;
2258    ///   3. null-space ridge (this change): the `sz` deviation blocks now carry
2259    ///      the per-null-dimension ridge structure of `fs`, mapped into the
2260    ///      zero-sum contrast space, so the {const, linear} null space is
2261    ///      shrinkable per dimension (the #700/#712/#713 partial-pooling form)
2262    ///      rather than left free — without breaking the zero-sum constraint.
2263    ///
2264    /// This is the gold-standard verification: it drives the real
2265    /// `fit_from_formula` REML λ-selection on data drawn from exactly the `sz`
2266    /// model class and asserts `sz` reaches the floor (and a `fs` control does
2267    /// too). It failed before the fixes and passes after.
2268    #[test]
2269    fn sz_factor_smooth_recovers_its_own_model_class_end_to_end() {
2270        let (data, _td) = sz_class_dataset();
2271
2272        // Control: bs="fs", a strict superset of the sz span, must reach the
2273        // noise floor — proves the data is well-posed and pins the floor.
2274        let fs_fit = fit_standard("y ~ s(x, g, bs='fs')", &data);
2275        let fs_resid = residual_sd(&fs_fit, &data);
2276        assert!(
2277            fs_resid < 1.2 * NOISE_SD,
2278            "control bs='fs' did not reach the noise floor: resid_sd={fs_resid:.4} \
2279             vs noise_sd={NOISE_SD} (data/floor sanity check)",
2280        );
2281
2282        // The documented sz idiom on data drawn from the sz model class.
2283        let sz_fit = fit_standard("y ~ s(x) + s(g, x, bs='sz')", &data);
2284        let sz_resid = residual_sd(&sz_fit, &data);
2285
2286        // A smoother whose span contains the truth, fit at large n, must explain
2287        // the systematic structure and leave ~only observation noise.
2288        assert!(
2289            sz_resid < 1.4 * NOISE_SD,
2290            "bs='sz' under-fits its own model class: resid_sd={sz_resid:.4} \
2291             ({:.2}x the noise floor {NOISE_SD}); the bs='fs' superset reached \
2292             {fs_resid:.4}. The sz fit leaves systematic signal in the residual.",
2293            sz_resid / NOISE_SD,
2294        );
2295
2296        // Comparative guard: sz must not be dramatically worse than the fs
2297        // superset that recovers the same data.
2298        assert!(
2299            sz_resid < 1.5 * fs_resid,
2300            "bs='sz' residual {sz_resid:.4} is {:.2}x the bs='fs' residual \
2301             {fs_resid:.4} on identical sz-class data",
2302            sz_resid / fs_resid,
2303        );
2304    }
2305}