Skip to main content

gam_solve/inference/
alo.rs

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