Skip to main content

gam_solve/pirls/
pls_solver.rs

1//! Penalized least-squares solver and Gaussian fast paths.
2//!
3//! Owns:
4//! - `GaussianFixedCache` — `XᵀWX`/`XᵀW(y−offset)` cache for the
5//!   Gaussian-Identity short-circuit that the REML outer loop reuses across
6//!   smoothing-parameter candidates.
7//! - `SparseXtwxPrecomputed` — the sparse-pattern-aligned twin of the above
8//!   for designs that take the sparse-native PIRLS path.
9//! - `solve_penalized_least_squares_implicit` — identity/Gaussian implicit
10//!   PLS, dense and sparse-native paths.
11
12use super::loop_driver::max_symmetric_asymmetry;
13use super::{
14    FIXED_STABILIZATION_RIDGE, PirlsPenalty, PirlsWorkspace, SparseXtWxCache, StablePLSResult,
15    WorkingReparamTransform, calculate_edf_from_sparse_factor,
16    calculate_edfwithworkspace_from_factor, ensure_sparse_positive_definitewithridge,
17    solve_sparse_spd,
18};
19use super::{
20    calculate_deviance_from_eta, computeworkingweight_derivatives_from_eta,
21    pirls_data_log_kernel_from_eta,
22};
23use crate::estimate::EstimationError;
24use faer::sparse::SparseColMat;
25use gam_linalg::faer_ndarray::{FaerLinalgError, array1_to_col_matmut};
26use gam_linalg::matrix::{DesignMatrix, LinearOperator, SymmetricMatrix};
27use gam_linalg::utils::{StableSolver, array_is_finite, inf_norm};
28use gam_problem::{Coefficients, GlmLikelihoodSpec, InverseLink, LinkFunction};
29use ndarray::{ArcArray1, Array1, Array2, ArrayView1, ShapeBuilder};
30use std::sync::Arc;
31
32/// #1868 / #1033: the once-built, ψ-invariant length-`n` row bundle for the
33/// Gaussian-identity n-free κ-trial *skip* path.
34///
35/// On that path the inner "solve" is a zero-iteration synthesis whose every
36/// length-`n` array is a trial-INVARIANT placeholder — the row predictions are
37/// not recomputed, so `η ≡ μ ≡ offset`, the working response `z ≡ y`, the
38/// score/Hessian weights `w ≡ priorweights`, and the working-weight
39/// derivatives are `computeworkingweight_derivatives_from_eta(offset)` — all
40/// functions of the frozen `(offset, y, weights)` and the fixed link, never of
41/// the trial ψ. Re-materialising them on every κ callback is the O(n)-per-call
42/// regression #1868 tracks (~16·n element touches per trial).
43///
44/// Building them **once** and sharing them by `ArcArray1` (a reference-counted
45/// ndarray whose `.clone()` is O(1)) lets each trial's `PirlsResult` reuse the
46/// same rows with zero per-callback row work, so the κ outer loop touches only
47/// k×k objects per trial — the #1033 architectural invariant. The two cached
48/// scalars (the P-IRLS data log-kernel at `μ=offset`,
49/// `max_abs_eta = ‖offset‖∞`) are the only other length-`n` reductions the
50/// synthesis performed per trial.
51#[derive(Debug, Clone)]
52pub struct GaussianFrozenRows {
53    /// `η ≡ μ ≡ offset` (identity link, stale rows) — shared by the
54    /// `final_offset`, `final_eta`, `finalmu`, and `solvemu` result fields.
55    pub eta: ArcArray1<f64>,
56    /// Working response `z ≡ y` — shared by `solveworking_response`.
57    pub z: ArcArray1<f64>,
58    /// Score/Hessian weights `w ≡ priorweights` — shared by `finalweights`
59    /// and `solveweights`.
60    pub weights: ArcArray1<f64>,
61    /// `dμ/dη` at `η=offset`.
62    pub solve_dmu_deta: ArcArray1<f64>,
63    /// `d²μ/dη²` at `η=offset`.
64    pub solve_d2mu_deta2: ArcArray1<f64>,
65    /// `d³μ/dη³` at `η=offset`.
66    pub solve_d3mu_deta3: ArcArray1<f64>,
67    /// `dW_H/dη` at `η=offset`.
68    pub solve_c_array: ArcArray1<f64>,
69    /// `d²W_H/dη²` at `η=offset`.
70    pub solve_d_array: ArcArray1<f64>,
71    /// Trial-invariant zero-iteration P-IRLS data log-kernel. For a profiled
72    /// Gaussian this is exactly negative one half of the raw weighted RSS, not
73    /// a physical unit-dispersion likelihood.
74    pub log_likelihood: f64,
75    /// `‖offset‖∞` — the trial-invariant `max_abs_eta`.
76    pub max_abs_eta: f64,
77}
78
79impl GaussianFrozenRows {
80    /// Build the ψ-invariant frozen row bundle ONCE from the fit's frozen
81    /// `(offset, y, weights)` and fixed link. This is the single O(n) reduction
82    /// the n-free κ loop is allowed to pay (it is amortised across every trial),
83    /// so every subsequent skip-path callback shares these rows O(1) and touches
84    /// zero length-`n` objects (#1868).
85    ///
86    /// The values are bit-identical to what the loop_driver stale-row synthesis
87    /// used to re-materialise per trial: `η ≡ μ ≡ offset` (the tensor path is
88    /// Gaussian-identity, so the row predictions are stale placeholders), the
89    /// working-weight derivatives are `computeworkingweight_derivatives_from_eta`
90    /// at `η=offset` (constant `(1,0,0,0,0)` for Gaussian-identity), and the two
91    /// scalars are the zero-iteration P-IRLS data log-kernel and `‖offset‖∞`.
92    pub(crate) fn build(
93        offset: ArrayView1<'_, f64>,
94        y: ArrayView1<'_, f64>,
95        weights: ArrayView1<'_, f64>,
96        likelihood: &GlmLikelihoodSpec,
97        inverse_link: &InverseLink,
98    ) -> Result<Self, EstimationError> {
99        let eta_owned = offset.to_owned();
100        let (solve_c_array, solve_d_array, solve_dmu_deta, solve_d2mu_deta2, solve_d3mu_deta3) =
101            computeworkingweight_derivatives_from_eta(
102                likelihood,
103                inverse_link,
104                &eta_owned,
105                weights,
106            )?;
107        let deviance = calculate_deviance_from_eta(
108            y.view(),
109            &eta_owned,
110            likelihood,
111            inverse_link,
112            weights.view(),
113        )?;
114        let log_likelihood = pirls_data_log_kernel_from_eta(
115            y,
116            &eta_owned,
117            likelihood,
118            inverse_link,
119            weights,
120            deviance,
121        )?;
122        let max_abs_eta = inf_norm(eta_owned.iter().copied());
123        Ok(Self {
124            eta: eta_owned.into_shared(),
125            z: y.to_owned().into_shared(),
126            weights: weights.to_owned().into_shared(),
127            solve_dmu_deta: solve_dmu_deta.into_shared(),
128            solve_d2mu_deta2: solve_d2mu_deta2.into_shared(),
129            solve_d3mu_deta3: solve_d3mu_deta3.into_shared(),
130            solve_c_array: solve_c_array.into_shared(),
131            solve_d_array: solve_d_array.into_shared(),
132            log_likelihood,
133            max_abs_eta,
134        })
135    }
136}
137
138/// Reusable `XᵀWX` and `XᵀW(y − offset)` for Gaussian + Identity REML fits.
139///
140/// The Gaussian-identity P-IRLS short-circuit solves a single linear system
141/// `(XᵀWX + Σ λ_k S_k + ρ·I) β = XᵀW(y − offset)`. The right-hand-side matrix
142/// and vector are independent of the smoothing parameters `λ`, so when the
143/// outer REML loop evaluates the same problem at many `(λ_1, …, λ_k)`
144/// candidates we only need to assemble them **once** before the loop and
145/// reuse them inside every inner PIRLS call.
146///
147/// Stored in *original* coordinates (no Qs rotation applied). When the
148/// inner solver uses a `WorkingReparamTransform`, it conjugates / projects
149/// these matrices on the fly — that step is O(p³) / O(p²), independent of N.
150#[derive(Debug)]
151pub struct GaussianFixedCache {
152    /// `XᵀWX` in the original coefficient basis. Symmetric, p × p.
153    pub xtwx_orig: Array2<f64>,
154    /// `XᵀW(y − offset)` in the original basis. Length p.
155    pub xtwy_orig: Array1<f64>,
156    /// `(y − offset)ᵀW(y − offset)`.
157    ///
158    /// Together with `xtwx_orig` and `xtwy_orig`, this is the last scalar
159    /// sufficient statistic needed to evaluate the Gaussian penalized RSS
160    /// exactly at any λ without re-streaming the rows.
161    pub centered_weighted_y_sq: f64,
162    /// When true, the caller is deliberately serving a design-moving trial from
163    /// sufficient statistics and the `DesignMatrix` rows on the current REML
164    /// surface may be a stale reference surface. Consumers must not apply those
165    /// rows for fitted values, RSS, or likelihood summaries.
166    pub row_prediction_is_stale: bool,
167    /// `XᵀWX` precomputed for the sparse path, aligned with the symbolic
168    /// pattern of `SparseXtWxCache::new(x)` on the original sparse design.
169    /// `None` when the design has no sparse form (e.g. dense-only fits).
170    ///
171    /// The sparse REML path rebuilds `H = XᵀWX + Sλ + δI` per outer
172    /// evaluation. For Gaussian-Identity the weights never change, so the
173    /// `XᵀWX` contribution is invariant across the outer loop and can be
174    /// scattered from this cached values vector instead of re-doing the
175    /// O(nnz²/n) SpGEMM each call.
176    pub xtwx_sparse_orig: Option<Arc<SparseXtwxPrecomputed>>,
177    /// #1868 / #1033: the once-built ψ-invariant frozen row bundle for the
178    /// n-free κ-trial skip path. Present exactly when `row_prediction_is_stale`
179    /// is `true` and the producer (`gaussian_fixed_cache_at` via
180    /// `install_psi_gram_statistics`) attached it. When present the Gaussian
181    /// zero-iteration inner synthesis shares these length-`n` placeholders O(1)
182    /// instead of re-materialising `offset`/`y`/`weights` and the working-weight
183    /// derivatives per trial. `None` on the exact (non-stale) path, where the
184    /// rows are freshly realised from the design.
185    pub frozen_rows: Option<Arc<GaussianFrozenRows>>,
186}
187
188/// Precomputed numerical values of `XᵀWX` aligned with the symbolic pattern
189/// that `SparseXtWxCache::new(x)` produces on its first call. Two such caches
190/// built from the same sparse `x` produce byte-identical symbolic patterns
191/// (faer's `sparse_sparse_matmul_symbolic` is deterministic), so the cached
192/// values can be installed back into a fresh `SparseXtWxCache` for the same
193/// `x` without rerunning the SpGEMM.
194///
195/// We snapshot the symbolic pattern (`col_ptr` / `row_idx`) alongside the
196/// values so the consumer can verify pattern equivalence and fall through to
197/// the per-call recomputation if anything diverges (e.g. an `x` with a
198/// different symbolic shape sneaks in).
199#[derive(Debug, Clone)]
200pub struct SparseXtwxPrecomputed {
201    pub xtwx_symbolic_col_ptr: Vec<usize>,
202    pub xtwx_symbolic_row_idx: Vec<usize>,
203    pub xtwxvalues: Vec<f64>,
204}
205
206impl SparseXtwxPrecomputed {
207    /// Build the precomputed `XᵀWX` value layout for `x` at the given
208    /// `weights`. The output reuses the same construction path the inner
209    /// PIRLS workspace uses, so it lands in exactly the symbolic pattern
210    /// the consumer expects.
211    pub fn build(
212        x: &SparseColMat<usize, f64>,
213        weights: &Array1<f64>,
214    ) -> Result<Self, EstimationError> {
215        let mut cache = SparseXtWxCache::new(x)?;
216        cache.compute_numeric(x, weights)?;
217        Ok(Self {
218            xtwx_symbolic_col_ptr: cache.xtwx_symbolic.col_ptr().to_vec(),
219            xtwx_symbolic_row_idx: cache.xtwx_symbolic.row_idx().to_vec(),
220            xtwxvalues: cache.xtwxvalues,
221        })
222    }
223}
224
225/// Identity-link solver that operates in original or QS-transformed coordinates
226/// without materializing X·Qs.  When the design is sparse and `qs` is `None`
227/// (sparse-native path), uses sparse Cholesky for O(nnz^{1.5}) cost instead
228/// of the O(p³) dense Cholesky.
229pub(super) fn solve_penalized_least_squares_implicit(
230    x_original: &DesignMatrix,
231    transform: Option<&WorkingReparamTransform>,
232    z: ArrayView1<f64>,
233    weights: ArrayView1<f64>,
234    offset: ArrayView1<f64>,
235    penalty: &PirlsPenalty,
236    workspace: &mut PirlsWorkspace,
237    y: ArrayView1<f64>,
238    link_function: LinkFunction,
239    gaussian_fixed_cache: Option<&GaussianFixedCache>,
240) -> Result<(StablePLSResult, usize), EstimationError> {
241    let p_dim = penalty.dim();
242
243    // ── Sparse-native fast path ──────────────────────────────────────────
244    // When design is sparse and we are in original coordinates (qs = None),
245    // assemble the penalized Hessian in sparse format and solve with sparse
246    // Cholesky.  This avoids O(p²) dense X'WX and O(p³) dense factorization.
247    if transform.is_none()
248        && let Some(x_sparse) = x_original.as_sparse()
249    {
250        let PirlsPenalty::Dense { s_transformed, .. } = penalty else {
251            crate::bail_invalid_estim!(
252                "sparse-native PIRLS requires a dense transformed penalty matrix"
253            );
254        };
255        let weights_owned = weights.to_owned();
256
257        // Gaussian-Identity fast path: the inner sparse `XᵀWX` is invariant
258        // across the outer REML loop because the IRLS weights are constant
259        // (W = priorweights). The cached values land in the inner workspace
260        // and bypass the per-eval SpGEMM.
261        let precomputed_xtwx =
262            gaussian_fixed_cache.and_then(|c| c.xtwx_sparse_orig.as_ref().map(|arc| arc.as_ref()));
263
264        // 1. Sparse penalized Hessian: H = X'diag(w)X + S_λ + ridge·I.
265        //    The Cholesky factor is reused from the SPD check so we avoid
266        //    factorizing the same matrix twice.
267        let (h_sparse, factor, ridge_used) = ensure_sparse_positive_definitewithridge(|ridge| {
268            let ridge = if ridge == 0.0 {
269                FIXED_STABILIZATION_RIDGE
270            } else {
271                ridge
272            };
273            workspace.assemble_sparse_penalized_hessian(
274                x_sparse,
275                &weights_owned,
276                s_transformed,
277                ridge,
278                precomputed_xtwx,
279            )
280        })?;
281
282        // 2. RHS = X'W(z - offset) + S_λ μ + ridge_used · μ.
283        // The `ridge_used · μ` term matches the diagonal ridge added to
284        // the Hessian in step 1, keeping the augmented system a
285        // Tikhonov regularization centered at the prior mean target
286        // rather than at zero (see `prior_mean_target` field docs).
287        let mut wz = z.to_owned();
288        wz -= &offset;
289        wz *= &weights_owned;
290        let mut rhs = x_original.transpose_vector_multiply(&wz);
291        rhs += penalty.linear_shift();
292        if ridge_used > 0.0 {
293            let prior_mean_target = penalty.prior_mean_target();
294            if prior_mean_target.len() == rhs.len() {
295                rhs.scaled_add(ridge_used, prior_mean_target);
296            }
297        }
298
299        // 3. Sparse Cholesky solve (factor reused from step 1)
300        let betavec = solve_sparse_spd(&factor, &rhs)?;
301
302        // 4. EDF — reuse the sparse Cholesky factor from step 1 to avoid a
303        // second O(nnz·…) factorization of the identical penalized Hessian.
304        let h_sym = SymmetricMatrix::Sparse(h_sparse);
305        let edf = calculate_edf_from_sparse_factor(&factor, penalty)?;
306
307        // 5. Scale. When Gaussian sufficient statistics are installed, compute
308        // RSS from k-space only; the design rows may be a stale reference
309        // surface on the #1033 ψ-tensor fast path.
310        let standard_deviation = match link_function {
311            LinkFunction::Identity => {
312                let weighted_rss = if let Some(cache) = gaussian_fixed_cache {
313                    let quadratic = betavec.dot(&cache.xtwx_orig.dot(&betavec));
314                    (cache.centered_weighted_y_sq - 2.0 * betavec.dot(&cache.xtwy_orig) + quadratic)
315                        .max(0.0)
316                } else {
317                    let fitted_vals = {
318                        let xb = x_original.apply(&betavec);
319                        let mut f = xb;
320                        f += &offset;
321                        f
322                    };
323                    let residuals = &y - &fitted_vals;
324                    weights
325                        .iter()
326                        .zip(residuals.iter())
327                        .map(|(&w, &r)| w * r * r)
328                        .sum()
329                };
330                let effective_n = y.len() as f64;
331                (weighted_rss / (effective_n - edf).max(1.0)).sqrt()
332            }
333            _ => 1.0,
334        };
335
336        return Ok((
337            StablePLSResult {
338                beta: Coefficients::new(betavec),
339                penalized_hessian: h_sym,
340                edf,
341                standard_deviation,
342                ridge_used,
343            },
344            p_dim,
345        ));
346    }
347
348    // ── Dense / QS-rotated path ──────────────────────────────────────────
349
350    // 1. Prepare weighted buffers
351    if workspace.wz.len() != z.len() {
352        workspace.wz = Array1::zeros(z.len());
353    }
354    workspace.wz.assign(&z);
355    workspace.wz -= &offset;
356    workspace.wz *= &weights;
357
358    // 2. Form X'WX: compute in original coordinates, then rotate by Qs.
359    //
360    // Gaussian + Identity REML reuses a precomputed `XᵀWX` (the weights and
361    // design never change across the outer loop in that family), so when the
362    // caller supplied a `GaussianFixedCache` we skip the O(N·p²) dense
363    // assembly here and adopt the cached matrix as-is.
364    let weights_owned = weights.to_owned();
365    let xtwx_orig = if let Some(cache) = gaussian_fixed_cache {
366        // Cache hit: weights and design are invariant for Gaussian-Identity
367        // across the outer REML loop, so adopt the precomputed XᵀWX directly
368        // and avoid the O(N·p²) dense assembly entirely.
369        let p = x_original.ncols();
370        if cache.xtwx_orig.nrows() != p || cache.xtwx_orig.ncols() != p {
371            return Err(EstimationError::InvalidInput(format!(
372                "GaussianFixedCache XᵀWX shape {}×{} does not match design p={}",
373                cache.xtwx_orig.nrows(),
374                cache.xtwx_orig.ncols(),
375                p,
376            )));
377        }
378        cache.xtwx_orig.clone()
379    } else {
380        match x_original {
381            // Only materialized dense designs can use the shared dense assembly path.
382            // Lazy operator-backed dense designs route to diag_xtw_x like sparse.
383            DesignMatrix::Dense(x_dense) if x_dense.is_materialized_dense() => {
384                let p = x_dense.ncols();
385                let x_dense = x_dense.to_dense_arc();
386                if workspace.hessian_buf.nrows() != p || workspace.hessian_buf.ncols() != p {
387                    workspace.hessian_buf = Array2::zeros((p, p).f());
388                } else {
389                    workspace.hessian_buf.fill(0.0);
390                }
391                PirlsWorkspace::add_dense_xtwx_signed(
392                    &weights_owned,
393                    &mut workspace.weighted_x_chunk,
394                    x_dense.as_ref(),
395                    &mut workspace.hessian_buf,
396                );
397                std::mem::take(&mut workspace.hessian_buf)
398            }
399            _ => {
400                // Operator-form fallback: sparse designs and lazy operator-backed
401                // dense designs cannot be densified, so route through the signed
402                // XᵀWX operator.
403                gam_linalg::matrix::xt_diag_x_signed(
404                    x_original,
405                    gam_linalg::matrix::FiniteSignedWeightsView::try_from_array(&weights_owned)
406                        .map_err(EstimationError::InvalidInput)?,
407                )
408                .map(|h| h.to_dense())
409                .map_err(EstimationError::InvalidInput)?
410            }
411        }
412    };
413    let xtwx_orig_asym = max_symmetric_asymmetry(&xtwx_orig);
414    let xtwx_transformed = if let Some(transform) = transform {
415        transform.conjugate_matrix(&xtwx_orig)
416    } else {
417        xtwx_orig
418    };
419    let mut penalized_hessian = xtwx_transformed.clone();
420    penalty.add_to_hessian(&mut penalized_hessian);
421
422    // 3. Form X'Wz: compute in original coordinates, then rotate.
423    //    With the Gaussian-Identity cache `z = y` and `wz = W·(y − offset)`
424    //    is identical across outer iterations, so reuse the precomputed
425    //    `XᵀW(y − offset)` directly.
426    let xtwy_orig = if let Some(cache) = gaussian_fixed_cache {
427        assert_eq!(
428            cache.xtwy_orig.len(),
429            x_original.ncols(),
430            "GaussianFixedCache XᵀW(y−offset) length must match design p"
431        );
432        cache.xtwy_orig.clone()
433    } else {
434        x_original.transpose_vector_multiply(&workspace.wz)
435    };
436    if workspace.vec_buf_p.len() != p_dim {
437        workspace.vec_buf_p = Array1::zeros(p_dim);
438    }
439    if let Some(transform) = transform {
440        workspace
441            .vec_buf_p
442            .assign(&transform.apply_transpose(&xtwy_orig));
443    } else {
444        workspace.vec_buf_p.assign(&xtwy_orig);
445    }
446    workspace.vec_buf_p += penalty.linear_shift();
447
448    {
449        // The penalized Hessian is assembled from symmetric pieces (XᵀWX and
450        // the penalty), so any asymmetry is pure floating-point accumulation
451        // error; anything above this floor signals a genuine assembly bug.
452        const PENALIZED_HESSIAN_ASYMMETRY_TOL: f64 = 1e-8;
453        let xtwx_asym = max_symmetric_asymmetry(&xtwx_transformed);
454        let penalty_asym = match penalty {
455            PirlsPenalty::Dense { s_transformed, .. } => max_symmetric_asymmetry(s_transformed),
456            PirlsPenalty::Diagonal { .. } => 0.0,
457        };
458        let total_asym = max_symmetric_asymmetry(&penalized_hessian);
459        assert!(
460            total_asym <= PENALIZED_HESSIAN_ASYMMETRY_TOL,
461            "implicit PLS penalized Hessian asymmetry too large: total={total_asym:.3e}, xtwx_orig={xtwx_orig_asym:.3e}, xtwx={xtwx_asym:.3e}, penalty={penalty_asym:.3e}, tol={PENALIZED_HESSIAN_ASYMMETRY_TOL:.3e}",
462        );
463    }
464
465    // 4. Ridge stabilization — CONDITIONAL, matching the sparse path
466    // (`ensure_sparse_positive_definitewithridge`) and the dense Newton path
467    // (`ensure_positive_definitewithridge`). A penalized Hessian assembled from
468    // `XᵀWX + S_λ` is mathematically PSD; a fixed tiny nugget is only needed to
469    // cure round-off when the bare matrix narrowly fails Cholesky. Applying the
470    // nugget UNCONDITIONALLY (the previous behaviour) made β̂ the stationary
471    // point of the RIDGED objective `½βᵀ(H+δI)β`, so the inner residual was
472    // `Xᵀu − S_λβ̂ = δβ̂` rather than 0. The outer REML ψ-gradient differentiates
473    // the BARE objective via the envelope theorem (it assumes exact
474    // stationarity), so the gratuitous δ broke the envelope identity: the
475    // analytic datafit derivative `a` was short by `½·δ·βᵀ(dβ̂/dψ)` and the
476    // β-independent `log|H|` term was differentiated on the un-ridged surface
477    // while the criterion VALUE used `log|H+δI|`. For the Matérn iso-κ joint
478    // REML at θ₀ (`TransformedQs` frame, δ_eff ≈ 1.75e-6 in the original basis)
479    // this is exactly the residual outer-gradient↔FD DESYNC of #1122 (gap
480    // 2.565e-2, with `cos(Xᵀu−S_λβ̂, β̂) = 1.0000` pinning the residual to the
481    // ridge gradient). Try the bare matrix first so the well-conditioned common
482    // case carries NO ridge (`ridge_used = 0`) and the envelope identity holds
483    // exactly; fall back to the Tikhonov nugget only when the bare factorization
484    // actually fails. The augmented RHS `r + δμ` keeps the fallback a Tikhonov
485    // regularization centered at the prior-mean target.
486    let bare_factor = StableSolver::new().factorize(&penalized_hessian).ok();
487    let (factor, ridge_used) = if let Some(factor) = bare_factor {
488        (factor, 0.0)
489    } else {
490        let nugget = FIXED_STABILIZATION_RIDGE;
491        let mut regularizedhessian = penalized_hessian.clone();
492        if nugget > 0.0 {
493            for i in 0..p_dim {
494                regularizedhessian[[i, i]] += nugget;
495            }
496        }
497        let factor = StableSolver::new()
498            .factorize(&regularizedhessian)
499            .map_err(EstimationError::LinearSystemSolveFailed)?;
500        (factor, nugget)
501    };
502
503    // 5. Solve
504    if workspace.rhs_full.len() != p_dim {
505        workspace.rhs_full = Array1::zeros(p_dim);
506    }
507    workspace.rhs_full.assign(&workspace.vec_buf_p);
508    if ridge_used > 0.0 {
509        let prior_mean_target = penalty.prior_mean_target();
510        if prior_mean_target.len() == p_dim {
511            workspace.rhs_full.scaled_add(ridge_used, prior_mean_target);
512        }
513    }
514    let mut rhsview = array1_to_col_matmut(&mut workspace.rhs_full);
515    factor.solve_in_place(rhsview.as_mut());
516    if !array_is_finite(&workspace.rhs_full) {
517        return Err(EstimationError::LinearSystemSolveFailed(
518            FaerLinalgError::FactorizationFailed {
519                context: "PIRLS implicit PLS non-finite solve",
520            },
521        ));
522    }
523    let betavec = workspace.rhs_full.clone();
524
525    // 6. EDF — reuse the factor already produced in step 5 to avoid a second
526    // O(p³) factorization of the identical regularized Hessian.
527    let edf = calculate_edfwithworkspace_from_factor(&factor, penalty, workspace)?;
528
529    // 7. Scale (composed: eta = offset + X Qs beta). When Gaussian sufficient
530    // statistics are installed, compute RSS from k-space only; the design rows
531    // may be a stale reference surface on the #1033 ψ-tensor fast path.
532    let qbeta = if let Some(transform) = transform {
533        transform.apply(&betavec)
534    } else {
535        betavec.clone()
536    };
537    let standard_deviation = match link_function {
538        LinkFunction::Identity => {
539            let weighted_rss = if let Some(cache) = gaussian_fixed_cache {
540                let quadratic = qbeta.dot(&cache.xtwx_orig.dot(&qbeta));
541                (cache.centered_weighted_y_sq - 2.0 * qbeta.dot(&cache.xtwy_orig) + quadratic)
542                    .max(0.0)
543            } else {
544                let xqbeta = x_original.apply(&qbeta);
545                let mut fitted = xqbeta;
546                fitted += &offset;
547                let residuals = &y - &fitted;
548                weights
549                    .iter()
550                    .zip(residuals.iter())
551                    .map(|(&w, &r)| w * r * r)
552                    .sum()
553            };
554            let effective_n = y.len() as f64;
555            (weighted_rss / (effective_n - edf).max(1.0)).sqrt()
556        }
557        _ => 1.0,
558    };
559
560    Ok((
561        StablePLSResult {
562            beta: Coefficients::new(betavec),
563            penalized_hessian: SymmetricMatrix::Dense(penalized_hessian),
564            edf,
565            standard_deviation,
566            ridge_used,
567        },
568        p_dim,
569    ))
570}