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