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