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