Skip to main content

gam_solve/inference/
alo.rs

1use crate::estimate::{
2    EstimationError, FitGeometry, UnifiedFitResult, WorkingGeometry, dispersion_from_likelihood,
3};
4use crate::pirls;
5use faer::Mat as FaerMat;
6use faer::linalg::matmul::matmul;
7use faer::prelude::ReborrowMut;
8use faer::{Accum, Par};
9use gam_linalg::faer_ndarray::{FaerArrayView, FaerCholesky};
10use gam_linalg::matrix::{DesignMatrix, PsdWeightsView, SignedWeightsView};
11use gam_linalg::utils::{
12    CertifiedSpdFactor, certified_spd_factorize, symmetric_extremes,
13    validate_finite_symmetric_matrix,
14};
15use gam_math::probability::signed_log_sum_exp;
16use gam_problem::{Dispersion, LikelihoodScaleMetadata, LinkFunction, ResponseFamily};
17use ndarray::{Array1, Array2, ArrayView1, ShapeBuilder, s};
18use opt::{BacktrackConfig, backtracking_line_search};
19use std::convert::Infallible;
20use std::fmt;
21use std::ops::Range;
22
23/// Typed error variants for the ALO (approximate leave-one-out) diagnostics
24/// module.
25///
26/// Public entry points continue to return `Result<_, EstimationError>`; this
27/// enum is materialized at leaf sites and converted at the boundary via
28/// `From<AloError> for EstimationError` so error text remains byte-identical
29/// to the previous `EstimationError::InvalidInput(format!(...))` /
30/// `ModelIsIllConditioned { ... }` output.
31#[derive(Debug, Clone)]
32pub enum AloError {
33    /// Caller-supplied configuration is structurally invalid: dimension
34    /// mismatch, non-finite inputs that are not weights/response, missing
35    /// PIRLS / geometry artifacts, or out-of-range scalar parameters.
36    InvalidInput { reason: String },
37    /// IRLS weights or working response contain a non-finite entry, or the
38    /// working response itself is invalid.
39    WeightInvalid { reason: String },
40    /// The dense design matrix required for ALO could not be materialized
41    /// from the underlying PIRLS artifact (e.g. sparse-only export).
42    DesignDegenerate { reason: String },
43    /// Per-observation ALO computation produced a non-finite value (variance,
44    /// denominator, or corrected η̃) at convergence.
45    LooComputationFailed { reason: String },
46}
47
48impl fmt::Display for AloError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            AloError::InvalidInput { reason }
52            | AloError::WeightInvalid { reason }
53            | AloError::DesignDegenerate { reason }
54            | AloError::LooComputationFailed { reason } => f.write_str(reason),
55        }
56    }
57}
58
59impl std::error::Error for AloError {}
60
61impl From<AloError> for EstimationError {
62    fn from(err: AloError) -> EstimationError {
63        match err {
64            AloError::InvalidInput { reason }
65            | AloError::WeightInvalid { reason }
66            | AloError::DesignDegenerate { reason }
67            | AloError::LooComputationFailed { reason } => EstimationError::InvalidInput(reason),
68        }
69    }
70}
71
72impl From<AloError> for String {
73    fn from(err: AloError) -> String {
74        err.to_string()
75    }
76}
77
78/// Approximate leave-one-out diagnostics derived from a fitted model.
79#[derive(Debug, Clone)]
80pub struct AloDiagnostics {
81    pub eta_tilde: Array1<f64>,
82    /// Bayesian/conditional standard error on eta:
83    /// sqrt(phi * x_i^T H^{-1} x_i).
84    pub se_bayes: Array1<f64>,
85    /// Frequentist sandwich-style standard error on eta:
86    /// sqrt(phi * x_i^T H^{-1} X^T W X H^{-1} x_i).
87    pub se_sandwich: Array1<f64>,
88    /// Observed-curvature row leverage `W_H,i x_iᵀH⁻¹x_i`. This is signed for
89    /// non-canonical links; negative values are valid and are never projected.
90    pub leverage: Array1<f64>,
91}
92
93#[inline]
94fn alo_eta_updatewith_offset(
95    eta_hat: f64,
96    z: f64,
97    offset: f64,
98    x_hinv_x: f64,
99    score_weight: f64,
100    denom: f64,
101) -> f64 {
102    // PIRLS working-response algebra is centered on offset, so the scalar
103    // score uses (eta - offset) - (z - offset).
104    let eta_centered = eta_hat - offset;
105    let z_centered = z - offset;
106    let score = score_weight * (eta_centered - z_centered);
107    offset + eta_centered + x_hinv_x * score / denom
108}
109
110/// Per-row score and curvature of the penalized NLL contribution as functions
111/// of the row's linear predictor `eta`.
112///
113/// Returns `(ℓ_i'(eta), ℓ_i''(eta))` where `ℓ_i` is the (dispersion-scaled)
114/// negative log-likelihood of observation `i` viewed as a univariate function
115/// of `eta_i = x_i^T β`. This is the local family geometry that the ALO
116/// frozen-curvature fixed point [`alo_eta_exact_frozen_curvature`] iterates to
117/// convergence; supplying it upgrades the single-Newton-step ALO correction to
118/// the exact leave-`i`-out predictor under a frozen penalized Hessian.
119pub type AloScalarScoreCurvature<'a> =
120    dyn Fn(usize, f64) -> Result<(f64, f64), AloError> + Sync + 'a;
121
122/// Maximum scalar Newton iterations for the exact frozen-curvature ALO fixed
123/// point. The map `r(η) = η − η̂ − a_ii ℓ_i'(η)` is one-dimensional and
124/// strongly contractive for the well-leveraged majority of points, so this
125/// caps the rare high-leverage / near-separation rows where convergence is
126/// slow without ever exceeding O(1) work per observation.
127const ALO_EXACT_SCALAR_MAX_ITERS: usize = 64;
128
129/// Backward-error allowance for the three-term scalar residual
130/// `η - η̂ - a·ℓ'(η)`. It scales with the largest term and shrinks all the way
131/// to exact zero, so small predictors do not inherit an absolute error floor.
132#[inline]
133fn alo_scalar_residual_allowance(eta: f64, eta_hat: f64, score_step: f64) -> f64 {
134    32.0 * f64::EPSILON * eta.abs().max(eta_hat.abs()).max(score_step.abs())
135}
136
137/// Solve the frozen-curvature ALO leave-`i`-out fixed point exactly.
138///
139/// The leave-`i`-out optimum differs from the full fit only through the removed
140/// observation, whose gradient/Hessian depend on `β` solely via the scalar
141/// `η_i = x_i^T β`. Freezing the penalized Hessian `H` at its converged value
142/// reduces the exact leave-`i`-out condition to the scalar equation
143///
144///   η = η̂_i + a_ii · ℓ_i'(η),     a_ii = x_i^T H^{-1} x_i,
145///
146/// where `ℓ_i'(η)` is the row's NLL score (so that `∇F = ℓ_i'(η_i) x_i` at the
147/// leave-`i`-out point). The single-Newton-step ALO is exactly the first
148/// iterate of Newton's method on `r(η) = η − η̂_i − a_ii ℓ_i'(η)` started at
149/// `η̂_i`; iterating to convergence captures the change in the held-out point's
150/// likelihood curvature (the dominant first-order error on small-`n`, curved
151/// likelihoods such as binomial logistic regression near separation).
152///
153/// `score_curvature(eta)` returns `(ℓ_i'(eta), ℓ_i''(eta))`. The returned value
154/// is the corrected linear predictor `η̃_i`. Failure to reach the residual
155/// tolerance is reported to the caller; no one-step approximation is substituted
156/// for a failed exact solve.
157#[derive(Debug, Clone, PartialEq)]
158enum AloExactScalarError {
159    EvaluationFailed {
160        eta: f64,
161        reason: String,
162    },
163    NonFiniteScoreCurvature {
164        eta: f64,
165        ell_prime: f64,
166        ell_double: f64,
167    },
168    DegenerateJacobian {
169        eta: f64,
170        jacobian: f64,
171    },
172    NonFiniteStep {
173        eta: f64,
174        residual: f64,
175        jacobian: f64,
176        next: f64,
177    },
178    MaxIterations {
179        iterations: usize,
180        residual: f64,
181        tolerance: f64,
182        eta: f64,
183    },
184}
185
186impl fmt::Display for AloExactScalarError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match *self {
189            AloExactScalarError::EvaluationFailed { eta, ref reason } => {
190                write!(
191                    f,
192                    "score/curvature evaluation failed at eta={eta:.6e}: {reason}"
193                )
194            }
195            AloExactScalarError::NonFiniteScoreCurvature {
196                eta,
197                ell_prime,
198                ell_double,
199            } => write!(
200                f,
201                "non-finite score/curvature at eta={eta:.6e}: ell_prime={ell_prime:.6e}, ell_double={ell_double:.6e}"
202            ),
203            AloExactScalarError::DegenerateJacobian { eta, jacobian } => write!(
204                f,
205                "degenerate Newton Jacobian at eta={eta:.6e}: jacobian={jacobian:.6e}"
206            ),
207            AloExactScalarError::NonFiniteStep {
208                eta,
209                residual,
210                jacobian,
211                next,
212            } => write!(
213                f,
214                "non-finite Newton step from eta={eta:.6e}: residual={residual:.6e}, jacobian={jacobian:.6e}, next={next:.6e}"
215            ),
216            AloExactScalarError::MaxIterations {
217                iterations,
218                residual,
219                tolerance,
220                eta,
221            } => write!(
222                f,
223                "did not converge within {iterations} iterations: residual={residual:.6e}, eta={eta:.6e}, backward-error allowance={tolerance:.6e}"
224            ),
225        }
226    }
227}
228
229/// Maximum number of step halvings in the backtracking line search that
230/// globalizes the scalar Newton iteration. `2^{-40}` shrinks a unit step below
231/// one ulp relative to an ordinary finite η, so a row that cannot make progress
232/// within this budget is genuinely stalled rather than merely under-damped.
233const ALO_EXACT_SCALAR_BACKTRACKS: usize = 40;
234
235#[inline]
236fn alo_eta_exact_frozen_curvature(
237    eta_hat: f64,
238    a_ii: f64,
239    score_curvature: &dyn Fn(f64) -> Result<(f64, f64), AloError>,
240) -> Result<f64, AloExactScalarError> {
241    // Residual of the leave-i-out fixed point η = η̂ + a_ii ℓ'(η):
242    //   r(η) = η − η̂ − a_ii ℓ'(η),     r'(η) = 1 − a_ii ℓ''(η) = jac.
243    // For an exponential-family NLL score ℓ'(η) = c_i(μ(η) − y) on a non-linear
244    // (e.g. log) link the curvature ℓ''(η) = c_i μ'(η) grows without bound, so
245    // r(η) is concave with an interior maximum where the weighted leverage
246    // a_ii ℓ'' passes 1 (jac = 0): the leave-i-out root that limits to η̂ as
247    // a_ii → 0 sits on the jac > 0 branch anchored at η̂, while beyond the
248    // maximum r turns over and diverges as μ(η) explodes.
249    //
250    // Two safeguards make the scalar solve globally convergent to that root:
251    //
252    //   1. Anchor the iteration at η̂ itself, not at the classical one-step ALO
253    //      predictor. At η̂ the weighted leverage a_ii ℓ''(η̂) < 1, so jac ≈ 1
254    //      and we start strictly inside the correct basin; the brute-force
255    //      n-fold reference solves the identical fixed point anchored at η̂.
256    //      Seeding at the one-step predictor instead can land a high-leverage
257    //      row *past* the interior maximum on the runaway branch, from which no
258    //      Newton iteration returns (Poisson/log row 198: η ≈ 6.3, r ≈ −577).
259    //
260    //   2. Backtrack on the merit ½r(η)². The Newton direction d = −r/jac
261    //      satisfies (½r²)'·d = r·jac·(−r/jac) = −r² < 0 for any finite nonzero
262    //      jac, so halving the step until |r| strictly decreases never leaves
263    //      the basin even if a full step would overshoot the maximum.
264    let residual_and_jac = |eta: f64| -> Result<(f64, f64, f64), AloExactScalarError> {
265        let (ell_prime, ell_double) =
266            score_curvature(eta).map_err(|error| AloExactScalarError::EvaluationFailed {
267                eta,
268                reason: error.to_string(),
269            })?;
270        if !ell_prime.is_finite() || !ell_double.is_finite() {
271            return Err(AloExactScalarError::NonFiniteScoreCurvature {
272                eta,
273                ell_prime,
274                ell_double,
275            });
276        }
277        let score_step = a_ii * ell_prime;
278        let residual = eta - eta_hat - score_step;
279        let jacobian = 1.0 - a_ii * ell_double;
280        let tolerance = alo_scalar_residual_allowance(eta, eta_hat, score_step);
281        if !score_step.is_finite()
282            || !residual.is_finite()
283            || !jacobian.is_finite()
284            || !tolerance.is_finite()
285        {
286            return Err(AloExactScalarError::NonFiniteStep {
287                eta,
288                residual,
289                jacobian,
290                next: f64::NAN,
291            });
292        }
293        Ok((residual, jacobian, tolerance))
294    };
295
296    let mut eta = eta_hat;
297    let (mut residual, mut jac, mut tolerance) = residual_and_jac(eta)?;
298    for _ in 0..ALO_EXACT_SCALAR_MAX_ITERS {
299        if residual.abs() <= tolerance {
300            return Ok(eta);
301        }
302        if jac == 0.0 || !jac.is_finite() {
303            return Err(AloExactScalarError::DegenerateJacobian { eta, jacobian: jac });
304        }
305        let step = residual / jac;
306        if !step.is_finite() {
307            return Err(AloExactScalarError::NonFiniteStep {
308                eta,
309                residual,
310                jacobian: jac,
311                next: eta - step,
312            });
313        }
314        // Backtracking line search: take the longest damped Newton step
315        // 2^{-k} that strictly reduces the merit |r|. A trial whose
316        // score/curvature evaluation errors (the runaway branch) is INVALID
317        // (`Ok(None)`), so the search retreats toward η̂ without consulting
318        // the merit test.
319        let accepted = match backtracking_line_search::<_, Infallible>(
320            BacktrackConfig {
321                max_steps: ALO_EXACT_SCALAR_BACKTRACKS,
322                ..BacktrackConfig::default()
323            },
324            |t| {
325                let trial = eta - t * step;
326                Ok(residual_and_jac(trial)
327                    .ok()
328                    .map(|(r_trial, j_trial, tol_trial)| {
329                        (r_trial.abs(), (trial, r_trial, j_trial, tol_trial))
330                    }))
331            },
332            |_t, merit| merit < residual.abs(),
333        ) {
334            Ok(result) => result,
335            Err(never) => match never {},
336        };
337        let Some(step) = accepted else {
338            break;
339        };
340        (eta, residual, jac, tolerance) = step.payload;
341    }
342    Err(AloExactScalarError::MaxIterations {
343        iterations: ALO_EXACT_SCALAR_MAX_ITERS,
344        residual,
345        tolerance,
346        eta,
347    })
348}
349
350/// Evaluate `rhs' solution` after a residual-certified SPD solve. Neumaier
351/// compensation is the allocation-free hot path. Only an overflowed,
352/// underflowed, or non-positive cancellation result pays for the signed-log
353/// reconstruction, which can distinguish an unrepresentable result from a
354/// silently wrong sign without multiplying two huge coordinates first.
355fn spd_quadratic_after_certified_solve(
356    row: usize,
357    rhs: ArrayView1<'_, f64>,
358    solution: ArrayView1<'_, f64>,
359) -> Result<f64, AloError> {
360    if rhs.len() != solution.len() {
361        return Err(AloError::LooComputationFailed {
362            reason: format!(
363                "ALO certified quadratic dimension mismatch at row {row}: rhs={}, solution={}",
364                rhs.len(),
365                solution.len()
366            ),
367        });
368    }
369    let mut sum = 0.0_f64;
370    let mut compensation = 0.0_f64;
371    let mut rhs_nonzero = false;
372    let mut fast_path_finite = true;
373    for (&left, &right) in rhs.iter().zip(solution.iter()) {
374        if !left.is_finite() || !right.is_finite() {
375            return Err(AloError::LooComputationFailed {
376                reason: format!(
377                    "ALO certified solve produced a non-finite quadratic coordinate at row {row}: rhs={left}, solution={right}"
378                ),
379            });
380        }
381        rhs_nonzero |= left != 0.0;
382        let term = left * right;
383        if !term.is_finite() {
384            fast_path_finite = false;
385            continue;
386        }
387        let next = sum + term;
388        if !next.is_finite() {
389            fast_path_finite = false;
390            continue;
391        }
392        compensation += if sum.abs() >= term.abs() {
393            (sum - next) + term
394        } else {
395            (term - next) + sum
396        };
397        sum = next;
398    }
399    let fast = sum + compensation;
400    if !rhs_nonzero {
401        return Ok(0.0);
402    }
403    if fast_path_finite && fast.is_finite() && fast > 0.0 {
404        return Ok(fast);
405    }
406
407    let mut log_magnitudes = Vec::with_capacity(rhs.len());
408    let mut signs = Vec::with_capacity(rhs.len());
409    for (&left, &right) in rhs.iter().zip(solution.iter()) {
410        if left == 0.0 || right == 0.0 {
411            log_magnitudes.push(f64::NEG_INFINITY);
412            signs.push(0.0);
413        } else {
414            log_magnitudes.push(left.abs().ln() + right.abs().ln());
415            signs.push(left.signum() * right.signum());
416        }
417    }
418    let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
419    if sign <= 0.0 || !log_magnitude.is_finite() {
420        return Err(AloError::LooComputationFailed {
421            reason: format!(
422                "ALO SPD quadratic could not be represented as strictly positive at row {row}: sign={sign}, log_magnitude={log_magnitude}, fast_value={fast}"
423            ),
424        });
425    }
426    let value = log_magnitude.exp();
427    if !value.is_finite() || value == 0.0 {
428        return Err(AloError::LooComputationFailed {
429            reason: format!(
430                "ALO SPD quadratic lies outside the nonzero finite f64 range at row {row}: log_magnitude={log_magnitude}"
431            ),
432        });
433    }
434    Ok(value)
435}
436
437/// Sum `weights[i] * values[i]^2` without forming `values[i]^2` first.
438/// Every term is non-negative by contract. A logarithmic reconstruction is
439/// needed only if direct products or the positive accumulation leave f64.
440fn finite_weighted_square_sum(
441    observation: usize,
442    weights: ArrayView1<'_, f64>,
443    values: &[f64],
444) -> Result<f64, AloError> {
445    if weights.len() != values.len() {
446        return Err(AloError::LooComputationFailed {
447            reason: format!(
448                "ALO sandwich quadratic dimension mismatch for observation {observation}: weights={}, values={}",
449                weights.len(),
450                values.len()
451            ),
452        });
453    }
454    let mut sum = 0.0_f64;
455    let mut compensation = 0.0_f64;
456    let mut has_mathematically_positive_term = false;
457    let mut fast_path_finite = true;
458    for (&weight, &value) in weights.iter().zip(values.iter()) {
459        if !weight.is_finite() || weight < 0.0 || !value.is_finite() {
460            return Err(AloError::LooComputationFailed {
461                reason: format!(
462                    "ALO sandwich quadratic has an invalid coordinate for observation {observation}: weight={weight}, value={value}"
463                ),
464            });
465        }
466        if weight == 0.0 || value == 0.0 {
467            continue;
468        }
469        has_mathematically_positive_term = true;
470        let term = (weight * value) * value;
471        if !term.is_finite() || term == 0.0 {
472            fast_path_finite = false;
473            continue;
474        }
475        let next = sum + term;
476        if !next.is_finite() {
477            fast_path_finite = false;
478            continue;
479        }
480        compensation += if sum.abs() >= term {
481            (sum - next) + term
482        } else {
483            (term - next) + sum
484        };
485        sum = next;
486    }
487    let fast = sum + compensation;
488    if !has_mathematically_positive_term {
489        return Ok(0.0);
490    }
491    if fast_path_finite && fast.is_finite() && fast > 0.0 {
492        return Ok(fast);
493    }
494
495    let mut log_magnitudes = Vec::with_capacity(values.len());
496    let mut signs = Vec::with_capacity(values.len());
497    for (&weight, &value) in weights.iter().zip(values.iter()) {
498        if weight == 0.0 || value == 0.0 {
499            log_magnitudes.push(f64::NEG_INFINITY);
500            signs.push(0.0);
501        } else {
502            log_magnitudes.push(weight.ln() + 2.0 * value.abs().ln());
503            signs.push(1.0);
504        }
505    }
506    let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
507    let value = log_magnitude.exp();
508    if sign != 1.0 || !value.is_finite() || value == 0.0 {
509        return Err(AloError::LooComputationFailed {
510            reason: format!(
511                "ALO sandwich quadratic lies outside the positive finite f64 range for observation {observation}: sign={sign}, log_magnitude={log_magnitude}"
512            ),
513        });
514    }
515    Ok(value)
516}
517
518fn finite_nonnegative_product(
519    row: usize,
520    quantity: &'static str,
521    left: f64,
522    right: f64,
523) -> Result<f64, AloError> {
524    if !(left.is_finite() && left >= 0.0 && right.is_finite() && right >= 0.0) {
525        return Err(AloError::LooComputationFailed {
526            reason: format!(
527                "ALO {quantity} requires finite non-negative factors at row {row}: left={left}, right={right}"
528            ),
529        });
530    }
531    if left == 0.0 || right == 0.0 {
532        return Ok(0.0);
533    }
534    let direct = left * right;
535    if direct.is_finite() && direct > 0.0 {
536        return Ok(direct);
537    }
538    let log_magnitude = left.ln() + right.ln();
539    let value = log_magnitude.exp();
540    if !value.is_finite() || value == 0.0 {
541        return Err(AloError::LooComputationFailed {
542            reason: format!(
543                "ALO {quantity} lies outside the positive finite f64 range at row {row}: log_magnitude={log_magnitude}"
544            ),
545        });
546    }
547    Ok(value)
548}
549
550fn finite_signed_product(
551    row: usize,
552    quantity: &'static str,
553    left: f64,
554    right: f64,
555) -> Result<f64, AloError> {
556    if !left.is_finite() || !right.is_finite() {
557        return Err(AloError::LooComputationFailed {
558            reason: format!(
559                "ALO {quantity} requires finite factors at row {row}: left={left}, right={right}"
560            ),
561        });
562    }
563    if left == 0.0 || right == 0.0 {
564        return Ok(0.0);
565    }
566    let direct = left * right;
567    if direct.is_finite() && direct != 0.0 {
568        return Ok(direct);
569    }
570    let log_magnitude = left.abs().ln() + right.abs().ln();
571    let value = left.signum() * right.signum() * log_magnitude.exp();
572    if !value.is_finite() || value == 0.0 {
573        return Err(AloError::LooComputationFailed {
574            reason: format!(
575                "ALO {quantity} lies outside the nonzero finite f64 range at row {row}: sign={}, log_magnitude={log_magnitude}",
576                left.signum() * right.signum()
577            ),
578        });
579    }
580    Ok(value)
581}
582
583const LEVERAGE_HIGH_THRESHOLD: f64 = 0.99;
584const LEVERAGE_VERY_HIGH_THRESHOLD: f64 = 0.999;
585const LEVERAGE_RATE_THRESHOLDS: [f64; 3] = [0.90, 0.95, 0.99];
586const LEVERAGE_PERCENTILES: [f64; 3] = [0.50, 0.95, 0.99];
587const MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
588
589/// Number of observation columns solved per blocked right-hand-side batch in the
590/// scalar-leverage path. Sizes the reusable `(p, .)` and `(e_rank, .)` scratch
591/// buffers so the dense multi-RHS solve stays BLAS-3 (good cache reuse) without
592/// materializing all `n` columns at once. The final batch is the remainder.
593const ALO_MAX_RHS_BLOCK_COLS: usize = 8192;
594
595/// Choose the scalar ALO solve width from the actual live scratch footprint.
596///
597/// One column retains `X'H^-1` input (`p`), the certified solution and its
598/// product/residual workspaces (conservatively `4p`), and `X H^-1 x_i` (`n`).
599/// The old fixed width of 8192 made the supposedly blocked `n x width` scratch
600/// consume multiple GiB on ordinary large fits. Saturating dimension arithmetic
601/// makes even an impossible allocation request resolve to a one-column attempt
602/// instead of wrapping the budget calculation.
603#[inline]
604fn alo_rhs_block_cols(n: usize, p: usize) -> usize {
605    let scalars_per_col = n.saturating_add(p.saturating_mul(5)).max(1);
606    let bytes_per_col = std::mem::size_of::<f64>().saturating_mul(scalars_per_col);
607    (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_col.max(1))
608        .max(1)
609        .min(ALO_MAX_RHS_BLOCK_COLS)
610}
611
612/// Roundoff multiplier for sign checks on local PSD quadratics. The deletion
613/// systems themselves use an operation-count-derived formation and solve bound;
614/// see [`identity_minus_product_lu_tolerance`].
615const LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR: f64 = 8.0;
616
617#[inline]
618fn percentile_index(sample_size: usize, quantile: f64) -> usize {
619    if sample_size <= 1 {
620        return 0;
621    }
622    let max_index = sample_size - 1;
623    ((quantile * max_index as f64).round() as usize).min(max_index)
624}
625
626#[inline]
627fn percentile_from_sorted(sorted: &[f64], quantile: f64) -> f64 {
628    if sorted.is_empty() {
629        0.0
630    } else {
631        sorted[percentile_index(sorted.len(), quantile)]
632    }
633}
634
635#[inline]
636fn compute_alo_diagnostics_from_pirls_impl(
637    base: &pirls::PirlsResult,
638    y: ArrayView1<f64>,
639) -> Result<AloDiagnostics, EstimationError> {
640    compute_alo_diagnostics_from_pirls_inner(base, y).map_err(EstimationError::from)
641}
642
643/// Resolve the multiplier on `H^-1` from the likelihood metadata and the
644/// converged profiled-Gaussian residual geometry. This is deliberately not a
645/// link switch: Gamma, Tweedie, Beta, NB, Poisson, Binomial, and fixed-scale
646/// Gaussian already carry their full scale in the working Hessian and therefore
647/// all have coefficient-covariance multiplier one.
648fn alo_covariance_scale(base: &pirls::PirlsResult) -> Result<f64, AloError> {
649    let dispersion = match (&base.likelihood.spec.response, base.likelihood.scale) {
650        (ResponseFamily::Gaussian, LikelihoodScaleMetadata::ProfiledGaussian) => {
651            let rss = base.deviance;
652            if !(rss.is_finite() && rss >= 0.0) {
653                return Err(AloError::InvalidInput {
654                    reason: format!(
655                        "ALO requires a finite non-negative profiled-Gaussian residual sum of squares; got {rss}"
656                    ),
657                });
658            }
659            let mut positive_rows = 0usize;
660            for (row, &weight) in base.finalweights.iter().enumerate() {
661                if !weight.is_finite() || weight < 0.0 {
662                    return Err(AloError::WeightInvalid {
663                        reason: format!(
664                            "profiled-Gaussian ALO requires finite non-negative converged weights; row {row} has {weight}"
665                        ),
666                    });
667                }
668                positive_rows += usize::from(weight > 0.0);
669            }
670            let residual_dof = positive_rows as f64 - base.edf;
671            if !(residual_dof.is_finite() && residual_dof > 0.0) {
672                return Err(AloError::InvalidInput {
673                    reason: format!(
674                        "profiled-Gaussian ALO requires positive residual degrees of freedom; positive_rows={positive_rows}, edf={}, residual_dof={residual_dof}",
675                        base.edf
676                    ),
677                });
678            }
679            let phi = rss / residual_dof;
680            if !phi.is_finite() || (rss > 0.0 && phi == 0.0) {
681                return Err(AloError::InvalidInput {
682                    reason: format!(
683                        "profiled-Gaussian ALO residual variance is not representable: rss={rss}, residual_dof={residual_dof}, phi={phi}"
684                    ),
685                });
686            }
687            Dispersion::estimated(phi).map_err(|error| AloError::InvalidInput {
688                reason: format!("invalid profiled-Gaussian ALO dispersion: {error}"),
689            })?
690        }
691        _ => dispersion_from_likelihood(&base.likelihood, None).map_err(|error| {
692            AloError::InvalidInput {
693                reason: format!("ALO could not resolve likelihood scale metadata: {error}"),
694            }
695        })?,
696    };
697    let scale = base
698        .likelihood
699        .coefficient_covariance_scale(dispersion.phi())
700        .map_err(|error| AloError::InvalidInput {
701            reason: format!("ALO could not resolve coefficient-covariance scale: {error}"),
702        })?;
703    if !(scale.is_finite() && scale > 0.0) {
704        return Err(AloError::InvalidInput {
705            reason: format!(
706                "ALO coefficient covariance is unavailable at non-positive or non-finite scale {scale}"
707            ),
708        });
709    }
710    Ok(scale)
711}
712
713/// True when the fitted GLM uses a *curved* canonical link, so that the row NLL
714/// score and curvature satisfy `ℓ_i'(η) = c_i(μ(η)−y_i)` and `ℓ_i''(η) = c_i μ'(η)`
715/// with a single per-row scale `c_i = (prior weight)/φ`. This is the exact
716/// condition under which the frozen-curvature ALO scalar fixed point matches
717/// the leave-`i`-out refit; only these families enable the exact refinement.
718///
719/// Gaussian identity is canonical too, but its per-row curvature is *constant*
720/// (`μ'(η) ≡ 1`), so the classical Sherman–Morrison one-step ALO is already the
721/// exact frozen-Hessian leave-`i`-out solution. Routing it through the scalar
722/// Newton closure would only add an O(n) nonlinear solve to diagnostics and
723/// quality sweeps without changing the answer, so it is excluded here and falls
724/// back to the (exact, for this family) one-step formula.
725fn alo_link_needs_exact_curvature_refinement(likelihood: &gam_problem::GlmLikelihoodSpec) -> bool {
726    use gam_problem::ResponseFamily;
727    matches!(
728        (&likelihood.spec.response, likelihood.link_function()),
729        (ResponseFamily::Binomial, LinkFunction::Logit)
730            | (ResponseFamily::Poisson, LinkFunction::Log)
731    )
732}
733
734fn compute_alo_diagnostics_from_pirls_inner(
735    base: &pirls::PirlsResult,
736    y: ArrayView1<f64>,
737) -> Result<AloDiagnostics, AloError> {
738    let x_dense_arc = base
739        .x_transformed
740        .try_to_dense_arc("ALO diagnostics require dense transformed design")
741        .map_err(|reason| AloError::DesignDegenerate { reason })?;
742    let x_dense = x_dense_arc.as_ref();
743    let n = x_dense.nrows();
744    if y.len() != n {
745        return Err(AloError::InvalidInput {
746            reason: format!(
747                "ALO response length must match the design row count; got {} responses for {n} rows",
748                y.len()
749            ),
750        });
751    }
752    if alo_link_needs_exact_curvature_refinement(&base.likelihood) {
753        for (row, &response) in y.iter().enumerate() {
754            let valid = response.is_finite()
755                && match &base.likelihood.spec.response {
756                    ResponseFamily::Binomial => (0.0..=1.0).contains(&response),
757                    ResponseFamily::Poisson => response >= 0.0,
758                    _ => true,
759                };
760            if !valid {
761                return Err(AloError::InvalidInput {
762                    reason: format!(
763                        "ALO canonical refinement received an invalid response at row {row}: {response}"
764                    ),
765                });
766            }
767        }
768    }
769
770    let phi = alo_covariance_scale(base)?;
771
772    // ALO needs the exact penalized Hessian materialized densely for chunked,
773    // residual-certified SPD solves. The PIRLS export path validates the matrix
774    // instead of falling back to a numerical Hessian approximation.
775    let h_dense_for_alo = base
776        .dense_stabilizedhessian_transformed(
777            "ALO diagnostics require exact dense stabilized penalized Hessian",
778        )
779        .map_err(|e| match e {
780            EstimationError::InvalidInput(reason) => AloError::InvalidInput { reason },
781            other => AloError::InvalidInput {
782                reason: format!("{other:?}"),
783            },
784        })?;
785
786    // Exact frozen-curvature ALO refinement for canonical-link GLMs.
787    //
788    // For a canonical link the row NLL score and curvature are
789    //   ℓ_i'(η)  = c_i · (μ(η) − y_i),     ℓ_i''(η) = c_i · μ'(η),
790    // with c_i = (prior weight)/φ recovered from the converged geometry as
791    // c_i = W_H[i] / μ'(η̂_i) (since W_H[i] = c_i μ'(η̂_i) at convergence).
792    // Supplying this evaluator lets `compute_alo_from_input_inner` solve the
793    // leave-i-out scalar fixed point η = η̂_i + a_ii ℓ_i'(η) exactly instead of
794    // taking a single Newton step, removing the first-order linearization error
795    // that dominates on small-n, strongly curved likelihoods (binomial logit).
796    //
797    // Restricted to canonical links because only there does the observed
798    // curvature carried by the frozen Hessian (W_H) coincide with c_i μ'(η) for
799    // every trial η; non-canonical links retain the classical one-step ALO.
800    // Per-row scale c_i = W_H[i]/μ'(η̂_i). This is an exact ratio, not a
801    // thresholded one: tiny positive curvature remains informative. If both
802    // numerator and derivative are exactly zero, the row has no representable
803    // local influence and c_i is exactly zero; every other nonrepresentable
804    // ratio is an explicit error.
805    let canonical_scale: Option<Array1<f64>> = if alo_link_needs_exact_curvature_refinement(
806        &base.likelihood,
807    ) {
808        let mut c = Array1::<f64>::zeros(n);
809        for i in 0..n {
810            let dmu = base.solve_dmu_deta[i];
811            let w_h = base.finalweights[i];
812            if !dmu.is_finite() || !w_h.is_finite() || dmu < 0.0 || w_h < 0.0 {
813                return Err(AloError::WeightInvalid {
814                    reason: format!(
815                        "canonical ALO requires finite non-negative local derivative and curvature; row {i} has dmu_deta={dmu}, weight={w_h}"
816                    ),
817                });
818            }
819            let scale = if dmu == 0.0 {
820                if w_h == 0.0 {
821                    0.0
822                } else {
823                    return Err(AloError::LooComputationFailed {
824                        reason: format!(
825                            "canonical ALO scale is undefined at row {i}: nonzero curvature {w_h} divided by zero inverse-link derivative"
826                        ),
827                    });
828                }
829            } else {
830                w_h / dmu
831            };
832            if !scale.is_finite() || scale < 0.0 || (w_h > 0.0 && scale == 0.0) {
833                return Err(AloError::LooComputationFailed {
834                    reason: format!(
835                        "canonical ALO scale is not representable at row {i}: weight={w_h}, dmu_deta={dmu}, scale={scale}"
836                    ),
837                });
838            }
839            c[i] = scale;
840        }
841        Some(c)
842    } else {
843        None
844    };
845
846    let inv_link_for_closure = base.likelihood.spec.link.clone();
847    let score_curvature_closure = canonical_scale.as_ref().map(|scale| {
848        move |i: usize, eta: f64| -> Result<(f64, f64), AloError> {
849            let (mu, dmu) = crate::mixture_link::inverse_link_mu_d1_for_inverse_link(
850                &inv_link_for_closure,
851                eta,
852            )
853            .map_err(|error| AloError::LooComputationFailed {
854                reason: format!(
855                    "ALO inverse-link evaluation failed at row {i}, eta={eta}: {error}"
856                ),
857            })?;
858            let c_i = scale[i];
859            let score = c_i * (mu - y[i]);
860            let curvature = c_i * dmu;
861            if !score.is_finite() || !curvature.is_finite() {
862                return Err(AloError::LooComputationFailed {
863                    reason: format!(
864                        "ALO canonical row geometry is not representable at row {i}, eta={eta}: score={score}, curvature={curvature}"
865                    ),
866                });
867            }
868            Ok((score, curvature))
869        }
870    });
871    let score_curvature_ref: Option<&AloScalarScoreCurvature> = score_curvature_closure
872        .as_ref()
873        .map(|f| f as &AloScalarScoreCurvature);
874
875    // Build model-agnostic AloInput from PIRLS geometry, then delegate.
876    // #1868: the PIRLS row fields are now shared `ArcArray1`; `AloInput` borrows
877    // `&Array1`, so materialise owned copies for this cold post-fit inference
878    // path (ALO runs once after the fit, not per κ trial).
879    let alo_working_response = base.solveworking_response.to_owned();
880    let alo_final_eta = base.final_eta.to_owned();
881    let alo_final_offset = base.final_offset.to_owned();
882    let input = AloInput {
883        design: x_dense,
884        penalized_hessian: &h_dense_for_alo,
885        hessian_weights: base.final_weights_signed(),
886        score_weights: base.solve_weights_psd(),
887        working_response: &alo_working_response,
888        eta: &alo_final_eta,
889        offset: &alo_final_offset,
890        phi,
891        score_curvature: score_curvature_ref,
892    };
893
894    let result = compute_alo_from_input_inner(&input)?;
895
896    // PIRLS-specific post-hoc leverage diagnostics logging.
897    log_leverage_diagnostics(&result.leverage, phi);
898
899    Ok(result)
900}
901
902/// Log detailed leverage percentile diagnostics for a completed ALO computation.
903fn log_leverage_diagnostics(leverage: &Array1<f64>, phi: f64) {
904    let n = leverage.len();
905    if n == 0 {
906        return;
907    }
908
909    let mut invalid_count = 0usize;
910    let mut high_leverage_count = 0usize;
911    let mut threshold_counts = [0usize; LEVERAGE_RATE_THRESHOLDS.len()];
912    let mut finite_leverage = Vec::with_capacity(n);
913
914    for (obs, &ai) in leverage.iter().enumerate() {
915        if ai.is_finite() {
916            finite_leverage.push(ai);
917        }
918
919        // Signed observed curvature permits exact negative row leverages for
920        // non-canonical links. They are not invalid: the corresponding ALO
921        // denominator `1-a_ii` is more strongly positive. Non-finite values
922        // remain invalid, while positive near-one leverage is the instability
923        // diagnostic of interest.
924        if !ai.is_finite() {
925            invalid_count += 1;
926            log::warn!("[GAM ALO] invalid leverage at i={}, a_ii={:.6e}", obs, ai);
927        } else if ai > LEVERAGE_HIGH_THRESHOLD {
928            high_leverage_count += 1;
929            if ai > LEVERAGE_VERY_HIGH_THRESHOLD {
930                log::warn!("[GAM ALO] very high leverage at i={}, a_ii={:.6e}", obs, ai);
931            }
932        }
933
934        for (idx, threshold) in LEVERAGE_RATE_THRESHOLDS.iter().enumerate() {
935            if ai > *threshold {
936                threshold_counts[idx] += 1;
937            }
938        }
939    }
940
941    if invalid_count > 0 || high_leverage_count > 0 {
942        log::warn!(
943            "[GAM ALO] leverage diagnostics: {} invalid values, {} high values (>0.99)",
944            invalid_count,
945            high_leverage_count
946        );
947    }
948
949    finite_leverage.sort_by(f64::total_cmp);
950
951    let finite_n = finite_leverage.len();
952    let a_mean = if finite_n > 0 {
953        finite_leverage.iter().copied().sum::<f64>() / finite_n as f64
954    } else {
955        0.0
956    };
957    let a_median = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[0]);
958    let a_p95 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[1]);
959    let a_p99 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[2]);
960    let a_max = finite_leverage.last().copied().unwrap_or(0.0);
961
962    // Routine per-ALO leverage summary: a diagnostic snapshot, not an
963    // anomaly. Emitted at `info!` so it is visible when the host raises
964    // verbosity (CLI `-v`; `gamfit.set_log_level("info")`) but silent at the
965    // default `Warn` level (genuine anomalies — invalid / very
966    // high leverage — are logged at `warn!` above and stay visible). This
967    // line fires once per ALO computation, which recurs across the outer
968    // smoothing loop, so at `warn!` it was a dominant source of stderr noise
969    // on perfectly healthy fits (#1689).
970    log::info!(
971        "[GAM ALO] leverage: n={}, mean={:.3e}, median={:.3e}, p95={:.3e}, p99={:.3e}, max={:.3e}",
972        n,
973        a_mean,
974        a_median,
975        a_p95,
976        a_p99,
977        a_max
978    );
979    log::info!(
980        "[GAM ALO] high-leverage: a>0.90: {:.2}%, a>0.95: {:.2}%, a>0.99: {:.2}%, dispersion phi={:.3e}",
981        100.0 * (threshold_counts[0] as f64) / n as f64,
982        100.0 * (threshold_counts[1] as f64) / n as f64,
983        100.0 * (threshold_counts[2] as f64) / n as f64,
984        phi
985    );
986}
987
988/// Model-agnostic input for ALO diagnostics.
989///
990/// Any model with a design matrix, penalized Hessian, and IRLS geometry can
991/// compute ALO leverages and leave-one-out predictions. This decouples ALO
992/// from the single-block PIRLS solver and enables diagnostics for GAMLSS,
993/// survival, and joint models.
994pub struct AloInput<'a> {
995    /// Dense design matrix X (n × p).
996    pub design: &'a Array2<f64>,
997    /// Penalized Hessian H = X'WX + S(λ) at convergence (p × p).
998    pub penalized_hessian: &'a Array2<f64>,
999    /// Hessian-side IRLS weights W_H at convergence (n). Sign-honest: for
1000    /// non-canonical links the observed-information diagonal can have negative
1001    /// entries, so the typed [`SignedWeightsView`] is the contract here. PSD
1002    /// callers needing to promote (e.g. the canonical-link case where the
1003    /// caller has discharged W_H ≥ 0 algebraically) can route through
1004    /// `SignedWeightsView::as_psd()` at the consumer.
1005    pub hessian_weights: SignedWeightsView<'a>,
1006    /// Score-side IRLS weights W_S paired with `working_response` (n).
1007    /// PSD-by-construction: the score-side Fisher weights `h'²/(φ V(μ)) ≥ 0`.
1008    pub score_weights: PsdWeightsView<'a>,
1009    /// IRLS working response at convergence (n).
1010    pub working_response: &'a Array1<f64>,
1011    /// Fitted linear predictor η̂ (n).
1012    pub eta: &'a Array1<f64>,
1013    /// Offset vector (n). Pass zeros if no offset.
1014    pub offset: &'a Array1<f64>,
1015    /// Dispersion parameter φ. For non-Gaussian families this is 1.0.
1016    pub phi: f64,
1017    /// Optional per-row score/curvature evaluator `(i, η) → (ℓ_i'(η), ℓ_i''(η))`.
1018    ///
1019    /// When supplied, the leave-`i`-out predictor is obtained by solving the
1020    /// frozen-curvature scalar fixed point `η = η̂_i + a_ii ℓ_i'(η)` to
1021    /// convergence (see [`alo_eta_exact_frozen_curvature`]) instead of taking a
1022    /// single Newton step. This eliminates the first-order linearization error
1023    /// that the one-step ALO incurs on small-`n`, strongly curved likelihoods
1024    /// (e.g. binomial logistic regression). Non-convergence or invalid scalar
1025    /// Newton geometry is returned as an ALO error. When `None`, the classical
1026    /// single-Newton-step ALO formula is used. The evaluator must be consistent
1027    /// with `hessian_weights` at convergence: `ℓ_i''(η̂_i) = W_H[i]` and
1028    /// `ℓ_i'(η̂_i) = W_S[i]·((η̂_i−o_i) − (z_i−o_i))`.
1029    pub score_curvature: Option<&'a AloScalarScoreCurvature<'a>>,
1030}
1031
1032impl<'a> AloInput<'a> {
1033    /// Build an `AloInput` from `FitGeometry` and an already active-coordinate
1034    /// design. Raw saved designs must first be restricted through
1035    /// `geom.coefficient_gauge`; keeping this constructor crate-private makes
1036    /// that frame transition explicit at the public fit boundary.
1037    fn from_active_geometry(
1038        geom: &'a FitGeometry,
1039        working: &'a WorkingGeometry,
1040        design: &'a Array2<f64>,
1041        eta: &'a Array1<f64>,
1042        offset: &'a Array1<f64>,
1043        phi: f64,
1044    ) -> Self {
1045        // FitGeometry stores one working-weight vector, so this constructor is
1046        // exact only when the score- and Hessian-side IRLS weights coincide
1047        // (canonical-link case where Fisher == Observed). In that path the
1048        // diagonal is the Fisher weight `h'²/(φ V(μ)) ≥ 0`, so the PSD
1049        // obligation is discharged algebraically without a runtime scan;
1050        // `as_signed()` re-views the same buffer for the Hessian-side slot.
1051        let psd_w = PsdWeightsView::from_view_unchecked(working.weights.view());
1052        Self {
1053            design,
1054            penalized_hessian: &geom.penalized_hessian,
1055            hessian_weights: psd_w.as_signed(),
1056            score_weights: psd_w,
1057            working_response: &working.response,
1058            eta,
1059            offset,
1060            phi,
1061            score_curvature: None,
1062        }
1063    }
1064
1065    /// Build an `AloInput` from an exact saved penalized Hessian plus externally
1066    /// supplied working weights / working response.
1067    ///
1068    /// The row-sized IRLS working vectors are *derived* quantities: at
1069    /// convergence they are deterministic functions of the linear predictor
1070    /// `η̂ = Xβ̂`, the response `y`, and the family (`w_i = h'(η̂_i)²/(φ V(μ̂_i))·
1071    /// prior_i`, `z_i = η̂_i + (y_i−μ̂_i)/h'(η̂_i)`). A saved-model consumer
1072    /// reconstructs them from the saved `β` by replaying the same PIRLS
1073    /// working-state update the fit used, then feeds them here. The precision
1074    /// comes from the canonical fit's exact unscaled Hessian accessor; callers
1075    /// do not need a second `FitGeometry` wrapper or a covariance inversion.
1076    ///
1077    /// Same canonical (Fisher == Observed) contract as
1078    /// [`from_active_geometry`]: the
1079    /// supplied `working_weights` are the score-side Fisher weights and are
1080    /// re-viewed for the Hessian-side slot via `as_signed()`.
1081    ///
1082    /// [`from_active_geometry`]: AloInput::from_active_geometry
1083    pub fn from_penalized_hessian_with_working_state(
1084        penalized_hessian: &'a Array2<f64>,
1085        design: &'a Array2<f64>,
1086        eta: &'a Array1<f64>,
1087        offset: &'a Array1<f64>,
1088        phi: f64,
1089        working_weights: &'a Array1<f64>,
1090        working_response: &'a Array1<f64>,
1091    ) -> Self {
1092        let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
1093        Self {
1094            design,
1095            penalized_hessian,
1096            hessian_weights: psd_w.as_signed(),
1097            score_weights: psd_w,
1098            working_response,
1099            eta,
1100            offset,
1101            phi,
1102            score_curvature: None,
1103        }
1104    }
1105}
1106
1107/// Compute ALO diagnostics from model-agnostic inputs.
1108///
1109/// This is the generalized entry point that works for any model type.
1110/// For standard single-block GAMs, prefer `compute_alo_diagnostics_from_fit`
1111/// which automatically extracts the PIRLS geometry (including sandwich SE).
1112pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
1113    compute_alo_from_input_inner(input).map_err(EstimationError::from)
1114}
1115
1116fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
1117    let x_dense = input.design;
1118    let n = x_dense.nrows();
1119    let p = x_dense.ncols();
1120    // Bind the underlying ArrayView1 once so the loop body can index and
1121    // borrow as before; the sign-character contract lives in the
1122    // `AloInput` field types, not in this local binding.
1123    let w_h = input.hessian_weights.view();
1124    let w_s = input.score_weights.view();
1125
1126    validate_alo_solve_setup(input, n, p)?;
1127
1128    let factor = certified_spd_factorize(input.penalized_hessian, "ALO penalized Hessian")
1129        .map_err(|error| AloError::InvalidInput {
1130            reason: format!(
1131                "ALO requires an unperturbed positive-definite penalized Hessian with a certified solve: {error}"
1132            ),
1133        })?;
1134
1135    let xt = x_dense.t();
1136    let phi = input.phi;
1137
1138    let mut aii = Array1::<f64>::zeros(n);
1139    let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
1140    let mut se_bayes = Array1::<f64>::zeros(n);
1141    let mut se_sandwich = Array1::<f64>::zeros(n);
1142
1143    let block_cols = alo_rhs_block_cols(n, p);
1144    // Allocate the RHS scratch in column-major (Fortran) order so its column
1145    // slices are contiguous and align with faer's column-major solve output.
1146    // This removes redundant `xrow = x_dense.row(obs)` indirection inside the
1147    // per-observation loop: rhs_chunk_buf already holds X^T at the right cols.
1148    let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
1149    // Reusable faer column-major buffer for X*S, where S = H^{-1}X_i for the
1150    // current RHS chunk. The sandwich SE uses the same frozen-curvature meat
1151    // as the exact LOO reference, `X' W_S X`, directly; no redundant penalty
1152    // root or ridge surrogate is carried through this API.
1153    let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
1154    let x_dense_view = FaerArrayView::new(x_dense);
1155
1156    for chunk_start in (0..n).step_by(block_cols) {
1157        let chunk_end = (chunk_start + block_cols).min(n);
1158        let width = chunk_end - chunk_start;
1159
1160        rhs_chunk_buf
1161            .slice_mut(s![.., ..width])
1162            .assign(&xt.slice(s![.., chunk_start..chunk_end]));
1163
1164        let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
1165        let rhs_chunk = rhs_chunkview.to_owned();
1166        let (s_chunk, _solve_certificate) = factor.solve_matrix(&rhs_chunk).map_err(|error| {
1167            AloError::LooComputationFailed {
1168                reason: format!(
1169                    "ALO penalized-Hessian solve could not be certified for rows {chunk_start}..{chunk_end}: {error}"
1170                ),
1171            }
1172        })?;
1173        let s_chunk_view = FaerArrayView::new(&s_chunk);
1174
1175        let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
1176        matmul(
1177            xs_target.rb_mut(),
1178            Accum::Replace,
1179            x_dense_view.as_ref(),
1180            s_chunk_view.as_ref(),
1181            1.0,
1182            Par::Seq,
1183        );
1184
1185        let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
1186
1187        for local_col in 0..width {
1188            let obs = chunk_start + local_col;
1189            // The RHS stays column-major; the certified solution is indexed
1190            // with its native ndarray strides so this path does not assume a
1191            // storage order chosen inside the factor API.
1192            let rhs_col = rhs_view.column(local_col);
1193            let solution_col = s_chunk.column(local_col);
1194            let x_hinv_x = spd_quadratic_after_certified_solve(obs, rhs_col, solution_col)?;
1195            // The bread uses the observed Hessian surface. For a non-canonical
1196            // link W_H is signed, so the exact row leverage W_H,i x_i'H^-1x_i
1197            // can be negative even though the assembled penalized H is SPD.
1198            // Projecting that row to zero changes both the ALO denominator and
1199            // corrected predictor; only the separate score-covariance meat is
1200            // PSD (and uses W_S below).
1201            let ai = finite_signed_product(obs, "leverage", w_h[obs], x_hinv_x)?;
1202            aii[obs] = ai;
1203            x_hinv_x_diag[obs] = x_hinv_x;
1204
1205            let var_bayes = finite_nonnegative_product(obs, "Bayesian variance", phi, x_hinv_x)?;
1206            let xs_slice = xs_chunk_storage.col_as_slice(local_col);
1207            // Sandwich meat is the SCORE covariance Xᵀ diag(W_S) X (Fisher,
1208            // PSD by construction), not the observed-information Hessian
1209            // weight W_H. The scale-safe sum preserves that non-negative
1210            // contract without projecting a signed result after the fact.
1211            let meat_quad = finite_weighted_square_sum(obs, w_s, xs_slice)?;
1212            let var_sandwich =
1213                finite_nonnegative_product(obs, "sandwich variance", phi, meat_quad)?;
1214
1215            se_bayes[obs] = var_bayes.sqrt();
1216            se_sandwich[obs] = var_sandwich.sqrt();
1217        }
1218    }
1219
1220    let eta_hat = input.eta;
1221    let z = input.working_response;
1222    let offset = input.offset;
1223
1224    use rayon::prelude::*;
1225    let eta_tilde_vec: Vec<f64> = (0..n)
1226        .into_par_iter()
1227        .map(|i| {
1228            let denom_raw = 1.0 - aii[i];
1229            if denom_raw == 0.0 || !denom_raw.is_finite() {
1230                return Err(AloError::LooComputationFailed {
1231                    reason: format!(
1232                        "ALO deletion denominator is not invertible at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}",
1233                        aii[i], denom_raw
1234                    ),
1235                });
1236            }
1237            let one_step = alo_eta_updatewith_offset(
1238                eta_hat[i],
1239                z[i],
1240                offset[i],
1241                x_hinv_x_diag[i],
1242                w_s[i],
1243                denom_raw,
1244            );
1245            // When the family score/curvature evaluator is supplied, solve the
1246            // exact frozen-curvature leave-i-out fixed point (anchored at η̂_i,
1247            // the basin that limits to the in-sample fit) instead of taking the
1248            // single Newton step. a_ii here is the unweighted influence
1249            // x_i^T H^{-1} x_i (= x_hinv_x_diag[i]); the per-row curvature
1250            // W_H[i] = ℓ_i''(η̂_i) is folded into the scalar fixed point via
1251            // score_curvature. Non-canonical links fall back to `one_step`.
1252            let v = if let Some(score_curvature) = input.score_curvature {
1253                alo_eta_exact_frozen_curvature(
1254                    eta_hat[i],
1255                    x_hinv_x_diag[i],
1256                    &|eta| score_curvature(i, eta),
1257                )
1258                .map_err(|err| AloError::LooComputationFailed {
1259                    reason: format!(
1260                        "ALO exact frozen-curvature solve failed at row {i}: {err}"
1261                    ),
1262                })?
1263            } else {
1264                one_step
1265            };
1266            if !v.is_finite() {
1267                return Err(AloError::LooComputationFailed {
1268                    reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1269                });
1270            }
1271            Ok(v)
1272        })
1273        .collect::<Result<_, _>>()?;
1274    let eta_tilde = Array1::from(eta_tilde_vec);
1275
1276    Ok(AloDiagnostics {
1277        eta_tilde,
1278        se_bayes,
1279        se_sandwich,
1280        leverage: aii,
1281    })
1282}
1283
1284fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1285    let h = input.penalized_hessian;
1286    if h.nrows() != p || h.ncols() != p {
1287        return Err(AloError::InvalidInput {
1288            reason: format!(
1289                "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1290                h.nrows(),
1291                h.ncols()
1292            ),
1293        });
1294    }
1295    let vector_lengths = [
1296        ("hessian_weights", input.hessian_weights.len()),
1297        ("score_weights", input.score_weights.len()),
1298        ("working_response", input.working_response.len()),
1299        ("eta", input.eta.len()),
1300        ("offset", input.offset.len()),
1301    ];
1302    for (name, len) in vector_lengths {
1303        if len != n {
1304            return Err(AloError::InvalidInput {
1305                reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1306            });
1307        }
1308    }
1309    if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1310        return Err(AloError::WeightInvalid {
1311            reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1312        });
1313    }
1314    if let Some((row, value)) = input
1315        .score_weights
1316        .view()
1317        .iter()
1318        .copied()
1319        .enumerate()
1320        .find(|(_, value)| !value.is_finite() || *value < 0.0)
1321    {
1322        return Err(AloError::WeightInvalid {
1323            reason: format!(
1324                "ALO diagnostics require finite non-negative score-side weights; row {row} has {value:?}"
1325            ),
1326        });
1327    }
1328    if input.working_response.iter().any(|v| !v.is_finite()) {
1329        return Err(AloError::WeightInvalid {
1330            reason: "ALO diagnostics require finite working responses".to_string(),
1331        });
1332    }
1333    if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1334        return Err(AloError::InvalidInput {
1335            reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1336        });
1337    }
1338    if !input.phi.is_finite() || input.phi <= 0.0 {
1339        return Err(AloError::InvalidInput {
1340            reason: format!(
1341                "ALO diagnostics require positive finite dispersion phi; got {}",
1342                input.phi
1343            ),
1344        });
1345    }
1346    Ok(())
1347}
1348
1349/// Compute ALO diagnostics (eta_tilde, SE, leverage) from a fitted GAM result.
1350pub fn compute_alo_diagnostics_from_fit(
1351    fit: &UnifiedFitResult,
1352    y: ArrayView1<f64>,
1353) -> Result<AloDiagnostics, EstimationError> {
1354    let pirls = fit
1355        .artifacts
1356        .pirls
1357        .as_ref()
1358        .ok_or_else(|| AloError::InvalidInput {
1359            reason:
1360                "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1361                    .to_string(),
1362        })
1363        .map_err(EstimationError::from)?;
1364    compute_alo_diagnostics_from_pirls_impl(pirls, y)
1365}
1366
1367/// Compute ALO diagnostics from a `UnifiedFitResult`.
1368///
1369/// Extracts `FitGeometry` from `unified.geometry`, pulls the raw row design
1370/// into the persisted active frame, and delegates to `compute_alo_from_input`.
1371/// This avoids requiring a full `UnifiedFitResult` with PIRLS artifacts.
1372pub fn compute_alo_diagnostics_from_unified(
1373    unified: &UnifiedFitResult,
1374    design: &Array2<f64>,
1375    eta: &Array1<f64>,
1376    offset: &Array1<f64>,
1377    phi: f64,
1378) -> Result<AloDiagnostics, EstimationError> {
1379    let geom = unified
1380        .geometry
1381        .as_ref()
1382        .ok_or_else(|| AloError::InvalidInput {
1383            reason: "UnifiedFitResult does not contain working-set geometry; \
1384             ALO diagnostics require geometry at convergence"
1385                .to_string(),
1386        })
1387        .map_err(EstimationError::from)?;
1388    let working = geom.working.as_ref().ok_or_else(|| {
1389        EstimationError::from(AloError::InvalidInput {
1390            reason: "UnifiedFitResult coefficient geometry has no owned single-diagonal working evidence; ALO diagnostics are unavailable for Exact-Newton and multi-parameter terminal geometry"
1391                .to_string(),
1392        })
1393    })?;
1394    geom.coefficient_gauge
1395        .validate()
1396        .map_err(|reason| AloError::InvalidInput {
1397            reason: format!("UnifiedFitResult ALO coefficient gauge is invalid: {reason}"),
1398        })
1399        .map_err(EstimationError::from)?;
1400    if design.ncols() != geom.coefficient_gauge.raw_total() {
1401        return Err(AloError::InvalidInput {
1402            reason: format!(
1403                "UnifiedFitResult ALO raw design has {} columns; coefficient gauge requires {}",
1404                design.ncols(),
1405                geom.coefficient_gauge.raw_total(),
1406            ),
1407        }
1408        .into());
1409    }
1410    let active_design = geom.coefficient_gauge.restrict_design(design);
1411    let input =
1412        AloInput::from_active_geometry(geom, working, &active_design, eta, offset, phi);
1413    compute_alo_from_input(&input)
1414}
1415
1416/// Compute ALO diagnostics from a PIRLS result for lower-level callers.
1417pub fn compute_alo_diagnostics_from_pirls(
1418    base: &pirls::PirlsResult,
1419    y: ArrayView1<f64>,
1420) -> Result<AloDiagnostics, EstimationError> {
1421    compute_alo_diagnostics_from_pirls_impl(base, y)
1422}
1423
1424/// Exact (one-step) case-deletion influence from a converged PIRLS fit, via
1425/// the one `FitSensitivity` operator (#935).
1426///
1427/// This is the diagnostic the sensitivity operator's `case_deletion` channel
1428/// was built to expose but had no production entry point for: per-observation
1429/// dfbetas `β̂ − β̂₍ᵢ₎`, hat-value leverage `h_ii = w_i x_iᵀ H⁻¹ x_i`, and
1430/// Cook's distance. It is the same factored inverse the REML gradient (IFT),
1431/// ALO, and the Riesz debias already contract — built once at the optimum,
1432/// asked in the leave-one-out direction — so no call site can disagree about
1433/// which `H⁻¹` is meant (the bug class #935 dismantles).
1434///
1435/// The penalized Hessian, design, working weights `w_i = W_H[i]` and working
1436/// residual `z_i − η̂_i` are read straight from the converged geometry — the
1437/// same PIRLS state [`compute_alo_diagnostics_from_pirls`] consumes — so the
1438/// IRLS reduction `scale = w_i r_i / (1 − h_ii)` is exact for the Gaussian
1439/// identity link and the one-step Newton deletion for canonical-link GLMs.
1440/// Returns `None` (rather than emitting `∞`) for any observation whose
1441/// leverage is one, or if the dense Hessian / design is unavailable.
1442pub fn compute_case_deletion_from_pirls(
1443    base: &pirls::PirlsResult,
1444) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1445    let x_dense_arc = base
1446        .x_transformed
1447        .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1448        .map_err(|reason| EstimationError::InvalidInput(reason))?;
1449    let x_dense = x_dense_arc.as_ref();
1450    let n = x_dense.nrows();
1451    let p = x_dense.ncols();
1452    if n == 0 || p == 0 {
1453        return Ok(None);
1454    }
1455
1456    let phi = alo_covariance_scale(base).map_err(EstimationError::from)?;
1457
1458    // The same dense stabilized penalized Hessian ALO materializes; the one
1459    // factored inverse every sensitivity channel shares.
1460    let h_dense = base
1461        .dense_stabilizedhessian_transformed(
1462            "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1463        )
1464        .map_err(|e| match e {
1465            EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1466            other => EstimationError::InvalidInput(format!("{other:?}")),
1467        })?;
1468
1469    let factor = match h_dense.cholesky(faer::Side::Lower) {
1470        Ok(f) => f,
1471        // A non-SPD stabilized Hessian means the optimum is rank-deficient in a
1472        // way the dense Cholesky case-deletion path cannot invert; decline
1473        // rather than fabricate an influence diagnostic.
1474        Err(_) => return Ok(None),
1475    };
1476
1477    // Working weights and working residual straight from the IRLS reduction:
1478    // w_i = W_H[i] and r_i = z_i − η̂_i, so w_i r_i is the working score the
1479    // closed-form deletion `scale = w_i r_i / (1 − h_ii)` consumes.
1480    let working_weights = base.finalweights.clone();
1481    let working_residual = &base.solveworking_response - &base.final_eta;
1482
1483    let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1484    Ok(sensitivity.case_deletion(
1485        x_dense,
1486        working_weights.view(),
1487        working_residual.view(),
1488        phi,
1489    ))
1490}
1491
1492// Multi-block ALO for multi-predictor models (GAMLSS, survival, joint)
1493
1494/// Diagnostics returned by multi-block ALO.
1495#[derive(Debug, Clone)]
1496pub struct MultiBlockAloDiagnostics {
1497    /// Corrected linear predictors η̃^{(-i)} for each observation.
1498    /// Outer length = n_obs, inner length = n_coordinates (B).
1499    pub eta_tilde: Vec<Array1<f64>>,
1500    /// Per-observation leverage tr(H_ii) where H_ii is the B×B hat-matrix block.
1501    pub leverage: Array1<f64>,
1502    /// Per-observation ALO variance diagonals: for each observation i,
1503    /// Var(Δη_i) ≈ A_i (I - W_i A_i)⁻¹ C_i (I - A_i W_i)⁻¹ A_iᵀ,
1504    /// where C_i is the score covariance (not assumed equal to W_i).
1505    /// Outer length = n_obs, inner length = n_coordinates (B) containing the
1506    /// diagonal entries of the variance matrix.
1507    pub alo_variance: Vec<Array1<f64>>,
1508    /// Model-based posterior predictive variance of each coordinate at each
1509    /// observation: `diag(A_i) = x_{d,i}ᵀ [H⁻¹] x_{d,i}` (unit dispersion; the
1510    /// caller scales by φ where applicable). Unlike [`Self::alo_variance`] —
1511    /// which for a single deleted row with the rank-1 self-score covariance
1512    /// collapses EXACTLY to the squared deletion correction `Δη²` and is
1513    /// therefore an influence magnitude, not an uncertainty — this is the
1514    /// genuine posterior uncertainty of the fitted coordinate, and the
1515    /// principled scale for judging how far an exact LOO refit (which also
1516    /// reselects smoothing on n−1 rows) may legitimately sit from the
1517    /// fixed-smoothing ALO point (#2301).
1518    /// Outer length = n_obs, inner length = n_coordinates (B).
1519    pub predictive_variance: Vec<Array1<f64>>,
1520    /// Cook-type ALO influence: D_i = Δη_iᵀ C_i Δη_i.
1521    /// Length = n_obs.
1522    pub cook_distance: Array1<f64>,
1523}
1524
1525/// Model-agnostic input for multi-predictor ALO diagnostics.
1526///
1527/// Generalises [`AloInput`] to models with B > 1 linear predictors per
1528/// observation (e.g. location-scale GAMLSS with B=2, or survival models
1529/// with time-dependent predictors).
1530///
1531/// # Mathematical setup
1532///
1533/// For observation i the per-observation Jacobian is a B × p_tot matrix X_i.
1534/// Row b embeds row i of `coordinate_designs[b]` at
1535/// `coordinate_coefficient_ranges[b]`. Ranges may overlap: this is required
1536/// for risk-set and latent-variable coordinates that share coefficients. The
1537/// joint hat-matrix block is
1538///
1539///   H_ii = X_i H⁻¹ X_iᵀ W_i     (B × B)
1540///
1541/// where H = Σ_i X_iᵀ W_i X_i + S is the total penalized Hessian and W_i
1542/// is the B × B per-observation weight matrix (negative Hessian of the
1543/// log-likelihood w.r.t. the B predictors at observation i).
1544///
1545/// The ALO leave-one-out correction is
1546///
1547///   Δη_i^ALO = A_i (I_B − W_i A_i)⁻¹ s_i
1548///
1549/// where A_i = X_i H⁻¹ X_iᵀ (the B×B per-observation influence matrix),
1550/// W_i is the B×B per-observation NLL Hessian, and
1551/// s_i = ∇_{η_i} NLL_i(η̂_i) is the B-dimensional score vector.
1552/// This is algebraically equivalent to (I_B − H_ii)⁻¹ H_ii W_i⁻¹ s_i
1553/// but does NOT require W_i⁻¹, which is critical when W_i is singular
1554/// (e.g. at boundary observations in survival models).
1555/// For B = 1 this reduces to the classical scalar ALO formula.
1556pub struct MultiBlockAloInput<'a> {
1557    /// Number of observations.
1558    pub n_obs: usize,
1559    /// Number of local likelihood coordinates per observation (B).
1560    pub n_coordinates: usize,
1561    /// B possibly operator-backed local design matrices, each n_obs × p_b.
1562    /// ALO materializes only bounded row chunks.
1563    pub coordinate_designs: &'a [DesignMatrix],
1564    /// Parameter alignment for each local design. Range b has length p_b and
1565    /// identifies the columns of the saved p_tot-dimensional Hessian touched
1566    /// by coordinate b. Ranges may overlap and need not cover every parameter.
1567    pub coordinate_coefficient_ranges: &'a [Range<usize>],
1568    /// Exact unscaled penalized Hessian H (p_tot × p_tot). ALO factors this
1569    /// matrix once and certifies blocked solves; it never materializes H⁻¹.
1570    pub penalized_hessian: &'a Array2<f64>,
1571    /// Per-observation observed NLL Hessians W_i (B × B). These drive the
1572    /// deletion denominator and may be indefinite even when H is SPD.
1573    pub observed_hessians: &'a [Array2<f64>],
1574    /// Per-observation score covariance matrices C_i (B × B). These drive
1575    /// variance and Cook influence, and must be positive semidefinite.
1576    pub score_covariances: &'a [Array2<f64>],
1577    /// Per-observation score vectors s_i = ∇_{η_i} NLL_i.  Length = n_obs,
1578    /// each entry is B-dimensional.
1579    pub scores: &'a [Array1<f64>],
1580    /// Fitted local-coordinate vectors η̂_i. Length = n_obs, each entry is
1581    /// B-dimensional. These are the exact row arguments paired with the
1582    /// coordinate designs; they need not be response means.
1583    pub coordinate_values: &'a [Array1<f64>],
1584}
1585
1586/// Compute multi-block ALO diagnostics: corrected η̃ and leverages.
1587///
1588/// # Optimisation note
1589///
1590/// The dominant cost is forming X_i H⁻¹ X_iᵀ for every observation.
1591/// Rather than forming the B × p_tot row-block X_i and multiplying naïvely,
1592/// we solve for each coordinate b and bounded row chunk the matrix
1593///
1594///   H Q_b = X_bᵀ      (p_tot × chunk)
1595///
1596/// Then the (a, b) entry of the B × B matrix X_i H⁻¹ X_iᵀ is simply
1597///
1598///   (X_i H⁻¹ X_iᵀ)_{a,b} = x_{a,i}ᵀ Q_b[:,i]
1599///                           = Σ_k  X_a[i,k] · Q_b[k,i]
1600///
1601/// where x_{a,i} is the i-th row of coordinate-design a. This turns the per-
1602/// observation work from O(B · p_tot²) into O(B² · p_tot), and the
1603/// solve stays bounded without forming a dense inverse or an n × p_tot panel.
1604pub fn compute_multiblock_alo(
1605    input: &MultiBlockAloInput,
1606) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1607    compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1608}
1609
1610fn validate_multiblock_alo_input(input: &MultiBlockAloInput<'_>) -> Result<(), AloError> {
1611    let n = input.n_obs;
1612    let b = input.n_coordinates;
1613    if n == 0 || b == 0 {
1614        return Err(AloError::InvalidInput {
1615            reason: format!(
1616                "multi-block ALO requires positive observation and coordinate counts; got n={n}, B={b}"
1617            ),
1618        });
1619    }
1620    if input.coordinate_designs.len() != b {
1621        return Err(AloError::InvalidInput {
1622            reason: format!(
1623                "multi-block ALO expected {b} coordinate designs, got {}",
1624                input.coordinate_designs.len()
1625            ),
1626        });
1627    }
1628    let p_tot = input.penalized_hessian.nrows();
1629    if input.penalized_hessian.ncols() != p_tot || p_tot == 0 {
1630        return Err(AloError::InvalidInput {
1631            reason: format!(
1632                "multi-block ALO penalized Hessian must be non-empty and square; got {}x{}",
1633                input.penalized_hessian.nrows(),
1634                input.penalized_hessian.ncols()
1635            ),
1636        });
1637    }
1638    if input.coordinate_coefficient_ranges.len() != b {
1639        return Err(AloError::InvalidInput {
1640            reason: format!(
1641                "multi-block ALO expected {b} coordinate coefficient ranges, got {}",
1642                input.coordinate_coefficient_ranges.len()
1643            ),
1644        });
1645    }
1646    for (coordinate, (design, coefficient_range)) in input
1647        .coordinate_designs
1648        .iter()
1649        .zip(input.coordinate_coefficient_ranges)
1650        .enumerate()
1651    {
1652        if design.nrows() != n {
1653            return Err(AloError::InvalidInput {
1654                reason: format!(
1655                    "multi-block ALO coordinate design {coordinate} has {} rows; expected {n}",
1656                    design.nrows()
1657                ),
1658            });
1659        }
1660        if design.ncols() == 0 || coefficient_range.is_empty() {
1661            return Err(AloError::InvalidInput {
1662                reason: format!(
1663                    "multi-block ALO coordinate {coordinate} has an empty local design or coefficient range"
1664                ),
1665            });
1666        }
1667        if coefficient_range.len() != design.ncols() || coefficient_range.end > p_tot {
1668            return Err(AloError::InvalidInput {
1669                reason: format!(
1670                    "multi-block ALO coordinate {coordinate} design has {} columns but parameter range {}..{} has length {} in a {p_tot}-dimensional saved Hessian",
1671                    design.ncols(),
1672                    coefficient_range.start,
1673                    coefficient_range.end,
1674                    coefficient_range.len()
1675                ),
1676            });
1677        }
1678    }
1679    for (label, length) in [
1680        ("observed_hessians", input.observed_hessians.len()),
1681        ("score_covariances", input.score_covariances.len()),
1682        ("scores", input.scores.len()),
1683        ("coordinate_values", input.coordinate_values.len()),
1684    ] {
1685        if length != n {
1686            return Err(AloError::InvalidInput {
1687                reason: format!("multi-block ALO requires {label} length {n}; got {length}"),
1688            });
1689        }
1690    }
1691    for row in 0..n {
1692        let observed = &input.observed_hessians[row];
1693        let score_covariance = &input.score_covariances[row];
1694        for (label, matrix) in [
1695            ("observed Hessian", observed),
1696            ("score covariance", score_covariance),
1697        ] {
1698            if matrix.dim() != (b, b) {
1699                return Err(AloError::InvalidInput {
1700                    reason: format!(
1701                        "multi-block ALO row {row} {label} has shape {}x{}; expected {b}x{b}",
1702                        matrix.nrows(),
1703                        matrix.ncols()
1704                    ),
1705                });
1706            }
1707            validate_finite_symmetric_matrix(matrix, &format!("multi-block ALO row {row} {label}"))
1708                .map_err(|error| AloError::InvalidInput {
1709                    reason: error.to_string(),
1710                })?;
1711        }
1712        let covariance_scale = score_covariance
1713            .iter()
1714            .fold(0.0_f64, |scale, value| scale.max(value.abs()));
1715        let (minimum, maximum) =
1716            symmetric_extremes(score_covariance).ok_or_else(|| AloError::InvalidInput {
1717                reason: format!(
1718                    "multi-block ALO row {row} score-covariance eigendecomposition failed"
1719                ),
1720            })?;
1721        let psd_tolerance = LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR
1722            * b as f64
1723            * f64::EPSILON
1724            * covariance_scale.max(maximum.abs());
1725        if minimum < -psd_tolerance {
1726            return Err(AloError::InvalidInput {
1727                reason: format!(
1728                    "multi-block ALO row {row} score covariance is not positive semidefinite: minimum eigenvalue {minimum:.6e}, roundoff allowance {psd_tolerance:.6e}"
1729                ),
1730            });
1731        }
1732        for (label, vector) in [
1733            ("score", &input.scores[row]),
1734            ("coordinate value", &input.coordinate_values[row]),
1735        ] {
1736            if vector.len() != b {
1737                return Err(AloError::InvalidInput {
1738                    reason: format!(
1739                        "multi-block ALO row {row} {label} has length {}; expected {b}",
1740                        vector.len()
1741                    ),
1742                });
1743            }
1744            if let Some((coordinate, value)) = vector
1745                .iter()
1746                .copied()
1747                .enumerate()
1748                .find(|(_, value)| !value.is_finite())
1749            {
1750                return Err(AloError::InvalidInput {
1751                    reason: format!(
1752                        "multi-block ALO row {row} {label} coordinate {coordinate} is non-finite: {value}"
1753                    ),
1754                });
1755            }
1756        }
1757    }
1758    Ok(())
1759}
1760
1761fn compute_multiblock_alo_inner(
1762    input: &MultiBlockAloInput,
1763) -> Result<MultiBlockAloDiagnostics, AloError> {
1764    use rayon::prelude::*;
1765
1766    let n = input.n_obs;
1767    let b = input.n_coordinates;
1768    let p_tot = input.penalized_hessian.nrows();
1769    validate_multiblock_alo_input(input)?;
1770    let factor = certified_spd_factorize(input.penalized_hessian, "multi-block ALO penalized Hessian")
1771        .map_err(|error| AloError::InvalidInput {
1772            reason: format!(
1773                "multi-block ALO requires an unperturbed positive-definite saved penalized Hessian: {error}"
1774            ),
1775        })?;
1776
1777    let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1778    let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1779
1780    // Each Rayon worker owns its small B×B/B-vector scratch buffers via
1781    // `map_init`, avoiding cross-thread mutation and avoiding per-observation
1782    // allocations.  The much larger Q panels are bounded by the parallel chunk
1783    // size and by wave-level concurrency, so at most roughly one global memory
1784    // budget worth of p_total × chunk_len panels can be live across workers.
1785    let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1786        Vec::with_capacity(chunk_starts.len());
1787    for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1788        let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1789            .par_iter()
1790            .map_init(
1791                || MultiBlockAloScratch::new(b),
1792                |scratch, &chunk_start| {
1793                    let chunk_end = (chunk_start + chunk_size).min(n);
1794                    compute_multiblock_alo_chunk(input, &factor, chunk_start, chunk_end, scratch)
1795                },
1796            )
1797            .collect();
1798        chunk_results.append(&mut wave_results);
1799    }
1800
1801    let mut eta_tilde = Vec::with_capacity(n);
1802    let mut leverage = Array1::<f64>::zeros(n);
1803    let mut alo_variance = Vec::with_capacity(n);
1804    let mut predictive_variance = Vec::with_capacity(n);
1805    let mut cook_distance = Array1::<f64>::zeros(n);
1806
1807    let mut chunks = Vec::with_capacity(chunk_results.len());
1808    for result in chunk_results {
1809        chunks.push(result?);
1810    }
1811    chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1812
1813    for chunk in chunks {
1814        let chunk_start = chunk.chunk_start;
1815        eta_tilde.extend(chunk.eta_tilde);
1816        alo_variance.extend(chunk.alo_variance);
1817        predictive_variance.extend(chunk.predictive_variance);
1818        for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1819            leverage[chunk_start + local_i] = lev;
1820        }
1821        for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1822            cook_distance[chunk_start + local_i] = cook;
1823        }
1824    }
1825
1826    Ok(MultiBlockAloDiagnostics {
1827        eta_tilde,
1828        leverage,
1829        alo_variance,
1830        predictive_variance,
1831        cook_distance,
1832    })
1833}
1834
1835#[inline]
1836fn multiblock_alo_parallel_plan(
1837    p_tot: usize,
1838    n_coordinates: usize,
1839    n_obs: usize,
1840) -> (usize, usize) {
1841    if p_tot == 0 || n_coordinates == 0 || n_obs == 0 {
1842        return (1, 1);
1843    }
1844    // Each live row keeps one p-vector in the materialized Jacobian chunk and
1845    // one in its certified solution, for every local coordinate.
1846    let bytes_per_obs = p_tot
1847        .saturating_mul(n_coordinates)
1848        .saturating_mul(2)
1849        .saturating_mul(std::mem::size_of::<f64>())
1850        .max(1);
1851    let workers = rayon::current_num_threads().max(1);
1852    let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1853        .max(1)
1854        .min(workers);
1855    let per_worker_budget =
1856        (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1857    let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1858    (budget_obs.min(n_obs), max_concurrent_chunks)
1859}
1860
1861struct MultiBlockAloScratch {
1862    a_i: Vec<f64>,
1863    wa: Vec<f64>,
1864    aw: Vec<f64>,
1865    imwa: Vec<f64>,
1866    imaw: Vec<f64>,
1867    perm_imwa: Vec<usize>,
1868    perm_imaw: Vec<usize>,
1869    delta_eta: Vec<f64>,
1870    rhs_buf: Vec<f64>,
1871    covariance_u: Vec<f64>,
1872    var_diag_buf: Vec<f64>,
1873    w_flat: Vec<f64>,
1874    covariance_flat: Vec<f64>,
1875    lu_scratch: Vec<f64>,
1876    original_rhs: Vec<f64>,
1877}
1878
1879impl MultiBlockAloScratch {
1880    fn new(b: usize) -> Self {
1881        let bb_sz = b * b;
1882        Self {
1883            a_i: vec![0.0f64; bb_sz],
1884            wa: vec![0.0f64; bb_sz],
1885            aw: vec![0.0f64; bb_sz],
1886            imwa: vec![0.0f64; bb_sz],
1887            imaw: vec![0.0f64; bb_sz],
1888            perm_imwa: vec![0usize; b],
1889            perm_imaw: vec![0usize; b],
1890            delta_eta: vec![0.0f64; b],
1891            rhs_buf: vec![0.0f64; b],
1892            covariance_u: vec![0.0f64; b],
1893            var_diag_buf: vec![0.0f64; b],
1894            w_flat: vec![0.0f64; bb_sz],
1895            covariance_flat: vec![0.0f64; bb_sz],
1896            lu_scratch: vec![0.0f64; b],
1897            original_rhs: vec![0.0f64; b],
1898        }
1899    }
1900}
1901
1902struct MultiBlockAloChunkDiagnostics {
1903    chunk_start: usize,
1904    eta_tilde: Vec<Array1<f64>>,
1905    leverage: Vec<f64>,
1906    alo_variance: Vec<Array1<f64>>,
1907    predictive_variance: Vec<Array1<f64>>,
1908    cook_distance: Vec<f64>,
1909}
1910
1911fn compute_multiblock_alo_chunk(
1912    input: &MultiBlockAloInput,
1913    factor: &CertifiedSpdFactor<'_>,
1914    chunk_start: usize,
1915    chunk_end: usize,
1916    scratch: &mut MultiBlockAloScratch,
1917) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1918    let b = input.n_coordinates;
1919    let p_tot = input.penalized_hessian.nrows();
1920    let chunk_len = chunk_end - chunk_start;
1921
1922    let mut design_chunks = Vec::with_capacity(b);
1923    let mut q_blocks = Vec::with_capacity(b);
1924    for coordinate in 0..b {
1925        let design_chunk = input.coordinate_designs[coordinate]
1926            .try_row_chunk(chunk_start..chunk_end)
1927            .map_err(|reason| AloError::DesignDegenerate {
1928                reason: format!(
1929                    "multi-block ALO could not materialize coordinate {coordinate} rows {chunk_start}..{chunk_end}: {reason}"
1930                ),
1931            })?;
1932        if let Some(((row, column), value)) = design_chunk
1933            .indexed_iter()
1934            .map(|(index, &value)| (index, value))
1935            .find(|(_, value)| !value.is_finite())
1936        {
1937            return Err(AloError::DesignDegenerate {
1938                reason: format!(
1939                    "multi-block ALO coordinate {coordinate} design is non-finite at source row {}, column {column}: {value}",
1940                    chunk_start + row
1941                ),
1942            });
1943        }
1944        let coefficient_range = input.coordinate_coefficient_ranges[coordinate].clone();
1945        let mut rhs = Array2::<f64>::zeros((p_tot, chunk_len));
1946        rhs.slice_mut(s![coefficient_range, ..])
1947            .assign(&design_chunk.t());
1948        let (solution, _) = factor.solve_matrix(&rhs).map_err(|error| {
1949            AloError::LooComputationFailed {
1950                reason: format!(
1951                    "multi-block ALO saved-Hessian solve failed for coordinate {coordinate}, rows {chunk_start}..{chunk_end}: {error}"
1952                ),
1953            }
1954        })?;
1955        design_chunks.push(design_chunk);
1956        q_blocks.push(solution);
1957    }
1958
1959    let mut eta_tilde = Vec::with_capacity(chunk_len);
1960    let mut leverage = vec![0.0f64; chunk_len];
1961    let mut alo_variance = Vec::with_capacity(chunk_len);
1962    let mut predictive_variance = Vec::with_capacity(chunk_len);
1963    let mut cook_distance = vec![0.0f64; chunk_len];
1964
1965    for local_i in 0..chunk_len {
1966        let i = chunk_start + local_i;
1967        let w_i = &input.observed_hessians[i];
1968        let covariance_i = &input.score_covariances[i];
1969
1970        // Flatten the distinct observed-Hessian and score-covariance surfaces
1971        // once per observation (row-major).
1972        for r in 0..b {
1973            for c in 0..b {
1974                scratch.w_flat[r * b + c] = w_i[(r, c)];
1975                scratch.covariance_flat[r * b + c] = covariance_i[(r, c)];
1976            }
1977        }
1978
1979        // --- Assemble A_i = X_i H⁻¹ X_iᵀ  (B × B), row-major flat. ---
1980        for a in 0..b {
1981            let x_a = &design_chunks[a];
1982            let p_a = x_a.ncols();
1983            let off_a = input.coordinate_coefficient_ranges[a].start;
1984            let xa_row = x_a.row(local_i);
1985            for bb in 0..b {
1986                let q_bb = &q_blocks[bb];
1987                let mut dot = 0.0f64;
1988                for k in 0..p_a {
1989                    dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1990                }
1991                scratch.a_i[a * b + bb] = dot;
1992            }
1993        }
1994
1995        // diag(A_i): the coordinate-wise posterior predictive variance
1996        // x_dᵀ H⁻¹ x_d (unit dispersion), captured before A_i is consumed by
1997        // the deletion algebra below. Clamped at zero: A_i is a Gram diagonal
1998        // of the SPD-certified H⁻¹, so a negative entry is pure roundoff.
1999        let mut pred_var = Array1::<f64>::zeros(b);
2000        for d in 0..b {
2001            pred_var[d] = scratch.a_i[d * b + d].max(0.0);
2002        }
2003        predictive_variance.push(pred_var);
2004
2005        // WA = W_i · A_i (row-major).
2006        mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
2007        // AW = A_i · W_i (row-major).
2008        mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
2009
2010        // Trace of H_ii = A_i W_i (= AW): leverage[i].
2011        // (Original code wrote H_ii = A · W — the same operator we already have in `aw`.)
2012        let mut tr = 0.0f64;
2013        for d in 0..b {
2014            tr += scratch.aw[d * b + d];
2015        }
2016        leverage[local_i] = tr;
2017
2018        // Build (I - W A) and (I - A W) into imwa/imaw.
2019        for r in 0..b {
2020            for c in 0..b {
2021                let idx = r * b + c;
2022                let id = if r == c { 1.0 } else { 0.0 };
2023                scratch.imwa[idx] = id - scratch.wa[idx];
2024                scratch.imaw[idx] = id - scratch.aw[idx];
2025            }
2026        }
2027
2028        // A singular frozen-H deletion system is diagnostic information, not a
2029        // request to alter the estimand with a local ridge. The uncertainty in
2030        // `I - product` is governed by the magnitudes of the two multiplicands,
2031        // even when their product cancels the identity almost completely. A
2032        // tolerance scaled only by the already-cancelled matrix would erase
2033        // exactly the information needed to recognize unit deletion leverage.
2034        let imwa_tolerance =
2035            identity_minus_product_lu_tolerance(&scratch.w_flat, &scratch.a_i, &scratch.wa, b)?;
2036        if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b, imwa_tolerance) {
2037            return Err(AloError::LooComputationFailed {
2038                reason: format!(
2039                    "multi-block ALO deletion system I-WA is singular at row {i}; local pivot allowance {imwa_tolerance:.6e}, leverage trace {:.6e}",
2040                    leverage[local_i]
2041                ),
2042            });
2043        }
2044        let imaw_tolerance =
2045            identity_minus_product_lu_tolerance(&scratch.a_i, &scratch.w_flat, &scratch.aw, b)?;
2046        if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b, imaw_tolerance) {
2047            return Err(AloError::LooComputationFailed {
2048                reason: format!(
2049                    "multi-block ALO transpose deletion system I-AW is singular at row {i}; local pivot allowance {imaw_tolerance:.6e}, leverage trace {:.6e}",
2050                    leverage[local_i]
2051                ),
2052            });
2053        }
2054
2055        // v_i = (I - W A)⁻¹ s_i  -- solve into rhs_buf.
2056        let s_i = &input.scores[i];
2057        for k in 0..b {
2058            scratch.rhs_buf[k] = s_i[k];
2059        }
2060        if let Err(failure) = solve_identity_minus_product_in_place(
2061            &scratch.imwa,
2062            &scratch.perm_imwa,
2063            &scratch.wa,
2064            &mut scratch.rhs_buf,
2065            &mut scratch.lu_scratch,
2066            &mut scratch.original_rhs,
2067            imwa_tolerance,
2068            b,
2069        ) {
2070            return Err(AloError::LooComputationFailed {
2071                reason: format!(
2072                    "multi-block ALO deletion solve I-WA failed backward-error certification at row {i}: residual {:.6e}, allowance {:.6e}",
2073                    failure.residual_norm, failure.allowance
2074                ),
2075            });
2076        }
2077        // delta_eta = A_i · v_i
2078        for r in 0..b {
2079            let mut acc = 0.0f64;
2080            let row_off = r * b;
2081            for k in 0..b {
2082                acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
2083            }
2084            scratch.delta_eta[r] = acc;
2085        }
2086
2087        let eta_i = &input.coordinate_values[i];
2088        let mut corrected = Array1::<f64>::zeros(b);
2089        for d in 0..b {
2090            corrected[d] = eta_i[d] + scratch.delta_eta[d];
2091            if !scratch.delta_eta[d].is_finite() || !corrected[d].is_finite() {
2092                return Err(AloError::LooComputationFailed {
2093                    reason: format!(
2094                        "multi-block ALO correction is non-finite at row {i}, coordinate {d}: delta={}, corrected={}",
2095                        scratch.delta_eta[d], corrected[d]
2096                    ),
2097                });
2098            }
2099        }
2100        eta_tilde.push(corrected);
2101
2102        // Cook's distance uses score covariance, not observed curvature.
2103        let mut cook = 0.0f64;
2104        let mut cook_scale = 0.0f64;
2105        for r in 0..b {
2106            let mut covariance_delta_r = 0.0f64;
2107            let row_off = r * b;
2108            for k in 0..b {
2109                covariance_delta_r += scratch.covariance_flat[row_off + k] * scratch.delta_eta[k];
2110            }
2111            let term = scratch.delta_eta[r] * covariance_delta_r;
2112            cook += term;
2113            cook_scale += term.abs();
2114        }
2115        let cook_tolerance =
2116            LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * cook_scale;
2117        if !cook.is_finite() || cook < -cook_tolerance {
2118            return Err(AloError::LooComputationFailed {
2119                reason: format!(
2120                    "multi-block ALO Cook influence is invalid at row {i}: value {cook:.6e}, roundoff allowance {cook_tolerance:.6e}"
2121                ),
2122            });
2123        }
2124        cook_distance[local_i] = cook.max(0.0);
2125
2126        // var_diag[d] = a_d^T (I-WA)⁻¹ C (I-AW)⁻¹ a_d
2127        // where a_d is the d-th row of A_i.
2128        // Reuses already-factored imwa and imaw (one LU factorization each, reused
2129        // across all B right-hand sides — major saving over the original which redid
2130        // both LU decompositions B times per observation).
2131        for d in 0..b {
2132            let row_off = d * b;
2133            // u_d = (I - A W)⁻¹ a_d
2134            for k in 0..b {
2135                scratch.rhs_buf[k] = scratch.a_i[row_off + k];
2136            }
2137            if let Err(failure) = solve_identity_minus_product_in_place(
2138                &scratch.imaw,
2139                &scratch.perm_imaw,
2140                &scratch.aw,
2141                &mut scratch.rhs_buf,
2142                &mut scratch.lu_scratch,
2143                &mut scratch.original_rhs,
2144                imaw_tolerance,
2145                b,
2146            ) {
2147                return Err(AloError::LooComputationFailed {
2148                    reason: format!(
2149                        "multi-block ALO transpose variance solve I-AW failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2150                        failure.residual_norm, failure.allowance
2151                    ),
2152                });
2153            }
2154            // covariance_u = C u_d
2155            for r in 0..b {
2156                let mut acc = 0.0f64;
2157                let wr = r * b;
2158                for k in 0..b {
2159                    acc += scratch.covariance_flat[wr + k] * scratch.rhs_buf[k];
2160                }
2161                scratch.covariance_u[r] = acc;
2162            }
2163            // t_d = (I - W A)⁻¹ C u_d.
2164            if let Err(failure) = solve_identity_minus_product_in_place(
2165                &scratch.imwa,
2166                &scratch.perm_imwa,
2167                &scratch.wa,
2168                &mut scratch.covariance_u,
2169                &mut scratch.lu_scratch,
2170                &mut scratch.original_rhs,
2171                imwa_tolerance,
2172                b,
2173            ) {
2174                return Err(AloError::LooComputationFailed {
2175                    reason: format!(
2176                        "multi-block ALO variance solve I-WA failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2177                        failure.residual_norm, failure.allowance
2178                    ),
2179                });
2180            }
2181            // v_dd = a_d^T t_d
2182            let mut v_dd = 0.0f64;
2183            for k in 0..b {
2184                v_dd += scratch.a_i[row_off + k] * scratch.covariance_u[k];
2185            }
2186            let variance_scale = scratch.a_i[row_off..row_off + b]
2187                .iter()
2188                .zip(scratch.covariance_u.iter())
2189                .map(|(left, right)| (left * right).abs())
2190                .sum::<f64>();
2191            let variance_tolerance =
2192                LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * variance_scale;
2193            if !v_dd.is_finite() || v_dd < -variance_tolerance {
2194                return Err(AloError::LooComputationFailed {
2195                    reason: format!(
2196                        "multi-block ALO variance is invalid at row {i}, coordinate {d}: value {v_dd:.6e}, roundoff allowance {variance_tolerance:.6e}"
2197                    ),
2198                });
2199            }
2200            scratch.var_diag_buf[d] = v_dd.max(0.0);
2201        }
2202        let mut var_diag = Array1::<f64>::zeros(b);
2203        for d in 0..b {
2204            var_diag[d] = scratch.var_diag_buf[d];
2205        }
2206        alo_variance.push(var_diag);
2207    }
2208
2209    Ok(MultiBlockAloChunkDiagnostics {
2210        chunk_start,
2211        eta_tilde,
2212        leverage,
2213        alo_variance,
2214        predictive_variance,
2215        cook_distance,
2216    })
2217}
2218
2219/// B × B row-major matmul: out = a · b.
2220#[inline]
2221fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
2222    for r in 0..b {
2223        let ar = r * b;
2224        let or = r * b;
2225        for c in 0..b {
2226            let mut acc = 0.0f64;
2227            for k in 0..b {
2228                acc += a[ar + k] * b_mat[k * b + c];
2229            }
2230            out[or + c] = acc;
2231        }
2232    }
2233}
2234
2235/// Standard `gamma_n = n*u/(1-n*u)` bound for `n` rounded operations, where
2236/// binary64 unit roundoff under round-to-nearest is `u = eps/2`.
2237#[inline]
2238fn floating_point_gamma(operation_count: usize) -> f64 {
2239    let accumulated = operation_count as f64 * (0.5 * f64::EPSILON);
2240    if accumulated < 1.0 {
2241        accumulated / (1.0 - accumulated)
2242    } else {
2243        f64::INFINITY
2244    }
2245}
2246
2247/// Pivot allowance for a row-major `I - left * right` local system.
2248///
2249/// The scale is `max(||I-left*right||_inf,
2250/// 1 + || |left||right| ||_inf)`: the second operand-derived term is the
2251/// magnitude envelope before cancellation, while the first retains the actual
2252/// operator scale when it is larger. Forming every product entry takes at most
2253/// `2B` rounded multiply/add operations and subtracting it from the identity
2254/// takes one more. The LU term accounts for the division/multiply/subtract chain
2255/// along at most `B` partial-pivoting elimination stages. The resulting bound
2256/// cannot collapse merely because `left * right` rounded close to the identity.
2257fn identity_minus_product_lu_tolerance(
2258    left: &[f64],
2259    right: &[f64],
2260    product: &[f64],
2261    b: usize,
2262) -> Result<f64, AloError> {
2263    let expected_len = b.checked_mul(b).ok_or_else(|| AloError::InvalidInput {
2264        reason: format!(
2265            "multi-block ALO local deletion dimension B={b} overflows the square matrix size"
2266        ),
2267    })?;
2268    for (name, actual_len) in [
2269        ("left operand", left.len()),
2270        ("right operand", right.len()),
2271        ("precomputed product", product.len()),
2272    ] {
2273        if actual_len != expected_len {
2274            return Err(AloError::InvalidInput {
2275                reason: format!(
2276                    "multi-block ALO local deletion {name} has length {actual_len}, expected B*B={expected_len} for B={b}"
2277                ),
2278            });
2279        }
2280    }
2281
2282    let mut operand_envelope_inf = 0.0_f64;
2283    let mut system_norm_inf = 0.0_f64;
2284    for row in 0..b {
2285        let mut operand_row_envelope = 1.0_f64;
2286        let mut system_row_norm = 0.0_f64;
2287        for column in 0..b {
2288            let mut product_entry_envelope = 0.0_f64;
2289            for inner in 0..b {
2290                product_entry_envelope +=
2291                    left[row * b + inner].abs() * right[inner * b + column].abs();
2292            }
2293            operand_row_envelope += product_entry_envelope;
2294            let identity = if row == column { 1.0 } else { 0.0 };
2295            system_row_norm += (identity - product[row * b + column]).abs();
2296        }
2297        operand_envelope_inf = operand_envelope_inf.max(operand_row_envelope);
2298        system_norm_inf = system_norm_inf.max(system_row_norm);
2299    }
2300
2301    let formation_operations = b.saturating_mul(2).saturating_add(1);
2302    let elimination_operations = b.saturating_mul(3);
2303    let backward_error_scale = operand_envelope_inf.max(system_norm_inf);
2304    Ok(
2305        (floating_point_gamma(formation_operations) + floating_point_gamma(elimination_operations))
2306            * backward_error_scale,
2307    )
2308}
2309
2310/// LU-decompose a B × B row-major matrix in place with partial pivoting and
2311/// physical row swaps. Returns false if any pivot is within the caller's
2312/// scale-aware backward-error allowance.
2313/// On success, `m` holds L (strict lower, unit diag implicit) and U (upper, diag
2314/// included); `perm[k]` records the original-row index that ended up in physical
2315/// row k after pivoting.
2316fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize, pivot_tolerance: f64) -> bool {
2317    for i in 0..b {
2318        perm[i] = i;
2319    }
2320    for col in 0..b {
2321        // Partial pivot on column `col` over physical rows `[col..b]`.
2322        let mut max_val = m[col * b + col].abs();
2323        let mut max_idx = col;
2324        for row in (col + 1)..b {
2325            let v = m[row * b + col].abs();
2326            if v > max_val {
2327                max_val = v;
2328                max_idx = row;
2329            }
2330        }
2331        if !max_val.is_finite() || max_val <= pivot_tolerance {
2332            return false;
2333        }
2334        if max_idx != col {
2335            // Physically swap rows `col` and `max_idx` (full row, all columns).
2336            for k in 0..b {
2337                m.swap(col * b + k, max_idx * b + k);
2338            }
2339            perm.swap(col, max_idx);
2340        }
2341        let pivot = m[col * b + col];
2342        for row in (col + 1)..b {
2343            let factor = m[row * b + col] / pivot;
2344            m[row * b + col] = factor; // store L below diag
2345            for k in (col + 1)..b {
2346                let upd = factor * m[col * b + k];
2347                m[row * b + k] -= upd;
2348            }
2349        }
2350    }
2351    true
2352}
2353
2354/// Solve L U x = P rhs using a previously factored matrix (LU in `m`, perm).
2355/// Writes the solution back into `rhs`. `scratch` must have length ≥ b.
2356fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
2357    // Forward substitution Ly = P rhs (L is unit-diag, strict lower of m).
2358    let y = &mut scratch[..b];
2359    for row in 0..b {
2360        let mut s = rhs[perm[row]];
2361        for k in 0..row {
2362            s -= m[row * b + k] * y[k];
2363        }
2364        y[row] = s;
2365    }
2366    // Back substitution U x = y.  Write into rhs[].
2367    for row in (0..b).rev() {
2368        let mut s = y[row];
2369        for k in (row + 1)..b {
2370            s -= m[row * b + k] * rhs[k];
2371        }
2372        rhs[row] = s / m[row * b + row];
2373    }
2374}
2375
2376#[derive(Clone, Copy, Debug)]
2377struct LocalSolveResidualFailure {
2378    residual_norm: f64,
2379    allowance: f64,
2380}
2381
2382/// Solve a factored `I - product` system and certify the result against the
2383/// unfactored operator. The residual allowance contains both the uncertainty in
2384/// forming the operator and the operation-count-derived error from LU,
2385/// triangular substitution, and residual evaluation. This is a backward-error
2386/// certificate, so a well-resolved but ill-conditioned system remains valid;
2387/// only a solve unsupported by its own arithmetic is refused.
2388fn solve_identity_minus_product_in_place(
2389    lu: &[f64],
2390    permutation: &[usize],
2391    product: &[f64],
2392    rhs: &mut [f64],
2393    lu_scratch: &mut [f64],
2394    original_rhs: &mut [f64],
2395    operator_error_bound: f64,
2396    b: usize,
2397) -> Result<(), LocalSolveResidualFailure> {
2398    original_rhs[..b].copy_from_slice(&rhs[..b]);
2399    lu_solve_in_place(lu, permutation, rhs, lu_scratch, b);
2400
2401    let rhs_norm = original_rhs[..b]
2402        .iter()
2403        .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2404    let solution_norm = rhs[..b]
2405        .iter()
2406        .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2407    let mut system_norm = 0.0_f64;
2408    let mut residual_norm = 0.0_f64;
2409    for row in 0..b {
2410        let mut row_norm = 0.0_f64;
2411        let mut residual = original_rhs[row];
2412        for column in 0..b {
2413            let identity = if row == column { 1.0 } else { 0.0 };
2414            let matrix_entry = identity - product[row * b + column];
2415            row_norm += matrix_entry.abs();
2416            residual -= matrix_entry * rhs[column];
2417        }
2418        system_norm = system_norm.max(row_norm);
2419        residual_norm = residual_norm.max(residual.abs());
2420    }
2421
2422    // Per dimension: at most 3B factorization operations on a surviving entry,
2423    // 2B in each triangular substitution, and 3B to reconstruct/evaluate the
2424    // residual from `I - product`.
2425    let certification_operations = b.saturating_mul(10);
2426    let arithmetic_scale = system_norm * solution_norm + rhs_norm;
2427    let allowance = floating_point_gamma(certification_operations) * arithmetic_scale
2428        + operator_error_bound * solution_norm;
2429    if rhs[..b].iter().any(|value| !value.is_finite())
2430        || !residual_norm.is_finite()
2431        || !allowance.is_finite()
2432        || residual_norm > allowance
2433    {
2434        Err(LocalSolveResidualFailure {
2435            residual_norm,
2436            allowance,
2437        })
2438    } else {
2439        Ok(())
2440    }
2441}
2442
2443#[cfg(test)]
2444mod tests {
2445    use super::{
2446        ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
2447        alo_eta_updatewith_offset, compute_alo_from_input_inner, finite_weighted_square_sum,
2448        percentile_from_sorted, percentile_index, spd_quadratic_after_certified_solve,
2449    };
2450    use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
2451
2452    #[test]
2453    fn alo_offset_update_matches_centered_algebra() {
2454        let eta_hat = 11.0;
2455        let z = 13.0;
2456        let offset = 10.0;
2457        let x_hinv_x = 0.2;
2458        let hessian_weight = 1.0;
2459        let score_weight = 1.0;
2460        // centered: eta~=off + ((eta-off)-a(z-off))/(1-a) when W_S = W_H.
2461        let leverage = hessian_weight * x_hinv_x;
2462        let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2463        let got =
2464            alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2465        assert!((got - expected).abs() < 1e-12);
2466    }
2467
2468    #[test]
2469    fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2470        let eta_hat = 1.25;
2471        let z = -0.5;
2472        let x_hinv_x = 0.35;
2473        let hessian_weight = 1.0;
2474        let score_weight = 1.0;
2475        let leverage = hessian_weight * x_hinv_x;
2476        let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2477        let got =
2478            alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2479        assert!((got - expected).abs() < 1e-12);
2480    }
2481
2482    #[test]
2483    fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2484        let eta_hat = 1.7;
2485        let z = 0.4;
2486        let offset = -0.2;
2487        let x_hinv_x = 0.15;
2488        let hessian_weight = 3.0;
2489        let score_weight = 5.0;
2490        let expected = offset
2491            + (eta_hat - offset)
2492            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2493                / (1.0 - hessian_weight * x_hinv_x);
2494        let got = alo_eta_updatewith_offset(
2495            eta_hat,
2496            z,
2497            offset,
2498            x_hinv_x,
2499            score_weight,
2500            1.0 - hessian_weight * x_hinv_x,
2501        );
2502        assert!((got - expected).abs() < 1e-12);
2503    }
2504
2505    #[test]
2506    fn alo_offset_update_handles_zero_hessian_weight() {
2507        let eta_hat = 0.8;
2508        let z = -0.3;
2509        let offset = 0.1;
2510        let x_hinv_x = 0.4;
2511        let hessian_weight = 0.0;
2512        let score_weight = 2.5;
2513        let expected = offset
2514            + (eta_hat - offset)
2515            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2516        let got = alo_eta_updatewith_offset(
2517            eta_hat,
2518            z,
2519            offset,
2520            x_hinv_x,
2521            score_weight,
2522            1.0 - hessian_weight * x_hinv_x,
2523        );
2524        assert!((got - expected).abs() < 1e-12);
2525    }
2526
2527    #[test]
2528    fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2529        let eta_hat = 1.0;
2530        let a_ii = 0.4;
2531        let got =
2532            alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| Ok((0.5 * (eta - 2.0), 0.5)))
2533                .expect("linear scalar fixed point should converge in one Newton step");
2534        assert!((got - 0.75).abs() < 1e-12);
2535    }
2536
2537    #[test]
2538    fn alo_exact_frozen_curvature_reports_nonconvergence() {
2539        let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| Ok((eta + 1.0, 0.0)))
2540            .expect_err("constant residual should exhaust the scalar iteration budget");
2541        let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2542            panic!("constant residual must report MaxIterations, got {err:?}");
2543        };
2544        assert_eq!(
2545            iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2546            "non-convergence must report the full scalar iteration budget"
2547        );
2548    }
2549
2550    #[test]
2551    fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2552        let design = Array2::from_elem((1, 1), 1.0);
2553        let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2554        let hessian_weights = Array1::from_vec(vec![0.0]);
2555        let score_weights = Array1::from_vec(vec![0.0]);
2556        let working_response = Array1::from_vec(vec![0.0]);
2557        let eta = Array1::from_vec(vec![0.0]);
2558        let offset = Array1::from_vec(vec![0.0]);
2559        let score_curvature = |_: usize, eta: f64| Ok((eta + 1.0, 0.0));
2560        let input = AloInput {
2561            design: &design,
2562            penalized_hessian: &penalized_hessian,
2563            hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2564            score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2565            working_response: &working_response,
2566            eta: &eta,
2567            offset: &offset,
2568            phi: 1.0,
2569            score_curvature: Some(&score_curvature),
2570        };
2571
2572        let err =
2573            compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2574        let msg = err.to_string();
2575        assert!(
2576            msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2577            "missing row context in exact ALO error: {msg}"
2578        );
2579        assert!(
2580            msg.contains("did not converge within"),
2581            "missing non-convergence cause in exact ALO error: {msg}"
2582        );
2583    }
2584
2585    #[test]
2586    fn alo_scale_safe_quadratics_preserve_tiny_weights_without_false_overflow() {
2587        let weights = Array1::from_vec(vec![1e-300, 2.0]);
2588        let values = [1e200, 3.0];
2589        let meat = finite_weighted_square_sum(0, weights.view(), &values)
2590            .expect("weighted square sum is representable");
2591        assert!(meat.is_finite());
2592        assert!((meat - 1e100).abs() <= 8.0 * f64::EPSILON * 1e100);
2593
2594        let rhs = Array1::from_vec(vec![2.0, -1.0]);
2595        let solution = Array1::from_vec(vec![1.5, 0.5]);
2596        let quadratic =
2597            spd_quadratic_after_certified_solve(0, rhs.view(), solution.view()).unwrap();
2598        assert_eq!(quadratic, 2.5);
2599    }
2600
2601    #[test]
2602    fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2603        // Regression for the sandwich-SE "meat" weight bug: the meat must be the
2604        // SCORE covariance Xᵀ diag(W_S) X (Fisher, PSD), NOT the observed-info
2605        // Hessian weight W_H (signed). This fixture mimics a non-canonical link
2606        // (W_H ≠ W_S) with mixed-sign observed curvature.
2607        //
2608        // Single column (p = 1) makes H a scalar, so the sandwich variance is
2609        // closed form: with H = Σ W_H·x² + s0 (> 0 after the penalty), the meat
2610        // for obs is x_obs²·H⁻²·Σ_row W_S·x_row², and se = sqrt(φ·meat).
2611        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2612        // Mixed-sign observed-information weights; the negative rows carry the
2613        // larger design values so Σ W_H·x² is NEGATIVE (see assert below).
2614        let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2615        // Score/Fisher weights are strictly positive (PSD by construction).
2616        let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2617        let phi = 1.3;
2618
2619        let n = x.nrows();
2620        let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2621        let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2622        // The whole point: Σ W_H·x² < 0 < Σ W_S·x². With W_H the meat is negative
2623        // and the "materially negative sandwich variance" guard would trip
2624        // (spurious LooComputationFailed); with W_S it is a valid PSD meat.
2625        assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2626        assert!(sum_ws_x2 > 0.0);
2627
2628        // Penalize enough that the penalized Hessian is PD despite Σ W_H·x² < 0.
2629        let s0 = 8.0_f64;
2630        let h = s0 + sum_wh_x2; // = 2.5
2631        assert!(h > 0.0, "penalized Hessian must stay PD");
2632        let penalized_hessian = Array2::from_elem((1, 1), h);
2633
2634        // Pre-fix arithmetic check: the OLD W_H meat would be materially negative
2635        // for the larger-x rows, so the old code returned LooComputationFailed.
2636        let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2637        assert!(phi * old_meat_obs1 < 0.0, "the pre-fix W_H meat is signed");
2638
2639        let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2640        let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2641        let offset = Array1::zeros(n);
2642        let input = AloInput {
2643            design: &x,
2644            penalized_hessian: &penalized_hessian,
2645            hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2646            score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2647            working_response: &working_response,
2648            eta: &eta,
2649            offset: &offset,
2650            phi,
2651            score_curvature: None,
2652        };
2653
2654        // The fix must let this succeed (no spurious negative-meat failure)...
2655        let diag = compute_alo_from_input_inner(&input)
2656            .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2657
2658        // ...and match the closed-form W_S reference for every row.
2659        for obs in 0..n {
2660            let expected = (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2661            assert!(
2662                (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2663                "row {obs}: se_sandwich={} expected={expected}",
2664                diag.se_sandwich[obs]
2665            );
2666            let expected_leverage = w_h_vec[obs] * x[[obs, 0]] * x[[obs, 0]] / h;
2667            assert!(
2668                (diag.leverage[obs] - expected_leverage).abs()
2669                    <= 1e-12 * expected_leverage.abs().max(1.0),
2670                "row {obs}: signed leverage={} expected={expected_leverage}",
2671                diag.leverage[obs]
2672            );
2673        }
2674        assert!(
2675            diag.leverage[1] < 0.0,
2676            "negative observed curvature must remain signed"
2677        );
2678    }
2679
2680    #[test]
2681    fn percentile_index_matches_expected_rounding() {
2682        assert_eq!(percentile_index(0, 0.95), 0);
2683        assert_eq!(percentile_index(1, 0.95), 0);
2684        assert_eq!(percentile_index(10, 0.50), 5);
2685        assert_eq!(percentile_index(10, 0.95), 9);
2686    }
2687
2688    #[test]
2689    fn percentile_from_sorted_returns_order_statistic() {
2690        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2691        assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2692        assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2693        assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2694    }
2695
2696    // --- Multi-block ALO tests ---
2697
2698    use super::{
2699        MultiBlockAloInput, compute_multiblock_alo, floating_point_gamma,
2700        identity_minus_product_lu_tolerance, lu_factor_in_place, mat_mul_flat,
2701    };
2702    use gam_linalg::matrix::DesignMatrix;
2703    use ndarray::{Array1, Array2};
2704
2705    fn local_identity_minus_product_is_factorable(left: &[f64], right: &[f64], b: usize) -> bool {
2706        let mut product = vec![0.0; b * b];
2707        mat_mul_flat(left, right, &mut product, b);
2708        let mut system = vec![0.0; b * b];
2709        for row in 0..b {
2710            for column in 0..b {
2711                let identity = if row == column { 1.0 } else { 0.0 };
2712                system[row * b + column] = identity - product[row * b + column];
2713            }
2714        }
2715        let tolerance = identity_minus_product_lu_tolerance(left, right, &product, b)
2716            .expect("test matrices satisfy the B-by-B local deletion contract");
2717        let mut permutation = vec![0; b];
2718        lu_factor_in_place(&mut system, &mut permutation, b, tolerance)
2719    }
2720
2721    #[test]
2722    fn multiblock_b1_matches_scalar_leverage() {
2723        // With B=1 the multi-block formula should reduce to the scalar case.
2724        // H_ii = x_i^T H^{-1} x_i * w_i  (scalar).
2725        let n = 3;
2726        let p = 2;
2727        let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2728        // H = X'WX + I (simple regularisation).
2729        let w = [1.0, 2.0, 0.5];
2730        let mut h = Array2::<f64>::eye(p);
2731        for i in 0..n {
2732            for r in 0..p {
2733                for c in 0..p {
2734                    h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2735                }
2736            }
2737        }
2738        // Invert H (2x2).
2739        let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2740        let mut h_inv = Array2::<f64>::zeros((p, p));
2741        h_inv[(0, 0)] = h[(1, 1)] / det;
2742        h_inv[(1, 1)] = h[(0, 0)] / det;
2743        h_inv[(0, 1)] = -h[(0, 1)] / det;
2744        h_inv[(1, 0)] = -h[(1, 0)] / det;
2745
2746        // Scalar leverages: a_ii = w_i * x_i^T H^{-1} x_i
2747        let mut scalar_lev = vec![0.0f64; n];
2748        for i in 0..n {
2749            let mut xhx = 0.0;
2750            for r in 0..p {
2751                for c in 0..p {
2752                    xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2753                }
2754            }
2755            scalar_lev[i] = w[i] * xhx;
2756        }
2757
2758        // Multi-block with B=1. The score covariance is deliberately supplied
2759        // separately even though this well-specified fixture sets C_i = W_i.
2760        let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2761        let coordinate_coefficient_ranges = vec![0..p];
2762        let observed_hessians: Vec<Array2<f64>> =
2763            w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2764        let score_covariances = observed_hessians.clone();
2765        let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2766        let coordinate_values: Vec<Array1<f64>> =
2767            (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2768
2769        let input = MultiBlockAloInput {
2770            n_obs: n,
2771            n_coordinates: 1,
2772            coordinate_designs: &coordinate_designs,
2773            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2774            penalized_hessian: &h,
2775            observed_hessians: &observed_hessians,
2776            score_covariances: &score_covariances,
2777            scores: &scores,
2778            coordinate_values: &coordinate_values,
2779        };
2780
2781        let result = compute_multiblock_alo(&input).unwrap();
2782        for i in 0..n {
2783            assert!(
2784                (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2785                "leverage mismatch at i={}: got {}, expected {}",
2786                i,
2787                result.leverage[i],
2788                scalar_lev[i]
2789            );
2790        }
2791    }
2792
2793    #[test]
2794    fn multiblock_b2_matches_closed_form_with_cross_geometry() {
2795        // A one-row B=2 identity-Jacobian fixture pins every matrix ordering:
2796        // A=H^-1, M=I-WA, delta=A M^-1 s, leverage=tr(AW), and the distinct
2797        // score covariance C drives Cook/variance. Diagonal-only or C=W code
2798        // cannot pass this fixture. Both coordinates address the same full
2799        // parameter range, exercising the shared-coefficient contract used by
2800        // survival and latent rows.
2801        let coordinate_designs = vec![
2802            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2803            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2804        ];
2805        let coordinate_coefficient_ranges = vec![0..2, 0..2];
2806        let h = Array2::from_shape_vec((2, 2), vec![2.0, 0.25, 0.25, 3.0]).unwrap();
2807        let w = Array2::from_shape_vec((2, 2), vec![0.2, 0.05, 0.05, 0.3]).unwrap();
2808        let c = Array2::from_shape_vec((2, 2), vec![0.5, 0.1, 0.1, 0.4]).unwrap();
2809        let observed_hessians = vec![w.clone()];
2810        let score_covariances = vec![c.clone()];
2811        let scores = vec![Array1::from_vec(vec![0.4, -0.2])];
2812        let coordinate_values = vec![Array1::from_vec(vec![1.0, -0.5])];
2813        let input = MultiBlockAloInput {
2814            n_obs: 1,
2815            n_coordinates: 2,
2816            coordinate_designs: &coordinate_designs,
2817            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2818            penalized_hessian: &h,
2819            observed_hessians: &observed_hessians,
2820            score_covariances: &score_covariances,
2821            scores: &scores,
2822            coordinate_values: &coordinate_values,
2823        };
2824
2825        let det_h = h[[0, 0]] * h[[1, 1]] - h[[0, 1]] * h[[1, 0]];
2826        let a = Array2::from_shape_vec(
2827            (2, 2),
2828            vec![
2829                h[[1, 1]] / det_h,
2830                -h[[0, 1]] / det_h,
2831                -h[[1, 0]] / det_h,
2832                h[[0, 0]] / det_h,
2833            ],
2834        )
2835        .unwrap();
2836        let m = Array2::<f64>::eye(2) - w.dot(&a);
2837        let det_m = m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]];
2838        let m_inv = Array2::from_shape_vec(
2839            (2, 2),
2840            vec![
2841                m[[1, 1]] / det_m,
2842                -m[[0, 1]] / det_m,
2843                -m[[1, 0]] / det_m,
2844                m[[0, 0]] / det_m,
2845            ],
2846        )
2847        .unwrap();
2848        let delta = a.dot(&m_inv.dot(&scores[0]));
2849        let expected_eta = &coordinate_values[0] + &delta;
2850        let expected_leverage = (a.dot(&w)).diag().sum();
2851        let expected_cook = delta.dot(&c.dot(&delta));
2852        let variance = a.dot(&m_inv).dot(&c).dot(&m_inv.t()).dot(&a.t());
2853
2854        let result = compute_multiblock_alo(&input).expect("B=2 closed-form ALO");
2855        for coordinate in 0..2 {
2856            assert!((result.eta_tilde[0][coordinate] - expected_eta[coordinate]).abs() < 2e-12);
2857            assert!(
2858                (result.alo_variance[0][coordinate] - variance[[coordinate, coordinate]]).abs()
2859                    < 2e-12
2860            );
2861        }
2862        assert!((result.leverage[0] - expected_leverage).abs() < 2e-12);
2863        assert!((result.cook_distance[0] - expected_cook).abs() < 2e-12);
2864    }
2865
2866    #[test]
2867    fn multiblock_singular_weight_still_corrects() {
2868        // When W_i = 0 (singular), the W_i⁻¹-free formula still works:
2869        // (I - W_i A_i)⁻¹ = I, so Δη = A_i s_i.
2870        // A_i = x H⁻¹ xᵀ = 1.0² + 0.5² = 1.25 (scalar, B=1).
2871        let n = 1;
2872        let p = 2;
2873        let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2874        let h = Array2::eye(p);
2875        let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2876        let coordinate_coefficient_ranges = vec![0..p];
2877        let observed_hessians = vec![Array2::from_elem((1, 1), 0.0)];
2878        let score_covariances = observed_hessians.clone();
2879        let scores = vec![Array1::from_vec(vec![1.0])];
2880        let coordinate_values = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2881
2882        let input = MultiBlockAloInput {
2883            n_obs: n,
2884            n_coordinates: 1,
2885            coordinate_designs: &coordinate_designs,
2886            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2887            penalized_hessian: &h,
2888            observed_hessians: &observed_hessians,
2889            score_covariances: &score_covariances,
2890            scores: &scores,
2891            coordinate_values: &coordinate_values,
2892        };
2893        let result = compute_multiblock_alo(&input).unwrap();
2894        // Δη = A_i * s_i = 1.25 * 1.0 = 1.25
2895        let expected = std::f64::consts::PI + 1.25;
2896        assert!(
2897            (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2898            "expected {}, got {}",
2899            expected,
2900            result.eta_tilde[0][0]
2901        );
2902        // Cook's distance should be 0 since C_i = 0.
2903        assert!(result.cook_distance[0].abs() < 1e-14);
2904        // ALO variance should be 0 since C_i = 0.
2905        assert!(result.alo_variance[0][0].abs() < 1e-14);
2906    }
2907
2908    #[test]
2909    fn multiblock_unit_leverage_refuses_instead_of_changing_estimand() {
2910        let coordinate_designs = vec![DesignMatrix::from(Array2::from_elem((1, 1), 1.0))];
2911        let coordinate_coefficient_ranges = vec![0..1];
2912        let h = Array2::from_elem((1, 1), 2.0);
2913        let observed_hessians = vec![Array2::from_elem((1, 1), 2.0)];
2914        let score_covariances = vec![Array2::from_elem((1, 1), 1.0)];
2915        let scores = vec![Array1::from_vec(vec![0.4])];
2916        let coordinate_values = vec![Array1::from_vec(vec![1.0])];
2917        let input = MultiBlockAloInput {
2918            n_obs: 1,
2919            n_coordinates: 1,
2920            coordinate_designs: &coordinate_designs,
2921            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2922            penalized_hessian: &h,
2923            observed_hessians: &observed_hessians,
2924            score_covariances: &score_covariances,
2925            scores: &scores,
2926            coordinate_values: &coordinate_values,
2927        };
2928        let error = compute_multiblock_alo(&input)
2929            .expect_err("unit deletion leverage must be reported as singular");
2930        assert!(
2931            error
2932                .to_string()
2933                .contains("deletion system I-WA is singular")
2934        );
2935    }
2936
2937    #[test]
2938    fn multiblock_b2_identity_cancellation_is_numerically_singular() {
2939        // The stored operands differ from an exact inverse pair by one ulp, so
2940        // the formed diagonal of I-WA is nonzero but smaller than the error in
2941        // forming the product. Scaling by ||I-WA|| alone would accept it.
2942        let above_two = f64::from_bits(2.0_f64.to_bits() + 1);
2943        let w = [above_two, 0.0, 0.0, above_two];
2944        let a = [0.5, 0.0, 0.0, 0.5];
2945        assert!(!local_identity_minus_product_is_factorable(&w, &a, 2));
2946        assert!(!local_identity_minus_product_is_factorable(&a, &w, 2));
2947    }
2948
2949    #[test]
2950    fn multiblock_b2_safely_near_singular_deletion_is_accepted() {
2951        // This system is ill-conditioned, but its smallest pivot is sqrt(eps),
2952        // well outside the O(eps) formation uncertainty of its operands.
2953        let gap = f64::EPSILON.sqrt();
2954        let identity = [1.0, 0.0, 0.0, 1.0];
2955        let product_operand = [1.0 - gap, 0.0, 0.0, 0.5];
2956        assert!(local_identity_minus_product_is_factorable(
2957            &identity,
2958            &product_operand,
2959            2
2960        ));
2961        assert!(local_identity_minus_product_is_factorable(
2962            &product_operand,
2963            &identity,
2964            2
2965        ));
2966    }
2967
2968    #[test]
2969    fn multiblock_trace_one_but_invertible_deletion_is_not_refused() {
2970        // tr(AW)=1 is only a scalar summary. Here I-AW has eigenvalues 3/4 and
2971        // 1/4, so a leverage gate would reject a perfectly regular exact solve.
2972        let coordinate_designs = vec![
2973            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2974            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2975        ];
2976        let coordinate_coefficient_ranges = vec![0..2, 0..2];
2977        let penalized_hessian = Array2::<f64>::eye(2);
2978        let observed_hessians =
2979            vec![Array2::from_shape_vec((2, 2), vec![0.25, 0.0, 0.0, 0.75]).unwrap()];
2980        let score_covariances = vec![Array2::<f64>::zeros((2, 2))];
2981        let scores = vec![Array1::from_vec(vec![0.75, -0.25])];
2982        let coordinate_values = vec![Array1::<f64>::zeros(2)];
2983        let input = MultiBlockAloInput {
2984            n_obs: 1,
2985            n_coordinates: 2,
2986            coordinate_designs: &coordinate_designs,
2987            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2988            penalized_hessian: &penalized_hessian,
2989            observed_hessians: &observed_hessians,
2990            score_covariances: &score_covariances,
2991            scores: &scores,
2992            coordinate_values: &coordinate_values,
2993        };
2994
2995        let result = compute_multiblock_alo(&input)
2996            .expect("trace-one but invertible deletion system must be solved exactly");
2997        let roundoff = floating_point_gamma(16);
2998        assert!((result.leverage[0] - 1.0).abs() <= roundoff);
2999        assert!((result.eta_tilde[0][0] - 1.0).abs() <= roundoff);
3000        assert!((result.eta_tilde[0][1] + 1.0).abs() <= roundoff);
3001    }
3002}