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 |_t, merit| merit < residual.abs(),
333 ) {
334 Ok(result) => result,
335 Err(never) => match never {},
336 };
337 let Some(step) = accepted else {
338 break;
339 };
340 (eta, residual, jac, tolerance) = step.payload;
341 }
342 Err(AloExactScalarError::MaxIterations {
343 iterations: ALO_EXACT_SCALAR_MAX_ITERS,
344 residual,
345 tolerance,
346 eta,
347 })
348}
349
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(
1084 penalized_hessian: &'a Array2<f64>,
1085 design: &'a Array2<f64>,
1086 eta: &'a Array1<f64>,
1087 offset: &'a Array1<f64>,
1088 phi: f64,
1089 working_weights: &'a Array1<f64>,
1090 working_response: &'a Array1<f64>,
1091 ) -> Self {
1092 let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
1093 Self {
1094 design,
1095 penalized_hessian,
1096 hessian_weights: psd_w.as_signed(),
1097 score_weights: psd_w,
1098 working_response,
1099 eta,
1100 offset,
1101 phi,
1102 score_curvature: None,
1103 }
1104 }
1105}
1106
1107pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
1113 compute_alo_from_input_inner(input).map_err(EstimationError::from)
1114}
1115
1116fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
1117 let x_dense = input.design;
1118 let n = x_dense.nrows();
1119 let p = x_dense.ncols();
1120 let w_h = input.hessian_weights.view();
1124 let w_s = input.score_weights.view();
1125
1126 validate_alo_solve_setup(input, n, p)?;
1127
1128 let factor = certified_spd_factorize(input.penalized_hessian, "ALO penalized Hessian")
1129 .map_err(|error| AloError::InvalidInput {
1130 reason: format!(
1131 "ALO requires an unperturbed positive-definite penalized Hessian with a certified solve: {error}"
1132 ),
1133 })?;
1134
1135 let xt = x_dense.t();
1136 let phi = input.phi;
1137
1138 let mut aii = Array1::<f64>::zeros(n);
1139 let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
1140 let mut se_bayes = Array1::<f64>::zeros(n);
1141 let mut se_sandwich = Array1::<f64>::zeros(n);
1142
1143 let block_cols = alo_rhs_block_cols(n, p);
1144 let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
1149 let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
1154 let x_dense_view = FaerArrayView::new(x_dense);
1155
1156 for chunk_start in (0..n).step_by(block_cols) {
1157 let chunk_end = (chunk_start + block_cols).min(n);
1158 let width = chunk_end - chunk_start;
1159
1160 rhs_chunk_buf
1161 .slice_mut(s![.., ..width])
1162 .assign(&xt.slice(s![.., chunk_start..chunk_end]));
1163
1164 let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
1165 let rhs_chunk = rhs_chunkview.to_owned();
1166 let (s_chunk, _solve_certificate) = factor.solve_matrix(&rhs_chunk).map_err(|error| {
1167 AloError::LooComputationFailed {
1168 reason: format!(
1169 "ALO penalized-Hessian solve could not be certified for rows {chunk_start}..{chunk_end}: {error}"
1170 ),
1171 }
1172 })?;
1173 let s_chunk_view = FaerArrayView::new(&s_chunk);
1174
1175 let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
1176 matmul(
1177 xs_target.rb_mut(),
1178 Accum::Replace,
1179 x_dense_view.as_ref(),
1180 s_chunk_view.as_ref(),
1181 1.0,
1182 Par::Seq,
1183 );
1184
1185 let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
1186
1187 for local_col in 0..width {
1188 let obs = chunk_start + local_col;
1189 let rhs_col = rhs_view.column(local_col);
1193 let solution_col = s_chunk.column(local_col);
1194 let x_hinv_x = spd_quadratic_after_certified_solve(obs, rhs_col, solution_col)?;
1195 let ai = finite_signed_product(obs, "leverage", w_h[obs], x_hinv_x)?;
1202 aii[obs] = ai;
1203 x_hinv_x_diag[obs] = x_hinv_x;
1204
1205 let var_bayes = finite_nonnegative_product(obs, "Bayesian variance", phi, x_hinv_x)?;
1206 let xs_slice = xs_chunk_storage.col_as_slice(local_col);
1207 let meat_quad = finite_weighted_square_sum(obs, w_s, xs_slice)?;
1212 let var_sandwich =
1213 finite_nonnegative_product(obs, "sandwich variance", phi, meat_quad)?;
1214
1215 se_bayes[obs] = var_bayes.sqrt();
1216 se_sandwich[obs] = var_sandwich.sqrt();
1217 }
1218 }
1219
1220 let eta_hat = input.eta;
1221 let z = input.working_response;
1222 let offset = input.offset;
1223
1224 use rayon::prelude::*;
1225 let eta_tilde_vec: Vec<f64> = (0..n)
1226 .into_par_iter()
1227 .map(|i| {
1228 let denom_raw = 1.0 - aii[i];
1229 if denom_raw == 0.0 || !denom_raw.is_finite() {
1230 return Err(AloError::LooComputationFailed {
1231 reason: format!(
1232 "ALO deletion denominator is not invertible at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}",
1233 aii[i], denom_raw
1234 ),
1235 });
1236 }
1237 let one_step = alo_eta_updatewith_offset(
1238 eta_hat[i],
1239 z[i],
1240 offset[i],
1241 x_hinv_x_diag[i],
1242 w_s[i],
1243 denom_raw,
1244 );
1245 let v = if let Some(score_curvature) = input.score_curvature {
1253 alo_eta_exact_frozen_curvature(
1254 eta_hat[i],
1255 x_hinv_x_diag[i],
1256 &|eta| score_curvature(i, eta),
1257 )
1258 .map_err(|err| AloError::LooComputationFailed {
1259 reason: format!(
1260 "ALO exact frozen-curvature solve failed at row {i}: {err}"
1261 ),
1262 })?
1263 } else {
1264 one_step
1265 };
1266 if !v.is_finite() {
1267 return Err(AloError::LooComputationFailed {
1268 reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
1269 });
1270 }
1271 Ok(v)
1272 })
1273 .collect::<Result<_, _>>()?;
1274 let eta_tilde = Array1::from(eta_tilde_vec);
1275
1276 Ok(AloDiagnostics {
1277 eta_tilde,
1278 se_bayes,
1279 se_sandwich,
1280 leverage: aii,
1281 })
1282}
1283
1284fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
1285 let h = input.penalized_hessian;
1286 if h.nrows() != p || h.ncols() != p {
1287 return Err(AloError::InvalidInput {
1288 reason: format!(
1289 "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
1290 h.nrows(),
1291 h.ncols()
1292 ),
1293 });
1294 }
1295 let vector_lengths = [
1296 ("hessian_weights", input.hessian_weights.len()),
1297 ("score_weights", input.score_weights.len()),
1298 ("working_response", input.working_response.len()),
1299 ("eta", input.eta.len()),
1300 ("offset", input.offset.len()),
1301 ];
1302 for (name, len) in vector_lengths {
1303 if len != n {
1304 return Err(AloError::InvalidInput {
1305 reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
1306 });
1307 }
1308 }
1309 if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
1310 return Err(AloError::WeightInvalid {
1311 reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
1312 });
1313 }
1314 if let Some((row, value)) = input
1315 .score_weights
1316 .view()
1317 .iter()
1318 .copied()
1319 .enumerate()
1320 .find(|(_, value)| !value.is_finite() || *value < 0.0)
1321 {
1322 return Err(AloError::WeightInvalid {
1323 reason: format!(
1324 "ALO diagnostics require finite non-negative score-side weights; row {row} has {value:?}"
1325 ),
1326 });
1327 }
1328 if input.working_response.iter().any(|v| !v.is_finite()) {
1329 return Err(AloError::WeightInvalid {
1330 reason: "ALO diagnostics require finite working responses".to_string(),
1331 });
1332 }
1333 if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
1334 return Err(AloError::InvalidInput {
1335 reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
1336 });
1337 }
1338 if !input.phi.is_finite() || input.phi <= 0.0 {
1339 return Err(AloError::InvalidInput {
1340 reason: format!(
1341 "ALO diagnostics require positive finite dispersion phi; got {}",
1342 input.phi
1343 ),
1344 });
1345 }
1346 Ok(())
1347}
1348
1349pub fn compute_alo_diagnostics_from_fit(
1351 fit: &UnifiedFitResult,
1352 y: ArrayView1<f64>,
1353) -> Result<AloDiagnostics, EstimationError> {
1354 let pirls = fit
1355 .artifacts
1356 .pirls
1357 .as_ref()
1358 .ok_or_else(|| AloError::InvalidInput {
1359 reason:
1360 "ALO diagnostics require a PIRLS-backed fit; this fit does not expose PIRLS geometry"
1361 .to_string(),
1362 })
1363 .map_err(EstimationError::from)?;
1364 compute_alo_diagnostics_from_pirls_impl(pirls, y)
1365}
1366
1367pub fn compute_alo_diagnostics_from_unified(
1373 unified: &UnifiedFitResult,
1374 design: &Array2<f64>,
1375 eta: &Array1<f64>,
1376 offset: &Array1<f64>,
1377 phi: f64,
1378) -> Result<AloDiagnostics, EstimationError> {
1379 let geom = unified
1380 .geometry
1381 .as_ref()
1382 .ok_or_else(|| AloError::InvalidInput {
1383 reason: "UnifiedFitResult does not contain working-set geometry; \
1384 ALO diagnostics require geometry at convergence"
1385 .to_string(),
1386 })
1387 .map_err(EstimationError::from)?;
1388 let working = geom.working.as_ref().ok_or_else(|| {
1389 EstimationError::from(AloError::InvalidInput {
1390 reason: "UnifiedFitResult coefficient geometry has no owned single-diagonal working evidence; ALO diagnostics are unavailable for Exact-Newton and multi-parameter terminal geometry"
1391 .to_string(),
1392 })
1393 })?;
1394 geom.coefficient_gauge
1395 .validate()
1396 .map_err(|reason| AloError::InvalidInput {
1397 reason: format!("UnifiedFitResult ALO coefficient gauge is invalid: {reason}"),
1398 })
1399 .map_err(EstimationError::from)?;
1400 if design.ncols() != geom.coefficient_gauge.raw_total() {
1401 return Err(AloError::InvalidInput {
1402 reason: format!(
1403 "UnifiedFitResult ALO raw design has {} columns; coefficient gauge requires {}",
1404 design.ncols(),
1405 geom.coefficient_gauge.raw_total(),
1406 ),
1407 }
1408 .into());
1409 }
1410 let active_design = geom.coefficient_gauge.restrict_design(design);
1411 let input =
1412 AloInput::from_active_geometry(geom, working, &active_design, eta, offset, phi);
1413 compute_alo_from_input(&input)
1414}
1415
1416pub fn compute_alo_diagnostics_from_pirls(
1418 base: &pirls::PirlsResult,
1419 y: ArrayView1<f64>,
1420) -> Result<AloDiagnostics, EstimationError> {
1421 compute_alo_diagnostics_from_pirls_impl(base, y)
1422}
1423
1424pub fn compute_case_deletion_from_pirls(
1443 base: &pirls::PirlsResult,
1444) -> Result<Option<crate::sensitivity::CaseDeletionInfluence>, EstimationError> {
1445 let x_dense_arc = base
1446 .x_transformed
1447 .try_to_dense_arc("case-deletion diagnostics require dense transformed design")
1448 .map_err(|reason| EstimationError::InvalidInput(reason))?;
1449 let x_dense = x_dense_arc.as_ref();
1450 let n = x_dense.nrows();
1451 let p = x_dense.ncols();
1452 if n == 0 || p == 0 {
1453 return Ok(None);
1454 }
1455
1456 let phi = alo_covariance_scale(base).map_err(EstimationError::from)?;
1457
1458 let h_dense = base
1461 .dense_stabilizedhessian_transformed(
1462 "case-deletion diagnostics require exact dense stabilized penalized Hessian",
1463 )
1464 .map_err(|e| match e {
1465 EstimationError::InvalidInput(reason) => EstimationError::InvalidInput(reason),
1466 other => EstimationError::InvalidInput(format!("{other:?}")),
1467 })?;
1468
1469 let factor = match h_dense.cholesky(faer::Side::Lower) {
1470 Ok(f) => f,
1471 Err(_) => return Ok(None),
1475 };
1476
1477 let working_weights = base.finalweights.clone();
1481 let working_residual = &base.solveworking_response - &base.final_eta;
1482
1483 let sensitivity = crate::sensitivity::FitSensitivity::from_faer_cholesky(&factor, p);
1484 Ok(sensitivity.case_deletion(
1485 x_dense,
1486 working_weights.view(),
1487 working_residual.view(),
1488 phi,
1489 ))
1490}
1491
1492#[derive(Debug, Clone)]
1496pub struct MultiBlockAloDiagnostics {
1497 pub eta_tilde: Vec<Array1<f64>>,
1500 pub leverage: Array1<f64>,
1502 pub alo_variance: Vec<Array1<f64>>,
1508 pub predictive_variance: Vec<Array1<f64>>,
1520 pub cook_distance: Array1<f64>,
1523}
1524
1525pub struct MultiBlockAloInput<'a> {
1557 pub n_obs: usize,
1559 pub n_coordinates: usize,
1561 pub coordinate_designs: &'a [DesignMatrix],
1564 pub coordinate_coefficient_ranges: &'a [Range<usize>],
1568 pub penalized_hessian: &'a Array2<f64>,
1571 pub observed_hessians: &'a [Array2<f64>],
1574 pub score_covariances: &'a [Array2<f64>],
1577 pub scores: &'a [Array1<f64>],
1580 pub coordinate_values: &'a [Array1<f64>],
1584}
1585
1586pub fn compute_multiblock_alo(
1605 input: &MultiBlockAloInput,
1606) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1607 compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1608}
1609
1610fn validate_multiblock_alo_input(input: &MultiBlockAloInput<'_>) -> Result<(), AloError> {
1611 let n = input.n_obs;
1612 let b = input.n_coordinates;
1613 if n == 0 || b == 0 {
1614 return Err(AloError::InvalidInput {
1615 reason: format!(
1616 "multi-block ALO requires positive observation and coordinate counts; got n={n}, B={b}"
1617 ),
1618 });
1619 }
1620 if input.coordinate_designs.len() != b {
1621 return Err(AloError::InvalidInput {
1622 reason: format!(
1623 "multi-block ALO expected {b} coordinate designs, got {}",
1624 input.coordinate_designs.len()
1625 ),
1626 });
1627 }
1628 let p_tot = input.penalized_hessian.nrows();
1629 if input.penalized_hessian.ncols() != p_tot || p_tot == 0 {
1630 return Err(AloError::InvalidInput {
1631 reason: format!(
1632 "multi-block ALO penalized Hessian must be non-empty and square; got {}x{}",
1633 input.penalized_hessian.nrows(),
1634 input.penalized_hessian.ncols()
1635 ),
1636 });
1637 }
1638 if input.coordinate_coefficient_ranges.len() != b {
1639 return Err(AloError::InvalidInput {
1640 reason: format!(
1641 "multi-block ALO expected {b} coordinate coefficient ranges, got {}",
1642 input.coordinate_coefficient_ranges.len()
1643 ),
1644 });
1645 }
1646 for (coordinate, (design, coefficient_range)) in input
1647 .coordinate_designs
1648 .iter()
1649 .zip(input.coordinate_coefficient_ranges)
1650 .enumerate()
1651 {
1652 if design.nrows() != n {
1653 return Err(AloError::InvalidInput {
1654 reason: format!(
1655 "multi-block ALO coordinate design {coordinate} has {} rows; expected {n}",
1656 design.nrows()
1657 ),
1658 });
1659 }
1660 if design.ncols() == 0 || coefficient_range.is_empty() {
1661 return Err(AloError::InvalidInput {
1662 reason: format!(
1663 "multi-block ALO coordinate {coordinate} has an empty local design or coefficient range"
1664 ),
1665 });
1666 }
1667 if coefficient_range.len() != design.ncols() || coefficient_range.end > p_tot {
1668 return Err(AloError::InvalidInput {
1669 reason: format!(
1670 "multi-block ALO coordinate {coordinate} design has {} columns but parameter range {}..{} has length {} in a {p_tot}-dimensional saved Hessian",
1671 design.ncols(),
1672 coefficient_range.start,
1673 coefficient_range.end,
1674 coefficient_range.len()
1675 ),
1676 });
1677 }
1678 }
1679 for (label, length) in [
1680 ("observed_hessians", input.observed_hessians.len()),
1681 ("score_covariances", input.score_covariances.len()),
1682 ("scores", input.scores.len()),
1683 ("coordinate_values", input.coordinate_values.len()),
1684 ] {
1685 if length != n {
1686 return Err(AloError::InvalidInput {
1687 reason: format!("multi-block ALO requires {label} length {n}; got {length}"),
1688 });
1689 }
1690 }
1691 for row in 0..n {
1692 let observed = &input.observed_hessians[row];
1693 let score_covariance = &input.score_covariances[row];
1694 for (label, matrix) in [
1695 ("observed Hessian", observed),
1696 ("score covariance", score_covariance),
1697 ] {
1698 if matrix.dim() != (b, b) {
1699 return Err(AloError::InvalidInput {
1700 reason: format!(
1701 "multi-block ALO row {row} {label} has shape {}x{}; expected {b}x{b}",
1702 matrix.nrows(),
1703 matrix.ncols()
1704 ),
1705 });
1706 }
1707 validate_finite_symmetric_matrix(matrix, &format!("multi-block ALO row {row} {label}"))
1708 .map_err(|error| AloError::InvalidInput {
1709 reason: error.to_string(),
1710 })?;
1711 }
1712 let covariance_scale = score_covariance
1713 .iter()
1714 .fold(0.0_f64, |scale, value| scale.max(value.abs()));
1715 let (minimum, maximum) =
1716 symmetric_extremes(score_covariance).ok_or_else(|| AloError::InvalidInput {
1717 reason: format!(
1718 "multi-block ALO row {row} score-covariance eigendecomposition failed"
1719 ),
1720 })?;
1721 let psd_tolerance = LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR
1722 * b as f64
1723 * f64::EPSILON
1724 * covariance_scale.max(maximum.abs());
1725 if minimum < -psd_tolerance {
1726 return Err(AloError::InvalidInput {
1727 reason: format!(
1728 "multi-block ALO row {row} score covariance is not positive semidefinite: minimum eigenvalue {minimum:.6e}, roundoff allowance {psd_tolerance:.6e}"
1729 ),
1730 });
1731 }
1732 for (label, vector) in [
1733 ("score", &input.scores[row]),
1734 ("coordinate value", &input.coordinate_values[row]),
1735 ] {
1736 if vector.len() != b {
1737 return Err(AloError::InvalidInput {
1738 reason: format!(
1739 "multi-block ALO row {row} {label} has length {}; expected {b}",
1740 vector.len()
1741 ),
1742 });
1743 }
1744 if let Some((coordinate, value)) = vector
1745 .iter()
1746 .copied()
1747 .enumerate()
1748 .find(|(_, value)| !value.is_finite())
1749 {
1750 return Err(AloError::InvalidInput {
1751 reason: format!(
1752 "multi-block ALO row {row} {label} coordinate {coordinate} is non-finite: {value}"
1753 ),
1754 });
1755 }
1756 }
1757 }
1758 Ok(())
1759}
1760
1761fn compute_multiblock_alo_inner(
1762 input: &MultiBlockAloInput,
1763) -> Result<MultiBlockAloDiagnostics, AloError> {
1764 use rayon::prelude::*;
1765
1766 let n = input.n_obs;
1767 let b = input.n_coordinates;
1768 let p_tot = input.penalized_hessian.nrows();
1769 validate_multiblock_alo_input(input)?;
1770 let factor = certified_spd_factorize(input.penalized_hessian, "multi-block ALO penalized Hessian")
1771 .map_err(|error| AloError::InvalidInput {
1772 reason: format!(
1773 "multi-block ALO requires an unperturbed positive-definite saved penalized Hessian: {error}"
1774 ),
1775 })?;
1776
1777 let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1778 let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1779
1780 let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1786 Vec::with_capacity(chunk_starts.len());
1787 for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1788 let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1789 .par_iter()
1790 .map_init(
1791 || MultiBlockAloScratch::new(b),
1792 |scratch, &chunk_start| {
1793 let chunk_end = (chunk_start + chunk_size).min(n);
1794 compute_multiblock_alo_chunk(input, &factor, chunk_start, chunk_end, scratch)
1795 },
1796 )
1797 .collect();
1798 chunk_results.append(&mut wave_results);
1799 }
1800
1801 let mut eta_tilde = Vec::with_capacity(n);
1802 let mut leverage = Array1::<f64>::zeros(n);
1803 let mut alo_variance = Vec::with_capacity(n);
1804 let mut predictive_variance = Vec::with_capacity(n);
1805 let mut cook_distance = Array1::<f64>::zeros(n);
1806
1807 let mut chunks = Vec::with_capacity(chunk_results.len());
1808 for result in chunk_results {
1809 chunks.push(result?);
1810 }
1811 chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1812
1813 for chunk in chunks {
1814 let chunk_start = chunk.chunk_start;
1815 eta_tilde.extend(chunk.eta_tilde);
1816 alo_variance.extend(chunk.alo_variance);
1817 predictive_variance.extend(chunk.predictive_variance);
1818 for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1819 leverage[chunk_start + local_i] = lev;
1820 }
1821 for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1822 cook_distance[chunk_start + local_i] = cook;
1823 }
1824 }
1825
1826 Ok(MultiBlockAloDiagnostics {
1827 eta_tilde,
1828 leverage,
1829 alo_variance,
1830 predictive_variance,
1831 cook_distance,
1832 })
1833}
1834
1835#[inline]
1836fn multiblock_alo_parallel_plan(
1837 p_tot: usize,
1838 n_coordinates: usize,
1839 n_obs: usize,
1840) -> (usize, usize) {
1841 if p_tot == 0 || n_coordinates == 0 || n_obs == 0 {
1842 return (1, 1);
1843 }
1844 let bytes_per_obs = p_tot
1847 .saturating_mul(n_coordinates)
1848 .saturating_mul(2)
1849 .saturating_mul(std::mem::size_of::<f64>())
1850 .max(1);
1851 let workers = rayon::current_num_threads().max(1);
1852 let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1853 .max(1)
1854 .min(workers);
1855 let per_worker_budget =
1856 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1857 let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1858 (budget_obs.min(n_obs), max_concurrent_chunks)
1859}
1860
1861struct MultiBlockAloScratch {
1862 a_i: Vec<f64>,
1863 wa: Vec<f64>,
1864 aw: Vec<f64>,
1865 imwa: Vec<f64>,
1866 imaw: Vec<f64>,
1867 perm_imwa: Vec<usize>,
1868 perm_imaw: Vec<usize>,
1869 delta_eta: Vec<f64>,
1870 rhs_buf: Vec<f64>,
1871 covariance_u: Vec<f64>,
1872 var_diag_buf: Vec<f64>,
1873 w_flat: Vec<f64>,
1874 covariance_flat: Vec<f64>,
1875 lu_scratch: Vec<f64>,
1876 original_rhs: Vec<f64>,
1877}
1878
1879impl MultiBlockAloScratch {
1880 fn new(b: usize) -> Self {
1881 let bb_sz = b * b;
1882 Self {
1883 a_i: vec![0.0f64; bb_sz],
1884 wa: vec![0.0f64; bb_sz],
1885 aw: vec![0.0f64; bb_sz],
1886 imwa: vec![0.0f64; bb_sz],
1887 imaw: vec![0.0f64; bb_sz],
1888 perm_imwa: vec![0usize; b],
1889 perm_imaw: vec![0usize; b],
1890 delta_eta: vec![0.0f64; b],
1891 rhs_buf: vec![0.0f64; b],
1892 covariance_u: vec![0.0f64; b],
1893 var_diag_buf: vec![0.0f64; b],
1894 w_flat: vec![0.0f64; bb_sz],
1895 covariance_flat: vec![0.0f64; bb_sz],
1896 lu_scratch: vec![0.0f64; b],
1897 original_rhs: vec![0.0f64; b],
1898 }
1899 }
1900}
1901
1902struct MultiBlockAloChunkDiagnostics {
1903 chunk_start: usize,
1904 eta_tilde: Vec<Array1<f64>>,
1905 leverage: Vec<f64>,
1906 alo_variance: Vec<Array1<f64>>,
1907 predictive_variance: Vec<Array1<f64>>,
1908 cook_distance: Vec<f64>,
1909}
1910
1911fn compute_multiblock_alo_chunk(
1912 input: &MultiBlockAloInput,
1913 factor: &CertifiedSpdFactor<'_>,
1914 chunk_start: usize,
1915 chunk_end: usize,
1916 scratch: &mut MultiBlockAloScratch,
1917) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1918 let b = input.n_coordinates;
1919 let p_tot = input.penalized_hessian.nrows();
1920 let chunk_len = chunk_end - chunk_start;
1921
1922 let mut design_chunks = Vec::with_capacity(b);
1923 let mut q_blocks = Vec::with_capacity(b);
1924 for coordinate in 0..b {
1925 let design_chunk = input.coordinate_designs[coordinate]
1926 .try_row_chunk(chunk_start..chunk_end)
1927 .map_err(|reason| AloError::DesignDegenerate {
1928 reason: format!(
1929 "multi-block ALO could not materialize coordinate {coordinate} rows {chunk_start}..{chunk_end}: {reason}"
1930 ),
1931 })?;
1932 if let Some(((row, column), value)) = design_chunk
1933 .indexed_iter()
1934 .map(|(index, &value)| (index, value))
1935 .find(|(_, value)| !value.is_finite())
1936 {
1937 return Err(AloError::DesignDegenerate {
1938 reason: format!(
1939 "multi-block ALO coordinate {coordinate} design is non-finite at source row {}, column {column}: {value}",
1940 chunk_start + row
1941 ),
1942 });
1943 }
1944 let coefficient_range = input.coordinate_coefficient_ranges[coordinate].clone();
1945 let mut rhs = Array2::<f64>::zeros((p_tot, chunk_len));
1946 rhs.slice_mut(s![coefficient_range, ..])
1947 .assign(&design_chunk.t());
1948 let (solution, _) = factor.solve_matrix(&rhs).map_err(|error| {
1949 AloError::LooComputationFailed {
1950 reason: format!(
1951 "multi-block ALO saved-Hessian solve failed for coordinate {coordinate}, rows {chunk_start}..{chunk_end}: {error}"
1952 ),
1953 }
1954 })?;
1955 design_chunks.push(design_chunk);
1956 q_blocks.push(solution);
1957 }
1958
1959 let mut eta_tilde = Vec::with_capacity(chunk_len);
1960 let mut leverage = vec![0.0f64; chunk_len];
1961 let mut alo_variance = Vec::with_capacity(chunk_len);
1962 let mut predictive_variance = Vec::with_capacity(chunk_len);
1963 let mut cook_distance = vec![0.0f64; chunk_len];
1964
1965 for local_i in 0..chunk_len {
1966 let i = chunk_start + local_i;
1967 let w_i = &input.observed_hessians[i];
1968 let covariance_i = &input.score_covariances[i];
1969
1970 for r in 0..b {
1973 for c in 0..b {
1974 scratch.w_flat[r * b + c] = w_i[(r, c)];
1975 scratch.covariance_flat[r * b + c] = covariance_i[(r, c)];
1976 }
1977 }
1978
1979 for a in 0..b {
1981 let x_a = &design_chunks[a];
1982 let p_a = x_a.ncols();
1983 let off_a = input.coordinate_coefficient_ranges[a].start;
1984 let xa_row = x_a.row(local_i);
1985 for bb in 0..b {
1986 let q_bb = &q_blocks[bb];
1987 let mut dot = 0.0f64;
1988 for k in 0..p_a {
1989 dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1990 }
1991 scratch.a_i[a * b + bb] = dot;
1992 }
1993 }
1994
1995 let mut pred_var = Array1::<f64>::zeros(b);
2000 for d in 0..b {
2001 pred_var[d] = scratch.a_i[d * b + d].max(0.0);
2002 }
2003 predictive_variance.push(pred_var);
2004
2005 mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
2007 mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
2009
2010 let mut tr = 0.0f64;
2013 for d in 0..b {
2014 tr += scratch.aw[d * b + d];
2015 }
2016 leverage[local_i] = tr;
2017
2018 for r in 0..b {
2020 for c in 0..b {
2021 let idx = r * b + c;
2022 let id = if r == c { 1.0 } else { 0.0 };
2023 scratch.imwa[idx] = id - scratch.wa[idx];
2024 scratch.imaw[idx] = id - scratch.aw[idx];
2025 }
2026 }
2027
2028 let imwa_tolerance =
2035 identity_minus_product_lu_tolerance(&scratch.w_flat, &scratch.a_i, &scratch.wa, b)?;
2036 if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b, imwa_tolerance) {
2037 return Err(AloError::LooComputationFailed {
2038 reason: format!(
2039 "multi-block ALO deletion system I-WA is singular at row {i}; local pivot allowance {imwa_tolerance:.6e}, leverage trace {:.6e}",
2040 leverage[local_i]
2041 ),
2042 });
2043 }
2044 let imaw_tolerance =
2045 identity_minus_product_lu_tolerance(&scratch.a_i, &scratch.w_flat, &scratch.aw, b)?;
2046 if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b, imaw_tolerance) {
2047 return Err(AloError::LooComputationFailed {
2048 reason: format!(
2049 "multi-block ALO transpose deletion system I-AW is singular at row {i}; local pivot allowance {imaw_tolerance:.6e}, leverage trace {:.6e}",
2050 leverage[local_i]
2051 ),
2052 });
2053 }
2054
2055 let s_i = &input.scores[i];
2057 for k in 0..b {
2058 scratch.rhs_buf[k] = s_i[k];
2059 }
2060 if let Err(failure) = solve_identity_minus_product_in_place(
2061 &scratch.imwa,
2062 &scratch.perm_imwa,
2063 &scratch.wa,
2064 &mut scratch.rhs_buf,
2065 &mut scratch.lu_scratch,
2066 &mut scratch.original_rhs,
2067 imwa_tolerance,
2068 b,
2069 ) {
2070 return Err(AloError::LooComputationFailed {
2071 reason: format!(
2072 "multi-block ALO deletion solve I-WA failed backward-error certification at row {i}: residual {:.6e}, allowance {:.6e}",
2073 failure.residual_norm, failure.allowance
2074 ),
2075 });
2076 }
2077 for r in 0..b {
2079 let mut acc = 0.0f64;
2080 let row_off = r * b;
2081 for k in 0..b {
2082 acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
2083 }
2084 scratch.delta_eta[r] = acc;
2085 }
2086
2087 let eta_i = &input.coordinate_values[i];
2088 let mut corrected = Array1::<f64>::zeros(b);
2089 for d in 0..b {
2090 corrected[d] = eta_i[d] + scratch.delta_eta[d];
2091 if !scratch.delta_eta[d].is_finite() || !corrected[d].is_finite() {
2092 return Err(AloError::LooComputationFailed {
2093 reason: format!(
2094 "multi-block ALO correction is non-finite at row {i}, coordinate {d}: delta={}, corrected={}",
2095 scratch.delta_eta[d], corrected[d]
2096 ),
2097 });
2098 }
2099 }
2100 eta_tilde.push(corrected);
2101
2102 let mut cook = 0.0f64;
2104 let mut cook_scale = 0.0f64;
2105 for r in 0..b {
2106 let mut covariance_delta_r = 0.0f64;
2107 let row_off = r * b;
2108 for k in 0..b {
2109 covariance_delta_r += scratch.covariance_flat[row_off + k] * scratch.delta_eta[k];
2110 }
2111 let term = scratch.delta_eta[r] * covariance_delta_r;
2112 cook += term;
2113 cook_scale += term.abs();
2114 }
2115 let cook_tolerance =
2116 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * cook_scale;
2117 if !cook.is_finite() || cook < -cook_tolerance {
2118 return Err(AloError::LooComputationFailed {
2119 reason: format!(
2120 "multi-block ALO Cook influence is invalid at row {i}: value {cook:.6e}, roundoff allowance {cook_tolerance:.6e}"
2121 ),
2122 });
2123 }
2124 cook_distance[local_i] = cook.max(0.0);
2125
2126 for d in 0..b {
2132 let row_off = d * b;
2133 for k in 0..b {
2135 scratch.rhs_buf[k] = scratch.a_i[row_off + k];
2136 }
2137 if let Err(failure) = solve_identity_minus_product_in_place(
2138 &scratch.imaw,
2139 &scratch.perm_imaw,
2140 &scratch.aw,
2141 &mut scratch.rhs_buf,
2142 &mut scratch.lu_scratch,
2143 &mut scratch.original_rhs,
2144 imaw_tolerance,
2145 b,
2146 ) {
2147 return Err(AloError::LooComputationFailed {
2148 reason: format!(
2149 "multi-block ALO transpose variance solve I-AW failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2150 failure.residual_norm, failure.allowance
2151 ),
2152 });
2153 }
2154 for r in 0..b {
2156 let mut acc = 0.0f64;
2157 let wr = r * b;
2158 for k in 0..b {
2159 acc += scratch.covariance_flat[wr + k] * scratch.rhs_buf[k];
2160 }
2161 scratch.covariance_u[r] = acc;
2162 }
2163 if let Err(failure) = solve_identity_minus_product_in_place(
2165 &scratch.imwa,
2166 &scratch.perm_imwa,
2167 &scratch.wa,
2168 &mut scratch.covariance_u,
2169 &mut scratch.lu_scratch,
2170 &mut scratch.original_rhs,
2171 imwa_tolerance,
2172 b,
2173 ) {
2174 return Err(AloError::LooComputationFailed {
2175 reason: format!(
2176 "multi-block ALO variance solve I-WA failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
2177 failure.residual_norm, failure.allowance
2178 ),
2179 });
2180 }
2181 let mut v_dd = 0.0f64;
2183 for k in 0..b {
2184 v_dd += scratch.a_i[row_off + k] * scratch.covariance_u[k];
2185 }
2186 let variance_scale = scratch.a_i[row_off..row_off + b]
2187 .iter()
2188 .zip(scratch.covariance_u.iter())
2189 .map(|(left, right)| (left * right).abs())
2190 .sum::<f64>();
2191 let variance_tolerance =
2192 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * variance_scale;
2193 if !v_dd.is_finite() || v_dd < -variance_tolerance {
2194 return Err(AloError::LooComputationFailed {
2195 reason: format!(
2196 "multi-block ALO variance is invalid at row {i}, coordinate {d}: value {v_dd:.6e}, roundoff allowance {variance_tolerance:.6e}"
2197 ),
2198 });
2199 }
2200 scratch.var_diag_buf[d] = v_dd.max(0.0);
2201 }
2202 let mut var_diag = Array1::<f64>::zeros(b);
2203 for d in 0..b {
2204 var_diag[d] = scratch.var_diag_buf[d];
2205 }
2206 alo_variance.push(var_diag);
2207 }
2208
2209 Ok(MultiBlockAloChunkDiagnostics {
2210 chunk_start,
2211 eta_tilde,
2212 leverage,
2213 alo_variance,
2214 predictive_variance,
2215 cook_distance,
2216 })
2217}
2218
2219#[inline]
2221fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
2222 for r in 0..b {
2223 let ar = r * b;
2224 let or = r * b;
2225 for c in 0..b {
2226 let mut acc = 0.0f64;
2227 for k in 0..b {
2228 acc += a[ar + k] * b_mat[k * b + c];
2229 }
2230 out[or + c] = acc;
2231 }
2232 }
2233}
2234
2235#[inline]
2238fn floating_point_gamma(operation_count: usize) -> f64 {
2239 let accumulated = operation_count as f64 * (0.5 * f64::EPSILON);
2240 if accumulated < 1.0 {
2241 accumulated / (1.0 - accumulated)
2242 } else {
2243 f64::INFINITY
2244 }
2245}
2246
2247fn identity_minus_product_lu_tolerance(
2258 left: &[f64],
2259 right: &[f64],
2260 product: &[f64],
2261 b: usize,
2262) -> Result<f64, AloError> {
2263 let expected_len = b.checked_mul(b).ok_or_else(|| AloError::InvalidInput {
2264 reason: format!(
2265 "multi-block ALO local deletion dimension B={b} overflows the square matrix size"
2266 ),
2267 })?;
2268 for (name, actual_len) in [
2269 ("left operand", left.len()),
2270 ("right operand", right.len()),
2271 ("precomputed product", product.len()),
2272 ] {
2273 if actual_len != expected_len {
2274 return Err(AloError::InvalidInput {
2275 reason: format!(
2276 "multi-block ALO local deletion {name} has length {actual_len}, expected B*B={expected_len} for B={b}"
2277 ),
2278 });
2279 }
2280 }
2281
2282 let mut operand_envelope_inf = 0.0_f64;
2283 let mut system_norm_inf = 0.0_f64;
2284 for row in 0..b {
2285 let mut operand_row_envelope = 1.0_f64;
2286 let mut system_row_norm = 0.0_f64;
2287 for column in 0..b {
2288 let mut product_entry_envelope = 0.0_f64;
2289 for inner in 0..b {
2290 product_entry_envelope +=
2291 left[row * b + inner].abs() * right[inner * b + column].abs();
2292 }
2293 operand_row_envelope += product_entry_envelope;
2294 let identity = if row == column { 1.0 } else { 0.0 };
2295 system_row_norm += (identity - product[row * b + column]).abs();
2296 }
2297 operand_envelope_inf = operand_envelope_inf.max(operand_row_envelope);
2298 system_norm_inf = system_norm_inf.max(system_row_norm);
2299 }
2300
2301 let formation_operations = b.saturating_mul(2).saturating_add(1);
2302 let elimination_operations = b.saturating_mul(3);
2303 let backward_error_scale = operand_envelope_inf.max(system_norm_inf);
2304 Ok(
2305 (floating_point_gamma(formation_operations) + floating_point_gamma(elimination_operations))
2306 * backward_error_scale,
2307 )
2308}
2309
2310fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize, pivot_tolerance: f64) -> bool {
2317 for i in 0..b {
2318 perm[i] = i;
2319 }
2320 for col in 0..b {
2321 let mut max_val = m[col * b + col].abs();
2323 let mut max_idx = col;
2324 for row in (col + 1)..b {
2325 let v = m[row * b + col].abs();
2326 if v > max_val {
2327 max_val = v;
2328 max_idx = row;
2329 }
2330 }
2331 if !max_val.is_finite() || max_val <= pivot_tolerance {
2332 return false;
2333 }
2334 if max_idx != col {
2335 for k in 0..b {
2337 m.swap(col * b + k, max_idx * b + k);
2338 }
2339 perm.swap(col, max_idx);
2340 }
2341 let pivot = m[col * b + col];
2342 for row in (col + 1)..b {
2343 let factor = m[row * b + col] / pivot;
2344 m[row * b + col] = factor; for k in (col + 1)..b {
2346 let upd = factor * m[col * b + k];
2347 m[row * b + k] -= upd;
2348 }
2349 }
2350 }
2351 true
2352}
2353
2354fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
2357 let y = &mut scratch[..b];
2359 for row in 0..b {
2360 let mut s = rhs[perm[row]];
2361 for k in 0..row {
2362 s -= m[row * b + k] * y[k];
2363 }
2364 y[row] = s;
2365 }
2366 for row in (0..b).rev() {
2368 let mut s = y[row];
2369 for k in (row + 1)..b {
2370 s -= m[row * b + k] * rhs[k];
2371 }
2372 rhs[row] = s / m[row * b + row];
2373 }
2374}
2375
2376#[derive(Clone, Copy, Debug)]
2377struct LocalSolveResidualFailure {
2378 residual_norm: f64,
2379 allowance: f64,
2380}
2381
2382fn solve_identity_minus_product_in_place(
2389 lu: &[f64],
2390 permutation: &[usize],
2391 product: &[f64],
2392 rhs: &mut [f64],
2393 lu_scratch: &mut [f64],
2394 original_rhs: &mut [f64],
2395 operator_error_bound: f64,
2396 b: usize,
2397) -> Result<(), LocalSolveResidualFailure> {
2398 original_rhs[..b].copy_from_slice(&rhs[..b]);
2399 lu_solve_in_place(lu, permutation, rhs, lu_scratch, b);
2400
2401 let rhs_norm = original_rhs[..b]
2402 .iter()
2403 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2404 let solution_norm = rhs[..b]
2405 .iter()
2406 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
2407 let mut system_norm = 0.0_f64;
2408 let mut residual_norm = 0.0_f64;
2409 for row in 0..b {
2410 let mut row_norm = 0.0_f64;
2411 let mut residual = original_rhs[row];
2412 for column in 0..b {
2413 let identity = if row == column { 1.0 } else { 0.0 };
2414 let matrix_entry = identity - product[row * b + column];
2415 row_norm += matrix_entry.abs();
2416 residual -= matrix_entry * rhs[column];
2417 }
2418 system_norm = system_norm.max(row_norm);
2419 residual_norm = residual_norm.max(residual.abs());
2420 }
2421
2422 let certification_operations = b.saturating_mul(10);
2426 let arithmetic_scale = system_norm * solution_norm + rhs_norm;
2427 let allowance = floating_point_gamma(certification_operations) * arithmetic_scale
2428 + operator_error_bound * solution_norm;
2429 if rhs[..b].iter().any(|value| !value.is_finite())
2430 || !residual_norm.is_finite()
2431 || !allowance.is_finite()
2432 || residual_norm > allowance
2433 {
2434 Err(LocalSolveResidualFailure {
2435 residual_norm,
2436 allowance,
2437 })
2438 } else {
2439 Ok(())
2440 }
2441}
2442
2443#[cfg(test)]
2444mod tests {
2445 use super::{
2446 ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, AloInput, alo_eta_exact_frozen_curvature,
2447 alo_eta_updatewith_offset, compute_alo_from_input_inner, finite_weighted_square_sum,
2448 percentile_from_sorted, percentile_index, spd_quadratic_after_certified_solve,
2449 };
2450 use gam_linalg::matrix::{PsdWeightsView, SignedWeightsView};
2451
2452 #[test]
2453 fn alo_offset_update_matches_centered_algebra() {
2454 let eta_hat = 11.0;
2455 let z = 13.0;
2456 let offset = 10.0;
2457 let x_hinv_x = 0.2;
2458 let hessian_weight = 1.0;
2459 let score_weight = 1.0;
2460 let leverage = hessian_weight * x_hinv_x;
2462 let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
2463 let got =
2464 alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
2465 assert!((got - expected).abs() < 1e-12);
2466 }
2467
2468 #[test]
2469 fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
2470 let eta_hat = 1.25;
2471 let z = -0.5;
2472 let x_hinv_x = 0.35;
2473 let hessian_weight = 1.0;
2474 let score_weight = 1.0;
2475 let leverage = hessian_weight * x_hinv_x;
2476 let expected = (eta_hat - leverage * z) / (1.0 - leverage);
2477 let got =
2478 alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
2479 assert!((got - expected).abs() < 1e-12);
2480 }
2481
2482 #[test]
2483 fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
2484 let eta_hat = 1.7;
2485 let z = 0.4;
2486 let offset = -0.2;
2487 let x_hinv_x = 0.15;
2488 let hessian_weight = 3.0;
2489 let score_weight = 5.0;
2490 let expected = offset
2491 + (eta_hat - offset)
2492 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
2493 / (1.0 - hessian_weight * x_hinv_x);
2494 let got = alo_eta_updatewith_offset(
2495 eta_hat,
2496 z,
2497 offset,
2498 x_hinv_x,
2499 score_weight,
2500 1.0 - hessian_weight * x_hinv_x,
2501 );
2502 assert!((got - expected).abs() < 1e-12);
2503 }
2504
2505 #[test]
2506 fn alo_offset_update_handles_zero_hessian_weight() {
2507 let eta_hat = 0.8;
2508 let z = -0.3;
2509 let offset = 0.1;
2510 let x_hinv_x = 0.4;
2511 let hessian_weight = 0.0;
2512 let score_weight = 2.5;
2513 let expected = offset
2514 + (eta_hat - offset)
2515 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
2516 let got = alo_eta_updatewith_offset(
2517 eta_hat,
2518 z,
2519 offset,
2520 x_hinv_x,
2521 score_weight,
2522 1.0 - hessian_weight * x_hinv_x,
2523 );
2524 assert!((got - expected).abs() < 1e-12);
2525 }
2526
2527 #[test]
2528 fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2529 let eta_hat = 1.0;
2530 let a_ii = 0.4;
2531 let got =
2532 alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| Ok((0.5 * (eta - 2.0), 0.5)))
2533 .expect("linear scalar fixed point should converge in one Newton step");
2534 assert!((got - 0.75).abs() < 1e-12);
2535 }
2536
2537 #[test]
2538 fn alo_exact_frozen_curvature_reports_nonconvergence() {
2539 let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| Ok((eta + 1.0, 0.0)))
2540 .expect_err("constant residual should exhaust the scalar iteration budget");
2541 let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2542 panic!("constant residual must report MaxIterations, got {err:?}");
2543 };
2544 assert_eq!(
2545 iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2546 "non-convergence must report the full scalar iteration budget"
2547 );
2548 }
2549
2550 #[test]
2551 fn alo_input_reports_exact_scalar_nonconvergence_with_row_context() {
2552 let design = Array2::from_elem((1, 1), 1.0);
2553 let penalized_hessian = Array2::from_elem((1, 1), 1.0);
2554 let hessian_weights = Array1::from_vec(vec![0.0]);
2555 let score_weights = Array1::from_vec(vec![0.0]);
2556 let working_response = Array1::from_vec(vec![0.0]);
2557 let eta = Array1::from_vec(vec![0.0]);
2558 let offset = Array1::from_vec(vec![0.0]);
2559 let score_curvature = |_: usize, eta: f64| Ok((eta + 1.0, 0.0));
2560 let input = AloInput {
2561 design: &design,
2562 penalized_hessian: &penalized_hessian,
2563 hessian_weights: SignedWeightsView::from_array(&hessian_weights),
2564 score_weights: PsdWeightsView::try_from_array(&score_weights).expect("psd weights"),
2565 working_response: &working_response,
2566 eta: &eta,
2567 offset: &offset,
2568 phi: 1.0,
2569 score_curvature: Some(&score_curvature),
2570 };
2571
2572 let err =
2573 compute_alo_from_input_inner(&input).expect_err("non-converged exact ALO must error");
2574 let msg = err.to_string();
2575 assert!(
2576 msg.contains("ALO exact frozen-curvature solve failed at row 0"),
2577 "missing row context in exact ALO error: {msg}"
2578 );
2579 assert!(
2580 msg.contains("did not converge within"),
2581 "missing non-convergence cause in exact ALO error: {msg}"
2582 );
2583 }
2584
2585 #[test]
2586 fn alo_scale_safe_quadratics_preserve_tiny_weights_without_false_overflow() {
2587 let weights = Array1::from_vec(vec![1e-300, 2.0]);
2588 let values = [1e200, 3.0];
2589 let meat = finite_weighted_square_sum(0, weights.view(), &values)
2590 .expect("weighted square sum is representable");
2591 assert!(meat.is_finite());
2592 assert!((meat - 1e100).abs() <= 8.0 * f64::EPSILON * 1e100);
2593
2594 let rhs = Array1::from_vec(vec![2.0, -1.0]);
2595 let solution = Array1::from_vec(vec![1.5, 0.5]);
2596 let quadratic =
2597 spd_quadratic_after_certified_solve(0, rhs.view(), solution.view()).unwrap();
2598 assert_eq!(quadratic, 2.5);
2599 }
2600
2601 #[test]
2602 fn sandwich_meat_uses_score_weights_not_hessian_weights_noncanonical() {
2603 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 1.0, 2.0, 1.0]).unwrap();
2612 let w_h_vec = Array1::from_vec(vec![1.0, -1.0, 1.0, -1.0, 0.5]);
2615 let w_s_vec = Array1::from_vec(vec![1.0, 0.8, 1.2, 0.6, 0.9]);
2617 let phi = 1.3;
2618
2619 let n = x.nrows();
2620 let sum_wh_x2: f64 = (0..n).map(|i| w_h_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2621 let sum_ws_x2: f64 = (0..n).map(|i| w_s_vec[i] * x[[i, 0]] * x[[i, 0]]).sum();
2622 assert!(sum_wh_x2 < 0.0, "fixture must exercise a negative W_H meat");
2626 assert!(sum_ws_x2 > 0.0);
2627
2628 let s0 = 8.0_f64;
2630 let h = s0 + sum_wh_x2; assert!(h > 0.0, "penalized Hessian must stay PD");
2632 let penalized_hessian = Array2::from_elem((1, 1), h);
2633
2634 let old_meat_obs1 = x[[1, 0]] * x[[1, 0]] / (h * h) * sum_wh_x2;
2637 assert!(phi * old_meat_obs1 < 0.0, "the pre-fix W_H meat is signed");
2638
2639 let working_response = Array1::from_vec(vec![0.3, -0.2, 0.5, 0.1, -0.4]);
2640 let eta = Array1::from_vec(vec![0.2, 0.1, 0.4, -0.1, 0.05]);
2641 let offset = Array1::zeros(n);
2642 let input = AloInput {
2643 design: &x,
2644 penalized_hessian: &penalized_hessian,
2645 hessian_weights: SignedWeightsView::from_array(&w_h_vec),
2646 score_weights: PsdWeightsView::try_from_array(&w_s_vec).expect("psd weights"),
2647 working_response: &working_response,
2648 eta: &eta,
2649 offset: &offset,
2650 phi,
2651 score_curvature: None,
2652 };
2653
2654 let diag = compute_alo_from_input_inner(&input)
2656 .expect("fixed sandwich meat (W_S) must not trip the negative-variance guard");
2657
2658 for obs in 0..n {
2660 let expected = (phi * x[[obs, 0]] * x[[obs, 0]] / (h * h) * sum_ws_x2).sqrt();
2661 assert!(
2662 (diag.se_sandwich[obs] - expected).abs() <= 1e-10 * expected.max(1.0),
2663 "row {obs}: se_sandwich={} expected={expected}",
2664 diag.se_sandwich[obs]
2665 );
2666 let expected_leverage = w_h_vec[obs] * x[[obs, 0]] * x[[obs, 0]] / h;
2667 assert!(
2668 (diag.leverage[obs] - expected_leverage).abs()
2669 <= 1e-12 * expected_leverage.abs().max(1.0),
2670 "row {obs}: signed leverage={} expected={expected_leverage}",
2671 diag.leverage[obs]
2672 );
2673 }
2674 assert!(
2675 diag.leverage[1] < 0.0,
2676 "negative observed curvature must remain signed"
2677 );
2678 }
2679
2680 #[test]
2681 fn percentile_index_matches_expected_rounding() {
2682 assert_eq!(percentile_index(0, 0.95), 0);
2683 assert_eq!(percentile_index(1, 0.95), 0);
2684 assert_eq!(percentile_index(10, 0.50), 5);
2685 assert_eq!(percentile_index(10, 0.95), 9);
2686 }
2687
2688 #[test]
2689 fn percentile_from_sorted_returns_order_statistic() {
2690 let values = [1.0, 2.0, 3.0, 4.0, 5.0];
2691 assert_eq!(percentile_from_sorted(&values, 0.50), 3.0);
2692 assert_eq!(percentile_from_sorted(&values, 0.95), 5.0);
2693 assert_eq!(percentile_from_sorted(&[], 0.95), 0.0);
2694 }
2695
2696 use super::{
2699 MultiBlockAloInput, compute_multiblock_alo, floating_point_gamma,
2700 identity_minus_product_lu_tolerance, lu_factor_in_place, mat_mul_flat,
2701 };
2702 use gam_linalg::matrix::DesignMatrix;
2703 use ndarray::{Array1, Array2};
2704
2705 fn local_identity_minus_product_is_factorable(left: &[f64], right: &[f64], b: usize) -> bool {
2706 let mut product = vec![0.0; b * b];
2707 mat_mul_flat(left, right, &mut product, b);
2708 let mut system = vec![0.0; b * b];
2709 for row in 0..b {
2710 for column in 0..b {
2711 let identity = if row == column { 1.0 } else { 0.0 };
2712 system[row * b + column] = identity - product[row * b + column];
2713 }
2714 }
2715 let tolerance = identity_minus_product_lu_tolerance(left, right, &product, b)
2716 .expect("test matrices satisfy the B-by-B local deletion contract");
2717 let mut permutation = vec![0; b];
2718 lu_factor_in_place(&mut system, &mut permutation, b, tolerance)
2719 }
2720
2721 #[test]
2722 fn multiblock_b1_matches_scalar_leverage() {
2723 let n = 3;
2726 let p = 2;
2727 let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2728 let w = [1.0, 2.0, 0.5];
2730 let mut h = Array2::<f64>::eye(p);
2731 for i in 0..n {
2732 for r in 0..p {
2733 for c in 0..p {
2734 h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2735 }
2736 }
2737 }
2738 let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2740 let mut h_inv = Array2::<f64>::zeros((p, p));
2741 h_inv[(0, 0)] = h[(1, 1)] / det;
2742 h_inv[(1, 1)] = h[(0, 0)] / det;
2743 h_inv[(0, 1)] = -h[(0, 1)] / det;
2744 h_inv[(1, 0)] = -h[(1, 0)] / det;
2745
2746 let mut scalar_lev = vec![0.0f64; n];
2748 for i in 0..n {
2749 let mut xhx = 0.0;
2750 for r in 0..p {
2751 for c in 0..p {
2752 xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2753 }
2754 }
2755 scalar_lev[i] = w[i] * xhx;
2756 }
2757
2758 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2761 let coordinate_coefficient_ranges = vec![0..p];
2762 let observed_hessians: Vec<Array2<f64>> =
2763 w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2764 let score_covariances = observed_hessians.clone();
2765 let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2766 let coordinate_values: Vec<Array1<f64>> =
2767 (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2768
2769 let input = MultiBlockAloInput {
2770 n_obs: n,
2771 n_coordinates: 1,
2772 coordinate_designs: &coordinate_designs,
2773 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2774 penalized_hessian: &h,
2775 observed_hessians: &observed_hessians,
2776 score_covariances: &score_covariances,
2777 scores: &scores,
2778 coordinate_values: &coordinate_values,
2779 };
2780
2781 let result = compute_multiblock_alo(&input).unwrap();
2782 for i in 0..n {
2783 assert!(
2784 (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2785 "leverage mismatch at i={}: got {}, expected {}",
2786 i,
2787 result.leverage[i],
2788 scalar_lev[i]
2789 );
2790 }
2791 }
2792
2793 #[test]
2794 fn multiblock_b2_matches_closed_form_with_cross_geometry() {
2795 let coordinate_designs = vec![
2802 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2803 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2804 ];
2805 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2806 let h = Array2::from_shape_vec((2, 2), vec![2.0, 0.25, 0.25, 3.0]).unwrap();
2807 let w = Array2::from_shape_vec((2, 2), vec![0.2, 0.05, 0.05, 0.3]).unwrap();
2808 let c = Array2::from_shape_vec((2, 2), vec![0.5, 0.1, 0.1, 0.4]).unwrap();
2809 let observed_hessians = vec![w.clone()];
2810 let score_covariances = vec![c.clone()];
2811 let scores = vec![Array1::from_vec(vec![0.4, -0.2])];
2812 let coordinate_values = vec![Array1::from_vec(vec![1.0, -0.5])];
2813 let input = MultiBlockAloInput {
2814 n_obs: 1,
2815 n_coordinates: 2,
2816 coordinate_designs: &coordinate_designs,
2817 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2818 penalized_hessian: &h,
2819 observed_hessians: &observed_hessians,
2820 score_covariances: &score_covariances,
2821 scores: &scores,
2822 coordinate_values: &coordinate_values,
2823 };
2824
2825 let det_h = h[[0, 0]] * h[[1, 1]] - h[[0, 1]] * h[[1, 0]];
2826 let a = Array2::from_shape_vec(
2827 (2, 2),
2828 vec![
2829 h[[1, 1]] / det_h,
2830 -h[[0, 1]] / det_h,
2831 -h[[1, 0]] / det_h,
2832 h[[0, 0]] / det_h,
2833 ],
2834 )
2835 .unwrap();
2836 let m = Array2::<f64>::eye(2) - w.dot(&a);
2837 let det_m = m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]];
2838 let m_inv = Array2::from_shape_vec(
2839 (2, 2),
2840 vec![
2841 m[[1, 1]] / det_m,
2842 -m[[0, 1]] / det_m,
2843 -m[[1, 0]] / det_m,
2844 m[[0, 0]] / det_m,
2845 ],
2846 )
2847 .unwrap();
2848 let delta = a.dot(&m_inv.dot(&scores[0]));
2849 let expected_eta = &coordinate_values[0] + δ
2850 let expected_leverage = (a.dot(&w)).diag().sum();
2851 let expected_cook = delta.dot(&c.dot(&delta));
2852 let variance = a.dot(&m_inv).dot(&c).dot(&m_inv.t()).dot(&a.t());
2853
2854 let result = compute_multiblock_alo(&input).expect("B=2 closed-form ALO");
2855 for coordinate in 0..2 {
2856 assert!((result.eta_tilde[0][coordinate] - expected_eta[coordinate]).abs() < 2e-12);
2857 assert!(
2858 (result.alo_variance[0][coordinate] - variance[[coordinate, coordinate]]).abs()
2859 < 2e-12
2860 );
2861 }
2862 assert!((result.leverage[0] - expected_leverage).abs() < 2e-12);
2863 assert!((result.cook_distance[0] - expected_cook).abs() < 2e-12);
2864 }
2865
2866 #[test]
2867 fn multiblock_singular_weight_still_corrects() {
2868 let n = 1;
2872 let p = 2;
2873 let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2874 let h = Array2::eye(p);
2875 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2876 let coordinate_coefficient_ranges = vec![0..p];
2877 let observed_hessians = vec![Array2::from_elem((1, 1), 0.0)];
2878 let score_covariances = observed_hessians.clone();
2879 let scores = vec![Array1::from_vec(vec![1.0])];
2880 let coordinate_values = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2881
2882 let input = MultiBlockAloInput {
2883 n_obs: n,
2884 n_coordinates: 1,
2885 coordinate_designs: &coordinate_designs,
2886 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2887 penalized_hessian: &h,
2888 observed_hessians: &observed_hessians,
2889 score_covariances: &score_covariances,
2890 scores: &scores,
2891 coordinate_values: &coordinate_values,
2892 };
2893 let result = compute_multiblock_alo(&input).unwrap();
2894 let expected = std::f64::consts::PI + 1.25;
2896 assert!(
2897 (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2898 "expected {}, got {}",
2899 expected,
2900 result.eta_tilde[0][0]
2901 );
2902 assert!(result.cook_distance[0].abs() < 1e-14);
2904 assert!(result.alo_variance[0][0].abs() < 1e-14);
2906 }
2907
2908 #[test]
2909 fn multiblock_unit_leverage_refuses_instead_of_changing_estimand() {
2910 let coordinate_designs = vec![DesignMatrix::from(Array2::from_elem((1, 1), 1.0))];
2911 let coordinate_coefficient_ranges = vec![0..1];
2912 let h = Array2::from_elem((1, 1), 2.0);
2913 let observed_hessians = vec![Array2::from_elem((1, 1), 2.0)];
2914 let score_covariances = vec![Array2::from_elem((1, 1), 1.0)];
2915 let scores = vec![Array1::from_vec(vec![0.4])];
2916 let coordinate_values = vec![Array1::from_vec(vec![1.0])];
2917 let input = MultiBlockAloInput {
2918 n_obs: 1,
2919 n_coordinates: 1,
2920 coordinate_designs: &coordinate_designs,
2921 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2922 penalized_hessian: &h,
2923 observed_hessians: &observed_hessians,
2924 score_covariances: &score_covariances,
2925 scores: &scores,
2926 coordinate_values: &coordinate_values,
2927 };
2928 let error = compute_multiblock_alo(&input)
2929 .expect_err("unit deletion leverage must be reported as singular");
2930 assert!(
2931 error
2932 .to_string()
2933 .contains("deletion system I-WA is singular")
2934 );
2935 }
2936
2937 #[test]
2938 fn multiblock_b2_identity_cancellation_is_numerically_singular() {
2939 let above_two = f64::from_bits(2.0_f64.to_bits() + 1);
2943 let w = [above_two, 0.0, 0.0, above_two];
2944 let a = [0.5, 0.0, 0.0, 0.5];
2945 assert!(!local_identity_minus_product_is_factorable(&w, &a, 2));
2946 assert!(!local_identity_minus_product_is_factorable(&a, &w, 2));
2947 }
2948
2949 #[test]
2950 fn multiblock_b2_safely_near_singular_deletion_is_accepted() {
2951 let gap = f64::EPSILON.sqrt();
2954 let identity = [1.0, 0.0, 0.0, 1.0];
2955 let product_operand = [1.0 - gap, 0.0, 0.0, 0.5];
2956 assert!(local_identity_minus_product_is_factorable(
2957 &identity,
2958 &product_operand,
2959 2
2960 ));
2961 assert!(local_identity_minus_product_is_factorable(
2962 &product_operand,
2963 &identity,
2964 2
2965 ));
2966 }
2967
2968 #[test]
2969 fn multiblock_trace_one_but_invertible_deletion_is_not_refused() {
2970 let coordinate_designs = vec![
2973 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2974 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2975 ];
2976 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2977 let penalized_hessian = Array2::<f64>::eye(2);
2978 let observed_hessians =
2979 vec![Array2::from_shape_vec((2, 2), vec![0.25, 0.0, 0.0, 0.75]).unwrap()];
2980 let score_covariances = vec![Array2::<f64>::zeros((2, 2))];
2981 let scores = vec![Array1::from_vec(vec![0.75, -0.25])];
2982 let coordinate_values = vec![Array1::<f64>::zeros(2)];
2983 let input = MultiBlockAloInput {
2984 n_obs: 1,
2985 n_coordinates: 2,
2986 coordinate_designs: &coordinate_designs,
2987 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2988 penalized_hessian: &penalized_hessian,
2989 observed_hessians: &observed_hessians,
2990 score_covariances: &score_covariances,
2991 scores: &scores,
2992 coordinate_values: &coordinate_values,
2993 };
2994
2995 let result = compute_multiblock_alo(&input)
2996 .expect("trace-one but invertible deletion system must be solved exactly");
2997 let roundoff = floating_point_gamma(16);
2998 assert!((result.leverage[0] - 1.0).abs() <= roundoff);
2999 assert!((result.eta_tilde[0][0] - 1.0).abs() <= roundoff);
3000 assert!((result.eta_tilde[0][1] + 1.0).abs() <= roundoff);
3001 }
3002}