Skip to main content

gam_solve/inference/
alo.rs

1use crate::estimate::EstimationError;
2use crate::estimate::{FitGeometry, UnifiedFitResult};
3use crate::pirls;
4use faer::Mat as FaerMat;
5use faer::linalg::matmul::matmul;
6use faer::prelude::ReborrowMut;
7use faer::{Accum, Par};
8use gam_linalg::faer_ndarray::{FaerArrayView, FaerCholesky};
9use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
10use gam_linalg::utils::StableSolver;
11use opt::{BacktrackConfig, backtracking_line_search};
12use gam_problem::LinkFunction;
13use ndarray::{Array1, Array2, ArrayView1, ShapeBuilder, s};
14use std::convert::Infallible;
15use std::fmt;
16
17/// Typed error variants for the ALO (approximate leave-one-out) diagnostics
18/// module.
19///
20/// Public entry points continue to return `Result<_, EstimationError>`; this
21/// enum is materialized at leaf sites and converted at the boundary via
22/// `From<AloError> for EstimationError` so error text remains byte-identical
23/// to the previous `EstimationError::InvalidInput(format!(...))` /
24/// `ModelIsIllConditioned { ... }` output.
25#[derive(Debug, Clone)]
26pub enum AloError {
27    /// Caller-supplied configuration is structurally invalid: dimension
28    /// mismatch, non-finite inputs that are not weights/response, missing
29    /// PIRLS / geometry artifacts, or out-of-range scalar parameters.
30    InvalidInput { reason: String },
31    /// IRLS weights or working response contain a non-finite entry, or the
32    /// working response itself is invalid.
33    WeightInvalid { reason: String },
34    /// The dense design matrix required for ALO could not be materialized
35    /// from the underlying PIRLS artifact (e.g. sparse-only export).
36    DesignDegenerate { reason: String },
37    /// The penalized Hessian factorization failed, or downstream diagnostics
38    /// produced NaN values that indicate the influence matrix is unusable.
39    InfluenceMatrixFailed { condition_number: f64 },
40    /// Per-observation ALO computation produced a non-finite value (variance,
41    /// denominator, or corrected η̃) at convergence.
42    LooComputationFailed { reason: String },
43}
44
45impl fmt::Display for AloError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            AloError::InvalidInput { reason }
49            | AloError::WeightInvalid { reason }
50            | AloError::DesignDegenerate { reason }
51            | AloError::LooComputationFailed { reason } => f.write_str(reason),
52            AloError::InfluenceMatrixFailed { condition_number } => {
53                write!(
54                    f,
55                    "ALO influence matrix failed (condition number {condition_number:.3e})"
56                )
57            }
58        }
59    }
60}
61
62impl std::error::Error for AloError {}
63
64impl From<AloError> for EstimationError {
65    fn from(err: AloError) -> EstimationError {
66        match err {
67            AloError::InvalidInput { reason }
68            | AloError::WeightInvalid { reason }
69            | AloError::DesignDegenerate { reason }
70            | AloError::LooComputationFailed { reason } => EstimationError::InvalidInput(reason),
71            AloError::InfluenceMatrixFailed { condition_number } => {
72                EstimationError::ModelIsIllConditioned { condition_number }
73            }
74        }
75    }
76}
77
78impl From<AloError> for String {
79    fn from(err: AloError) -> String {
80        err.to_string()
81    }
82}
83
84/// Approximate leave-one-out diagnostics derived from a fitted model.
85#[derive(Debug, Clone)]
86pub struct AloDiagnostics {
87    pub eta_tilde: Array1<f64>,
88    /// Bayesian/conditional standard error on eta:
89    /// sqrt(phi * x_i^T H^{-1} x_i).
90    pub se_bayes: Array1<f64>,
91    /// Frequentist sandwich-style standard error on eta:
92    /// sqrt(phi * x_i^T H^{-1} X^T W X H^{-1} x_i).
93    pub se_sandwich: Array1<f64>,
94    pub pred_identity: Array1<f64>,
95    pub leverage: Array1<f64>,
96    pub fisherweights: Array1<f64>,
97}
98
99#[inline]
100fn alo_eta_updatewith_offset(
101    eta_hat: f64,
102    z: f64,
103    offset: f64,
104    x_hinv_x: f64,
105    score_weight: f64,
106    denom: f64,
107) -> f64 {
108    // PIRLS working-response algebra is centered on offset, so the scalar
109    // score uses (eta - offset) - (z - offset).
110    let eta_centered = eta_hat - offset;
111    let z_centered = z - offset;
112    let score = score_weight * (eta_centered - z_centered);
113    offset + eta_centered + x_hinv_x * score / denom
114}
115
116/// Per-row score and curvature of the penalized NLL contribution as functions
117/// of the row's linear predictor `eta`.
118///
119/// Returns `(ℓ_i'(eta), ℓ_i''(eta))` where `ℓ_i` is the (dispersion-scaled)
120/// negative log-likelihood of observation `i` viewed as a univariate function
121/// of `eta_i = x_i^T β`. This is the local family geometry that the ALO
122/// frozen-curvature fixed point [`alo_eta_exact_frozen_curvature`] iterates to
123/// convergence; supplying it upgrades the single-Newton-step ALO correction to
124/// the exact leave-`i`-out predictor under a frozen penalized Hessian.
125pub type AloScalarScoreCurvature<'a> = dyn Fn(usize, f64) -> (f64, f64) + Sync + 'a;
126
127/// Maximum scalar Newton iterations for the exact frozen-curvature ALO fixed
128/// point. The map `r(η) = η − η̂ − a_ii ℓ_i'(η)` is one-dimensional and
129/// strongly contractive for the well-leveraged majority of points, so this
130/// caps the rare high-leverage / near-separation rows where convergence is
131/// slow without ever exceeding O(1) work per observation.
132const ALO_EXACT_SCALAR_MAX_ITERS: usize = 64;
133
134/// Absolute convergence tolerance on the scalar residual `r(η)` for the exact
135/// frozen-curvature ALO fixed point. Well below the `1e-2` predictive bar the
136/// LOO comparison asserts, so the refinement is not the limiting error term.
137const ALO_EXACT_SCALAR_TOL: f64 = 1e-12;
138
139/// Solve the frozen-curvature ALO leave-`i`-out fixed point exactly.
140///
141/// The leave-`i`-out optimum differs from the full fit only through the removed
142/// observation, whose gradient/Hessian depend on `β` solely via the scalar
143/// `η_i = x_i^T β`. Freezing the penalized Hessian `H` at its converged value
144/// reduces the exact leave-`i`-out condition to the scalar equation
145///
146///   η = η̂_i + a_ii · ℓ_i'(η),     a_ii = x_i^T H^{-1} x_i,
147///
148/// where `ℓ_i'(η)` is the row's NLL score (so that `∇F = ℓ_i'(η_i) x_i` at the
149/// leave-`i`-out point). The single-Newton-step ALO is exactly the first
150/// iterate of Newton's method on `r(η) = η − η̂_i − a_ii ℓ_i'(η)` started at
151/// `η̂_i`; iterating to convergence captures the change in the held-out point's
152/// likelihood curvature (the dominant first-order error on small-`n`, curved
153/// likelihoods such as binomial logistic regression near separation).
154///
155/// `score_curvature(eta)` returns `(ℓ_i'(eta), ℓ_i''(eta))`. The returned value
156/// is the corrected linear predictor `η̃_i`. Failure to reach the residual
157/// tolerance is reported to the caller; no one-step approximation is substituted
158/// for a failed exact solve.
159#[derive(Debug, Clone, Copy, PartialEq)]
160enum AloExactScalarError {
161    NonFiniteScoreCurvature {
162        eta: f64,
163        ell_prime: f64,
164        ell_double: f64,
165    },
166    DegenerateJacobian {
167        eta: f64,
168        jacobian: f64,
169    },
170    NonFiniteStep {
171        eta: f64,
172        residual: f64,
173        jacobian: f64,
174        next: f64,
175    },
176    MaxIterations {
177        iterations: usize,
178        residual: f64,
179        eta: f64,
180    },
181}
182
183impl fmt::Display for AloExactScalarError {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match *self {
186            AloExactScalarError::NonFiniteScoreCurvature {
187                eta,
188                ell_prime,
189                ell_double,
190            } => write!(
191                f,
192                "non-finite score/curvature at eta={eta:.6e}: ell_prime={ell_prime:.6e}, ell_double={ell_double:.6e}"
193            ),
194            AloExactScalarError::DegenerateJacobian { eta, jacobian } => write!(
195                f,
196                "degenerate Newton Jacobian at eta={eta:.6e}: jacobian={jacobian:.6e}, min={ALO_DENOMINATOR_MIN:.1e}"
197            ),
198            AloExactScalarError::NonFiniteStep {
199                eta,
200                residual,
201                jacobian,
202                next,
203            } => write!(
204                f,
205                "non-finite Newton step from eta={eta:.6e}: residual={residual:.6e}, jacobian={jacobian:.6e}, next={next:.6e}"
206            ),
207            AloExactScalarError::MaxIterations {
208                iterations,
209                residual,
210                eta,
211            } => write!(
212                f,
213                "did not converge within {iterations} iterations: residual={residual:.6e}, eta={eta:.6e}, tol={ALO_EXACT_SCALAR_TOL:.1e}"
214            ),
215        }
216    }
217}
218
219/// Maximum number of step halvings in the backtracking line search that
220/// globalizes the scalar Newton iteration. `2^{-40}` shrinks a unit step well
221/// below `ALO_EXACT_SCALAR_TOL` relative to any η of practical magnitude, so a
222/// row that cannot make progress within this budget is genuinely stalled rather
223/// than merely under-damped.
224const ALO_EXACT_SCALAR_BACKTRACKS: usize = 40;
225
226#[inline]
227fn alo_eta_exact_frozen_curvature(
228    eta_hat: f64,
229    a_ii: f64,
230    score_curvature: &dyn Fn(f64) -> (f64, f64),
231) -> Result<f64, AloExactScalarError> {
232    // Residual of the leave-i-out fixed point η = η̂ + a_ii ℓ'(η):
233    //   r(η) = η − η̂ − a_ii ℓ'(η),     r'(η) = 1 − a_ii ℓ''(η) = jac.
234    // For an exponential-family NLL score ℓ'(η) = c_i(μ(η) − y) on a non-linear
235    // (e.g. log) link the curvature ℓ''(η) = c_i μ'(η) grows without bound, so
236    // r(η) is concave with an interior maximum where the weighted leverage
237    // a_ii ℓ'' passes 1 (jac = 0): the leave-i-out root that limits to η̂ as
238    // a_ii → 0 sits on the jac > 0 branch anchored at η̂, while beyond the
239    // maximum r turns over and diverges as μ(η) explodes.
240    //
241    // Two safeguards make the scalar solve globally convergent to that root:
242    //
243    //   1. Anchor the iteration at η̂ itself, not at the classical one-step ALO
244    //      predictor. At η̂ the weighted leverage a_ii ℓ''(η̂) < 1, so jac ≈ 1
245    //      and we start strictly inside the correct basin; the brute-force
246    //      n-fold reference solves the identical fixed point anchored at η̂.
247    //      Seeding at the one-step predictor instead can land a high-leverage
248    //      row *past* the interior maximum on the runaway branch, from which no
249    //      Newton iteration returns (Poisson/log row 198: η ≈ 6.3, r ≈ −577).
250    //
251    //   2. Backtrack on the merit ½r(η)². The Newton direction d = −r/jac
252    //      satisfies (½r²)'·d = r·jac·(−r/jac) = −r² < 0 for any finite nonzero
253    //      jac, so halving the step until |r| strictly decreases never leaves
254    //      the basin even if a full step would overshoot the maximum.
255    let residual_and_jac = |eta: f64| -> Result<(f64, f64), AloExactScalarError> {
256        let (ell_prime, ell_double) = score_curvature(eta);
257        if !ell_prime.is_finite() || !ell_double.is_finite() {
258            return Err(AloExactScalarError::NonFiniteScoreCurvature {
259                eta,
260                ell_prime,
261                ell_double,
262            });
263        }
264        Ok((eta - eta_hat - a_ii * ell_prime, 1.0 - a_ii * ell_double))
265    };
266
267    let mut eta = eta_hat;
268    let (mut residual, mut jac) = residual_and_jac(eta)?;
269    for _ in 0..ALO_EXACT_SCALAR_MAX_ITERS {
270        if residual.abs() <= ALO_EXACT_SCALAR_TOL {
271            return Ok(eta);
272        }
273        if jac.abs() <= ALO_DENOMINATOR_MIN || !jac.is_finite() {
274            return Err(AloExactScalarError::DegenerateJacobian { eta, jacobian: jac });
275        }
276        let step = residual / jac;
277        if !step.is_finite() {
278            return Err(AloExactScalarError::NonFiniteStep {
279                eta,
280                residual,
281                jacobian: jac,
282                next: eta - step,
283            });
284        }
285        // Backtracking line search: take the longest damped Newton step
286        // 2^{-k} that strictly reduces the merit |r|. A trial whose
287        // score/curvature evaluation errors (the runaway branch) is INVALID
288        // (`Ok(None)`), so the search retreats toward η̂ without consulting
289        // the merit test.
290        let accepted = match backtracking_line_search::<_, Infallible>(
291            BacktrackConfig {
292                max_steps: ALO_EXACT_SCALAR_BACKTRACKS,
293                ..BacktrackConfig::default()
294            },
295            |t| {
296                let trial = eta - t * step;
297                Ok(residual_and_jac(trial)
298                    .ok()
299                    .map(|(r_trial, j_trial)| (r_trial.abs(), (trial, r_trial, j_trial))))
300            },
301            |_t, merit| merit < residual.abs(),
302        ) {
303            Ok(result) => result,
304            Err(never) => match never {},
305        };
306        let Some(step) = accepted else {
307            break;
308        };
309        (eta, residual, jac) = step.payload;
310    }
311    Err(AloExactScalarError::MaxIterations {
312        iterations: ALO_EXACT_SCALAR_MAX_ITERS,
313        residual,
314        eta,
315    })
316}
317
318#[inline]
319fn bayesvar_eta(phi: f64, x_hinv_x: f64) -> f64 {
320    phi * x_hinv_x
321}
322
323#[inline]
324fn sandwichvar_eta_from_meat(phi: f64, meat_quad: f64) -> f64 {
325    phi * meat_quad
326}
327
328#[inline]
329fn variance_negative_tolerance(scale: f64) -> f64 {
330    // Tight relative tolerance for cancellation from x'H^{-1}x - ||E t||^2 - ridge||t||^2.
331    1e-12 * scale.abs().max(1.0)
332}
333
334const LEVERAGE_HIGH_THRESHOLD: f64 = 0.99;
335const LEVERAGE_VERY_HIGH_THRESHOLD: f64 = 0.999;
336const LEVERAGE_RATE_THRESHOLDS: [f64; 3] = [0.90, 0.95, 0.99];
337const LEVERAGE_PERCENTILES: [f64; 3] = [0.50, 0.95, 0.99];
338const ALO_DENOMINATOR_MIN: f64 = 1e-12;
339const MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
340
341/// Number of observation columns solved per blocked right-hand-side batch in the
342/// scalar-leverage path. Sizes the reusable `(p, .)` and `(e_rank, .)` scratch
343/// buffers so the dense multi-RHS solve stays BLAS-3 (good cache reuse) without
344/// materializing all `n` columns at once. The final batch is the remainder.
345const ALO_RHS_BLOCK_COLS: usize = 8192;
346
347/// Relative tolerance for accepting the input penalised Hessian `H` as
348/// symmetric. We require `|H_ij − H_ji| ≤ HESSIAN_SYMMETRY_REL_TOL ·
349/// max(|H_ij|, |H_ji|, 1)`. `1e-8` matches the loosest tolerance any
350/// upstream symmetrisation pass leaves on the matrix and is tight enough
351/// that a genuinely asymmetric Hessian (a real bug) is caught.
352const HESSIAN_SYMMETRY_REL_TOL: f64 = 1e-8;
353
354/// Diagonal ridge added to the local block precision when its LU pivot is
355/// below [`LU_PIVOT_SINGULAR_TOL`]. Matches the legacy `eps = 1e-6`
356/// regularisation in the prior `det_small < 1e-12` branch — bumping the
357/// determinant of `I − W A` (or `I − A W`) safely off zero without
358/// perturbing well-conditioned blocks.
359const ALO_LOCAL_BLOCK_RIDGE: f64 = 1e-6;
360
361/// Pivot magnitude below which [`lu_factor_in_place`] reports the block
362/// `I − W A` as singular and triggers the ridge-regularised refactor.
363/// Equivalent to the original `det_small < 1e-12` test on the unfactored
364/// determinant.
365const LU_PIVOT_SINGULAR_TOL: f64 = 1e-12;
366
367#[inline]
368fn percentile_index(sample_size: usize, quantile: f64) -> usize {
369    if sample_size <= 1 {
370        return 0;
371    }
372    let max_index = sample_size - 1;
373    ((quantile * max_index as f64).round() as usize).min(max_index)
374}
375
376#[inline]
377fn percentile_from_sorted(sorted: &[f64], quantile: f64) -> f64 {
378    if sorted.is_empty() {
379        0.0
380    } else {
381        sorted[percentile_index(sorted.len(), quantile)]
382    }
383}
384
385#[inline]
386fn multiblock_col_offsets(block_designs: &[Array2<f64>]) -> Vec<usize> {
387    let mut offsets = Vec::with_capacity(block_designs.len());
388    let mut off = 0usize;
389    for design in block_designs {
390        offsets.push(off);
391        off += design.ncols();
392    }
393    offsets
394}
395
396#[inline]
397fn multiblock_alo_parallel_leverage_chunk_size(
398    p_tot: usize,
399    n_blocks: usize,
400    n_obs: usize,
401    max_workers: usize,
402) -> usize {
403    if p_tot == 0 || n_blocks == 0 || n_obs == 0 {
404        return 1;
405    }
406
407    // Each parallel leverage chunk owns q_storage for all block RHS products
408    // (B * p_tot * chunk_len) plus one transposed design chunk across all
409    // blocks (p_tot * chunk_len).  Divide the global scratch budget by the
410    // maximum number of chunks Rayon can execute concurrently so total live
411    // per-chunk scratch remains bounded.
412    let workers = max_workers.max(1);
413    let per_worker_budget = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / workers).max(1);
414    let elem_count_per_obs = p_tot.saturating_mul(n_blocks.saturating_add(1)).max(1);
415    let bytes_per_obs = elem_count_per_obs
416        .saturating_mul(std::mem::size_of::<f64>())
417        .max(1);
418    let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
419    budget_obs.min(n_obs)
420}
421
422fn compute_alo_diagnostics_from_pirls_impl(
423    base: &pirls::PirlsResult,
424    y: ArrayView1<f64>,
425    link: LinkFunction,
426) -> Result<AloDiagnostics, EstimationError> {
427    compute_alo_diagnostics_from_pirls_inner(base, y, link).map_err(EstimationError::from)
428}
429
430/// True when the fitted GLM uses a *curved* canonical link, so that the row NLL
431/// score and curvature satisfy `ℓ_i'(η) = c_i(μ(η)−y_i)` and `ℓ_i''(η) = c_i μ'(η)`
432/// with a single per-row scale `c_i = (prior weight)/φ`. This is the exact
433/// condition under which the frozen-curvature ALO scalar fixed point matches
434/// the leave-`i`-out refit; only these families enable the exact refinement.
435///
436/// Gaussian identity is canonical too, but its per-row curvature is *constant*
437/// (`μ'(η) ≡ 1`), so the classical Sherman–Morrison one-step ALO is already the
438/// exact frozen-Hessian leave-`i`-out solution. Routing it through the scalar
439/// Newton closure would only add an O(n) nonlinear solve to diagnostics and
440/// quality sweeps without changing the answer, so it is excluded here and falls
441/// back to the (exact, for this family) one-step formula.
442fn alo_link_needs_exact_curvature_refinement(likelihood: &gam_problem::GlmLikelihoodSpec) -> bool {
443    use gam_problem::ResponseFamily;
444    matches!(
445        (&likelihood.spec.response, likelihood.link_function()),
446        (ResponseFamily::Binomial, LinkFunction::Logit)
447            | (ResponseFamily::Poisson, LinkFunction::Log)
448    )
449}
450
451fn compute_alo_diagnostics_from_pirls_inner(
452    base: &pirls::PirlsResult,
453    y: ArrayView1<f64>,
454    link: LinkFunction,
455) -> Result<AloDiagnostics, AloError> {
456    let x_dense_arc = base
457        .x_transformed
458        .try_to_dense_arc("ALO diagnostics require dense transformed design")
459        .map_err(|reason| AloError::DesignDegenerate { reason })?;
460    let x_dense = x_dense_arc.as_ref();
461    let n = x_dense.nrows();
462
463    // Compute dispersion parameter.
464    let phi = match link {
465        LinkFunction::Log => 1.0,
466        LinkFunction::Logit
467        | LinkFunction::Probit
468        | LinkFunction::CLogLog
469        | LinkFunction::LogLog
470        | LinkFunction::Cauchit
471        | LinkFunction::Sas
472        | LinkFunction::BetaLogistic => 1.0,
473        LinkFunction::Identity => {
474            use rayon::iter::{IntoParallelIterator, ParallelIterator};
475            let rss: f64 = (0..n)
476                .into_par_iter()
477                .map(|i| {
478                    let r = y[i] - base.finalmu[i];
479                    base.finalweights[i] * r * r
480                })
481                .sum();
482            // Effective sample size for dispersion (#584): a zero prior weight
483            // makes w_i·r_i² = 0, so the row is already excluded from the RSS
484            // numerator and must be excluded from the denominator too. Count only
485            // positive-weight rows, exactly as the main optimizer path does
486            // (optimizer.rs ~1567); using the raw row count over a zero-excluding
487            // numerator biases φ̂ low and shrinks every ALO SE.
488            let n_pos = (0..n).filter(|&i| base.finalweights[i] > 0.0).count();
489            let dof = (n_pos as f64) - base.edf;
490            let denom = dof.max(1.0);
491            rss / denom
492        }
493    };
494
495    let e = &base.reparam_result.e_transformed;
496    let ridge = base.ridge_passport.laplacehessianridge().max(0.0);
497
498    // ALO needs the exact penalized Hessian materialized densely for chunked
499    // column solves via StableSolver.  The PIRLS export path validates the
500    // matrix instead of falling back to a numerical Hessian approximation.
501    let h_dense_for_alo = base
502        .dense_stabilizedhessian_transformed(
503            "ALO diagnostics require exact dense stabilized penalized Hessian",
504        )
505        .map_err(|e| match e {
506            EstimationError::InvalidInput(reason) => AloError::InvalidInput { reason },
507            other => AloError::InvalidInput {
508                reason: format!("{other:?}"),
509            },
510        })?;
511
512    // Exact frozen-curvature ALO refinement for canonical-link GLMs.
513    //
514    // For a canonical link the row NLL score and curvature are
515    //   ℓ_i'(η)  = c_i · (μ(η) − y_i),     ℓ_i''(η) = c_i · μ'(η),
516    // with c_i = (prior weight)/φ recovered from the converged geometry as
517    // c_i = W_H[i] / μ'(η̂_i) (since W_H[i] = c_i μ'(η̂_i) at convergence).
518    // Supplying this evaluator lets `compute_alo_from_input_inner` solve the
519    // leave-i-out scalar fixed point η = η̂_i + a_ii ℓ_i'(η) exactly instead of
520    // taking a single Newton step, removing the first-order linearization error
521    // that dominates on small-n, strongly curved likelihoods (binomial logit).
522    //
523    // Restricted to canonical links because only there does the observed
524    // curvature carried by the frozen Hessian (W_H) coincide with c_i μ'(η) for
525    // every trial η; non-canonical links retain the classical one-step ALO.
526    // Per-row scale c_i = W_H[i]/μ'(η̂_i). Rows whose μ'(η̂_i) is negligible
527    // (saturated / near-separation) get c_i = NaN, which makes the exact solver
528    // reject that row explicitly rather than substituting the classical one-step
529    // ALO.
530    let canonical_scale: Option<Array1<f64>> =
531        if alo_link_needs_exact_curvature_refinement(&base.likelihood) {
532            let mut c = Array1::<f64>::zeros(n);
533            for i in 0..n {
534                let dmu = base.solve_dmu_deta[i];
535                let w_h = base.finalweights[i];
536                c[i] = if dmu.abs() <= ALO_DENOMINATOR_MIN || !dmu.is_finite() || !w_h.is_finite() {
537                    f64::NAN
538                } else {
539                    w_h / dmu
540                };
541            }
542            Some(c)
543        } else {
544            None
545        };
546
547    let inv_link_for_closure = base.likelihood.spec.link.clone();
548    let score_curvature_closure = canonical_scale.as_ref().map(|scale| {
549        move |i: usize, eta: f64| -> (f64, f64) {
550            let (mu, dmu) = crate::mixture_link::inverse_link_mu_d1_for_inverse_link(
551                &inv_link_for_closure,
552                eta,
553            )
554            .unwrap_or((f64::NAN, f64::NAN));
555            let c_i = scale[i];
556            (c_i * (mu - y[i]), c_i * dmu)
557        }
558    });
559    let score_curvature_ref: Option<&AloScalarScoreCurvature> = score_curvature_closure
560        .as_ref()
561        .map(|f| f as &AloScalarScoreCurvature);
562
563    // Build model-agnostic AloInput from PIRLS geometry, then delegate.
564    // #1868: the PIRLS row fields are now shared `ArcArray1`; `AloInput` borrows
565    // `&Array1`, so materialise owned copies for this cold post-fit inference
566    // path (ALO runs once after the fit, not per κ trial).
567    let alo_working_response = base.solveworking_response.to_owned();
568    let alo_final_eta = base.final_eta.to_owned();
569    let alo_final_offset = base.final_offset.to_owned();
570    let input = AloInput {
571        design: x_dense,
572        penalized_hessian: &h_dense_for_alo,
573        hessian_weights: base.final_weights_signed(),
574        score_weights: base.solve_weights_psd(),
575        working_response: &alo_working_response,
576        eta: &alo_final_eta,
577        offset: &alo_final_offset,
578        link,
579        phi,
580        penalty_root: if e.nrows() > 0 { Some(e) } else { None },
581        ridge,
582        score_curvature: score_curvature_ref,
583    };
584
585    let result = compute_alo_from_input_inner(&input)?;
586
587    // PIRLS-specific post-hoc leverage diagnostics logging.
588    log_leverage_diagnostics(&result.leverage, phi);
589
590    // Final NaN guard with detailed error reporting.
591    let has_nan_pred = result.eta_tilde.iter().any(|&x| x.is_nan());
592    let has_nan_se_bayes = result.se_bayes.iter().any(|&x| x.is_nan());
593    let has_nan_se_sandwich = result.se_sandwich.iter().any(|&x| x.is_nan());
594    let has_nan_leverage = result.leverage.iter().any(|&x| x.is_nan());
595
596    if has_nan_pred || has_nan_se_bayes || has_nan_se_sandwich || has_nan_leverage {
597        log::error!("[GAM ALO] NaN values found in ALO diagnostics:");
598        log::error!(
599            "[GAM ALO] eta_tilde: {} NaN values",
600            result.eta_tilde.iter().filter(|&&x| x.is_nan()).count()
601        );
602        log::error!(
603            "[GAM ALO] se_bayes: {} NaN values",
604            result.se_bayes.iter().filter(|&&x| x.is_nan()).count()
605        );
606        log::error!(
607            "[GAM ALO] se_sandwich: {} NaN values",
608            result.se_sandwich.iter().filter(|&&x| x.is_nan()).count()
609        );
610        log::error!(
611            "[GAM ALO] leverage: {} NaN values",
612            result.leverage.iter().filter(|&&x| x.is_nan()).count()
613        );
614        return Err(AloError::InfluenceMatrixFailed {
615            condition_number: f64::INFINITY,
616        });
617    }
618
619    Ok(result)
620}
621
622/// Log detailed leverage percentile diagnostics for a completed ALO computation.
623fn log_leverage_diagnostics(leverage: &Array1<f64>, phi: f64) {
624    let n = leverage.len();
625    if n == 0 {
626        return;
627    }
628
629    let mut invalid_count = 0usize;
630    let mut high_leverage_count = 0usize;
631    let mut threshold_counts = [0usize; LEVERAGE_RATE_THRESHOLDS.len()];
632    let mut finite_leverage = Vec::with_capacity(n);
633
634    for (obs, &ai) in leverage.iter().enumerate() {
635        if ai.is_finite() {
636            finite_leverage.push(ai);
637        }
638
639        if !(0.0..=1.0).contains(&ai) || !ai.is_finite() {
640            invalid_count += 1;
641            log::warn!("[GAM ALO] invalid leverage at i={}, a_ii={:.6e}", obs, ai);
642        } else if ai > LEVERAGE_HIGH_THRESHOLD {
643            high_leverage_count += 1;
644            if ai > LEVERAGE_VERY_HIGH_THRESHOLD {
645                log::warn!("[GAM ALO] very high leverage at i={}, a_ii={:.6e}", obs, ai);
646            }
647        }
648
649        for (idx, threshold) in LEVERAGE_RATE_THRESHOLDS.iter().enumerate() {
650            if ai > *threshold {
651                threshold_counts[idx] += 1;
652            }
653        }
654    }
655
656    if invalid_count > 0 || high_leverage_count > 0 {
657        log::warn!(
658            "[GAM ALO] leverage diagnostics: {} invalid values, {} high values (>0.99)",
659            invalid_count,
660            high_leverage_count
661        );
662    }
663
664    finite_leverage.sort_by(f64::total_cmp);
665
666    let finite_n = finite_leverage.len();
667    let a_mean = if finite_n > 0 {
668        finite_leverage.iter().copied().sum::<f64>() / finite_n as f64
669    } else {
670        0.0
671    };
672    let a_median = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[0]);
673    let a_p95 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[1]);
674    let a_p99 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[2]);
675    let a_max = finite_leverage.last().copied().unwrap_or(0.0);
676
677    // Routine per-ALO leverage summary: a diagnostic snapshot, not an
678    // anomaly. Emitted at `info!` so it is visible when the host raises
679    // verbosity (CLI `-v`; `gamfit.set_log_level("info")`) but silent at the
680    // default `Warn` level (genuine anomalies — invalid / very
681    // high leverage — are logged at `warn!` above and stay visible). This
682    // line fires once per ALO computation, which recurs across the outer
683    // smoothing loop, so at `warn!` it was a dominant source of stderr noise
684    // on perfectly healthy fits (#1689).
685    log::info!(
686        "[GAM ALO] leverage: n={}, mean={:.3e}, median={:.3e}, p95={:.3e}, p99={:.3e}, max={:.3e}",
687        n,
688        a_mean,
689        a_median,
690        a_p95,
691        a_p99,
692        a_max
693    );
694    log::info!(
695        "[GAM ALO] high-leverage: a>0.90: {:.2}%, a>0.95: {:.2}%, a>0.99: {:.2}%, dispersion phi={:.3e}",
696        100.0 * (threshold_counts[0] as f64) / n as f64,
697        100.0 * (threshold_counts[1] as f64) / n as f64,
698        100.0 * (threshold_counts[2] as f64) / n as f64,
699        phi
700    );
701}
702
703/// Model-agnostic input for ALO diagnostics.
704///
705/// Any model with a design matrix, penalized Hessian, and IRLS geometry can
706/// compute ALO leverages and leave-one-out predictions. This decouples ALO
707/// from the single-block PIRLS solver and enables diagnostics for GAMLSS,
708/// survival, and joint models.
709pub struct AloInput<'a> {
710    /// Dense design matrix X (n × p).
711    pub design: &'a Array2<f64>,
712    /// Penalized Hessian H = X'WX + S(λ) at convergence (p × p).
713    pub penalized_hessian: &'a Array2<f64>,
714    /// Hessian-side IRLS weights W_H at convergence (n). Sign-honest: for
715    /// non-canonical links the observed-information diagonal can have negative
716    /// entries, so the typed [`SignedWeightsView`] is the contract here. PSD
717    /// callers needing to promote (e.g. the canonical-link case where the
718    /// caller has discharged W_H ≥ 0 algebraically) can route through
719    /// `SignedWeightsView::as_psd()` at the consumer.
720    pub hessian_weights: SignedWeightsView<'a>,
721    /// Score-side IRLS weights W_S paired with `working_response` (n).
722    /// PSD-by-construction: the score-side Fisher weights `h'²/(φ V(μ)) ≥ 0`.
723    pub score_weights: PsdWeightsView<'a>,
724    /// IRLS working response at convergence (n).
725    pub working_response: &'a Array1<f64>,
726    /// Fitted linear predictor η̂ (n).
727    pub eta: &'a Array1<f64>,
728    /// Offset vector (n). Pass zeros if no offset.
729    pub offset: &'a Array1<f64>,
730    /// Link function (for phi determination).
731    pub link: LinkFunction,
732    /// Dispersion parameter φ. For non-Gaussian families this is 1.0.
733    pub phi: f64,
734    /// Optional penalty square root E with E^T E = S(λ) (rank × p) for sandwich SE.
735    /// When `None`, sandwich SE is set equal to Bayesian SE.
736    pub penalty_root: Option<&'a Array2<f64>>,
737    /// Ridge added to the Hessian for logdet surface.
738    pub ridge: f64,
739    /// Optional per-row score/curvature evaluator `(i, η) → (ℓ_i'(η), ℓ_i''(η))`.
740    ///
741    /// When supplied, the leave-`i`-out predictor is obtained by solving the
742    /// frozen-curvature scalar fixed point `η = η̂_i + a_ii ℓ_i'(η)` to
743    /// convergence (see [`alo_eta_exact_frozen_curvature`]) instead of taking a
744    /// single Newton step. This eliminates the first-order linearization error
745    /// that the one-step ALO incurs on small-`n`, strongly curved likelihoods
746    /// (e.g. binomial logistic regression). Non-convergence or invalid scalar
747    /// Newton geometry is returned as an ALO error. When `None`, the classical
748    /// single-Newton-step ALO formula is used. The evaluator must be consistent
749    /// with `hessian_weights` at convergence: `ℓ_i''(η̂_i) = W_H[i]` and
750    /// `ℓ_i'(η̂_i) = W_S[i]·((η̂_i−o_i) − (z_i−o_i))`.
751    pub score_curvature: Option<&'a AloScalarScoreCurvature<'a>>,
752}
753
754impl<'a> AloInput<'a> {
755    /// Build an `AloInput` from `FitGeometry` and associated vectors.
756    pub fn from_geometry(
757        geom: &'a FitGeometry,
758        design: &'a Array2<f64>,
759        eta: &'a Array1<f64>,
760        offset: &'a Array1<f64>,
761        link: LinkFunction,
762        phi: f64,
763    ) -> Self {
764        // FitGeometry stores one working-weight vector, so this constructor is
765        // exact only when the score- and Hessian-side IRLS weights coincide
766        // (canonical-link case where Fisher == Observed). In that path the
767        // diagonal is the Fisher weight `h'²/(φ V(μ)) ≥ 0`, so the PSD
768        // obligation is discharged algebraically without a runtime scan;
769        // `as_signed()` re-views the same buffer for the Hessian-side slot.
770        let psd_w = PsdWeightsView::from_view_unchecked(geom.working_weights.view());
771        Self {
772            design,
773            penalized_hessian: &geom.penalized_hessian,
774            hessian_weights: psd_w.as_signed(),
775            score_weights: psd_w,
776            working_response: &geom.working_response,
777            eta,
778            offset,
779            link,
780            phi,
781            penalty_root: None,
782            ridge: 0.0,
783            score_curvature: None,
784        }
785    }
786
787    /// Build an `AloInput` from a `FitGeometry`'s penalized Hessian plus
788    /// externally supplied working weights / working response.
789    ///
790    /// The row-sized IRLS working vectors are *derived* quantities: at
791    /// convergence they are deterministic functions of the linear predictor
792    /// `η̂ = Xβ̂`, the response `y`, and the family (`w_i = h'(η̂_i)²/(φ V(μ̂_i))·
793    /// prior_i`, `z_i = η̂_i + (y_i−μ̂_i)/h'(η̂_i)`). A size-compacted saved model
794    /// keeps the p×p `penalized_hessian` (n-independent) but drops those n-sized
795    /// vectors; a post-fit consumer such as `gam diagnose` reconstructs them from
796    /// the saved `β` by replaying the same PIRLS working-state update the fit
797    /// used, then feeds them here. This preserves the size win of dropping the
798    /// working vectors from persistence while still serving the exact geometry
799    /// ALO path (no refit, exact saved Hessian).
800    ///
801    /// Same canonical (Fisher == Observed) contract as [`from_geometry`]: the
802    /// supplied `working_weights` are the score-side Fisher weights and are
803    /// re-viewed for the Hessian-side slot via `as_signed()`.
804    ///
805    /// [`from_geometry`]: AloInput::from_geometry
806    pub fn from_geometry_with_working_state(
807        geom: &'a FitGeometry,
808        design: &'a Array2<f64>,
809        eta: &'a Array1<f64>,
810        offset: &'a Array1<f64>,
811        link: LinkFunction,
812        phi: f64,
813        working_weights: &'a Array1<f64>,
814        working_response: &'a Array1<f64>,
815    ) -> Self {
816        let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
817        Self {
818            design,
819            penalized_hessian: &geom.penalized_hessian,
820            hessian_weights: psd_w.as_signed(),
821            score_weights: psd_w,
822            working_response,
823            eta,
824            offset,
825            link,
826            phi,
827            penalty_root: None,
828            ridge: 0.0,
829            score_curvature: None,
830        }
831    }
832}
833
834/// Compute ALO diagnostics from model-agnostic inputs.
835///
836/// This is the generalized entry point that works for any model type.
837/// For standard single-block GAMs, prefer `compute_alo_diagnostics_from_fit`
838/// which automatically extracts the PIRLS geometry (including sandwich SE).
839pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
840    compute_alo_from_input_inner(input).map_err(EstimationError::from)
841}
842
843fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
844    let x_dense = input.design;
845    let n = x_dense.nrows();
846    let p = x_dense.ncols();
847    // Bind the underlying ArrayView1 once so the loop body can index and
848    // borrow as before; the sign-character contract lives in the
849    // `AloInput` field types, not in this local binding.
850    let w_h = input.hessian_weights.view();
851    let w_s = input.score_weights.view();
852
853    validate_alo_solve_setup(input, n, p)?;
854
855    let factor = StableSolver::new("alo penalized hessian")
856        .factorize(input.penalized_hessian)
857        .map_err(|_| AloError::InfluenceMatrixFailed {
858            condition_number: f64::INFINITY,
859        })?;
860
861    let xt = x_dense.t();
862    let phi = input.phi;
863
864    let mut aii = Array1::<f64>::zeros(n);
865    let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
866    let mut se_bayes = Array1::<f64>::zeros(n);
867    let mut se_sandwich = Array1::<f64>::zeros(n);
868
869    let block_cols = ALO_RHS_BLOCK_COLS;
870    // Allocate the RHS scratch in column-major (Fortran) order so its column
871    // slices are contiguous and align with faer's column-major solve output.
872    // This removes redundant `xrow = x_dense.row(obs)` indirection inside the
873    // per-observation loop: rhs_chunk_buf already holds X^T at the right cols.
874    let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
875    // Reusable faer column-major buffer for X*S, where S = H^{-1}X_i for the
876    // current RHS chunk.  The sandwich SE must use the same frozen-curvature
877    // meat as the exact LOO reference, `X' W X`, directly; reconstructing it as
878    // `H - S_penalty - ridge*I` is brittle because the exported stabilized
879    // Hessian may include curvature/stabilization details that are not exactly
880    // represented by the penalty root plus public ridge scalar.
881    let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
882    let x_dense_view = FaerArrayView::new(x_dense);
883
884    for chunk_start in (0..n).step_by(block_cols) {
885        let chunk_end = (chunk_start + block_cols).min(n);
886        let width = chunk_end - chunk_start;
887
888        rhs_chunk_buf
889            .slice_mut(s![.., ..width])
890            .assign(&xt.slice(s![.., chunk_start..chunk_end]));
891
892        let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
893        let rhs_chunk = FaerArrayView::new(&rhs_chunkview);
894        // s_chunk is owned column-major faer storage; its column slices are
895        // contiguous and can be read directly via `col_as_slice` — no need to
896        // materialize a parallel ndarray copy.
897        let s_chunk = factor.solve(rhs_chunk.as_ref());
898
899        let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
900        matmul(
901            xs_target.rb_mut(),
902            Accum::Replace,
903            x_dense_view.as_ref(),
904            s_chunk.as_ref(),
905            1.0,
906            Par::Seq,
907        );
908
909        let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
910
911        for local_col in 0..width {
912            let obs = chunk_start + local_col;
913            // rhs is column-major Fortran ndarray; faer Mat columns are
914            // contiguous by construction. Both accesses borrow the existing
915            // storage directly — no per-column copy.
916            let rhs_col = rhs_view.column(local_col);
917            let rhs_slice = rhs_col.as_slice().expect("column-major col contiguous");
918            let s_slice = s_chunk.col_as_slice(local_col);
919
920            let mut x_hinv_x = 0.0f64;
921            // Fused dot product over the current solve column.
922            for k in 0..p {
923                let sval = s_slice[k];
924                let xval = rhs_slice[k];
925                x_hinv_x = sval.mul_add(xval, x_hinv_x);
926            }
927            let ai = w_h[obs].max(0.0) * x_hinv_x;
928            aii[obs] = ai;
929            x_hinv_x_diag[obs] = x_hinv_x;
930
931            let var_bayes = bayesvar_eta(phi, x_hinv_x);
932            let xs_slice = xs_chunk_storage.col_as_slice(local_col);
933            let mut meat_quad = 0.0f64;
934            for row in 0..n {
935                let xs = xs_slice[row];
936                // Sandwich meat is the SCORE covariance Xᵀ diag(W_S) X (Fisher,
937                // PSD by construction), not the observed-information Hessian
938                // weight W_H: the estimator is Var = H⁻¹·Cov(score)·H⁻¹ with the
939                // bread H = Xᵀ W_H X + S. For non-canonical links W_H ≠ W_S (and
940                // W_H can be negative), so using W_H here gives a wrong — even
941                // negative — sandwich SE. See `AloInput::score_weights`.
942                meat_quad += w_s[row] * xs * xs;
943            }
944            let var_sandwich = sandwichvar_eta_from_meat(phi, meat_quad);
945
946            if !var_bayes.is_finite() || !var_sandwich.is_finite() {
947                return Err(AloError::LooComputationFailed {
948                    reason: format!(
949                        "ALO variance is not finite at row {obs}: bayes={var_bayes:.6e}, sandwich={var_sandwich:.6e}"
950                    ),
951                });
952            }
953            let bayes_tol = variance_negative_tolerance(phi * x_hinv_x.abs());
954            if var_bayes < -bayes_tol {
955                return Err(AloError::LooComputationFailed {
956                    reason: format!(
957                        "ALO Bayesian variance is materially negative at row {obs}: var={var_bayes:.6e}, tol={bayes_tol:.6e}"
958                    ),
959                });
960            }
961            let sandwich_scale = phi * meat_quad.abs().max(x_hinv_x.abs());
962            let sandwich_tol = variance_negative_tolerance(sandwich_scale);
963            if var_sandwich < -sandwich_tol {
964                return Err(AloError::LooComputationFailed {
965                    reason: format!(
966                        "ALO sandwich variance is materially negative at row {obs}: var={var_sandwich:.6e}, tol={sandwich_tol:.6e}"
967                    ),
968                });
969            }
970
971            se_bayes[obs] = var_bayes.max(0.0).sqrt();
972            se_sandwich[obs] = var_sandwich.max(0.0).sqrt();
973        }
974    }
975
976    let eta_hat = input.eta;
977    let z = input.working_response;
978    let offset = input.offset;
979
980    use rayon::prelude::*;
981    let eta_tilde_vec: Vec<f64> = (0..n)
982        .into_par_iter()
983        .map(|i| {
984            let denom_raw = 1.0 - aii[i];
985            if denom_raw <= ALO_DENOMINATOR_MIN || !denom_raw.is_finite() {
986                return Err(AloError::LooComputationFailed {
987                    reason: format!(
988                        "ALO denominator is too small at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}, min={:.1e}",
989                        aii[i], denom_raw, ALO_DENOMINATOR_MIN
990                    ),
991                });
992            }
993            let one_step = alo_eta_updatewith_offset(
994                eta_hat[i],
995                z[i],
996                offset[i],
997                x_hinv_x_diag[i],
998                w_s[i],
999                denom_raw,
1000            );
1001            // When the family score/curvature evaluator is supplied, solve the
1002            // exact frozen-curvature leave-i-out fixed point (anchored at η̂_i,
1003            // the basin that limits to the in-sample fit) instead of taking the
1004            // single Newton step. a_ii here is the unweighted influence
1005            // x_i^T H^{-1} x_i (= x_hinv_x_diag[i]); the per-row curvature
1006            // W_H[i] = ℓ_i''(η̂_i) is folded into the scalar fixed point via
1007            // score_curvature. Non-canonical links fall back to `one_step`.
1008            let v = if let Some(score_curvature) = input.score_curvature {
1009                alo_eta_exact_frozen_curvature(
1010                    eta_hat[i],
1011                    x_hinv_x_diag[i],
1012                    &|eta| score_curvature(i, eta),
1013                )
1014                .map_err(|err| AloError::LooComputationFailed {
1015                    reason: format!(
1016                        "ALO exact frozen-curvature solve failed at row {i}: {err}"
1017                    ),
1018                })?
1019            } else {
1020                one_step
1021            };
1022            if !v.is_finite() {
1023                return Err(AloError::LooComputationFailed {
1024                    reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1025                });
1026            }
1027            Ok(v)
1028        })
1029        .collect::<Result<_, _>>()?;
1030    let eta_tilde = Array1::from(eta_tilde_vec);
1031
1032    Ok(AloDiagnostics {
1033        eta_tilde,
1034        se_bayes,
1035        se_sandwich,
1036        pred_identity: eta_hat.clone(),
1037        leverage: aii,
1038        fisherweights: w_h.to_owned(),
1039    })
1040}
1041
1042fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1043    let h = input.penalized_hessian;
1044    if h.nrows() != p || h.ncols() != p {
1045        return Err(AloError::InvalidInput {
1046            reason: format!(
1047                "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1048                h.nrows(),
1049                h.ncols()
1050            ),
1051        });
1052    }
1053    if h.iter().any(|v| !v.is_finite()) {
1054        return Err(AloError::InvalidInput {
1055            reason: "ALO diagnostics require a finite dense exact penalized Hessian".to_string(),
1056        });
1057    }
1058    for i in 0..p {
1059        for j in 0..i {
1060            let a = h[[i, j]];
1061            let b = h[[j, i]];
1062            let scale = a.abs().max(b.abs()).max(1.0);
1063            if (a - b).abs() > HESSIAN_SYMMETRY_REL_TOL * scale {
1064                return Err(AloError::InvalidInput {
1065                    reason: format!(
1066                        "ALO diagnostics require a symmetric dense exact penalized Hessian; entries ({i},{j}) and ({j},{i}) differ by {:.3e}",
1067                        (a - b).abs()
1068                    ),
1069                });
1070            }
1071        }
1072    }
1073
1074    let vector_lengths = [
1075        ("hessian_weights", input.hessian_weights.len()),
1076        ("score_weights", input.score_weights.len()),
1077        ("working_response", input.working_response.len()),
1078        ("eta", input.eta.len()),
1079        ("offset", input.offset.len()),
1080    ];
1081    for (name, len) in vector_lengths {
1082        if len != n {
1083            return Err(AloError::InvalidInput {
1084                reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1085            });
1086        }
1087    }
1088    if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1089        return Err(AloError::WeightInvalid {
1090            reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1091        });
1092    }
1093    if input.score_weights.view().iter().any(|v| !v.is_finite()) {
1094        return Err(AloError::WeightInvalid {
1095            reason: "ALO diagnostics require finite score-side weights".to_string(),
1096        });
1097    }
1098    if input.working_response.iter().any(|v| !v.is_finite()) {
1099        return Err(AloError::WeightInvalid {
1100            reason: "ALO diagnostics require finite working responses".to_string(),
1101        });
1102    }
1103    if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1104        return Err(AloError::InvalidInput {
1105            reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1106        });
1107    }
1108    if !input.phi.is_finite() || input.phi <= 0.0 {
1109        return Err(AloError::InvalidInput {
1110            reason: format!(
1111                "ALO diagnostics require positive finite dispersion phi; got {}",
1112                input.phi
1113            ),
1114        });
1115    }
1116    if !input.ridge.is_finite() || input.ridge < 0.0 {
1117        return Err(AloError::InvalidInput {
1118            reason: format!(
1119                "ALO diagnostics require a finite non-negative Hessian ridge; got {}",
1120                input.ridge
1121            ),
1122        });
1123    }
1124    if let Some(e) = input.penalty_root {
1125        if e.ncols() != p {
1126            return Err(AloError::InvalidInput {
1127                reason: format!(
1128                    "ALO diagnostics require penalty root to have {p} columns; got {}",
1129                    e.ncols()
1130                ),
1131            });
1132        }
1133        if e.iter().any(|v| !v.is_finite()) {
1134            return Err(AloError::InvalidInput {
1135                reason: "ALO diagnostics require finite penalty-root entries".to_string(),
1136            });
1137        }
1138    }
1139    Ok(())
1140}
1141
1142/// Compute ALO diagnostics (eta_tilde, SE, leverage) from a fitted GAM result.
1143pub fn compute_alo_diagnostics_from_fit(
1144    fit: &UnifiedFitResult,
1145    y: ArrayView1<f64>,
1146    link: LinkFunction,
1147) -> Result<AloDiagnostics, EstimationError> {
1148    let pirls = fit
1149        .artifacts
1150        .pirls
1151        .as_ref()
1152        .ok_or_else(|| AloError::InvalidInput {
1153            reason:
1154                "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1155                    .to_string(),
1156        })
1157        .map_err(EstimationError::from)?;
1158    compute_alo_diagnostics_from_pirls_impl(pirls, y, link)
1159}
1160
1161/// Compute ALO diagnostics from a `UnifiedFitResult`.
1162///
1163/// Extracts `FitGeometry` from `unified.geometry`, builds an `AloInput`
1164/// via `from_geometry`, and delegates to `compute_alo_from_input`.
1165/// This avoids requiring a full `UnifiedFitResult` with PIRLS artifacts.
1166pub fn compute_alo_diagnostics_from_unified(
1167    unified: &UnifiedFitResult,
1168    design: &Array2<f64>,
1169    eta: &Array1<f64>,
1170    offset: &Array1<f64>,
1171    link: LinkFunction,
1172    phi: f64,
1173) -> Result<AloDiagnostics, EstimationError> {
1174    let geom = unified
1175        .geometry
1176        .as_ref()
1177        .ok_or_else(|| AloError::InvalidInput {
1178            reason: "UnifiedFitResult does not contain working-set geometry; \
1179             ALO diagnostics require geometry at convergence"
1180                .to_string(),
1181        })
1182        .map_err(EstimationError::from)?;
1183    let input = AloInput::from_geometry(geom, design, eta, offset, link, phi);
1184    compute_alo_from_input(&input)
1185}
1186
1187/// Compute ALO diagnostics from a PIRLS result for lower-level callers.
1188pub fn compute_alo_diagnostics_from_pirls(
1189    base: &pirls::PirlsResult,
1190    y: ArrayView1<f64>,
1191    link: LinkFunction,
1192) -> Result<AloDiagnostics, EstimationError> {
1193    compute_alo_diagnostics_from_pirls_impl(base, y, link)
1194}
1195
1196/// Exact (one-step) case-deletion influence from a converged PIRLS fit, via
1197/// the one `FitSensitivity` operator (#935).
1198///
1199/// This is the diagnostic the sensitivity operator's `case_deletion` channel
1200/// was built to expose but had no production entry point for: per-observation
1201/// dfbetas `β̂ − β̂₍ᵢ₎`, hat-value leverage `h_ii = w_i x_iᵀ H⁻¹ x_i`, and
1202/// Cook's distance. It is the same factored inverse the REML gradient (IFT),
1203/// ALO, and the Riesz debias already contract — built once at the optimum,
1204/// asked in the leave-one-out direction — so no call site can disagree about
1205/// which `H⁻¹` is meant (the bug class #935 dismantles).
1206///
1207/// The penalized Hessian, design, working weights `w_i = W_H[i]` and working
1208/// residual `z_i − η̂_i` are read straight from the converged geometry — the
1209/// same PIRLS state [`compute_alo_diagnostics_from_pirls`] consumes — so the
1210/// IRLS reduction `scale = w_i r_i / (1 − h_ii)` is exact for the Gaussian
1211/// identity link and the one-step Newton deletion for canonical-link GLMs.
1212/// Returns `None` (rather than emitting `∞`) for any observation whose
1213/// leverage is one, or if the dense Hessian / design is unavailable.
1214pub fn compute_case_deletion_from_pirls(
1215    base: &pirls::PirlsResult,
1216    y: ArrayView1<f64>,
1217    link: LinkFunction,
1218) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1219    let x_dense_arc = base
1220        .x_transformed
1221        .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1222        .map_err(|reason| EstimationError::InvalidInput(reason))?;
1223    let x_dense = x_dense_arc.as_ref();
1224    let n = x_dense.nrows();
1225    let p = x_dense.ncols();
1226    if n == 0 || p == 0 {
1227        return Ok(None);
1228    }
1229
1230    // Dispersion φ matches the ALO entry point: estimated RSS/(n_pos−edf) for
1231    // the Gaussian identity link, fixed at 1 for the single-parameter families.
1232    // Zero-weight rows contribute nothing to the RSS, so they must not inflate
1233    // the residual degrees of freedom either (#584 weighting consistency).
1234    let phi = match link {
1235        LinkFunction::Identity => {
1236            use rayon::iter::{IntoParallelIterator, ParallelIterator};
1237            let rss: f64 = (0..n)
1238                .into_par_iter()
1239                .map(|i| {
1240                    let r = y[i] - base.finalmu[i];
1241                    base.finalweights[i] * r * r
1242                })
1243                .sum();
1244            let n_pos = (0..n).filter(|&i| base.finalweights[i] > 0.0).count();
1245            let dof = (n_pos as f64) - base.edf;
1246            rss / dof.max(1.0)
1247        }
1248        _ => 1.0,
1249    };
1250    if !(phi.is_finite() && phi > 0.0) {
1251        return Ok(None);
1252    }
1253
1254    // The same dense stabilized penalized Hessian ALO materializes; the one
1255    // factored inverse every sensitivity channel shares.
1256    let h_dense = base
1257        .dense_stabilizedhessian_transformed(
1258            "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1259        )
1260        .map_err(|e| match e {
1261            EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1262            other => EstimationError::InvalidInput(format!("{other:?}")),
1263        })?;
1264
1265    let factor = match h_dense.cholesky(faer::Side::Lower) {
1266        Ok(f) => f,
1267        // A non-SPD stabilized Hessian means the optimum is rank-deficient in a
1268        // way the dense Cholesky case-deletion path cannot invert; decline
1269        // rather than fabricate an influence diagnostic.
1270        Err(_) => return Ok(None),
1271    };
1272
1273    // Working weights and working residual straight from the IRLS reduction:
1274    // w_i = W_H[i] and r_i = z_i − η̂_i, so w_i r_i is the working score the
1275    // closed-form deletion `scale = w_i r_i / (1 − h_ii)` consumes.
1276    let working_weights = base.finalweights.clone();
1277    let working_residual = &base.solveworking_response - &base.final_eta;
1278
1279    let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1280    Ok(sensitivity.case_deletion(
1281        x_dense,
1282        working_weights.view(),
1283        working_residual.view(),
1284        phi,
1285    ))
1286}
1287
1288// Multi-block ALO for multi-predictor models (GAMLSS, survival, joint)
1289
1290/// Diagnostics returned by multi-block ALO.
1291#[derive(Debug, Clone)]
1292pub struct MultiBlockAloDiagnostics {
1293    /// Corrected linear predictors η̃^{(-i)} for each observation.
1294    /// Outer length = n_obs, inner length = n_blocks (B).
1295    pub eta_tilde: Vec<Array1<f64>>,
1296    /// Per-observation leverage tr(H_ii) where H_ii is the B×B hat-matrix block.
1297    pub leverage: Array1<f64>,
1298    /// Per-observation ALO variance diagonals: for each observation i,
1299    /// Var(Δη_i) ≈ A_i (I - W_i A_i)⁻¹ W_i (I - A_i W_i)⁻¹ A_iᵀ.
1300    /// Outer length = n_obs, inner length = n_blocks (B) containing the
1301    /// diagonal entries of the variance matrix.
1302    pub alo_variance: Vec<Array1<f64>>,
1303    /// Cook-type ALO influence: D_i = Δη_iᵀ W_i Δη_i.
1304    /// Length = n_obs.
1305    pub cook_distance: Array1<f64>,
1306}
1307
1308/// Model-agnostic input for multi-predictor ALO diagnostics.
1309///
1310/// Generalises [`AloInput`] to models with B > 1 linear predictors per
1311/// observation (e.g. location-scale GAMLSS with B=2, or survival models
1312/// with time-dependent predictors).
1313///
1314/// # Mathematical setup
1315///
1316/// For observation i the per-observation Jacobian is a B × p_tot block matrix
1317/// X_i whose b-th row is the i-th row of `block_designs[b]`.  The joint
1318/// hat-matrix block is
1319///
1320///   H_ii = X_i H⁻¹ X_iᵀ W_i     (B × B)
1321///
1322/// where H = Σ_i X_iᵀ W_i X_i + S is the total penalized Hessian and W_i
1323/// is the B × B per-observation weight matrix (negative Hessian of the
1324/// log-likelihood w.r.t. the B predictors at observation i).
1325///
1326/// The ALO leave-one-out correction is
1327///
1328///   Δη_i^ALO = A_i (I_B − W_i A_i)⁻¹ s_i
1329///
1330/// where A_i = X_i H⁻¹ X_iᵀ (the B×B per-observation influence matrix),
1331/// W_i is the B×B per-observation NLL Hessian, and
1332/// s_i = ∇_{η_i} NLL_i(η̂_i) is the B-dimensional score vector.
1333/// This is algebraically equivalent to (I_B − H_ii)⁻¹ H_ii W_i⁻¹ s_i
1334/// but does NOT require W_i⁻¹, which is critical when W_i is singular
1335/// (e.g. at boundary observations in survival models).
1336/// For B = 1 this reduces to the classical scalar ALO formula.
1337pub struct MultiBlockAloInput<'a> {
1338    /// Number of observations.
1339    pub n_obs: usize,
1340    /// Number of predictors per observation (B).
1341    pub n_blocks: usize,
1342    /// B design matrices, each n_obs × p_b.  The total parameter count is
1343    /// p_tot = Σ_b p_b.
1344    pub block_designs: &'a [Array2<f64>],
1345    /// Inverse of the penalized Hessian, H⁻¹ (p_tot × p_tot).
1346    pub penalized_hessian_inv: &'a Array2<f64>,
1347    /// Per-observation weight matrices W_i (B × B).  Length = n_obs.
1348    pub block_weights: Vec<Array2<f64>>,
1349    /// Per-observation score vectors s_i = ∇_{η_i} NLL_i.  Length = n_obs,
1350    /// each entry is B-dimensional.
1351    pub scores: Vec<Array1<f64>>,
1352    /// Fitted linear predictor vectors η̂_i.  Length = n_obs, each entry is
1353    /// B-dimensional.
1354    pub eta_hat: Vec<Array1<f64>>,
1355}
1356
1357/// Compute multi-block ALO diagnostics: corrected η̃ and leverages.
1358///
1359/// # Optimisation note
1360///
1361/// The dominant cost is forming X_i H⁻¹ X_iᵀ for every observation.
1362/// Rather than forming the B × p_tot row-block X_i and multiplying naïvely,
1363/// we precompute for each block b the matrix
1364///
1365///   Q_b = H⁻¹ X_bᵀ      (p_tot × n)
1366///
1367/// Then the (a, b) entry of the B × B matrix X_i H⁻¹ X_iᵀ is simply
1368///
1369///   (X_i H⁻¹ X_iᵀ)_{a,b} = x_{a,i}ᵀ Q_b[:,i]
1370///                           = Σ_k  X_a[i,k] · Q_b[k,i]
1371///
1372/// where x_{a,i} is the i-th row of block-design a.  This turns the per-
1373/// observation work from O(B · p_tot²) into O(B² · p_tot), and the
1374/// precomputation is O(B · p_tot² · n) total via a single blocked solve.
1375pub fn compute_multiblock_alo(
1376    input: &MultiBlockAloInput,
1377) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1378    compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1379}
1380
1381fn compute_multiblock_alo_inner(
1382    input: &MultiBlockAloInput,
1383) -> Result<MultiBlockAloDiagnostics, AloError> {
1384    use rayon::prelude::*;
1385
1386    let n = input.n_obs;
1387    let b = input.n_blocks;
1388    let p_tot = input.penalized_hessian_inv.nrows();
1389
1390    // --- Validate dimensions ---
1391    if input.block_designs.len() != b {
1392        return Err(AloError::InvalidInput {
1393            reason: format!(
1394                "MultiBlockAloInput: expected {} block designs, got {}",
1395                b,
1396                input.block_designs.len()
1397            ),
1398        });
1399    }
1400
1401    // Verify total column count matches p_tot.
1402    let col_sum: usize = input.block_designs.iter().map(|d| d.ncols()).sum();
1403    if col_sum != p_tot {
1404        return Err(AloError::InvalidInput {
1405            reason: format!(
1406                "MultiBlockAloInput: total design columns ({}) != penalized_hessian_inv size ({})",
1407                col_sum, p_tot
1408            ),
1409        });
1410    }
1411
1412    let col_offsets = multiblock_col_offsets(input.block_designs);
1413    let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1414    let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1415
1416    // Each Rayon worker owns its small B×B/B-vector scratch buffers via
1417    // `map_init`, avoiding cross-thread mutation and avoiding per-observation
1418    // allocations.  The much larger Q panels are bounded by the parallel chunk
1419    // size and by wave-level concurrency, so at most roughly one global memory
1420    // budget worth of p_total × chunk_len panels can be live across workers.
1421    let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1422        Vec::with_capacity(chunk_starts.len());
1423    for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1424        let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1425            .par_iter()
1426            .map_init(
1427                || MultiBlockAloScratch::new(b),
1428                |scratch, &chunk_start| {
1429                    let chunk_end = (chunk_start + chunk_size).min(n);
1430                    compute_multiblock_alo_chunk(
1431                        input,
1432                        &col_offsets,
1433                        chunk_start,
1434                        chunk_end,
1435                        scratch,
1436                    )
1437                },
1438            )
1439            .collect();
1440        chunk_results.append(&mut wave_results);
1441    }
1442
1443    let mut eta_tilde = Vec::with_capacity(n);
1444    let mut leverage = Array1::<f64>::zeros(n);
1445    let mut alo_variance = Vec::with_capacity(n);
1446    let mut cook_distance = Array1::<f64>::zeros(n);
1447
1448    let mut chunks = Vec::with_capacity(chunk_results.len());
1449    for result in chunk_results {
1450        chunks.push(result?);
1451    }
1452    chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1453
1454    for chunk in chunks {
1455        let chunk_start = chunk.chunk_start;
1456        eta_tilde.extend(chunk.eta_tilde);
1457        alo_variance.extend(chunk.alo_variance);
1458        for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1459            leverage[chunk_start + local_i] = lev;
1460        }
1461        for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1462            cook_distance[chunk_start + local_i] = cook;
1463        }
1464    }
1465
1466    Ok(MultiBlockAloDiagnostics {
1467        eta_tilde,
1468        leverage,
1469        alo_variance,
1470        cook_distance,
1471    })
1472}
1473
1474#[inline]
1475fn multiblock_alo_parallel_plan(p_tot: usize, n_blocks: usize, n_obs: usize) -> (usize, usize) {
1476    if p_tot == 0 || n_blocks == 0 || n_obs == 0 {
1477        return (1, 1);
1478    }
1479    let bytes_per_obs = (p_tot * n_blocks * std::mem::size_of::<f64>()).max(1);
1480    let workers = rayon::current_num_threads().max(1);
1481    let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1482        .max(1)
1483        .min(workers);
1484    let per_worker_budget =
1485        (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1486    let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1487    (budget_obs.min(n_obs), max_concurrent_chunks)
1488}
1489
1490struct MultiBlockAloScratch {
1491    a_i: Vec<f64>,
1492    wa: Vec<f64>,
1493    aw: Vec<f64>,
1494    imwa: Vec<f64>,
1495    imaw: Vec<f64>,
1496    perm_imwa: Vec<usize>,
1497    perm_imaw: Vec<usize>,
1498    delta_eta: Vec<f64>,
1499    rhs_buf: Vec<f64>,
1500    w_u: Vec<f64>,
1501    var_diag_buf: Vec<f64>,
1502    w_flat: Vec<f64>,
1503    lu_scratch: Vec<f64>,
1504}
1505
1506impl MultiBlockAloScratch {
1507    fn new(b: usize) -> Self {
1508        let bb_sz = b * b;
1509        Self {
1510            a_i: vec![0.0f64; bb_sz],
1511            wa: vec![0.0f64; bb_sz],
1512            aw: vec![0.0f64; bb_sz],
1513            imwa: vec![0.0f64; bb_sz],
1514            imaw: vec![0.0f64; bb_sz],
1515            perm_imwa: vec![0usize; b],
1516            perm_imaw: vec![0usize; b],
1517            delta_eta: vec![0.0f64; b],
1518            rhs_buf: vec![0.0f64; b],
1519            w_u: vec![0.0f64; b],
1520            var_diag_buf: vec![0.0f64; b],
1521            w_flat: vec![0.0f64; bb_sz],
1522            lu_scratch: vec![0.0f64; b],
1523        }
1524    }
1525}
1526
1527struct MultiBlockAloChunkDiagnostics {
1528    chunk_start: usize,
1529    eta_tilde: Vec<Array1<f64>>,
1530    leverage: Vec<f64>,
1531    alo_variance: Vec<Array1<f64>>,
1532    cook_distance: Vec<f64>,
1533}
1534
1535fn compute_multiblock_alo_chunk(
1536    input: &MultiBlockAloInput,
1537    col_offsets: &[usize],
1538    chunk_start: usize,
1539    chunk_end: usize,
1540    scratch: &mut MultiBlockAloScratch,
1541) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1542    let b = input.n_blocks;
1543    let chunk_len = chunk_end - chunk_start;
1544
1545    let mut q_blocks = Vec::with_capacity(b);
1546    for blk in 0..b {
1547        let x_chunk_t = input.block_designs[blk]
1548            .slice(s![chunk_start..chunk_end, ..])
1549            .t()
1550            .to_owned();
1551        let off_b = col_offsets[blk];
1552        let h_slice = input
1553            .penalized_hessian_inv
1554            .slice(s![.., off_b..off_b + x_chunk_t.nrows()])
1555            .to_owned();
1556        q_blocks.push(h_slice.dot(&x_chunk_t));
1557    }
1558
1559    let mut eta_tilde = Vec::with_capacity(chunk_len);
1560    let mut leverage = vec![0.0f64; chunk_len];
1561    let mut alo_variance = Vec::with_capacity(chunk_len);
1562    let mut cook_distance = vec![0.0f64; chunk_len];
1563
1564    for local_i in 0..chunk_len {
1565        let i = chunk_start + local_i;
1566        let w_i = &input.block_weights[i];
1567
1568        // Flatten W_i once per observation (row-major).
1569        for r in 0..b {
1570            for c in 0..b {
1571                scratch.w_flat[r * b + c] = w_i[(r, c)];
1572            }
1573        }
1574
1575        // --- Assemble A_i = X_i H⁻¹ X_iᵀ  (B × B), row-major flat. ---
1576        for a in 0..b {
1577            let x_a = &input.block_designs[a];
1578            let p_a = x_a.ncols();
1579            let off_a = col_offsets[a];
1580            let xa_row = x_a.row(i);
1581            for bb in 0..b {
1582                let q_bb = &q_blocks[bb];
1583                let mut dot = 0.0f64;
1584                for k in 0..p_a {
1585                    dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1586                }
1587                scratch.a_i[a * b + bb] = dot;
1588            }
1589        }
1590
1591        // WA = W_i · A_i (row-major).
1592        mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
1593        // AW = A_i · W_i (row-major).
1594        mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
1595
1596        // Trace of H_ii = A_i W_i (= AW): leverage[i].
1597        // (Original code wrote H_ii = A · W — the same operator we already have in `aw`.)
1598        let mut tr = 0.0f64;
1599        for d in 0..b {
1600            tr += scratch.aw[d * b + d];
1601        }
1602        leverage[local_i] = tr;
1603
1604        // Build (I - W A) and (I - A W) into imwa/imaw.
1605        for r in 0..b {
1606            for c in 0..b {
1607                let idx = r * b + c;
1608                let id = if r == c { 1.0 } else { 0.0 };
1609                scratch.imwa[idx] = id - scratch.wa[idx];
1610                scratch.imaw[idx] = id - scratch.aw[idx];
1611            }
1612        }
1613
1614        // Factor in place with partial pivoting; ridge on the diagonal if singular.
1615        // Equivalence with original: original computed det via det_small, regularized
1616        // by adding eps=1e-6 to the diagonal when |det| < 1e-12, then re-factored on
1617        // the regularized matrix. Here we factor directly; if any pivot is below the
1618        // singular threshold we add the ridge once and re-factor — same numerical path.
1619        if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b) {
1620            for r in 0..b {
1621                for c in 0..b {
1622                    let idx = r * b + c;
1623                    let id = if r == c { 1.0 } else { 0.0 };
1624                    scratch.imwa[idx] = id - scratch.wa[idx];
1625                }
1626            }
1627            for d in 0..b {
1628                scratch.imwa[d * b + d] += ALO_LOCAL_BLOCK_RIDGE;
1629            }
1630            let refactored = lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b);
1631            assert!(
1632                refactored,
1633                "ALO local block remained singular after ridge regularization"
1634            );
1635        }
1636        if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b) {
1637            for r in 0..b {
1638                for c in 0..b {
1639                    let idx = r * b + c;
1640                    let id = if r == c { 1.0 } else { 0.0 };
1641                    scratch.imaw[idx] = id - scratch.aw[idx];
1642                }
1643            }
1644            for d in 0..b {
1645                scratch.imaw[d * b + d] += ALO_LOCAL_BLOCK_RIDGE;
1646            }
1647            let refactored = lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b);
1648            assert!(
1649                refactored,
1650                "ALO local variance block remained singular after ridge regularization"
1651            );
1652        }
1653
1654        // v_i = (I - W A)⁻¹ s_i  -- solve into rhs_buf.
1655        let s_i = &input.scores[i];
1656        for k in 0..b {
1657            scratch.rhs_buf[k] = s_i[k];
1658        }
1659        lu_solve_in_place(
1660            &scratch.imwa,
1661            &scratch.perm_imwa,
1662            &mut scratch.rhs_buf,
1663            &mut scratch.lu_scratch,
1664            b,
1665        );
1666        // delta_eta = A_i · v_i
1667        for r in 0..b {
1668            let mut acc = 0.0f64;
1669            let row_off = r * b;
1670            for k in 0..b {
1671                acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
1672            }
1673            scratch.delta_eta[r] = acc;
1674        }
1675
1676        let eta_i = &input.eta_hat[i];
1677        let mut corrected = Array1::<f64>::zeros(b);
1678        for d in 0..b {
1679            corrected[d] = eta_i[d] + scratch.delta_eta[d];
1680        }
1681        eta_tilde.push(corrected);
1682
1683        // Cook's distance: δη^T W δη.
1684        let mut cook = 0.0f64;
1685        for r in 0..b {
1686            let mut w_delta_r = 0.0f64;
1687            let row_off = r * b;
1688            for k in 0..b {
1689                w_delta_r += scratch.w_flat[row_off + k] * scratch.delta_eta[k];
1690            }
1691            cook += scratch.delta_eta[r] * w_delta_r;
1692        }
1693        cook_distance[local_i] = cook;
1694
1695        // var_diag[d] = a_d^T (I-WA)⁻¹ W (I-AW)⁻¹ a_d
1696        // where a_d is the d-th row of A_i.
1697        // Reuses already-factored imwa and imaw (one LU factorization each, reused
1698        // across all B right-hand sides — major saving over the original which redid
1699        // both LU decompositions B times per observation).
1700        for d in 0..b {
1701            let row_off = d * b;
1702            // u_d = (I - A W)⁻¹ a_d
1703            for k in 0..b {
1704                scratch.rhs_buf[k] = scratch.a_i[row_off + k];
1705            }
1706            lu_solve_in_place(
1707                &scratch.imaw,
1708                &scratch.perm_imaw,
1709                &mut scratch.rhs_buf,
1710                &mut scratch.lu_scratch,
1711                b,
1712            );
1713            // w_u = W u_d
1714            for r in 0..b {
1715                let mut acc = 0.0f64;
1716                let wr = r * b;
1717                for k in 0..b {
1718                    acc += scratch.w_flat[wr + k] * scratch.rhs_buf[k];
1719                }
1720                scratch.w_u[r] = acc;
1721            }
1722            // t_d = (I - W A)⁻¹ w_u  (back-solve in place using w_u as RHS).
1723            lu_solve_in_place(
1724                &scratch.imwa,
1725                &scratch.perm_imwa,
1726                &mut scratch.w_u,
1727                &mut scratch.lu_scratch,
1728                b,
1729            );
1730            // v_dd = a_d^T t_d
1731            let mut v_dd = 0.0f64;
1732            for k in 0..b {
1733                v_dd += scratch.a_i[row_off + k] * scratch.w_u[k];
1734            }
1735            scratch.var_diag_buf[d] = v_dd.max(0.0);
1736        }
1737        let mut var_diag = Array1::<f64>::zeros(b);
1738        for d in 0..b {
1739            var_diag[d] = scratch.var_diag_buf[d];
1740        }
1741        alo_variance.push(var_diag);
1742    }
1743
1744    Ok(MultiBlockAloChunkDiagnostics {
1745        chunk_start,
1746        eta_tilde,
1747        leverage,
1748        alo_variance,
1749        cook_distance,
1750    })
1751}
1752
1753/// B × B row-major matmul: out = a · b.
1754#[inline]
1755fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
1756    for r in 0..b {
1757        let ar = r * b;
1758        let or = r * b;
1759        for c in 0..b {
1760            let mut acc = 0.0f64;
1761            for k in 0..b {
1762                acc += a[ar + k] * b_mat[k * b + c];
1763            }
1764            out[or + c] = acc;
1765        }
1766    }
1767}
1768
1769/// LU-decompose a B × B row-major matrix in place with partial pivoting and
1770/// physical row swaps. Returns false if any pivot |a_kk| < 1e-12 (singular).
1771/// On success, `m` holds L (strict lower, unit diag implicit) and U (upper, diag
1772/// included); `perm[k]` records the original-row index that ended up in physical
1773/// row k after pivoting. Pivot threshold matches the original `det_small < 1e-12`
1774/// path so the regularization branch fires under equivalent conditions.
1775fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize) -> bool {
1776    for i in 0..b {
1777        perm[i] = i;
1778    }
1779    for col in 0..b {
1780        // Partial pivot on column `col` over physical rows `[col..b]`.
1781        let mut max_val = m[col * b + col].abs();
1782        let mut max_idx = col;
1783        for row in (col + 1)..b {
1784            let v = m[row * b + col].abs();
1785            if v > max_val {
1786                max_val = v;
1787                max_idx = row;
1788            }
1789        }
1790        if max_val < LU_PIVOT_SINGULAR_TOL {
1791            return false;
1792        }
1793        if max_idx != col {
1794            // Physically swap rows `col` and `max_idx` (full row, all columns).
1795            for k in 0..b {
1796                m.swap(col * b + k, max_idx * b + k);
1797            }
1798            perm.swap(col, max_idx);
1799        }
1800        let pivot = m[col * b + col];
1801        for row in (col + 1)..b {
1802            let factor = m[row * b + col] / pivot;
1803            m[row * b + col] = factor; // store L below diag
1804            for k in (col + 1)..b {
1805                let upd = factor * m[col * b + k];
1806                m[row * b + k] -= upd;
1807            }
1808        }
1809    }
1810    true
1811}
1812
1813/// Solve L U x = P rhs using a previously factored matrix (LU in `m`, perm).
1814/// Writes the solution back into `rhs`. `scratch` must have length ≥ b.
1815fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
1816    // Forward substitution Ly = P rhs (L is unit-diag, strict lower of m).
1817    let y = &mut scratch[..b];
1818    for row in 0..b {
1819        let mut s = rhs[perm[row]];
1820        for k in 0..row {
1821            s -= m[row * b + k] * y[k];
1822        }
1823        y[row] = s;
1824    }
1825    // Back substitution U x = y.  Write into rhs[].
1826    for row in (0..b).rev() {
1827        let mut s = y[row];
1828        for k in (row + 1)..b {
1829            s -= m[row * b + k] * rhs[k];
1830        }
1831        rhs[row] = s / m[row * b + row];
1832    }
1833}
1834
1835/// Compute only per-observation leverages tr(H_ii) for multi-predictor models.
1836///
1837/// This is cheaper than the full ALO correction when only EDF or leverage
1838/// diagnostics are needed (no scores or W⁻¹ computation required).
1839///
1840/// Returns an n-length array of leverages.  The total model EDF is the sum
1841/// of all leverages.
1842pub fn compute_multiblock_alo_leverages(
1843    n_obs: usize,
1844    n_blocks: usize,
1845    block_designs: &[Array2<f64>],
1846    penalized_hessian_inv: &Array2<f64>,
1847    block_weights: &[Array2<f64>],
1848) -> Result<Array1<f64>, EstimationError> {
1849    use rayon::prelude::*;
1850
1851    let n = n_obs;
1852    let b = n_blocks;
1853    let p_tot = penalized_hessian_inv.nrows();
1854
1855    let col_offsets = multiblock_col_offsets(block_designs);
1856    let max_workers = rayon::current_num_threads();
1857    let chunk_size = multiblock_alo_parallel_leverage_chunk_size(p_tot, b, n, max_workers);
1858
1859    let mut leverage = Array1::<f64>::zeros(n);
1860
1861    // Per-block H_inv stripe scratch (p_tot × p_blk) is read-only once built
1862    // and shared by the parallel chunks.  Only per-chunk q/XT/B×B scratch is
1863    // replicated across Rayon workers.
1864    let block_widths: Vec<usize> = block_designs.iter().map(|d| d.ncols()).collect();
1865    let mut h_stripes: Vec<FaerMat<f64>> = block_widths
1866        .iter()
1867        .map(|&p_blk| FaerMat::<f64>::zeros(p_tot, p_blk))
1868        .collect();
1869    // Populate the H_inv stripes once: each block reads a constant column slab
1870    // out of `penalized_hessian_inv` and copies it into a column-major faer Mat.
1871    for blk in 0..b {
1872        let off_b = col_offsets[blk];
1873        let p_blk = block_widths[blk];
1874        let stripe = &mut h_stripes[blk];
1875        for c in 0..p_blk {
1876            for r in 0..p_tot {
1877                stripe[(r, c)] = penalized_hessian_inv[(r, off_b + c)];
1878            }
1879        }
1880    }
1881
1882    leverage
1883        .as_slice_mut()
1884        .expect("newly allocated Array1 is contiguous")
1885        .par_chunks_mut(chunk_size)
1886        .enumerate()
1887        .for_each(|(chunk_idx, leverage_chunk)| {
1888            let chunk_start = chunk_idx * chunk_size;
1889            let chunk_len = leverage_chunk.len();
1890            let chunk_end = chunk_start + chunk_len;
1891
1892            // Chunk-local scratch: B×B flat row-major buffers for A_i, W_i
1893            // and AW = A·W.  Each worker writes only its `leverage_chunk`, so
1894            // output writes are disjoint and require no synchronization.
1895            let bb_sz = b * b;
1896            let mut a_i = vec![0.0f64; bb_sz];
1897            let mut aw = vec![0.0f64; bb_sz];
1898            let mut w_flat = vec![0.0f64; bb_sz];
1899
1900            // Column-major faer storage for q_blocks: q_k has shape
1901            // (p_tot, chunk_len) with contiguous columns, so
1902            // `col_as_slice(local_i)` is a direct stripe.
1903            let mut q_storage: Vec<FaerMat<f64>> = block_widths
1904                .iter()
1905                .map(|_| FaerMat::<f64>::zeros(p_tot, chunk_len))
1906                .collect();
1907
1908            // Per-block X^T scratch in column-major faer storage
1909            // (p_blk × chunk_len), owned by this chunk to keep the matmul input
1910            // contiguous without sharing mutable scratch across threads.
1911            let mut xt_storage: Vec<FaerMat<f64>> = block_widths
1912                .iter()
1913                .map(|&p_blk| FaerMat::<f64>::zeros(p_blk, chunk_len))
1914                .collect();
1915
1916            // Build q_blocks[blk] = H_inv[:, off..off+p_blk] · X_blk[chunk, :]^T
1917            // entirely in column-major faer storage so subsequent column reads
1918            // are contiguous f64 stripes — replaces the per-chunk `to_owned()`
1919            // ndarray slicing + row-major `dot()` from the original.
1920            for blk in 0..b {
1921                let p_blk = block_widths[blk];
1922
1923                let x_chunk = block_designs[blk].slice(s![chunk_start..chunk_end, ..]);
1924                let xt = &mut xt_storage[blk];
1925                for local_i in 0..chunk_len {
1926                    let row = x_chunk.row(local_i);
1927                    for j in 0..p_blk {
1928                        xt[(j, local_i)] = row[j];
1929                    }
1930                }
1931
1932                matmul(
1933                    q_storage[blk].as_mut(),
1934                    Accum::Replace,
1935                    h_stripes[blk].as_ref(),
1936                    xt_storage[blk].as_ref(),
1937                    1.0,
1938                    Par::Seq,
1939                );
1940            }
1941
1942            for local_i in 0..chunk_len {
1943                let i = chunk_start + local_i;
1944                let w_i = &block_weights[i];
1945
1946                // Flatten W_i once per observation (row-major).
1947                for r in 0..b {
1948                    for c in 0..b {
1949                        w_flat[r * b + c] = w_i[(r, c)];
1950                    }
1951                }
1952
1953                // Assemble A_i[a, k] = X_a[i, :] · q_k[off_a:off_a+p_a, local_i].
1954                // For each k, read its column once (contiguous f64 stripe), then
1955                // for each a take the matching offset slab.
1956                for r in 0..bb_sz {
1957                    a_i[r] = 0.0;
1958                }
1959                for k in 0..b {
1960                    let q_k = &q_storage[k];
1961                    let q_col = q_k.col_as_slice(local_i);
1962                    for a in 0..b {
1963                        let p_a = block_widths[a];
1964                        let off_a = col_offsets[a];
1965                        let xa_row = block_designs[a].row(i);
1966                        let mut dot = 0.0f64;
1967                        for j in 0..p_a {
1968                            dot = xa_row[j].mul_add(q_col[off_a + j], dot);
1969                        }
1970                        a_i[a * b + k] = dot;
1971                    }
1972                }
1973
1974                // AW = A_i · W_i (B×B), then leverage = trace(AW) = sum_{a,k} A[a,k]·W[k,a].
1975                mat_mul_flat(&a_i, &w_flat, &mut aw, b);
1976                let mut tr = 0.0f64;
1977                for d in 0..b {
1978                    tr += aw[d * b + d];
1979                }
1980                leverage_chunk[local_i] = tr;
1981            }
1982        });
1983
1984    Ok(leverage)
1985}
1986
1987// (Allocation-free, factor-once-reuse-many B×B LU helpers live next to the
1988// multi-block ALO callsite — see `lu_factor_in_place` and `lu_solve_in_place`.)
1989
1990#[cfg(test)]
1991mod tests {
1992    use super::{
1993        ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
1994        alo_eta_updatewith_offset, bayesvar_eta, compute_alo_from_input_inner,
1995        percentile_from_sorted, percentile_index, sandwichvar_eta_from_meat,
1996    };
1997    use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
1998    use gam_problem::LinkFunction;
1999
2000    #[test]
2001    fn alo_offset_update_matches_centered_algebra() {
2002        let eta_hat = 11.0;
2003        let z = 13.0;
2004        let offset = 10.0;
2005        let x_hinv_x = 0.2;
2006        let hessian_weight = 1.0;
2007        let score_weight = 1.0;
2008        // centered: eta~=off + ((eta-off)-a(z-off))/(1-a) when W_S = W_H.
2009        let leverage = hessian_weight * x_hinv_x;
2010        let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2011        let got =
2012            alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2013        assert!((got - expected).abs() < 1e-12);
2014    }
2015
2016    #[test]
2017    fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2018        let eta_hat = 1.25;
2019        let z = -0.5;
2020        let x_hinv_x = 0.35;
2021        let hessian_weight = 1.0;
2022        let score_weight = 1.0;
2023        let leverage = hessian_weight * x_hinv_x;
2024        let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2025        let got =
2026            alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2027        assert!((got - expected).abs() < 1e-12);
2028    }
2029
2030    #[test]
2031    fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2032        let eta_hat = 1.7;
2033        let z = 0.4;
2034        let offset = -0.2;
2035        let x_hinv_x = 0.15;
2036        let hessian_weight = 3.0;
2037        let score_weight = 5.0;
2038        let expected = offset
2039            + (eta_hat - offset)
2040            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2041                / (1.0 - hessian_weight * x_hinv_x);
2042        let got = alo_eta_updatewith_offset(
2043            eta_hat,
2044            z,
2045            offset,
2046            x_hinv_x,
2047            score_weight,
2048            1.0 - hessian_weight * x_hinv_x,
2049        );
2050        assert!((got - expected).abs() < 1e-12);
2051    }
2052
2053    #[test]
2054    fn alo_offset_update_handles_zero_hessian_weight() {
2055        let eta_hat = 0.8;
2056        let z = -0.3;
2057        let offset = 0.1;
2058        let x_hinv_x = 0.4;
2059        let hessian_weight = 0.0;
2060        let score_weight = 2.5;
2061        let expected = offset
2062            + (eta_hat - offset)
2063            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2064        let got = alo_eta_updatewith_offset(
2065            eta_hat,
2066            z,
2067            offset,
2068            x_hinv_x,
2069            score_weight,
2070            1.0 - hessian_weight * x_hinv_x,
2071        );
2072        assert!((got - expected).abs() < 1e-12);
2073    }
2074
2075    #[test]
2076    fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2077        let eta_hat = 1.0;
2078        let a_ii = 0.4;
2079        let got = alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| (0.5 * (eta - 2.0), 0.5))
2080            .expect("linear scalar fixed point should converge in one Newton step");
2081        assert!((got - 0.75).abs() < 1e-12);
2082    }
2083
2084    #[test]
2085    fn alo_exact_frozen_curvature_reports_nonconvergence() {
2086        let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| (eta + 1.0, 0.0))
2087            .expect_err("constant residual should exhaust the scalar iteration budget");
2088        let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2089            panic!("constant residual must report MaxIterations, got {err:?}");
2090        };
2091        assert_eq!(
2092            iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2093            "non-convergence must report the full scalar iteration budget"
2094        );
2095    }
2096
2097    #[test]
2098    fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2099        let design = Array2::from_elem((1, 1), 1.0);
2100        let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2101        let hessian_weights = Array1::from_vec(vec![0.0]);
2102        let score_weights = Array1::from_vec(vec![0.0]);
2103        let working_response = Array1::from_vec(vec![0.0]);
2104        let eta = Array1::from_vec(vec![0.0]);
2105        let offset = Array1::from_vec(vec![0.0]);
2106        let score_curvature = |_: usize, eta: f64| (eta + 1.0, 0.0);
2107        let input = AloInput {
2108            design: &design,
2109            penalized_hessian: &penalized_hessian,
2110            hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2111            score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2112            working_response: &working_response,
2113            eta: &eta,
2114            offset: &offset,
2115            link: LinkFunction::Logit,
2116            phi: 1.0,
2117            penalty_root: None,
2118            ridge: 0.0,
2119            score_curvature: Some(&score_curvature),
2120        };
2121
2122        let err =
2123            compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2124        let msg = err.to_string();
2125        assert!(
2126            msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2127            "missing row context in exact ALO error: {msg}"
2128        );
2129        assert!(
2130            msg.contains("did not converge within"),
2131            "missing non-convergence cause in exact ALO error: {msg}"
2132        );
2133    }
2134
2135    #[test]
2136    fn gaussian_unpenalized_direct_sandwich_equals_bayes() {
2137        // In a Gaussian linear model with H = X'WX, direct meat
2138        // x_i'H^{-1}X'WXH^{-1}x_i equals x_i'H^{-1}x_i.
2139        let phi = 2.5;
2140        let x_hinv_x = 0.3;
2141        let vb = bayesvar_eta(phi, x_hinv_x);
2142        let vs = sandwichvar_eta_from_meat(phi, x_hinv_x);
2143        assert!((vb - vs).abs() < 1e-12);
2144    }
2145
2146    #[test]
2147    fn sandwich_from_direct_meat_scales_by_phi() {
2148        let phi = 1.7;
2149        let meat_quad = 0.358;
2150        let got = sandwichvar_eta_from_meat(phi, meat_quad);
2151        let expected = phi * meat_quad;
2152        assert!((got - expected).abs() < 1e-12);
2153    }
2154
2155    #[test]
2156    fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2157        // Regression for the sandwich-SE "meat" weight bug: the meat must be the
2158        // SCORE covariance Xᵀ diag(W_S) X (Fisher, PSD), NOT the observed-info
2159        // Hessian weight W_H (signed). This fixture mimics a non-canonical link
2160        // (W_H ≠ W_S) with mixed-sign observed curvature.
2161        //
2162        // Single column (p = 1) makes H a scalar, so the sandwich variance is
2163        // closed form: with H = Σ W_H·x² + s0 (> 0 after the penalty), the meat
2164        // for obs is x_obs²·H⁻²·Σ_row W_S·x_row², and se = sqrt(φ·meat).
2165        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2166        // Mixed-sign observed-information weights; the negative rows carry the
2167        // larger design values so Σ W_H·x² is NEGATIVE (see assert below).
2168        let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2169        // Score/Fisher weights are strictly positive (PSD by construction).
2170        let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2171        let phi = 1.3;
2172
2173        let n = x.nrows();
2174        let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2175        let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2176        // The whole point: Σ W_H·x² < 0 < Σ W_S·x². With W_H the meat is negative
2177        // and the "materially negative sandwich variance" guard would trip
2178        // (spurious LooComputationFailed); with W_S it is a valid PSD meat.
2179        assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2180        assert!(sum_ws_x2 > 0.0);
2181
2182        // Penalize enough that the penalized Hessian is PD despite Σ W_H·x² < 0.
2183        let s0 = 8.0_f64;
2184        let h = s0 + sum_wh_x2; // = 2.5
2185        assert!(h > 0.0, "penalized Hessian must stay PD");
2186        let penalized_hessian = Array2::from_elem((1, 1), h);
2187
2188        // Pre-fix arithmetic check: the OLD W_H meat would be materially negative
2189        // for the larger-x rows, so the old code returned LooComputationFailed.
2190        let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2191        assert!(
2192            phi * old_meat_obs1 < -super::variance_negative_tolerance(phi * old_meat_obs1.abs()),
2193            "the pre-fix W_H meat must be materially negative (guard would trip)"
2194        );
2195
2196        let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2197        let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2198        let offset = Array1::zeros(n);
2199        let input = AloInput {
2200            design: &x,
2201            penalized_hessian: &penalized_hessian,
2202            hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2203            score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2204            working_response: &working_response,
2205            eta: &eta,
2206            offset: &offset,
2207            link: LinkFunction::Probit,
2208            phi,
2209            penalty_root: None,
2210            ridge: 0.0,
2211            score_curvature: None,
2212        };
2213
2214        // The fix must let this succeed (no spurious negative-meat failure)...
2215        let diag = compute_alo_from_input_inner(&input)
2216            .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2217
2218        // ...and match the closed-form W_S reference for every row.
2219        for obs in 0..n {
2220            let expected =
2221                (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2222            assert!(
2223                (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2224                "row {obs}: se_sandwich={} expected={expected}",
2225                diag.se_sandwich[obs]
2226            );
2227        }
2228    }
2229
2230    #[test]
2231    fn percentile_index_matches_expected_rounding() {
2232        assert_eq!(percentile_index(0, 0.95), 0);
2233        assert_eq!(percentile_index(1, 0.95), 0);
2234        assert_eq!(percentile_index(10, 0.50), 5);
2235        assert_eq!(percentile_index(10, 0.95), 9);
2236    }
2237
2238    #[test]
2239    fn percentile_from_sorted_returns_order_statistic() {
2240        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2241        assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2242        assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2243        assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2244    }
2245
2246    // --- Multi-block ALO tests ---
2247
2248    use super::{MultiBlockAloInput, compute_multiblock_alo, compute_multiblock_alo_leverages};
2249    use ndarray::{Array1, Array2};
2250
2251    #[test]
2252    fn multiblock_b1_matches_scalar_leverage() {
2253        // With B=1 the multi-block formula should reduce to the scalar case.
2254        // H_ii = x_i^T H^{-1} x_i * w_i  (scalar).
2255        let n = 3;
2256        let p = 2;
2257        let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2258        // H = X'WX + I (simple regularisation).
2259        let w = [1.0, 2.0, 0.5];
2260        let mut h = Array2::<f64>::eye(p);
2261        for i in 0..n {
2262            for r in 0..p {
2263                for c in 0..p {
2264                    h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2265                }
2266            }
2267        }
2268        // Invert H (2x2).
2269        let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2270        let mut h_inv = Array2::<f64>::zeros((p, p));
2271        h_inv[(0, 0)] = h[(1, 1)] / det;
2272        h_inv[(1, 1)] = h[(0, 0)] / det;
2273        h_inv[(0, 1)] = -h[(0, 1)] / det;
2274        h_inv[(1, 0)] = -h[(1, 0)] / det;
2275
2276        // Scalar leverages: a_ii = w_i * x_i^T H^{-1} x_i
2277        let mut scalar_lev = vec![0.0f64; n];
2278        for i in 0..n {
2279            let mut xhx = 0.0;
2280            for r in 0..p {
2281                for c in 0..p {
2282                    xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2283                }
2284            }
2285            scalar_lev[i] = w[i] * xhx;
2286        }
2287
2288        // Multi-block with B=1.
2289        let block_designs = vec![x.clone()];
2290        let block_weights: Vec<Array2<f64>> =
2291            w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2292        let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2293        let eta_hat: Vec<Array1<f64>> = (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2294
2295        let input = MultiBlockAloInput {
2296            n_obs: n,
2297            n_blocks: 1,
2298            block_designs: &block_designs,
2299            penalized_hessian_inv: &h_inv,
2300            block_weights,
2301            scores,
2302            eta_hat,
2303        };
2304
2305        let result = compute_multiblock_alo(&input).unwrap();
2306        for i in 0..n {
2307            assert!(
2308                (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2309                "leverage mismatch at i={}: got {}, expected {}",
2310                i,
2311                result.leverage[i],
2312                scalar_lev[i]
2313            );
2314        }
2315    }
2316
2317    #[test]
2318    fn multiblock_leverage_only_matches_full() {
2319        // Verify that compute_multiblock_alo_leverages returns the same
2320        // leverages as compute_multiblock_alo.
2321        let n = 4;
2322        let p1 = 2;
2323        let p2 = 3;
2324        let x1 = Array2::from_shape_fn((n, p1), |(i, j)| (i + j + 1) as f64 * 0.3);
2325        let x2 = Array2::from_shape_fn((n, p2), |(i, j)| (i * 2 + j) as f64 * 0.2 - 0.1);
2326        let p_tot = p1 + p2;
2327        let h_inv = Array2::<f64>::eye(p_tot); // Simple identity for test.
2328        let block_weights: Vec<Array2<f64>> = (0..n)
2329            .map(|i| {
2330                let v = (i + 1) as f64;
2331                Array2::from_shape_vec((2, 2), vec![v, 0.1, 0.1, v * 0.5]).unwrap()
2332            })
2333            .collect();
2334        let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.0, 0.0])).collect();
2335        let eta_hat: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.0, 0.0])).collect();
2336        let block_designs = vec![x1.clone(), x2.clone()];
2337
2338        let input = MultiBlockAloInput {
2339            n_obs: n,
2340            n_blocks: 2,
2341            block_designs: &block_designs,
2342            penalized_hessian_inv: &h_inv,
2343            block_weights: block_weights.clone(),
2344            scores,
2345            eta_hat,
2346        };
2347        let full = compute_multiblock_alo(&input).unwrap();
2348        let lev_only =
2349            compute_multiblock_alo_leverages(n, 2, &block_designs, &h_inv, &block_weights).unwrap();
2350
2351        for i in 0..n {
2352            assert!(
2353                (full.leverage[i] - lev_only[i]).abs() < 1e-12,
2354                "leverage mismatch at i={}: full={}, lev_only={}",
2355                i,
2356                full.leverage[i],
2357                lev_only[i]
2358            );
2359        }
2360    }
2361
2362    #[test]
2363    fn multiblock_singular_weight_still_corrects() {
2364        // When W_i = 0 (singular), the W_i⁻¹-free formula still works:
2365        // (I - W_i A_i)⁻¹ = I, so Δη = A_i s_i.
2366        // A_i = x H⁻¹ xᵀ = 1.0² + 0.5² = 1.25 (scalar, B=1).
2367        let n = 1;
2368        let p = 2;
2369        let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2370        let h_inv = Array2::eye(p);
2371        let block_designs = vec![x.clone()];
2372        let block_weights = vec![Array2::from_elem((1, 1), 0.0)]; // singular
2373        let scores = vec![Array1::from_vec(vec![1.0])];
2374        let eta_hat = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2375
2376        let input = MultiBlockAloInput {
2377            n_obs: n,
2378            n_blocks: 1,
2379            block_designs: &block_designs,
2380            penalized_hessian_inv: &h_inv,
2381            block_weights,
2382            scores,
2383            eta_hat,
2384        };
2385        let result = compute_multiblock_alo(&input).unwrap();
2386        // Δη = A_i * s_i = 1.25 * 1.0 = 1.25
2387        let expected = std::f64::consts::PI + 1.25;
2388        assert!(
2389            (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2390            "expected {}, got {}",
2391            expected,
2392            result.eta_tilde[0][0]
2393        );
2394        // Cook's distance should be 0 since W_i = 0.
2395        assert!(result.cook_distance[0].abs() < 1e-14);
2396        // ALO variance should be 0 since W_i = 0.
2397        assert!(result.alo_variance[0][0].abs() < 1e-14);
2398    }
2399
2400    #[test]
2401    fn multiblock_cook_and_variance_basic() {
2402        // B=1 with known values: verify Cook's distance and variance.
2403        let n = 1;
2404        let x = Array2::from_elem((1, 1), 1.0);
2405        // H⁻¹ = [[0.5]]
2406        let h_inv = Array2::from_elem((1, 1), 0.5);
2407        let block_designs = vec![x.clone()];
2408        let w_val = 2.0;
2409        let s_val = 0.4;
2410        let block_weights = vec![Array2::from_elem((1, 1), w_val)];
2411        let scores = vec![Array1::from_vec(vec![s_val])];
2412        let eta_hat = vec![Array1::from_vec(vec![1.0])];
2413
2414        let input = MultiBlockAloInput {
2415            n_obs: n,
2416            n_blocks: 1,
2417            block_designs: &block_designs,
2418            penalized_hessian_inv: &h_inv,
2419            block_weights,
2420            scores,
2421            eta_hat,
2422        };
2423        let result = compute_multiblock_alo(&input).unwrap();
2424
2425        // A_i = x H⁻¹ xᵀ = 1 * 0.5 * 1 = 0.5
2426        // (I - W A)⁻¹ = 1 / (1 - 2.0 * 0.5) = 1/0 => regularised
2427        // Actually 1 - w*a = 1 - 1.0 = 0.0, so det < 1e-12 => regularised with eps=1e-6
2428        // (I - W A + eps) = 1e-6, so v = s / 1e-6 = 4e5
2429        // delta_eta = A * v = 0.5 * 4e5 = 2e5
2430        // This is the regularised case; just check it doesn't panic and returns finite values.
2431        assert!(result.eta_tilde[0][0].is_finite());
2432        assert!(result.cook_distance[0].is_finite());
2433        assert!(result.alo_variance[0][0].is_finite());
2434    }
2435}