1use crate::estimate::{
2 EstimationError, FitGeometry, UnifiedFitResult, WorkingGeometry, dispersion_from_likelihood,
3};
4use crate::pirls;
5use faer::Mat as FaerMat;
6use faer::linalg::matmul::matmul;
7use faer::prelude::ReborrowMut;
8use faer::{Accum, Par};
9use gam_linalg::faer_ndarray::{FaerArrayView, FaerCholesky};
10use gam_linalg::matrix::{DesignMatrix, PsdWeightsView, SignedWeightsView};
11use gam_linalg::utils::{
12 CertifiedSpdFactor, certified_spd_factorize, symmetric_extremes,
13 validate_finite_symmetric_matrix,
14};
15use gam_math::probability::signed_log_sum_exp;
16use gam_problem::{Dispersion, LikelihoodScaleMetadata, LinkFunction, ResponseFamily};
17use ndarray::{Array1, Array2, ArrayView1, ShapeBuilder, s};
18use opt::{BacktrackConfig, backtracking_line_search};
19use std::convert::Infallible;
20use std::fmt;
21use std::ops::Range;
22
23#[derive(Debug, Clone)]
32pub enum AloError {
33 InvalidInput { reason: String },
37 WeightInvalid { reason: String },
40 DesignDegenerate { reason: String },
43 LooComputationFailed { reason: String },
46}
47
48impl fmt::Display for AloError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 AloError::InvalidInput { reason }
52 | AloError::WeightInvalid { reason }
53 | AloError::DesignDegenerate { reason }
54 | AloError::LooComputationFailed { reason } => f.write_str(reason),
55 }
56 }
57}
58
59impl std::error::Error for AloError {}
60
61impl From<AloError> for EstimationError {
62 fn from(err: AloError) -> EstimationError {
63 match err {
64 AloError::InvalidInput { reason }
65 | AloError::WeightInvalid { reason }
66 | AloError::DesignDegenerate { reason }
67 | AloError::LooComputationFailed { reason } => EstimationError::InvalidInput(reason),
68 }
69 }
70}
71
72impl From<AloError> for String {
73 fn from(err: AloError) -> String {
74 err.to_string()
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct AloDiagnostics {
81 pub eta_tilde: Array1<f64>,
82 pub se_bayes: Array1<f64>,
85 pub se_sandwich: Array1<f64>,
88 pub leverage: Array1<f64>,
91}
92
93#[inline]
94fn alo_eta_updatewith_offset(
95 eta_hat: f64,
96 z: f64,
97 offset: f64,
98 x_hinv_x: f64,
99 score_weight: f64,
100 denom: f64,
101) -> f64 {
102 let eta_centered = eta_hat - offset;
105 let z_centered = z - offset;
106 let score = score_weight * (eta_centered - z_centered);
107 offset + eta_centered + x_hinv_x * score / denom
108}
109
110pub type AloScalarScoreCurvature<'a> =
120 dyn Fn(usize, f64) -> Result<(f64, f64), AloError> + Sync + 'a;
121
122const ALO_EXACT_SCALAR_MAX_ITERS: usize = 64;
128
129#[inline]
133fn alo_scalar_residual_allowance(eta: f64, eta_hat: f64, score_step: f64) -> f64 {
134 32.0 * f64::EPSILON * eta.abs().max(eta_hat.abs()).max(score_step.abs())
135}
136
137#[derive(Debug, Clone, PartialEq)]
158enum AloExactScalarError {
159 EvaluationFailed {
160 eta: f64,
161 reason: String,
162 },
163 NonFiniteScoreCurvature {
164 eta: f64,
165 ell_prime: f64,
166 ell_double: f64,
167 },
168 DegenerateJacobian {
169 eta: f64,
170 jacobian: f64,
171 },
172 NonFiniteStep {
173 eta: f64,
174 residual: f64,
175 jacobian: f64,
176 next: f64,
177 },
178 MaxIterations {
179 iterations: usize,
180 residual: f64,
181 tolerance: f64,
182 eta: f64,
183 },
184}
185
186impl fmt::Display for AloExactScalarError {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 match *self {
189 AloExactScalarError::EvaluationFailed { eta, ref reason } => {
190 write!(
191 f,
192 "score/curvature evaluation failed at eta={eta:.6e}: {reason}"
193 )
194 }
195 AloExactScalarError::NonFiniteScoreCurvature {
196 eta,
197 ell_prime,
198 ell_double,
199 } => write!(
200 f,
201 "non-finite score/curvature at eta={eta:.6e}: ell_prime={ell_prime:.6e}, ell_double={ell_double:.6e}"
202 ),
203 AloExactScalarError::DegenerateJacobian { eta, jacobian } => write!(
204 f,
205 "degenerate Newton Jacobian at eta={eta:.6e}: jacobian={jacobian:.6e}"
206 ),
207 AloExactScalarError::NonFiniteStep {
208 eta,
209 residual,
210 jacobian,
211 next,
212 } => write!(
213 f,
214 "non-finite Newton step from eta={eta:.6e}: residual={residual:.6e}, jacobian={jacobian:.6e}, next={next:.6e}"
215 ),
216 AloExactScalarError::MaxIterations {
217 iterations,
218 residual,
219 tolerance,
220 eta,
221 } => write!(
222 f,
223 "did not converge within {iterations} iterations: residual={residual:.6e}, eta={eta:.6e}, backward-error allowance={tolerance:.6e}"
224 ),
225 }
226 }
227}
228
229const ALO_EXACT_SCALAR_BACKTRACKS: usize = 40;
234
235#[inline]
236fn alo_eta_exact_frozen_curvature(
237 eta_hat: f64,
238 a_ii: f64,
239 score_curvature: &dyn Fn(f64) -> Result<(f64, f64), AloError>,
240) -> Result<f64, AloExactScalarError> {
241 let residual_and_jac = |eta: f64| -> Result<(f64, f64, f64), AloExactScalarError> {
265 let (ell_prime, ell_double) =
266 score_curvature(eta).map_err(|error| AloExactScalarError::EvaluationFailed {
267 eta,
268 reason: error.to_string(),
269 })?;
270 if !ell_prime.is_finite() || !ell_double.is_finite() {
271 return Err(AloExactScalarError::NonFiniteScoreCurvature {
272 eta,
273 ell_prime,
274 ell_double,
275 });
276 }
277 let score_step = a_ii * ell_prime;
278 let residual = eta - eta_hat - score_step;
279 let jacobian = 1.0 - a_ii * ell_double;
280 let tolerance = alo_scalar_residual_allowance(eta, eta_hat, score_step);
281 if !score_step.is_finite()
282 || !residual.is_finite()
283 || !jacobian.is_finite()
284 || !tolerance.is_finite()
285 {
286 return Err(AloExactScalarError::NonFiniteStep {
287 eta,
288 residual,
289 jacobian,
290 next: f64::NAN,
291 });
292 }
293 Ok((residual, jacobian, tolerance))
294 };
295
296 let mut eta = eta_hat;
297 let (mut residual, mut jac, mut tolerance) = residual_and_jac(eta)?;
298 for _ in 0..ALO_EXACT_SCALAR_MAX_ITERS {
299 if residual.abs() <= tolerance {
300 return Ok(eta);
301 }
302 if jac == 0.0 || !jac.is_finite() {
303 return Err(AloExactScalarError::DegenerateJacobian { eta, jacobian: jac });
304 }
305 let step = residual / jac;
306 if !step.is_finite() {
307 return Err(AloExactScalarError::NonFiniteStep {
308 eta,
309 residual,
310 jacobian: jac,
311 next: eta - step,
312 });
313 }
314 let accepted = match backtracking_line_search::<_, Infallible>(
320 BacktrackConfig {
321 max_steps: ALO_EXACT_SCALAR_BACKTRACKS,
322 ..BacktrackConfig::default()
323 },
324 |t| {
325 let trial = eta - t * step;
326 Ok(residual_and_jac(trial)
327 .ok()
328 .map(|(r_trial, j_trial, tol_trial)| {
329 (r_trial.abs(), (trial, r_trial, j_trial, tol_trial))
330 }))
331 },
332 |_, merit| merit < residual.abs(),
333 ) {
334 Ok(result) => result,
335 Err(never) => match never {},
336 };
337 let Some(step) = accepted else {
338 break;
339 };
340 (eta, residual, jac, tolerance) = step.payload;
341 }
342 Err(AloExactScalarError::MaxIterations {
343 iterations: ALO_EXACT_SCALAR_MAX_ITERS,
344 residual,
345 tolerance,
346 eta,
347 })
348}
349
350fn spd_quadratic_after_certified_solve(
356 row: usize,
357 rhs: ArrayView1<'_, f64>,
358 solution: ArrayView1<'_, f64>,
359) -> Result<f64, AloError> {
360 if rhs.len() != solution.len() {
361 return Err(AloError::LooComputationFailed {
362 reason: format!(
363 "ALO certified quadratic dimension mismatch at row {row}: rhs={}, solution={}",
364 rhs.len(),
365 solution.len()
366 ),
367 });
368 }
369 let mut sum = 0.0_f64;
370 let mut compensation = 0.0_f64;
371 let mut rhs_nonzero = false;
372 let mut fast_path_finite = true;
373 for (&left, &right) in rhs.iter().zip(solution.iter()) {
374 if !left.is_finite() || !right.is_finite() {
375 return Err(AloError::LooComputationFailed {
376 reason: format!(
377 "ALO certified solve produced a non-finite quadratic coordinate at row {row}: rhs={left}, solution={right}"
378 ),
379 });
380 }
381 rhs_nonzero |= left != 0.0;
382 let term = left * right;
383 if !term.is_finite() {
384 fast_path_finite = false;
385 continue;
386 }
387 let next = sum + term;
388 if !next.is_finite() {
389 fast_path_finite = false;
390 continue;
391 }
392 compensation += if sum.abs() >= term.abs() {
393 (sum - next) + term
394 } else {
395 (term - next) + sum
396 };
397 sum = next;
398 }
399 let fast = sum + compensation;
400 if !rhs_nonzero {
401 return Ok(0.0);
402 }
403 if fast_path_finite && fast.is_finite() && fast > 0.0 {
404 return Ok(fast);
405 }
406
407 let mut log_magnitudes = Vec::with_capacity(rhs.len());
408 let mut signs = Vec::with_capacity(rhs.len());
409 for (&left, &right) in rhs.iter().zip(solution.iter()) {
410 if left == 0.0 || right == 0.0 {
411 log_magnitudes.push(f64::NEG_INFINITY);
412 signs.push(0.0);
413 } else {
414 log_magnitudes.push(left.abs().ln() + right.abs().ln());
415 signs.push(left.signum() * right.signum());
416 }
417 }
418 let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
419 if sign <= 0.0 || !log_magnitude.is_finite() {
420 return Err(AloError::LooComputationFailed {
421 reason: format!(
422 "ALO SPD quadratic could not be represented as strictly positive at row {row}: sign={sign}, log_magnitude={log_magnitude}, fast_value={fast}"
423 ),
424 });
425 }
426 let value = log_magnitude.exp();
427 if !value.is_finite() || value == 0.0 {
428 return Err(AloError::LooComputationFailed {
429 reason: format!(
430 "ALO SPD quadratic lies outside the nonzero finite f64 range at row {row}: log_magnitude={log_magnitude}"
431 ),
432 });
433 }
434 Ok(value)
435}
436
437fn finite_weighted_square_sum(
441 observation: usize,
442 weights: ArrayView1<'_, f64>,
443 values: &[f64],
444) -> Result<f64, AloError> {
445 if weights.len() != values.len() {
446 return Err(AloError::LooComputationFailed {
447 reason: format!(
448 "ALO sandwich quadratic dimension mismatch for observation {observation}: weights={}, values={}",
449 weights.len(),
450 values.len()
451 ),
452 });
453 }
454 let mut sum = 0.0_f64;
455 let mut compensation = 0.0_f64;
456 let mut has_mathematically_positive_term = false;
457 let mut fast_path_finite = true;
458 for (&weight, &value) in weights.iter().zip(values.iter()) {
459 if !weight.is_finite() || weight < 0.0 || !value.is_finite() {
460 return Err(AloError::LooComputationFailed {
461 reason: format!(
462 "ALO sandwich quadratic has an invalid coordinate for observation {observation}: weight={weight}, value={value}"
463 ),
464 });
465 }
466 if weight == 0.0 || value == 0.0 {
467 continue;
468 }
469 has_mathematically_positive_term = true;
470 let term = (weight * value) * value;
471 if !term.is_finite() || term == 0.0 {
472 fast_path_finite = false;
473 continue;
474 }
475 let next = sum + term;
476 if !next.is_finite() {
477 fast_path_finite = false;
478 continue;
479 }
480 compensation += if sum.abs() >= term {
481 (sum - next) + term
482 } else {
483 (term - next) + sum
484 };
485 sum = next;
486 }
487 let fast = sum + compensation;
488 if !has_mathematically_positive_term {
489 return Ok(0.0);
490 }
491 if fast_path_finite && fast.is_finite() && fast > 0.0 {
492 return Ok(fast);
493 }
494
495 let mut log_magnitudes = Vec::with_capacity(values.len());
496 let mut signs = Vec::with_capacity(values.len());
497 for (&weight, &value) in weights.iter().zip(values.iter()) {
498 if weight == 0.0 || value == 0.0 {
499 log_magnitudes.push(f64::NEG_INFINITY);
500 signs.push(0.0);
501 } else {
502 log_magnitudes.push(weight.ln() + 2.0 * value.abs().ln());
503 signs.push(1.0);
504 }
505 }
506 let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
507 let value = log_magnitude.exp();
508 if sign != 1.0 || !value.is_finite() || value == 0.0 {
509 return Err(AloError::LooComputationFailed {
510 reason: format!(
511 "ALO sandwich quadratic lies outside the positive finite f64 range for observation {observation}: sign={sign}, log_magnitude={log_magnitude}"
512 ),
513 });
514 }
515 Ok(value)
516}
517
518fn finite_nonnegative_product(
519 row: usize,
520 quantity: &'static str,
521 left: f64,
522 right: f64,
523) -> Result<f64, AloError> {
524 if !(left.is_finite() && left >= 0.0 && right.is_finite() && right >= 0.0) {
525 return Err(AloError::LooComputationFailed {
526 reason: format!(
527 "ALO {quantity} requires finite non-negative factors at row {row}: left={left}, right={right}"
528 ),
529 });
530 }
531 if left == 0.0 || right == 0.0 {
532 return Ok(0.0);
533 }
534 let direct = left * right;
535 if direct.is_finite() && direct > 0.0 {
536 return Ok(direct);
537 }
538 let log_magnitude = left.ln() + right.ln();
539 let value = log_magnitude.exp();
540 if !value.is_finite() || value == 0.0 {
541 return Err(AloError::LooComputationFailed {
542 reason: format!(
543 "ALO {quantity} lies outside the positive finite f64 range at row {row}: log_magnitude={log_magnitude}"
544 ),
545 });
546 }
547 Ok(value)
548}
549
550fn finite_signed_product(
551 row: usize,
552 quantity: &'static str,
553 left: f64,
554 right: f64,
555) -> Result<f64, AloError> {
556 if !left.is_finite() || !right.is_finite() {
557 return Err(AloError::LooComputationFailed {
558 reason: format!(
559 "ALO {quantity} requires finite factors at row {row}: left={left}, right={right}"
560 ),
561 });
562 }
563 if left == 0.0 || right == 0.0 {
564 return Ok(0.0);
565 }
566 let direct = left * right;
567 if direct.is_finite() && direct != 0.0 {
568 return Ok(direct);
569 }
570 let log_magnitude = left.abs().ln() + right.abs().ln();
571 let value = left.signum() * right.signum() * log_magnitude.exp();
572 if !value.is_finite() || value == 0.0 {
573 return Err(AloError::LooComputationFailed {
574 reason: format!(
575 "ALO {quantity} lies outside the nonzero finite f64 range at row {row}: sign={}, log_magnitude={log_magnitude}",
576 left.signum() * right.signum()
577 ),
578 });
579 }
580 Ok(value)
581}
582
583const LEVERAGE_HIGH_THRESHOLD: f64 = 0.99;
584const LEVERAGE_VERY_HIGH_THRESHOLD: f64 = 0.999;
585const LEVERAGE_RATE_THRESHOLDS: [f64; 3] = [0.90, 0.95, 0.99];
586const LEVERAGE_PERCENTILES: [f64; 3] = [0.50, 0.95, 0.99];
587const MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
588
589const ALO_MAX_RHS_BLOCK_COLS: usize = 8192;
594
595#[inline]
604fn alo_rhs_block_cols(n: usize, p: usize) -> usize {
605 let scalars_per_col = n.saturating_add(p.saturating_mul(5)).max(1);
606 let bytes_per_col = std::mem::size_of::<f64>().saturating_mul(scalars_per_col);
607 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_col.max(1))
608 .max(1)
609 .min(ALO_MAX_RHS_BLOCK_COLS)
610}
611
612const LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR: f64 = 8.0;
616
617#[inline]
618fn percentile_index(sample_size: usize, quantile: f64) -> usize {
619 if sample_size <= 1 {
620 return 0;
621 }
622 let max_index = sample_size - 1;
623 ((quantile * max_index as f64).round() as usize).min(max_index)
624}
625
626#[inline]
627fn percentile_from_sorted(sorted: &[f64], quantile: f64) -> f64 {
628 if sorted.is_empty() {
629 0.0
630 } else {
631 sorted[percentile_index(sorted.len(), quantile)]
632 }
633}
634
635#[inline]
636fn compute_alo_diagnostics_from_pirls_impl(
637 base: &pirls::PirlsResult,
638 y: ArrayView1<f64>,
639) -> Result<AloDiagnostics, EstimationError> {
640 compute_alo_diagnostics_from_pirls_inner(base, y).map_err(EstimationError::from)
641}
642
643fn alo_covariance_scale(base: &pirls::PirlsResult) -> Result<f64, AloError> {
649 let dispersion = match (&base.likelihood.spec.response, base.likelihood.scale) {
650 (ResponseFamily::Gaussian, LikelihoodScaleMetadata::ProfiledGaussian) => {
651 let rss = base.deviance;
652 if !(rss.is_finite() && rss >= 0.0) {
653 return Err(AloError::InvalidInput {
654 reason: format!(
655 "ALO requires a finite non-negative profiled-Gaussian residual sum of squares; got {rss}"
656 ),
657 });
658 }
659 let mut positive_rows = 0usize;
660 for (row, &weight) in base.finalweights.iter().enumerate() {
661 if !weight.is_finite() || weight < 0.0 {
662 return Err(AloError::WeightInvalid {
663 reason: format!(
664 "profiled-Gaussian ALO requires finite non-negative converged weights; row {row} has {weight}"
665 ),
666 });
667 }
668 positive_rows += usize::from(weight > 0.0);
669 }
670 let residual_dof = positive_rows as f64 - base.edf;
671 if !(residual_dof.is_finite() && residual_dof > 0.0) {
672 return Err(AloError::InvalidInput {
673 reason: format!(
674 "profiled-Gaussian ALO requires positive residual degrees of freedom; positive_rows={positive_rows}, edf={}, residual_dof={residual_dof}",
675 base.edf
676 ),
677 });
678 }
679 let phi = rss / residual_dof;
680 if !phi.is_finite() || (rss > 0.0 && phi == 0.0) {
681 return Err(AloError::InvalidInput {
682 reason: format!(
683 "profiled-Gaussian ALO residual variance is not representable: rss={rss}, residual_dof={residual_dof}, phi={phi}"
684 ),
685 });
686 }
687 Dispersion::estimated(phi).map_err(|error| AloError::InvalidInput {
688 reason: format!("invalid profiled-Gaussian ALO dispersion: {error}"),
689 })?
690 }
691 _ => dispersion_from_likelihood(&base.likelihood, None).map_err(|error| {
692 AloError::InvalidInput {
693 reason: format!("ALO could not resolve likelihood scale metadata: {error}"),
694 }
695 })?,
696 };
697 let scale = base
698 .likelihood
699 .coefficient_covariance_scale(dispersion.phi())
700 .map_err(|error| AloError::InvalidInput {
701 reason: format!("ALO could not resolve coefficient-covariance scale: {error}"),
702 })?;
703 if !(scale.is_finite() && scale > 0.0) {
704 return Err(AloError::InvalidInput {
705 reason: format!(
706 "ALO coefficient covariance is unavailable at non-positive or non-finite scale {scale}"
707 ),
708 });
709 }
710 Ok(scale)
711}
712
713fn alo_link_needs_exact_curvature_refinement(likelihood: &gam_problem::GlmLikelihoodSpec) -> bool {
726 use gam_problem::ResponseFamily;
727 matches!(
728 (&likelihood.spec.response, likelihood.link_function()),
729 (ResponseFamily::Binomial, LinkFunction::Logit)
730 | (ResponseFamily::Poisson, LinkFunction::Log)
731 )
732}
733
734fn compute_alo_diagnostics_from_pirls_inner(
735 base: &pirls::PirlsResult,
736 y: ArrayView1<f64>,
737) -> Result<AloDiagnostics, AloError> {
738 let x_dense_arc = base
739 .x_transformed
740 .try_to_dense_arc("ALO diagnostics require dense transformed design")
741 .map_err(|reason| AloError::DesignDegenerate { reason })?;
742 let x_dense = x_dense_arc.as_ref();
743 let n = x_dense.nrows();
744 if y.len() != n {
745 return Err(AloError::InvalidInput {
746 reason: format!(
747 "ALO response length must match the design row count; got {} responses for {n} rows",
748 y.len()
749 ),
750 });
751 }
752 if alo_link_needs_exact_curvature_refinement(&base.likelihood) {
753 for (row, &response) in y.iter().enumerate() {
754 let valid = response.is_finite()
755 && match &base.likelihood.spec.response {
756 ResponseFamily::Binomial => (0.0..=1.0).contains(&response),
757 ResponseFamily::Poisson => response >= 0.0,
758 _ => true,
759 };
760 if !valid {
761 return Err(AloError::InvalidInput {
762 reason: format!(
763 "ALO canonical refinement received an invalid response at row {row}: {response}"
764 ),
765 });
766 }
767 }
768 }
769
770 let phi = alo_covariance_scale(base)?;
771
772 let h_dense_for_alo = base
776 .dense_stabilizedhessian_transformed(
777 "ALO diagnostics require exact dense stabilized penalized Hessian",
778 )
779 .map_err(|e| match e {
780 EstimationError::InvalidInput(reason) => AloError::InvalidInput { reason },
781 other => AloError::InvalidInput {
782 reason: format!("{other:?}"),
783 },
784 })?;
785
786 let canonical_scale: Option<Array1<f64>> = if alo_link_needs_exact_curvature_refinement(
806 &base.likelihood,
807 ) {
808 let mut c = Array1::<f64>::zeros(n);
809 for i in 0..n {
810 let dmu = base.solve_dmu_deta[i];
811 let w_h = base.finalweights[i];
812 if !dmu.is_finite() || !w_h.is_finite() || dmu < 0.0 || w_h < 0.0 {
813 return Err(AloError::WeightInvalid {
814 reason: format!(
815 "canonical ALO requires finite non-negative local derivative and curvature; row {i} has dmu_deta={dmu}, weight={w_h}"
816 ),
817 });
818 }
819 let scale = if dmu == 0.0 {
820 if w_h == 0.0 {
821 0.0
822 } else {
823 return Err(AloError::LooComputationFailed {
824 reason: format!(
825 "canonical ALO scale is undefined at row {i}: nonzero curvature {w_h} divided by zero inverse-link derivative"
826 ),
827 });
828 }
829 } else {
830 w_h / dmu
831 };
832 if !scale.is_finite() || scale < 0.0 || (w_h > 0.0 && scale == 0.0) {
833 return Err(AloError::LooComputationFailed {
834 reason: format!(
835 "canonical ALO scale is not representable at row {i}: weight={w_h}, dmu_deta={dmu}, scale={scale}"
836 ),
837 });
838 }
839 c[i] = scale;
840 }
841 Some(c)
842 } else {
843 None
844 };
845
846 let inv_link_for_closure = base.likelihood.spec.link.clone();
847 let score_curvature_closure = canonical_scale.as_ref().map(|scale| {
848 move |i: usize, eta: f64| -> Result<(f64, f64), AloError> {
849 let (mu, dmu) = crate::mixture_link::inverse_link_mu_d1_for_inverse_link(
850 &inv_link_for_closure,
851 eta,
852 )
853 .map_err(|error| AloError::LooComputationFailed {
854 reason: format!(
855 "ALO inverse-link evaluation failed at row {i}, eta={eta}: {error}"
856 ),
857 })?;
858 let c_i = scale[i];
859 let score = c_i * (mu - y[i]);
860 let curvature = c_i * dmu;
861 if !score.is_finite() || !curvature.is_finite() {
862 return Err(AloError::LooComputationFailed {
863 reason: format!(
864 "ALO canonical row geometry is not representable at row {i}, eta={eta}: score={score}, curvature={curvature}"
865 ),
866 });
867 }
868 Ok((score, curvature))
869 }
870 });
871 let score_curvature_ref: Option<&AloScalarScoreCurvature> = score_curvature_closure
872 .as_ref()
873 .map(|f| f as &AloScalarScoreCurvature);
874
875 let alo_working_response = base.solveworking_response.to_owned();
880 let alo_final_eta = base.final_eta.to_owned();
881 let alo_final_offset = base.final_offset.to_owned();
882 let input = AloInput {
883 design: x_dense,
884 penalized_hessian: &h_dense_for_alo,
885 hessian_weights: base.final_weights_signed(),
886 score_weights: base.solve_weights_psd(),
887 working_response: &alo_working_response,
888 eta: &alo_final_eta,
889 offset: &alo_final_offset,
890 phi,
891 score_curvature: score_curvature_ref,
892 };
893
894 let result = compute_alo_from_input_inner(&input)?;
895
896 log_leverage_diagnostics(&result.leverage, phi);
898
899 Ok(result)
900}
901
902fn log_leverage_diagnostics(leverage: &Array1<f64>, phi: f64) {
904 let n = leverage.len();
905 if n == 0 {
906 return;
907 }
908
909 let mut invalid_count = 0usize;
910 let mut high_leverage_count = 0usize;
911 let mut threshold_counts = [0usize; LEVERAGE_RATE_THRESHOLDS.len()];
912 let mut finite_leverage = Vec::with_capacity(n);
913
914 for (obs, &ai) in leverage.iter().enumerate() {
915 if ai.is_finite() {
916 finite_leverage.push(ai);
917 }
918
919 if !ai.is_finite() {
925 invalid_count += 1;
926 log::warn!("[GAM ALO] invalid leverage at i={}, a_ii={:.6e}", obs, ai);
927 } else if ai > LEVERAGE_HIGH_THRESHOLD {
928 high_leverage_count += 1;
929 if ai > LEVERAGE_VERY_HIGH_THRESHOLD {
930 log::warn!("[GAM ALO] very high leverage at i={}, a_ii={:.6e}", obs, ai);
931 }
932 }
933
934 for (idx, threshold) in LEVERAGE_RATE_THRESHOLDS.iter().enumerate() {
935 if ai > *threshold {
936 threshold_counts[idx] += 1;
937 }
938 }
939 }
940
941 if invalid_count > 0 || high_leverage_count > 0 {
942 log::warn!(
943 "[GAM ALO] leverage diagnostics: {} invalid values, {} high values (>0.99)",
944 invalid_count,
945 high_leverage_count
946 );
947 }
948
949 finite_leverage.sort_by(f64::total_cmp);
950
951 let finite_n = finite_leverage.len();
952 let a_mean = if finite_n > 0 {
953 finite_leverage.iter().copied().sum::<f64>() / finite_n as f64
954 } else {
955 0.0
956 };
957 let a_median = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[0]);
958 let a_p95 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[1]);
959 let a_p99 = percentile_from_sorted(&finite_leverage, LEVERAGE_PERCENTILES[2]);
960 let a_max = finite_leverage.last().copied().unwrap_or(0.0);
961
962 log::info!(
971 "[GAM ALO] leverage: n={}, mean={:.3e}, median={:.3e}, p95={:.3e}, p99={:.3e}, max={:.3e}",
972 n,
973 a_mean,
974 a_median,
975 a_p95,
976 a_p99,
977 a_max
978 );
979 log::info!(
980 "[GAM ALO] high-leverage: a>0.90: {:.2}%, a>0.95: {:.2}%, a>0.99: {:.2}%, dispersion phi={:.3e}",
981 100.0 * (threshold_counts[0] as f64) / n as f64,
982 100.0 * (threshold_counts[1] as f64) / n as f64,
983 100.0 * (threshold_counts[2] as f64) / n as f64,
984 phi
985 );
986}
987
988pub struct AloInput<'a> {
995 pub design: &'a Array2<f64>,
997 pub penalized_hessian: &'a Array2<f64>,
999 pub hessian_weights: SignedWeightsView<'a>,
1006 pub score_weights: PsdWeightsView<'a>,
1009 pub working_response: &'a Array1<f64>,
1011 pub eta: &'a Array1<f64>,
1013 pub offset: &'a Array1<f64>,
1015 pub phi: f64,
1017 pub score_curvature: Option<&'a AloScalarScoreCurvature<'a>>,
1030}
1031
1032impl<'a> AloInput<'a> {
1033 fn from_active_geometry(
1038 geom: &'a FitGeometry,
1039 working: &'a WorkingGeometry,
1040 design: &'a Array2<f64>,
1041 eta: &'a Array1<f64>,
1042 offset: &'a Array1<f64>,
1043 phi: f64,
1044 ) -> Self {
1045 let psd_w = PsdWeightsView::from_view_unchecked(working.weights.view());
1052 Self {
1053 design,
1054 penalized_hessian: &geom.penalized_hessian,
1055 hessian_weights: psd_w.as_signed(),
1056 score_weights: psd_w,
1057 working_response: &working.response,
1058 eta,
1059 offset,
1060 phi,
1061 score_curvature: None,
1062 }
1063 }
1064
1065 pub fn from_penalized_hessian_with_working_state(
1083 penalized_hessian: &'a Array2<f64>,
1084 design: &'a Array2<f64>,
1085 eta: &'a Array1<f64>,
1086 offset: &'a Array1<f64>,
1087 phi: f64,
1088 working_weights: &'a Array1<f64>,
1089 working_response: &'a Array1<f64>,
1090 ) -> Self {
1091 let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
1092 Self {
1093 design,
1094 penalized_hessian,
1095 hessian_weights: psd_w.as_signed(),
1096 score_weights: psd_w,
1097 working_response,
1098 eta,
1099 offset,
1100 phi,
1101 score_curvature: None,
1102 }
1103 }
1104}
1105
1106pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
1112 compute_alo_from_input_inner(input).map_err(EstimationError::from)
1113}
1114
1115fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
1116 let x_dense = input.design;
1117 let n = x_dense.nrows();
1118 let p = x_dense.ncols();
1119 let w_h = input.hessian_weights.view();
1123 let w_s = input.score_weights.view();
1124
1125 validate_alo_solve_setup(input, n, p)?;
1126
1127 let factor = certified_spd_factorize(input.penalized_hessian, "ALO penalized Hessian")
1128 .map_err(|error| AloError::InvalidInput {
1129 reason: format!(
1130 "ALO requires an unperturbed positive-definite penalized Hessian with a certified solve: {error}"
1131 ),
1132 })?;
1133
1134 let xt = x_dense.t();
1135 let phi = input.phi;
1136
1137 let mut aii = Array1::<f64>::zeros(n);
1138 let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
1139 let mut se_bayes = Array1::<f64>::zeros(n);
1140 let mut se_sandwich = Array1::<f64>::zeros(n);
1141
1142 let block_cols = alo_rhs_block_cols(n, p);
1143 let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
1148 let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
1153 let x_dense_view = FaerArrayView::new(x_dense);
1154
1155 for chunk_start in (0..n).step_by(block_cols) {
1156 let chunk_end = (chunk_start + block_cols).min(n);
1157 let width = chunk_end - chunk_start;
1158
1159 rhs_chunk_buf
1160 .slice_mut(s![.., ..width])
1161 .assign(&xt.slice(s![.., chunk_start..chunk_end]));
1162
1163 let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
1164 let rhs_chunk = rhs_chunkview.to_owned();
1165 let (s_chunk, _solve_certificate) = factor.solve_matrix(&rhs_chunk).map_err(|error| {
1166 AloError::LooComputationFailed {
1167 reason: format!(
1168 "ALO penalized-Hessian solve could not be certified for rows {chunk_start}..{chunk_end}: {error}"
1169 ),
1170 }
1171 })?;
1172 let s_chunk_view = FaerArrayView::new(&s_chunk);
1173
1174 let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
1175 matmul(
1176 xs_target.rb_mut(),
1177 Accum::Replace,
1178 x_dense_view.as_ref(),
1179 s_chunk_view.as_ref(),
1180 1.0,
1181 Par::Seq,
1182 );
1183
1184 let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
1185
1186 for local_col in 0..width {
1187 let obs = chunk_start + local_col;
1188 let rhs_col = rhs_view.column(local_col);
1192 let solution_col = s_chunk.column(local_col);
1193 let x_hinv_x = spd_quadratic_after_certified_solve(obs, rhs_col, solution_col)?;
1194 let ai = finite_signed_product(obs, "leverage", w_h[obs], x_hinv_x)?;
1201 aii[obs] = ai;
1202 x_hinv_x_diag[obs] = x_hinv_x;
1203
1204 let var_bayes = finite_nonnegative_product(obs, "Bayesian variance", phi, x_hinv_x)?;
1205 let xs_slice = xs_chunk_storage.col_as_slice(local_col);
1206 let meat_quad = finite_weighted_square_sum(obs, w_s, xs_slice)?;
1211 let var_sandwich =
1212 finite_nonnegative_product(obs, "sandwich variance", phi, meat_quad)?;
1213
1214 se_bayes[obs] = var_bayes.sqrt();
1215 se_sandwich[obs] = var_sandwich.sqrt();
1216 }
1217 }
1218
1219 let eta_hat = input.eta;
1220 let z = input.working_response;
1221 let offset = input.offset;
1222
1223 use rayon::prelude::*;
1224 let eta_tilde_vec: Vec<f64> = (0..n)
1225 .into_par_iter()
1226 .map(|i| {
1227 let denom_raw = 1.0 - aii[i];
1228 if denom_raw == 0.0 || !denom_raw.is_finite() {
1229 return Err(AloError::LooComputationFailed {
1230 reason: format!(
1231 "ALO deletion denominator is not invertible at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}",
1232 aii[i], denom_raw
1233 ),
1234 });
1235 }
1236 let one_step = alo_eta_updatewith_offset(
1237 eta_hat[i],
1238 z[i],
1239 offset[i],
1240 x_hinv_x_diag[i],
1241 w_s[i],
1242 denom_raw,
1243 );
1244 let v = if let Some(score_curvature) = input.score_curvature {
1252 alo_eta_exact_frozen_curvature(
1253 eta_hat[i],
1254 x_hinv_x_diag[i],
1255 &|eta| score_curvature(i, eta),
1256 )
1257 .map_err(|err| AloError::LooComputationFailed {
1258 reason: format!(
1259 "ALO exact frozen-curvature solve failed at row {i}: {err}"
1260 ),
1261 })?
1262 } else {
1263 one_step
1264 };
1265 if !v.is_finite() {
1266 return Err(AloError::LooComputationFailed {
1267 reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1268 });
1269 }
1270 Ok(v)
1271 })
1272 .collect::<Result<_, _>>()?;
1273 let eta_tilde = Array1::from(eta_tilde_vec);
1274
1275 Ok(AloDiagnostics {
1276 eta_tilde,
1277 se_bayes,
1278 se_sandwich,
1279 leverage: aii,
1280 })
1281}
1282
1283fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1284 let h = input.penalized_hessian;
1285 if h.nrows() != p || h.ncols() != p {
1286 return Err(AloError::InvalidInput {
1287 reason: format!(
1288 "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1289 h.nrows(),
1290 h.ncols()
1291 ),
1292 });
1293 }
1294 let vector_lengths = [
1295 ("hessian_weights", input.hessian_weights.len()),
1296 ("score_weights", input.score_weights.len()),
1297 ("working_response", input.working_response.len()),
1298 ("eta", input.eta.len()),
1299 ("offset", input.offset.len()),
1300 ];
1301 for (name, len) in vector_lengths {
1302 if len != n {
1303 return Err(AloError::InvalidInput {
1304 reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1305 });
1306 }
1307 }
1308 if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1309 return Err(AloError::WeightInvalid {
1310 reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1311 });
1312 }
1313 if let Some((row, value)) = input
1314 .score_weights
1315 .view()
1316 .iter()
1317 .copied()
1318 .enumerate()
1319 .find(|(_, value)| !value.is_finite() || *value < 0.0)
1320 {
1321 return Err(AloError::WeightInvalid {
1322 reason: format!(
1323 "ALO diagnostics require finite non-negative score-side weights; row {row} has {value:?}"
1324 ),
1325 });
1326 }
1327 if input.working_response.iter().any(|v| !v.is_finite()) {
1328 return Err(AloError::WeightInvalid {
1329 reason: "ALO diagnostics require finite working responses".to_string(),
1330 });
1331 }
1332 if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1333 return Err(AloError::InvalidInput {
1334 reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1335 });
1336 }
1337 if !input.phi.is_finite() || input.phi <= 0.0 {
1338 return Err(AloError::InvalidInput {
1339 reason: format!(
1340 "ALO diagnostics require positive finite dispersion phi; got {}",
1341 input.phi
1342 ),
1343 });
1344 }
1345 Ok(())
1346}
1347
1348pub fn compute_alo_diagnostics_from_fit(
1350 fit: &UnifiedFitResult,
1351 y: ArrayView1<f64>,
1352) -> Result<AloDiagnostics, EstimationError> {
1353 let pirls = fit
1354 .artifacts
1355 .pirls
1356 .as_ref()
1357 .ok_or_else(|| AloError::InvalidInput {
1358 reason:
1359 "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1360 .to_string(),
1361 })
1362 .map_err(EstimationError::from)?;
1363 compute_alo_diagnostics_from_pirls_impl(pirls, y)
1364}
1365
1366pub fn compute_alo_diagnostics_from_unified(
1372 unified: &UnifiedFitResult,
1373 design: &Array2<f64>,
1374 eta: &Array1<f64>,
1375 offset: &Array1<f64>,
1376 phi: f64,
1377) -> Result<AloDiagnostics, EstimationError> {
1378 let geom = unified
1379 .geometry
1380 .as_ref()
1381 .ok_or_else(|| AloError::InvalidInput {
1382 reason: "UnifiedFitResult does not contain working-set geometry; \
1383 ALO diagnostics require geometry at convergence"
1384 .to_string(),
1385 })
1386 .map_err(EstimationError::from)?;
1387 let working = geom.working.as_ref().ok_or_else(|| {
1388 EstimationError::from(AloError::InvalidInput {
1389 reason: "UnifiedFitResult coefficient geometry has no owned single-diagonal working evidence; ALO diagnostics are unavailable for Exact-Newton and multi-parameter terminal geometry"
1390 .to_string(),
1391 })
1392 })?;
1393 geom.coefficient_gauge
1394 .validate()
1395 .map_err(|reason| AloError::InvalidInput {
1396 reason: format!("UnifiedFitResult ALO coefficient gauge is invalid: {reason}"),
1397 })
1398 .map_err(EstimationError::from)?;
1399 if design.ncols() != geom.coefficient_gauge.raw_total() {
1400 return Err(AloError::InvalidInput {
1401 reason: format!(
1402 "UnifiedFitResult ALO raw design has {} columns; coefficient gauge requires {}",
1403 design.ncols(),
1404 geom.coefficient_gauge.raw_total(),
1405 ),
1406 }
1407 .into());
1408 }
1409 let active_design = geom.coefficient_gauge.restrict_design(design);
1410 let input =
1411 AloInput::from_active_geometry(geom, working, &active_design, eta, offset, phi);
1412 compute_alo_from_input(&input)
1413}
1414
1415pub fn compute_alo_diagnostics_from_pirls(
1417 base: &pirls::PirlsResult,
1418 y: ArrayView1<f64>,
1419) -> Result<AloDiagnostics, EstimationError> {
1420 compute_alo_diagnostics_from_pirls_impl(base, y)
1421}
1422
1423pub fn compute_case_deletion_from_pirls(
1442 base: &pirls::PirlsResult,
1443) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1444 let x_dense_arc = base
1445 .x_transformed
1446 .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1447 .map_err(|reason| EstimationError::InvalidInput(reason))?;
1448 let x_dense = x_dense_arc.as_ref();
1449 let n = x_dense.nrows();
1450 let p = x_dense.ncols();
1451 if n == 0 || p == 0 {
1452 return Ok(None);
1453 }
1454
1455 let phi = alo_covariance_scale(base).map_err(EstimationError::from)?;
1456
1457 let h_dense = base
1460 .dense_stabilizedhessian_transformed(
1461 "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1462 )
1463 .map_err(|e| match e {
1464 EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1465 other => EstimationError::InvalidInput(format!("{other:?}")),
1466 })?;
1467
1468 let factor = match h_dense.cholesky(faer::Side::Lower) {
1469 Ok(f) => f,
1470 Err(_) => return Ok(None),
1474 };
1475
1476 let working_weights = base.finalweights.clone();
1480 let working_residual = &base.solveworking_response - &base.final_eta;
1481
1482 let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1483 Ok(sensitivity.case_deletion(
1484 x_dense,
1485 working_weights.view(),
1486 working_residual.view(),
1487 phi,
1488 ))
1489}
1490
1491#[derive(Debug, Clone)]
1495pub struct MultiBlockAloDiagnostics {
1496 pub eta_tilde: Vec<Array1<f64>>,
1499 pub leverage: Array1<f64>,
1501 pub alo_variance: Vec<Array1<f64>>,
1507 pub predictive_variance: Vec<Array1<f64>>,
1519 pub cook_distance: Array1<f64>,
1522}
1523
1524pub struct MultiBlockAloInput<'a> {
1556 pub n_obs: usize,
1558 pub n_coordinates: usize,
1560 pub coordinate_designs: &'a [DesignMatrix],
1563 pub coordinate_coefficient_ranges: &'a [Range<usize>],
1567 pub penalized_hessian: &'a Array2<f64>,
1570 pub observed_hessians: &'a [Array2<f64>],
1573 pub score_covariances: &'a [Array2<f64>],
1576 pub scores: &'a [Array1<f64>],
1579 pub coordinate_values: &'a [Array1<f64>],
1583}
1584
1585pub fn compute_multiblock_alo(
1604 input: &MultiBlockAloInput,
1605) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1606 compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1607}
1608
1609fn validate_multiblock_alo_input(input: &MultiBlockAloInput<'_>) -> Result<(), AloError> {
1610 let n = input.n_obs;
1611 let b = input.n_coordinates;
1612 if n == 0 || b == 0 {
1613 return Err(AloError::InvalidInput {
1614 reason: format!(
1615 "multi-block ALO requires positive observation and coordinate counts; got n={n}, B={b}"
1616 ),
1617 });
1618 }
1619 if input.coordinate_designs.len() != b {
1620 return Err(AloError::InvalidInput {
1621 reason: format!(
1622 "multi-block ALO expected {b} coordinate designs, got {}",
1623 input.coordinate_designs.len()
1624 ),
1625 });
1626 }
1627 let p_tot = input.penalized_hessian.nrows();
1628 if input.penalized_hessian.ncols() != p_tot || p_tot == 0 {
1629 return Err(AloError::InvalidInput {
1630 reason: format!(
1631 "multi-block ALO penalized Hessian must be non-empty and square; got {}x{}",
1632 input.penalized_hessian.nrows(),
1633 input.penalized_hessian.ncols()
1634 ),
1635 });
1636 }
1637 if input.coordinate_coefficient_ranges.len() != b {
1638 return Err(AloError::InvalidInput {
1639 reason: format!(
1640 "multi-block ALO expected {b} coordinate coefficient ranges, got {}",
1641 input.coordinate_coefficient_ranges.len()
1642 ),
1643 });
1644 }
1645 for (coordinate, (design, coefficient_range)) in input
1646 .coordinate_designs
1647 .iter()
1648 .zip(input.coordinate_coefficient_ranges)
1649 .enumerate()
1650 {
1651 if design.nrows() != n {
1652 return Err(AloError::InvalidInput {
1653 reason: format!(
1654 "multi-block ALO coordinate design {coordinate} has {} rows; expected {n}",
1655 design.nrows()
1656 ),
1657 });
1658 }
1659 if design.ncols() == 0 || coefficient_range.is_empty() {
1660 return Err(AloError::InvalidInput {
1661 reason: format!(
1662 "multi-block ALO coordinate {coordinate} has an empty local design or coefficient range"
1663 ),
1664 });
1665 }
1666 if coefficient_range.len() != design.ncols() || coefficient_range.end > p_tot {
1667 return Err(AloError::InvalidInput {
1668 reason: format!(
1669 "multi-block ALO coordinate {coordinate} design has {} columns but parameter range {}..{} has length {} in a {p_tot}-dimensional saved Hessian",
1670 design.ncols(),
1671 coefficient_range.start,
1672 coefficient_range.end,
1673 coefficient_range.len()
1674 ),
1675 });
1676 }
1677 }
1678 for (label, length) in [
1679 ("observed_hessians", input.observed_hessians.len()),
1680 ("score_covariances", input.score_covariances.len()),
1681 ("scores", input.scores.len()),
1682 ("coordinate_values", input.coordinate_values.len()),
1683 ] {
1684 if length != n {
1685 return Err(AloError::InvalidInput {
1686 reason: format!("multi-block ALO requires {label} length {n}; got {length}"),
1687 });
1688 }
1689 }
1690 for row in 0..n {
1691 let observed = &input.observed_hessians[row];
1692 let score_covariance = &input.score_covariances[row];
1693 for (label, matrix) in [
1694 ("observed Hessian", observed),
1695 ("score covariance", score_covariance),
1696 ] {
1697 if matrix.dim() != (b, b) {
1698 return Err(AloError::InvalidInput {
1699 reason: format!(
1700 "multi-block ALO row {row} {label} has shape {}x{}; expected {b}x{b}",
1701 matrix.nrows(),
1702 matrix.ncols()
1703 ),
1704 });
1705 }
1706 validate_finite_symmetric_matrix(matrix, &format!("multi-block ALO row {row} {label}"))
1707 .map_err(|error| AloError::InvalidInput {
1708 reason: error.to_string(),
1709 })?;
1710 }
1711 let covariance_scale = score_covariance
1712 .iter()
1713 .fold(0.0_f64, |scale, value| scale.max(value.abs()));
1714 let (minimum, maximum) =
1715 symmetric_extremes(score_covariance).ok_or_else(|| AloError::InvalidInput {
1716 reason: format!(
1717 "multi-block ALO row {row} score-covariance eigendecomposition failed"
1718 ),
1719 })?;
1720 let psd_tolerance = LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR
1721 * b as f64
1722 * f64::EPSILON
1723 * covariance_scale.max(maximum.abs());
1724 if minimum < -psd_tolerance {
1725 return Err(AloError::InvalidInput {
1726 reason: format!(
1727 "multi-block ALO row {row} score covariance is not positive semidefinite: minimum eigenvalue {minimum:.6e}, roundoff allowance {psd_tolerance:.6e}"
1728 ),
1729 });
1730 }
1731 for (label, vector) in [
1732 ("score", &input.scores[row]),
1733 ("coordinate value", &input.coordinate_values[row]),
1734 ] {
1735 if vector.len() != b {
1736 return Err(AloError::InvalidInput {
1737 reason: format!(
1738 "multi-block ALO row {row} {label} has length {}; expected {b}",
1739 vector.len()
1740 ),
1741 });
1742 }
1743 if let Some((coordinate, value)) = vector
1744 .iter()
1745 .copied()
1746 .enumerate()
1747 .find(|(_, value)| !value.is_finite())
1748 {
1749 return Err(AloError::InvalidInput {
1750 reason: format!(
1751 "multi-block ALO row {row} {label} coordinate {coordinate} is non-finite: {value}"
1752 ),
1753 });
1754 }
1755 }
1756 }
1757 Ok(())
1758}
1759
1760fn compute_multiblock_alo_inner(
1761 input: &MultiBlockAloInput,
1762) -> Result<MultiBlockAloDiagnostics, AloError> {
1763 use rayon::prelude::*;
1764
1765 let n = input.n_obs;
1766 let b = input.n_coordinates;
1767 let p_tot = input.penalized_hessian.nrows();
1768 validate_multiblock_alo_input(input)?;
1769 let factor = certified_spd_factorize(input.penalized_hessian, "multi-block ALO penalized Hessian")
1770 .map_err(|error| AloError::InvalidInput {
1771 reason: format!(
1772 "multi-block ALO requires an unperturbed positive-definite saved penalized Hessian: {error}"
1773 ),
1774 })?;
1775
1776 let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1777 let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1778
1779 let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1785 Vec::with_capacity(chunk_starts.len());
1786 for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1787 let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1788 .par_iter()
1789 .map_init(
1790 || MultiBlockAloScratch::new(b),
1791 |scratch, &chunk_start| {
1792 let chunk_end = (chunk_start + chunk_size).min(n);
1793 compute_multiblock_alo_chunk(input, &factor, chunk_start, chunk_end, scratch)
1794 },
1795 )
1796 .collect();
1797 chunk_results.append(&mut wave_results);
1798 }
1799
1800 let mut eta_tilde = Vec::with_capacity(n);
1801 let mut leverage = Array1::<f64>::zeros(n);
1802 let mut alo_variance = Vec::with_capacity(n);
1803 let mut predictive_variance = Vec::with_capacity(n);
1804 let mut cook_distance = Array1::<f64>::zeros(n);
1805
1806 let mut chunks = Vec::with_capacity(chunk_results.len());
1807 for result in chunk_results {
1808 chunks.push(result?);
1809 }
1810 chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1811
1812 for chunk in chunks {
1813 let chunk_start = chunk.chunk_start;
1814 eta_tilde.extend(chunk.eta_tilde);
1815 alo_variance.extend(chunk.alo_variance);
1816 predictive_variance.extend(chunk.predictive_variance);
1817 for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1818 leverage[chunk_start + local_i] = lev;
1819 }
1820 for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1821 cook_distance[chunk_start + local_i] = cook;
1822 }
1823 }
1824
1825 Ok(MultiBlockAloDiagnostics {
1826 eta_tilde,
1827 leverage,
1828 alo_variance,
1829 predictive_variance,
1830 cook_distance,
1831 })
1832}
1833
1834#[inline]
1835fn multiblock_alo_parallel_plan(
1836 p_tot: usize,
1837 n_coordinates: usize,
1838 n_obs: usize,
1839) -> (usize, usize) {
1840 if p_tot == 0 || n_coordinates == 0 || n_obs == 0 {
1841 return (1, 1);
1842 }
1843 let bytes_per_obs = p_tot
1846 .saturating_mul(n_coordinates)
1847 .saturating_mul(2)
1848 .saturating_mul(std::mem::size_of::<f64>())
1849 .max(1);
1850 let workers = rayon::current_num_threads().max(1);
1851 let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1852 .max(1)
1853 .min(workers);
1854 let per_worker_budget =
1855 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1856 let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1857 (budget_obs.min(n_obs), max_concurrent_chunks)
1858}
1859
1860struct MultiBlockAloScratch {
1861 a_i: Vec<f64>,
1862 wa: Vec<f64>,
1863 aw: Vec<f64>,
1864 imwa: Vec<f64>,
1865 imaw: Vec<f64>,
1866 perm_imwa: Vec<usize>,
1867 perm_imaw: Vec<usize>,
1868 delta_eta: Vec<f64>,
1869 rhs_buf: Vec<f64>,
1870 covariance_u: Vec<f64>,
1871 var_diag_buf: Vec<f64>,
1872 w_flat: Vec<f64>,
1873 covariance_flat: Vec<f64>,
1874 lu_scratch: Vec<f64>,
1875 original_rhs: Vec<f64>,
1876}
1877
1878impl MultiBlockAloScratch {
1879 fn new(b: usize) -> Self {
1880 let bb_sz = b * b;
1881 Self {
1882 a_i: vec![0.0f64; bb_sz],
1883 wa: vec![0.0f64; bb_sz],
1884 aw: vec![0.0f64; bb_sz],
1885 imwa: vec![0.0f64; bb_sz],
1886 imaw: vec![0.0f64; bb_sz],
1887 perm_imwa: vec![0usize; b],
1888 perm_imaw: vec![0usize; b],
1889 delta_eta: vec![0.0f64; b],
1890 rhs_buf: vec![0.0f64; b],
1891 covariance_u: vec![0.0f64; b],
1892 var_diag_buf: vec![0.0f64; b],
1893 w_flat: vec![0.0f64; bb_sz],
1894 covariance_flat: vec![0.0f64; bb_sz],
1895 lu_scratch: vec![0.0f64; b],
1896 original_rhs: vec![0.0f64; b],
1897 }
1898 }
1899}
1900
1901struct MultiBlockAloChunkDiagnostics {
1902 chunk_start: usize,
1903 eta_tilde: Vec<Array1<f64>>,
1904 leverage: Vec<f64>,
1905 alo_variance: Vec<Array1<f64>>,
1906 predictive_variance: Vec<Array1<f64>>,
1907 cook_distance: Vec<f64>,
1908}
1909
1910fn compute_multiblock_alo_chunk(
1911 input: &MultiBlockAloInput,
1912 factor: &CertifiedSpdFactor<'_>,
1913 chunk_start: usize,
1914 chunk_end: usize,
1915 scratch: &mut MultiBlockAloScratch,
1916) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1917 let b = input.n_coordinates;
1918 let p_tot = input.penalized_hessian.nrows();
1919 let chunk_len = chunk_end - chunk_start;
1920
1921 let mut design_chunks = Vec::with_capacity(b);
1922 let mut q_blocks = Vec::with_capacity(b);
1923 for coordinate in 0..b {
1924 let design_chunk = input.coordinate_designs[coordinate]
1925 .try_row_chunk(chunk_start..chunk_end)
1926 .map_err(|reason| AloError::DesignDegenerate {
1927 reason: format!(
1928 "multi-block ALO could not materialize coordinate {coordinate} rows {chunk_start}..{chunk_end}: {reason}"
1929 ),
1930 })?;
1931 if let Some(((row, column), value)) = design_chunk
1932 .indexed_iter()
1933 .map(|(index, &value)| (index, value))
1934 .find(|(_, value)| !value.is_finite())
1935 {
1936 return Err(AloError::DesignDegenerate {
1937 reason: format!(
1938 "multi-block ALO coordinate {coordinate} design is non-finite at source row {}, column {column}: {value}",
1939 chunk_start + row
1940 ),
1941 });
1942 }
1943 let coefficient_range = input.coordinate_coefficient_ranges[coordinate].clone();
1944 let mut rhs = Array2::<f64>::zeros((p_tot, chunk_len));
1945 rhs.slice_mut(s![coefficient_range, ..])
1946 .assign(&design_chunk.t());
1947 let (solution, _) = factor.solve_matrix(&rhs).map_err(|error| {
1948 AloError::LooComputationFailed {
1949 reason: format!(
1950 "multi-block ALO saved-Hessian solve failed for coordinate {coordinate}, rows {chunk_start}..{chunk_end}: {error}"
1951 ),
1952 }
1953 })?;
1954 design_chunks.push(design_chunk);
1955 q_blocks.push(solution);
1956 }
1957
1958 let mut eta_tilde = Vec::with_capacity(chunk_len);
1959 let mut leverage = vec![0.0f64; chunk_len];
1960 let mut alo_variance = Vec::with_capacity(chunk_len);
1961 let mut predictive_variance = Vec::with_capacity(chunk_len);
1962 let mut cook_distance = vec![0.0f64; chunk_len];
1963
1964 for local_i in 0..chunk_len {
1965 let i = chunk_start + local_i;
1966 let w_i = &input.observed_hessians[i];
1967 let covariance_i = &input.score_covariances[i];
1968
1969 for r in 0..b {
1972 for c in 0..b {
1973 scratch.w_flat[r * b + c] = w_i[(r, c)];
1974 scratch.covariance_flat[r * b + c] = covariance_i[(r, c)];
1975 }
1976 }
1977
1978 for a in 0..b {
1980 let x_a = &design_chunks[a];
1981 let p_a = x_a.ncols();
1982 let off_a = input.coordinate_coefficient_ranges[a].start;
1983 let xa_row = x_a.row(local_i);
1984 for bb in 0..b {
1985 let q_bb = &q_blocks[bb];
1986 let mut dot = 0.0f64;
1987 for k in 0..p_a {
1988 dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1989 }
1990 scratch.a_i[a * b + bb] = dot;
1991 }
1992 }
1993
1994 let mut pred_var = Array1::<f64>::zeros(b);
1999 for d in 0..b {
2000 pred_var[d] = scratch.a_i[d * b + d].max(0.0);
2001 }
2002 predictive_variance.push(pred_var);
2003
2004 mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
2006 mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
2008
2009 let mut tr = 0.0f64;
2012 for d in 0..b {
2013 tr += scratch.aw[d * b + d];
2014 }
2015 leverage[local_i] = tr;
2016
2017 for r in 0..b {
2019 for c in 0..b {
2020 let idx = r * b + c;
2021 let id = if r == c { 1.0 } else { 0.0 };
2022 scratch.imwa[idx] = id - scratch.wa[idx];
2023 scratch.imaw[idx] = id - scratch.aw[idx];
2024 }
2025 }
2026
2027 let imwa_tolerance =
2034 identity_minus_product_lu_tolerance(&scratch.w_flat, &scratch.a_i, &scratch.wa, b)?;
2035 if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b, imwa_tolerance) {
2036 return Err(AloError::LooComputationFailed {
2037 reason: format!(
2038 "multi-block ALO deletion system I-WA is singular at row {i}; local pivot allowance {imwa_tolerance:.6e}, leverage trace {:.6e}",
2039 leverage[local_i]
2040 ),
2041 });
2042 }
2043 let imaw_tolerance =
2044 identity_minus_product_lu_tolerance(&scratch.a_i, &scratch.w_flat, &scratch.aw, b)?;
2045 if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b, imaw_tolerance) {
2046 return Err(AloError::LooComputationFailed {
2047 reason: format!(
2048 "multi-block ALO transpose deletion system I-AW is singular at row {i}; local pivot allowance {imaw_tolerance:.6e}, leverage trace {:.6e}",
2049 leverage[local_i]
2050 ),
2051 });
2052 }
2053
2054 let s_i = &input.scores[i];
2056 for k in 0..b {
2057 scratch.rhs_buf[k] = s_i[k];
2058 }
2059 if let Err(failure) = solve_identity_minus_product_in_place(
2060 &scratch.imwa,
2061 &scratch.perm_imwa,
2062 &scratch.wa,
2063 &mut scratch.rhs_buf,
2064 &mut scratch.lu_scratch,
2065 &mut scratch.original_rhs,
2066 imwa_tolerance,
2067 b,
2068 ) {
2069 return Err(AloError::LooComputationFailed {
2070 reason: format!(
2071 "multi-block ALO deletion solve I-WA failed backward-error certification at row {i}: residual {:.6e}, allowance {:.6e}",
2072 failure.residual_norm, failure.allowance
2073 ),
2074 });
2075 }
2076 for r in 0..b {
2078 let mut acc = 0.0f64;
2079 let row_off = r * b;
2080 for k in 0..b {
2081 acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
2082 }
2083 scratch.delta_eta[r] = acc;
2084 }
2085
2086 let eta_i = &input.coordinate_values[i];
2087 let mut corrected = Array1::<f64>::zeros(b);
2088 for d in 0..b {
2089 corrected[d] = eta_i[d] + scratch.delta_eta[d];
2090 if !scratch.delta_eta[d].is_finite() || !corrected[d].is_finite() {
2091 return Err(AloError::LooComputationFailed {
2092 reason: format!(
2093 "multi-block ALO correction is non-finite at row {i}, coordinate {d}: delta={}, corrected={}",
2094 scratch.delta_eta[d], corrected[d]
2095 ),
2096 });
2097 }
2098 }
2099 eta_tilde.push(corrected);
2100
2101 let mut cook = 0.0f64;
2103 let mut cook_scale = 0.0f64;
2104 for r in 0..b {
2105 let mut covariance_delta_r = 0.0f64;
2106 let row_off = r * b;
2107 for k in 0..b {
2108 covariance_delta_r += scratch.covariance_flat[row_off + k] * scratch.delta_eta[k];
2109 }
2110 let term = scratch.delta_eta[r] * covariance_delta_r;
2111 cook += term;
2112 cook_scale += term.abs();
2113 }
2114 let cook_tolerance =
2115 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * cook_scale;
2116 if !cook.is_finite() || cook < -cook_tolerance {
2117 return Err(AloError::LooComputationFailed {
2118 reason: format!(
2119 "multi-block ALO Cook influence is invalid at row {i}: value {cook:.6e}, roundoff allowance {cook_tolerance:.6e}"
2120 ),
2121 });
2122 }
2123 cook_distance[local_i] = cook.max(0.0);
2124
2125 for d in 0..b {
2131 let row_off = d * b;
2132 for k in 0..b {
2134 scratch.rhs_buf[k] = scratch.a_i[row_off + k];
2135 }
2136 if let Err(failure) = solve_identity_minus_product_in_place(
2137 &scratch.imaw,
2138 &scratch.perm_imaw,
2139 &scratch.aw,
2140 &mut scratch.rhs_buf,
2141 &mut scratch.lu_scratch,
2142 &mut scratch.original_rhs,
2143 imaw_tolerance,
2144 b,
2145 ) {
2146 return Err(AloError::LooComputationFailed {
2147 reason: format!(
2148 "multi-block ALO transpose variance solve I-AW failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2149 failure.residual_norm, failure.allowance
2150 ),
2151 });
2152 }
2153 for r in 0..b {
2155 let mut acc = 0.0f64;
2156 let wr = r * b;
2157 for k in 0..b {
2158 acc += scratch.covariance_flat[wr + k] * scratch.rhs_buf[k];
2159 }
2160 scratch.covariance_u[r] = acc;
2161 }
2162 if let Err(failure) = solve_identity_minus_product_in_place(
2164 &scratch.imwa,
2165 &scratch.perm_imwa,
2166 &scratch.wa,
2167 &mut scratch.covariance_u,
2168 &mut scratch.lu_scratch,
2169 &mut scratch.original_rhs,
2170 imwa_tolerance,
2171 b,
2172 ) {
2173 return Err(AloError::LooComputationFailed {
2174 reason: format!(
2175 "multi-block ALO variance solve I-WA failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2176 failure.residual_norm, failure.allowance
2177 ),
2178 });
2179 }
2180 let mut v_dd = 0.0f64;
2182 for k in 0..b {
2183 v_dd += scratch.a_i[row_off + k] * scratch.covariance_u[k];
2184 }
2185 let variance_scale = scratch.a_i[row_off..row_off + b]
2186 .iter()
2187 .zip(scratch.covariance_u.iter())
2188 .map(|(left, right)| (left * right).abs())
2189 .sum::<f64>();
2190 let variance_tolerance =
2191 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * variance_scale;
2192 if !v_dd.is_finite() || v_dd < -variance_tolerance {
2193 return Err(AloError::LooComputationFailed {
2194 reason: format!(
2195 "multi-block ALO variance is invalid at row {i}, coordinate {d}: value {v_dd:.6e}, roundoff allowance {variance_tolerance:.6e}"
2196 ),
2197 });
2198 }
2199 scratch.var_diag_buf[d] = v_dd.max(0.0);
2200 }
2201 let mut var_diag = Array1::<f64>::zeros(b);
2202 for d in 0..b {
2203 var_diag[d] = scratch.var_diag_buf[d];
2204 }
2205 alo_variance.push(var_diag);
2206 }
2207
2208 Ok(MultiBlockAloChunkDiagnostics {
2209 chunk_start,
2210 eta_tilde,
2211 leverage,
2212 alo_variance,
2213 predictive_variance,
2214 cook_distance,
2215 })
2216}
2217
2218#[inline]
2220fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
2221 for r in 0..b {
2222 let ar = r * b;
2223 let or = r * b;
2224 for c in 0..b {
2225 let mut acc = 0.0f64;
2226 for k in 0..b {
2227 acc += a[ar + k] * b_mat[k * b + c];
2228 }
2229 out[or + c] = acc;
2230 }
2231 }
2232}
2233
2234#[inline]
2237fn floating_point_gamma(operation_count: usize) -> f64 {
2238 let accumulated = operation_count as f64 * (0.5 * f64::EPSILON);
2239 if accumulated < 1.0 {
2240 accumulated / (1.0 - accumulated)
2241 } else {
2242 f64::INFINITY
2243 }
2244}
2245
2246fn identity_minus_product_lu_tolerance(
2257 left: &[f64],
2258 right: &[f64],
2259 product: &[f64],
2260 b: usize,
2261) -> Result<f64, AloError> {
2262 let expected_len = b.checked_mul(b).ok_or_else(|| AloError::InvalidInput {
2263 reason: format!(
2264 "multi-block ALO local deletion dimension B={b} overflows the square matrix size"
2265 ),
2266 })?;
2267 for (name, actual_len) in [
2268 ("left operand", left.len()),
2269 ("right operand", right.len()),
2270 ("precomputed product", product.len()),
2271 ] {
2272 if actual_len != expected_len {
2273 return Err(AloError::InvalidInput {
2274 reason: format!(
2275 "multi-block ALO local deletion {name} has length {actual_len}, expected B*B={expected_len} for B={b}"
2276 ),
2277 });
2278 }
2279 }
2280
2281 let mut operand_envelope_inf = 0.0_f64;
2282 let mut system_norm_inf = 0.0_f64;
2283 for row in 0..b {
2284 let mut operand_row_envelope = 1.0_f64;
2285 let mut system_row_norm = 0.0_f64;
2286 for column in 0..b {
2287 let mut product_entry_envelope = 0.0_f64;
2288 for inner in 0..b {
2289 product_entry_envelope +=
2290 left[row * b + inner].abs() * right[inner * b + column].abs();
2291 }
2292 operand_row_envelope += product_entry_envelope;
2293 let identity = if row == column { 1.0 } else { 0.0 };
2294 system_row_norm += (identity - product[row * b + column]).abs();
2295 }
2296 operand_envelope_inf = operand_envelope_inf.max(operand_row_envelope);
2297 system_norm_inf = system_norm_inf.max(system_row_norm);
2298 }
2299
2300 let formation_operations = b.saturating_mul(2).saturating_add(1);
2301 let elimination_operations = b.saturating_mul(3);
2302 let backward_error_scale = operand_envelope_inf.max(system_norm_inf);
2303 Ok(
2304 (floating_point_gamma(formation_operations) + floating_point_gamma(elimination_operations))
2305 * backward_error_scale,
2306 )
2307}
2308
2309fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize, pivot_tolerance: f64) -> bool {
2316 for i in 0..b {
2317 perm[i] = i;
2318 }
2319 for col in 0..b {
2320 let mut max_val = m[col * b + col].abs();
2322 let mut max_idx = col;
2323 for row in (col + 1)..b {
2324 let v = m[row * b + col].abs();
2325 if v > max_val {
2326 max_val = v;
2327 max_idx = row;
2328 }
2329 }
2330 if !max_val.is_finite() || max_val <= pivot_tolerance {
2331 return false;
2332 }
2333 if max_idx != col {
2334 for k in 0..b {
2336 m.swap(col * b + k, max_idx * b + k);
2337 }
2338 perm.swap(col, max_idx);
2339 }
2340 let pivot = m[col * b + col];
2341 for row in (col + 1)..b {
2342 let factor = m[row * b + col] / pivot;
2343 m[row * b + col] = factor; for k in (col + 1)..b {
2345 let upd = factor * m[col * b + k];
2346 m[row * b + k] -= upd;
2347 }
2348 }
2349 }
2350 true
2351}
2352
2353fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
2356 let y = &mut scratch[..b];
2358 for row in 0..b {
2359 let mut s = rhs[perm[row]];
2360 for k in 0..row {
2361 s -= m[row * b + k] * y[k];
2362 }
2363 y[row] = s;
2364 }
2365 for row in (0..b).rev() {
2367 let mut s = y[row];
2368 for k in (row + 1)..b {
2369 s -= m[row * b + k] * rhs[k];
2370 }
2371 rhs[row] = s / m[row * b + row];
2372 }
2373}
2374
2375#[derive(Clone, Copy, Debug)]
2376struct LocalSolveResidualFailure {
2377 residual_norm: f64,
2378 allowance: f64,
2379}
2380
2381fn solve_identity_minus_product_in_place(
2388 lu: &[f64],
2389 permutation: &[usize],
2390 product: &[f64],
2391 rhs: &mut [f64],
2392 lu_scratch: &mut [f64],
2393 original_rhs: &mut [f64],
2394 operator_error_bound: f64,
2395 b: usize,
2396) -> Result<(), LocalSolveResidualFailure> {
2397 original_rhs[..b].copy_from_slice(&rhs[..b]);
2398 lu_solve_in_place(lu, permutation, rhs, lu_scratch, b);
2399
2400 let rhs_norm = original_rhs[..b]
2401 .iter()
2402 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2403 let solution_norm = rhs[..b]
2404 .iter()
2405 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2406 let mut system_norm = 0.0_f64;
2407 let mut residual_norm = 0.0_f64;
2408 for row in 0..b {
2409 let mut row_norm = 0.0_f64;
2410 let mut residual = original_rhs[row];
2411 for column in 0..b {
2412 let identity = if row == column { 1.0 } else { 0.0 };
2413 let matrix_entry = identity - product[row * b + column];
2414 row_norm += matrix_entry.abs();
2415 residual -= matrix_entry * rhs[column];
2416 }
2417 system_norm = system_norm.max(row_norm);
2418 residual_norm = residual_norm.max(residual.abs());
2419 }
2420
2421 let certification_operations = b.saturating_mul(10);
2425 let arithmetic_scale = system_norm * solution_norm + rhs_norm;
2426 let allowance = floating_point_gamma(certification_operations) * arithmetic_scale
2427 + operator_error_bound * solution_norm;
2428 if rhs[..b].iter().any(|value| !value.is_finite())
2429 || !residual_norm.is_finite()
2430 || !allowance.is_finite()
2431 || residual_norm > allowance
2432 {
2433 Err(LocalSolveResidualFailure {
2434 residual_norm,
2435 allowance,
2436 })
2437 } else {
2438 Ok(())
2439 }
2440}
2441
2442#[cfg(test)]
2443mod tests {
2444 use super::{
2445 ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
2446 alo_eta_updatewith_offset, compute_alo_from_input_inner, finite_weighted_square_sum,
2447 percentile_from_sorted, percentile_index, spd_quadratic_after_certified_solve,
2448 };
2449 use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
2450
2451 #[test]
2452 fn alo_offset_update_matches_centered_algebra() {
2453 let eta_hat = 11.0;
2454 let z = 13.0;
2455 let offset = 10.0;
2456 let x_hinv_x = 0.2;
2457 let hessian_weight = 1.0;
2458 let score_weight = 1.0;
2459 let leverage = hessian_weight * x_hinv_x;
2461 let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2462 let got =
2463 alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2464 assert!((got - expected).abs() < 1e-12);
2465 }
2466
2467 #[test]
2468 fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2469 let eta_hat = 1.25;
2470 let z = -0.5;
2471 let x_hinv_x = 0.35;
2472 let hessian_weight = 1.0;
2473 let score_weight = 1.0;
2474 let leverage = hessian_weight * x_hinv_x;
2475 let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2476 let got =
2477 alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2478 assert!((got - expected).abs() < 1e-12);
2479 }
2480
2481 #[test]
2482 fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2483 let eta_hat = 1.7;
2484 let z = 0.4;
2485 let offset = -0.2;
2486 let x_hinv_x = 0.15;
2487 let hessian_weight = 3.0;
2488 let score_weight = 5.0;
2489 let expected = offset
2490 + (eta_hat - offset)
2491 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2492 / (1.0 - hessian_weight * x_hinv_x);
2493 let got = alo_eta_updatewith_offset(
2494 eta_hat,
2495 z,
2496 offset,
2497 x_hinv_x,
2498 score_weight,
2499 1.0 - hessian_weight * x_hinv_x,
2500 );
2501 assert!((got - expected).abs() < 1e-12);
2502 }
2503
2504 #[test]
2505 fn alo_offset_update_handles_zero_hessian_weight() {
2506 let eta_hat = 0.8;
2507 let z = -0.3;
2508 let offset = 0.1;
2509 let x_hinv_x = 0.4;
2510 let hessian_weight = 0.0;
2511 let score_weight = 2.5;
2512 let expected = offset
2513 + (eta_hat - offset)
2514 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2515 let got = alo_eta_updatewith_offset(
2516 eta_hat,
2517 z,
2518 offset,
2519 x_hinv_x,
2520 score_weight,
2521 1.0 - hessian_weight * x_hinv_x,
2522 );
2523 assert!((got - expected).abs() < 1e-12);
2524 }
2525
2526 #[test]
2527 fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2528 let eta_hat = 1.0;
2529 let a_ii = 0.4;
2530 let got =
2531 alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| Ok((0.5 * (eta - 2.0), 0.5)))
2532 .expect("linear scalar fixed point should converge in one Newton step");
2533 assert!((got - 0.75).abs() < 1e-12);
2534 }
2535
2536 #[test]
2537 fn alo_exact_frozen_curvature_reports_nonconvergence() {
2538 let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| Ok((eta + 1.0, 0.0)))
2539 .expect_err("constant residual should exhaust the scalar iteration budget");
2540 let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2541 panic!("constant residual must report MaxIterations, got {err:?}");
2542 };
2543 assert_eq!(
2544 iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2545 "non-convergence must report the full scalar iteration budget"
2546 );
2547 }
2548
2549 #[test]
2550 fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2551 let design = Array2::from_elem((1, 1), 1.0);
2552 let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2553 let hessian_weights = Array1::from_vec(vec![0.0]);
2554 let score_weights = Array1::from_vec(vec![0.0]);
2555 let working_response = Array1::from_vec(vec![0.0]);
2556 let eta = Array1::from_vec(vec![0.0]);
2557 let offset = Array1::from_vec(vec![0.0]);
2558 let score_curvature = |_: usize, eta: f64| Ok((eta + 1.0, 0.0));
2559 let input = AloInput {
2560 design: &design,
2561 penalized_hessian: &penalized_hessian,
2562 hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2563 score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2564 working_response: &working_response,
2565 eta: &eta,
2566 offset: &offset,
2567 phi: 1.0,
2568 score_curvature: Some(&score_curvature),
2569 };
2570
2571 let err =
2572 compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2573 let msg = err.to_string();
2574 assert!(
2575 msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2576 "missing row context in exact ALO error: {msg}"
2577 );
2578 assert!(
2579 msg.contains("did not converge within"),
2580 "missing non-convergence cause in exact ALO error: {msg}"
2581 );
2582 }
2583
2584 #[test]
2585 fn alo_scale_safe_quadratics_preserve_tiny_weights_without_false_overflow() {
2586 let weights = Array1::from_vec(vec![1e-300, 2.0]);
2587 let values = [1e200, 3.0];
2588 let meat = finite_weighted_square_sum(0, weights.view(), &values)
2589 .expect("weighted square sum is representable");
2590 assert!(meat.is_finite());
2591 assert!((meat - 1e100).abs() <= 8.0 * f64::EPSILON * 1e100);
2592
2593 let rhs = Array1::from_vec(vec![2.0, -1.0]);
2594 let solution = Array1::from_vec(vec![1.5, 0.5]);
2595 let quadratic =
2596 spd_quadratic_after_certified_solve(0, rhs.view(), solution.view()).unwrap();
2597 assert_eq!(quadratic, 2.5);
2598 }
2599
2600 #[test]
2601 fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2602 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2611 let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2614 let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2616 let phi = 1.3;
2617
2618 let n = x.nrows();
2619 let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2620 let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2621 assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2625 assert!(sum_ws_x2 > 0.0);
2626
2627 let s0 = 8.0_f64;
2629 let h = s0 + sum_wh_x2; assert!(h > 0.0, "penalized Hessian must stay PD");
2631 let penalized_hessian = Array2::from_elem((1, 1), h);
2632
2633 let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2636 assert!(phi * old_meat_obs1 < 0.0, "the pre-fix W_H meat is signed");
2637
2638 let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2639 let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2640 let offset = Array1::zeros(n);
2641 let input = AloInput {
2642 design: &x,
2643 penalized_hessian: &penalized_hessian,
2644 hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2645 score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2646 working_response: &working_response,
2647 eta: &eta,
2648 offset: &offset,
2649 phi,
2650 score_curvature: None,
2651 };
2652
2653 let diag = compute_alo_from_input_inner(&input)
2655 .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2656
2657 for obs in 0..n {
2659 let expected = (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2660 assert!(
2661 (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2662 "row {obs}: se_sandwich={} expected={expected}",
2663 diag.se_sandwich[obs]
2664 );
2665 let expected_leverage = w_h_vec[obs] * x[[obs, 0]] * x[[obs, 0]] / h;
2666 assert!(
2667 (diag.leverage[obs] - expected_leverage).abs()
2668 <= 1e-12 * expected_leverage.abs().max(1.0),
2669 "row {obs}: signed leverage={} expected={expected_leverage}",
2670 diag.leverage[obs]
2671 );
2672 }
2673 assert!(
2674 diag.leverage[1] < 0.0,
2675 "negative observed curvature must remain signed"
2676 );
2677 }
2678
2679 #[test]
2680 fn percentile_index_matches_expected_rounding() {
2681 assert_eq!(percentile_index(0, 0.95), 0);
2682 assert_eq!(percentile_index(1, 0.95), 0);
2683 assert_eq!(percentile_index(10, 0.50), 5);
2684 assert_eq!(percentile_index(10, 0.95), 9);
2685 }
2686
2687 #[test]
2688 fn percentile_from_sorted_returns_order_statistic() {
2689 let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2690 assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2691 assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2692 assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2693 }
2694
2695 use super::{
2698 MultiBlockAloInput, compute_multiblock_alo, floating_point_gamma,
2699 identity_minus_product_lu_tolerance, lu_factor_in_place, mat_mul_flat,
2700 };
2701 use gam_linalg::matrix::DesignMatrix;
2702 use ndarray::{Array1, Array2};
2703
2704 fn local_identity_minus_product_is_factorable(left: &[f64], right: &[f64], b: usize) -> bool {
2705 let mut product = vec![0.0; b * b];
2706 mat_mul_flat(left, right, &mut product, b);
2707 let mut system = vec![0.0; b * b];
2708 for row in 0..b {
2709 for column in 0..b {
2710 let identity = if row == column { 1.0 } else { 0.0 };
2711 system[row * b + column] = identity - product[row * b + column];
2712 }
2713 }
2714 let tolerance = identity_minus_product_lu_tolerance(left, right, &product, b)
2715 .expect("test matrices satisfy the B-by-B local deletion contract");
2716 let mut permutation = vec![0; b];
2717 lu_factor_in_place(&mut system, &mut permutation, b, tolerance)
2718 }
2719
2720 #[test]
2721 fn multiblock_b1_matches_scalar_leverage() {
2722 let n = 3;
2725 let p = 2;
2726 let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2727 let w = [1.0, 2.0, 0.5];
2729 let mut h = Array2::<f64>::eye(p);
2730 for i in 0..n {
2731 for r in 0..p {
2732 for c in 0..p {
2733 h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2734 }
2735 }
2736 }
2737 let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2739 let mut h_inv = Array2::<f64>::zeros((p, p));
2740 h_inv[(0, 0)] = h[(1, 1)] / det;
2741 h_inv[(1, 1)] = h[(0, 0)] / det;
2742 h_inv[(0, 1)] = -h[(0, 1)] / det;
2743 h_inv[(1, 0)] = -h[(1, 0)] / det;
2744
2745 let mut scalar_lev = vec![0.0f64; n];
2747 for i in 0..n {
2748 let mut xhx = 0.0;
2749 for r in 0..p {
2750 for c in 0..p {
2751 xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2752 }
2753 }
2754 scalar_lev[i] = w[i] * xhx;
2755 }
2756
2757 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2760 let coordinate_coefficient_ranges = vec![0..p];
2761 let observed_hessians: Vec<Array2<f64>> =
2762 w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2763 let score_covariances = observed_hessians.clone();
2764 let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2765 let coordinate_values: Vec<Array1<f64>> =
2766 (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2767
2768 let input = MultiBlockAloInput {
2769 n_obs: n,
2770 n_coordinates: 1,
2771 coordinate_designs: &coordinate_designs,
2772 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2773 penalized_hessian: &h,
2774 observed_hessians: &observed_hessians,
2775 score_covariances: &score_covariances,
2776 scores: &scores,
2777 coordinate_values: &coordinate_values,
2778 };
2779
2780 let result = compute_multiblock_alo(&input).unwrap();
2781 for i in 0..n {
2782 assert!(
2783 (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2784 "leverage mismatch at i={}: got {}, expected {}",
2785 i,
2786 result.leverage[i],
2787 scalar_lev[i]
2788 );
2789 }
2790 }
2791
2792 #[test]
2793 fn multiblock_b2_matches_closed_form_with_cross_geometry() {
2794 let coordinate_designs = vec![
2801 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2802 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2803 ];
2804 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2805 let h = Array2::from_shape_vec((2, 2), vec![2.0, 0.25, 0.25, 3.0]).unwrap();
2806 let w = Array2::from_shape_vec((2, 2), vec![0.2, 0.05, 0.05, 0.3]).unwrap();
2807 let c = Array2::from_shape_vec((2, 2), vec![0.5, 0.1, 0.1, 0.4]).unwrap();
2808 let observed_hessians = vec![w.clone()];
2809 let score_covariances = vec![c.clone()];
2810 let scores = vec![Array1::from_vec(vec![0.4, -0.2])];
2811 let coordinate_values = vec![Array1::from_vec(vec![1.0, -0.5])];
2812 let input = MultiBlockAloInput {
2813 n_obs: 1,
2814 n_coordinates: 2,
2815 coordinate_designs: &coordinate_designs,
2816 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2817 penalized_hessian: &h,
2818 observed_hessians: &observed_hessians,
2819 score_covariances: &score_covariances,
2820 scores: &scores,
2821 coordinate_values: &coordinate_values,
2822 };
2823
2824 let det_h = h[[0, 0]] * h[[1, 1]] - h[[0, 1]] * h[[1, 0]];
2825 let a = Array2::from_shape_vec(
2826 (2, 2),
2827 vec![
2828 h[[1, 1]] / det_h,
2829 -h[[0, 1]] / det_h,
2830 -h[[1, 0]] / det_h,
2831 h[[0, 0]] / det_h,
2832 ],
2833 )
2834 .unwrap();
2835 let m = Array2::<f64>::eye(2) - w.dot(&a);
2836 let det_m = m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]];
2837 let m_inv = Array2::from_shape_vec(
2838 (2, 2),
2839 vec![
2840 m[[1, 1]] / det_m,
2841 -m[[0, 1]] / det_m,
2842 -m[[1, 0]] / det_m,
2843 m[[0, 0]] / det_m,
2844 ],
2845 )
2846 .unwrap();
2847 let delta = a.dot(&m_inv.dot(&scores[0]));
2848 let expected_eta = &coordinate_values[0] + δ
2849 let expected_leverage = (a.dot(&w)).diag().sum();
2850 let expected_cook = delta.dot(&c.dot(&delta));
2851 let variance = a.dot(&m_inv).dot(&c).dot(&m_inv.t()).dot(&a.t());
2852
2853 let result = compute_multiblock_alo(&input).expect("B=2 closed-form ALO");
2854 for coordinate in 0..2 {
2855 assert!((result.eta_tilde[0][coordinate] - expected_eta[coordinate]).abs() < 2e-12);
2856 assert!(
2857 (result.alo_variance[0][coordinate] - variance[[coordinate, coordinate]]).abs()
2858 < 2e-12
2859 );
2860 }
2861 assert!((result.leverage[0] - expected_leverage).abs() < 2e-12);
2862 assert!((result.cook_distance[0] - expected_cook).abs() < 2e-12);
2863 }
2864
2865 #[test]
2866 fn multiblock_singular_weight_still_corrects() {
2867 let n = 1;
2871 let p = 2;
2872 let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2873 let h = Array2::eye(p);
2874 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2875 let coordinate_coefficient_ranges = vec![0..p];
2876 let observed_hessians = vec![Array2::from_elem((1, 1), 0.0)];
2877 let score_covariances = observed_hessians.clone();
2878 let scores = vec![Array1::from_vec(vec![1.0])];
2879 let coordinate_values = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2880
2881 let input = MultiBlockAloInput {
2882 n_obs: n,
2883 n_coordinates: 1,
2884 coordinate_designs: &coordinate_designs,
2885 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2886 penalized_hessian: &h,
2887 observed_hessians: &observed_hessians,
2888 score_covariances: &score_covariances,
2889 scores: &scores,
2890 coordinate_values: &coordinate_values,
2891 };
2892 let result = compute_multiblock_alo(&input).unwrap();
2893 let expected = std::f64::consts::PI + 1.25;
2895 assert!(
2896 (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2897 "expected {}, got {}",
2898 expected,
2899 result.eta_tilde[0][0]
2900 );
2901 assert!(result.cook_distance[0].abs() < 1e-14);
2903 assert!(result.alo_variance[0][0].abs() < 1e-14);
2905 }
2906
2907 #[test]
2908 fn multiblock_unit_leverage_refuses_instead_of_changing_estimand() {
2909 let coordinate_designs = vec![DesignMatrix::from(Array2::from_elem((1, 1), 1.0))];
2910 let coordinate_coefficient_ranges = vec![0..1];
2911 let h = Array2::from_elem((1, 1), 2.0);
2912 let observed_hessians = vec![Array2::from_elem((1, 1), 2.0)];
2913 let score_covariances = vec![Array2::from_elem((1, 1), 1.0)];
2914 let scores = vec![Array1::from_vec(vec![0.4])];
2915 let coordinate_values = vec![Array1::from_vec(vec![1.0])];
2916 let input = MultiBlockAloInput {
2917 n_obs: 1,
2918 n_coordinates: 1,
2919 coordinate_designs: &coordinate_designs,
2920 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2921 penalized_hessian: &h,
2922 observed_hessians: &observed_hessians,
2923 score_covariances: &score_covariances,
2924 scores: &scores,
2925 coordinate_values: &coordinate_values,
2926 };
2927 let error = compute_multiblock_alo(&input)
2928 .expect_err("unit deletion leverage must be reported as singular");
2929 assert!(
2930 error
2931 .to_string()
2932 .contains("deletion system I-WA is singular")
2933 );
2934 }
2935
2936 #[test]
2937 fn multiblock_b2_identity_cancellation_is_numerically_singular() {
2938 let above_two = f64::from_bits(2.0_f64.to_bits() + 1);
2942 let w = [above_two, 0.0, 0.0, above_two];
2943 let a = [0.5, 0.0, 0.0, 0.5];
2944 assert!(!local_identity_minus_product_is_factorable(&w, &a, 2));
2945 assert!(!local_identity_minus_product_is_factorable(&a, &w, 2));
2946 }
2947
2948 #[test]
2949 fn multiblock_b2_safely_near_singular_deletion_is_accepted() {
2950 let gap = f64::EPSILON.sqrt();
2953 let identity = [1.0, 0.0, 0.0, 1.0];
2954 let product_operand = [1.0 - gap, 0.0, 0.0, 0.5];
2955 assert!(local_identity_minus_product_is_factorable(
2956 &identity,
2957 &product_operand,
2958 2
2959 ));
2960 assert!(local_identity_minus_product_is_factorable(
2961 &product_operand,
2962 &identity,
2963 2
2964 ));
2965 }
2966
2967 #[test]
2968 fn multiblock_trace_one_but_invertible_deletion_is_not_refused() {
2969 let coordinate_designs = vec![
2972 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2973 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2974 ];
2975 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2976 let penalized_hessian = Array2::<f64>::eye(2);
2977 let observed_hessians =
2978 vec![Array2::from_shape_vec((2, 2), vec![0.25, 0.0, 0.0, 0.75]).unwrap()];
2979 let score_covariances = vec![Array2::<f64>::zeros((2, 2))];
2980 let scores = vec![Array1::from_vec(vec![0.75, -0.25])];
2981 let coordinate_values = vec![Array1::<f64>::zeros(2)];
2982 let input = MultiBlockAloInput {
2983 n_obs: 1,
2984 n_coordinates: 2,
2985 coordinate_designs: &coordinate_designs,
2986 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2987 penalized_hessian: &penalized_hessian,
2988 observed_hessians: &observed_hessians,
2989 score_covariances: &score_covariances,
2990 scores: &scores,
2991 coordinate_values: &coordinate_values,
2992 };
2993
2994 let result = compute_multiblock_alo(&input)
2995 .expect("trace-one but invertible deletion system must be solved exactly");
2996 let roundoff = floating_point_gamma(16);
2997 assert!((result.leverage[0] - 1.0).abs() <= roundoff);
2998 assert!((result.eta_tilde[0][0] - 1.0).abs() <= roundoff);
2999 assert!((result.eta_tilde[0][1] + 1.0).abs() <= roundoff);
3000 }
3001}