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