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            |_, 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    /// `AloInput::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    pub fn from_penalized_hessian_with_working_state(
1083        penalized_hessian: &'a Array2<f64>,
1084        design: &'a Array2<f64>,
1085        eta: &'a Array1<f64>,
1086        offset: &'a Array1<f64>,
1087        phi: f64,
1088        working_weights: &'a Array1<f64>,
1089        working_response: &'a Array1<f64>,
1090    ) -> Self {
1091        let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
1092        Self {
1093            design,
1094            penalized_hessian,
1095            hessian_weights: psd_w.as_signed(),
1096            score_weights: psd_w,
1097            working_response,
1098            eta,
1099            offset,
1100            phi,
1101            score_curvature: None,
1102        }
1103    }
1104}
1105
1106/// Compute ALO diagnostics from model-agnostic inputs.
1107///
1108/// This is the generalized entry point that works for any model type.
1109/// For standard single-block GAMs, prefer `compute_alo_diagnostics_from_fit`
1110/// which automatically extracts the PIRLS geometry (including sandwich SE).
1111pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
1112    compute_alo_from_input_inner(input).map_err(EstimationError::from)
1113}
1114
1115fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
1116    let x_dense = input.design;
1117    let n = x_dense.nrows();
1118    let p = x_dense.ncols();
1119    // Bind the underlying ArrayView1 once so the loop body can index and
1120    // borrow as before; the sign-character contract lives in the
1121    // `AloInput` field types, not in this local binding.
1122    let w_h = input.hessian_weights.view();
1123    let w_s = input.score_weights.view();
1124
1125    validate_alo_solve_setup(input, n, p)?;
1126
1127    let factor = certified_spd_factorize(input.penalized_hessian, "ALO penalized Hessian")
1128        .map_err(|error| AloError::InvalidInput {
1129            reason: format!(
1130                "ALO requires an unperturbed positive-definite penalized Hessian with a certified solve: {error}"
1131            ),
1132        })?;
1133
1134    let xt = x_dense.t();
1135    let phi = input.phi;
1136
1137    let mut aii = Array1::<f64>::zeros(n);
1138    let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
1139    let mut se_bayes = Array1::<f64>::zeros(n);
1140    let mut se_sandwich = Array1::<f64>::zeros(n);
1141
1142    let block_cols = alo_rhs_block_cols(n, p);
1143    // Allocate the RHS scratch in column-major (Fortran) order so its column
1144    // slices are contiguous and align with faer's column-major solve output.
1145    // This removes redundant `xrow = x_dense.row(obs)` indirection inside the
1146    // per-observation loop: rhs_chunk_buf already holds X^T at the right cols.
1147    let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
1148    // Reusable faer column-major buffer for X*S, where S = H^{-1}X_i for the
1149    // current RHS chunk. The sandwich SE uses the same frozen-curvature meat
1150    // as the exact LOO reference, `X' W_S X`, directly; no redundant penalty
1151    // root or ridge surrogate is carried through this API.
1152    let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
1153    let x_dense_view = FaerArrayView::new(x_dense);
1154
1155    for chunk_start in (0..n).step_by(block_cols) {
1156        let chunk_end = (chunk_start + block_cols).min(n);
1157        let width = chunk_end - chunk_start;
1158
1159        rhs_chunk_buf
1160            .slice_mut(s![.., ..width])
1161            .assign(&xt.slice(s![.., chunk_start..chunk_end]));
1162
1163        let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
1164        let rhs_chunk = rhs_chunkview.to_owned();
1165        let (s_chunk, _solve_certificate) = factor.solve_matrix(&rhs_chunk).map_err(|error| {
1166            AloError::LooComputationFailed {
1167                reason: format!(
1168                    "ALO penalized-Hessian solve could not be certified for rows {chunk_start}..{chunk_end}: {error}"
1169                ),
1170            }
1171        })?;
1172        let s_chunk_view = FaerArrayView::new(&s_chunk);
1173
1174        let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
1175        matmul(
1176            xs_target.rb_mut(),
1177            Accum::Replace,
1178            x_dense_view.as_ref(),
1179            s_chunk_view.as_ref(),
1180            1.0,
1181            Par::Seq,
1182        );
1183
1184        let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
1185
1186        for local_col in 0..width {
1187            let obs = chunk_start + local_col;
1188            // The RHS stays column-major; the certified solution is indexed
1189            // with its native ndarray strides so this path does not assume a
1190            // storage order chosen inside the factor API.
1191            let rhs_col = rhs_view.column(local_col);
1192            let solution_col = s_chunk.column(local_col);
1193            let x_hinv_x = spd_quadratic_after_certified_solve(obs, rhs_col, solution_col)?;
1194            // The bread uses the observed Hessian surface. For a non-canonical
1195            // link W_H is signed, so the exact row leverage W_H,i x_i'H^-1x_i
1196            // can be negative even though the assembled penalized H is SPD.
1197            // Projecting that row to zero changes both the ALO denominator and
1198            // corrected predictor; only the separate score-covariance meat is
1199            // PSD (and uses W_S below).
1200            let ai = finite_signed_product(obs, "leverage", w_h[obs], x_hinv_x)?;
1201            aii[obs] = ai;
1202            x_hinv_x_diag[obs] = x_hinv_x;
1203
1204            let var_bayes = finite_nonnegative_product(obs, "Bayesian variance", phi, x_hinv_x)?;
1205            let xs_slice = xs_chunk_storage.col_as_slice(local_col);
1206            // Sandwich meat is the SCORE covariance Xᵀ diag(W_S) X (Fisher,
1207            // PSD by construction), not the observed-information Hessian
1208            // weight W_H. The scale-safe sum preserves that non-negative
1209            // contract without projecting a signed result after the fact.
1210            let meat_quad = finite_weighted_square_sum(obs, w_s, xs_slice)?;
1211            let var_sandwich =
1212                finite_nonnegative_product(obs, "sandwich variance", phi, meat_quad)?;
1213
1214            se_bayes[obs] = var_bayes.sqrt();
1215            se_sandwich[obs] = var_sandwich.sqrt();
1216        }
1217    }
1218
1219    let eta_hat = input.eta;
1220    let z = input.working_response;
1221    let offset = input.offset;
1222
1223    use rayon::prelude::*;
1224    let eta_tilde_vec: Vec<f64> = (0..n)
1225        .into_par_iter()
1226        .map(|i| {
1227            let denom_raw = 1.0 - aii[i];
1228            if denom_raw == 0.0 || !denom_raw.is_finite() {
1229                return Err(AloError::LooComputationFailed {
1230                    reason: format!(
1231                        "ALO deletion denominator is not invertible at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}",
1232                        aii[i], denom_raw
1233                    ),
1234                });
1235            }
1236            let one_step = alo_eta_updatewith_offset(
1237                eta_hat[i],
1238                z[i],
1239                offset[i],
1240                x_hinv_x_diag[i],
1241                w_s[i],
1242                denom_raw,
1243            );
1244            // When the family score/curvature evaluator is supplied, solve the
1245            // exact frozen-curvature leave-i-out fixed point (anchored at η̂_i,
1246            // the basin that limits to the in-sample fit) instead of taking the
1247            // single Newton step. a_ii here is the unweighted influence
1248            // x_i^T H^{-1} x_i (= x_hinv_x_diag[i]); the per-row curvature
1249            // W_H[i] = ℓ_i''(η̂_i) is folded into the scalar fixed point via
1250            // score_curvature. Non-canonical links fall back to `one_step`.
1251            let v = if let Some(score_curvature) = input.score_curvature {
1252                alo_eta_exact_frozen_curvature(
1253                    eta_hat[i],
1254                    x_hinv_x_diag[i],
1255                    &|eta| score_curvature(i, eta),
1256                )
1257                .map_err(|err| AloError::LooComputationFailed {
1258                    reason: format!(
1259                        "ALO exact frozen-curvature solve failed at row {i}: {err}"
1260                    ),
1261                })?
1262            } else {
1263                one_step
1264            };
1265            if !v.is_finite() {
1266                return Err(AloError::LooComputationFailed {
1267                    reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1268                });
1269            }
1270            Ok(v)
1271        })
1272        .collect::<Result<_, _>>()?;
1273    let eta_tilde = Array1::from(eta_tilde_vec);
1274
1275    Ok(AloDiagnostics {
1276        eta_tilde,
1277        se_bayes,
1278        se_sandwich,
1279        leverage: aii,
1280    })
1281}
1282
1283fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1284    let h = input.penalized_hessian;
1285    if h.nrows() != p || h.ncols() != p {
1286        return Err(AloError::InvalidInput {
1287            reason: format!(
1288                "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1289                h.nrows(),
1290                h.ncols()
1291            ),
1292        });
1293    }
1294    let vector_lengths = [
1295        ("hessian_weights", input.hessian_weights.len()),
1296        ("score_weights", input.score_weights.len()),
1297        ("working_response", input.working_response.len()),
1298        ("eta", input.eta.len()),
1299        ("offset", input.offset.len()),
1300    ];
1301    for (name, len) in vector_lengths {
1302        if len != n {
1303            return Err(AloError::InvalidInput {
1304                reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1305            });
1306        }
1307    }
1308    if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1309        return Err(AloError::WeightInvalid {
1310            reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1311        });
1312    }
1313    if let Some((row, value)) = input
1314        .score_weights
1315        .view()
1316        .iter()
1317        .copied()
1318        .enumerate()
1319        .find(|(_, value)| !value.is_finite() || *value < 0.0)
1320    {
1321        return Err(AloError::WeightInvalid {
1322            reason: format!(
1323                "ALO diagnostics require finite non-negative score-side weights; row {row} has {value:?}"
1324            ),
1325        });
1326    }
1327    if input.working_response.iter().any(|v| !v.is_finite()) {
1328        return Err(AloError::WeightInvalid {
1329            reason: "ALO diagnostics require finite working responses".to_string(),
1330        });
1331    }
1332    if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1333        return Err(AloError::InvalidInput {
1334            reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1335        });
1336    }
1337    if !input.phi.is_finite() || input.phi <= 0.0 {
1338        return Err(AloError::InvalidInput {
1339            reason: format!(
1340                "ALO diagnostics require positive finite dispersion phi; got {}",
1341                input.phi
1342            ),
1343        });
1344    }
1345    Ok(())
1346}
1347
1348/// Compute ALO diagnostics (eta_tilde, SE, leverage) from a fitted GAM result.
1349pub fn compute_alo_diagnostics_from_fit(
1350    fit: &UnifiedFitResult,
1351    y: ArrayView1<f64>,
1352) -> Result<AloDiagnostics, EstimationError> {
1353    let pirls = fit
1354        .artifacts
1355        .pirls
1356        .as_ref()
1357        .ok_or_else(|| AloError::InvalidInput {
1358            reason:
1359                "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1360                    .to_string(),
1361        })
1362        .map_err(EstimationError::from)?;
1363    compute_alo_diagnostics_from_pirls_impl(pirls, y)
1364}
1365
1366/// Compute ALO diagnostics from a `UnifiedFitResult`.
1367///
1368/// Extracts `FitGeometry` from `unified.geometry`, pulls the raw row design
1369/// into the persisted active frame, and delegates to `compute_alo_from_input`.
1370/// This avoids requiring a full `UnifiedFitResult` with PIRLS artifacts.
1371pub fn compute_alo_diagnostics_from_unified(
1372    unified: &UnifiedFitResult,
1373    design: &Array2<f64>,
1374    eta: &Array1<f64>,
1375    offset: &Array1<f64>,
1376    phi: f64,
1377) -> Result<AloDiagnostics, EstimationError> {
1378    let geom = unified
1379        .geometry
1380        .as_ref()
1381        .ok_or_else(|| AloError::InvalidInput {
1382            reason: "UnifiedFitResult does not contain working-set geometry; \
1383             ALO diagnostics require geometry at convergence"
1384                .to_string(),
1385        })
1386        .map_err(EstimationError::from)?;
1387    let working = geom.working.as_ref().ok_or_else(|| {
1388        EstimationError::from(AloError::InvalidInput {
1389            reason: "UnifiedFitResult coefficient geometry has no owned single-diagonal working evidence; ALO diagnostics are unavailable for Exact-Newton and multi-parameter terminal geometry"
1390                .to_string(),
1391        })
1392    })?;
1393    geom.coefficient_gauge
1394        .validate()
1395        .map_err(|reason| AloError::InvalidInput {
1396            reason: format!("UnifiedFitResult ALO coefficient gauge is invalid: {reason}"),
1397        })
1398        .map_err(EstimationError::from)?;
1399    if design.ncols() != geom.coefficient_gauge.raw_total() {
1400        return Err(AloError::InvalidInput {
1401            reason: format!(
1402                "UnifiedFitResult ALO raw design has {} columns; coefficient gauge requires {}",
1403                design.ncols(),
1404                geom.coefficient_gauge.raw_total(),
1405            ),
1406        }
1407        .into());
1408    }
1409    let active_design = geom.coefficient_gauge.restrict_design(design);
1410    let input =
1411        AloInput::from_active_geometry(geom, working, &active_design, eta, offset, phi);
1412    compute_alo_from_input(&input)
1413}
1414
1415/// Compute ALO diagnostics from a PIRLS result for lower-level callers.
1416pub fn compute_alo_diagnostics_from_pirls(
1417    base: &pirls::PirlsResult,
1418    y: ArrayView1<f64>,
1419) -> Result<AloDiagnostics, EstimationError> {
1420    compute_alo_diagnostics_from_pirls_impl(base, y)
1421}
1422
1423/// Exact (one-step) case-deletion influence from a converged PIRLS fit, via
1424/// the one `FitSensitivity` operator (#935).
1425///
1426/// This is the diagnostic the sensitivity operator's `case_deletion` channel
1427/// was built to expose but had no production entry point for: per-observation
1428/// dfbetas `β̂ − β̂₍ᵢ₎`, hat-value leverage `h_ii = w_i x_iᵀ H⁻¹ x_i`, and
1429/// Cook's distance. It is the same factored inverse the REML gradient (IFT),
1430/// ALO, and the Riesz debias already contract — built once at the optimum,
1431/// asked in the leave-one-out direction — so no call site can disagree about
1432/// which `H⁻¹` is meant (the bug class #935 dismantles).
1433///
1434/// The penalized Hessian, design, working weights `w_i = W_H[i]` and working
1435/// residual `z_i − η̂_i` are read straight from the converged geometry — the
1436/// same PIRLS state [`compute_alo_diagnostics_from_pirls`] consumes — so the
1437/// IRLS reduction `scale = w_i r_i / (1 − h_ii)` is exact for the Gaussian
1438/// identity link and the one-step Newton deletion for canonical-link GLMs.
1439/// Returns `None` (rather than emitting `∞`) for any observation whose
1440/// leverage is one, or if the dense Hessian / design is unavailable.
1441pub fn compute_case_deletion_from_pirls(
1442    base: &pirls::PirlsResult,
1443) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1444    let x_dense_arc = base
1445        .x_transformed
1446        .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1447        .map_err(|reason| EstimationError::InvalidInput(reason))?;
1448    let x_dense = x_dense_arc.as_ref();
1449    let n = x_dense.nrows();
1450    let p = x_dense.ncols();
1451    if n == 0 || p == 0 {
1452        return Ok(None);
1453    }
1454
1455    let phi = alo_covariance_scale(base).map_err(EstimationError::from)?;
1456
1457    // The same dense stabilized penalized Hessian ALO materializes; the one
1458    // factored inverse every sensitivity channel shares.
1459    let h_dense = base
1460        .dense_stabilizedhessian_transformed(
1461            "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1462        )
1463        .map_err(|e| match e {
1464            EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1465            other => EstimationError::InvalidInput(format!("{other:?}")),
1466        })?;
1467
1468    let factor = match h_dense.cholesky(faer::Side::Lower) {
1469        Ok(f) => f,
1470        // A non-SPD stabilized Hessian means the optimum is rank-deficient in a
1471        // way the dense Cholesky case-deletion path cannot invert; decline
1472        // rather than fabricate an influence diagnostic.
1473        Err(_) => return Ok(None),
1474    };
1475
1476    // Working weights and working residual straight from the IRLS reduction:
1477    // w_i = W_H[i] and r_i = z_i − η̂_i, so w_i r_i is the working score the
1478    // closed-form deletion `scale = w_i r_i / (1 − h_ii)` consumes.
1479    let working_weights = base.finalweights.clone();
1480    let working_residual = &base.solveworking_response - &base.final_eta;
1481
1482    let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1483    Ok(sensitivity.case_deletion(
1484        x_dense,
1485        working_weights.view(),
1486        working_residual.view(),
1487        phi,
1488    ))
1489}
1490
1491// Multi-block ALO for multi-predictor models (GAMLSS, survival, joint)
1492
1493/// Diagnostics returned by multi-block ALO.
1494#[derive(Debug, Clone)]
1495pub struct MultiBlockAloDiagnostics {
1496    /// Corrected linear predictors η̃^{(-i)} for each observation.
1497    /// Outer length = n_obs, inner length = n_coordinates (B).
1498    pub eta_tilde: Vec<Array1<f64>>,
1499    /// Per-observation leverage tr(H_ii) where H_ii is the B×B hat-matrix block.
1500    pub leverage: Array1<f64>,
1501    /// Per-observation ALO variance diagonals: for each observation i,
1502    /// Var(Δη_i) ≈ A_i (I - W_i A_i)⁻¹ C_i (I - A_i W_i)⁻¹ A_iᵀ,
1503    /// where C_i is the score covariance (not assumed equal to W_i).
1504    /// Outer length = n_obs, inner length = n_coordinates (B) containing the
1505    /// diagonal entries of the variance matrix.
1506    pub alo_variance: Vec<Array1<f64>>,
1507    /// Model-based posterior predictive variance of each coordinate at each
1508    /// observation: `diag(A_i) = x_{d,i}ᵀ [H⁻¹] x_{d,i}` (unit dispersion; the
1509    /// caller scales by φ where applicable). Unlike [`Self::alo_variance`] —
1510    /// which for a single deleted row with the rank-1 self-score covariance
1511    /// collapses EXACTLY to the squared deletion correction `Δη²` and is
1512    /// therefore an influence magnitude, not an uncertainty — this is the
1513    /// genuine posterior uncertainty of the fitted coordinate, and the
1514    /// principled scale for judging how far an exact LOO refit (which also
1515    /// reselects smoothing on n−1 rows) may legitimately sit from the
1516    /// fixed-smoothing ALO point (#2301).
1517    /// Outer length = n_obs, inner length = n_coordinates (B).
1518    pub predictive_variance: Vec<Array1<f64>>,
1519    /// Cook-type ALO influence: D_i = Δη_iᵀ C_i Δη_i.
1520    /// Length = n_obs.
1521    pub cook_distance: Array1<f64>,
1522}
1523
1524/// Model-agnostic input for multi-predictor ALO diagnostics.
1525///
1526/// Generalises [`AloInput`] to models with B > 1 linear predictors per
1527/// observation (e.g. location-scale GAMLSS with B=2, or survival models
1528/// with time-dependent predictors).
1529///
1530/// # Mathematical setup
1531///
1532/// For observation i the per-observation Jacobian is a B × p_tot matrix X_i.
1533/// Row b embeds row i of `coordinate_designs[b]` at
1534/// `coordinate_coefficient_ranges[b]`. Ranges may overlap: this is required
1535/// for risk-set and latent-variable coordinates that share coefficients. The
1536/// joint hat-matrix block is
1537///
1538///   H_ii = X_i H⁻¹ X_iᵀ W_i     (B × B)
1539///
1540/// where H = Σ_i X_iᵀ W_i X_i + S is the total penalized Hessian and W_i
1541/// is the B × B per-observation weight matrix (negative Hessian of the
1542/// log-likelihood w.r.t. the B predictors at observation i).
1543///
1544/// The ALO leave-one-out correction is
1545///
1546///   Δη_i^ALO = A_i (I_B − W_i A_i)⁻¹ s_i
1547///
1548/// where A_i = X_i H⁻¹ X_iᵀ (the B×B per-observation influence matrix),
1549/// W_i is the B×B per-observation NLL Hessian, and
1550/// s_i = ∇_{η_i} NLL_i(η̂_i) is the B-dimensional score vector.
1551/// This is algebraically equivalent to (I_B − H_ii)⁻¹ H_ii W_i⁻¹ s_i
1552/// but does NOT require W_i⁻¹, which is critical when W_i is singular
1553/// (e.g. at boundary observations in survival models).
1554/// For B = 1 this reduces to the classical scalar ALO formula.
1555pub struct MultiBlockAloInput<'a> {
1556    /// Number of observations.
1557    pub n_obs: usize,
1558    /// Number of local likelihood coordinates per observation (B).
1559    pub n_coordinates: usize,
1560    /// B possibly operator-backed local design matrices, each n_obs × p_b.
1561    /// ALO materializes only bounded row chunks.
1562    pub coordinate_designs: &'a [DesignMatrix],
1563    /// Parameter alignment for each local design. Range b has length p_b and
1564    /// identifies the columns of the saved p_tot-dimensional Hessian touched
1565    /// by coordinate b. Ranges may overlap and need not cover every parameter.
1566    pub coordinate_coefficient_ranges: &'a [Range<usize>],
1567    /// Exact unscaled penalized Hessian H (p_tot × p_tot). ALO factors this
1568    /// matrix once and certifies blocked solves; it never materializes H⁻¹.
1569    pub penalized_hessian: &'a Array2<f64>,
1570    /// Per-observation observed NLL Hessians W_i (B × B). These drive the
1571    /// deletion denominator and may be indefinite even when H is SPD.
1572    pub observed_hessians: &'a [Array2<f64>],
1573    /// Per-observation score covariance matrices C_i (B × B). These drive
1574    /// variance and Cook influence, and must be positive semidefinite.
1575    pub score_covariances: &'a [Array2<f64>],
1576    /// Per-observation score vectors s_i = ∇_{η_i} NLL_i.  Length = n_obs,
1577    /// each entry is B-dimensional.
1578    pub scores: &'a [Array1<f64>],
1579    /// Fitted local-coordinate vectors η̂_i. Length = n_obs, each entry is
1580    /// B-dimensional. These are the exact row arguments paired with the
1581    /// coordinate designs; they need not be response means.
1582    pub coordinate_values: &'a [Array1<f64>],
1583}
1584
1585/// Compute multi-block ALO diagnostics: corrected η̃ and leverages.
1586///
1587/// # Optimisation note
1588///
1589/// The dominant cost is forming X_i H⁻¹ X_iᵀ for every observation.
1590/// Rather than forming the B × p_tot row-block X_i and multiplying naïvely,
1591/// we solve for each coordinate b and bounded row chunk the matrix
1592///
1593///   H Q_b = X_bᵀ      (p_tot × chunk)
1594///
1595/// Then the (a, b) entry of the B × B matrix X_i H⁻¹ X_iᵀ is simply
1596///
1597///   (X_i H⁻¹ X_iᵀ)_{a,b} = x_{a,i}ᵀ Q_b\[:,i\]
1598///                           = Σ_k  X_a\[i,k\] · Q_b\[k,i\]
1599///
1600/// where x_{a,i} is the i-th row of coordinate-design a. This turns the per-
1601/// observation work from O(B · p_tot²) into O(B² · p_tot), and the
1602/// solve stays bounded without forming a dense inverse or an n × p_tot panel.
1603pub fn compute_multiblock_alo(
1604    input: &MultiBlockAloInput,
1605) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1606    compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1607}
1608
1609fn validate_multiblock_alo_input(input: &MultiBlockAloInput<'_>) -> Result<(), AloError> {
1610    let n = input.n_obs;
1611    let b = input.n_coordinates;
1612    if n == 0 || b == 0 {
1613        return Err(AloError::InvalidInput {
1614            reason: format!(
1615                "multi-block ALO requires positive observation and coordinate counts; got n={n}, B={b}"
1616            ),
1617        });
1618    }
1619    if input.coordinate_designs.len() != b {
1620        return Err(AloError::InvalidInput {
1621            reason: format!(
1622                "multi-block ALO expected {b} coordinate designs, got {}",
1623                input.coordinate_designs.len()
1624            ),
1625        });
1626    }
1627    let p_tot = input.penalized_hessian.nrows();
1628    if input.penalized_hessian.ncols() != p_tot || p_tot == 0 {
1629        return Err(AloError::InvalidInput {
1630            reason: format!(
1631                "multi-block ALO penalized Hessian must be non-empty and square; got {}x{}",
1632                input.penalized_hessian.nrows(),
1633                input.penalized_hessian.ncols()
1634            ),
1635        });
1636    }
1637    if input.coordinate_coefficient_ranges.len() != b {
1638        return Err(AloError::InvalidInput {
1639            reason: format!(
1640                "multi-block ALO expected {b} coordinate coefficient ranges, got {}",
1641                input.coordinate_coefficient_ranges.len()
1642            ),
1643        });
1644    }
1645    for (coordinate, (design, coefficient_range)) in input
1646        .coordinate_designs
1647        .iter()
1648        .zip(input.coordinate_coefficient_ranges)
1649        .enumerate()
1650    {
1651        if design.nrows() != n {
1652            return Err(AloError::InvalidInput {
1653                reason: format!(
1654                    "multi-block ALO coordinate design {coordinate} has {} rows; expected {n}",
1655                    design.nrows()
1656                ),
1657            });
1658        }
1659        if design.ncols() == 0 || coefficient_range.is_empty() {
1660            return Err(AloError::InvalidInput {
1661                reason: format!(
1662                    "multi-block ALO coordinate {coordinate} has an empty local design or coefficient range"
1663                ),
1664            });
1665        }
1666        if coefficient_range.len() != design.ncols() || coefficient_range.end > p_tot {
1667            return Err(AloError::InvalidInput {
1668                reason: format!(
1669                    "multi-block ALO coordinate {coordinate} design has {} columns but parameter range {}..{} has length {} in a {p_tot}-dimensional saved Hessian",
1670                    design.ncols(),
1671                    coefficient_range.start,
1672                    coefficient_range.end,
1673                    coefficient_range.len()
1674                ),
1675            });
1676        }
1677    }
1678    for (label, length) in [
1679        ("observed_hessians", input.observed_hessians.len()),
1680        ("score_covariances", input.score_covariances.len()),
1681        ("scores", input.scores.len()),
1682        ("coordinate_values", input.coordinate_values.len()),
1683    ] {
1684        if length != n {
1685            return Err(AloError::InvalidInput {
1686                reason: format!("multi-block ALO requires {label} length {n}; got {length}"),
1687            });
1688        }
1689    }
1690    for row in 0..n {
1691        let observed = &input.observed_hessians[row];
1692        let score_covariance = &input.score_covariances[row];
1693        for (label, matrix) in [
1694            ("observed Hessian", observed),
1695            ("score covariance", score_covariance),
1696        ] {
1697            if matrix.dim() != (b, b) {
1698                return Err(AloError::InvalidInput {
1699                    reason: format!(
1700                        "multi-block ALO row {row} {label} has shape {}x{}; expected {b}x{b}",
1701                        matrix.nrows(),
1702                        matrix.ncols()
1703                    ),
1704                });
1705            }
1706            validate_finite_symmetric_matrix(matrix, &format!("multi-block ALO row {row} {label}"))
1707                .map_err(|error| AloError::InvalidInput {
1708                    reason: error.to_string(),
1709                })?;
1710        }
1711        let covariance_scale = score_covariance
1712            .iter()
1713            .fold(0.0_f64, |scale, value| scale.max(value.abs()));
1714        let (minimum, maximum) =
1715            symmetric_extremes(score_covariance).ok_or_else(|| AloError::InvalidInput {
1716                reason: format!(
1717                    "multi-block ALO row {row} score-covariance eigendecomposition failed"
1718                ),
1719            })?;
1720        let psd_tolerance = LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR
1721            * b as f64
1722            * f64::EPSILON
1723            * covariance_scale.max(maximum.abs());
1724        if minimum < -psd_tolerance {
1725            return Err(AloError::InvalidInput {
1726                reason: format!(
1727                    "multi-block ALO row {row} score covariance is not positive semidefinite: minimum eigenvalue {minimum:.6e}, roundoff allowance {psd_tolerance:.6e}"
1728                ),
1729            });
1730        }
1731        for (label, vector) in [
1732            ("score", &input.scores[row]),
1733            ("coordinate value", &input.coordinate_values[row]),
1734        ] {
1735            if vector.len() != b {
1736                return Err(AloError::InvalidInput {
1737                    reason: format!(
1738                        "multi-block ALO row {row} {label} has length {}; expected {b}",
1739                        vector.len()
1740                    ),
1741                });
1742            }
1743            if let Some((coordinate, value)) = vector
1744                .iter()
1745                .copied()
1746                .enumerate()
1747                .find(|(_, value)| !value.is_finite())
1748            {
1749                return Err(AloError::InvalidInput {
1750                    reason: format!(
1751                        "multi-block ALO row {row} {label} coordinate {coordinate} is non-finite: {value}"
1752                    ),
1753                });
1754            }
1755        }
1756    }
1757    Ok(())
1758}
1759
1760fn compute_multiblock_alo_inner(
1761    input: &MultiBlockAloInput,
1762) -> Result<MultiBlockAloDiagnostics, AloError> {
1763    use rayon::prelude::*;
1764
1765    let n = input.n_obs;
1766    let b = input.n_coordinates;
1767    let p_tot = input.penalized_hessian.nrows();
1768    validate_multiblock_alo_input(input)?;
1769    let factor = certified_spd_factorize(input.penalized_hessian, "multi-block ALO penalized Hessian")
1770        .map_err(|error| AloError::InvalidInput {
1771            reason: format!(
1772                "multi-block ALO requires an unperturbed positive-definite saved penalized Hessian: {error}"
1773            ),
1774        })?;
1775
1776    let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1777    let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1778
1779    // Each Rayon worker owns its small B×B/B-vector scratch buffers via
1780    // `map_init`, avoiding cross-thread mutation and avoiding per-observation
1781    // allocations.  The much larger Q panels are bounded by the parallel chunk
1782    // size and by wave-level concurrency, so at most roughly one global memory
1783    // budget worth of p_total × chunk_len panels can be live across workers.
1784    let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1785        Vec::with_capacity(chunk_starts.len());
1786    for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1787        let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1788            .par_iter()
1789            .map_init(
1790                || MultiBlockAloScratch::new(b),
1791                |scratch, &chunk_start| {
1792                    let chunk_end = (chunk_start + chunk_size).min(n);
1793                    compute_multiblock_alo_chunk(input, &factor, chunk_start, chunk_end, scratch)
1794                },
1795            )
1796            .collect();
1797        chunk_results.append(&mut wave_results);
1798    }
1799
1800    let mut eta_tilde = Vec::with_capacity(n);
1801    let mut leverage = Array1::<f64>::zeros(n);
1802    let mut alo_variance = Vec::with_capacity(n);
1803    let mut predictive_variance = Vec::with_capacity(n);
1804    let mut cook_distance = Array1::<f64>::zeros(n);
1805
1806    let mut chunks = Vec::with_capacity(chunk_results.len());
1807    for result in chunk_results {
1808        chunks.push(result?);
1809    }
1810    chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1811
1812    for chunk in chunks {
1813        let chunk_start = chunk.chunk_start;
1814        eta_tilde.extend(chunk.eta_tilde);
1815        alo_variance.extend(chunk.alo_variance);
1816        predictive_variance.extend(chunk.predictive_variance);
1817        for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1818            leverage[chunk_start + local_i] = lev;
1819        }
1820        for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1821            cook_distance[chunk_start + local_i] = cook;
1822        }
1823    }
1824
1825    Ok(MultiBlockAloDiagnostics {
1826        eta_tilde,
1827        leverage,
1828        alo_variance,
1829        predictive_variance,
1830        cook_distance,
1831    })
1832}
1833
1834#[inline]
1835fn multiblock_alo_parallel_plan(
1836    p_tot: usize,
1837    n_coordinates: usize,
1838    n_obs: usize,
1839) -> (usize, usize) {
1840    if p_tot == 0 || n_coordinates == 0 || n_obs == 0 {
1841        return (1, 1);
1842    }
1843    // Each live row keeps one p-vector in the materialized Jacobian chunk and
1844    // one in its certified solution, for every local coordinate.
1845    let bytes_per_obs = p_tot
1846        .saturating_mul(n_coordinates)
1847        .saturating_mul(2)
1848        .saturating_mul(std::mem::size_of::<f64>())
1849        .max(1);
1850    let workers = rayon::current_num_threads().max(1);
1851    let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1852        .max(1)
1853        .min(workers);
1854    let per_worker_budget =
1855        (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1856    let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1857    (budget_obs.min(n_obs), max_concurrent_chunks)
1858}
1859
1860struct MultiBlockAloScratch {
1861    a_i: Vec<f64>,
1862    wa: Vec<f64>,
1863    aw: Vec<f64>,
1864    imwa: Vec<f64>,
1865    imaw: Vec<f64>,
1866    perm_imwa: Vec<usize>,
1867    perm_imaw: Vec<usize>,
1868    delta_eta: Vec<f64>,
1869    rhs_buf: Vec<f64>,
1870    covariance_u: Vec<f64>,
1871    var_diag_buf: Vec<f64>,
1872    w_flat: Vec<f64>,
1873    covariance_flat: Vec<f64>,
1874    lu_scratch: Vec<f64>,
1875    original_rhs: Vec<f64>,
1876}
1877
1878impl MultiBlockAloScratch {
1879    fn new(b: usize) -> Self {
1880        let bb_sz = b * b;
1881        Self {
1882            a_i: vec![0.0f64; bb_sz],
1883            wa: vec![0.0f64; bb_sz],
1884            aw: vec![0.0f64; bb_sz],
1885            imwa: vec![0.0f64; bb_sz],
1886            imaw: vec![0.0f64; bb_sz],
1887            perm_imwa: vec![0usize; b],
1888            perm_imaw: vec![0usize; b],
1889            delta_eta: vec![0.0f64; b],
1890            rhs_buf: vec![0.0f64; b],
1891            covariance_u: vec![0.0f64; b],
1892            var_diag_buf: vec![0.0f64; b],
1893            w_flat: vec![0.0f64; bb_sz],
1894            covariance_flat: vec![0.0f64; bb_sz],
1895            lu_scratch: vec![0.0f64; b],
1896            original_rhs: vec![0.0f64; b],
1897        }
1898    }
1899}
1900
1901struct MultiBlockAloChunkDiagnostics {
1902    chunk_start: usize,
1903    eta_tilde: Vec<Array1<f64>>,
1904    leverage: Vec<f64>,
1905    alo_variance: Vec<Array1<f64>>,
1906    predictive_variance: Vec<Array1<f64>>,
1907    cook_distance: Vec<f64>,
1908}
1909
1910fn compute_multiblock_alo_chunk(
1911    input: &MultiBlockAloInput,
1912    factor: &CertifiedSpdFactor<'_>,
1913    chunk_start: usize,
1914    chunk_end: usize,
1915    scratch: &mut MultiBlockAloScratch,
1916) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1917    let b = input.n_coordinates;
1918    let p_tot = input.penalized_hessian.nrows();
1919    let chunk_len = chunk_end - chunk_start;
1920
1921    let mut design_chunks = Vec::with_capacity(b);
1922    let mut q_blocks = Vec::with_capacity(b);
1923    for coordinate in 0..b {
1924        let design_chunk = input.coordinate_designs[coordinate]
1925            .try_row_chunk(chunk_start..chunk_end)
1926            .map_err(|reason| AloError::DesignDegenerate {
1927                reason: format!(
1928                    "multi-block ALO could not materialize coordinate {coordinate} rows {chunk_start}..{chunk_end}: {reason}"
1929                ),
1930            })?;
1931        if let Some(((row, column), value)) = design_chunk
1932            .indexed_iter()
1933            .map(|(index, &value)| (index, value))
1934            .find(|(_, value)| !value.is_finite())
1935        {
1936            return Err(AloError::DesignDegenerate {
1937                reason: format!(
1938                    "multi-block ALO coordinate {coordinate} design is non-finite at source row {}, column {column}: {value}",
1939                    chunk_start + row
1940                ),
1941            });
1942        }
1943        let coefficient_range = input.coordinate_coefficient_ranges[coordinate].clone();
1944        let mut rhs = Array2::<f64>::zeros((p_tot, chunk_len));
1945        rhs.slice_mut(s![coefficient_range, ..])
1946            .assign(&design_chunk.t());
1947        let (solution, _) = factor.solve_matrix(&rhs).map_err(|error| {
1948            AloError::LooComputationFailed {
1949                reason: format!(
1950                    "multi-block ALO saved-Hessian solve failed for coordinate {coordinate}, rows {chunk_start}..{chunk_end}: {error}"
1951                ),
1952            }
1953        })?;
1954        design_chunks.push(design_chunk);
1955        q_blocks.push(solution);
1956    }
1957
1958    let mut eta_tilde = Vec::with_capacity(chunk_len);
1959    let mut leverage = vec![0.0f64; chunk_len];
1960    let mut alo_variance = Vec::with_capacity(chunk_len);
1961    let mut predictive_variance = Vec::with_capacity(chunk_len);
1962    let mut cook_distance = vec![0.0f64; chunk_len];
1963
1964    for local_i in 0..chunk_len {
1965        let i = chunk_start + local_i;
1966        let w_i = &input.observed_hessians[i];
1967        let covariance_i = &input.score_covariances[i];
1968
1969        // Flatten the distinct observed-Hessian and score-covariance surfaces
1970        // once per observation (row-major).
1971        for r in 0..b {
1972            for c in 0..b {
1973                scratch.w_flat[r * b + c] = w_i[(r, c)];
1974                scratch.covariance_flat[r * b + c] = covariance_i[(r, c)];
1975            }
1976        }
1977
1978        // --- Assemble A_i = X_i H⁻¹ X_iᵀ  (B × B), row-major flat. ---
1979        for a in 0..b {
1980            let x_a = &design_chunks[a];
1981            let p_a = x_a.ncols();
1982            let off_a = input.coordinate_coefficient_ranges[a].start;
1983            let xa_row = x_a.row(local_i);
1984            for bb in 0..b {
1985                let q_bb = &q_blocks[bb];
1986                let mut dot = 0.0f64;
1987                for k in 0..p_a {
1988                    dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1989                }
1990                scratch.a_i[a * b + bb] = dot;
1991            }
1992        }
1993
1994        // diag(A_i): the coordinate-wise posterior predictive variance
1995        // x_dᵀ H⁻¹ x_d (unit dispersion), captured before A_i is consumed by
1996        // the deletion algebra below. Clamped at zero: A_i is a Gram diagonal
1997        // of the SPD-certified H⁻¹, so a negative entry is pure roundoff.
1998        let mut pred_var = Array1::<f64>::zeros(b);
1999        for d in 0..b {
2000            pred_var[d] = scratch.a_i[d * b + d].max(0.0);
2001        }
2002        predictive_variance.push(pred_var);
2003
2004        // WA = W_i · A_i (row-major).
2005        mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
2006        // AW = A_i · W_i (row-major).
2007        mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
2008
2009        // Trace of H_ii = A_i W_i (= AW): leverage[i].
2010        // (Original code wrote H_ii = A · W — the same operator we already have in `aw`.)
2011        let mut tr = 0.0f64;
2012        for d in 0..b {
2013            tr += scratch.aw[d * b + d];
2014        }
2015        leverage[local_i] = tr;
2016
2017        // Build (I - W A) and (I - A W) into imwa/imaw.
2018        for r in 0..b {
2019            for c in 0..b {
2020                let idx = r * b + c;
2021                let id = if r == c { 1.0 } else { 0.0 };
2022                scratch.imwa[idx] = id - scratch.wa[idx];
2023                scratch.imaw[idx] = id - scratch.aw[idx];
2024            }
2025        }
2026
2027        // A singular frozen-H deletion system is diagnostic information, not a
2028        // request to alter the estimand with a local ridge. The uncertainty in
2029        // `I - product` is governed by the magnitudes of the two multiplicands,
2030        // even when their product cancels the identity almost completely. A
2031        // tolerance scaled only by the already-cancelled matrix would erase
2032        // exactly the information needed to recognize unit deletion leverage.
2033        let imwa_tolerance =
2034            identity_minus_product_lu_tolerance(&scratch.w_flat, &scratch.a_i, &scratch.wa, b)?;
2035        if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b, imwa_tolerance) {
2036            return Err(AloError::LooComputationFailed {
2037                reason: format!(
2038                    "multi-block ALO deletion system I-WA is singular at row {i}; local pivot allowance {imwa_tolerance:.6e}, leverage trace {:.6e}",
2039                    leverage[local_i]
2040                ),
2041            });
2042        }
2043        let imaw_tolerance =
2044            identity_minus_product_lu_tolerance(&scratch.a_i, &scratch.w_flat, &scratch.aw, b)?;
2045        if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b, imaw_tolerance) {
2046            return Err(AloError::LooComputationFailed {
2047                reason: format!(
2048                    "multi-block ALO transpose deletion system I-AW is singular at row {i}; local pivot allowance {imaw_tolerance:.6e}, leverage trace {:.6e}",
2049                    leverage[local_i]
2050                ),
2051            });
2052        }
2053
2054        // v_i = (I - W A)⁻¹ s_i  -- solve into rhs_buf.
2055        let s_i = &input.scores[i];
2056        for k in 0..b {
2057            scratch.rhs_buf[k] = s_i[k];
2058        }
2059        if let Err(failure) = solve_identity_minus_product_in_place(
2060            &scratch.imwa,
2061            &scratch.perm_imwa,
2062            &scratch.wa,
2063            &mut scratch.rhs_buf,
2064            &mut scratch.lu_scratch,
2065            &mut scratch.original_rhs,
2066            imwa_tolerance,
2067            b,
2068        ) {
2069            return Err(AloError::LooComputationFailed {
2070                reason: format!(
2071                    "multi-block ALO deletion solve I-WA failed backward-error certification at row {i}: residual {:.6e}, allowance {:.6e}",
2072                    failure.residual_norm, failure.allowance
2073                ),
2074            });
2075        }
2076        // delta_eta = A_i · v_i
2077        for r in 0..b {
2078            let mut acc = 0.0f64;
2079            let row_off = r * b;
2080            for k in 0..b {
2081                acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
2082            }
2083            scratch.delta_eta[r] = acc;
2084        }
2085
2086        let eta_i = &input.coordinate_values[i];
2087        let mut corrected = Array1::<f64>::zeros(b);
2088        for d in 0..b {
2089            corrected[d] = eta_i[d] + scratch.delta_eta[d];
2090            if !scratch.delta_eta[d].is_finite() || !corrected[d].is_finite() {
2091                return Err(AloError::LooComputationFailed {
2092                    reason: format!(
2093                        "multi-block ALO correction is non-finite at row {i}, coordinate {d}: delta={}, corrected={}",
2094                        scratch.delta_eta[d], corrected[d]
2095                    ),
2096                });
2097            }
2098        }
2099        eta_tilde.push(corrected);
2100
2101        // Cook's distance uses score covariance, not observed curvature.
2102        let mut cook = 0.0f64;
2103        let mut cook_scale = 0.0f64;
2104        for r in 0..b {
2105            let mut covariance_delta_r = 0.0f64;
2106            let row_off = r * b;
2107            for k in 0..b {
2108                covariance_delta_r += scratch.covariance_flat[row_off + k] * scratch.delta_eta[k];
2109            }
2110            let term = scratch.delta_eta[r] * covariance_delta_r;
2111            cook += term;
2112            cook_scale += term.abs();
2113        }
2114        let cook_tolerance =
2115            LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * cook_scale;
2116        if !cook.is_finite() || cook < -cook_tolerance {
2117            return Err(AloError::LooComputationFailed {
2118                reason: format!(
2119                    "multi-block ALO Cook influence is invalid at row {i}: value {cook:.6e}, roundoff allowance {cook_tolerance:.6e}"
2120                ),
2121            });
2122        }
2123        cook_distance[local_i] = cook.max(0.0);
2124
2125        // var_diag[d] = a_d^T (I-WA)⁻¹ C (I-AW)⁻¹ a_d
2126        // where a_d is the d-th row of A_i.
2127        // Reuses already-factored imwa and imaw (one LU factorization each, reused
2128        // across all B right-hand sides — major saving over the original which redid
2129        // both LU decompositions B times per observation).
2130        for d in 0..b {
2131            let row_off = d * b;
2132            // u_d = (I - A W)⁻¹ a_d
2133            for k in 0..b {
2134                scratch.rhs_buf[k] = scratch.a_i[row_off + k];
2135            }
2136            if let Err(failure) = solve_identity_minus_product_in_place(
2137                &scratch.imaw,
2138                &scratch.perm_imaw,
2139                &scratch.aw,
2140                &mut scratch.rhs_buf,
2141                &mut scratch.lu_scratch,
2142                &mut scratch.original_rhs,
2143                imaw_tolerance,
2144                b,
2145            ) {
2146                return Err(AloError::LooComputationFailed {
2147                    reason: format!(
2148                        "multi-block ALO transpose variance solve I-AW failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2149                        failure.residual_norm, failure.allowance
2150                    ),
2151                });
2152            }
2153            // covariance_u = C u_d
2154            for r in 0..b {
2155                let mut acc = 0.0f64;
2156                let wr = r * b;
2157                for k in 0..b {
2158                    acc += scratch.covariance_flat[wr + k] * scratch.rhs_buf[k];
2159                }
2160                scratch.covariance_u[r] = acc;
2161            }
2162            // t_d = (I - W A)⁻¹ C u_d.
2163            if let Err(failure) = solve_identity_minus_product_in_place(
2164                &scratch.imwa,
2165                &scratch.perm_imwa,
2166                &scratch.wa,
2167                &mut scratch.covariance_u,
2168                &mut scratch.lu_scratch,
2169                &mut scratch.original_rhs,
2170                imwa_tolerance,
2171                b,
2172            ) {
2173                return Err(AloError::LooComputationFailed {
2174                    reason: format!(
2175                        "multi-block ALO variance solve I-WA failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2176                        failure.residual_norm, failure.allowance
2177                    ),
2178                });
2179            }
2180            // v_dd = a_d^T t_d
2181            let mut v_dd = 0.0f64;
2182            for k in 0..b {
2183                v_dd += scratch.a_i[row_off + k] * scratch.covariance_u[k];
2184            }
2185            let variance_scale = scratch.a_i[row_off..row_off + b]
2186                .iter()
2187                .zip(scratch.covariance_u.iter())
2188                .map(|(left, right)| (left * right).abs())
2189                .sum::<f64>();
2190            let variance_tolerance =
2191                LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * variance_scale;
2192            if !v_dd.is_finite() || v_dd < -variance_tolerance {
2193                return Err(AloError::LooComputationFailed {
2194                    reason: format!(
2195                        "multi-block ALO variance is invalid at row {i}, coordinate {d}: value {v_dd:.6e}, roundoff allowance {variance_tolerance:.6e}"
2196                    ),
2197                });
2198            }
2199            scratch.var_diag_buf[d] = v_dd.max(0.0);
2200        }
2201        let mut var_diag = Array1::<f64>::zeros(b);
2202        for d in 0..b {
2203            var_diag[d] = scratch.var_diag_buf[d];
2204        }
2205        alo_variance.push(var_diag);
2206    }
2207
2208    Ok(MultiBlockAloChunkDiagnostics {
2209        chunk_start,
2210        eta_tilde,
2211        leverage,
2212        alo_variance,
2213        predictive_variance,
2214        cook_distance,
2215    })
2216}
2217
2218/// B × B row-major matmul: out = a · b.
2219#[inline]
2220fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
2221    for r in 0..b {
2222        let ar = r * b;
2223        let or = r * b;
2224        for c in 0..b {
2225            let mut acc = 0.0f64;
2226            for k in 0..b {
2227                acc += a[ar + k] * b_mat[k * b + c];
2228            }
2229            out[or + c] = acc;
2230        }
2231    }
2232}
2233
2234/// Standard `gamma_n = n*u/(1-n*u)` bound for `n` rounded operations, where
2235/// binary64 unit roundoff under round-to-nearest is `u = eps/2`.
2236#[inline]
2237fn floating_point_gamma(operation_count: usize) -> f64 {
2238    let accumulated = operation_count as f64 * (0.5 * f64::EPSILON);
2239    if accumulated < 1.0 {
2240        accumulated / (1.0 - accumulated)
2241    } else {
2242        f64::INFINITY
2243    }
2244}
2245
2246/// Pivot allowance for a row-major `I - left * right` local system.
2247///
2248/// The scale is `max(||I-left*right||_inf,
2249/// 1 + || |left||right| ||_inf)`: the second operand-derived term is the
2250/// magnitude envelope before cancellation, while the first retains the actual
2251/// operator scale when it is larger. Forming every product entry takes at most
2252/// `2B` rounded multiply/add operations and subtracting it from the identity
2253/// takes one more. The LU term accounts for the division/multiply/subtract chain
2254/// along at most `B` partial-pivoting elimination stages. The resulting bound
2255/// cannot collapse merely because `left * right` rounded close to the identity.
2256fn identity_minus_product_lu_tolerance(
2257    left: &[f64],
2258    right: &[f64],
2259    product: &[f64],
2260    b: usize,
2261) -> Result<f64, AloError> {
2262    let expected_len = b.checked_mul(b).ok_or_else(|| AloError::InvalidInput {
2263        reason: format!(
2264            "multi-block ALO local deletion dimension B={b} overflows the square matrix size"
2265        ),
2266    })?;
2267    for (name, actual_len) in [
2268        ("left operand", left.len()),
2269        ("right operand", right.len()),
2270        ("precomputed product", product.len()),
2271    ] {
2272        if actual_len != expected_len {
2273            return Err(AloError::InvalidInput {
2274                reason: format!(
2275                    "multi-block ALO local deletion {name} has length {actual_len}, expected B*B={expected_len} for B={b}"
2276                ),
2277            });
2278        }
2279    }
2280
2281    let mut operand_envelope_inf = 0.0_f64;
2282    let mut system_norm_inf = 0.0_f64;
2283    for row in 0..b {
2284        let mut operand_row_envelope = 1.0_f64;
2285        let mut system_row_norm = 0.0_f64;
2286        for column in 0..b {
2287            let mut product_entry_envelope = 0.0_f64;
2288            for inner in 0..b {
2289                product_entry_envelope +=
2290                    left[row * b + inner].abs() * right[inner * b + column].abs();
2291            }
2292            operand_row_envelope += product_entry_envelope;
2293            let identity = if row == column { 1.0 } else { 0.0 };
2294            system_row_norm += (identity - product[row * b + column]).abs();
2295        }
2296        operand_envelope_inf = operand_envelope_inf.max(operand_row_envelope);
2297        system_norm_inf = system_norm_inf.max(system_row_norm);
2298    }
2299
2300    let formation_operations = b.saturating_mul(2).saturating_add(1);
2301    let elimination_operations = b.saturating_mul(3);
2302    let backward_error_scale = operand_envelope_inf.max(system_norm_inf);
2303    Ok(
2304        (floating_point_gamma(formation_operations) + floating_point_gamma(elimination_operations))
2305            * backward_error_scale,
2306    )
2307}
2308
2309/// LU-decompose a B × B row-major matrix in place with partial pivoting and
2310/// physical row swaps. Returns false if any pivot is within the caller's
2311/// scale-aware backward-error allowance.
2312/// On success, `m` holds L (strict lower, unit diag implicit) and U (upper, diag
2313/// included); `perm[k]` records the original-row index that ended up in physical
2314/// row k after pivoting.
2315fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize, pivot_tolerance: f64) -> bool {
2316    for i in 0..b {
2317        perm[i] = i;
2318    }
2319    for col in 0..b {
2320        // Partial pivot on column `col` over physical rows `[col..b]`.
2321        let mut max_val = m[col * b + col].abs();
2322        let mut max_idx = col;
2323        for row in (col + 1)..b {
2324            let v = m[row * b + col].abs();
2325            if v > max_val {
2326                max_val = v;
2327                max_idx = row;
2328            }
2329        }
2330        if !max_val.is_finite() || max_val <= pivot_tolerance {
2331            return false;
2332        }
2333        if max_idx != col {
2334            // Physically swap rows `col` and `max_idx` (full row, all columns).
2335            for k in 0..b {
2336                m.swap(col * b + k, max_idx * b + k);
2337            }
2338            perm.swap(col, max_idx);
2339        }
2340        let pivot = m[col * b + col];
2341        for row in (col + 1)..b {
2342            let factor = m[row * b + col] / pivot;
2343            m[row * b + col] = factor; // store L below diag
2344            for k in (col + 1)..b {
2345                let upd = factor * m[col * b + k];
2346                m[row * b + k] -= upd;
2347            }
2348        }
2349    }
2350    true
2351}
2352
2353/// Solve L U x = P rhs using a previously factored matrix (LU in `m`, perm).
2354/// Writes the solution back into `rhs`. `scratch` must have length ≥ b.
2355fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
2356    // Forward substitution Ly = P rhs (L is unit-diag, strict lower of m).
2357    let y = &mut scratch[..b];
2358    for row in 0..b {
2359        let mut s = rhs[perm[row]];
2360        for k in 0..row {
2361            s -= m[row * b + k] * y[k];
2362        }
2363        y[row] = s;
2364    }
2365    // Back substitution U x = y.  Write into rhs[].
2366    for row in (0..b).rev() {
2367        let mut s = y[row];
2368        for k in (row + 1)..b {
2369            s -= m[row * b + k] * rhs[k];
2370        }
2371        rhs[row] = s / m[row * b + row];
2372    }
2373}
2374
2375#[derive(Clone, Copy, Debug)]
2376struct LocalSolveResidualFailure {
2377    residual_norm: f64,
2378    allowance: f64,
2379}
2380
2381/// Solve a factored `I - product` system and certify the result against the
2382/// unfactored operator. The residual allowance contains both the uncertainty in
2383/// forming the operator and the operation-count-derived error from LU,
2384/// triangular substitution, and residual evaluation. This is a backward-error
2385/// certificate, so a well-resolved but ill-conditioned system remains valid;
2386/// only a solve unsupported by its own arithmetic is refused.
2387fn solve_identity_minus_product_in_place(
2388    lu: &[f64],
2389    permutation: &[usize],
2390    product: &[f64],
2391    rhs: &mut [f64],
2392    lu_scratch: &mut [f64],
2393    original_rhs: &mut [f64],
2394    operator_error_bound: f64,
2395    b: usize,
2396) -> Result<(), LocalSolveResidualFailure> {
2397    original_rhs[..b].copy_from_slice(&rhs[..b]);
2398    lu_solve_in_place(lu, permutation, rhs, lu_scratch, b);
2399
2400    let rhs_norm = original_rhs[..b]
2401        .iter()
2402        .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2403    let solution_norm = rhs[..b]
2404        .iter()
2405        .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2406    let mut system_norm = 0.0_f64;
2407    let mut residual_norm = 0.0_f64;
2408    for row in 0..b {
2409        let mut row_norm = 0.0_f64;
2410        let mut residual = original_rhs[row];
2411        for column in 0..b {
2412            let identity = if row == column { 1.0 } else { 0.0 };
2413            let matrix_entry = identity - product[row * b + column];
2414            row_norm += matrix_entry.abs();
2415            residual -= matrix_entry * rhs[column];
2416        }
2417        system_norm = system_norm.max(row_norm);
2418        residual_norm = residual_norm.max(residual.abs());
2419    }
2420
2421    // Per dimension: at most 3B factorization operations on a surviving entry,
2422    // 2B in each triangular substitution, and 3B to reconstruct/evaluate the
2423    // residual from `I - product`.
2424    let certification_operations = b.saturating_mul(10);
2425    let arithmetic_scale = system_norm * solution_norm + rhs_norm;
2426    let allowance = floating_point_gamma(certification_operations) * arithmetic_scale
2427        + operator_error_bound * solution_norm;
2428    if rhs[..b].iter().any(|value| !value.is_finite())
2429        || !residual_norm.is_finite()
2430        || !allowance.is_finite()
2431        || residual_norm > allowance
2432    {
2433        Err(LocalSolveResidualFailure {
2434            residual_norm,
2435            allowance,
2436        })
2437    } else {
2438        Ok(())
2439    }
2440}
2441
2442#[cfg(test)]
2443mod tests {
2444    use super::{
2445        ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
2446        alo_eta_updatewith_offset, compute_alo_from_input_inner, finite_weighted_square_sum,
2447        percentile_from_sorted, percentile_index, spd_quadratic_after_certified_solve,
2448    };
2449    use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
2450
2451    #[test]
2452    fn alo_offset_update_matches_centered_algebra() {
2453        let eta_hat = 11.0;
2454        let z = 13.0;
2455        let offset = 10.0;
2456        let x_hinv_x = 0.2;
2457        let hessian_weight = 1.0;
2458        let score_weight = 1.0;
2459        // centered: eta~=off + ((eta-off)-a(z-off))/(1-a) when W_S = W_H.
2460        let leverage = hessian_weight * x_hinv_x;
2461        let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2462        let got =
2463            alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2464        assert!((got - expected).abs() < 1e-12);
2465    }
2466
2467    #[test]
2468    fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2469        let eta_hat = 1.25;
2470        let z = -0.5;
2471        let x_hinv_x = 0.35;
2472        let hessian_weight = 1.0;
2473        let score_weight = 1.0;
2474        let leverage = hessian_weight * x_hinv_x;
2475        let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2476        let got =
2477            alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2478        assert!((got - expected).abs() < 1e-12);
2479    }
2480
2481    #[test]
2482    fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2483        let eta_hat = 1.7;
2484        let z = 0.4;
2485        let offset = -0.2;
2486        let x_hinv_x = 0.15;
2487        let hessian_weight = 3.0;
2488        let score_weight = 5.0;
2489        let expected = offset
2490            + (eta_hat - offset)
2491            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2492                / (1.0 - hessian_weight * x_hinv_x);
2493        let got = alo_eta_updatewith_offset(
2494            eta_hat,
2495            z,
2496            offset,
2497            x_hinv_x,
2498            score_weight,
2499            1.0 - hessian_weight * x_hinv_x,
2500        );
2501        assert!((got - expected).abs() < 1e-12);
2502    }
2503
2504    #[test]
2505    fn alo_offset_update_handles_zero_hessian_weight() {
2506        let eta_hat = 0.8;
2507        let z = -0.3;
2508        let offset = 0.1;
2509        let x_hinv_x = 0.4;
2510        let hessian_weight = 0.0;
2511        let score_weight = 2.5;
2512        let expected = offset
2513            + (eta_hat - offset)
2514            + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2515        let got = alo_eta_updatewith_offset(
2516            eta_hat,
2517            z,
2518            offset,
2519            x_hinv_x,
2520            score_weight,
2521            1.0 - hessian_weight * x_hinv_x,
2522        );
2523        assert!((got - expected).abs() < 1e-12);
2524    }
2525
2526    #[test]
2527    fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2528        let eta_hat = 1.0;
2529        let a_ii = 0.4;
2530        let got =
2531            alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| Ok((0.5 * (eta - 2.0), 0.5)))
2532                .expect("linear scalar fixed point should converge in one Newton step");
2533        assert!((got - 0.75).abs() < 1e-12);
2534    }
2535
2536    #[test]
2537    fn alo_exact_frozen_curvature_reports_nonconvergence() {
2538        let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| Ok((eta + 1.0, 0.0)))
2539            .expect_err("constant residual should exhaust the scalar iteration budget");
2540        let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2541            panic!("constant residual must report MaxIterations, got {err:?}");
2542        };
2543        assert_eq!(
2544            iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2545            "non-convergence must report the full scalar iteration budget"
2546        );
2547    }
2548
2549    #[test]
2550    fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2551        let design = Array2::from_elem((1, 1), 1.0);
2552        let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2553        let hessian_weights = Array1::from_vec(vec![0.0]);
2554        let score_weights = Array1::from_vec(vec![0.0]);
2555        let working_response = Array1::from_vec(vec![0.0]);
2556        let eta = Array1::from_vec(vec![0.0]);
2557        let offset = Array1::from_vec(vec![0.0]);
2558        let score_curvature = |_: usize, eta: f64| Ok((eta + 1.0, 0.0));
2559        let input = AloInput {
2560            design: &design,
2561            penalized_hessian: &penalized_hessian,
2562            hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2563            score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2564            working_response: &working_response,
2565            eta: &eta,
2566            offset: &offset,
2567            phi: 1.0,
2568            score_curvature: Some(&score_curvature),
2569        };
2570
2571        let err =
2572            compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2573        let msg = err.to_string();
2574        assert!(
2575            msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2576            "missing row context in exact ALO error: {msg}"
2577        );
2578        assert!(
2579            msg.contains("did not converge within"),
2580            "missing non-convergence cause in exact ALO error: {msg}"
2581        );
2582    }
2583
2584    #[test]
2585    fn alo_scale_safe_quadratics_preserve_tiny_weights_without_false_overflow() {
2586        let weights = Array1::from_vec(vec![1e-300, 2.0]);
2587        let values = [1e200, 3.0];
2588        let meat = finite_weighted_square_sum(0, weights.view(), &values)
2589            .expect("weighted square sum is representable");
2590        assert!(meat.is_finite());
2591        assert!((meat - 1e100).abs() <= 8.0 * f64::EPSILON * 1e100);
2592
2593        let rhs = Array1::from_vec(vec![2.0, -1.0]);
2594        let solution = Array1::from_vec(vec![1.5, 0.5]);
2595        let quadratic =
2596            spd_quadratic_after_certified_solve(0, rhs.view(), solution.view()).unwrap();
2597        assert_eq!(quadratic, 2.5);
2598    }
2599
2600    #[test]
2601    fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2602        // Regression for the sandwich-SE "meat" weight bug: the meat must be the
2603        // SCORE covariance Xᵀ diag(W_S) X (Fisher, PSD), NOT the observed-info
2604        // Hessian weight W_H (signed). This fixture mimics a non-canonical link
2605        // (W_H ≠ W_S) with mixed-sign observed curvature.
2606        //
2607        // Single column (p = 1) makes H a scalar, so the sandwich variance is
2608        // closed form: with H = Σ W_H·x² + s0 (> 0 after the penalty), the meat
2609        // for obs is x_obs²·H⁻²·Σ_row W_S·x_row², and se = sqrt(φ·meat).
2610        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2611        // Mixed-sign observed-information weights; the negative rows carry the
2612        // larger design values so Σ W_H·x² is NEGATIVE (see assert below).
2613        let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2614        // Score/Fisher weights are strictly positive (PSD by construction).
2615        let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2616        let phi = 1.3;
2617
2618        let n = x.nrows();
2619        let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2620        let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2621        // The whole point: Σ W_H·x² < 0 < Σ W_S·x². With W_H the meat is negative
2622        // and the "materially negative sandwich variance" guard would trip
2623        // (spurious LooComputationFailed); with W_S it is a valid PSD meat.
2624        assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2625        assert!(sum_ws_x2 > 0.0);
2626
2627        // Penalize enough that the penalized Hessian is PD despite Σ W_H·x² < 0.
2628        let s0 = 8.0_f64;
2629        let h = s0 + sum_wh_x2; // = 2.5
2630        assert!(h > 0.0, "penalized Hessian must stay PD");
2631        let penalized_hessian = Array2::from_elem((1, 1), h);
2632
2633        // Pre-fix arithmetic check: the OLD W_H meat would be materially negative
2634        // for the larger-x rows, so the old code returned LooComputationFailed.
2635        let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2636        assert!(phi * old_meat_obs1 < 0.0, "the pre-fix W_H meat is signed");
2637
2638        let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2639        let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2640        let offset = Array1::zeros(n);
2641        let input = AloInput {
2642            design: &x,
2643            penalized_hessian: &penalized_hessian,
2644            hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2645            score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2646            working_response: &working_response,
2647            eta: &eta,
2648            offset: &offset,
2649            phi,
2650            score_curvature: None,
2651        };
2652
2653        // The fix must let this succeed (no spurious negative-meat failure)...
2654        let diag = compute_alo_from_input_inner(&input)
2655            .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2656
2657        // ...and match the closed-form W_S reference for every row.
2658        for obs in 0..n {
2659            let expected = (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2660            assert!(
2661                (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2662                "row {obs}: se_sandwich={} expected={expected}",
2663                diag.se_sandwich[obs]
2664            );
2665            let expected_leverage = w_h_vec[obs] * x[[obs, 0]] * x[[obs, 0]] / h;
2666            assert!(
2667                (diag.leverage[obs] - expected_leverage).abs()
2668                    <= 1e-12 * expected_leverage.abs().max(1.0),
2669                "row {obs}: signed leverage={} expected={expected_leverage}",
2670                diag.leverage[obs]
2671            );
2672        }
2673        assert!(
2674            diag.leverage[1] < 0.0,
2675            "negative observed curvature must remain signed"
2676        );
2677    }
2678
2679    #[test]
2680    fn percentile_index_matches_expected_rounding() {
2681        assert_eq!(percentile_index(0, 0.95), 0);
2682        assert_eq!(percentile_index(1, 0.95), 0);
2683        assert_eq!(percentile_index(10, 0.50), 5);
2684        assert_eq!(percentile_index(10, 0.95), 9);
2685    }
2686
2687    #[test]
2688    fn percentile_from_sorted_returns_order_statistic() {
2689        let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2690        assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2691        assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2692        assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2693    }
2694
2695    // --- Multi-block ALO tests ---
2696
2697    use super::{
2698        MultiBlockAloInput, compute_multiblock_alo, floating_point_gamma,
2699        identity_minus_product_lu_tolerance, lu_factor_in_place, mat_mul_flat,
2700    };
2701    use gam_linalg::matrix::DesignMatrix;
2702    use ndarray::{Array1, Array2};
2703
2704    fn local_identity_minus_product_is_factorable(left: &[f64], right: &[f64], b: usize) -> bool {
2705        let mut product = vec![0.0; b * b];
2706        mat_mul_flat(left, right, &mut product, b);
2707        let mut system = vec![0.0; b * b];
2708        for row in 0..b {
2709            for column in 0..b {
2710                let identity = if row == column { 1.0 } else { 0.0 };
2711                system[row * b + column] = identity - product[row * b + column];
2712            }
2713        }
2714        let tolerance = identity_minus_product_lu_tolerance(left, right, &product, b)
2715            .expect("test matrices satisfy the B-by-B local deletion contract");
2716        let mut permutation = vec![0; b];
2717        lu_factor_in_place(&mut system, &mut permutation, b, tolerance)
2718    }
2719
2720    #[test]
2721    fn multiblock_b1_matches_scalar_leverage() {
2722        // With B=1 the multi-block formula should reduce to the scalar case.
2723        // H_ii = x_i^T H^{-1} x_i * w_i  (scalar).
2724        let n = 3;
2725        let p = 2;
2726        let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2727        // H = X'WX + I (simple regularisation).
2728        let w = [1.0, 2.0, 0.5];
2729        let mut h = Array2::<f64>::eye(p);
2730        for i in 0..n {
2731            for r in 0..p {
2732                for c in 0..p {
2733                    h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2734                }
2735            }
2736        }
2737        // Invert H (2x2).
2738        let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2739        let mut h_inv = Array2::<f64>::zeros((p, p));
2740        h_inv[(0, 0)] = h[(1, 1)] / det;
2741        h_inv[(1, 1)] = h[(0, 0)] / det;
2742        h_inv[(0, 1)] = -h[(0, 1)] / det;
2743        h_inv[(1, 0)] = -h[(1, 0)] / det;
2744
2745        // Scalar leverages: a_ii = w_i * x_i^T H^{-1} x_i
2746        let mut scalar_lev = vec![0.0f64; n];
2747        for i in 0..n {
2748            let mut xhx = 0.0;
2749            for r in 0..p {
2750                for c in 0..p {
2751                    xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2752                }
2753            }
2754            scalar_lev[i] = w[i] * xhx;
2755        }
2756
2757        // Multi-block with B=1. The score covariance is deliberately supplied
2758        // separately even though this well-specified fixture sets C_i = W_i.
2759        let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2760        let coordinate_coefficient_ranges = vec![0..p];
2761        let observed_hessians: Vec<Array2<f64>> =
2762            w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2763        let score_covariances = observed_hessians.clone();
2764        let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2765        let coordinate_values: Vec<Array1<f64>> =
2766            (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2767
2768        let input = MultiBlockAloInput {
2769            n_obs: n,
2770            n_coordinates: 1,
2771            coordinate_designs: &coordinate_designs,
2772            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2773            penalized_hessian: &h,
2774            observed_hessians: &observed_hessians,
2775            score_covariances: &score_covariances,
2776            scores: &scores,
2777            coordinate_values: &coordinate_values,
2778        };
2779
2780        let result = compute_multiblock_alo(&input).unwrap();
2781        for i in 0..n {
2782            assert!(
2783                (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2784                "leverage mismatch at i={}: got {}, expected {}",
2785                i,
2786                result.leverage[i],
2787                scalar_lev[i]
2788            );
2789        }
2790    }
2791
2792    #[test]
2793    fn multiblock_b2_matches_closed_form_with_cross_geometry() {
2794        // A one-row B=2 identity-Jacobian fixture pins every matrix ordering:
2795        // A=H^-1, M=I-WA, delta=A M^-1 s, leverage=tr(AW), and the distinct
2796        // score covariance C drives Cook/variance. Diagonal-only or C=W code
2797        // cannot pass this fixture. Both coordinates address the same full
2798        // parameter range, exercising the shared-coefficient contract used by
2799        // survival and latent rows.
2800        let coordinate_designs = vec![
2801            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2802            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2803        ];
2804        let coordinate_coefficient_ranges = vec![0..2, 0..2];
2805        let h = Array2::from_shape_vec((2, 2), vec![2.0, 0.25, 0.25, 3.0]).unwrap();
2806        let w = Array2::from_shape_vec((2, 2), vec![0.2, 0.05, 0.05, 0.3]).unwrap();
2807        let c = Array2::from_shape_vec((2, 2), vec![0.5, 0.1, 0.1, 0.4]).unwrap();
2808        let observed_hessians = vec![w.clone()];
2809        let score_covariances = vec![c.clone()];
2810        let scores = vec![Array1::from_vec(vec![0.4, -0.2])];
2811        let coordinate_values = vec![Array1::from_vec(vec![1.0, -0.5])];
2812        let input = MultiBlockAloInput {
2813            n_obs: 1,
2814            n_coordinates: 2,
2815            coordinate_designs: &coordinate_designs,
2816            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2817            penalized_hessian: &h,
2818            observed_hessians: &observed_hessians,
2819            score_covariances: &score_covariances,
2820            scores: &scores,
2821            coordinate_values: &coordinate_values,
2822        };
2823
2824        let det_h = h[[0, 0]] * h[[1, 1]] - h[[0, 1]] * h[[1, 0]];
2825        let a = Array2::from_shape_vec(
2826            (2, 2),
2827            vec![
2828                h[[1, 1]] / det_h,
2829                -h[[0, 1]] / det_h,
2830                -h[[1, 0]] / det_h,
2831                h[[0, 0]] / det_h,
2832            ],
2833        )
2834        .unwrap();
2835        let m = Array2::<f64>::eye(2) - w.dot(&a);
2836        let det_m = m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]];
2837        let m_inv = Array2::from_shape_vec(
2838            (2, 2),
2839            vec![
2840                m[[1, 1]] / det_m,
2841                -m[[0, 1]] / det_m,
2842                -m[[1, 0]] / det_m,
2843                m[[0, 0]] / det_m,
2844            ],
2845        )
2846        .unwrap();
2847        let delta = a.dot(&m_inv.dot(&scores[0]));
2848        let expected_eta = &coordinate_values[0] + &delta;
2849        let expected_leverage = (a.dot(&w)).diag().sum();
2850        let expected_cook = delta.dot(&c.dot(&delta));
2851        let variance = a.dot(&m_inv).dot(&c).dot(&m_inv.t()).dot(&a.t());
2852
2853        let result = compute_multiblock_alo(&input).expect("B=2 closed-form ALO");
2854        for coordinate in 0..2 {
2855            assert!((result.eta_tilde[0][coordinate] - expected_eta[coordinate]).abs() < 2e-12);
2856            assert!(
2857                (result.alo_variance[0][coordinate] - variance[[coordinate, coordinate]]).abs()
2858                    < 2e-12
2859            );
2860        }
2861        assert!((result.leverage[0] - expected_leverage).abs() < 2e-12);
2862        assert!((result.cook_distance[0] - expected_cook).abs() < 2e-12);
2863    }
2864
2865    #[test]
2866    fn multiblock_singular_weight_still_corrects() {
2867        // When W_i = 0 (singular), the W_i⁻¹-free formula still works:
2868        // (I - W_i A_i)⁻¹ = I, so Δη = A_i s_i.
2869        // A_i = x H⁻¹ xᵀ = 1.0² + 0.5² = 1.25 (scalar, B=1).
2870        let n = 1;
2871        let p = 2;
2872        let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2873        let h = Array2::eye(p);
2874        let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2875        let coordinate_coefficient_ranges = vec![0..p];
2876        let observed_hessians = vec![Array2::from_elem((1, 1), 0.0)];
2877        let score_covariances = observed_hessians.clone();
2878        let scores = vec![Array1::from_vec(vec![1.0])];
2879        let coordinate_values = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2880
2881        let input = MultiBlockAloInput {
2882            n_obs: n,
2883            n_coordinates: 1,
2884            coordinate_designs: &coordinate_designs,
2885            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2886            penalized_hessian: &h,
2887            observed_hessians: &observed_hessians,
2888            score_covariances: &score_covariances,
2889            scores: &scores,
2890            coordinate_values: &coordinate_values,
2891        };
2892        let result = compute_multiblock_alo(&input).unwrap();
2893        // Δη = A_i * s_i = 1.25 * 1.0 = 1.25
2894        let expected = std::f64::consts::PI + 1.25;
2895        assert!(
2896            (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2897            "expected {}, got {}",
2898            expected,
2899            result.eta_tilde[0][0]
2900        );
2901        // Cook's distance should be 0 since C_i = 0.
2902        assert!(result.cook_distance[0].abs() < 1e-14);
2903        // ALO variance should be 0 since C_i = 0.
2904        assert!(result.alo_variance[0][0].abs() < 1e-14);
2905    }
2906
2907    #[test]
2908    fn multiblock_unit_leverage_refuses_instead_of_changing_estimand() {
2909        let coordinate_designs = vec![DesignMatrix::from(Array2::from_elem((1, 1), 1.0))];
2910        let coordinate_coefficient_ranges = vec![0..1];
2911        let h = Array2::from_elem((1, 1), 2.0);
2912        let observed_hessians = vec![Array2::from_elem((1, 1), 2.0)];
2913        let score_covariances = vec![Array2::from_elem((1, 1), 1.0)];
2914        let scores = vec![Array1::from_vec(vec![0.4])];
2915        let coordinate_values = vec![Array1::from_vec(vec![1.0])];
2916        let input = MultiBlockAloInput {
2917            n_obs: 1,
2918            n_coordinates: 1,
2919            coordinate_designs: &coordinate_designs,
2920            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2921            penalized_hessian: &h,
2922            observed_hessians: &observed_hessians,
2923            score_covariances: &score_covariances,
2924            scores: &scores,
2925            coordinate_values: &coordinate_values,
2926        };
2927        let error = compute_multiblock_alo(&input)
2928            .expect_err("unit deletion leverage must be reported as singular");
2929        assert!(
2930            error
2931                .to_string()
2932                .contains("deletion system I-WA is singular")
2933        );
2934    }
2935
2936    #[test]
2937    fn multiblock_b2_identity_cancellation_is_numerically_singular() {
2938        // The stored operands differ from an exact inverse pair by one ulp, so
2939        // the formed diagonal of I-WA is nonzero but smaller than the error in
2940        // forming the product. Scaling by ||I-WA|| alone would accept it.
2941        let above_two = f64::from_bits(2.0_f64.to_bits() + 1);
2942        let w = [above_two, 0.0, 0.0, above_two];
2943        let a = [0.5, 0.0, 0.0, 0.5];
2944        assert!(!local_identity_minus_product_is_factorable(&w, &a, 2));
2945        assert!(!local_identity_minus_product_is_factorable(&a, &w, 2));
2946    }
2947
2948    #[test]
2949    fn multiblock_b2_safely_near_singular_deletion_is_accepted() {
2950        // This system is ill-conditioned, but its smallest pivot is sqrt(eps),
2951        // well outside the O(eps) formation uncertainty of its operands.
2952        let gap = f64::EPSILON.sqrt();
2953        let identity = [1.0, 0.0, 0.0, 1.0];
2954        let product_operand = [1.0 - gap, 0.0, 0.0, 0.5];
2955        assert!(local_identity_minus_product_is_factorable(
2956            &identity,
2957            &product_operand,
2958            2
2959        ));
2960        assert!(local_identity_minus_product_is_factorable(
2961            &product_operand,
2962            &identity,
2963            2
2964        ));
2965    }
2966
2967    #[test]
2968    fn multiblock_trace_one_but_invertible_deletion_is_not_refused() {
2969        // tr(AW)=1 is only a scalar summary. Here I-AW has eigenvalues 3/4 and
2970        // 1/4, so a leverage gate would reject a perfectly regular exact solve.
2971        let coordinate_designs = vec![
2972            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2973            DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2974        ];
2975        let coordinate_coefficient_ranges = vec![0..2, 0..2];
2976        let penalized_hessian = Array2::<f64>::eye(2);
2977        let observed_hessians =
2978            vec![Array2::from_shape_vec((2, 2), vec![0.25, 0.0, 0.0, 0.75]).unwrap()];
2979        let score_covariances = vec![Array2::<f64>::zeros((2, 2))];
2980        let scores = vec![Array1::from_vec(vec![0.75, -0.25])];
2981        let coordinate_values = vec![Array1::<f64>::zeros(2)];
2982        let input = MultiBlockAloInput {
2983            n_obs: 1,
2984            n_coordinates: 2,
2985            coordinate_designs: &coordinate_designs,
2986            coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2987            penalized_hessian: &penalized_hessian,
2988            observed_hessians: &observed_hessians,
2989            score_covariances: &score_covariances,
2990            scores: &scores,
2991            coordinate_values: &coordinate_values,
2992        };
2993
2994        let result = compute_multiblock_alo(&input)
2995            .expect("trace-one but invertible deletion system must be solved exactly");
2996        let roundoff = floating_point_gamma(16);
2997        assert!((result.leverage[0] - 1.0).abs() <= roundoff);
2998        assert!((result.eta_tilde[0][0] - 1.0).abs() <= roundoff);
2999        assert!((result.eta_tilde[0][1] + 1.0).abs() <= roundoff);
3000    }
3001}