Skip to main content

gam_solve/pirls/
loop_driver.rs

1//! Outer driver for a single fixed-ρ PIRLS fit.
2//!
3//! Owns:
4//! - `fit_model_for_fixed_rho` and `fit_model_for_fixed_rho_with_adaptive_kkt`
5//!   — build the working model, run the inner LM loop, assemble the final result.
6//! - `PirlsProblem`, `PenaltyConfig`, `PirlsConfig` — the configuration types.
7//! - Helper functions exclusive to the fixed-ρ fitting path: constraint
8//!   transformation, sparse-native decision, reparam materialisation, prior
9//!   shift assembly, initial-β guess, Gaussian short-circuit assembly, etc.
10//! - The two GPU dispatch blocks (Stage 3.3) that call into
11//!   `crate::gpu::pirls_dispatch_wire`.
12
13use super::{
14    // state re-exports
15    AdaptiveKktTolerance,
16    ExportedLaplaceCurvature,
17    FirthDiagnostics,
18    GamWorkingModel,
19    GaussianFrozenRows,
20    HessianCurvatureKind,
21    // penalty types
22    KroneckerQsTransform,
23    LinearInequalityConstraints,
24    PirlsCoordinateFrame,
25    PirlsLinearSolvePath,
26    PirlsPenalty,
27    PirlsResult,
28    PirlsStatus,
29    PirlsWorkspace,
30    SparsePirlsDecision,
31    WorkingModelIterationInfo,
32    WorkingModelPirlsOptions,
33    WorkingModelPirlsResult,
34    WorkingReparamTransform,
35    WorkingState,
36    // misc helpers
37    array1_l2_norm,
38    attach_penalty_shift,
39    // compute functions
40    calculate_deviance_from_eta,
41    // edf helpers
42    calculate_edf_with_penalty,
43    calculate_edfwithworkspace_with_penalty,
44    compute_constraint_kkt_diagnostics,
45    computeworkingweight_derivatives_from_eta,
46    inf_norm,
47    pirls_data_log_kernel_from_eta,
48    runworking_model_pirls,
49    should_use_sparse_native_pirls,
50    solve_penalized_least_squares_implicit,
51    standard_inverse_link_jet,
52};
53use super::{
54    ArrowSchurInnerConfig, GamModelFinalState, effective_kkt_tolerance,
55    project_coefficients_to_lower_bounds,
56};
57use crate::active_set;
58use crate::estimate::EstimationError;
59use crate::gpu::pirls_host_dispatch::{try_gaussian_pls_gpu, try_pirls_loop_gpu};
60use faer::sparse::{SparseColMat, Triplet};
61use gam_linalg::faer_ndarray::fast_ab;
62use gam_linalg::matrix::{DesignMatrix, LinearOperator, ReparamOperator, SymmetricMatrix};
63use gam_math::probability::standard_normal_quantile;
64use gam_problem::{
65    Coefficients, GlmLikelihoodSpec, InverseLink, LinearPredictor, LinkFunction,
66    LogSmoothingParamsView, MixtureLinkState, ResolvedLikelihoodScale, ResponseFamily,
67    RidgePassport, RidgePolicy, SasLinkState, StandardLink,
68};
69use gam_terms::construction::{KroneckerReparamResult, ReparamResult};
70use ndarray::{ArcArray1, Array1, Array2, ArrayView1, ArrayView2, s};
71use std::sync::Arc;
72use std::sync::atomic::{AtomicU64, Ordering};
73
74/// #1868 deterministic n-independence instrument.
75///
76/// Process-global accumulator of the number of length-`n` row-element touches
77/// (array allocations / row-wise scans) performed by the Gaussian
78/// zero-iteration inner synthesis on the **#1033 n-free κ-trial skip path**
79/// (`row_prediction_is_stale`). On that path the outer criterion, gradient and
80/// inner solve are all served from the k-space ψ-Gram sufficient statistics, so
81/// the architectural invariant (#1033: "each hyperparameter trial touches only
82/// k×k objects") requires this counter to stay FLAT — it must not grow with `n`.
83/// A value that scales with `n` is exactly the #1868 O(n)-per-callback
84/// regression (the stale-row lane re-materialising `offset`/`y`/`weights` and
85/// the constant working-weight derivative arrays per trial instead of sharing
86/// the once-built frozen row bundle).
87///
88/// This is the *deterministic* replacement for the old wall-clock
89/// per-callback-ratio gate (#1868 / #2055): the same invariant, read as an exact
90/// integer in milliseconds at small `n` instead of a noisy timing ratio that
91/// needed a multi-hour 320k sweep to surface. Monotonic; callers snapshot the
92/// value before and after the κ-trial phase and assert on the delta.
93pub(crate) static NFREE_SKIP_ROW_ELEMENT_TOUCHES: AtomicU64 = AtomicU64::new(0);
94
95/// Record `elems` length-`n` row-element touches on the n-free κ-trial skip
96/// path (see [`NFREE_SKIP_ROW_ELEMENT_TOUCHES`]). Called at each length-`n`
97/// materialisation the stale-row Gaussian synthesis performs; after the #1868
98/// frozen-row-bundle fix the skip path performs none, so the accumulator holds
99/// flat across `n`.
100#[inline]
101pub(crate) fn record_nfree_skip_row_touches(elems: usize) {
102    NFREE_SKIP_ROW_ELEMENT_TOUCHES.fetch_add(elems as u64, Ordering::Relaxed);
103}
104
105/// Read the process-global n-free κ-trial skip-path row-touch accumulator.
106/// Exposed so the spatial length-scale driver can snapshot deltas across the
107/// κ-optimisation phase and thread them into the reported timing.
108pub fn nfree_skip_row_element_touches() -> u64 {
109    NFREE_SKIP_ROW_ELEMENT_TOUCHES.load(Ordering::Relaxed)
110}
111
112/// Contract two vectors with a compensated accumulator and an exact product
113/// split, so the result carries ~1 ulp of its own magnitude rather than ~n ulp.
114///
115/// This exists for one contraction: `qb^T (X^T W z)` in the Gaussian
116/// zero-iteration synthesis below, where the #1033 n-free kappa-trial path
117/// recovers the deviance as `z^T W z - 2 qb^T b + qb^T G qb`. The design is
118/// never realized at the trial psi on that path, so the row-wise
119/// `sum w (y - mu)^2` is unavailable and this cancellation is the only route to
120/// the deviance. It is a large one by construction: measured on the #2624
121/// fixture, `z^T W z = 3.13466938668704074e2` against a converged
122/// `D_p = 4.0e-5` -- 7.1 orders. The profiled-Gaussian REML criterion then
123/// multiplies the RELATIVE error of `D_p` by `(n - M_p)/2`, because its whole
124/// `D_p` dependence is the single term `((n-M_p)/2) * ln(2 pi D_p/(n-M_p))`, so
125/// at n = 600 an error in `D_p` reaches the outer surface 300x magnified.
126/// Resolving this contraction to 1 ulp is therefore cheap insurance on a
127/// quantity the outer surface is unusually sensitive to, and it costs less than
128/// the spelling it replaced (one fewer matvec).
129///
130/// WHAT IT IS NOT. It was landed claiming to be the #2624 fix. **That claim was
131/// measured and is false**, and the measurements are recorded here so the claim
132/// is not re-made:
133///
134/// * Printing BOTH spellings on the SAME calls of the spatial fast path, the
135///   compensation moves `D_p` by 1.8e-14 to 1.1e-13, while the call-to-call
136///   variation of `D_p` at essentially one theta is 5.1e-12. So it perturbs the
137///   value by ~1% of the noise it was supposed to remove; the dominant carrier
138///   is upstream of this contraction (both spellings share `z^T W z` and the
139///   tensor-served `gram_at(psi)` / `rhs_at(psi)`, so a common-mode error
140///   cancels out of their difference and is invisible in it).
141/// * Against the exact row deviance on the live-row lane, it improves the gap
142///   by 2.32x at one point, 1.006x at another of the same depth, and 1.00x
143///   elsewhere.
144/// * On the non-spatial Python witness (`audit_outer_value_agreement`), a wheel
145///   built from the landing commit reproduces `value-only`, `analytic-sample`
146///   and `disagreement = 1.805e-5` BIT-IDENTICALLY to the pre-commit run.
147///
148/// The 8/13 -> 10/13 change in certified #2624 arms that accompanied the
149/// landing is therefore NOT attributable to a quieter criterion. A perturbation
150/// of this size reshuffles the outer trajectory, and on a fixture whose residual
151/// failure mode is "the multistart certifies the wrong basin" that moves arms in
152/// both directions -- which is exactly what was observed, `length_scale` 1.2,
153/// 1.0 and 0.95 gaining and 0.7 regressing.
154///
155/// Neumaier compensation on the running sum, plus `mul_add` to recover the
156/// exact product error. Falls back to the ordinary contraction on a length
157/// mismatch so a shape bug surfaces where shapes are checked, not here.
158fn compensated_dot(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
159    if a.len() != b.len() {
160        return a.dot(b);
161    }
162    let mut sum = 0.0_f64;
163    let mut compensation = 0.0_f64;
164    for (&x, &y) in a.iter().zip(b.iter()) {
165        let product = x * y;
166        // `x*y - product` exactly, when an FMA is available.
167        compensation += f64::mul_add(x, y, -product);
168        let next = sum + product;
169        compensation += if sum.abs() >= product.abs() {
170            (sum - next) + product
171        } else {
172            (product - next) + sum
173        };
174        sum = next;
175    }
176    sum + compensation
177}
178
179pub(crate) fn exact_lambdas_from_rho(rho: LogSmoothingParamsView<'_>) -> Array1<f64> {
180    rho.exact_exp()
181}
182
183pub(super) fn default_beta_guess_external(
184    p: usize,
185    link_function: LinkFunction,
186    y: ArrayView1<f64>,
187    priorweights: ArrayView1<f64>,
188    mixture_link_state: Option<&MixtureLinkState>,
189    sas_link_state: Option<&SasLinkState>,
190) -> Array1<f64> {
191    let mut beta = Array1::<f64>::zeros(p);
192    let intercept_col = 0usize;
193    match link_function {
194        LinkFunction::Logit
195        | LinkFunction::Probit
196        | LinkFunction::CLogLog
197        | LinkFunction::LogLog
198        | LinkFunction::Cauchit
199        | LinkFunction::Sas
200        | LinkFunction::BetaLogistic => {
201            let mut weighted_sum = 0.0;
202            let mut totalweight = 0.0;
203            for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
204                weighted_sum += wi * yi;
205                totalweight += wi;
206            }
207            if totalweight > 0.0 {
208                let prevalence =
209                    ((weighted_sum + 0.5) / (totalweight + 1.0)).clamp(1e-6, 1.0 - 1e-6);
210                beta[intercept_col] = match link_function {
211                    LinkFunction::Logit => (prevalence / (1.0 - prevalence)).ln(),
212                    LinkFunction::Probit => {
213                        standard_normal_quantile(prevalence).unwrap_or_else(|err| {
214                            // `prevalence` is clamped to (0, 1); this fallback is
215                            // only for defensive robustness under non-finite upstream inputs.
216                            log::debug!(
217                                "[PIRLS init] probit intercept seed: Φ⁻¹({prevalence:.6}) \
218                                 failed ({err}); using the logit transform instead"
219                            );
220                            (prevalence / (1.0 - prevalence)).ln()
221                        })
222                    }
223                    LinkFunction::CLogLog => (-(1.0 - prevalence).ln()).ln(),
224                    LinkFunction::LogLog => -(-prevalence.ln()).ln(),
225                    LinkFunction::Cauchit => (std::f64::consts::PI * (prevalence - 0.5)).tan(),
226                    LinkFunction::Sas => solve_intercept_for_prevalence(
227                        link_function,
228                        prevalence,
229                        mixture_link_state,
230                        sas_link_state,
231                    )
232                    .unwrap_or_else(|| {
233                        standard_normal_quantile(prevalence).unwrap_or_else(|err| {
234                            log::debug!(
235                                "[PIRLS init] intercept seed: Φ⁻¹({prevalence:.6}) failed \
236                                 ({err}); using the logit transform instead"
237                            );
238                            (prevalence / (1.0 - prevalence)).ln()
239                        })
240                    }),
241                    LinkFunction::BetaLogistic => solve_intercept_for_prevalence(
242                        link_function,
243                        prevalence,
244                        mixture_link_state,
245                        sas_link_state,
246                    )
247                    .unwrap_or_else(|| {
248                        standard_normal_quantile(prevalence).unwrap_or_else(|err| {
249                            log::debug!(
250                                "[PIRLS init] intercept seed: Φ⁻¹({prevalence:.6}) failed \
251                                 ({err}); using the logit transform instead"
252                            );
253                            (prevalence / (1.0 - prevalence)).ln()
254                        })
255                    }),
256                    // Outer arm guard already filtered out Log/Identity; fall
257                    // back to the canonical logit transform for defensive safety
258                    // if these are ever reached unexpectedly.
259                    LinkFunction::Log | LinkFunction::Identity => {
260                        (prevalence / (1.0 - prevalence)).ln()
261                    }
262                };
263                if mixture_link_state.is_some() {
264                    beta[intercept_col] = solve_intercept_for_prevalence(
265                        link_function,
266                        prevalence,
267                        mixture_link_state,
268                        sas_link_state,
269                    )
270                    .unwrap_or(beta[intercept_col]);
271                }
272            }
273        }
274        LinkFunction::Identity => {
275            let mut weighted_sum = 0.0;
276            let mut totalweight = 0.0;
277            for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
278                weighted_sum += wi * yi;
279                totalweight += wi;
280            }
281            if totalweight > 0.0 {
282                beta[intercept_col] = weighted_sum / totalweight;
283            }
284        }
285        LinkFunction::Log => {
286            // For log link, intercept = ln(weighted mean of y)
287            let mut weighted_sum = 0.0;
288            let mut totalweight = 0.0;
289            for (&yi, &wi) in y.iter().zip(priorweights.iter()) {
290                weighted_sum += wi * yi;
291                totalweight += wi;
292            }
293            if totalweight > 0.0 {
294                let mean_y = weighted_sum / totalweight;
295                // A zero weighted mean (every response zero) has no finite
296                // log: the intercept keeps its zero seed and the solve judges
297                // the degenerate data itself, instead of starting at the log
298                // of an invented floor (#2469).
299                if mean_y > 0.0 {
300                    beta[intercept_col] = mean_y.ln();
301                }
302            }
303        }
304    }
305    beta
306}
307
308pub(super) fn solve_intercept_for_prevalence(
309    link_function: LinkFunction,
310    prevalence: f64,
311    mixture_link_state: Option<&MixtureLinkState>,
312    sas_link_state: Option<&SasLinkState>,
313) -> Option<f64> {
314    #[inline]
315    fn f_eta(
316        link_function: LinkFunction,
317        eta: f64,
318        prevalence: f64,
319        mixture_link_state: Option<&MixtureLinkState>,
320        sas_link_state: Option<&SasLinkState>,
321    ) -> f64 {
322        let inverse_link = if let Some(state) = mixture_link_state {
323            InverseLink::Mixture(state.clone())
324        } else if let Some(state) = sas_link_state {
325            match link_function {
326                LinkFunction::BetaLogistic => InverseLink::BetaLogistic(*state),
327                _ => InverseLink::Sas(*state),
328            }
329        } else {
330            // SAFETY: when `sas_link_state` is None, `solve_intercept_for_prevalence`
331            // is only invoked with the five legal `StandardLink` variants (the
332            // dispatch site at pirls.rs:4203 routes Sas/BetaLogistic into the
333            // Some branch above with state).
334            InverseLink::Standard(StandardLink::try_from(link_function).expect(
335                "state-bearing link reached state-less arm in solve_intercept_for_prevalence",
336            ))
337        };
338        standard_inverse_link_jet(&inverse_link, eta)
339            .map(|jet| jet.mu - prevalence)
340            .unwrap_or(f64::NAN)
341    }
342
343    let mut lo = -40.0;
344    let mut hi = 40.0;
345    let mut f_lo = f_eta(
346        link_function,
347        lo,
348        prevalence,
349        mixture_link_state,
350        sas_link_state,
351    );
352    let mut f_hi = f_eta(
353        link_function,
354        hi,
355        prevalence,
356        mixture_link_state,
357        sas_link_state,
358    );
359    if !(f_lo.is_finite() && f_hi.is_finite()) {
360        return None;
361    }
362    for _ in 0..8 {
363        if f_lo <= 0.0 && f_hi >= 0.0 {
364            break;
365        }
366        lo *= 2.0;
367        hi *= 2.0;
368        f_lo = f_eta(
369            link_function,
370            lo,
371            prevalence,
372            mixture_link_state,
373            sas_link_state,
374        );
375        f_hi = f_eta(
376            link_function,
377            hi,
378            prevalence,
379            mixture_link_state,
380            sas_link_state,
381        );
382        if !(f_lo.is_finite() && f_hi.is_finite()) {
383            return None;
384        }
385    }
386    if f_lo > 0.0 {
387        return Some(lo);
388    }
389    if f_hi < 0.0 {
390        return Some(hi);
391    }
392    for _ in 0..80 {
393        let mid = 0.5 * (lo + hi);
394        let f_mid = f_eta(
395            link_function,
396            mid,
397            prevalence,
398            mixture_link_state,
399            sas_link_state,
400        );
401        if !f_mid.is_finite() {
402            return None;
403        }
404        if f_mid > 0.0 {
405            hi = mid;
406        } else {
407            lo = mid;
408        }
409    }
410    Some(0.5 * (lo + hi))
411}
412
413pub(super) fn assemble_pirls_result(
414    working_summary: &WorkingModelPirlsResult,
415    likelihood: GlmLikelihoodSpec,
416    offset: ArrayView1<'_, f64>,
417    penalized_hessian_transformed: SymmetricMatrix,
418    stabilizedhessian_transformed: SymmetricMatrix,
419    edf: f64,
420    penalty_term: f64,
421    finalmu: &Array1<f64>,
422    finalweights: &Array1<f64>,
423    scoreweights: &Array1<f64>,
424    finalz: &Array1<f64>,
425    final_c: &Array1<f64>,
426    final_d: &Array1<f64>,
427    final_dmu_deta: &Array1<f64>,
428    final_d2mu_deta2: &Array1<f64>,
429    final_d3mu_deta3: &Array1<f64>,
430    status: PirlsStatus,
431    reparam_result: ReparamResult,
432    x_transformed: DesignMatrix,
433    coordinate_frame: PirlsCoordinateFrame,
434    linear_constraints_transformed: Option<LinearInequalityConstraints>,
435) -> Result<PirlsResult, EstimationError> {
436    // #1868: the full-assembly path is legitimately O(n) (this is the one-off
437    // final fit, not a per-callback n-free skip); wrap its freshly-realised row
438    // arrays in the shared `ArcArray1` representation (`.into_shared()` moves the
439    // owned buffer into an `Arc`, O(1)). `finalmu`/`solvemu` share one handle.
440    let final_eta_arr = working_summary.state.eta.as_ref().clone();
441    let finalmu_shared = finalmu.clone().into_shared();
442    Ok(PirlsResult {
443        likelihood,
444        beta_transformed: working_summary.beta.clone(),
445        penalized_hessian_transformed,
446        stabilizedhessian_transformed,
447        ridge_passport: RidgePassport::scaled_identity(
448            working_summary.state.ridge_used,
449            RidgePolicy::exact_full_objective(),
450        )?,
451        deviance: working_summary.state.deviance,
452        edf,
453        stable_penalty_term: penalty_term,
454        firth: working_summary.state.firth.clone(),
455        finalweights: finalweights.clone().into_shared(),
456        final_offset: offset.to_owned().into_shared(),
457        final_eta: final_eta_arr.into_shared(),
458        finalmu: finalmu_shared.clone(),
459        solveweights: scoreweights.clone().into_shared(),
460        solveworking_response: finalz.clone().into_shared(),
461        solvemu: finalmu_shared,
462        solve_dmu_deta: final_dmu_deta.clone().into_shared(),
463        solve_d2mu_deta2: final_d2mu_deta2.clone().into_shared(),
464        solve_d3mu_deta3: final_d3mu_deta3.clone().into_shared(),
465        solve_c_array: final_c.clone().into_shared(),
466        solve_c_nontrivial: final_c.iter().any(|&value| value != 0.0),
467        solve_d_array: final_d.clone().into_shared(),
468        derivatives_unsupported: false,
469        status,
470        iteration: working_summary.iterations,
471        max_abs_eta: working_summary.max_abs_eta,
472        lastgradient_norm: working_summary.lastgradient_norm,
473        gradient_natural_scale: working_summary.state.gradient_natural_scale,
474        penalized_gradient_transformed: working_summary.state.gradient.clone(),
475        last_deviance_change: working_summary.last_deviance_change,
476        last_step_halving: working_summary.last_step_halving,
477        hessian_curvature: working_summary.state.hessian_curvature,
478        exported_laplace_curvature: working_summary.exported_laplace_curvature.clone(),
479        final_lm_lambda: working_summary.final_lm_lambda,
480        final_accept_rho: working_summary.final_accept_rho,
481        constraint_kkt: working_summary.constraint_kkt.clone(),
482        final_kkt_tolerance: working_summary.final_kkt_tolerance,
483        linear_constraints_transformed,
484        reparam_result,
485        x_transformed,
486        coordinate_frame,
487        used_device: false,
488        cache_compacted: false,
489        min_penalized_deviance: working_summary.min_penalized_deviance,
490    })
491}
492
493pub(super) fn detect_logit_instability(
494    link: LinkFunction,
495    response: &ResponseFamily,
496    has_penalty: bool,
497    firth_active: bool,
498    summary: &WorkingModelPirlsResult,
499    finalmu: &Array1<f64>,
500    y: ArrayView1<'_, f64>,
501) -> bool {
502    // Perfect / quasi-perfect separation is a *Bernoulli/Binomial* pathology.
503    // Every heuristic below is binary-response–specific: saturation toward
504    // μ ∈ {0, 1}, the `yᵢ > 0.5` order-separation split, and working-weight
505    // collapse only carry meaning when each `yᵢ` is a 0/1 outcome (or a
506    // proportion of Bernoulli trials). The Beta family also fits through the
507    // logit link, but its response is *continuous* on (0, 1): a perfectly
508    // healthy monotone mean (μ increasing in a covariate ⇒ rows with y > 0.5
509    // sit at higher η than rows with y ≤ 0.5) trivially satisfies the
510    // `order_separated` test, so gating this detector on the logit link alone
511    // misclassifies well-behaved Beta fits as separated and forces a spurious
512    // inner-solve retreat at every smoothing-parameter seed (issue #499).
513    // Gate strictly on the Binomial response so only binary GLMs are screened.
514    if !matches!(response, ResponseFamily::Binomial) || link != LinkFunction::Logit || firth_active
515    {
516        return false;
517    }
518
519    // #2273 — a PENALIZED fit has nothing to detect here, and detecting it
520    // anyway deletes the optimum.
521    //
522    // What this retreat is for: when the penalized objective
523    // `−ℓ(β) + ½βᵀS(λ)β` has NO finite minimizer, there is no β̂(λ) for the
524    // outer criterion to be a function of, and the honest reply is `+∞` so the
525    // outer search backs out of that λ. That happens exactly when a direction
526    // of recession of `−ℓ` (a `d` with `sign(2yᵢ−1)·(Xd)ᵢ ≥ 0` for every row,
527    // strictly for one) lies in `null(S(λ))` — the classical separation
528    // condition (Silvapulle 1981). With no penalty at all, every separating
529    // direction qualifies, which is the `!has_penalty` branch below.
530    //
531    // Under a penalty that covers the separating direction the objective is
532    // strictly convex and coercive, so `β̂(λ)` is finite and unique **even under
533    // exact separation** — and then every signal this function used to test is
534    // a property of the CORRECT fit rather than of a divergence: on separable
535    // data a good fit's η *does* order the classes (`order_separated`), its μ
536    // *are* near {0,1} (`severe_saturation`), its working weights *do* collapse
537    // (`weights_collapsed`) and its deviance *is* tiny (`dev_extremely_small`).
538    // Refusing those is refusing the answer. Measured on #2273's n=60
539    // `y ~ smooth(x)` fixture (double penalty, so `S(λ)` is full-rank on the
540    // smooth block): the criterion was `+∞` for every `ρ_range ≤ −2` and every
541    // `ρ_null ≤ 0`, and the criterion DESCENDS into that region — its interior
542    // minimum lives inside the part this detector deleted, so the fit could
543    // never converge and the reported failure was a line search unable to move.
544    //
545    // The genuinely unbounded λ are still refused, and by measurement rather
546    // than by this heuristic. `detect_logit_instability` cannot fire on a
547    // non-logit link, so the same design fitted with `link=probit` is the
548    // control: it is finite across the coercive region (a clean interior
549    // minimum at `ρ_null ≈ −4`) and still `+∞` for `ρ_range ≤ −8`, where only
550    // the rank-1 null-space penalty is left on an 11-dimensional block and the
551    // problem really is unbounded. Along a recession direction in `null(S(λ))`
552    // the penalized Hessian `XᵀWX + S(λ)` loses definiteness — `W → 0` on the
553    // saturated rows and `dᵀS(λ)d = 0` — so `ModelIsIllConditioned` /
554    // `PirlsDidNotConverge` catch it, which is what the probit arm's remaining
555    // `+∞` cells ARE. Nothing is lost by not guessing.
556    if has_penalty {
557        return false;
558    }
559
560    // Separation-detection policy thresholds for the UNPENALIZED fit, where the
561    // MLE genuinely escapes to infinity under separation. Each is a heuristic
562    // cut-off, not a math identity.
563    //
564    // `ORDER_SEPARATION_ETA_GAP`: a strictly positive η-gap between the lowest
565    //   η among y=1 rows and the highest among y=0 rows means the two classes
566    //   are linearly separable on the linear predictor.
567    // `EXTREME_ETA`: |η| this large drives μ to within machine-ε of {0,1}.
568    // `SATURATION_FRACTION`: share of fitted μ pinned to the {0,1} boundary
569    //   that flags saturation.
570    // `DEGENERATE_DEVIANCE_PER_SAMPLE`: near-zero per-sample deviance means the
571    //   model fits the data perfectly.
572    // `EXTREME_BETA_NORM`: coefficient norm blow-up characteristic of the MLE
573    //   escaping to infinity under separation.
574    const ORDER_SEPARATION_ETA_GAP: f64 = 1e-3;
575    const EXTREME_ETA: f64 = 30.0;
576    const SATURATION_FRACTION: f64 = 0.98;
577    const DEGENERATE_DEVIANCE_PER_SAMPLE: f64 = 1e-3;
578    const EXTREME_BETA_NORM: f64 = 1e4;
579
580    let n = y.len() as f64;
581    if n == 0.0 {
582        return false;
583    }
584
585    let max_abs_eta = summary.max_abs_eta;
586    let sat_fraction = {
587        const SAT_EPS: f64 = 1e-3;
588        finalmu
589            .iter()
590            .filter(|&&m| m <= SAT_EPS || m >= 1.0 - SAT_EPS)
591            .count() as f64
592            / n
593    };
594
595    let beta_norm = summary.beta.as_ref().dot(summary.beta.as_ref()).sqrt();
596    let dev_per_sample = summary.state.deviance / n;
597
598    let mut has_pos = false;
599    let mut has_neg = false;
600    let mut min_eta_pos = f64::INFINITY;
601    let mut max_eta_neg = f64::NEG_INFINITY;
602    for (eta_i, &yi) in summary.state.eta.iter().zip(y.iter()) {
603        if yi > 0.5 {
604            has_pos = true;
605            if *eta_i < min_eta_pos {
606                min_eta_pos = *eta_i;
607            }
608        } else {
609            has_neg = true;
610            if *eta_i > max_eta_neg {
611                max_eta_neg = *eta_i;
612            }
613        }
614    }
615    let order_separated =
616        has_pos && has_neg && (min_eta_pos - max_eta_neg) > ORDER_SEPARATION_ETA_GAP;
617
618    let classic_signals = max_abs_eta > EXTREME_ETA
619        || sat_fraction > SATURATION_FRACTION
620        || dev_per_sample < DEGENERATE_DEVIANCE_PER_SAMPLE
621        || beta_norm > EXTREME_BETA_NORM;
622
623    classic_signals || order_separated
624}
625
626/// Stack λ-weighted penalty roots from canonical penalties into a single
627/// `total_rank × p` matrix for PIRLS. Each block-local root is embedded
628/// into the full column space on-the-fly.
629pub(super) fn stack_lambdaweighted_penalty_root_canonical(
630    penalties: &[gam_terms::construction::CanonicalPenalty],
631    lambdas: &[f64],
632    p: usize,
633) -> Array2<f64> {
634    let totalrows: usize = penalties.iter().map(|cp| cp.rank()).sum();
635    if totalrows == 0 {
636        return Array2::zeros((0, p));
637    }
638    let mut e = Array2::<f64>::zeros((totalrows, p));
639    let mut row_start = 0usize;
640    for (k, cp) in penalties.iter().enumerate() {
641        let rows = cp.rank();
642        if rows == 0 {
643            continue;
644        }
645        let scale = lambdas.get(k).copied().unwrap_or(0.0).max(0.0).sqrt();
646        if scale != 0.0 {
647            // Embed block-local root (rank × block_dim) into full width (rank × p).
648            let r = &cp.col_range;
649            for row in 0..rows {
650                for col in 0..cp.block_dim() {
651                    e[[row_start + row, r.start + col]] = scale * cp.root[[row, col]];
652                }
653            }
654        }
655        row_start += rows;
656    }
657    e
658}
659
660pub(super) fn build_sparse_native_reparam_result(
661    base: ReparamResult,
662    penalties: &[gam_terms::construction::CanonicalPenalty],
663    lambdas: &[f64],
664    p: usize,
665) -> ReparamResult {
666    // Map the engine penalty back into identity (original) coordinates. The
667    // The engine returns `s_transformed = Qsᵀ S Qs` (and
668    // `e_transformed = E Qs`). With sparse-native `qs = I`, round-trip that
669    // declared penalty to original coordinates so the inner solve, EDF, and
670    // REML logdet all use exactly the same matrix.
671    let qs = &base.qs;
672    let s_orig = if qs.nrows() == p && qs.ncols() == base.s_transformed.nrows() {
673        // S_orig = Qs · S_transformed · Qsᵀ
674        let qs_s = fast_ab(qs, &base.s_transformed);
675        qs_s.dot(&qs.t())
676    } else {
677        // Degenerate fallback (engine produced no transform): use the bare
678        // lambda-weighted sum. Shrinkage is zero in this branch by construction.
679        let mut s_original = Array2::<f64>::zeros((p, p));
680        for (k, cp) in penalties.iter().enumerate() {
681            let lambda_k = lambdas.get(k).copied().unwrap_or(0.0);
682            if lambda_k != 0.0 {
683                cp.accumulate_weighted(&mut s_original, lambda_k);
684            }
685        }
686        s_original
687    };
688    // E_orig = E_transformed · Qsᵀ  (so that E_origᵀ E_orig = S_orig and the EDF
689    // augmented system matches the inner Hessian).
690    let e_orig = if qs.nrows() == p && base.e_transformed.ncols() == qs.ncols() {
691        base.e_transformed.dot(&qs.t())
692    } else {
693        stack_lambdaweighted_penalty_root_canonical(penalties, lambdas, p)
694    };
695    let u_original = if base.u_truncated.nrows() == p {
696        fast_ab(&base.qs, &base.u_truncated)
697    } else {
698        Array2::<f64>::eye(p)
699    };
700    // In the sparse-native path, qs = I, so the penalties are already in the
701    // right coordinate frame. We keep them as-is in canonical_transformed.
702    let canonical_transformed: Vec<gam_terms::construction::CanonicalPenalty> = penalties.to_vec();
703    ReparamResult {
704        s_transformed: s_orig,
705        log_det: base.log_det,
706        det1: base.det1,
707        qs: Array2::<f64>::eye(p),
708        canonical_transformed,
709        e_transformed: e_orig,
710        u_truncated: u_original,
711    }
712}
713
714pub(super) fn build_diagonal_penalty_from_kronecker(
715    kron_result: &KroneckerReparamResult,
716    lambdas: &[f64],
717) -> PirlsPenalty {
718    let d = kron_result.marginal_dims.len();
719    let p: usize = kron_result.marginal_dims.iter().copied().product();
720    let mut diag = Array1::<f64>::zeros(p);
721    let mut positive_indices = Vec::new();
722
723    const KRONECKER_STRUCTURAL_ZERO_TOL: f64 = 1e-12;
724    let mut multi_idx = vec![0usize; d];
725    let mut flat = 0usize;
726    loop {
727        let mut sigma = 0.0;
728        let mut structural_sigma = 0.0;
729        for k in 0..d {
730            let marginal_eigenvalue = kron_result.marginal_eigenvalues[k][multi_idx[k]];
731            structural_sigma += marginal_eigenvalue;
732            sigma += lambdas[k] * marginal_eigenvalue;
733        }
734        let joint_null = structural_sigma <= KRONECKER_STRUCTURAL_ZERO_TOL;
735        if kron_result.has_double_penalty && lambdas.len() > d && joint_null {
736            sigma += lambdas[d];
737        }
738        diag[flat] = sigma;
739        if sigma > 0.0 {
740            positive_indices.push(flat);
741        }
742        flat += 1;
743
744        let mut carry = true;
745        for dim in (0..d).rev() {
746            if carry {
747                multi_idx[dim] += 1;
748                if multi_idx[dim] < kron_result.marginal_dims[dim] {
749                    carry = false;
750                } else {
751                    multi_idx[dim] = 0;
752                }
753            }
754        }
755        if carry {
756            break;
757        }
758    }
759
760    PirlsPenalty::Diagonal {
761        diag,
762        positive_indices,
763        linear_shift: Array1::zeros(p),
764        constant_shift: 0.0,
765        prior_mean_target: Array1::zeros(p),
766    }
767}
768
769pub(super) fn canonical_prior_shift(
770    penalties: &[gam_terms::construction::CanonicalPenalty],
771    lambdas: &[f64],
772    p: usize,
773) -> (Array1<f64>, f64) {
774    let mut linear = Array1::<f64>::zeros(p);
775    let mut constant = 0.0;
776    for (idx, cp) in penalties.iter().enumerate() {
777        let Some(&lambda) = lambdas.get(idx) else {
778            continue;
779        };
780        if lambda == 0.0 {
781            continue;
782        }
783        linear += &cp.prior_linear_shift(lambda);
784        constant += cp.prior_constant_shift(lambda);
785    }
786    (linear, constant)
787}
788
789/// Aggregate prior-mean target across canonical penalty blocks: the sum of
790/// each block's `full_width_prior_mean()`. Used by the PIRLS solve sites
791/// that add a fixed stabilization ridge `δI` to the penalized Hessian — they
792/// must also add `δ · prior_mean_target` to the RHS to keep `β = μ` recovery
793/// exact when the data carries no information (X'WX = 0). Equivalent to
794/// `canonical_prior_shift` with all λ = 1 and dropping `S_k` from the linear
795/// piece (i.e., raw μ rather than `S_k μ`). Returned in the *original*
796/// coordinates; callers transform if needed.
797pub(super) fn canonical_prior_mean_aggregate(
798    penalties: &[gam_terms::construction::CanonicalPenalty],
799    p: usize,
800) -> Array1<f64> {
801    let mut mean = Array1::<f64>::zeros(p);
802    for cp in penalties {
803        mean += &cp.full_width_prior_mean();
804    }
805    mean
806}
807
808pub struct PirlsProblem<'a, X> {
809    pub x: X,
810    pub offset: ArrayView1<'a, f64>,
811    pub y: ArrayView1<'a, f64>,
812    pub priorweights: ArrayView1<'a, f64>,
813    pub covariate_se: Option<ArrayView1<'a, f64>>,
814    /// When set, the inner PLS solver reuses the precomputed `XᵀWX` and
815    /// `XᵀW(y − offset)` in *original* coordinates instead of streaming the
816    /// O(N·p²) GEMM and the O(N·p) matvec on every outer REML iteration.
817    ///
818    /// Valid only when the family is Gaussian + Identity link, prior weights
819    /// are constant across outer iterations (always true in the REML outer
820    /// loop), no Firth bias reduction, and no inequality / lower-bound
821    /// constraints (matching the existing Identity short-circuit at
822    /// `pirls.rs:6237`). The penalty `λ·S` is still added per-λ on top of
823    /// the cached `XᵀWX`.
824    pub gaussian_fixed_cache: Option<&'a GaussianFixedCache>,
825    /// Frozen-weight first-Fisher-step data-fit Gram `XᵀWX` for a GLM
826    /// design-moving ψ-trial (#1111 / #1033 mechanism (c)), in *original*
827    /// (conditioned `x_fit`) coordinates. When set, the iterative GLM P-IRLS
828    /// serves its FIRST Fisher-scoring iteration's `XᵀWX` from this matrix
829    /// instead of streaming the O(N·p²) weighted cross-product; every later
830    /// iteration restreams the true moving `W`, so the converged β̂ is
831    /// unchanged. Mutually distinct from `gaussian_fixed_cache` (which is the
832    /// Gaussian-identity converged-objective short-circuit); this is the GLM
833    /// first-step lane and never short-circuits the iteration count.
834    pub glm_first_step_gram: Option<&'a Array2<f64>>,
835}
836
837// GaussianFixedCache is defined in pls_solver.
838pub use super::pls_solver::GaussianFixedCache;
839
840pub struct PenaltyConfig<'a> {
841    /// Block-local canonical penalties with precomputed roots and spectral data.
842    /// This is the single canonical penalty representation — no full-width
843    /// `rank × p` roots are stored. When the reparameterization engine needs
844    /// full-width roots, they are derived on-the-fly from these block-local roots.
845    pub canonical_penalties: &'a [gam_terms::construction::CanonicalPenalty],
846    pub balanced_penalty_root: Option<&'a Array2<f64>>,
847    pub reparam_invariant: Option<&'a gam_terms::construction::ReparamInvariant>,
848    pub p: usize,
849    pub coefficient_lower_bounds: Option<&'a Array1<f64>>,
850    pub linear_constraints_original: Option<&'a LinearInequalityConstraints>,
851    /// When set, the penalties have Kronecker (tensor-product) structure.
852    /// The reparameterization engine will use factored Qs = U_1 ⊗ ... ⊗ U_d
853    /// instead of eigendecomposing the full p×p balanced penalty.
854    pub kronecker_factored: Option<&'a gam_terms::basis::KroneckerFactoredBasis>,
855}
856
857/// P-IRLS solver that follows mgcv's architecture exactly
858///
859/// This function implements the complete algorithm from mgcv's gam.fit3 function
860/// for fitting a GAM model with a fixed set of smoothing parameters:
861///
862/// - Perform stable reparameterization ONCE at the beginning (mgcv's gam.reparam)
863/// - Transform the design matrix into this stable basis
864/// - Extract a single penalty square root from the transformed penalty
865/// - Run the P-IRLS loop entirely in the transformed basis
866/// - Transform the coefficients back to the original basis only when returning
867/// - Reuse a cached balanced penalty root when available to avoid repeated eigendecompositions
868///
869/// This architecture ensures optimal numerical stability throughout the entire
870/// fitting process by working in a well-conditioned parameter space.
871pub fn fit_model_for_fixed_rho<'a, X: Into<DesignMatrix> + Clone>(
872    rho: LogSmoothingParamsView<'_>,
873    problem: PirlsProblem<'a, X>,
874    penalty: PenaltyConfig<'_>,
875    config: &PirlsConfig,
876    warm_start_beta: Option<&Coefficients>,
877) -> Result<(PirlsResult, WorkingModelPirlsResult), EstimationError> {
878    fit_model_for_fixed_rho_with_adaptive_kkt(
879        rho,
880        problem,
881        penalty,
882        config,
883        warm_start_beta,
884        None,
885        false,
886        None,
887    )
888}
889
890/// `refine_dispersion_at_converged_eta`: when `true`, after the inner P-IRLS
891/// solve converges, re-estimate the family's estimated dispersion nuisance — the
892/// Gamma shape ν = 1/φ or the Beta precision φ — at the *converged* linear
893/// predictor and iterate the (β, dispersion) pair to its joint fixed point at the
894/// current λ (see the in-body comments at each refresh loop). This is ON only for
895/// the single final, reported fit at the REML-selected λ (#678 for Gamma, #769
896/// for Beta). It is deliberately OFF for every REML cost / sigma-point evaluation:
897/// re-profiling the dispersion against each trial λ's converged residuals would
898/// couple the scale to the smoothing parameter (a flat over-smoothed μ inflates
899/// the deviance ⇒ a smaller effective precision ⇒ a smaller `deviance/(2φ)` REML
900/// term), perversely rewarding over-smoothing and biasing λ selection. mgcv
901/// likewise estimates the scale at the converged fit, not inside the λ search.
902///
903/// The Gamma and Beta cases differ in what the re-solve buys. For Gamma the shape
904/// is a pure nuisance — β̂ is essentially scale-free — so the re-solve only keeps
905/// the reported dispersion and SEs self-consistent. For Beta the precision φ
906/// enters the *mean* score through the digamma terms
907/// `μ*ᵢ = ψ(μᵢφ) − ψ((1−μᵢ)φ)`, so a φ measured at the cold null predictor
908/// (μ ≈ 0.5) attenuates every slope toward zero; here the fixed point is
909/// load-bearing — it is what recovers the correct mean coefficients (the betareg
910/// alternating mean-fit ↔ φ-estimate scheme).
911pub(crate) fn fit_model_for_fixed_rho_with_adaptive_kkt<'a, X: Into<DesignMatrix> + Clone>(
912    rho: LogSmoothingParamsView<'_>,
913    problem: PirlsProblem<'a, X>,
914    penalty: PenaltyConfig<'_>,
915    config: &PirlsConfig,
916    warm_start_beta: Option<&Coefficients>,
917    adaptive_kkt_tolerance: Option<AdaptiveKktTolerance>,
918    refine_dispersion_at_converged_eta: bool,
919    // Shared invariant row carrier for a Gaussian value-only evaluation.
920    //
921    // `Some` requests sufficient-statistic-only result synthesis: beta,
922    // deviance, gradient, and curvature remain exact, while observation-scale
923    // fields share these placeholders instead of recomputing `X beta`.
924    // Full gradients and accepted fits always pass `None`.
925    cost_only_gaussian_rows: Option<&Arc<GaussianFrozenRows>>,
926) -> Result<(PirlsResult, WorkingModelPirlsResult), EstimationError> {
927    let PirlsProblem {
928        x,
929        offset,
930        y,
931        priorweights,
932        covariate_se,
933        gaussian_fixed_cache,
934        glm_first_step_gram,
935    } = problem;
936    let quadctx = crate::quadrature::QuadratureContext::new();
937    let lambdas = exact_lambdas_from_rho(rho);
938    let lambdas_slice = lambdas.as_slice_memory_order().ok_or_else(|| {
939        EstimationError::InvalidInput("non-contiguous lambda storage".to_string())
940    })?;
941
942    let likelihood = &config.likelihood;
943    // Resolve family and scalar ownership once at the fit boundary. This makes
944    // malformed family/metadata pairs fail before either the CPU or GPU path
945    // can interpret an absent scalar as a unit value.
946    let resolved_likelihood_scale = likelihood
947        .resolved_scale()
948        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
949    let link_function = config.link_function();
950
951    use gam_terms::construction::{EngineDims, stable_reparameterization_engine_canonical};
952
953    // Build a cheap weighted penalty sum for the sparse-native decision
954    // WITHOUT running the expensive eigendecomposition engine.
955    // The full reparameterization is deferred until we know which path we need.
956    let cheap_s_lambda: Option<Array2<f64>> = if penalty.kronecker_factored.is_none() {
957        let mut s = Array2::<f64>::zeros((penalty.p, penalty.p));
958        for (k, cp) in penalty.canonical_penalties.iter().enumerate() {
959            let lam = lambdas_slice.get(k).copied().unwrap_or(0.0);
960            if lam != 0.0 {
961                cp.accumulate_weighted(&mut s, lam);
962            }
963        }
964        Some(s)
965    } else {
966        None
967    };
968    let kronecker_runtime = if let Some(kron) = penalty.kronecker_factored {
969        // The marginal eigensystems and reparameterized marginals depend only on
970        // the fixed marginal designs/penalties, not on λ = exp(ρ). Memoize them
971        // once per fit so each outer REML iterate reuses the eigendecomposition
972        // instead of recomputing `eigh()` + `B_k·U_k` every call; only the cheap
973        // λ-grid logdet/derivative sweep is redone here. Bit-identical to the
974        // unmemoized engine.
975        let invariant = kron.invariant_structure()?;
976        let kron_result =
977            gam_terms::construction::kronecker_reparameterization_engine_with_invariant(
978                invariant.as_ref(),
979                &kron.marginal_dims,
980                lambdas_slice,
981                kron.has_double_penalty,
982            )?;
983        let transform = Arc::new(KroneckerQsTransform::new(&kron_result));
984        let penalty_diag = build_diagonal_penalty_from_kronecker(&kron_result, lambdas_slice);
985        Some((kron_result, transform, penalty_diag))
986    } else {
987        None
988    };
989    // Constraint transformation is deferred until after the sparse-native
990    // decision, because the dense reparameterization engine (which provides Qs)
991    // is now run lazily.  Kronecker constraints can be built eagerly since
992    // the Kronecker transform is already available.
993    let kronecker_constraints = if let Some((_, transform, _)) = kronecker_runtime.as_ref() {
994        let tb = build_transformed_lower_bound_constraints_with_transform(
995            &WorkingReparamTransform::Kronecker(Arc::clone(transform)),
996            penalty.coefficient_lower_bounds,
997        );
998        let tl = build_transformed_linear_constraints_with_transform(
999            &WorkingReparamTransform::Kronecker(Arc::clone(transform)),
1000            penalty.linear_constraints_original,
1001        );
1002        Some(merge_linear_constraints(tb, tl))
1003    } else {
1004        None
1005    };
1006
1007    let x_original: DesignMatrix = x.into();
1008    // Auto-detect sparse structure in dense designs so the sparse-native path
1009    // can engage for structurally sparse models that happen to be stored dense.
1010    //
1011    // A Gaussian value-only probe already owns the exact dense coefficient
1012    // statistics consumed by its solve. Scanning all design rows here to
1013    // rediscover a sparse representation cannot change that solve and would
1014    // make every rho candidate O(n) before the sufficient-statistic lane even
1015    // begins (#2435).
1016    let x_original = if cost_only_gaussian_rows.is_some() {
1017        x_original
1018    } else {
1019        let auto_sparse = x_original
1020            .as_dense()
1021            .and_then(|dense| sparse_from_denseview(dense.view()));
1022        auto_sparse.unwrap_or(x_original)
1023    };
1024    // A value-only Gaussian probe is already represented completely by its
1025    // coefficient-space sufficient statistics and shared frozen row carrier.
1026    // It exits through the exact zero-iteration branch below, so constructing
1027    // the general workspace with n rows would allocate five length-n scratch
1028    // vectors that no consumer reads (#2435). Full gradients, final fits, and
1029    // every iterative family retain the ordinary observation workspace.
1030    let mut workspace = if cost_only_gaussian_rows.is_some() {
1031        PirlsWorkspace::coefficient_only(x_original.ncols())
1032    } else {
1033        PirlsWorkspace::new(x_original.nrows(), x_original.ncols())
1034    };
1035    let solver_decision = if cost_only_gaussian_rows.is_some() {
1036        SparsePirlsDecision {
1037            path: PirlsLinearSolvePath::DenseTransformed,
1038            reason: "gaussian_sufficient_statistics",
1039            p: x_original.ncols(),
1040            nnz_x: 0,
1041            nnz_xtwx_symbolic: None,
1042            nnz_s_lambda: 0,
1043            nnz_h_est: None,
1044            density_h_est: None,
1045        }
1046    } else if let Some((_, _, _)) = kronecker_runtime.as_ref() {
1047        SparsePirlsDecision {
1048            path: PirlsLinearSolvePath::DenseTransformed,
1049            reason: "kronecker_runtime",
1050            p: x_original.ncols(),
1051            nnz_x: 0,
1052            nnz_xtwx_symbolic: None,
1053            nnz_s_lambda: 0,
1054            nnz_h_est: None,
1055            density_h_est: None,
1056        }
1057    } else {
1058        should_use_sparse_native_pirls(
1059            &mut workspace,
1060            &x_original,
1061            cheap_s_lambda
1062                .as_ref()
1063                .expect("cheap_s_lambda should be present outside Kronecker path"),
1064            penalty.coefficient_lower_bounds,
1065            penalty.linear_constraints_original,
1066        )
1067    };
1068    solver_decision.log_once();
1069
1070    let use_sparse_native = matches!(solver_decision.path, PirlsLinearSolvePath::SparseNative);
1071
1072    // Run the eigendecomposition engine for the dense-transformed path. The
1073    // sparse-native path also needs it, but only to obtain a penalty that is
1074    // *consistent with the REML penalty log-determinant it reports* — see the
1075    // sparse-native `reparam` below. The dense path keeps `qs ≠ I`; the
1076    // sparse-native path discards `qs` (identity coords) and reuses only the
1077    // declared `s_transformed`/`e_transformed`.
1078    let dense_reparam_result = if !use_sparse_native && penalty.kronecker_factored.is_none() {
1079        Some(stable_reparameterization_engine_canonical(
1080            penalty.canonical_penalties,
1081            lambdas_slice,
1082            EngineDims::new(penalty.p, penalty.canonical_penalties.len()),
1083            penalty.reparam_invariant,
1084        )?)
1085    } else {
1086        None
1087    };
1088    // Sparse-native reparameterization in identity (original) coordinates.
1089    // Reusing the engine's declared penalty keeps all backends on the same
1090    // penalized objective.
1091    let sparse_native_reparam = if use_sparse_native && penalty.kronecker_factored.is_none() {
1092        let base = stable_reparameterization_engine_canonical(
1093            penalty.canonical_penalties,
1094            lambdas_slice,
1095            EngineDims::new(penalty.p, penalty.canonical_penalties.len()),
1096            penalty.reparam_invariant,
1097        )?;
1098        Some(build_sparse_native_reparam_result(
1099            base,
1100            penalty.canonical_penalties,
1101            lambdas_slice,
1102            penalty.p,
1103        ))
1104    } else {
1105        None
1106    };
1107    let qs_arc = dense_reparam_result
1108        .as_ref()
1109        .map(|reparam_result| Arc::new(reparam_result.qs.clone()));
1110    let transform_active = if let Some((_, transform, _)) = kronecker_runtime.as_ref() {
1111        Some(WorkingReparamTransform::Kronecker(Arc::clone(transform)))
1112    } else if use_sparse_native {
1113        None
1114    } else {
1115        Some(WorkingReparamTransform::Dense(Arc::clone(
1116            qs_arc
1117                .as_ref()
1118                .expect("dense Qs should exist for non-Kronecker transformed path"),
1119        )))
1120    };
1121    let mut penalty_active = if let Some((_, _, penalty_diag)) = kronecker_runtime.as_ref() {
1122        penalty_diag.clone()
1123    } else if use_sparse_native {
1124        // Sparse-native inner penalty in original (identity) coordinates. Use
1125        // the reparameterized declared root and Gram so `H = XᵀWX + S` matches
1126        // the penalty whose log-determinant REML reports.
1127        let sparse_reparam = sparse_native_reparam
1128            .as_ref()
1129            .expect("sparse_native_reparam should be present for sparse-native path");
1130        PirlsPenalty::Dense {
1131            s_transformed: sparse_reparam.s_transformed.clone(),
1132            e_transformed: sparse_reparam.e_transformed.clone(),
1133            linear_shift: Array1::zeros(penalty.p),
1134            constant_shift: 0.0,
1135            prior_mean_target: Array1::zeros(penalty.p),
1136        }
1137    } else {
1138        let dense = dense_reparam_result
1139            .as_ref()
1140            .expect("dense reparam result should be present outside Kronecker path");
1141        PirlsPenalty::Dense {
1142            s_transformed: dense.s_transformed.clone(),
1143            e_transformed: dense.e_transformed.clone(),
1144            linear_shift: Array1::zeros(penalty.p),
1145            constant_shift: 0.0,
1146            prior_mean_target: Array1::zeros(penalty.p),
1147        }
1148    };
1149    let (shift_original, shift_constant) =
1150        canonical_prior_shift(penalty.canonical_penalties, lambdas_slice, penalty.p);
1151    let shift_active = transform_active
1152        .as_ref()
1153        .map(|transform| transform.apply_transpose(&shift_original))
1154        .unwrap_or(shift_original);
1155    let prior_mean_original =
1156        canonical_prior_mean_aggregate(penalty.canonical_penalties, penalty.p);
1157    let prior_mean_active = transform_active
1158        .as_ref()
1159        .map(|transform| transform.apply_transpose(&prior_mean_original))
1160        .unwrap_or(prior_mean_original);
1161    attach_penalty_shift(
1162        &mut penalty_active,
1163        shift_active,
1164        shift_constant,
1165        prior_mean_active,
1166    );
1167    // Build transformed constraints now that dense_reparam_result is available.
1168    let linear_constraints = if let Some(kc) = kronecker_constraints {
1169        kc
1170    } else if let Some(reparam) = dense_reparam_result.as_ref() {
1171        let tb = build_transformed_lower_bound_constraints(
1172            &reparam.qs,
1173            penalty.coefficient_lower_bounds,
1174        );
1175        let tl =
1176            build_transformed_linear_constraints(&reparam.qs, penalty.linear_constraints_original);
1177        merge_linear_constraints(tb, tl)
1178    } else {
1179        // Sparse-native without dense reparam: constraints stay in original
1180        // coordinates (identity Qs).  Use an identity matrix of appropriate size.
1181        let p = penalty.p;
1182        let qs_identity = Array2::<f64>::eye(p);
1183        let tb = build_transformed_lower_bound_constraints(
1184            &qs_identity,
1185            penalty.coefficient_lower_bounds,
1186        );
1187        let tl =
1188            build_transformed_linear_constraints(&qs_identity, penalty.linear_constraints_original);
1189        merge_linear_constraints(tb, tl)
1190    };
1191
1192    let coordinate_frame = if use_sparse_native {
1193        PirlsCoordinateFrame::OriginalSparseNative
1194    } else {
1195        PirlsCoordinateFrame::TransformedQs
1196    };
1197    let materialize_final_reparam_result = || -> Result<ReparamResult, EstimationError> {
1198        if let Some((kron_result, _, _)) = kronecker_runtime.as_ref() {
1199            let rs_list: Vec<Array2<f64>> = penalty
1200                .canonical_penalties
1201                .iter()
1202                .map(|cp| cp.full_width_root())
1203                .collect();
1204            kron_result.materialize_dense_artifact_result(&rs_list, lambdas_slice, penalty.p)
1205        } else if use_sparse_native {
1206            // Sparse-native path: reuse the engine result already computed for
1207            // `penalty_active` (with the shrinkage floor folded in and mapped to
1208            // identity coordinates). This is both correct — the REML
1209            // log-determinant now matches the penalty the inner solve used — and
1210            // cheaper, since the eigendecomposition is no longer run twice.
1211            Ok(sparse_native_reparam
1212                .as_ref()
1213                .expect("sparse_native_reparam should be present for sparse-native path")
1214                .clone())
1215        } else {
1216            Ok(dense_reparam_result
1217                .as_ref()
1218                .expect("dense reparam result should be present outside Kronecker path")
1219                .clone())
1220        }
1221    };
1222
1223    // Stage 3.3-GI: GPU exact PLS dispatch — see pirls_host_dispatch::try_gaussian_pls_gpu.
1224    if let Some(result) = try_gaussian_pls_gpu(
1225        link_function,
1226        config,
1227        penalty.coefficient_lower_bounds,
1228        penalty.linear_constraints_original,
1229        gaussian_fixed_cache,
1230        &penalty_active,
1231        &qs_arc,
1232        &x_original,
1233        use_sparse_native,
1234        penalty.p,
1235        || materialize_final_reparam_result(),
1236        y,
1237        priorweights,
1238        offset,
1239        coordinate_frame,
1240        &linear_constraints,
1241        // gh#2544: this dispatch pre-empts the Gaussian-Identity zero-iteration
1242        // branch below on the same admission predicate, so the #1868 frozen-row
1243        // bundle has to reach it too — otherwise the optimisation is live on the
1244        // branch that never runs and dead on the one that does.
1245        cost_only_gaussian_rows,
1246    ) {
1247        return result;
1248    }
1249
1250    if matches!(link_function, LinkFunction::Identity) && linear_constraints.is_none() {
1251        // Gaussian-Identity zero-iteration exact solve. The unconstrained
1252        // penalized least-squares system is linear, so for an identity link a
1253        // single solve is the exact minimizer and no PIRLS iteration is needed.
1254        //
1255        // This shortcut is only valid in the *unconstrained* convex program.
1256        // When shape/box/linear inequality constraints are present (e.g. a
1257        // `shape=monotone_increasing` smooth, whose cumulative-sum box-reparam
1258        // bounds `γ_j ≥ 0` are folded into `linear_constraints` above), the
1259        // minimizer is the solution of an inequality-constrained QP, not the
1260        // plain normal-equations solve. Taking this branch then returns the
1261        // unconstrained β, which generically violates the constraints and is
1262        // rejected by the REML startup KKT gate (`enforce_constraint_kkt`),
1263        // aborting the whole fit. Gating on `linear_constraints.is_none()`
1264        // routes every constrained Identity fit to the iterative loop below,
1265        // which builds a feasible initial point and solves the exact QP via
1266        // the active-set solver — mirroring the gate already enforced on the
1267        // GPU Gaussian-PLS path in `try_gaussian_pls_gpu`.
1268        //
1269        // Apply the Gaussian-Identity fixed-data cache only when every
1270        // precondition for the short-circuit's exact reuse holds: the family
1271        // really is Gaussian (z = y), there is no Firth bias-reduction term,
1272        // no coefficient lower bounds, and no linear inequality constraints
1273        // — anything that would change the right-hand side or the system
1274        // beyond the additive penalty would invalidate the cache.
1275        let cache_eligible = gaussian_fixed_cache.is_some()
1276            && likelihood.spec.is_gaussian_identity()
1277            && !config.firth_bias_reduction
1278            && penalty.coefficient_lower_bounds.is_none()
1279            && penalty.linear_constraints_original.is_none();
1280        let cache_for_solve = if cache_eligible {
1281            gaussian_fixed_cache
1282        } else {
1283            None
1284        };
1285        let (pls_result, _) = solve_penalized_least_squares_implicit(
1286            &x_original,
1287            transform_active.as_ref(),
1288            y,
1289            priorweights,
1290            offset,
1291            &penalty_active,
1292            &mut workspace,
1293            y,
1294            link_function,
1295            cache_for_solve,
1296        )?;
1297
1298        let beta_transformed = pls_result.beta;
1299        let penalized_hessian = pls_result.penalized_hessian;
1300        let edf = pls_result.edf;
1301        let baseridge = pls_result.ridge_used;
1302
1303        // eta = offset + X Qs beta (composed, no materialization) unless a
1304        // design-moving ψ tensor cache explicitly says the surface rows are a
1305        // stale reference. In that lane the Gaussian objective and gradient are
1306        // fully determined by (G, r, y'Wy), so applying `x_original` would both
1307        // reintroduce per-trial row work and evaluate the wrong ψ.
1308        let qbeta = transform_active
1309            .as_ref()
1310            .map(|transform| transform.apply(beta_transformed.as_ref()))
1311            .unwrap_or_else(|| beta_transformed.as_ref().clone());
1312        let sufficient_only_row_cache = cache_for_solve.filter(|cache| {
1313            cache.row_prediction_is_stale || cost_only_gaussian_rows.is_some()
1314        });
1315
1316        // #1868: all length-`n` row arrays of the zero-iteration synthesis,
1317        // collected in one place so the skip path can SHARE them O(1) from the
1318        // once-built frozen bundle (zero row touches) while the exact path builds
1319        // them owned and moves them into the shared `ArcArray1` representation
1320        // via `.into_shared()` (O(1), no element copy).
1321        struct ZeroIterRows {
1322            final_offset: ArcArray1<f64>,
1323            final_eta: ArcArray1<f64>,
1324            finalmu: ArcArray1<f64>,
1325            finalz: ArcArray1<f64>,
1326            finalweights: ArcArray1<f64>,
1327            solve_dmu_deta: ArcArray1<f64>,
1328            solve_d2mu_deta2: ArcArray1<f64>,
1329            solve_d3mu_deta3: ArcArray1<f64>,
1330            solve_c_array: ArcArray1<f64>,
1331            solve_d_array: ArcArray1<f64>,
1332            /// Working-state η. Empty on the skip path (the stale rows are never
1333            /// read on the n-free κ criterion path, so it is not materialised —
1334            /// keeping the callback O(1)); the freshly-realised η on the exact
1335            /// path.
1336            working_eta: LinearPredictor,
1337            gradient_data: Array1<f64>,
1338            deviance: f64,
1339            log_likelihood: f64,
1340            max_abs_eta: f64,
1341        }
1342
1343        let rows = if let Some(cache) = sufficient_only_row_cache {
1344            // #1868 FAST PATH: the criterion, gradient and inner solve are served
1345            // entirely from k-space Gram sufficient statistics; the length-`n`
1346            // row arrays are trial-invariant placeholders (η≡μ≡offset, z≡y,
1347            // w≡priorweights, constant Gaussian working-weight derivatives). When
1348            // the producer attached the once-built frozen bundle we clone its
1349            // `ArcArray1` handles (O(1), zero element touches) instead of
1350            // re-materialising ~16·n elements per κ callback — the #1868 fix.
1351            let mut grad_orig = cache.xtwx_orig.dot(&qbeta);
1352            grad_orig -= &cache.xtwy_orig;
1353            // #2624: `z^T W z - 2 qb^T b + qb^T G qb` regrouped as
1354            // `(z^T W z - qb^T b) + qb^T (G qb - b)`. The two are the same
1355            // number in exact arithmetic; they are not the same computation.
1356            // `G qb - b` is formed elementwise above and equals `-S beta` at the
1357            // inner mode, so the second contraction is over SMALL entries and
1358            // its absolute error is negligible -- which leaves exactly one
1359            // contraction at the `z^T W z` magnitude for `compensated_dot` to
1360            // resolve. It also drops a matvec, so it is cheaper than the
1361            // spelling it replaces. See `compensated_dot` for the measured size
1362            // of what this buys, which is much smaller than the claim it was
1363            // landed under.
1364            let residual_inner = qbeta.dot(&grad_orig);
1365            let gradient_data = transform_active
1366                .as_ref()
1367                .map(|transform| transform.apply_transpose(&grad_orig))
1368                .unwrap_or(grad_orig);
1369            let weighted_rss = (cache.centered_weighted_y_sq
1370                - compensated_dot(&qbeta, &cache.xtwy_orig)
1371                + residual_inner)
1372                .max(0.0);
1373            match resolved_likelihood_scale {
1374                ResolvedLikelihoodScale::ProfiledGaussian
1375                | ResolvedLikelihoodScale::FixedGaussian { .. } => {}
1376                other => {
1377                    return Err(EstimationError::InvalidInput(format!(
1378                        "Gaussian identity cache received non-Gaussian resolved scale {other:?}"
1379                    )));
1380                }
1381            }
1382            // Conventional Gaussian deviance is raw weighted RSS for both a
1383            // profiled and a fixed dispersion. Scale enters the fixed
1384            // likelihood kernel and working curvature, never this reporting
1385            // statistic.
1386            let deviance = weighted_rss;
1387
1388            if let Some(bundle) = cost_only_gaussian_rows.or(cache.frozen_rows.as_ref()) {
1389                // Zero length-`n` touches: every row array is an O(1) Arc clone
1390                // of the shared frozen bundle (η≡μ≡offset via `bundle.eta`).
1391                ZeroIterRows {
1392                    final_offset: bundle.eta.clone(),
1393                    final_eta: bundle.eta.clone(),
1394                    finalmu: bundle.eta.clone(),
1395                    finalz: bundle.z.clone(),
1396                    finalweights: bundle.weights.clone(),
1397                    solve_dmu_deta: bundle.solve_dmu_deta.clone(),
1398                    solve_d2mu_deta2: bundle.solve_d2mu_deta2.clone(),
1399                    solve_d3mu_deta3: bundle.solve_d3mu_deta3.clone(),
1400                    solve_c_array: bundle.solve_c_array.clone(),
1401                    solve_d_array: bundle.solve_d_array.clone(),
1402                    working_eta: LinearPredictor::new(Array1::zeros(0)),
1403                    gradient_data,
1404                    deviance,
1405                    log_likelihood: bundle.log_likelihood,
1406                    max_abs_eta: bundle.max_abs_eta,
1407                }
1408            } else {
1409                // No bundle attached (producer could not build it): fall back to
1410                // the correct-but-O(n) re-materialisation so the fit is never
1411                // wrong. Counted so the deterministic gate still sees this work.
1412                let n_rows = offset.len();
1413                record_nfree_skip_row_touches(11 * n_rows);
1414                let final_eta = offset.to_owned();
1415                let finalmu = final_eta.clone();
1416                let priorweights_owned = priorweights.to_owned();
1417                let (c, d, dmu_deta, d2mu_deta2, d3mu_deta3) =
1418                    computeworkingweight_derivatives_from_eta(
1419                        &config.likelihood,
1420                        &config.link_kind,
1421                        &final_eta,
1422                        priorweights_owned.view(),
1423                    )?;
1424                let log_likelihood = pirls_data_log_kernel_from_eta(
1425                    y,
1426                    &final_eta,
1427                    likelihood,
1428                    &config.link_kind,
1429                    priorweights,
1430                    deviance,
1431                )?;
1432                let max_abs_eta = inf_norm(finalmu.iter().copied());
1433                ZeroIterRows {
1434                    final_offset: offset.to_owned().into_shared(),
1435                    final_eta: final_eta.into_shared(),
1436                    finalmu: finalmu.into_shared(),
1437                    finalz: y.to_owned().into_shared(),
1438                    finalweights: priorweights_owned.into_shared(),
1439                    solve_dmu_deta: dmu_deta.into_shared(),
1440                    solve_d2mu_deta2: d2mu_deta2.into_shared(),
1441                    solve_d3mu_deta3: d3mu_deta3.into_shared(),
1442                    solve_c_array: c.into_shared(),
1443                    solve_d_array: d.into_shared(),
1444                    working_eta: LinearPredictor::new(Array1::zeros(0)),
1445                    gradient_data,
1446                    deviance,
1447                    log_likelihood,
1448                    max_abs_eta,
1449                }
1450            }
1451        } else {
1452            // EXACT path: rows are freshly realised from the (non-stale) design.
1453            // Legitimately O(n) — this is the one-off final assembly / a
1454            // non-tensor trial, not a per-callback n-free skip.
1455            let priorweights_owned = priorweights.to_owned();
1456            let mut eta = offset.to_owned();
1457            eta += &x_original.apply(&qbeta);
1458            let final_eta = eta.clone();
1459            let finalmu = eta;
1460
1461            let mut weighted_residual = finalmu.clone();
1462            weighted_residual -= &y;
1463            weighted_residual *= &priorweights_owned;
1464            // gradient = Qs^T X^T (w * residual) (composed)
1465            let xt_wr = x_original.apply_transpose(&weighted_residual);
1466            let gradient_data = transform_active
1467                .as_ref()
1468                .map(|transform| transform.apply_transpose(&xt_wr))
1469                .unwrap_or(xt_wr);
1470            let deviance = calculate_deviance_from_eta(
1471                y,
1472                &final_eta,
1473                likelihood,
1474                &config.link_kind,
1475                priorweights,
1476            )?;
1477            let log_likelihood = pirls_data_log_kernel_from_eta(
1478                y,
1479                &final_eta,
1480                likelihood,
1481                &config.link_kind,
1482                priorweights,
1483                deviance,
1484            )?;
1485            let max_abs_eta = inf_norm(finalmu.iter().copied());
1486            let (c, d, dmu_deta, d2mu_deta2, d3mu_deta3) =
1487                computeworkingweight_derivatives_from_eta(
1488                    &config.likelihood,
1489                    &config.link_kind,
1490                    &final_eta,
1491                    priorweights_owned.view(),
1492                )?;
1493            ZeroIterRows {
1494                final_offset: offset.to_owned().into_shared(),
1495                working_eta: LinearPredictor::new(finalmu.clone()),
1496                final_eta: final_eta.into_shared(),
1497                finalmu: finalmu.into_shared(),
1498                finalz: y.to_owned().into_shared(),
1499                finalweights: priorweights_owned.into_shared(),
1500                solve_dmu_deta: dmu_deta.into_shared(),
1501                solve_d2mu_deta2: d2mu_deta2.into_shared(),
1502                solve_d3mu_deta3: d3mu_deta3.into_shared(),
1503                solve_c_array: c.into_shared(),
1504                solve_d_array: d.into_shared(),
1505                gradient_data,
1506                deviance,
1507                log_likelihood,
1508                max_abs_eta,
1509            }
1510        };
1511        let ZeroIterRows {
1512            final_offset,
1513            final_eta,
1514            finalmu,
1515            finalz,
1516            finalweights,
1517            solve_dmu_deta,
1518            solve_d2mu_deta2,
1519            solve_d3mu_deta3,
1520            solve_c_array,
1521            solve_d_array,
1522            working_eta,
1523            gradient_data,
1524            deviance,
1525            log_likelihood,
1526            max_abs_eta,
1527        } = rows;
1528        let score_norm = array1_l2_norm(&gradient_data);
1529        let s_beta = penalty_active.shifted_gradient(beta_transformed.as_ref());
1530        let s_beta_norm = array1_l2_norm(&s_beta);
1531        let mut gradient = gradient_data;
1532        gradient += &s_beta;
1533        let mut penalty_term = penalty_active.shifted_quadratic(beta_transformed.as_ref());
1534        let ridge_used = baseridge;
1535        // ONE OWNER FOR δ, AND IT IS THE ASSEMBLER.
1536        //
1537        // `solve_penalized_least_squares_implicit` folds `ridge_used` into the
1538        // diagonal of the matrix it returns, on BOTH of its branches: the dense
1539        // branch adds it in place before factorizing, and the sparse branch
1540        // asks `assemble_sparse_penalized_hessian` for `H = XᵀWX + S_λ + δI`.
1541        // So `penalized_hessian` ALREADY carries δ and this seam must not add
1542        // it a second time.
1543        //
1544        // This used to be `penalized_hessian.addridge(ridge_used)`. It was
1545        // masked, not correct: the sparse branch reported `ridge_used = 0.0`
1546        // while handing back a matrix that carried δ = 1e-8 (its assembler
1547        // closure rewrote a requested 0 into `FIXED_STABILIZATION_RIDGE`), so
1548        // the `> 0.0` guard skipped the second addition. Making the reported
1549        // ridge equal the applied ridge — the point of the #2519 repair —
1550        // unmasks it, and every sparse fit would silently get `H + 2δ` in
1551        // `0.5·log|H|` while `penalty_term` and the gradient carried only one δ.
1552        //
1553        // The finalization seam of the ITERATED path already states this
1554        // contract for the same reason ("P-IRLS already folded any
1555        // stabilization ridge directly into the Hessian. Keep that exact matrix
1556        // so outer LAML derivatives stay consistent"), and
1557        // `gam_working_model::update` is the assembler that folds it there. The
1558        // zero-iteration synthesis now matches: `penalized_hessian ==
1559        // stabilizedhessian == XᵀWX + S_λ + δI`.
1560        //
1561        // The objective bookkeeping below is NOT double counting: `δ‖β‖²` in
1562        // `penalty_term` and `δβ` in the gradient are the scalar/vector
1563        // companions of the δI already in the matrix, exactly as
1564        // `gam_working_model::update` adds them alongside its in-place ridge.
1565        let stabilizedhessian = penalized_hessian.clone();
1566        let mut ridge_grad_norm = 0.0;
1567        if ridge_used > 0.0 {
1568            let ridge_penalty =
1569                ridge_used * beta_transformed.as_ref().dot(beta_transformed.as_ref());
1570            penalty_term += ridge_penalty;
1571            gradient += &beta_transformed.as_ref().mapv(|v| ridge_used * v);
1572            ridge_grad_norm = ridge_used * array1_l2_norm(beta_transformed.as_ref());
1573        }
1574
1575        let gradient_norm = array1_l2_norm(&gradient);
1576        let working_state = WorkingState {
1577            eta: working_eta,
1578            gradient: gradient.clone(),
1579            hessian: penalized_hessian.clone(),
1580
1581            log_likelihood,
1582            deviance,
1583            penalty_term,
1584            firth: FirthDiagnostics::Inactive,
1585            ridge_used,
1586            hessian_curvature: HessianCurvatureKind::Fisher,
1587            gradient_natural_scale: score_norm + s_beta_norm + ridge_grad_norm,
1588        };
1589
1590        let zero_iter_penalized = deviance + penalty_term;
1591        let working_summary = WorkingModelPirlsResult {
1592            beta: beta_transformed.clone(),
1593            state: working_state,
1594            status: PirlsStatus::Converged,
1595            iterations: 1,
1596            lastgradient_norm: gradient_norm,
1597            last_deviance_change: 0.0,
1598            last_step_size: 1.0,
1599            last_step_halving: 0,
1600            max_abs_eta,
1601            constraint_kkt: linear_constraints.as_ref().map(|lin| {
1602                compute_constraint_kkt_diagnostics(beta_transformed.as_ref(), &gradient, lin)
1603            }),
1604            min_penalized_deviance: if zero_iter_penalized.is_finite() {
1605                zero_iter_penalized
1606            } else {
1607                f64::INFINITY
1608            },
1609            // Zero-iteration synthesis: the closed form is exact and no
1610            // certificate was evaluated, so there is no tolerance that decided
1611            // anything here.
1612            final_kkt_tolerance: None,
1613            // Zero-iteration synthesis: no LM damping was exercised, so
1614            // hand the next solve the cold default.
1615            final_lm_lambda: 1e-6,
1616            // Zero-iteration synthesis: no LM gain ratio was measured.
1617            final_accept_rho: None,
1618            // Zero-iteration synthesis assembles the Hessian with prior
1619            // weights only; no observed-information re-evaluation has
1620            // happened. Label honestly as a Fisher-type surrogate so
1621            // outer Laplace consumers see the truth.
1622            exported_laplace_curvature: ExportedLaplaceCurvature::ExpectedInformationSurrogate,
1623        };
1624
1625        // #1868: `solve_*`/`final_*` row arrays now come from the row synthesis
1626        // above (shared O(1) from the frozen bundle on the skip path); the exact
1627        // per-callback `computeworkingweight_derivatives_from_eta` re-computation
1628        // that used to run here is folded into that synthesis.
1629        let reparam_result = materialize_final_reparam_result()?;
1630        let qs_arc_final = Arc::new(reparam_result.qs.clone());
1631        let pirls_result = PirlsResult {
1632            likelihood: config.likelihood.clone(),
1633            beta_transformed,
1634            penalized_hessian_transformed: penalized_hessian,
1635            stabilizedhessian_transformed: stabilizedhessian,
1636            ridge_passport: RidgePassport::scaled_identity(
1637                ridge_used,
1638                RidgePolicy::exact_full_objective(),
1639            )?,
1640            deviance,
1641            edf,
1642            stable_penalty_term: penalty_term,
1643            firth: FirthDiagnostics::Inactive,
1644            finalweights: finalweights.clone(),
1645            final_offset,
1646            final_eta,
1647            finalmu: finalmu.clone(),
1648            solveweights: finalweights,
1649            solveworking_response: finalz,
1650            solvemu: finalmu,
1651            solve_dmu_deta,
1652            solve_d2mu_deta2,
1653            solve_d3mu_deta3,
1654            solve_c_array,
1655            solve_c_nontrivial: false,
1656            solve_d_array,
1657            derivatives_unsupported: false,
1658            status: PirlsStatus::Converged,
1659            iteration: 1,
1660            max_abs_eta,
1661            lastgradient_norm: gradient_norm,
1662            gradient_natural_scale: score_norm + s_beta_norm + ridge_grad_norm,
1663            penalized_gradient_transformed: gradient.clone(),
1664            last_deviance_change: 0.0,
1665            last_step_halving: 0,
1666            hessian_curvature: HessianCurvatureKind::Fisher,
1667            exported_laplace_curvature: working_summary.exported_laplace_curvature.clone(),
1668            final_lm_lambda: working_summary.final_lm_lambda,
1669            final_accept_rho: working_summary.final_accept_rho,
1670            constraint_kkt: working_summary.constraint_kkt.clone(),
1671        final_kkt_tolerance: working_summary.final_kkt_tolerance,
1672            linear_constraints_transformed: linear_constraints.clone(),
1673            reparam_result,
1674            x_transformed: make_reparam_operator(&x_original, &qs_arc_final, use_sparse_native),
1675            coordinate_frame,
1676            used_device: false,
1677            cache_compacted: false,
1678            min_penalized_deviance: working_summary.min_penalized_deviance,
1679        };
1680
1681        return Ok((pirls_result, working_summary));
1682    }
1683
1684    let x_original_for_result = x_original.clone();
1685    let mut working_model = GamWorkingModel::new(
1686        None, // No pre-materialized x_transformed: use implicit Qs composition
1687        x_original.clone(),
1688        coordinate_frame,
1689        offset,
1690        y,
1691        priorweights,
1692        penalty_active.clone(),
1693        workspace,
1694        config.likelihood.clone(),
1695        config.link_kind.clone(),
1696        // Inner Firth/Jeffreys activation must agree with the caller-requested
1697        // mode. The REML *outer* analytic derivative assembly only carries the
1698        // Jeffreys score/curvature term when `firth_bias_reduction` is set
1699        // (`reml_robust_jeffreys_link` returns `None` otherwise), so arming the
1700        // inner penalty unconditionally would converge the inner mode to the
1701        // Firth-penalized stationary point while the outer H/u/IFT stayed
1702        // non-Firth — the two would then disagree by exactly the Jeffreys
1703        // contribution (broken τ-τ Hessian-vs-FD and stationarity-cancellation
1704        // identities, #825). Gate on `firth_bias_reduction` so inner and outer
1705        // are the same objective.
1706        config.firth_bias_reduction
1707            && matches!(config.likelihood.spec.response, ResponseFamily::Binomial)
1708            && config.link_kind.has_fisher_weight_jet(),
1709        transform_active.clone(),
1710        quadctx,
1711        // #1111 / #1033 mechanism (c): frozen-W first-Fisher-step XᵀWX in the
1712        // original (conditioned x_fit) frame, served n-free on the first inner
1713        // iteration. Suppressed under Firth bias reduction, which shifts the
1714        // working response per iteration (the installer also gates Firth off).
1715        if config.firth_bias_reduction {
1716            None
1717        } else {
1718            glm_first_step_gram.cloned()
1719        },
1720    );
1721
1722    // Apply integrated (GHQ) likelihood if per-observation SE is provided.
1723    // This is used by the calibrator to coherently account for base prediction uncertainty.
1724    if let Some(se) = covariate_se {
1725        working_model = working_model.with_covariate_se(se.to_owned());
1726    }
1727
1728    let mut beta_guess_original = warm_start_beta
1729        .filter(|beta| beta.len() == penalty.p)
1730        .map(|beta| beta.to_owned())
1731        .unwrap_or_else(|| {
1732            Coefficients::new(default_beta_guess_external(
1733                penalty.p,
1734                link_function,
1735                y,
1736                priorweights,
1737                config.link_kind.mixture_state(),
1738                config.link_kind.sas_state(),
1739            ))
1740        });
1741    if let Some(lb) = penalty.coefficient_lower_bounds {
1742        project_coefficients_to_lower_bounds(&mut beta_guess_original.0, lb);
1743    }
1744    let initial_beta = transform_active
1745        .as_ref()
1746        .map(|transform| transform.apply_transpose(beta_guess_original.as_ref()))
1747        .unwrap_or_else(|| beta_guess_original.as_ref().clone());
1748    let initial_beta = if let Some(constraints) = linear_constraints.as_ref() {
1749        // Worst per-row *scaled* (geometric) slack of the current seed against the
1750        // constraint cone. Negative ⇒ the seed violates a row; ~0 ⇒ the seed sits
1751        // ON the boundary (for a homogeneous convex/concave second-difference
1752        // cone, `β = 0` — the unconstrained Gaussian seed — sits on EVERY row's
1753        // boundary, i.e. the cone vertex). Either way the seed must be pushed
1754        // strictly into the interior before P-IRLS starts.
1755        let mut min_scaled_slack = f64::INFINITY;
1756        for i in 0..constraints.a.nrows() {
1757            let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1758            let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1759            let slack = (constraints.a.row(i).dot(&initial_beta) - constraints.b[i]) * inv;
1760            min_scaled_slack = min_scaled_slack.min(slack);
1761        }
1762        // Push the seed to the nearest STRICTLY-INTERIOR feasible point whenever
1763        // any row is tight or violated. A seed on the cone boundary (most acutely
1764        // the vertex `β = 0`) hands the inner active-set QP an all-rows-active
1765        // working set, where it stalls on a degenerate, non-stationary face — so
1766        // the fit silently diverges (or aborts in release) between a cold and a
1767        // warm warm-start cache (#873). A strictly-interior seed makes the QP's
1768        // initial active set empty; it then adds only the genuinely binding rows
1769        // and converges to the certified constrained optimum regardless of cache
1770        // state. The projection keeps the data-driven curvature of `initial_beta`
1771        // and falls back to the min-norm feasible point only if it cannot certify
1772        // a strictly-interior solution.
1773        //
1774        // The min-norm fallback (`feasible_point_for_linear_constraints`) is only
1775        // used for a NON-homogeneous cone (`b ≠ 0`), where it returns a genuine
1776        // interior-of-the-offset-polyhedron point. For a HOMOGENEOUS shape cone
1777        // (`b ≈ 0` — the convex/concave second-difference rows) that function
1778        // returns the minimum-norm feasible point `β = 0`, which is the cone
1779        // *vertex*: the exact all-rows-tight degenerate seed #873 is about. Taking
1780        // it would silently reintroduce the #873 pathology whenever the strict
1781        // projection rarely fails to certify. So for a homogeneous cone we skip the
1782        // vertex fallback entirely and prefer the data-driven `initial_beta`: it
1783        // violates at most *some* rows (a lower-dimensional, non-degenerate face the
1784        // inner active-set QP can recover from), strictly better than the vertex
1785        // where *every* row is simultaneously tight.
1786        let cone_is_homogeneous = constraints.b.iter().all(|v| v.abs() <= 1e-14);
1787        if min_scaled_slack < active_set::interior_seed_margin() {
1788            let projected =
1789                active_set::project_point_strictly_into_feasible_cone(&initial_beta, constraints)
1790                    .or_else(|| {
1791                        if cone_is_homogeneous {
1792                            None
1793                        } else {
1794                            active_set::feasible_point_for_linear_constraints(
1795                                constraints,
1796                                initial_beta.len(),
1797                            )
1798                        }
1799                    });
1800            projected.unwrap_or(initial_beta)
1801        } else {
1802            initial_beta
1803        }
1804    } else {
1805        initial_beta
1806    };
1807    // Inner P-IRLS Firth activation. The inner penalized objective must match
1808    // the objective the REML outer derivatives are assembled against: the outer
1809    // path carries the Jeffreys/Firth score+curvature only when the caller set
1810    // `firth_bias_reduction` (`reml_robust_jeffreys_link` is `None` otherwise),
1811    // so the inner Firth term is armed iff the caller requested it AND the link
1812    // exposes a Fisher-weight jet (#825). Forcing it on unconditionally desynced
1813    // the Firth-penalized inner mode from the non-Firth outer assembly.
1814    let firth_active = config.firth_bias_reduction
1815        && matches!(config.likelihood.spec.response, ResponseFamily::Binomial)
1816        && config.link_kind.has_fisher_weight_jet();
1817    let base_max_step_halving = if firth_active { 60 } else { 30 };
1818    let options = WorkingModelPirlsOptions {
1819        // The Firth-penalized P-IRLS converges at the same iteration count as
1820        // the unpenalized fit — the Jeffreys term is a smooth, bounded addition
1821        // to a Newton system that is already well conditioned (the additional
1822        // per-iteration LM step-halving budget above absorbs the early-iteration
1823        // curvature change). Bumping the outer-iteration cap to mask a
1824        // mis-conditioned step would only hide non-convergence, so the cap stays
1825        // the caller's `max_iterations` and trips as a hard error if exceeded.
1826        max_iterations: config.max_iterations,
1827        convergence_tolerance: config.convergence_tolerance,
1828        adaptive_kkt_tolerance,
1829        // LM step-halving is a per-iteration damping retry budget; it is
1830        // independent of the total outer-iteration cap. Tying the two
1831        // together collapsed step halving to 3 under seed screening (where
1832        // max_iterations is intentionally capped low), turning recoverable
1833        // damping into spurious failures.
1834        max_step_halving: base_max_step_halving,
1835        min_step_size: if firth_active { 1e-12 } else { 1e-10 },
1836        firth_bias_reduction: firth_active,
1837        coefficient_lower_bounds: None,
1838        linear_constraints: linear_constraints.clone(),
1839        initial_lm_lambda: config.initial_lm_lambda,
1840        arrow_schur: config.arrow_schur.clone(),
1841    };
1842
1843    let mut iteration_logger = |info: &WorkingModelIterationInfo| {
1844        log::debug!(
1845            "[PIRLS] iter {:>3} | deviance {:.6e} | |grad| {:.3e} | step {:.3e} (halving {})",
1846            info.iteration,
1847            info.deviance,
1848            info.gradient_norm,
1849            info.step_size,
1850            info.step_halving
1851        );
1852    };
1853
1854    // Stage 3.3 GPU PIRLS-loop dispatch — see pirls_host_dispatch::try_pirls_loop_gpu.
1855    if let Some(result) = try_pirls_loop_gpu(
1856        config,
1857        &penalty_active,
1858        kronecker_runtime.is_none(),
1859        use_sparse_native,
1860        &linear_constraints,
1861        &x_original,
1862        &qs_arc,
1863        penalty.p,
1864        &x_original_for_result,
1865        || materialize_final_reparam_result(),
1866        y,
1867        priorweights,
1868        offset,
1869        &initial_beta,
1870        link_function,
1871        coordinate_frame,
1872    ) {
1873        return result;
1874    }
1875
1876    let mut working_summary = runworking_model_pirls(
1877        &mut working_model,
1878        Coefficients::new(initial_beta),
1879        &options,
1880        Some(&mut iteration_logger),
1881    )?;
1882
1883    // ── Gamma dispersion: re-estimate the shape at the *converged* η (#678) ──
1884    //
1885    // The inner LM solve estimates the Gamma shape ν = 1/φ **once** from the
1886    // warm-start η and freezes it for the rest of the solve (see the
1887    // `gamma_shape_locked` doc on `GamWorkingModel`): holding ν fixed keeps the
1888    // product φ·λ — and hence the penalized argmin β̂ — a stationary LM target,
1889    // so the gain ratio compares one objective. That lock is correct *within* a
1890    // solve, but it pins ν to whatever η the solve started from. When the fit
1891    // cold-starts (the final dedicated fit at the converged ρ passes
1892    // `warm_start_beta = None`, and seed screening starts from a default guess),
1893    // that warm-start η has not yet captured the mean structure; the leftover
1894    // spread of μ inflates the Gamma deviance term `mean[y/μ − ln(y/μ) − 1]` and
1895    // biases ν **down** (φ up) by >2× whenever μ varies appreciably. The mean
1896    // surface still converges (β̂ is essentially scale-free here), but the frozen
1897    // ν that survives into `UnifiedFitResult::dispersion_phi()` — and from there
1898    // into every coefficient SE `Vb = H⁻¹·φ̂`, prediction interval, and
1899    // observation-noise interval — is the early, mean-spread-contaminated value.
1900    //
1901    // Fix: after the solve converges, re-estimate ν at the converged η. If it
1902    // moved, re-solve β (warm-started, ν held fixed at the refreshed value) and
1903    // repeat, driving the pair (β, ν) to their joint fixed point at the current
1904    // λ. At convergence the reported dispersion is the Gamma ML estimate at the
1905    // converged mean (mgcv's post-hoc Pearson/deviance scale), and the final
1906    // working state — `finalweights`, the penalized Hessian, the deviance, μ —
1907    // is rebuilt with that same ν, so `Vb = H⁻¹·φ̂` stays internally consistent.
1908    // Warm-started solves (every REML cost eval) already sit near the converged
1909    // η, so the first refresh check confirms ν and exits without a re-solve; the
1910    // added cost there is a single O(n) shape evaluation.
1911    let gamma_scale = working_model
1912        .likelihood
1913        .resolved_scale()
1914        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1915    if refine_dispersion_at_converged_eta
1916        && matches!(
1917            gamma_scale,
1918            ResolvedLikelihoodScale::Gamma {
1919                estimated: true,
1920                ..
1921            }
1922        )
1923    {
1924        // A few passes suffice: the converged-η shape map is a strong
1925        // contraction (β̂ barely moves once the mean is captured), so cold
1926        // starts settle in 1–2 re-solves and warm starts in zero.
1927        const MAX_SHAPE_REFRESH: usize = 5;
1928        // Relative shape tolerance below which a re-solve cannot move any
1929        // reported quantity meaningfully (far under statistical resolution).
1930        const SHAPE_REFRESH_REL_TOL: f64 = 1e-4;
1931        for refresh_iter in 0..MAX_SHAPE_REFRESH {
1932            let refreshed_shape = super::estimate_gamma_shape_from_eta(
1933                y,
1934                working_summary.state.eta.as_ref(),
1935                priorweights,
1936            )?;
1937            let prior_shape = working_model
1938                .likelihood
1939                .resolved_gamma_shape()
1940                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
1941            let rel_change =
1942                (refreshed_shape - prior_shape).abs() / prior_shape.max(f64::MIN_POSITIVE);
1943            // Install the refreshed shape and hold it fixed for any re-solve so
1944            // the LM objective stays stationary (the lock is *re-armed*, not
1945            // released — the seed-from-warm-start branch in `update_with_curvature`
1946            // must not overwrite this deliberately chosen value). Because this
1947            // assignment evaluated the shape at the *current* converged η and no
1948            // re-solve follows it on the exit paths below, the reported shape
1949            // always equals `estimate_gamma_shape_from_eta(final_eta)` — the
1950            // self-consistency invariant the in-module Gamma unit test checks.
1951            working_model.likelihood = working_model
1952                .likelihood
1953                .clone()
1954                .with_gamma_shape(refreshed_shape);
1955            working_model.gamma_shape_locked = true;
1956            if rel_change <= SHAPE_REFRESH_REL_TOL {
1957                // Converged: the working-state buffers (weights, Hessian,
1958                // deviance) already reflect a shape within tolerance of
1959                // `refreshed_shape`, because the only way to reach here without
1960                // a re-solve is that the prior solve's shape already matched the
1961                // converged-η estimate. Nothing left to rebuild.
1962                break;
1963            }
1964            if refresh_iter + 1 == MAX_SHAPE_REFRESH {
1965                // Final allowed pass and the shape is still drifting (a
1966                // pathological non-contraction). Do NOT re-solve: re-solving
1967                // would advance `final_eta` past the η the just-installed shape
1968                // was evaluated at, breaking the stored-shape == estimate(final_eta)
1969                // invariant. Stopping here keeps the reported shape exactly the
1970                // ML estimate at the reported η; the residual weight/φ drift is
1971                // bounded by the last `rel_change` and never worse than the
1972                // pre-fix frozen-warm-start value.
1973                break;
1974            }
1975            // The shape moved: re-solve β at the corrected shape, warm-started
1976            // at the converged β, so the final working state is rebuilt with the
1977            // refreshed ν.
1978            working_summary = runworking_model_pirls(
1979                &mut working_model,
1980                working_summary.beta.clone(),
1981                &options,
1982                Some(&mut iteration_logger),
1983            )?;
1984        }
1985    }
1986
1987    // ── Tweedie dispersion φ: re-estimate at the *converged* η (#771) ─────────
1988    //
1989    // Identical in spirit to the Gamma-shape refresh above: the inner LM solve
1990    // estimates φ **once** from the warm-start η and freezes it (the
1991    // `tweedie_phi_locked` lock), keeping the product φ·λ — and hence β̂ — a
1992    // stationary LM target. φ enters only the working weight `prior·μ^{2−p}/φ`
1993    // and not the working response, so (like the Gamma shape, and unlike the
1994    // Beta precision which couples through the digamma mean score) the mean
1995    // surface is essentially scale-free and β̂ barely moves when φ is corrected.
1996    // But the frozen warm-start φ is the value that survives into
1997    // `FitInference::dispersion` and the covariance `Vb = H⁻¹` (whose √φ scaling
1998    // lives in the weight); at a cold-started η ≈ 0 the Pearson residuals carry
1999    // the *marginal* spread of y, biasing the estimate. Re-estimating at the
2000    // converged η — re-solving β only if φ moved materially — drives (β, φ) to
2001    // their joint fixed point, so the reported φ is the converged-mean Pearson
2002    // estimate and the final weights/Hessian/SE are internally consistent with
2003    // it. Held OFF inside the REML λ search (the flag), φ is refreshed only at
2004    // the reported fit, so it cannot couple to the smoothing parameter.
2005    let tweedie_scale = working_model
2006        .likelihood
2007        .resolved_scale()
2008        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2009    if refine_dispersion_at_converged_eta
2010        && matches!(
2011            tweedie_scale,
2012            ResolvedLikelihoodScale::Tweedie {
2013                estimated: true,
2014                ..
2015            }
2016        )
2017    {
2018        if let ResponseFamily::Tweedie { p } = working_model.likelihood.spec.response {
2019            // The converged-η Pearson map is a strong contraction (β̂ scale-free
2020            // here), so cold starts settle in 1–2 re-solves and warm starts in
2021            // zero.
2022            const MAX_PHI_REFRESH: usize = 5;
2023            // Relative φ tolerance below which a re-solve cannot move any reported
2024            // quantity meaningfully (far under statistical resolution).
2025            const PHI_REFRESH_REL_TOL: f64 = 1e-4;
2026            for refresh_iter in 0..MAX_PHI_REFRESH {
2027                let refreshed_phi = super::estimate_tweedie_phi_from_eta(
2028                    y,
2029                    working_summary.state.eta.as_ref(),
2030                    priorweights,
2031                    p,
2032                )?;
2033                let prior_phi = working_model
2034                    .likelihood
2035                    .resolved_tweedie_phi()
2036                    .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2037                let rel_change =
2038                    (refreshed_phi - prior_phi).abs() / prior_phi.max(f64::MIN_POSITIVE);
2039                // Install the refreshed φ (the scale metadata the working weight
2040                // reads via `fixed_phi()`) and re-arm the lock so a following
2041                // re-solve does not overwrite this converged-η value. Because the
2042                // exit paths below evaluate φ at the *current* η with no following
2043                // re-solve, the reported φ always equals
2044                // `estimate_tweedie_phi_from_eta(final_eta)`.
2045                working_model.likelihood = working_model
2046                    .likelihood
2047                    .clone()
2048                    .with_tweedie_phi(refreshed_phi);
2049                working_model.tweedie_phi_locked = true;
2050                if rel_change <= PHI_REFRESH_REL_TOL {
2051                    // Converged: the working state already reflects a φ within
2052                    // tolerance of `refreshed_phi`. Nothing left to rebuild.
2053                    break;
2054                }
2055                if refresh_iter + 1 == MAX_PHI_REFRESH {
2056                    // Final allowed pass and φ is still drifting. Do NOT re-solve:
2057                    // re-solving would advance η past the point φ was evaluated at,
2058                    // breaking the stored-φ == estimate(final_eta) invariant.
2059                    break;
2060                }
2061                // φ moved materially: re-solve β at the corrected φ, warm-started
2062                // at the converged β, so the final working state is rebuilt with
2063                // the refreshed φ.
2064                working_summary = runworking_model_pirls(
2065                    &mut working_model,
2066                    working_summary.beta.clone(),
2067                    &options,
2068                    Some(&mut iteration_logger),
2069                )?;
2070            }
2071        }
2072    }
2073
2074    // ── Beta precision φ: re-estimate at the *converged* η and drive (β, φ) to
2075    //    their joint fixed point (#769) ──────────────────────────────────────
2076    //
2077    // Like the Gamma shape above, the inner LM solve estimates φ **once** from
2078    // the warm-start η and freezes it for the rest of the solve (the
2079    // `beta_phi_locked` doc on `GamWorkingModel`): holding φ fixed keeps the
2080    // penalized argmin β̂ a stationary LM target so the gain ratio compares one
2081    // objective. But that lock pins φ to whatever η the solve started from, and
2082    // for the final dedicated fit at the converged ρ the warm-start is the cold
2083    // default guess (η ≈ 0, μ ≈ 0.5 everywhere). At the null predictor the
2084    // Pearson residuals `(y−μ)²/(μ(1−μ))` capture the full *marginal* spread of
2085    // y rather than its *conditional* spread, so the moment estimator
2086    // `1+φ = Σw / Σ w·s` returns a precision far too small (≈3 when the truth is
2087    // ≈20 here).
2088    //
2089    // Crucially — and unlike the Gamma shape — φ does **not** factor out of the
2090    // Beta mean score. With the logit link the score for β is
2091    //     ∂ℓ/∂β = φ · Σᵢ xᵢ (y*ᵢ − μ*ᵢ),   y*ᵢ = logit(yᵢ),
2092    //     μ*ᵢ = ψ(μᵢφ) − ψ((1−μᵢ)φ),
2093    // so the root β̂ depends on φ through the digamma terms. A φ that is too
2094    // small shrinks every fitted coefficient toward zero. So this refresh is not
2095    // cosmetic (as it is for Gamma): the re-solve is what *recovers the mean*.
2096    //
2097    // Fix: after the cold solve converges, re-estimate φ at the converged η,
2098    // re-solve β at the corrected φ (warm-started), and repeat. This is the
2099    // betareg alternating mean-fit ↔ φ-estimate scheme; the moment estimator is
2100    // a strong contraction once the mean has any structure, so the pair settles
2101    // in a handful of passes. Held OFF inside the REML λ search (see the flag
2102    // doc), φ is refreshed only here at the reported fit, so it cannot couple to
2103    // the smoothing parameter and reward over-smoothing. As with Gamma, every
2104    // exit path installs φ evaluated at the *current* η with no following
2105    // re-solve, so the reported φ (which flows into `EstimatedBetaPhi`, the
2106    // embedded `Beta { phi }`, `dispersion`, and every SE) always equals
2107    // `estimate_beta_phi_from_eta(final_eta)`.
2108    let beta_scale = working_model
2109        .likelihood
2110        .resolved_scale()
2111        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2112    if refine_dispersion_at_converged_eta
2113        && matches!(
2114            beta_scale,
2115            ResolvedLikelihoodScale::BetaPrecision {
2116                estimated: true,
2117                ..
2118            }
2119        )
2120    {
2121        // The mean moves between passes (φ feeds back through the digamma
2122        // score), so allow a few more passes than the scale-free Gamma case;
2123        // the contraction is fast and warm-started re-solves are cheap.
2124        const MAX_PHI_REFRESH: usize = 30;
2125        // Relative φ tolerance below which a re-solve cannot move β̂ — and hence
2126        // any reported quantity — by a statistically meaningful amount.
2127        const PHI_REFRESH_REL_TOL: f64 = 1e-4;
2128        for refresh_iter in 0..MAX_PHI_REFRESH {
2129            let refreshed_phi = super::estimate_beta_phi_from_eta(
2130                y,
2131                working_summary.state.eta.as_ref(),
2132                priorweights,
2133            )?;
2134            let prior_phi = working_model
2135                .likelihood
2136                .resolved_beta_precision()
2137                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2138            let rel_change = (refreshed_phi - prior_phi).abs() / prior_phi.max(f64::MIN_POSITIVE);
2139            // Install the refreshed φ (updates BOTH the `Beta { phi }` family
2140            // variant every weight/deviance expression reads and the
2141            // `EstimatedBetaPhi` scale metadata) and re-arm the lock so a
2142            // following re-solve's `update_with_curvature` does not overwrite
2143            // this deliberately chosen value with a fresh cold estimate.
2144            working_model.likelihood = working_model
2145                .likelihood
2146                .clone()
2147                .with_beta_phi(refreshed_phi);
2148            working_model.beta_phi_locked = true;
2149            if rel_change <= PHI_REFRESH_REL_TOL {
2150                // Converged: the just-installed φ matches (to tolerance) the φ
2151                // the current working state was solved at, so β̂, the weights,
2152                // the Hessian and the deviance are already self-consistent with
2153                // the reported φ. Nothing left to rebuild.
2154                break;
2155            }
2156            if refresh_iter + 1 == MAX_PHI_REFRESH {
2157                // Final allowed pass and φ is still drifting. Do NOT re-solve:
2158                // re-solving would advance η past the point the just-installed φ
2159                // was evaluated at, breaking the stored-φ == estimate(final_eta)
2160                // invariant. Stop here so the reported φ is exactly the moment
2161                // estimate at the reported η.
2162                break;
2163            }
2164            // φ moved materially: re-solve β at the corrected φ, warm-started at
2165            // the converged β, so the mean is refit under the better precision
2166            // and the final working state is rebuilt consistently.
2167            //
2168            // Every pass of the alternation must land AT A MINIMUM of the mean
2169            // objective. The moment estimate reads the Pearson residuals of
2170            // whatever η the re-solve returned; continuing from a mean that
2171            // exhausted its step search or its iteration budget feeds the next
2172            // pass a precision that no minimum produced. Measured on a
2173            // noise-free logistic response (`beta_regression_fits_clean_
2174            // monotone_separation_prone` before its fixture carried
2175            // dispersion): φ ran 1.1 → 6.4 → 50 → 1.6e3 → 1.4e6 → 1.0e12, the
2176            // re-solve at 1.0e12 ended `LM step search exhausted`, the loop still
2177            // refreshed φ from that η to 5.1e23, and the fit died inside the next
2178            // P-IRLS with `did not converge within 300 iterations, gradient
2179            // 9.5e12` — a message about the wrong object. A fixed λ rescales
2180            // nothing when φ grows, so the effective penalty λ/φ vanishes, the
2181            // mean interpolates, the residuals collapse and φ has no finite
2182            // fixed point: that is the fact to report, at the pass where it
2183            // became measurable.
2184            //
2185            // `StalledAtValidMinimum` continues the alternation: it is the
2186            // objective-resolution exit of a mean that IS at its minimum (the
2187            // deviance is ∝ φ, so at a large finite φ the strict certificate is
2188            // routinely out of the objective's floating-point reach), and the
2189            // same fixture with dispersion reached φ ≈ 1.5e5 through exactly
2190            // such passes before its next pass converged. Whether the FINAL
2191            // mean is certified is fit assembly's own gate, unchanged here.
2192            let deviance_at_prior = working_summary.state.deviance;
2193            let refused = |inner_status: String| {
2194                EstimationError::BetaPrecisionRefinementDidNotConverge {
2195                    passes: refresh_iter + 1,
2196                    prior_phi,
2197                    refreshed_phi,
2198                    deviance: deviance_at_prior,
2199                    inner_status,
2200                }
2201            };
2202            working_summary = match runworking_model_pirls(
2203                &mut working_model,
2204                working_summary.beta.clone(),
2205                &options,
2206                Some(&mut iteration_logger),
2207            ) {
2208                Ok(summary) => summary,
2209                Err(inner) => return Err(refused(inner.to_string())),
2210            };
2211            if !matches!(
2212                working_summary.status,
2213                PirlsStatus::Converged | PirlsStatus::StalledAtValidMinimum
2214            ) {
2215                return Err(refused(working_summary.status.label().to_string()));
2216            }
2217        }
2218    }
2219
2220    // ── Negative-Binomial overdispersion θ: re-estimate at the *converged* η and
2221    //    drive (β, θ) to their joint fixed point (#802) ───────────────────────
2222    //
2223    // Identical in spirit to the Beta-precision refresh above. The inner LM solve
2224    // estimates θ **once** from the warm-start η and freezes it (the
2225    // `negbin_theta_locked` lock), keeping the penalized argmin β̂ a stationary LM
2226    // target. But that lock pins θ to whatever η the solve started from, and for
2227    // the final dedicated fit at the converged ρ the warm-start is the cold
2228    // default guess (η ≈ 0). At the null predictor the Pearson residuals carry
2229    // the *marginal* spread of y rather than its *conditional* spread, biasing
2230    // the moment seed — and the frozen θ is what survives into the working weight
2231    // `W = μθ/(θ+μ)`, the covariance `Vb = H⁻¹` (whose overdispersion scaling
2232    // lives in that weight, not a post-hoc multiply), and every reported SE /
2233    // interval / `generate` draw.
2234    //
2235    // Like the Beta precision — and unlike the scale-free Gamma shape / Tweedie φ
2236    // — θ enters the NB2 working *response*, not only the weight, so re-solving β
2237    // under the corrected θ is not cosmetic: it recovers the mean under the right
2238    // variance function. Re-estimating at the converged η, re-solving β
2239    // (warm-started), and repeating drives (β, θ) to their joint maximum-
2240    // likelihood fixed point. Held OFF inside the REML λ search (the flag), θ is
2241    // refreshed only here at the reported fit, so it cannot couple to the
2242    // smoothing parameter. Every exit path installs θ evaluated at the *current*
2243    // η with no following re-solve, so the reported θ (which flows into the
2244    // embedded `NegativeBinomial { theta }`, the `EstimatedNegBinTheta` scale
2245    // metadata, the predictive-interval variance, and every SE) always equals
2246    // `estimate_negbin_theta_from_eta(final_eta)`.
2247    let negbin_scale = working_model
2248        .likelihood
2249        .resolved_scale()
2250        .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2251    if refine_dispersion_at_converged_eta
2252        && matches!(
2253            negbin_scale,
2254            ResolvedLikelihoodScale::NegativeBinomial {
2255                estimated: true,
2256                ..
2257            }
2258        )
2259    {
2260        // θ feeds back through the working response, so allow a few more passes
2261        // than the scale-free Gamma case; the alternation is a strong contraction
2262        // and warm-started re-solves are cheap.
2263        const MAX_THETA_REFRESH: usize = 30;
2264        // Relative θ tolerance below which a re-solve cannot move β̂ — and hence
2265        // any reported quantity — by a statistically meaningful amount.
2266        const THETA_REFRESH_REL_TOL: f64 = 1e-4;
2267        for refresh_iter in 0..MAX_THETA_REFRESH {
2268            let refreshed_theta = super::estimate_negbin_theta_from_eta(
2269                y,
2270                working_summary.state.eta.as_ref(),
2271                priorweights,
2272            )?;
2273            let prior_theta = working_model
2274                .likelihood
2275                .resolved_negbin_theta()
2276                .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
2277            let rel_change =
2278                (refreshed_theta - prior_theta).abs() / prior_theta.max(f64::MIN_POSITIVE);
2279            // Install the refreshed θ (updates BOTH the `NegativeBinomial { theta }`
2280            // family variant every weight/deviance expression reads and the
2281            // `EstimatedNegBinTheta` scale metadata) and re-arm the lock so a
2282            // following re-solve's `update_with_curvature` does not overwrite this
2283            // deliberately chosen value with a fresh cold estimate.
2284            working_model.likelihood = working_model
2285                .likelihood
2286                .clone()
2287                .with_negbin_theta(refreshed_theta);
2288            working_model.negbin_theta_locked = true;
2289            if rel_change <= THETA_REFRESH_REL_TOL {
2290                // Converged: the just-installed θ matches (to tolerance) the θ the
2291                // current working state was solved at, so β̂, the weights, the
2292                // Hessian and the deviance are already self-consistent with the
2293                // reported θ. Nothing left to rebuild.
2294                break;
2295            }
2296            if refresh_iter + 1 == MAX_THETA_REFRESH {
2297                // Final allowed pass and θ is still drifting. Do NOT re-solve:
2298                // re-solving would advance η past the point the just-installed θ
2299                // was evaluated at, breaking the stored-θ == estimate(final_eta)
2300                // invariant. Stop here so the reported θ is exactly the ML
2301                // estimate at the reported η.
2302                break;
2303            }
2304            // θ moved materially: re-solve β at the corrected θ, warm-started at
2305            // the converged β, so the mean is refit under the better variance
2306            // function and the final working state is rebuilt consistently.
2307            working_summary = runworking_model_pirls(
2308                &mut working_model,
2309                working_summary.beta.clone(),
2310                &options,
2311                Some(&mut iteration_logger),
2312            )?;
2313        }
2314    }
2315
2316    // Candidate screens and a rejected post-loop polish are speculative
2317    // mutations of the model's row-space scratch. Reinstall the certified
2318    // coefficient state's arrays only when that scratch no longer carries its
2319    // exact coefficient identity; the common accepted-state path is an O(p)
2320    // bit comparison and performs no extra curvature work.
2321    working_model.refresh_working_arrays_for_state(
2322        &working_summary.beta,
2323        &working_summary.state,
2324        "finalization",
2325    )?;
2326
2327    // Extract workspace before consuming working_model so we can reuse
2328    // the pre-allocated buffers in calculate_edfwithworkspace_with_penalty.
2329    // into_final_state() drops the workspace field anyway (it uses `..` in
2330    // its destructure); we replace it with a zero-sized stub to satisfy the
2331    // borrow checker, then keep the real workspace alive for the EDF call.
2332    let mut saved_workspace = std::mem::replace(
2333        &mut working_model.workspace,
2334        PirlsWorkspace::new(0, 0),
2335    );
2336    let final_state = working_model.into_final_state();
2337    let GamModelFinalState {
2338        likelihood: final_likelihood,
2339        coordinate_frame,
2340        finalmu,
2341        finalweights,
2342        scoreweights,
2343        finalz,
2344        final_c,
2345        final_d,
2346        final_dmu_deta,
2347        final_d2mu_deta2,
2348        final_d3mu_deta3,
2349        penalty_term,
2350        ..
2351    } = final_state;
2352
2353    // Preserve the Hessian as-is (sparse or dense) — no densification.
2354    // P-IRLS already folded any stabilization ridge directly into the Hessian.
2355    // Keep that exact matrix so outer LAML derivatives stay consistent:
2356    // H_eff = X'W_H X + S_λ + ridge I (if ridge_used > 0).
2357    let penalized_hessian_transformed = working_summary.state.hessian.clone();
2358    let stabilizedhessian_transformed = penalized_hessian_transformed.clone();
2359    // Use the workspace-backed variant for the dense path to reuse the
2360    // `final_aug_matrix` allocation; the sparse path still allocates
2361    // internally because no pre-computed factor is available at this site.
2362    let mut edf = if let Some(dense_h) = penalized_hessian_transformed.as_dense() {
2363        calculate_edfwithworkspace_with_penalty(dense_h, &penalty_active, &mut saved_workspace)?
2364    } else {
2365        calculate_edf_with_penalty(&penalized_hessian_transformed, &penalty_active)?
2366    };
2367    if !edf.is_finite() || edf.is_nan() {
2368        let p = penalized_hessian_transformed.ncols() as f64;
2369        let r = penalty_active.rank() as f64;
2370        edf = (p - r).max(0.0);
2371    }
2372
2373    // Outer rescue: a fit that hit max-iterations may still be a usable
2374    // minimum if progress has effectively stopped (deviance plateaued or
2375    // step size collapsed to the floor) AND the projected gradient is in
2376    // the near-stationary band under the scale-invariant certificate.
2377    // Same logic for non-Firth and Firth paths; firth_active just gates
2378    // the second pass.
2379    let stalled_at_valid_minimum = |summary: &WorkingModelPirlsResult| -> bool {
2380        // Scale-equivariant deviance plateau band (issue #1127). The
2381        // `last_deviance_change` compared below and the deviance both scale as
2382        // `O(a²)` under a response rescaling `y → a·y` (the penalized normal
2383        // equations are linear in `y`, so `β → a·β` and the RSS-deviance
2384        // scales by `a²`). Keying the plateau band to the deviance's own
2385        // magnitude `+ |penalty|` makes the ratio `Δdev / dev_scale`
2386        // scale-invariant. The previous `.max(1.0)` absolute floor broke this:
2387        // for a micro-unit response (`a = 1e-6`) the deviance is `O(1e-12)`, so
2388        // the floor pinned the band at `1.0` — ~1e9× too loose — and this
2389        // max-iteration rescue declared `progress_stopped` at an over-smoothed
2390        // iterate, propagating an inflated `λ̂` to the outer REML loop. For a
2391        // well-scaled (`a ≳ 1`) or up-scaled (`a = 1e6`) objective the floor was
2392        // already a no-op, so those directions are byte-identical. A perfect
2393        // interpolating fit gives a `0` band, so the relative `Δdev` test cannot
2394        // fire spuriously and the scale-invariant `near_stationary_kkt`
2395        // certificate then governs acceptance.
2396        let dev_scale = summary.state.deviance.abs() + summary.state.penalty_term.abs();
2397        // Progress plateau uses the fixed solver tolerance; only the KKT band below adapts.
2398        let dev_tol = options.convergence_tolerance * dev_scale;
2399        let step_floor = options.min_step_size * 2.0;
2400        let progress_stopped =
2401            summary.last_deviance_change.abs() <= dev_tol || summary.last_step_size <= step_floor;
2402        let near_stationary = summary
2403            .state
2404            .near_stationary_kkt(summary.lastgradient_norm, effective_kkt_tolerance(&options));
2405        progress_stopped && near_stationary
2406    };
2407
2408    let mut status = working_summary.status;
2409    if status.is_failed_max_iterations() && stalled_at_valid_minimum(&working_summary) {
2410        status = PirlsStatus::StalledAtValidMinimum;
2411        working_summary.status = status;
2412    }
2413    if status.is_failed_max_iterations()
2414        && firth_active
2415        && stalled_at_valid_minimum(&working_summary)
2416    {
2417        // Firth-adjusted fits can stall; accept under the same dual-criterion
2418        // near-stationary band.
2419        status = PirlsStatus::StalledAtValidMinimum;
2420        working_summary.status = status;
2421    }
2422    let has_penalty = penalty_active.rank() > 0;
2423    let firth_active = options.firth_bias_reduction;
2424    if detect_logit_instability(
2425        link_function,
2426        &final_likelihood.spec.response,
2427        has_penalty,
2428        firth_active,
2429        &working_summary,
2430        &finalmu,
2431        y,
2432    ) {
2433        status = PirlsStatus::Unstable;
2434        working_summary.status = status;
2435    }
2436
2437    // Store a lazy ReparamOperator instead of materializing X·Qs.
2438    // Consumers that truly need dense access can call .to_dense() on demand.
2439    let reparam_result_final = materialize_final_reparam_result()?;
2440    let qs_arc_final = Arc::new(reparam_result_final.qs.clone());
2441    let x_transformed_final =
2442        make_reparam_operator(&x_original_for_result, &qs_arc_final, use_sparse_native);
2443
2444    let pirls_result = assemble_pirls_result(
2445        &working_summary,
2446        final_likelihood,
2447        offset,
2448        penalized_hessian_transformed,
2449        stabilizedhessian_transformed,
2450        edf,
2451        penalty_term,
2452        &finalmu,
2453        &finalweights,
2454        &scoreweights,
2455        &finalz,
2456        &final_c,
2457        &final_d,
2458        &final_dmu_deta,
2459        &final_d2mu_deta2,
2460        &final_d3mu_deta3,
2461        status,
2462        reparam_result_final,
2463        x_transformed_final,
2464        coordinate_frame,
2465        linear_constraints,
2466    )?;
2467
2468    Ok((pirls_result, working_summary))
2469}
2470
2471#[derive(Clone)]
2472pub struct PirlsConfig {
2473    pub likelihood: GlmLikelihoodSpec,
2474    pub link_kind: InverseLink,
2475    pub max_iterations: usize,
2476    pub convergence_tolerance: f64,
2477    pub firth_bias_reduction: bool,
2478    /// Optional warm-start hint for `WorkingModelPirlsOptions::initial_lm_lambda`.
2479    /// Forwarded directly when `fit_model_for_fixed_rho` builds its
2480    /// internal options. See the field doc on `WorkingModelPirlsOptions`
2481    /// for the seeding semantics.
2482    pub initial_lm_lambda: Option<f64>,
2483    /// Optional arrow-Schur structured-inner-solve descriptor. When
2484    /// `Some`, forwarded to `WorkingModelPirlsOptions::arrow_schur` so
2485    /// each accepted LM step is solved by the per-observation
2486    /// arrow-Schur path
2487    /// ([`crate::arrow_schur::ArrowSchurSystem`]). When `None`
2488    /// (the default), the existing β-only path is used unchanged.
2489    ///
2490    /// See [`ArrowSchurInnerConfig`] for the closure contract.
2491    pub arrow_schur: Option<ArrowSchurInnerConfig>,
2492}
2493
2494impl PirlsConfig {
2495    #[inline]
2496    pub fn link_function(&self) -> LinkFunction {
2497        self.link_kind.link_function()
2498    }
2499}
2500
2501#[inline]
2502pub(super) fn max_symmetric_asymmetry(matrix: &Array2<f64>) -> f64 {
2503    let n = matrix.nrows().min(matrix.ncols());
2504    let mut max_asym = 0.0_f64;
2505    for i in 0..n {
2506        for j in 0..i {
2507            let diff = (matrix[[i, j]] - matrix[[j, i]]).abs();
2508            if diff > max_asym {
2509                max_asym = diff;
2510            }
2511        }
2512    }
2513    max_asym
2514}
2515
2516#[inline]
2517pub(super) fn assert_symmetric_tol(matrix: &Array2<f64>, label: &str, tol: f64) {
2518    let max_asym = max_symmetric_asymmetry(matrix);
2519    assert!(
2520        max_asym <= tol,
2521        "{} asymmetry too large: {:.3e} (tol {:.3e})",
2522        label,
2523        max_asym,
2524        tol
2525    );
2526}
2527
2528/// Build a DesignMatrix wrapping a lazy ReparamOperator (or the original for sparse-native).
2529pub(crate) fn make_reparam_operator(
2530    x_original: &DesignMatrix,
2531    qs_arc: &Arc<Array2<f64>>,
2532    use_sparse_native: bool,
2533) -> DesignMatrix {
2534    if use_sparse_native {
2535        x_original.clone()
2536    } else {
2537        DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(
2538            ReparamOperator::new(x_original.clone(), Arc::clone(qs_arc)),
2539        )))
2540    }
2541}
2542
2543// solve_penalized_least_squares_implicit lives in pls_solver (imported above).
2544
2545pub(super) fn build_transformed_lower_bound_constraints(
2546    qs: &Array2<f64>,
2547    coefficient_lower_bounds: Option<&Array1<f64>>,
2548) -> Option<LinearInequalityConstraints> {
2549    let lb = coefficient_lower_bounds?;
2550    if lb.len() != qs.nrows() {
2551        return None;
2552    }
2553    let activerows: Vec<usize> = (0..lb.len()).filter(|&i| lb[i].is_finite()).collect();
2554    if activerows.is_empty() {
2555        return None;
2556    }
2557    let mut a = Array2::<f64>::zeros((activerows.len(), qs.ncols()));
2558    let mut b = Array1::<f64>::zeros(activerows.len());
2559    for (r, &idx) in activerows.iter().enumerate() {
2560        a.row_mut(r).assign(&qs.row(idx));
2561        b[r] = lb[idx];
2562    }
2563    Some(
2564        LinearInequalityConstraints::new(a, b)
2565            .expect("transformed lower-bound constraint shape invariant"),
2566    )
2567}
2568
2569pub(super) fn build_transformed_lower_bound_constraints_with_transform(
2570    transform: &WorkingReparamTransform,
2571    coefficient_lower_bounds: Option<&Array1<f64>>,
2572) -> Option<LinearInequalityConstraints> {
2573    let lb = coefficient_lower_bounds?;
2574    let p = match transform {
2575        WorkingReparamTransform::Dense(qs) => qs.nrows(),
2576        WorkingReparamTransform::Kronecker(kron) => kron.p,
2577    };
2578    if lb.len() != p {
2579        return None;
2580    }
2581    let activerows: Vec<usize> = (0..lb.len()).filter(|&i| lb[i].is_finite()).collect();
2582    if activerows.is_empty() {
2583        return None;
2584    }
2585    let mut a = Array2::<f64>::zeros((activerows.len(), p));
2586    let mut b = Array1::<f64>::zeros(activerows.len());
2587    for (r, &idx) in activerows.iter().enumerate() {
2588        let mut basis = Array1::<f64>::zeros(p);
2589        basis[idx] = 1.0;
2590        let row = transform.apply_transpose(&basis);
2591        a.row_mut(r).assign(&row);
2592        b[r] = lb[idx];
2593    }
2594    Some(
2595        LinearInequalityConstraints::new(a, b)
2596            .expect("transformed lower-bound constraint shape invariant"),
2597    )
2598}
2599
2600pub(super) fn build_transformed_linear_constraints(
2601    qs: &Array2<f64>,
2602    linear_constraints: Option<&LinearInequalityConstraints>,
2603) -> Option<LinearInequalityConstraints> {
2604    let lc = linear_constraints?;
2605    if lc.a.ncols() != qs.nrows() {
2606        return None;
2607    }
2608    Some(
2609        LinearInequalityConstraints::new(lc.a.dot(qs), lc.b.clone())
2610            .expect("transformed linear constraint shape invariant"),
2611    )
2612}
2613
2614pub(super) fn build_transformed_linear_constraints_with_transform(
2615    transform: &WorkingReparamTransform,
2616    linear_constraints: Option<&LinearInequalityConstraints>,
2617) -> Option<LinearInequalityConstraints> {
2618    let lc = linear_constraints?;
2619    let p = match transform {
2620        WorkingReparamTransform::Dense(qs) => qs.nrows(),
2621        WorkingReparamTransform::Kronecker(kron) => kron.p,
2622    };
2623    if lc.a.ncols() != p {
2624        return None;
2625    }
2626    let mut a = Array2::<f64>::zeros((lc.a.nrows(), p));
2627    for row in 0..lc.a.nrows() {
2628        let transformed = transform.apply_transpose(&lc.a.row(row).to_owned());
2629        a.row_mut(row).assign(&transformed);
2630    }
2631    Some(LinearInequalityConstraints { a, b: lc.b.clone() })
2632}
2633
2634pub(super) fn merge_linear_constraints(
2635    first: Option<LinearInequalityConstraints>,
2636    second: Option<LinearInequalityConstraints>,
2637) -> Option<LinearInequalityConstraints> {
2638    match (first, second) {
2639        (None, None) => None,
2640        (Some(c), None) | (None, Some(c)) => Some(c),
2641        (Some(c1), Some(c2)) => {
2642            if c1.a.ncols() != c2.a.ncols() {
2643                return None;
2644            }
2645            let rows = c1.a.nrows() + c2.a.nrows();
2646            let cols = c1.a.ncols();
2647            let mut a = Array2::<f64>::zeros((rows, cols));
2648            a.slice_mut(s![0..c1.a.nrows(), ..]).assign(&c1.a);
2649            a.slice_mut(s![c1.a.nrows()..rows, ..]).assign(&c2.a);
2650            let mut b = Array1::<f64>::zeros(rows);
2651            b.slice_mut(s![0..c1.b.len()]).assign(&c1.b);
2652            b.slice_mut(s![c1.b.len()..rows]).assign(&c2.b);
2653            Some(LinearInequalityConstraints { a, b })
2654        }
2655    }
2656}
2657
2658pub(super) fn sparse_from_denseview(x: ArrayView2<f64>) -> Option<DesignMatrix> {
2659    // Below this column count a dense factorization beats the sparse path even
2660    // at high sparsity, so skip the sparsity scan entirely for narrow designs.
2661    const DENSE_PREFERRED_MAX_COLS: usize = 32;
2662    // Sparse storage + sparse Cholesky only pays off below this density (nnz as
2663    // a fraction of all entries); denser matrices stay dense.
2664    const SPARSE_DENSITY_LIMIT: f64 = 0.20;
2665
2666    let nrows = x.nrows();
2667    let ncols = x.ncols();
2668    if nrows == 0 || ncols == 0 {
2669        return None;
2670    }
2671    // Narrow matrices are faster in dense form; avoid any sparsity scan overhead.
2672    if ncols <= DENSE_PREFERRED_MAX_COLS {
2673        return None;
2674    }
2675
2676    const ZERO_EPS: f64 = 1e-12;
2677    let total = nrows.saturating_mul(ncols);
2678    if total == 0 {
2679        return None;
2680    }
2681    // If a matrix exceeds this nnz count it is too dense for sparse path; bail early.
2682    let sparse_nnz_limit = ((total as f64) * SPARSE_DENSITY_LIMIT).floor() as usize;
2683    let mut nnz = 0usize;
2684    for &val in x.iter() {
2685        if val.abs() > ZERO_EPS {
2686            nnz += 1;
2687            if nnz > sparse_nnz_limit {
2688                return None;
2689            }
2690        }
2691    }
2692    let mut triplets = Vec::with_capacity(nnz);
2693    for (row_idx, row) in x.outer_iter().enumerate() {
2694        for (col_idx, &val) in row.iter().enumerate() {
2695            if val.abs() > ZERO_EPS {
2696                triplets.push(Triplet::new(row_idx, col_idx, val));
2697            }
2698        }
2699    }
2700    SparseColMat::try_new_from_triplets(nrows, ncols, &triplets)
2701        .ok()
2702        .map(DesignMatrix::from)
2703}
2704
2705#[cfg(test)]
2706mod tests {
2707    use super::{PirlsPenalty, build_diagonal_penalty_from_kronecker};
2708    use gam_terms::construction::KroneckerReparamResult;
2709    use ndarray::{Array1, Array2, array};
2710
2711    #[test]
2712    fn kronecker_diagonal_double_penalty_hits_only_joint_null_space() {
2713        let kron_result = KroneckerReparamResult {
2714            reparameterized_marginals: std::sync::Arc::new(Vec::new()),
2715            marginal_eigenvalues: std::sync::Arc::new(vec![array![0.0, 2.0], array![0.0, 3.0]]),
2716            marginal_qs: std::sync::Arc::new(Vec::new()),
2717            log_det: 0.0,
2718            det1: Array1::zeros(3),
2719            det2: Array2::zeros((3, 3)),
2720            has_double_penalty: true,
2721            marginal_dims: vec![2usize, 2usize],
2722        };
2723        let penalty = build_diagonal_penalty_from_kronecker(&kron_result, &[5.0, 7.0, 11.0]);
2724
2725        let PirlsPenalty::Diagonal {
2726            diag,
2727            positive_indices,
2728            ..
2729        } = penalty
2730        else {
2731            panic!("expected diagonal Kronecker PIRLS penalty");
2732        };
2733        // Pure `sum_d lambda_d * e_d[idx_d]`, plus `lambda_2` on the joint null
2734        // and nowhere else — which is what this test's NAME asserts, and what it
2735        // did not actually assert until `9c6c188b7` (#2623) deleted the hidden
2736        // `penalty_shrinkage_ridge`:
2737        //
2738        //   (0,0)  e = (0, 0)  joint null  -> lambda_2       = 11
2739        //   (0,1)  e = (0, 3)              -> 7*3            = 21
2740        //   (1,0)  e = (2, 0)              -> 5*2            = 10
2741        //   (1,1)  e = (2, 3)              -> 5*2 + 7*3      = 31
2742        //
2743        // The former `[11.0, 21.5, 10.5, 31.5]` is exactly this plus the `0.5`
2744        // rho-independent ridge on every non-null direction — the term #2623
2745        // removed as "a different model, not numerical conditioning", whose own
2746        // commit message says it deleted "tests that institutionalized it". This
2747        // one was missed, so it has been red on `main` since; the joint-null
2748        // entry is unchanged because the ridge was never added there.
2749        let expected = [11.0, 21.0, 10.0, 31.0];
2750        for (idx, expected_diag) in expected.iter().copied().enumerate() {
2751            assert!(
2752                (diag[idx] - expected_diag).abs() <= 1e-12,
2753                "diagonal {idx} got {}, expected {expected_diag}",
2754                diag[idx]
2755            );
2756        }
2757        assert_eq!(positive_indices, vec![0, 1, 2, 3]);
2758    }
2759}