Skip to main content

gam_solve/pirls/
state.rs

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