Skip to main content

gam_solve/pirls/
state.rs

1use crate::active_set::ConstraintKktDiagnostics;
2use crate::estimate::EstimationError;
3use gam_linalg::matrix::{
4    DesignMatrix, PsdWeightsView, ReparamOperator, SignedWeightsView, SymmetricMatrix,
5};
6use gam_problem::LinearInequalityConstraints;
7use gam_problem::{Coefficients, GlmLikelihoodSpec, InverseLink, LinearPredictor, RidgePassport};
8use gam_terms::construction::ReparamResult;
9use ndarray::{ArcArray1, Array1, Array2, ArrayView1};
10use serde::{Deserialize, Serialize};
11use std::sync::Arc;
12
13use super::{compute_observed_hessian_curvature_arrays, computeworkingweight_derivatives_from_eta};
14
15/// Whether the solve operates in sparse-native or dense-transformed coordinates.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum PirlsLinearSolvePath {
18    DenseTransformed,
19    SparseNative,
20}
21
22/// Coordinate frame for the PIRLS inner iteration.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PirlsCoordinateFrame {
25    TransformedQs,
26    OriginalSparseNative,
27}
28
29/// Firth bias-reduction diagnostics at convergence.
30#[derive(Debug, Clone, Default)]
31pub enum FirthDiagnostics {
32    #[default]
33    Inactive,
34    Active {
35        jeffreys_logdet: f64,
36        hat_diag: Array1<f64>,
37    },
38}
39
40impl FirthDiagnostics {
41    #[inline]
42    pub fn jeffreys_logdet(&self) -> Option<f64> {
43        match self {
44            Self::Inactive => None,
45            Self::Active {
46                jeffreys_logdet, ..
47            } => Some(*jeffreys_logdet),
48        }
49    }
50}
51
52/// Which information matrix the penalized Hessian carries at the current
53/// PIRLS iterate.
54///
55/// Canonical links (logit-Binomial, log-Poisson) have W_obs == W_Fisher, so
56/// the two choices coincide. Non-canonical links (probit, cloglog, mixture,
57/// flexible, Gamma-log, ...) need observed information W_obs = W_Fisher -
58/// (y - mu) * B for the outer REML/Laplace log|H| and trace terms to be
59/// exact; Fisher weights alone yield a PQL-type surrogate. We fall back to
60/// `Fisher` only when the observed-information Hessian fails the
61/// positive-definiteness check, since the inner Newton step must be SPD.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub enum HessianCurvatureKind {
64    /// Expected (Fisher) information: W_Fisher = h'^2 / (phi * V(mu)).
65    /// Used as the inner iteration matrix when observed curvature fails (non-SPD).
66    Fisher,
67    /// Observed information: W_obs = W_Fisher - (y - mu) * B.
68    /// Required for the outer REML log|H| and trace terms (exact Laplace).
69    Observed,
70}
71
72/// The exported Laplace curvature kind used for the outer REML criterion.
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub enum ExportedLaplaceCurvature {
75    ObservedExact,
76    ExpectedInformationSurrogate,
77    InvalidObservedCurvature {
78        min_eigenvalue: f64,
79        pd_tolerance: f64,
80        gradient_norm: f64,
81    },
82}
83
84/// Working state at a PIRLS iterate: gradient, Hessian, deviance, etc.
85#[derive(Debug, Clone)]
86pub struct WorkingState {
87    pub eta: LinearPredictor,
88    pub gradient: Array1<f64>,
89    pub hessian: gam_linalg::matrix::SymmetricMatrix,
90    /// Inner data log-kernel. A profiled Gaussian stores exactly `-D/2` for
91    /// conventional deviance `D`; likelihoods with a resolved physical scale
92    /// store the strict eta-space log-likelihood omitting response constants.
93    pub log_likelihood: f64,
94    pub deviance: f64,
95    pub penalty_term: f64,
96    pub firth: FirthDiagnostics,
97    // Ridge added to ensure positive definiteness of the penalized Hessian.
98    // `penalty_term` stores the full quadratic form contribution
99    // ridge * ||beta||^2. The optimization objective uses
100    // 0.5 * (deviance + penalty_term), so this corresponds to
101    // 0.5 * ridge * ||beta||^2 on the log-likelihood scale.
102    pub ridge_used: f64,
103    pub hessian_curvature: HessianCurvatureKind,
104    // Natural scale of the penalized gradient, used to form a scale-invariant
105    // KKT certificate.  Equal to ||X'(weighted_residual)||_2 + ||S*beta||_2
106    // (+ ridge*||beta||_2 when a stabilizing ridge is active).  Under
107    // stochastic noise the score component scales as O(sqrt(n)), so an
108    // absolute ||g||_2 < tol test rejects fits whose normalized stationarity
109    // residual is already negligible. Convergence uses ||g||_2 / (1 + this).
110    pub gradient_natural_scale: f64,
111}
112
113impl WorkingState {
114    /// Value minimized by PIRLS for this fully evaluated state.
115    #[inline]
116    pub fn penalized_objective(&self) -> f64 {
117        0.5 * (self.deviance + self.penalty_term)
118    }
119
120    #[inline]
121    pub fn jeffreys_logdet(&self) -> Option<f64> {
122        self.firth.jeffreys_logdet()
123    }
124
125    /// Scale-invariant relative gradient residual.
126    ///
127    /// Returns ||g||_2 / (1 + ||score||_2 + ||S*beta||_2 + ridge*||beta||_2).
128    /// `g_norm` is the projected/constrained stationarity residual in the
129    /// current PIRLS basis; the denominator is the natural magnitude of the
130    /// penalized gradient and is invariant under uniform rescaling of the
131    /// objective.
132    #[inline]
133    pub fn relative_gradient_norm(&self, g_norm: f64) -> f64 {
134        g_norm / (1.0 + self.gradient_natural_scale)
135    }
136
137    /// Dimension-based scale `√n · max(1, √p)` for the structural KKT bound.
138    ///
139    /// Under standardized columns, the score `Xᵀ(μ − y)` has components of
140    /// order O(√n), so the absolute test ‖g‖ < τ becomes systematically too
141    /// tight at large n. Multiplying τ by this scale restores the advertised
142    /// per-observation meaning.
143    #[inline]
144    pub(crate) fn kkt_dimension_scale(&self) -> f64 {
145        let n = self.eta.len().max(1) as f64;
146        let p = (self.gradient.len() as f64).max(1.0);
147        n.sqrt() * p.sqrt()
148    }
149
150    /// Strict KKT acceptance: `g_norm` certifies stationarity under EITHER
151    /// scale-invariant criterion (dimension-based or data-driven natural-scale).
152    ///
153    /// Both certificates are invariant under uniform rescaling of the objective
154    /// `F → c·F` (in the limit where the natural scale dominates the additive
155    /// `1` floor). Acceptance under either is sufficient because:
156    ///   - the natural-scale bound is tighter when the data are well-scaled
157    ///     (it tracks actual gradient component magnitudes);
158    ///   - the dimension bound is tighter when the design matrix has unusual
159    ///     scaling (so the natural scale is dominated by a single component).
160    #[inline]
161    pub fn certifies_kkt(&self, g_norm: f64, tol: f64) -> bool {
162        g_norm < tol * self.kkt_dimension_scale() || self.relative_gradient_norm(g_norm) < tol
163    }
164
165    /// Near-stationary band (10× the strict KKT tolerance) under EITHER
166    /// scale-invariant criterion. Used as a "good-enough" plateau check
167    /// that classifies a fit as `StalledAtValidMinimum` rather than as a
168    /// hard non-convergence. The band is `10 · tol` without a
169    /// floor — a caller asking for `tol = 1e-12` gets a 1e-11 band, not
170    /// the 1e-5 the old `tol.max(1e-6) * 10` formula silently widened it
171    /// to. The 1e-6 floor was masking real convergence regressions
172    /// (e.g. `constant_prior_mean_centers_penalty`'s LM-ridge induced
173    /// 2.5e-8 bias visible only when the user asked for sub-1e-6
174    /// precision).
175    #[inline]
176    pub fn near_stationary_kkt(&self, g_norm: f64, tol: f64) -> bool {
177        let near_tol = tol * 10.0;
178        g_norm <= near_tol * self.kkt_dimension_scale()
179            || self.relative_gradient_norm(g_norm) <= near_tol
180    }
181}
182
183/// Numerically stable Euclidean norm of an `Array1<f64>`.
184///
185/// Used to assemble the penalized-gradient natural scale at every
186/// `WorkingState` construction site (main GAM, identity-link short circuit,
187/// survival, test mocks). Centralizing here avoids drift between sites and
188/// makes the convergence certificate's denominator a single source of truth.
189///
190/// One pass, no allocation, O(p). At p≈10⁴ the cost is ≪ the O(np²) PIRLS
191/// inner work, so this is free in any setting where it matters.
192#[inline]
193pub fn array1_l2_norm(v: &Array1<f64>) -> f64 {
194    v.iter().map(|x| x * x).sum::<f64>().sqrt()
195}
196
197/// Adaptive KKT tolerance parameters for the inner PIRLS convergence test.
198#[derive(Clone, Copy, Debug)]
199pub struct AdaptiveKktTolerance {
200    pub eta: f64,
201    pub floor: f64,
202    pub ceiling: f64,
203    pub outer_grad_norm: f64,
204}
205
206/// Per-iteration PIRLS diagnostic info reported to the callback.
207#[derive(Clone, Debug)]
208pub struct WorkingModelIterationInfo {
209    pub iteration: usize,
210    pub deviance: f64,
211    pub gradient_norm: f64,
212    pub step_size: f64,
213    pub step_halving: usize,
214}
215
216/// Result of the inner `runworking_model_pirls` loop.
217#[derive(Clone)]
218pub struct WorkingModelPirlsResult {
219    pub beta: Coefficients,
220    pub state: WorkingState,
221    pub status: PirlsStatus,
222    pub iterations: usize,
223    pub lastgradient_norm: f64,
224    pub last_deviance_change: f64,
225    pub last_step_size: f64,
226    pub last_step_halving: usize,
227    pub max_abs_eta: f64,
228    pub constraint_kkt: Option<ConstraintKktDiagnostics>,
229    /// Levenberg-Marquardt damping coefficient at the last accepted
230    /// inner iter. Used by the REML runtime to seed the next PIRLS call
231    /// at the same outer fit, avoiding 4-6 iters of damping rediscovery
232    /// when the geometry calls for `λ_LM > 1e-6`.
233    pub final_lm_lambda: f64,
234    /// Gain ratio (`actual_reduction / predicted_reduction`) at the
235    /// last accepted inner iter. `None` when no step was accepted
236    /// (rejection-exhausted, MaxIterationsReached without acceptance).
237    /// Programmatic counterpart to the per-iter `[PIRLS lm-trajectory]`
238    /// log line's `accept_rho` field — the log is grep-only, this
239    /// field is queryable by the outer schedule and convergence guard.
240    /// Values near 1.0 indicate the quadratic model is faithful;
241    /// values much smaller indicate the LM model is over-stating
242    /// predicted reduction and the inner Newton may benefit from
243    /// shorter steps.
244    pub final_accept_rho: Option<f64>,
245    /// Minimum penalized objective (`½(state.deviance + state.penalty_term)`)
246    /// observed across all iterations whose state was computed during the
247    /// inner P-IRLS loop. The penalized objective is monotonically decreasing
248    /// along any descent path the inner solver takes, so this minimum is a
249    /// principled seed-screening proxy that remains meaningful even when the
250    /// solver hit its iteration cap before reaching the mode. `f64::INFINITY`
251    /// when no state was ever computed (paths that synthesize a result
252    /// without iterating, e.g. zero-iteration warm-only paths).
253    pub min_penalized_deviance: f64,
254    pub exported_laplace_curvature: ExportedLaplaceCurvature,
255}
256
257/// The status of the P-IRLS convergence.
258#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
259pub enum PirlsStatus {
260    /// Converged successfully within tolerance.
261    Converged,
262    /// Reached maximum iterations but the gradient and Hessian indicate a valid minimum.
263    StalledAtValidMinimum,
264    /// Reached maximum iterations without converging.
265    MaxIterationsReached,
266    /// Levenberg-Marquardt step search exhausted its retry budget (damping λ
267    /// reached its ceiling, attempts counter expired, or λ went non-finite)
268    /// before the projected gradient entered the near-stationary band. Distinct
269    /// from `MaxIterationsReached`, which means the outer iteration counter
270    /// itself ran out — that exhaustion is a "looped 100×, made progress each
271    /// time but never converged" signal, while this one is a "no acceptable
272    /// step direction even after damping" signal pointing at curvature trouble
273    /// or saturated likelihoods.
274    LmStepSearchExhausted,
275    /// Fitting process became unstable, likely due to perfect separation.
276    Unstable,
277}
278
279impl PirlsStatus {
280    /// Whether the inner loop concluded without producing a usable mode.
281    /// Both the iteration-cap and LM-exhausted exits should be treated the
282    /// same by callers that just want to know "did we get a valid solution?".
283    #[inline]
284    pub const fn is_failed_max_iterations(self) -> bool {
285        matches!(
286            self,
287            PirlsStatus::MaxIterationsReached | PirlsStatus::LmStepSearchExhausted
288        )
289    }
290
291    /// Short human-readable label for reports and diagnostics. Stable text
292    /// (not the `Debug` rendering) so report output does not silently change if
293    /// the variant identifiers are ever renamed.
294    #[inline]
295    pub const fn label(self) -> &'static str {
296        match self {
297            PirlsStatus::Converged => "Converged",
298            PirlsStatus::StalledAtValidMinimum => "Stalled at valid minimum",
299            PirlsStatus::MaxIterationsReached => "Max iterations reached",
300            PirlsStatus::LmStepSearchExhausted => "LM step search exhausted",
301            PirlsStatus::Unstable => "Unstable (possible separation)",
302        }
303    }
304
305    /// Whether this status represents a clean convergence to the mode. Only
306    /// `Converged` qualifies; every other state carries a caveat a reader
307    /// should see flagged.
308    #[inline]
309    pub const fn is_converged(self) -> bool {
310        matches!(self, PirlsStatus::Converged)
311    }
312
313    /// Whether this status certifies a stationary inner mode that may mint a
314    /// fit. `StalledAtValidMinimum` qualifies alongside `Converged`: it is
315    /// *only* produced when the iteration/LM budget was exhausted AT a point
316    /// that already passed the near-stationary soft-acceptance band (a
317    /// KKT-tolerance-gated plateau / boundary-saturation / relative-decrement
318    /// certificate — see `reweight.rs` final-state certification and the
319    /// `loop_driver.rs` `stalled_at_valid_minimum` rescue). Per the variant's
320    /// own contract, "the gradient and Hessian indicate a valid minimum"; the
321    /// exhausted resource is the iteration counter, not the validity of the
322    /// mode. `MaxIterationsReached` and `LmStepSearchExhausted` (which never
323    /// cleared that band) and `Unstable` (separation) do NOT qualify.
324    ///
325    /// This is deliberately broader than [`Self::is_converged`], which keeps
326    /// its strict "clean convergence, only `Converged`" meaning for callers
327    /// that must distinguish a strict-KKT mode from a plateau-rescued one.
328    #[inline]
329    pub const fn is_certified_minimum(self) -> bool {
330        matches!(
331            self,
332            PirlsStatus::Converged | PirlsStatus::StalledAtValidMinimum
333        )
334    }
335}
336
337/// Holds the result of a converged P-IRLS inner loop for a fixed rho.
338///
339/// # Basis of Returned Tensors
340///
341/// **IMPORTANT:** All vector and matrix outputs in this struct (`beta_transformed`,
342/// `penalized_hessian_transformed`) are in the **stable, transformed basis**
343/// that was computed for the given set of smoothing parameters.
344///
345/// To obtain coefficients in the original, interpretable basis, the caller must
346/// back-transform them using the `qs` matrix from the `reparam_result` field:
347/// `beta_original = reparam_result.qs.dot(&beta_transformed)`
348///
349/// # Fields
350///
351/// * `beta_transformed`: The estimated coefficient vector in the STABLE, TRANSFORMED basis.
352/// * `penalized_hessian_transformed`: The penalized Hessian matrix at convergence
353///   (`X'W_H X + S_λ`, with `W_H` equal to Fisher or observed curvature,
354///   depending on the accepted PIRLS step) in the STABLE, TRANSFORMED basis.
355/// * `deviance`: The final deviance value. This is family-specific:
356///    - Gaussian identity: weighted residual sum of squares.
357///    - Binomial families: binomial deviance.
358///    - Poisson log: Poisson deviance.
359///    - Gamma log: Gamma unit deviance scaled by the fitted Gamma shape.
360/// * `finalweights`: The final Hessian-side working weights at convergence.
361/// * `solveweights`: The final score-side Fisher weights used in
362///   `X'W(z-eta) - S beta`.
363/// * `reparam_result`: Contains the transformation matrix (`qs`) and other reparameterization data.
364///
365/// # Point Estimate: Posterior Mode (MAP)
366///
367/// The coefficients returned by PIRLS are the **posterior mode** (Maximum A Posteriori estimate),
368/// not the posterior mean. For risk predictions, the posterior mean is theoretically preferable
369/// mode ≈ mean and it doesn't matter. For asymmetric posteriors (rare events, boundary effects),
370/// the mean would give more accurate calibrated probabilities. To obtain the posterior mean,
371/// one would need MCMC sampling from the posterior and average f(patient, β) over samples.
372#[derive(Clone)]
373pub struct PirlsResult {
374    pub likelihood: GlmLikelihoodSpec,
375    // Coefficients and Hessian are now in the STABLE, TRANSFORMED basis
376    pub beta_transformed: Coefficients,
377    pub penalized_hessian_transformed: SymmetricMatrix,
378    // Single stabilized Hessian for consistent cost/gradient computation
379    pub stabilizedhessian_transformed: SymmetricMatrix,
380    /// Canonical ridge metadata passport consumed by outer objective/gradient code.
381    pub ridge_passport: RidgePassport,
382
383    // The unpenalized deviance, calculated from mu and y
384    pub deviance: f64,
385
386    // Effective degrees of freedom at the solution
387    pub edf: f64,
388
389    // The penalty term, calculated stably within P-IRLS.
390    // This is beta_transformed' * S_transformed * beta_transformed, plus
391    // ridge_used * ||beta||^2 when stabilization is active so that the
392    // penalized deviance matches the stabilized Hessian.
393    pub stable_penalty_term: f64,
394
395    /// Firth diagnostics in the converged PIRLS state.
396    pub firth: FirthDiagnostics,
397
398    // Diagonal weights defining the Hessian surface returned to outer REML/LAML.
399    //
400    // For canonical links Fisher = Observed identically. For non-canonical links,
401    // PIRLS always recomputes observed weights at the accepted β̂ in a
402    // post-convergence finalization step (see "Post-convergence Laplace curvature
403    // finalization"), so `finalweights` carries the *observed-information* diagonal
404    // whenever the model supports it — even if the inner LM loop ended on Fisher
405    // due to a fallback. Exact label of what these represent is in
406    // `exported_laplace_curvature`; do not infer the kind from `hessian_curvature`
407    // (which records what the inner loop's last accepted step happened to use).
408    // #1868: the length-`n` row fields are `ArcArray1` (reference-counted
409    // ndarray, O(1) clone) so the n-free κ-trial skip path can SHARE the
410    // once-built frozen row bundle across every trial instead of
411    // re-materialising these placeholders per callback. On the exact path they
412    // are built owned and moved into the shared representation via
413    // `.into_shared()` (O(1) — no element copy). `ArcArray1` is an `ArrayBase`,
414    // so reads (indexing, iteration, `.dot`, `&a - &b`, `.len`, `.view`) work
415    // unchanged; only sites needing an owned `Array1`/`&Array1` take
416    // `.to_owned()`/`.view()`.
417    pub finalweights: ArcArray1<f64>,
418    // Additional PIRLS state captured at the accepted step to support
419    // cost/gradient consistency in the outer optimization
420    pub final_offset: ArcArray1<f64>,
421    pub final_eta: ArcArray1<f64>,
422    pub finalmu: ArcArray1<f64>,
423    /// Score-side Fisher weights used in `X'W(z-eta) - S beta`.
424    pub solveweights: ArcArray1<f64>,
425    pub solveworking_response: ArcArray1<f64>,
426    pub solvemu: ArcArray1<f64>,
427    pub solve_dmu_deta: ArcArray1<f64>,
428    pub solve_d2mu_deta2: ArcArray1<f64>,
429    pub solve_d3mu_deta3: ArcArray1<f64>,
430    /// First eta-derivative of the diagonal Hessian curvature W_H(eta):
431    /// c_i := dW_i/deta_i at the accepted PIRLS solution.
432    ///
433    /// This carries 3rd-order likelihood information used in exact dH/dρ
434    /// terms for outer LAML derivatives.
435    pub solve_c_array: ArcArray1<f64>,
436    /// Second eta-derivative of the diagonal Hessian curvature W_H(eta):
437    /// d_i := d²W_i/deta_i² at the accepted PIRLS solution.
438    ///
439    /// This carries 4th-order likelihood information used in exact d²H/dρ²
440    /// terms for the outer LAML Hessian.
441    pub solve_d_array: ArcArray1<f64>,
442    /// True when `solve_c_array` / `solve_d_array` are placeholders rather
443    /// than supported likelihood derivatives.
444    pub derivatives_unsupported: bool,
445
446    // Keep all other fields as they are
447    pub status: PirlsStatus,
448    pub iteration: usize,
449    pub max_abs_eta: f64,
450    pub lastgradient_norm: f64,
451    /// Natural scale of the penalized gradient at the accepted PIRLS state,
452    /// equal to ‖Xᵀ(weighted residual)‖₂ + ‖Sβ‖₂ (+ ridge·‖β‖₂ when active).
453    /// Mirrors `WorkingState::gradient_natural_scale` so that callers reading
454    /// `PirlsResult` directly (e.g. seed-screening cost augmentation) can form
455    /// the scale-invariant residual r_g = ‖g‖ / (1 + this) without rebuilding
456    /// the score and penalty norms.
457    pub gradient_natural_scale: f64,
458    /// Penalized inner KKT residual `r = ∇_β L_pen(β̂) = Sβ̂ − ∇ℓ(β̂) (+ridge·β̂)`
459    /// at the accepted P-IRLS iterate, in the STABLE/TRANSFORMED coefficient
460    /// basis (the same frame as `beta_transformed` and the transformed penalized
461    /// Hessian). This is the exact vector whose L2 norm `lastgradient_norm`
462    /// records (see `WorkingState::gradient`, assembled as `Xᵀ(η−z)·w + Sβ`,
463    /// which equals `Sβ − ∇ℓ` because `Xᵀ(η−z)·w = −∇ℓ`). Storing the vector —
464    /// not just its norm — lets the outer REML/LAML evaluator engage the
465    /// inner-KKT envelope correction `Ṽ = V − ½·rᵀH⁻¹r` on design-moving
466    /// flexible-link and ψ/anisotropy paths, where the outer optimizer may
467    /// accept β̂ at a first-order inner cap short of exact stationarity. The
468    /// correction and its θ-gradient vanish as `r → 0`, so a fully-converged
469    /// fit is unchanged. See [`crate::model_types::ProjectedKktResidual`].
470    pub penalized_gradient_transformed: Array1<f64>,
471    pub last_deviance_change: f64,
472    pub last_step_halving: usize,
473    pub hessian_curvature: HessianCurvatureKind,
474    pub exported_laplace_curvature: ExportedLaplaceCurvature,
475    /// Levenberg-Marquardt damping coefficient at the converged inner
476    /// iter. Cached by the REML runtime so the next PIRLS call in the
477    /// same outer optimization can seed `λ_LM` to this value instead
478    /// of cold-starting at `1e-6`. Mirrors `WorkingModelPirlsResult::final_lm_lambda`.
479    pub final_lm_lambda: f64,
480    /// Gain ratio of the last accepted LM step inside this PIRLS solve,
481    /// `None` when no step was accepted (e.g. zero-iteration synthesis,
482    /// rejection-exhausted, MaxIterations without acceptance). Mirrors
483    /// `WorkingModelPirlsResult::final_accept_rho`. Programmatic
484    /// counterpart to the per-iter `[PIRLS lm-trajectory]` log line's
485    /// `accept_rho` field, queryable by outer consumers (cap schedule,
486    /// convergence guard) for inner-Newton model-fidelity decisions.
487    pub final_accept_rho: Option<f64>,
488    /// Optional KKT diagnostics when inequality constraints were active.
489    pub constraint_kkt: Option<ConstraintKktDiagnostics>,
490    /// Linear inequality system enforced in transformed PIRLS coordinates:
491    /// `A * beta_transformed >= b`.
492    pub linear_constraints_transformed: Option<LinearInequalityConstraints>,
493
494    // Pass through the entire reparameterization result for use in the gradient
495    pub reparam_result: ReparamResult,
496    // Cached X·Qs for this PIRLS result (transformed design matrix)
497    pub x_transformed: DesignMatrix,
498    pub coordinate_frame: PirlsCoordinateFrame,
499    /// True when this fixed-rho inner solve completed on a GPU path.
500    pub used_device: bool,
501    /// True when this result was compacted for REML LRU storage and needs
502    /// cold artifacts (for example `x_transformed`) rehydrated before exact
503    /// bundle construction.
504    pub cache_compacted: bool,
505    /// Minimum penalized objective observed across the inner P-IRLS loop.
506    /// Mirrors `WorkingModelPirlsResult::min_penalized_deviance`. Used as the
507    /// seed-screening ranking proxy: the penalized objective descends monotonically
508    /// along any inner descent path, so the per-seed minimum tells the outer
509    /// cascade "how good a fit this rho's neighbourhood can support" even
510    /// when the inner solver was capped before reaching the mode.
511    pub min_penalized_deviance: f64,
512}
513
514impl PirlsResult {
515    /// Export the stabilized transformed Hessian as an exact dense matrix for
516    /// downstream solve paths that require explicit Hessians.
517    ///
518    /// The returned matrix is the convergence Hessian already used by PIRLS and
519    /// REML (`X'W_HX + S_λ`, plus the explicit stabilization ridge when active).
520    /// Sparse-native fits are materialized from their assembled sparse Hessian;
521    /// no numerical Hessian approximation or compatibility fallback is used.
522    pub fn dense_stabilizedhessian_transformed(
523        &self,
524        context: &str,
525    ) -> Result<Array2<f64>, EstimationError> {
526        self.stabilizedhessian_transformed
527            .try_to_dense_exact(context)
528            .map_err(EstimationError::InvalidInput)
529    }
530
531    #[inline]
532    pub fn jeffreys_logdet(&self) -> Option<f64> {
533        self.firth.jeffreys_logdet()
534    }
535
536    /// Typed view of the Hessian-side working weight diagonal stored on this
537    /// result, sign-honest. `finalweights` carries the observed-information
538    /// diagonal whenever the model supports it (see `exported_laplace_curvature`),
539    /// and observed weights `W_obs = W_F - (y - μ) · B` can be negative for
540    /// non-canonical links. Consumers feeding this into the asymmetric
541    /// `X_iᵀ W X_j` path, `weighted_crossprod_dense_rows`, or
542    /// `xt_diag_x_signed_op` must use this typed view rather than borrowing
543    /// the raw `Array1<f64>` so the function-boundary type contract from
544    /// `linalg/matrix.rs` is construction-enforced.
545    #[inline]
546    pub fn final_weights_signed(&self) -> SignedWeightsView<'_> {
547        SignedWeightsView::new(self.finalweights.view())
548    }
549
550    /// Typed view of the score-side Fisher weights `W_F = h'²/(φ V(μ)) ≥ 0`
551    /// stored on this result, PSD-by-construction. Used by PSD-Gram kernels
552    /// (`dense_xtwx_view`, `sparse_csr_weighted_xtwx_*`, `xt_diag_x_psd_op`)
553    /// without a runtime sign scan; the PSD obligation is discharged
554    /// algebraically by the Fisher formula at the construction site in
555    /// `solver/pirls/mod.rs`. New callers that need the same diagonal under
556    /// a sign-honest API should route through `as_signed()` on the returned
557    /// view rather than reconstructing from the raw array.
558    #[inline]
559    pub fn solve_weights_psd(&self) -> PsdWeightsView<'_> {
560        PsdWeightsView::from_view_unchecked(self.solveweights.view())
561    }
562
563    /// Scale-invariant relative gradient residual at the accepted PIRLS state.
564    ///
565    /// Returns ‖g‖ / (1 + ‖score‖ + ‖Sβ‖ + ridge·‖β‖). Numerator is
566    /// `lastgradient_norm`; denominator is `1 + gradient_natural_scale`.
567    /// This is the "r_g" used by seed-screening cost augmentation.
568    #[inline]
569    pub fn relative_gradient_norm(&self) -> f64 {
570        self.lastgradient_norm / (1.0 + self.gradient_natural_scale)
571    }
572
573    pub(crate) fn compact_for_reml_cache(&self) -> Self {
574        Self {
575            likelihood: self.likelihood.clone(),
576            beta_transformed: self.beta_transformed.clone(),
577            penalized_hessian_transformed: self.penalized_hessian_transformed.clone(),
578            stabilizedhessian_transformed: self.stabilizedhessian_transformed.clone(),
579            ridge_passport: self.ridge_passport,
580            deviance: self.deviance,
581            edf: self.edf,
582            stable_penalty_term: self.stable_penalty_term,
583            firth: self.firth.clone(),
584            finalweights: ArcArray1::zeros(0),
585            final_offset: ArcArray1::zeros(0),
586            final_eta: self.final_eta.clone(),
587            finalmu: ArcArray1::zeros(0),
588            solveweights: self.solveweights.clone(),
589            solveworking_response: self.solveworking_response.clone(),
590            solvemu: self.solvemu.clone(),
591            solve_dmu_deta: ArcArray1::zeros(0),
592            solve_d2mu_deta2: ArcArray1::zeros(0),
593            solve_d3mu_deta3: ArcArray1::zeros(0),
594            solve_c_array: self.solve_c_array.clone(),
595            solve_d_array: self.solve_d_array.clone(),
596            derivatives_unsupported: self.derivatives_unsupported,
597            status: self.status,
598            iteration: self.iteration,
599            max_abs_eta: self.max_abs_eta,
600            lastgradient_norm: self.lastgradient_norm,
601            gradient_natural_scale: self.gradient_natural_scale,
602            // Length-p vector; carried across compaction/rehydration so the
603            // inner-KKT envelope correction survives an LRU round-trip without
604            // rebuilding the score from the (dropped) transformed design.
605            penalized_gradient_transformed: self.penalized_gradient_transformed.clone(),
606            last_deviance_change: self.last_deviance_change,
607            last_step_halving: self.last_step_halving,
608            hessian_curvature: self.hessian_curvature,
609            exported_laplace_curvature: self.exported_laplace_curvature.clone(),
610            final_lm_lambda: self.final_lm_lambda,
611            final_accept_rho: self.final_accept_rho,
612            constraint_kkt: self.constraint_kkt.clone(),
613            linear_constraints_transformed: self.linear_constraints_transformed.clone(),
614            reparam_result: self.reparam_result.clone(),
615            x_transformed: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
616                Array2::zeros((0, 0)),
617            )),
618            coordinate_frame: self.coordinate_frame,
619            used_device: self.used_device,
620            cache_compacted: true,
621            min_penalized_deviance: self.min_penalized_deviance,
622        }
623    }
624
625    pub(crate) fn rehydrate_after_reml_cache(
626        &self,
627        x_original: &DesignMatrix,
628        y: ArrayView1<'_, f64>,
629        priorweights: ArrayView1<'_, f64>,
630        offset: ArrayView1<'_, f64>,
631        inverse_link: &InverseLink,
632    ) -> Result<Self, EstimationError> {
633        if !self.cache_compacted {
634            return Ok(self.clone());
635        }
636
637        // #1868: cold LRU rehydration path — materialise the compacted rows from
638        // the frozen link/derivatives and re-wrap into the shared `ArcArray1`
639        // fields (`.into()`, O(1) once owned).
640        let final_eta_owned = self.final_eta.to_owned();
641        let (score_c_array, score_d_array, solve_dmu_deta, solve_d2mu_deta2, solve_d3mu_deta3) =
642            computeworkingweight_derivatives_from_eta(
643                &self.likelihood,
644                inverse_link,
645                &final_eta_owned,
646                priorweights,
647            )?;
648        let (finalweights, solve_c_array, solve_d_array): (
649            ArcArray1<f64>,
650            ArcArray1<f64>,
651            ArcArray1<f64>,
652        ) = if self.hessian_curvature == HessianCurvatureKind::Observed {
653            let (fw, sc, sd) = compute_observed_hessian_curvature_arrays(
654                &self.likelihood,
655                inverse_link,
656                &final_eta_owned,
657                y,
658                &self.solveweights.to_owned(),
659                priorweights,
660            )?;
661            (fw.into(), sc.into(), sd.into())
662        } else {
663            (
664                self.solveweights.clone(),
665                score_c_array.clone().into(),
666                score_d_array.clone().into(),
667            )
668        };
669        // Lazy rehydration: wrap in ReparamOperator instead of materializing X·Qs.
670        let qs_arc = Arc::new(self.reparam_result.qs.clone());
671        Ok(Self {
672            likelihood: self.likelihood.clone(),
673            beta_transformed: self.beta_transformed.clone(),
674            penalized_hessian_transformed: self.penalized_hessian_transformed.clone(),
675            stabilizedhessian_transformed: self.stabilizedhessian_transformed.clone(),
676            ridge_passport: self.ridge_passport,
677            used_device: self.used_device,
678            deviance: self.deviance,
679            edf: self.edf,
680            stable_penalty_term: self.stable_penalty_term,
681            firth: self.firth.clone(),
682            finalweights,
683            final_offset: offset.to_owned().into(),
684            final_eta: self.final_eta.clone(),
685            finalmu: self.solvemu.clone(),
686            solveweights: self.solveweights.clone(),
687            solveworking_response: self.solveworking_response.clone(),
688            solvemu: self.solvemu.clone(),
689            solve_dmu_deta: solve_dmu_deta.into(),
690            solve_d2mu_deta2: solve_d2mu_deta2.into(),
691            solve_d3mu_deta3: solve_d3mu_deta3.into(),
692            solve_c_array,
693            solve_d_array,
694            derivatives_unsupported: self.derivatives_unsupported,
695            status: self.status,
696            iteration: self.iteration,
697            max_abs_eta: self.max_abs_eta,
698            lastgradient_norm: self.lastgradient_norm,
699            gradient_natural_scale: self.gradient_natural_scale,
700            // Length-p vector; carried across compaction/rehydration so the
701            // inner-KKT envelope correction survives an LRU round-trip without
702            // rebuilding the score from the (dropped) transformed design.
703            penalized_gradient_transformed: self.penalized_gradient_transformed.clone(),
704            last_deviance_change: self.last_deviance_change,
705            last_step_halving: self.last_step_halving,
706            hessian_curvature: self.hessian_curvature,
707            exported_laplace_curvature: self.exported_laplace_curvature.clone(),
708            final_lm_lambda: self.final_lm_lambda,
709            final_accept_rho: self.final_accept_rho,
710            constraint_kkt: self.constraint_kkt.clone(),
711            linear_constraints_transformed: self.linear_constraints_transformed.clone(),
712            reparam_result: self.reparam_result.clone(),
713            x_transformed: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
714                Arc::new(ReparamOperator::new(x_original.clone(), qs_arc)),
715            )),
716            coordinate_frame: self.coordinate_frame,
717            cache_compacted: false,
718            min_penalized_deviance: self.min_penalized_deviance,
719        })
720    }
721}