1use crate::estimate::EstimationError;
2use faer::Mat as FaerMat;
3use faer::linalg::matmul::matmul;
4use faer::prelude::ReborrowMut;
5use faer::{Accum, Par};
6use gam_linalg::faer_ndarray::FaerArrayView;
7use gam_linalg::matrix::{DesignMatrix, PsdWeightsView, SignedWeightsView};
8use gam_linalg::utils::{
9 CertifiedSpdFactor, certified_spd_factorize, symmetric_extremes,
10 validate_finite_symmetric_matrix,
11};
12use gam_math::probability::signed_log_sum_exp;
13use ndarray::{Array1, Array2, ArrayView1, ShapeBuilder, s};
14use opt::{BacktrackConfig, backtracking_line_search};
15use std::convert::Infallible;
16use std::fmt;
17use std::ops::Range;
18use crate::estimate::UnifiedFitResult;
19use crate::estimate::FitGeometry;
20use crate::estimate::WorkingGeometry;
21
22#[derive(Debug, Clone)]
31pub enum AloError {
32 InvalidInput { reason: String },
36 WeightInvalid { reason: String },
39 DesignDegenerate { reason: String },
42 LooComputationFailed { reason: String },
45}
46
47impl fmt::Display for AloError {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 AloError::InvalidInput { reason }
51 | AloError::WeightInvalid { reason }
52 | AloError::DesignDegenerate { reason }
53 | AloError::LooComputationFailed { reason } => f.write_str(reason),
54 }
55 }
56}
57
58impl std::error::Error for AloError {}
59
60impl From<AloError> for EstimationError {
61 fn from(err: AloError) -> EstimationError {
62 match err {
63 AloError::InvalidInput { reason }
64 | AloError::WeightInvalid { reason }
65 | AloError::DesignDegenerate { reason }
66 | AloError::LooComputationFailed { reason } => EstimationError::InvalidInput(reason),
67 }
68 }
69}
70
71impl From<AloError> for String {
72 fn from(err: AloError) -> String {
73 err.to_string()
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct AloDiagnostics {
80 pub eta_tilde: Array1<f64>,
81 pub se_bayes: Array1<f64>,
84 pub se_sandwich: Array1<f64>,
87 pub leverage: Array1<f64>,
90}
91
92#[inline]
93fn alo_eta_updatewith_offset(
94 eta_hat: f64,
95 z: f64,
96 offset: f64,
97 x_hinv_x: f64,
98 score_weight: f64,
99 denom: f64,
100) -> f64 {
101 let eta_centered = eta_hat - offset;
104 let z_centered = z - offset;
105 let score = score_weight * (eta_centered - z_centered);
106 offset + eta_centered + x_hinv_x * score / denom
107}
108
109pub type AloScalarScoreCurvature<'a> =
119 dyn Fn(usize, f64) -> Result<(f64, f64), AloError> + Sync + 'a;
120
121const ALO_EXACT_SCALAR_MAX_ITERS: usize = 64;
127
128#[inline]
132fn alo_scalar_residual_allowance(eta: f64, eta_hat: f64, score_step: f64) -> f64 {
133 32.0 * f64::EPSILON * eta.abs().max(eta_hat.abs()).max(score_step.abs())
134}
135
136#[derive(Debug, Clone, PartialEq)]
157enum AloExactScalarError {
158 EvaluationFailed {
159 eta: f64,
160 reason: String,
161 },
162 NonFiniteScoreCurvature {
163 eta: f64,
164 ell_prime: f64,
165 ell_double: f64,
166 },
167 DegenerateJacobian {
168 eta: f64,
169 jacobian: f64,
170 },
171 NonFiniteStep {
172 eta: f64,
173 residual: f64,
174 jacobian: f64,
175 next: f64,
176 },
177 MaxIterations {
178 iterations: usize,
179 residual: f64,
180 tolerance: f64,
181 eta: f64,
182 },
183}
184
185impl fmt::Display for AloExactScalarError {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 match *self {
188 AloExactScalarError::EvaluationFailed { eta, ref reason } => {
189 write!(
190 f,
191 "score/curvature evaluation failed at eta={eta:.6e}: {reason}"
192 )
193 }
194 AloExactScalarError::NonFiniteScoreCurvature {
195 eta,
196 ell_prime,
197 ell_double,
198 } => write!(
199 f,
200 "non-finite score/curvature at eta={eta:.6e}: ell_prime={ell_prime:.6e}, ell_double={ell_double:.6e}"
201 ),
202 AloExactScalarError::DegenerateJacobian { eta, jacobian } => write!(
203 f,
204 "degenerate Newton Jacobian at eta={eta:.6e}: jacobian={jacobian:.6e}"
205 ),
206 AloExactScalarError::NonFiniteStep {
207 eta,
208 residual,
209 jacobian,
210 next,
211 } => write!(
212 f,
213 "non-finite Newton step from eta={eta:.6e}: residual={residual:.6e}, jacobian={jacobian:.6e}, next={next:.6e}"
214 ),
215 AloExactScalarError::MaxIterations {
216 iterations,
217 residual,
218 tolerance,
219 eta,
220 } => write!(
221 f,
222 "did not converge within {iterations} iterations: residual={residual:.6e}, eta={eta:.6e}, backward-error allowance={tolerance:.6e}"
223 ),
224 }
225 }
226}
227
228const ALO_EXACT_SCALAR_BACKTRACKS: usize = 40;
233
234#[inline]
235fn alo_eta_exact_frozen_curvature(
236 eta_hat: f64,
237 a_ii: f64,
238 score_curvature: &dyn Fn(f64) -> Result<(f64, f64), AloError>,
239) -> Result<f64, AloExactScalarError> {
240 let residual_and_jac = |eta: f64| -> Result<(f64, f64, f64), AloExactScalarError> {
264 let (ell_prime, ell_double) =
265 score_curvature(eta).map_err(|error| AloExactScalarError::EvaluationFailed {
266 eta,
267 reason: error.to_string(),
268 })?;
269 if !ell_prime.is_finite() || !ell_double.is_finite() {
270 return Err(AloExactScalarError::NonFiniteScoreCurvature {
271 eta,
272 ell_prime,
273 ell_double,
274 });
275 }
276 let score_step = a_ii * ell_prime;
277 let residual = eta - eta_hat - score_step;
278 let jacobian = 1.0 - a_ii * ell_double;
279 let tolerance = alo_scalar_residual_allowance(eta, eta_hat, score_step);
280 if !score_step.is_finite()
281 || !residual.is_finite()
282 || !jacobian.is_finite()
283 || !tolerance.is_finite()
284 {
285 return Err(AloExactScalarError::NonFiniteStep {
286 eta,
287 residual,
288 jacobian,
289 next: f64::NAN,
290 });
291 }
292 Ok((residual, jacobian, tolerance))
293 };
294
295 let mut eta = eta_hat;
296 let (mut residual, mut jac, mut tolerance) = residual_and_jac(eta)?;
297 for _ in 0..ALO_EXACT_SCALAR_MAX_ITERS {
298 if residual.abs() <= tolerance {
299 return Ok(eta);
300 }
301 if jac == 0.0 || !jac.is_finite() {
302 return Err(AloExactScalarError::DegenerateJacobian { eta, jacobian: jac });
303 }
304 let step = residual / jac;
305 if !step.is_finite() {
306 return Err(AloExactScalarError::NonFiniteStep {
307 eta,
308 residual,
309 jacobian: jac,
310 next: eta - step,
311 });
312 }
313 let accepted = match backtracking_line_search::<_, Infallible>(
319 BacktrackConfig {
320 max_steps: ALO_EXACT_SCALAR_BACKTRACKS,
321 ..BacktrackConfig::default()
322 },
323 |t| {
324 let trial = eta - t * step;
325 Ok(residual_and_jac(trial)
326 .ok()
327 .map(|(r_trial, j_trial, tol_trial)| {
328 (r_trial.abs(), (trial, r_trial, j_trial, tol_trial))
329 }))
330 },
331 |_, merit| merit < residual.abs(),
332 ) {
333 Ok(result) => result,
334 Err(never) => match never {},
335 };
336 let Some(step) = accepted else {
337 break;
338 };
339 (eta, residual, jac, tolerance) = step.payload;
340 }
341 Err(AloExactScalarError::MaxIterations {
342 iterations: ALO_EXACT_SCALAR_MAX_ITERS,
343 residual,
344 tolerance,
345 eta,
346 })
347}
348
349fn spd_quadratic_after_certified_solve(
355 row: usize,
356 rhs: ArrayView1<'_, f64>,
357 solution: ArrayView1<'_, f64>,
358) -> Result<f64, AloError> {
359 if rhs.len() != solution.len() {
360 return Err(AloError::LooComputationFailed {
361 reason: format!(
362 "ALO certified quadratic dimension mismatch at row {row}: rhs={}, solution={}",
363 rhs.len(),
364 solution.len()
365 ),
366 });
367 }
368 let mut sum = 0.0_f64;
369 let mut compensation = 0.0_f64;
370 let mut rhs_nonzero = false;
371 let mut fast_path_finite = true;
372 for (&left, &right) in rhs.iter().zip(solution.iter()) {
373 if !left.is_finite() || !right.is_finite() {
374 return Err(AloError::LooComputationFailed {
375 reason: format!(
376 "ALO certified solve produced a non-finite quadratic coordinate at row {row}: rhs={left}, solution={right}"
377 ),
378 });
379 }
380 rhs_nonzero |= left != 0.0;
381 let term = left * right;
382 if !term.is_finite() {
383 fast_path_finite = false;
384 continue;
385 }
386 let next = sum + term;
387 if !next.is_finite() {
388 fast_path_finite = false;
389 continue;
390 }
391 compensation += if sum.abs() >= term.abs() {
392 (sum - next) + term
393 } else {
394 (term - next) + sum
395 };
396 sum = next;
397 }
398 let fast = sum + compensation;
399 if !rhs_nonzero {
400 return Ok(0.0);
401 }
402 if fast_path_finite && fast.is_finite() && fast > 0.0 {
403 return Ok(fast);
404 }
405
406 let mut log_magnitudes = Vec::with_capacity(rhs.len());
407 let mut signs = Vec::with_capacity(rhs.len());
408 for (&left, &right) in rhs.iter().zip(solution.iter()) {
409 if left == 0.0 || right == 0.0 {
410 log_magnitudes.push(f64::NEG_INFINITY);
411 signs.push(0.0);
412 } else {
413 log_magnitudes.push(left.abs().ln() + right.abs().ln());
414 signs.push(left.signum() * right.signum());
415 }
416 }
417 let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
418 if sign <= 0.0 || !log_magnitude.is_finite() {
419 return Err(AloError::LooComputationFailed {
420 reason: format!(
421 "ALO SPD quadratic could not be represented as strictly positive at row {row}: sign={sign}, log_magnitude={log_magnitude}, fast_value={fast}"
422 ),
423 });
424 }
425 let value = log_magnitude.exp();
426 if !value.is_finite() || value == 0.0 {
427 return Err(AloError::LooComputationFailed {
428 reason: format!(
429 "ALO SPD quadratic lies outside the nonzero finite f64 range at row {row}: log_magnitude={log_magnitude}"
430 ),
431 });
432 }
433 Ok(value)
434}
435
436fn finite_weighted_square_sum(
440 observation: usize,
441 weights: ArrayView1<'_, f64>,
442 values: &[f64],
443) -> Result<f64, AloError> {
444 if weights.len() != values.len() {
445 return Err(AloError::LooComputationFailed {
446 reason: format!(
447 "ALO sandwich quadratic dimension mismatch for observation {observation}: weights={}, values={}",
448 weights.len(),
449 values.len()
450 ),
451 });
452 }
453 let mut sum = 0.0_f64;
454 let mut compensation = 0.0_f64;
455 let mut has_mathematically_positive_term = false;
456 let mut fast_path_finite = true;
457 for (&weight, &value) in weights.iter().zip(values.iter()) {
458 if !weight.is_finite() || weight < 0.0 || !value.is_finite() {
459 return Err(AloError::LooComputationFailed {
460 reason: format!(
461 "ALO sandwich quadratic has an invalid coordinate for observation {observation}: weight={weight}, value={value}"
462 ),
463 });
464 }
465 if weight == 0.0 || value == 0.0 {
466 continue;
467 }
468 has_mathematically_positive_term = true;
469 let term = (weight * value) * value;
470 if !term.is_finite() || term == 0.0 {
471 fast_path_finite = false;
472 continue;
473 }
474 let next = sum + term;
475 if !next.is_finite() {
476 fast_path_finite = false;
477 continue;
478 }
479 compensation += if sum.abs() >= term {
480 (sum - next) + term
481 } else {
482 (term - next) + sum
483 };
484 sum = next;
485 }
486 let fast = sum + compensation;
487 if !has_mathematically_positive_term {
488 return Ok(0.0);
489 }
490 if fast_path_finite && fast.is_finite() && fast > 0.0 {
491 return Ok(fast);
492 }
493
494 let mut log_magnitudes = Vec::with_capacity(values.len());
495 let mut signs = Vec::with_capacity(values.len());
496 for (&weight, &value) in weights.iter().zip(values.iter()) {
497 if weight == 0.0 || value == 0.0 {
498 log_magnitudes.push(f64::NEG_INFINITY);
499 signs.push(0.0);
500 } else {
501 log_magnitudes.push(weight.ln() + 2.0 * value.abs().ln());
502 signs.push(1.0);
503 }
504 }
505 let (log_magnitude, sign) = signed_log_sum_exp(&log_magnitudes, &signs);
506 let value = log_magnitude.exp();
507 if sign != 1.0 || !value.is_finite() || value == 0.0 {
508 return Err(AloError::LooComputationFailed {
509 reason: format!(
510 "ALO sandwich quadratic lies outside the positive finite f64 range for observation {observation}: sign={sign}, log_magnitude={log_magnitude}"
511 ),
512 });
513 }
514 Ok(value)
515}
516
517fn finite_nonnegative_product(
518 row: usize,
519 quantity: &'static str,
520 left: f64,
521 right: f64,
522) -> Result<f64, AloError> {
523 if !(left.is_finite() && left >= 0.0 && right.is_finite() && right >= 0.0) {
524 return Err(AloError::LooComputationFailed {
525 reason: format!(
526 "ALO {quantity} requires finite non-negative factors at row {row}: left={left}, right={right}"
527 ),
528 });
529 }
530 if left == 0.0 || right == 0.0 {
531 return Ok(0.0);
532 }
533 let direct = left * right;
534 if direct.is_finite() && direct > 0.0 {
535 return Ok(direct);
536 }
537 let log_magnitude = left.ln() + right.ln();
538 let value = log_magnitude.exp();
539 if !value.is_finite() || value == 0.0 {
540 return Err(AloError::LooComputationFailed {
541 reason: format!(
542 "ALO {quantity} lies outside the positive finite f64 range at row {row}: log_magnitude={log_magnitude}"
543 ),
544 });
545 }
546 Ok(value)
547}
548
549fn finite_signed_product(
550 row: usize,
551 quantity: &'static str,
552 left: f64,
553 right: f64,
554) -> Result<f64, AloError> {
555 if !left.is_finite() || !right.is_finite() {
556 return Err(AloError::LooComputationFailed {
557 reason: format!(
558 "ALO {quantity} requires finite factors at row {row}: left={left}, right={right}"
559 ),
560 });
561 }
562 if left == 0.0 || right == 0.0 {
563 return Ok(0.0);
564 }
565 let direct = left * right;
566 if direct.is_finite() && direct != 0.0 {
567 return Ok(direct);
568 }
569 let log_magnitude = left.abs().ln() + right.abs().ln();
570 let value = left.signum() * right.signum() * log_magnitude.exp();
571 if !value.is_finite() || value == 0.0 {
572 return Err(AloError::LooComputationFailed {
573 reason: format!(
574 "ALO {quantity} lies outside the nonzero finite f64 range at row {row}: sign={}, log_magnitude={log_magnitude}",
575 left.signum() * right.signum()
576 ),
577 });
578 }
579 Ok(value)
580}
581
582const MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES: usize = 256 * 1024 * 1024;
583
584const ALO_MAX_RHS_BLOCK_COLS: usize = 8192;
589
590#[inline]
599fn alo_rhs_block_cols(n: usize, p: usize) -> usize {
600 let scalars_per_col = n.saturating_add(p.saturating_mul(5)).max(1);
601 let bytes_per_col = std::mem::size_of::<f64>().saturating_mul(scalars_per_col);
602 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_col.max(1))
603 .max(1)
604 .min(ALO_MAX_RHS_BLOCK_COLS)
605}
606
607const LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR: f64 = 8.0;
611
612pub struct AloInput<'a> {
619 pub design: &'a Array2<f64>,
621 pub penalized_hessian: &'a Array2<f64>,
623 pub hessian_weights: SignedWeightsView<'a>,
630 pub score_weights: PsdWeightsView<'a>,
633 pub working_response: &'a Array1<f64>,
635 pub eta: &'a Array1<f64>,
637 pub offset: &'a Array1<f64>,
639 pub phi: f64,
641 pub score_curvature: Option<&'a AloScalarScoreCurvature<'a>>,
654}
655
656impl<'a> AloInput<'a> {
657
658 fn from_active_geometry(
663 geom: &'a FitGeometry,
664 working: &'a WorkingGeometry,
665 design: &'a Array2<f64>,
666 eta: &'a Array1<f64>,
667 offset: &'a Array1<f64>,
668 phi: f64,
669 ) -> Self {
670 let psd_w = PsdWeightsView::from_view_unchecked(working.weights.view());
677 Self {
678 design,
679 penalized_hessian: &geom.penalized_hessian,
680 hessian_weights: psd_w.as_signed(),
681 score_weights: psd_w,
682 working_response: &working.response,
683 eta,
684 offset,
685 phi,
686 score_curvature: None,
687 }
688 }
689
690 pub fn from_penalized_hessian_with_working_state(
708 penalized_hessian: &'a Array2<f64>,
709 design: &'a Array2<f64>,
710 eta: &'a Array1<f64>,
711 offset: &'a Array1<f64>,
712 phi: f64,
713 working_weights: &'a Array1<f64>,
714 working_response: &'a Array1<f64>,
715 ) -> Self {
716 let psd_w = PsdWeightsView::from_view_unchecked(working_weights.view());
717 Self {
718 design,
719 penalized_hessian,
720 hessian_weights: psd_w.as_signed(),
721 score_weights: psd_w,
722 working_response,
723 eta,
724 offset,
725 phi,
726 score_curvature: None,
727 }
728 }
729}
730
731pub fn compute_alo_from_input(input: &AloInput) -> Result<AloDiagnostics, EstimationError> {
737 compute_alo_from_input_inner(input).map_err(EstimationError::from)
738}
739
740fn compute_alo_from_input_inner(input: &AloInput) -> Result<AloDiagnostics, AloError> {
741 let x_dense = input.design;
742 let n = x_dense.nrows();
743 let p = x_dense.ncols();
744 let w_h = input.hessian_weights.view();
748 let w_s = input.score_weights.view();
749
750 validate_alo_solve_setup(input, n, p)?;
751
752 let factor = certified_spd_factorize(input.penalized_hessian, "ALO penalized Hessian")
753 .map_err(|error| AloError::InvalidInput {
754 reason: format!(
755 "ALO requires an unperturbed positive-definite penalized Hessian with a certified solve: {error}"
756 ),
757 })?;
758
759 let xt = x_dense.t();
760 let phi = input.phi;
761
762 let mut aii = Array1::<f64>::zeros(n);
763 let mut x_hinv_x_diag = Array1::<f64>::zeros(n);
764 let mut se_bayes = Array1::<f64>::zeros(n);
765 let mut se_sandwich = Array1::<f64>::zeros(n);
766
767 let block_cols = alo_rhs_block_cols(n, p);
768 let mut rhs_chunk_buf = Array2::<f64>::zeros((p, block_cols).f());
773 let mut xs_chunk_storage = FaerMat::<f64>::zeros(n, block_cols);
778 let x_dense_view = FaerArrayView::new(x_dense);
779
780 for chunk_start in (0..n).step_by(block_cols) {
781 let chunk_end = (chunk_start + block_cols).min(n);
782 let width = chunk_end - chunk_start;
783
784 rhs_chunk_buf
785 .slice_mut(s![.., ..width])
786 .assign(&xt.slice(s![.., chunk_start..chunk_end]));
787
788 let rhs_chunkview = rhs_chunk_buf.slice(s![.., ..width]);
789 let rhs_chunk = rhs_chunkview.to_owned();
790 let (s_chunk, _solve_certificate) = factor.solve_matrix(&rhs_chunk).map_err(|error| {
791 AloError::LooComputationFailed {
792 reason: format!(
793 "ALO penalized-Hessian solve could not be certified for rows {chunk_start}..{chunk_end}: {error}"
794 ),
795 }
796 })?;
797 let s_chunk_view = FaerArrayView::new(&s_chunk);
798
799 let mut xs_target = xs_chunk_storage.as_mut().subcols_mut(0, width);
800 matmul(
801 xs_target.rb_mut(),
802 Accum::Replace,
803 x_dense_view.as_ref(),
804 s_chunk_view.as_ref(),
805 1.0,
806 Par::Seq,
807 );
808
809 let rhs_view = rhs_chunk_buf.slice(s![.., ..width]);
810
811 for local_col in 0..width {
812 let obs = chunk_start + local_col;
813 let rhs_col = rhs_view.column(local_col);
817 let solution_col = s_chunk.column(local_col);
818 let x_hinv_x = spd_quadratic_after_certified_solve(obs, rhs_col, solution_col)?;
819 let ai = finite_signed_product(obs, "leverage", w_h[obs], x_hinv_x)?;
826 aii[obs] = ai;
827 x_hinv_x_diag[obs] = x_hinv_x;
828
829 let var_bayes = finite_nonnegative_product(obs, "Bayesian variance", phi, x_hinv_x)?;
830 let xs_slice = xs_chunk_storage.col_as_slice(local_col);
831 let meat_quad = finite_weighted_square_sum(obs, w_s, xs_slice)?;
836 let var_sandwich =
837 finite_nonnegative_product(obs, "sandwich variance", phi, meat_quad)?;
838
839 se_bayes[obs] = var_bayes.sqrt();
840 se_sandwich[obs] = var_sandwich.sqrt();
841 }
842 }
843
844 let eta_hat = input.eta;
845 let z = input.working_response;
846 let offset = input.offset;
847
848 use rayon::prelude::*;
849 let eta_tilde_vec: Vec<f64> = (0..n)
850 .into_par_iter()
851 .map(|i| {
852 let denom_raw = 1.0 - aii[i];
853 if denom_raw == 0.0 || !denom_raw.is_finite() {
854 return Err(AloError::LooComputationFailed {
855 reason: format!(
856 "ALO deletion denominator is not invertible at row {i}: a_ii={:.6e}, 1-a_ii={:.6e}",
857 aii[i], denom_raw
858 ),
859 });
860 }
861 let one_step = alo_eta_updatewith_offset(
862 eta_hat[i],
863 z[i],
864 offset[i],
865 x_hinv_x_diag[i],
866 w_s[i],
867 denom_raw,
868 );
869 let v = if let Some(score_curvature) = input.score_curvature {
877 alo_eta_exact_frozen_curvature(
878 eta_hat[i],
879 x_hinv_x_diag[i],
880 &|eta| score_curvature(i, eta),
881 )
882 .map_err(|err| AloError::LooComputationFailed {
883 reason: format!(
884 "ALO exact frozen-curvature solve failed at row {i}: {err}"
885 ),
886 })?
887 } else {
888 one_step
889 };
890 if !v.is_finite() {
891 return Err(AloError::LooComputationFailed {
892 reason: format!("ALO eta_tilde is not finite at row {i}: eta_tilde={v}"),
893 });
894 }
895 Ok(v)
896 })
897 .collect::<Result<_, _>>()?;
898 let eta_tilde = Array1::from(eta_tilde_vec);
899
900 Ok(AloDiagnostics {
901 eta_tilde,
902 se_bayes,
903 se_sandwich,
904 leverage: aii,
905 })
906}
907
908fn validate_alo_solve_setup(input: &AloInput, n: usize, p: usize) -> Result<(), AloError> {
909 let h = input.penalized_hessian;
910 if h.nrows() != p || h.ncols() != p {
911 return Err(AloError::InvalidInput {
912 reason: format!(
913 "ALO diagnostics require a dense exact penalized Hessian with shape {p}x{p}; got {}x{}",
914 h.nrows(),
915 h.ncols()
916 ),
917 });
918 }
919 let vector_lengths = [
920 ("hessian_weights", input.hessian_weights.len()),
921 ("score_weights", input.score_weights.len()),
922 ("working_response", input.working_response.len()),
923 ("eta", input.eta.len()),
924 ("offset", input.offset.len()),
925 ];
926 for (name, len) in vector_lengths {
927 if len != n {
928 return Err(AloError::InvalidInput {
929 reason: format!("ALO diagnostics require {name} length {n}; got {len}"),
930 });
931 }
932 }
933 if input.hessian_weights.view().iter().any(|v| !v.is_finite()) {
934 return Err(AloError::WeightInvalid {
935 reason: "ALO diagnostics require finite Hessian-side weights".to_string(),
936 });
937 }
938 if let Some((row, value)) = input
939 .score_weights
940 .view()
941 .iter()
942 .copied()
943 .enumerate()
944 .find(|(_, value)| !value.is_finite() || *value < 0.0)
945 {
946 return Err(AloError::WeightInvalid {
947 reason: format!(
948 "ALO diagnostics require finite non-negative score-side weights; row {row} has {value:?}"
949 ),
950 });
951 }
952 if input.working_response.iter().any(|v| !v.is_finite()) {
953 return Err(AloError::WeightInvalid {
954 reason: "ALO diagnostics require finite working responses".to_string(),
955 });
956 }
957 if input.eta.iter().any(|v| !v.is_finite()) || input.offset.iter().any(|v| !v.is_finite()) {
958 return Err(AloError::InvalidInput {
959 reason: "ALO diagnostics require finite linear predictors and offsets".to_string(),
960 });
961 }
962 if !input.phi.is_finite() || input.phi <= 0.0 {
963 return Err(AloError::InvalidInput {
964 reason: format!(
965 "ALO diagnostics require positive finite dispersion phi; got {}",
966 input.phi
967 ),
968 });
969 }
970 Ok(())
971}
972
973#[derive(Debug, Clone)]
977pub struct MultiBlockAloDiagnostics {
978 pub eta_tilde: Vec<Array1<f64>>,
981 pub leverage: Array1<f64>,
983 pub alo_variance: Vec<Array1<f64>>,
989 pub predictive_variance: Vec<Array1<f64>>,
1001 pub cook_distance: Array1<f64>,
1004}
1005
1006pub struct MultiBlockAloInput<'a> {
1038 pub n_obs: usize,
1040 pub n_coordinates: usize,
1042 pub coordinate_designs: &'a [DesignMatrix],
1045 pub coordinate_coefficient_ranges: &'a [Range<usize>],
1049 pub penalized_hessian: &'a Array2<f64>,
1052 pub observed_hessians: &'a [Array2<f64>],
1055 pub score_covariances: &'a [Array2<f64>],
1058 pub scores: &'a [Array1<f64>],
1061 pub coordinate_values: &'a [Array1<f64>],
1065}
1066
1067pub fn compute_multiblock_alo(
1086 input: &MultiBlockAloInput,
1087) -> Result<MultiBlockAloDiagnostics, EstimationError> {
1088 compute_multiblock_alo_inner(input).map_err(EstimationError::from)
1089}
1090
1091fn validate_multiblock_alo_input(input: &MultiBlockAloInput<'_>) -> Result<(), AloError> {
1092 let n = input.n_obs;
1093 let b = input.n_coordinates;
1094 if n == 0 || b == 0 {
1095 return Err(AloError::InvalidInput {
1096 reason: format!(
1097 "multi-block ALO requires positive observation and coordinate counts; got n={n}, B={b}"
1098 ),
1099 });
1100 }
1101 if input.coordinate_designs.len() != b {
1102 return Err(AloError::InvalidInput {
1103 reason: format!(
1104 "multi-block ALO expected {b} coordinate designs, got {}",
1105 input.coordinate_designs.len()
1106 ),
1107 });
1108 }
1109 let p_tot = input.penalized_hessian.nrows();
1110 if input.penalized_hessian.ncols() != p_tot || p_tot == 0 {
1111 return Err(AloError::InvalidInput {
1112 reason: format!(
1113 "multi-block ALO penalized Hessian must be non-empty and square; got {}x{}",
1114 input.penalized_hessian.nrows(),
1115 input.penalized_hessian.ncols()
1116 ),
1117 });
1118 }
1119 if input.coordinate_coefficient_ranges.len() != b {
1120 return Err(AloError::InvalidInput {
1121 reason: format!(
1122 "multi-block ALO expected {b} coordinate coefficient ranges, got {}",
1123 input.coordinate_coefficient_ranges.len()
1124 ),
1125 });
1126 }
1127 for (coordinate, (design, coefficient_range)) in input
1128 .coordinate_designs
1129 .iter()
1130 .zip(input.coordinate_coefficient_ranges)
1131 .enumerate()
1132 {
1133 if design.nrows() != n {
1134 return Err(AloError::InvalidInput {
1135 reason: format!(
1136 "multi-block ALO coordinate design {coordinate} has {} rows; expected {n}",
1137 design.nrows()
1138 ),
1139 });
1140 }
1141 if design.ncols() == 0 || coefficient_range.is_empty() {
1142 return Err(AloError::InvalidInput {
1143 reason: format!(
1144 "multi-block ALO coordinate {coordinate} has an empty local design or coefficient range"
1145 ),
1146 });
1147 }
1148 if coefficient_range.len() != design.ncols() || coefficient_range.end > p_tot {
1149 return Err(AloError::InvalidInput {
1150 reason: format!(
1151 "multi-block ALO coordinate {coordinate} design has {} columns but parameter range {}..{} has length {} in a {p_tot}-dimensional saved Hessian",
1152 design.ncols(),
1153 coefficient_range.start,
1154 coefficient_range.end,
1155 coefficient_range.len()
1156 ),
1157 });
1158 }
1159 }
1160 for (label, length) in [
1161 ("observed_hessians", input.observed_hessians.len()),
1162 ("score_covariances", input.score_covariances.len()),
1163 ("scores", input.scores.len()),
1164 ("coordinate_values", input.coordinate_values.len()),
1165 ] {
1166 if length != n {
1167 return Err(AloError::InvalidInput {
1168 reason: format!("multi-block ALO requires {label} length {n}; got {length}"),
1169 });
1170 }
1171 }
1172 for row in 0..n {
1173 let observed = &input.observed_hessians[row];
1174 let score_covariance = &input.score_covariances[row];
1175 for (label, matrix) in [
1176 ("observed Hessian", observed),
1177 ("score covariance", score_covariance),
1178 ] {
1179 if matrix.dim() != (b, b) {
1180 return Err(AloError::InvalidInput {
1181 reason: format!(
1182 "multi-block ALO row {row} {label} has shape {}x{}; expected {b}x{b}",
1183 matrix.nrows(),
1184 matrix.ncols()
1185 ),
1186 });
1187 }
1188 validate_finite_symmetric_matrix(matrix, &format!("multi-block ALO row {row} {label}"))
1189 .map_err(|error| AloError::InvalidInput {
1190 reason: error.to_string(),
1191 })?;
1192 }
1193 let covariance_scale = score_covariance
1194 .iter()
1195 .fold(0.0_f64, |scale, value| scale.max(value.abs()));
1196 let (minimum, maximum) =
1197 symmetric_extremes(score_covariance).ok_or_else(|| AloError::InvalidInput {
1198 reason: format!(
1199 "multi-block ALO row {row} score-covariance eigendecomposition failed"
1200 ),
1201 })?;
1202 let psd_tolerance = LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR
1203 * b as f64
1204 * f64::EPSILON
1205 * covariance_scale.max(maximum.abs());
1206 if minimum < -psd_tolerance {
1207 return Err(AloError::InvalidInput {
1208 reason: format!(
1209 "multi-block ALO row {row} score covariance is not positive semidefinite: minimum eigenvalue {minimum:.6e}, roundoff allowance {psd_tolerance:.6e}"
1210 ),
1211 });
1212 }
1213 for (label, vector) in [
1214 ("score", &input.scores[row]),
1215 ("coordinate value", &input.coordinate_values[row]),
1216 ] {
1217 if vector.len() != b {
1218 return Err(AloError::InvalidInput {
1219 reason: format!(
1220 "multi-block ALO row {row} {label} has length {}; expected {b}",
1221 vector.len()
1222 ),
1223 });
1224 }
1225 if let Some((coordinate, value)) = vector
1226 .iter()
1227 .copied()
1228 .enumerate()
1229 .find(|(_, value)| !value.is_finite())
1230 {
1231 return Err(AloError::InvalidInput {
1232 reason: format!(
1233 "multi-block ALO row {row} {label} coordinate {coordinate} is non-finite: {value}"
1234 ),
1235 });
1236 }
1237 }
1238 }
1239 Ok(())
1240}
1241
1242fn compute_multiblock_alo_inner(
1243 input: &MultiBlockAloInput,
1244) -> Result<MultiBlockAloDiagnostics, AloError> {
1245 use rayon::prelude::*;
1246
1247 let n = input.n_obs;
1248 let b = input.n_coordinates;
1249 let p_tot = input.penalized_hessian.nrows();
1250 validate_multiblock_alo_input(input)?;
1251 let factor = certified_spd_factorize(input.penalized_hessian, "multi-block ALO penalized Hessian")
1252 .map_err(|error| AloError::InvalidInput {
1253 reason: format!(
1254 "multi-block ALO requires an unperturbed positive-definite saved penalized Hessian: {error}"
1255 ),
1256 })?;
1257
1258 let (chunk_size, max_concurrent_chunks) = multiblock_alo_parallel_plan(p_tot, b, n);
1259 let chunk_starts: Vec<usize> = (0..n).step_by(chunk_size).collect();
1260
1261 let mut chunk_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> =
1267 Vec::with_capacity(chunk_starts.len());
1268 for chunk_wave in chunk_starts.chunks(max_concurrent_chunks) {
1269 let mut wave_results: Vec<Result<MultiBlockAloChunkDiagnostics, AloError>> = chunk_wave
1270 .par_iter()
1271 .map_init(
1272 || MultiBlockAloScratch::new(b),
1273 |scratch, &chunk_start| {
1274 let chunk_end = (chunk_start + chunk_size).min(n);
1275 compute_multiblock_alo_chunk(input, &factor, chunk_start, chunk_end, scratch)
1276 },
1277 )
1278 .collect();
1279 chunk_results.append(&mut wave_results);
1280 }
1281
1282 let mut eta_tilde = Vec::with_capacity(n);
1283 let mut leverage = Array1::<f64>::zeros(n);
1284 let mut alo_variance = Vec::with_capacity(n);
1285 let mut predictive_variance = Vec::with_capacity(n);
1286 let mut cook_distance = Array1::<f64>::zeros(n);
1287
1288 let mut chunks = Vec::with_capacity(chunk_results.len());
1289 for result in chunk_results {
1290 chunks.push(result?);
1291 }
1292 chunks.sort_unstable_by_key(|chunk| chunk.chunk_start);
1293
1294 for chunk in chunks {
1295 let chunk_start = chunk.chunk_start;
1296 eta_tilde.extend(chunk.eta_tilde);
1297 alo_variance.extend(chunk.alo_variance);
1298 predictive_variance.extend(chunk.predictive_variance);
1299 for (local_i, lev) in chunk.leverage.into_iter().enumerate() {
1300 leverage[chunk_start + local_i] = lev;
1301 }
1302 for (local_i, cook) in chunk.cook_distance.into_iter().enumerate() {
1303 cook_distance[chunk_start + local_i] = cook;
1304 }
1305 }
1306
1307 Ok(MultiBlockAloDiagnostics {
1308 eta_tilde,
1309 leverage,
1310 alo_variance,
1311 predictive_variance,
1312 cook_distance,
1313 })
1314}
1315
1316#[inline]
1317fn multiblock_alo_parallel_plan(
1318 p_tot: usize,
1319 n_coordinates: usize,
1320 n_obs: usize,
1321) -> (usize, usize) {
1322 if p_tot == 0 || n_coordinates == 0 || n_obs == 0 {
1323 return (1, 1);
1324 }
1325 let bytes_per_obs = p_tot
1328 .saturating_mul(n_coordinates)
1329 .saturating_mul(2)
1330 .saturating_mul(std::mem::size_of::<f64>())
1331 .max(1);
1332 let workers = rayon::current_num_threads().max(1);
1333 let max_concurrent_chunks = (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / bytes_per_obs)
1334 .max(1)
1335 .min(workers);
1336 let per_worker_budget =
1337 (MULTIBLOCK_ALO_MEMORY_BUDGET_BYTES / max_concurrent_chunks).max(bytes_per_obs);
1338 let budget_obs = (per_worker_budget / bytes_per_obs).max(1);
1339 (budget_obs.min(n_obs), max_concurrent_chunks)
1340}
1341
1342struct MultiBlockAloScratch {
1343 a_i: Vec<f64>,
1344 wa: Vec<f64>,
1345 aw: Vec<f64>,
1346 imwa: Vec<f64>,
1347 imaw: Vec<f64>,
1348 perm_imwa: Vec<usize>,
1349 perm_imaw: Vec<usize>,
1350 delta_eta: Vec<f64>,
1351 rhs_buf: Vec<f64>,
1352 covariance_u: Vec<f64>,
1353 var_diag_buf: Vec<f64>,
1354 w_flat: Vec<f64>,
1355 covariance_flat: Vec<f64>,
1356 lu_scratch: Vec<f64>,
1357 original_rhs: Vec<f64>,
1358}
1359
1360impl MultiBlockAloScratch {
1361 fn new(b: usize) -> Self {
1362 let bb_sz = b * b;
1363 Self {
1364 a_i: vec![0.0f64; bb_sz],
1365 wa: vec![0.0f64; bb_sz],
1366 aw: vec![0.0f64; bb_sz],
1367 imwa: vec![0.0f64; bb_sz],
1368 imaw: vec![0.0f64; bb_sz],
1369 perm_imwa: vec![0usize; b],
1370 perm_imaw: vec![0usize; b],
1371 delta_eta: vec![0.0f64; b],
1372 rhs_buf: vec![0.0f64; b],
1373 covariance_u: vec![0.0f64; b],
1374 var_diag_buf: vec![0.0f64; b],
1375 w_flat: vec![0.0f64; bb_sz],
1376 covariance_flat: vec![0.0f64; bb_sz],
1377 lu_scratch: vec![0.0f64; b],
1378 original_rhs: vec![0.0f64; b],
1379 }
1380 }
1381}
1382
1383struct MultiBlockAloChunkDiagnostics {
1384 chunk_start: usize,
1385 eta_tilde: Vec<Array1<f64>>,
1386 leverage: Vec<f64>,
1387 alo_variance: Vec<Array1<f64>>,
1388 predictive_variance: Vec<Array1<f64>>,
1389 cook_distance: Vec<f64>,
1390}
1391
1392fn compute_multiblock_alo_chunk(
1393 input: &MultiBlockAloInput,
1394 factor: &CertifiedSpdFactor<'_>,
1395 chunk_start: usize,
1396 chunk_end: usize,
1397 scratch: &mut MultiBlockAloScratch,
1398) -> Result<MultiBlockAloChunkDiagnostics, AloError> {
1399 let b = input.n_coordinates;
1400 let p_tot = input.penalized_hessian.nrows();
1401 let chunk_len = chunk_end - chunk_start;
1402
1403 let mut design_chunks = Vec::with_capacity(b);
1404 let mut q_blocks = Vec::with_capacity(b);
1405 for coordinate in 0..b {
1406 let design_chunk = input.coordinate_designs[coordinate]
1407 .try_row_chunk(chunk_start..chunk_end)
1408 .map_err(|reason| AloError::DesignDegenerate {
1409 reason: format!(
1410 "multi-block ALO could not materialize coordinate {coordinate} rows {chunk_start}..{chunk_end}: {reason}"
1411 ),
1412 })?;
1413 if let Some(((row, column), value)) = design_chunk
1414 .indexed_iter()
1415 .map(|(index, &value)| (index, value))
1416 .find(|(_, value)| !value.is_finite())
1417 {
1418 return Err(AloError::DesignDegenerate {
1419 reason: format!(
1420 "multi-block ALO coordinate {coordinate} design is non-finite at source row {}, column {column}: {value}",
1421 chunk_start + row
1422 ),
1423 });
1424 }
1425 let coefficient_range = input.coordinate_coefficient_ranges[coordinate].clone();
1426 let mut rhs = Array2::<f64>::zeros((p_tot, chunk_len));
1427 rhs.slice_mut(s![coefficient_range, ..])
1428 .assign(&design_chunk.t());
1429 let (solution, _) = factor.solve_matrix(&rhs).map_err(|error| {
1430 AloError::LooComputationFailed {
1431 reason: format!(
1432 "multi-block ALO saved-Hessian solve failed for coordinate {coordinate}, rows {chunk_start}..{chunk_end}: {error}"
1433 ),
1434 }
1435 })?;
1436 design_chunks.push(design_chunk);
1437 q_blocks.push(solution);
1438 }
1439
1440 let mut eta_tilde = Vec::with_capacity(chunk_len);
1441 let mut leverage = vec![0.0f64; chunk_len];
1442 let mut alo_variance = Vec::with_capacity(chunk_len);
1443 let mut predictive_variance = Vec::with_capacity(chunk_len);
1444 let mut cook_distance = vec![0.0f64; chunk_len];
1445
1446 for local_i in 0..chunk_len {
1447 let i = chunk_start + local_i;
1448 let w_i = &input.observed_hessians[i];
1449 let covariance_i = &input.score_covariances[i];
1450
1451 for r in 0..b {
1454 for c in 0..b {
1455 scratch.w_flat[r * b + c] = w_i[(r, c)];
1456 scratch.covariance_flat[r * b + c] = covariance_i[(r, c)];
1457 }
1458 }
1459
1460 for a in 0..b {
1462 let x_a = &design_chunks[a];
1463 let p_a = x_a.ncols();
1464 let off_a = input.coordinate_coefficient_ranges[a].start;
1465 let xa_row = x_a.row(local_i);
1466 for bb in 0..b {
1467 let q_bb = &q_blocks[bb];
1468 let mut dot = 0.0f64;
1469 for k in 0..p_a {
1470 dot += xa_row[k] * q_bb[(off_a + k, local_i)];
1471 }
1472 scratch.a_i[a * b + bb] = dot;
1473 }
1474 }
1475
1476 let mut pred_var = Array1::<f64>::zeros(b);
1481 for d in 0..b {
1482 pred_var[d] = scratch.a_i[d * b + d].max(0.0);
1483 }
1484 predictive_variance.push(pred_var);
1485
1486 mat_mul_flat(&scratch.w_flat, &scratch.a_i, &mut scratch.wa, b);
1488 mat_mul_flat(&scratch.a_i, &scratch.w_flat, &mut scratch.aw, b);
1490
1491 let mut tr = 0.0f64;
1494 for d in 0..b {
1495 tr += scratch.aw[d * b + d];
1496 }
1497 leverage[local_i] = tr;
1498
1499 for r in 0..b {
1501 for c in 0..b {
1502 let idx = r * b + c;
1503 let id = if r == c { 1.0 } else { 0.0 };
1504 scratch.imwa[idx] = id - scratch.wa[idx];
1505 scratch.imaw[idx] = id - scratch.aw[idx];
1506 }
1507 }
1508
1509 let imwa_tolerance =
1516 identity_minus_product_lu_tolerance(&scratch.w_flat, &scratch.a_i, &scratch.wa, b)?;
1517 if !lu_factor_in_place(&mut scratch.imwa, &mut scratch.perm_imwa, b, imwa_tolerance) {
1518 return Err(AloError::LooComputationFailed {
1519 reason: format!(
1520 "multi-block ALO deletion system I-WA is singular at row {i}; local pivot allowance {imwa_tolerance:.6e}, leverage trace {:.6e}",
1521 leverage[local_i]
1522 ),
1523 });
1524 }
1525 let imaw_tolerance =
1526 identity_minus_product_lu_tolerance(&scratch.a_i, &scratch.w_flat, &scratch.aw, b)?;
1527 if !lu_factor_in_place(&mut scratch.imaw, &mut scratch.perm_imaw, b, imaw_tolerance) {
1528 return Err(AloError::LooComputationFailed {
1529 reason: format!(
1530 "multi-block ALO transpose deletion system I-AW is singular at row {i}; local pivot allowance {imaw_tolerance:.6e}, leverage trace {:.6e}",
1531 leverage[local_i]
1532 ),
1533 });
1534 }
1535
1536 let s_i = &input.scores[i];
1538 for k in 0..b {
1539 scratch.rhs_buf[k] = s_i[k];
1540 }
1541 if let Err(failure) = solve_identity_minus_product_in_place(
1542 &scratch.imwa,
1543 &scratch.perm_imwa,
1544 &scratch.wa,
1545 &mut scratch.rhs_buf,
1546 &mut scratch.lu_scratch,
1547 &mut scratch.original_rhs,
1548 imwa_tolerance,
1549 b,
1550 ) {
1551 return Err(AloError::LooComputationFailed {
1552 reason: format!(
1553 "multi-block ALO deletion solve I-WA failed backward-error certification at row {i}: residual {:.6e}, allowance {:.6e}",
1554 failure.residual_norm, failure.allowance
1555 ),
1556 });
1557 }
1558 for r in 0..b {
1560 let mut acc = 0.0f64;
1561 let row_off = r * b;
1562 for k in 0..b {
1563 acc += scratch.a_i[row_off + k] * scratch.rhs_buf[k];
1564 }
1565 scratch.delta_eta[r] = acc;
1566 }
1567
1568 let eta_i = &input.coordinate_values[i];
1569 let mut corrected = Array1::<f64>::zeros(b);
1570 for d in 0..b {
1571 corrected[d] = eta_i[d] + scratch.delta_eta[d];
1572 if !scratch.delta_eta[d].is_finite() || !corrected[d].is_finite() {
1573 return Err(AloError::LooComputationFailed {
1574 reason: format!(
1575 "multi-block ALO correction is non-finite at row {i}, coordinate {d}: delta={}, corrected={}",
1576 scratch.delta_eta[d], corrected[d]
1577 ),
1578 });
1579 }
1580 }
1581 eta_tilde.push(corrected);
1582
1583 let mut cook = 0.0f64;
1585 let mut cook_scale = 0.0f64;
1586 for r in 0..b {
1587 let mut covariance_delta_r = 0.0f64;
1588 let row_off = r * b;
1589 for k in 0..b {
1590 covariance_delta_r += scratch.covariance_flat[row_off + k] * scratch.delta_eta[k];
1591 }
1592 let term = scratch.delta_eta[r] * covariance_delta_r;
1593 cook += term;
1594 cook_scale += term.abs();
1595 }
1596 let cook_tolerance =
1597 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * cook_scale;
1598 if !cook.is_finite() || cook < -cook_tolerance {
1599 return Err(AloError::LooComputationFailed {
1600 reason: format!(
1601 "multi-block ALO Cook influence is invalid at row {i}: value {cook:.6e}, roundoff allowance {cook_tolerance:.6e}"
1602 ),
1603 });
1604 }
1605 cook_distance[local_i] = cook.max(0.0);
1606
1607 for d in 0..b {
1613 let row_off = d * b;
1614 for k in 0..b {
1616 scratch.rhs_buf[k] = scratch.a_i[row_off + k];
1617 }
1618 if let Err(failure) = solve_identity_minus_product_in_place(
1619 &scratch.imaw,
1620 &scratch.perm_imaw,
1621 &scratch.aw,
1622 &mut scratch.rhs_buf,
1623 &mut scratch.lu_scratch,
1624 &mut scratch.original_rhs,
1625 imaw_tolerance,
1626 b,
1627 ) {
1628 return Err(AloError::LooComputationFailed {
1629 reason: format!(
1630 "multi-block ALO transpose variance solve I-AW failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
1631 failure.residual_norm, failure.allowance
1632 ),
1633 });
1634 }
1635 for r in 0..b {
1637 let mut acc = 0.0f64;
1638 let wr = r * b;
1639 for k in 0..b {
1640 acc += scratch.covariance_flat[wr + k] * scratch.rhs_buf[k];
1641 }
1642 scratch.covariance_u[r] = acc;
1643 }
1644 if let Err(failure) = solve_identity_minus_product_in_place(
1646 &scratch.imwa,
1647 &scratch.perm_imwa,
1648 &scratch.wa,
1649 &mut scratch.covariance_u,
1650 &mut scratch.lu_scratch,
1651 &mut scratch.original_rhs,
1652 imwa_tolerance,
1653 b,
1654 ) {
1655 return Err(AloError::LooComputationFailed {
1656 reason: format!(
1657 "multi-block ALO variance solve I-WA failed backward-error certification at row {i}, coordinate {d}: residual {:.6e}, allowance {:.6e}",
1658 failure.residual_norm, failure.allowance
1659 ),
1660 });
1661 }
1662 let mut v_dd = 0.0f64;
1664 for k in 0..b {
1665 v_dd += scratch.a_i[row_off + k] * scratch.covariance_u[k];
1666 }
1667 let variance_scale = scratch.a_i[row_off..row_off + b]
1668 .iter()
1669 .zip(scratch.covariance_u.iter())
1670 .map(|(left, right)| (left * right).abs())
1671 .sum::<f64>();
1672 let variance_tolerance =
1673 LOCAL_DELETE_SOLVE_ROUNDOFF_FACTOR * b as f64 * f64::EPSILON * variance_scale;
1674 if !v_dd.is_finite() || v_dd < -variance_tolerance {
1675 return Err(AloError::LooComputationFailed {
1676 reason: format!(
1677 "multi-block ALO variance is invalid at row {i}, coordinate {d}: value {v_dd:.6e}, roundoff allowance {variance_tolerance:.6e}"
1678 ),
1679 });
1680 }
1681 scratch.var_diag_buf[d] = v_dd.max(0.0);
1682 }
1683 let mut var_diag = Array1::<f64>::zeros(b);
1684 for d in 0..b {
1685 var_diag[d] = scratch.var_diag_buf[d];
1686 }
1687 alo_variance.push(var_diag);
1688 }
1689
1690 Ok(MultiBlockAloChunkDiagnostics {
1691 chunk_start,
1692 eta_tilde,
1693 leverage,
1694 alo_variance,
1695 predictive_variance,
1696 cook_distance,
1697 })
1698}
1699
1700#[inline]
1702fn mat_mul_flat(a: &[f64], b_mat: &[f64], out: &mut [f64], b: usize) {
1703 for r in 0..b {
1704 let ar = r * b;
1705 let or = r * b;
1706 for c in 0..b {
1707 let mut acc = 0.0f64;
1708 for k in 0..b {
1709 acc += a[ar + k] * b_mat[k * b + c];
1710 }
1711 out[or + c] = acc;
1712 }
1713 }
1714}
1715
1716#[inline]
1719fn floating_point_gamma(operation_count: usize) -> f64 {
1720 let accumulated = operation_count as f64 * (0.5 * f64::EPSILON);
1721 if accumulated < 1.0 {
1722 accumulated / (1.0 - accumulated)
1723 } else {
1724 f64::INFINITY
1725 }
1726}
1727
1728fn identity_minus_product_lu_tolerance(
1739 left: &[f64],
1740 right: &[f64],
1741 product: &[f64],
1742 b: usize,
1743) -> Result<f64, AloError> {
1744 let expected_len = b.checked_mul(b).ok_or_else(|| AloError::InvalidInput {
1745 reason: format!(
1746 "multi-block ALO local deletion dimension B={b} overflows the square matrix size"
1747 ),
1748 })?;
1749 for (name, actual_len) in [
1750 ("left operand", left.len()),
1751 ("right operand", right.len()),
1752 ("precomputed product", product.len()),
1753 ] {
1754 if actual_len != expected_len {
1755 return Err(AloError::InvalidInput {
1756 reason: format!(
1757 "multi-block ALO local deletion {name} has length {actual_len}, expected B*B={expected_len} for B={b}"
1758 ),
1759 });
1760 }
1761 }
1762
1763 let mut operand_envelope_inf = 0.0_f64;
1764 let mut system_norm_inf = 0.0_f64;
1765 for row in 0..b {
1766 let mut operand_row_envelope = 1.0_f64;
1767 let mut system_row_norm = 0.0_f64;
1768 for column in 0..b {
1769 let mut product_entry_envelope = 0.0_f64;
1770 for inner in 0..b {
1771 product_entry_envelope +=
1772 left[row * b + inner].abs() * right[inner * b + column].abs();
1773 }
1774 operand_row_envelope += product_entry_envelope;
1775 let identity = if row == column { 1.0 } else { 0.0 };
1776 system_row_norm += (identity - product[row * b + column]).abs();
1777 }
1778 operand_envelope_inf = operand_envelope_inf.max(operand_row_envelope);
1779 system_norm_inf = system_norm_inf.max(system_row_norm);
1780 }
1781
1782 let formation_operations = b.saturating_mul(2).saturating_add(1);
1783 let elimination_operations = b.saturating_mul(3);
1784 let backward_error_scale = operand_envelope_inf.max(system_norm_inf);
1785 Ok(
1786 (floating_point_gamma(formation_operations) + floating_point_gamma(elimination_operations))
1787 * backward_error_scale,
1788 )
1789}
1790
1791fn lu_factor_in_place(m: &mut [f64], perm: &mut [usize], b: usize, pivot_tolerance: f64) -> bool {
1798 for i in 0..b {
1799 perm[i] = i;
1800 }
1801 for col in 0..b {
1802 let mut max_val = m[col * b + col].abs();
1804 let mut max_idx = col;
1805 for row in (col + 1)..b {
1806 let v = m[row * b + col].abs();
1807 if v > max_val {
1808 max_val = v;
1809 max_idx = row;
1810 }
1811 }
1812 if !max_val.is_finite() || max_val <= pivot_tolerance {
1813 return false;
1814 }
1815 if max_idx != col {
1816 for k in 0..b {
1818 m.swap(col * b + k, max_idx * b + k);
1819 }
1820 perm.swap(col, max_idx);
1821 }
1822 let pivot = m[col * b + col];
1823 for row in (col + 1)..b {
1824 let factor = m[row * b + col] / pivot;
1825 m[row * b + col] = factor; for k in (col + 1)..b {
1827 let upd = factor * m[col * b + k];
1828 m[row * b + k] -= upd;
1829 }
1830 }
1831 }
1832 true
1833}
1834
1835fn lu_solve_in_place(m: &[f64], perm: &[usize], rhs: &mut [f64], scratch: &mut [f64], b: usize) {
1838 let y = &mut scratch[..b];
1840 for row in 0..b {
1841 let mut s = rhs[perm[row]];
1842 for k in 0..row {
1843 s -= m[row * b + k] * y[k];
1844 }
1845 y[row] = s;
1846 }
1847 for row in (0..b).rev() {
1849 let mut s = y[row];
1850 for k in (row + 1)..b {
1851 s -= m[row * b + k] * rhs[k];
1852 }
1853 rhs[row] = s / m[row * b + row];
1854 }
1855}
1856
1857#[derive(Clone, Copy, Debug)]
1858struct LocalSolveResidualFailure {
1859 residual_norm: f64,
1860 allowance: f64,
1861}
1862
1863fn solve_identity_minus_product_in_place(
1870 lu: &[f64],
1871 permutation: &[usize],
1872 product: &[f64],
1873 rhs: &mut [f64],
1874 lu_scratch: &mut [f64],
1875 original_rhs: &mut [f64],
1876 operator_error_bound: f64,
1877 b: usize,
1878) -> Result<(), LocalSolveResidualFailure> {
1879 original_rhs[..b].copy_from_slice(&rhs[..b]);
1880 lu_solve_in_place(lu, permutation, rhs, lu_scratch, b);
1881
1882 let rhs_norm = original_rhs[..b]
1883 .iter()
1884 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
1885 let solution_norm = rhs[..b]
1886 .iter()
1887 .fold(0.0_f64, |norm, value| norm.max(value.abs()));
1888 let mut system_norm = 0.0_f64;
1889 let mut residual_norm = 0.0_f64;
1890 for row in 0..b {
1891 let mut row_norm = 0.0_f64;
1892 let mut residual = original_rhs[row];
1893 for column in 0..b {
1894 let identity = if row == column { 1.0 } else { 0.0 };
1895 let matrix_entry = identity - product[row * b + column];
1896 row_norm += matrix_entry.abs();
1897 residual -= matrix_entry * rhs[column];
1898 }
1899 system_norm = system_norm.max(row_norm);
1900 residual_norm = residual_norm.max(residual.abs());
1901 }
1902
1903 let certification_operations = b.saturating_mul(10);
1907 let arithmetic_scale = system_norm * solution_norm + rhs_norm;
1908 let allowance = floating_point_gamma(certification_operations) * arithmetic_scale
1909 + operator_error_bound * solution_norm;
1910 if rhs[..b].iter().any(|value| !value.is_finite())
1911 || !residual_norm.is_finite()
1912 || !allowance.is_finite()
1913 || residual_norm > allowance
1914 {
1915 Err(LocalSolveResidualFailure {
1916 residual_norm,
1917 allowance,
1918 })
1919 } else {
1920 Ok(())
1921 }
1922}
1923
1924#[cfg(test)]
1925mod tests {
1926 use super::{ALO_EXACT_SCALAR_MAX_ITERS, AloExactScalarError, alo_eta_exact_frozen_curvature, alo_eta_updatewith_offset, finite_weighted_square_sum, spd_quadratic_after_certified_solve};
1927
1928 #[test]
1929 fn alo_offset_update_matches_centered_algebra() {
1930 let eta_hat = 11.0;
1931 let z = 13.0;
1932 let offset = 10.0;
1933 let x_hinv_x = 0.2;
1934 let hessian_weight = 1.0;
1935 let score_weight = 1.0;
1936 let leverage = hessian_weight * x_hinv_x;
1938 let expected = offset + ((eta_hat - offset) - leverage * (z - offset)) / (1.0 - leverage);
1939 let got =
1940 alo_eta_updatewith_offset(eta_hat, z, offset, x_hinv_x, score_weight, 1.0 - leverage);
1941 assert!((got - expected).abs() < 1e-12);
1942 }
1943
1944 #[test]
1945 fn alo_offset_update_reduces_to_classicwhen_offsetzero() {
1946 let eta_hat = 1.25;
1947 let z = -0.5;
1948 let x_hinv_x = 0.35;
1949 let hessian_weight = 1.0;
1950 let score_weight = 1.0;
1951 let leverage = hessian_weight * x_hinv_x;
1952 let expected = (eta_hat - leverage * z) / (1.0 - leverage);
1953 let got =
1954 alo_eta_updatewith_offset(eta_hat, z, 0.0, x_hinv_x, score_weight, 1.0 - leverage);
1955 assert!((got - expected).abs() < 1e-12);
1956 }
1957
1958 #[test]
1959 fn alo_offset_update_uses_distinct_score_and_hessian_weights() {
1960 let eta_hat = 1.7;
1961 let z = 0.4;
1962 let offset = -0.2;
1963 let x_hinv_x = 0.15;
1964 let hessian_weight = 3.0;
1965 let score_weight = 5.0;
1966 let expected = offset
1967 + (eta_hat - offset)
1968 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset))
1969 / (1.0 - hessian_weight * x_hinv_x);
1970 let got = alo_eta_updatewith_offset(
1971 eta_hat,
1972 z,
1973 offset,
1974 x_hinv_x,
1975 score_weight,
1976 1.0 - hessian_weight * x_hinv_x,
1977 );
1978 assert!((got - expected).abs() < 1e-12);
1979 }
1980
1981 #[test]
1982 fn alo_offset_update_handles_zero_hessian_weight() {
1983 let eta_hat = 0.8;
1984 let z = -0.3;
1985 let offset = 0.1;
1986 let x_hinv_x = 0.4;
1987 let hessian_weight = 0.0;
1988 let score_weight = 2.5;
1989 let expected = offset
1990 + (eta_hat - offset)
1991 + x_hinv_x * score_weight * ((eta_hat - offset) - (z - offset));
1992 let got = alo_eta_updatewith_offset(
1993 eta_hat,
1994 z,
1995 offset,
1996 x_hinv_x,
1997 score_weight,
1998 1.0 - hessian_weight * x_hinv_x,
1999 );
2000 assert!((got - expected).abs() < 1e-12);
2001 }
2002
2003 #[test]
2004 fn alo_exact_frozen_curvature_converges_to_fixed_point() {
2005 let eta_hat = 1.0;
2006 let a_ii = 0.4;
2007 let got =
2008 alo_eta_exact_frozen_curvature(eta_hat, a_ii, &|eta| Ok((0.5 * (eta - 2.0), 0.5)))
2009 .expect("linear scalar fixed point should converge in one Newton step");
2010 assert!((got - 0.75).abs() < 1e-12);
2011 }
2012
2013 #[test]
2014 fn alo_exact_frozen_curvature_reports_nonconvergence() {
2015 let err = alo_eta_exact_frozen_curvature(0.0, 1.0, &|eta| Ok((eta + 1.0, 0.0)))
2016 .expect_err("constant residual should exhaust the scalar iteration budget");
2017 let AloExactScalarError::MaxIterations { iterations, .. } = err else {
2018 panic!("constant residual must report MaxIterations, got {err:?}");
2019 };
2020 assert_eq!(
2021 iterations, ALO_EXACT_SCALAR_MAX_ITERS,
2022 "non-convergence must report the full scalar iteration budget"
2023 );
2024 }
2025
2026 #[test]
2027 fn alo_scale_safe_quadratics_preserve_tiny_weights_without_false_overflow() {
2028 let weights = Array1::from_vec(vec![1e-300, 2.0]);
2029 let values = [1e200, 3.0];
2030 let meat = finite_weighted_square_sum(0, weights.view(), &values)
2031 .expect("weighted square sum is representable");
2032 assert!(meat.is_finite());
2033 assert!((meat - 1e100).abs() <= 8.0 * f64::EPSILON * 1e100);
2034
2035 let rhs = Array1::from_vec(vec![2.0, -1.0]);
2036 let solution = Array1::from_vec(vec![1.5, 0.5]);
2037 let quadratic =
2038 spd_quadratic_after_certified_solve(0, rhs.view(), solution.view()).unwrap();
2039 assert_eq!(quadratic, 2.5);
2040 }
2041
2042 use super::{
2045 MultiBlockAloInput, compute_multiblock_alo, floating_point_gamma,
2046 identity_minus_product_lu_tolerance, lu_factor_in_place, mat_mul_flat,
2047 };
2048 use gam_linalg::matrix::DesignMatrix;
2049 use ndarray::{Array1, Array2};
2050
2051 fn local_identity_minus_product_is_factorable(left: &[f64], right: &[f64], b: usize) -> bool {
2052 let mut product = vec![0.0; b * b];
2053 mat_mul_flat(left, right, &mut product, b);
2054 let mut system = vec![0.0; b * b];
2055 for row in 0..b {
2056 for column in 0..b {
2057 let identity = if row == column { 1.0 } else { 0.0 };
2058 system[row * b + column] = identity - product[row * b + column];
2059 }
2060 }
2061 let tolerance = identity_minus_product_lu_tolerance(left, right, &product, b)
2062 .expect("test matrices satisfy the B-by-B local deletion contract");
2063 let mut permutation = vec![0; b];
2064 lu_factor_in_place(&mut system, &mut permutation, b, tolerance)
2065 }
2066
2067 #[test]
2068 fn multiblock_b1_matches_scalar_leverage() {
2069 let n = 3;
2072 let p = 2;
2073 let x = Array2::from_shape_vec((n, p), vec![1.0, 0.5, 0.8, -0.3, 0.2, 1.1]).unwrap();
2074 let w = [1.0, 2.0, 0.5];
2076 let mut h = Array2::<f64>::eye(p);
2077 for i in 0..n {
2078 for r in 0..p {
2079 for c in 0..p {
2080 h[(r, c)] += w[i] * x[(i, r)] * x[(i, c)];
2081 }
2082 }
2083 }
2084 let det = h[(0, 0)] * h[(1, 1)] - h[(0, 1)] * h[(1, 0)];
2086 let mut h_inv = Array2::<f64>::zeros((p, p));
2087 h_inv[(0, 0)] = h[(1, 1)] / det;
2088 h_inv[(1, 1)] = h[(0, 0)] / det;
2089 h_inv[(0, 1)] = -h[(0, 1)] / det;
2090 h_inv[(1, 0)] = -h[(1, 0)] / det;
2091
2092 let mut scalar_lev = vec![0.0f64; n];
2094 for i in 0..n {
2095 let mut xhx = 0.0;
2096 for r in 0..p {
2097 for c in 0..p {
2098 xhx += x[(i, r)] * h_inv[(r, c)] * x[(i, c)];
2099 }
2100 }
2101 scalar_lev[i] = w[i] * xhx;
2102 }
2103
2104 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2107 let coordinate_coefficient_ranges = vec![0..p];
2108 let observed_hessians: Vec<Array2<f64>> =
2109 w.iter().map(|&wi| Array2::from_elem((1, 1), wi)).collect();
2110 let score_covariances = observed_hessians.clone();
2111 let scores: Vec<Array1<f64>> = (0..n).map(|_| Array1::from_vec(vec![0.1])).collect();
2112 let coordinate_values: Vec<Array1<f64>> =
2113 (0..n).map(|i| Array1::from_vec(vec![i as f64])).collect();
2114
2115 let input = MultiBlockAloInput {
2116 n_obs: n,
2117 n_coordinates: 1,
2118 coordinate_designs: &coordinate_designs,
2119 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2120 penalized_hessian: &h,
2121 observed_hessians: &observed_hessians,
2122 score_covariances: &score_covariances,
2123 scores: &scores,
2124 coordinate_values: &coordinate_values,
2125 };
2126
2127 let result = compute_multiblock_alo(&input).unwrap();
2128 for i in 0..n {
2129 assert!(
2130 (result.leverage[i] - scalar_lev[i]).abs() < 1e-10,
2131 "leverage mismatch at i={}: got {}, expected {}",
2132 i,
2133 result.leverage[i],
2134 scalar_lev[i]
2135 );
2136 }
2137 }
2138
2139 #[test]
2140 fn multiblock_b2_matches_closed_form_with_cross_geometry() {
2141 let coordinate_designs = vec![
2148 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2149 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2150 ];
2151 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2152 let h = Array2::from_shape_vec((2, 2), vec![2.0, 0.25, 0.25, 3.0]).unwrap();
2153 let w = Array2::from_shape_vec((2, 2), vec![0.2, 0.05, 0.05, 0.3]).unwrap();
2154 let c = Array2::from_shape_vec((2, 2), vec![0.5, 0.1, 0.1, 0.4]).unwrap();
2155 let observed_hessians = vec![w.clone()];
2156 let score_covariances = vec![c.clone()];
2157 let scores = vec![Array1::from_vec(vec![0.4, -0.2])];
2158 let coordinate_values = vec![Array1::from_vec(vec![1.0, -0.5])];
2159 let input = MultiBlockAloInput {
2160 n_obs: 1,
2161 n_coordinates: 2,
2162 coordinate_designs: &coordinate_designs,
2163 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2164 penalized_hessian: &h,
2165 observed_hessians: &observed_hessians,
2166 score_covariances: &score_covariances,
2167 scores: &scores,
2168 coordinate_values: &coordinate_values,
2169 };
2170
2171 let det_h = h[[0, 0]] * h[[1, 1]] - h[[0, 1]] * h[[1, 0]];
2172 let a = Array2::from_shape_vec(
2173 (2, 2),
2174 vec![
2175 h[[1, 1]] / det_h,
2176 -h[[0, 1]] / det_h,
2177 -h[[1, 0]] / det_h,
2178 h[[0, 0]] / det_h,
2179 ],
2180 )
2181 .unwrap();
2182 let m = Array2::<f64>::eye(2) - w.dot(&a);
2183 let det_m = m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]];
2184 let m_inv = Array2::from_shape_vec(
2185 (2, 2),
2186 vec![
2187 m[[1, 1]] / det_m,
2188 -m[[0, 1]] / det_m,
2189 -m[[1, 0]] / det_m,
2190 m[[0, 0]] / det_m,
2191 ],
2192 )
2193 .unwrap();
2194 let delta = a.dot(&m_inv.dot(&scores[0]));
2195 let expected_eta = &coordinate_values[0] + δ
2196 let expected_leverage = (a.dot(&w)).diag().sum();
2197 let expected_cook = delta.dot(&c.dot(&delta));
2198 let variance = a.dot(&m_inv).dot(&c).dot(&m_inv.t()).dot(&a.t());
2199
2200 let result = compute_multiblock_alo(&input).expect("B=2 closed-form ALO");
2201 for coordinate in 0..2 {
2202 assert!((result.eta_tilde[0][coordinate] - expected_eta[coordinate]).abs() < 2e-12);
2203 assert!(
2204 (result.alo_variance[0][coordinate] - variance[[coordinate, coordinate]]).abs()
2205 < 2e-12
2206 );
2207 }
2208 assert!((result.leverage[0] - expected_leverage).abs() < 2e-12);
2209 assert!((result.cook_distance[0] - expected_cook).abs() < 2e-12);
2210 }
2211
2212 #[test]
2213 fn multiblock_singular_weight_still_corrects() {
2214 let n = 1;
2218 let p = 2;
2219 let x = Array2::from_shape_vec((1, p), vec![1.0, 0.5]).unwrap();
2220 let h = Array2::eye(p);
2221 let coordinate_designs = vec![DesignMatrix::from(x.clone())];
2222 let coordinate_coefficient_ranges = vec![0..p];
2223 let observed_hessians = vec![Array2::from_elem((1, 1), 0.0)];
2224 let score_covariances = observed_hessians.clone();
2225 let scores = vec![Array1::from_vec(vec![1.0])];
2226 let coordinate_values = vec![Array1::from_vec(vec![std::f64::consts::PI])];
2227
2228 let input = MultiBlockAloInput {
2229 n_obs: n,
2230 n_coordinates: 1,
2231 coordinate_designs: &coordinate_designs,
2232 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2233 penalized_hessian: &h,
2234 observed_hessians: &observed_hessians,
2235 score_covariances: &score_covariances,
2236 scores: &scores,
2237 coordinate_values: &coordinate_values,
2238 };
2239 let result = compute_multiblock_alo(&input).unwrap();
2240 let expected = std::f64::consts::PI + 1.25;
2242 assert!(
2243 (result.eta_tilde[0][0] - expected).abs() < 1e-12,
2244 "expected {}, got {}",
2245 expected,
2246 result.eta_tilde[0][0]
2247 );
2248 assert!(result.cook_distance[0].abs() < 1e-14);
2250 assert!(result.alo_variance[0][0].abs() < 1e-14);
2252 }
2253
2254 #[test]
2255 fn multiblock_unit_leverage_refuses_instead_of_changing_estimand() {
2256 let coordinate_designs = vec![DesignMatrix::from(Array2::from_elem((1, 1), 1.0))];
2257 let coordinate_coefficient_ranges = vec![0..1];
2258 let h = Array2::from_elem((1, 1), 2.0);
2259 let observed_hessians = vec![Array2::from_elem((1, 1), 2.0)];
2260 let score_covariances = vec![Array2::from_elem((1, 1), 1.0)];
2261 let scores = vec![Array1::from_vec(vec![0.4])];
2262 let coordinate_values = vec![Array1::from_vec(vec![1.0])];
2263 let input = MultiBlockAloInput {
2264 n_obs: 1,
2265 n_coordinates: 1,
2266 coordinate_designs: &coordinate_designs,
2267 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2268 penalized_hessian: &h,
2269 observed_hessians: &observed_hessians,
2270 score_covariances: &score_covariances,
2271 scores: &scores,
2272 coordinate_values: &coordinate_values,
2273 };
2274 let error = compute_multiblock_alo(&input)
2275 .expect_err("unit deletion leverage must be reported as singular");
2276 assert!(
2277 error
2278 .to_string()
2279 .contains("deletion system I-WA is singular")
2280 );
2281 }
2282
2283 #[test]
2284 fn multiblock_b2_identity_cancellation_is_numerically_singular() {
2285 let above_two = f64::from_bits(2.0_f64.to_bits() + 1);
2289 let w = [above_two, 0.0, 0.0, above_two];
2290 let a = [0.5, 0.0, 0.0, 0.5];
2291 assert!(!local_identity_minus_product_is_factorable(&w, &a, 2));
2292 assert!(!local_identity_minus_product_is_factorable(&a, &w, 2));
2293 }
2294
2295 #[test]
2296 fn multiblock_b2_safely_near_singular_deletion_is_accepted() {
2297 let gap = f64::EPSILON.sqrt();
2300 let identity = [1.0, 0.0, 0.0, 1.0];
2301 let product_operand = [1.0 - gap, 0.0, 0.0, 0.5];
2302 assert!(local_identity_minus_product_is_factorable(
2303 &identity,
2304 &product_operand,
2305 2
2306 ));
2307 assert!(local_identity_minus_product_is_factorable(
2308 &product_operand,
2309 &identity,
2310 2
2311 ));
2312 }
2313
2314 #[test]
2315 fn multiblock_trace_one_but_invertible_deletion_is_not_refused() {
2316 let coordinate_designs = vec![
2319 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap()),
2320 DesignMatrix::from(Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).unwrap()),
2321 ];
2322 let coordinate_coefficient_ranges = vec![0..2, 0..2];
2323 let penalized_hessian = Array2::<f64>::eye(2);
2324 let observed_hessians =
2325 vec![Array2::from_shape_vec((2, 2), vec![0.25, 0.0, 0.0, 0.75]).unwrap()];
2326 let score_covariances = vec![Array2::<f64>::zeros((2, 2))];
2327 let scores = vec![Array1::from_vec(vec![0.75, -0.25])];
2328 let coordinate_values = vec![Array1::<f64>::zeros(2)];
2329 let input = MultiBlockAloInput {
2330 n_obs: 1,
2331 n_coordinates: 2,
2332 coordinate_designs: &coordinate_designs,
2333 coordinate_coefficient_ranges: &coordinate_coefficient_ranges,
2334 penalized_hessian: &penalized_hessian,
2335 observed_hessians: &observed_hessians,
2336 score_covariances: &score_covariances,
2337 scores: &scores,
2338 coordinate_values: &coordinate_values,
2339 };
2340
2341 let result = compute_multiblock_alo(&input)
2342 .expect("trace-one but invertible deletion system must be solved exactly");
2343 let roundoff = floating_point_gamma(16);
2344 assert!((result.leverage[0] - 1.0).abs() <= roundoff);
2345 assert!((result.eta_tilde[0][0] - 1.0).abs() <= roundoff);
2346 assert!((result.eta_tilde[0][1] + 1.0).abs() <= roundoff);
2347 }
2348}
2349
2350pub fn compute_alo_diagnostics_from_unified(
2356 unified: &UnifiedFitResult,
2357 design: &Array2<f64>,
2358 eta: &Array1<f64>,
2359 offset: &Array1<f64>,
2360 phi: f64,
2361) -> Result<AloDiagnostics, EstimationError> {
2362 let geom = unified
2363 .geometry
2364 .as_ref()
2365 .ok_or_else(|| AloError::InvalidInput {
2366 reason: "UnifiedFitResult does not contain working-set geometry; \
2367 ALO diagnostics require geometry at convergence"
2368 .to_string(),
2369 })
2370 .map_err(EstimationError::from)?;
2371 let working = geom.working.as_ref().ok_or_else(|| {
2372 EstimationError::from(AloError::InvalidInput {
2373 reason: "UnifiedFitResult coefficient geometry has no owned single-diagonal working evidence; ALO diagnostics are unavailable for Exact-Newton and multi-parameter terminal geometry"
2374 .to_string(),
2375 })
2376 })?;
2377 geom.coefficient_gauge
2378 .validate()
2379 .map_err(|reason| AloError::InvalidInput {
2380 reason: format!("UnifiedFitResult ALO coefficient gauge is invalid: {reason}"),
2381 })
2382 .map_err(EstimationError::from)?;
2383 if design.ncols() != geom.coefficient_gauge.raw_total() {
2384 return Err(AloError::InvalidInput {
2385 reason: format!(
2386 "UnifiedFitResult ALO raw design has {} columns; coefficient gauge requires {}",
2387 design.ncols(),
2388 geom.coefficient_gauge.raw_total(),
2389 ),
2390 }
2391 .into());
2392 }
2393 let active_design = geom.coefficient_gauge.restrict_design(design);
2394 let input =
2395 AloInput::from_active_geometry(geom, working, &active_design, eta, offset, phi);
2396 compute_alo_from_input(&input)
2397}