Skip to main content

gam_models/fit_orchestration/
entry.rs

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