1use super::*;
2
3pub struct SparseCholeskyOperator {
12 pub(crate) factor: std::sync::Arc<gam_linalg::sparse_exact::SparseExactFactor>,
14 pub(crate) takahashi: Option<std::sync::Arc<gam_linalg::sparse_exact::TakahashiInverse>>,
17 pub(crate) cached_logdet: f64,
19 pub(crate) n_dim: usize,
21}
22
23impl SparseCholeskyOperator {
24 pub fn new(
26 factor: std::sync::Arc<gam_linalg::sparse_exact::SparseExactFactor>,
27 logdet_h: f64,
28 dim: usize,
29 ) -> Self {
30 Self {
31 factor,
32 takahashi: None,
33 cached_logdet: logdet_h,
34 n_dim: dim,
35 }
36 }
37
38 pub fn with_takahashi(
39 mut self,
40 taka: std::sync::Arc<gam_linalg::sparse_exact::TakahashiInverse>,
41 ) -> Self {
42 self.takahashi = Some(taka);
43 self
44 }
45
46 pub(crate) const OPERATOR_SOLVE_CHUNK: usize = 64;
47
48 pub(crate) fn takahashi_block_trace(
49 taka: &gam_linalg::sparse_exact::TakahashiInverse,
50 block: &Array2<f64>,
51 start: usize,
52 ) -> f64 {
53 assert_eq!(block.nrows(), block.ncols());
54 let mut trace = 0.0;
55 for i in 0..block.nrows() {
56 let diag = block[[i, i]];
57 if diag.abs() > 1e-30 {
58 trace += taka.get(start + i, start + i) * diag;
59 }
60 for j in (i + 1)..block.ncols() {
61 let pair = block[[i, j]] + block[[j, i]];
62 if pair.abs() > 1e-30 {
63 trace += taka.get(start + i, start + j) * pair;
64 }
65 }
66 }
67 trace
68 }
69
70 pub(crate) fn takahashi_left_multiply_block(
71 taka: &gam_linalg::sparse_exact::TakahashiInverse,
72 block: &Array2<f64>,
73 start: usize,
74 ) -> Array2<f64> {
75 let dim = block.nrows();
76 let mut out = Array2::<f64>::zeros((dim, dim));
77 for i in 0..dim {
78 let z_diag = taka.get(start + i, start + i);
79 if z_diag.abs() > 1e-30 {
80 for k in 0..dim {
81 out[[i, k]] += z_diag * block[[i, k]];
82 }
83 }
84 for j in (i + 1)..dim {
85 let z = taka.get(start + i, start + j);
86 if z.abs() <= 1e-30 {
87 continue;
88 }
89 for k in 0..dim {
90 out[[i, k]] += z * block[[j, k]];
91 out[[j, k]] += z * block[[i, k]];
92 }
93 }
94 }
95 out
96 }
97
98 pub(crate) fn trace_hinv_operator_exact(&self, op: &dyn HyperOperator) -> f64 {
99 let (range_start, range_end) = op
100 .block_local_data()
101 .map(|(_, start, end)| (start, end))
102 .unwrap_or((0, self.n_dim));
103 let chunk = Self::OPERATOR_SOLVE_CHUNK.min(self.n_dim.max(1));
104 let mut trace = 0.0_f64;
105 let mut rhs_block = Array2::<f64>::zeros((self.n_dim, chunk));
106 let mut start = range_start;
107
108 while start < range_end {
109 let end = (start + chunk).min(range_end);
110 let cols = end - start;
111 op.mul_basis_columns_into(start, rhs_block.slice_mut(ndarray::s![.., ..cols]));
112
113 let diagonal_sum = if cols == chunk {
114 gam_linalg::sparse_exact::solve_sparse_spdmulti_diagonal_sum(
115 &self.factor,
116 &rhs_block,
117 start,
118 )
119 } else {
120 let rhs_view = rhs_block.slice(ndarray::s![.., ..cols]);
121 gam_linalg::sparse_exact::solve_sparse_spdmulti_diagonal_sum(
122 &self.factor,
123 &rhs_view,
124 start,
125 )
126 };
127 trace += diagonal_sum.unwrap_or_else(|e| {
128 reml_contract_panic(format!(
137 "SparseCholeskyOperator exact trace_hinv_operator solve failed: {e}"
138 ))
139 });
140 start = end;
141 }
142
143 trace
144 }
145
146 pub(crate) fn solve_operator_column_range_rows_exact(
147 &self,
148 op: &dyn HyperOperator,
149 col_start: usize,
150 col_end: usize,
151 row_start: usize,
152 row_end: usize,
153 ) -> Result<Array2<f64>, String> {
154 let chunk = Self::OPERATOR_SOLVE_CHUNK.min(self.n_dim.max(1));
155 let cols_total = col_end - col_start;
156 let rows_total = row_end - row_start;
157 let mut solved = Array2::<f64>::zeros((rows_total, cols_total));
158 let mut rhs_block = Array2::<f64>::zeros((self.n_dim, chunk));
159 let mut start = col_start;
160
161 while start < col_end {
162 let end = (start + chunk).min(col_end);
163 let cols = end - start;
164 op.mul_basis_columns_into(start, rhs_block.slice_mut(ndarray::s![.., ..cols]));
165
166 let solved_block = if cols == chunk {
167 gam_linalg::sparse_exact::solve_sparse_spdmulti_rows(
168 &self.factor,
169 &rhs_block,
170 row_start,
171 row_end,
172 )
173 } else {
174 let rhs_view = rhs_block.slice(ndarray::s![.., ..cols]);
175 gam_linalg::sparse_exact::solve_sparse_spdmulti_rows(
176 &self.factor,
177 &rhs_view,
178 row_start,
179 row_end,
180 )
181 }
182 .map_err(|e| {
183 format!(
184 "SparseCholeskyOperator::solve_operator_column_range_rows_exact multi-solve failed: {e}"
185 )
186 })?;
187 solved
188 .slice_mut(ndarray::s![.., start - col_start..end - col_start])
189 .assign(&solved_block);
190 start = end;
191 }
192
193 Ok(solved)
194 }
195
196 pub(crate) fn trace_hinv_matrix_operator_cross_exact(
197 &self,
198 matrix: &Array2<f64>,
199 op: &dyn HyperOperator,
200 ) -> f64 {
201 if let Some((_, range_start, range_end)) = op.block_local_data()
202 && range_end - range_start < self.n_dim
203 {
204 return self.trace_hinv_matrix_block_operator_cross_exact(
205 matrix,
206 op,
207 range_start,
208 range_end,
209 );
210 }
211
212 let solved_matrix = self.solve_multi(matrix);
213 let chunk = Self::OPERATOR_SOLVE_CHUNK.min(self.n_dim.max(1));
214 let mut rhs_block = Array2::<f64>::zeros((self.n_dim, chunk));
215 let mut trace = 0.0_f64;
216 let (range_start, range_end) = op
217 .block_local_data()
218 .map(|(_, start, end)| (start, end))
219 .unwrap_or((0, self.n_dim));
220 let mut start = range_start;
221
222 while start < range_end {
223 let end = (start + chunk).min(range_end);
224 let cols = end - start;
225 op.mul_basis_columns_into(start, rhs_block.slice_mut(ndarray::s![.., ..cols]));
226
227 let solved_op = if cols == chunk {
228 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &rhs_block)
229 } else {
230 let rhs_view = rhs_block.slice(ndarray::s![.., ..cols]);
231 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &rhs_view)
232 };
233
234 let solved_op = solved_op.unwrap_or_else(|e| {
235 panic!("SparseCholeskyOperator exact matrix/operator cross solve failed: {e}")
242 });
243
244 for local_col in 0..cols {
245 let matrix_row = start + local_col;
246 for row in 0..self.n_dim {
247 trace += solved_matrix[[matrix_row, row]] * solved_op[[row, local_col]];
248 }
249 }
250 start = end;
251 }
252
253 trace
254 }
255
256 pub(crate) fn trace_hinv_matrix_block_operator_cross_exact(
257 &self,
258 matrix: &Array2<f64>,
259 op: &dyn HyperOperator,
260 range_start: usize,
261 range_end: usize,
262 ) -> f64 {
263 let t_start = std::time::Instant::now();
264 let chunk = Self::OPERATOR_SOLVE_CHUNK.min(self.n_dim.max(1));
265 let mut op_rhs_block = Array2::<f64>::zeros((self.n_dim, chunk));
266 let mut eye_rhs_block = Array2::<f64>::zeros((self.n_dim, chunk));
267 let mut trace = 0.0_f64;
268 let mut start = range_start;
269
270 while start < range_end {
271 let end = (start + chunk).min(range_end);
272 let cols = end - start;
273 op.mul_basis_columns_into(start, op_rhs_block.slice_mut(ndarray::s![.., ..cols]));
274
275 eye_rhs_block.fill(0.0);
276 for local_col in 0..cols {
277 eye_rhs_block[[start + local_col, local_col]] = 1.0;
278 }
279
280 let solved_op = if cols == chunk {
281 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &op_rhs_block)
282 } else {
283 let rhs_view = op_rhs_block.slice(ndarray::s![.., ..cols]);
284 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &rhs_view)
285 };
286 let solved_op = solved_op.unwrap_or_else(|e| {
287 panic!(
293 "SparseCholeskyOperator exact matrix/block-operator cross operator solve failed: {e}"
294 )
295 });
296
297 let solved_eye = if cols == chunk {
298 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &eye_rhs_block)
299 } else {
300 let rhs_view = eye_rhs_block.slice(ndarray::s![.., ..cols]);
301 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, &rhs_view)
302 };
303 let solved_eye = solved_eye.unwrap_or_else(|e| {
304 panic!(
310 "SparseCholeskyOperator exact matrix/block-operator cross identity solve failed: {e}"
311 )
312 });
313
314 let selected_rows_t = matrix.t().dot(&solved_eye);
315 for local_col in 0..cols {
316 for row in 0..self.n_dim {
317 trace += selected_rows_t[[row, local_col]] * solved_op[[row, local_col]];
318 }
319 }
320 start = end;
321 }
322
323 let elapsed_ms = t_start.elapsed().as_secs_f64() * 1000.0;
324 if elapsed_ms > REML_TRACE_SLOW_LOG_MS {
325 log::info!(
326 "[REML-trace] matrix_block_op_cross_exact | n_dim={} | block={} | {:.1}ms",
327 self.n_dim,
328 range_end - range_start,
329 elapsed_ms
330 );
331 }
332 trace
333 }
334
335 pub(crate) fn trace_hinv_operator_cross_exact(
336 &self,
337 left: &dyn HyperOperator,
338 right: &dyn HyperOperator,
339 ) -> f64 {
340 let (left_start, left_end) = left
341 .block_local_data()
342 .map(|(_, start, end)| (start, end))
343 .unwrap_or((0, self.n_dim));
344 let (right_start, right_end) = right
345 .block_local_data()
346 .map(|(_, start, end)| (start, end))
347 .unwrap_or((0, self.n_dim));
348
349 let solved_left = self
350 .solve_operator_column_range_rows_exact(
351 left,
352 left_start,
353 left_end,
354 right_start,
355 right_end,
356 )
357 .unwrap_or_else(|e| {
358 panic!("SparseCholeskyOperator exact operator cross left solve failed: {e}")
365 });
366 let same_operator =
367 std::ptr::addr_eq(left, right) && left_start == right_start && left_end == right_end;
368 let solved_right = if same_operator {
369 None
370 } else {
371 Some(
372 self.solve_operator_column_range_rows_exact(
373 right,
374 right_start,
375 right_end,
376 left_start,
377 left_end,
378 )
379 .unwrap_or_else(|e| {
380 panic!("SparseCholeskyOperator exact operator cross right solve failed: {e}")
386 }),
387 )
388 };
389
390 let right_cols = right_end - right_start;
391 let mut trace = 0.0;
392 for left_col in 0..(left_end - left_start) {
393 for right_col in 0..right_cols {
394 let right_value = match solved_right.as_ref() {
395 Some(solved) => solved[[left_col, right_col]],
396 None => solved_left[[left_col, right_col]],
397 };
398 trace += solved_left[[right_col, left_col]] * right_value;
399 }
400 }
401 trace
402 }
403}
404
405impl HessianFactorization for SparseCholeskyOperator {
406 fn logdet(&self) -> f64 {
407 self.cached_logdet
408 }
409
410 fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
411 let h = gam_linalg::sparse_exact::assemble_sparse_factor_h_dense(&self.factor)
412 .map_err(|e| e.to_string())?;
413 if h.nrows() != self.n_dim || h.ncols() != self.n_dim {
414 return Err(format!(
415 "sparse Cholesky tangent projection dense H has shape {}x{}, expected {}x{}",
416 h.nrows(),
417 h.ncols(),
418 self.n_dim,
419 self.n_dim
420 ));
421 }
422 Ok(h)
423 }
424
425 fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
426 if let Some(ref taka) = self.takahashi {
429 let mut trace = 0.0;
430 for i in 0..a.nrows() {
431 let a_ii = a[[i, i]];
432 if a_ii.abs() > 1e-30 {
433 trace += taka.get(i, i) * a_ii;
434 }
435 for j in (i + 1)..a.ncols() {
436 let pair = a[[i, j]] + a[[j, i]];
437 if pair.abs() > 1e-30 {
438 trace += taka.get(i, j) * pair;
439 }
440 }
441 }
442 return trace;
443 }
444 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, a)
445 .unwrap_or_else(|e| {
446 panic!("SparseCholeskyOperator exact trace_hinv_product solve failed: {e}")
453 })
454 .diag()
455 .sum()
456 }
457
458 fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
459 if let Some(ref taka) = self.takahashi {
460 if let Some((local, start, end)) = op.block_local_data() {
461 assert_eq!(local.nrows(), end - start);
462 return Self::takahashi_block_trace(taka, local, start);
463 }
464 if !op.is_implicit() {
466 let dense = op.to_dense();
467 return self.trace_hinv_product(&dense);
468 }
469 }
470 self.trace_hinv_operator_exact(op)
471 }
472
473 fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
474 self.trace_hinv_operator(op)
475 }
476
477 fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
478 gam_linalg::sparse_exact::solve_sparse_spd(&self.factor, rhs)
483 .unwrap_or_else(|e| panic!("SparseCholeskyOperator exact solve failed: {e}"))
485 }
486
487 fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
488 gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, rhs)
492 .unwrap_or_else(|e| panic!("SparseCholeskyOperator exact multi-solve failed: {e}"))
494 }
495
496 fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
497 let solved_a = self.solve_multi(a);
501 if std::ptr::eq(a, b) {
502 return dense::trace_product(&solved_a, &solved_a);
503 }
504 let solved_b = self.solve_multi(b);
505 dense::trace_product(&solved_a, &solved_b)
506 }
507
508 fn trace_hinv_matrix_operator_cross(
509 &self,
510 matrix: &Array2<f64>,
511 op: &dyn HyperOperator,
512 ) -> f64 {
513 self.trace_hinv_matrix_operator_cross_exact(matrix, op)
517 }
518
519 fn trace_hinv_operator_cross(
520 &self,
521 left: &dyn HyperOperator,
522 right: &dyn HyperOperator,
523 ) -> f64 {
524 if let Some(ref taka) = self.takahashi
527 && let (Some((a_local, a_start, a_end)), Some((b_local, b_start, b_end))) =
528 (left.block_local_data(), right.block_local_data())
529 && a_start == b_start
530 && a_end == b_end
531 {
532 let za = Self::takahashi_left_multiply_block(taka, a_local, a_start);
534 if std::ptr::addr_eq(left, right) {
535 return dense::trace_product(&za, &za);
536 }
537 let zb = Self::takahashi_left_multiply_block(taka, b_local, b_start);
538 return (&za * &zb.t()).sum();
540 }
541 self.trace_hinv_operator_cross_exact(left, right)
544 }
545
546 fn trace_logdet_hessian_cross_matrix_operator(
547 &self,
548 h_i: &Array2<f64>,
549 h_j: &dyn HyperOperator,
550 ) -> f64 {
551 -self.trace_hinv_matrix_operator_cross(h_i, h_j)
552 }
553
554 fn trace_logdet_hessian_cross_operator(
555 &self,
556 h_i: &dyn HyperOperator,
557 h_j: &dyn HyperOperator,
558 ) -> f64 {
559 -self.trace_hinv_operator_cross(h_i, h_j)
560 }
561
562 fn active_rank(&self) -> usize {
563 self.n_dim
564 }
565
566 fn dim(&self) -> usize {
567 self.n_dim
568 }
569}
570
571pub struct DenseCholeskyOperator {
593 pub(crate) chol: gam_linalg::faer_ndarray::FaerCholeskyFactor,
595 pub(crate) cached_logdet: f64,
597 pub(crate) n_dim: usize,
599 pub(crate) matrix: Array2<f64>,
602 pub(crate) inverse_root: Array2<f64>,
606}
607
608impl DenseCholeskyOperator {
609 pub(crate) fn install_root_scale_logdet(&mut self, value: f64) {
614 self.cached_logdet = value;
615 }
616
617 fn inverse_transpose_root(lower: &Array2<f64>) -> Array2<f64> {
622 let n = lower.nrows();
623 let lower_values = lower
624 .as_slice()
625 .expect("Cholesky lower triangle is standard-layout");
626 let mut lower_inverse = vec![0.0_f64; n * n];
627
628 for column in 0..n {
631 for row in column..n {
632 let mut value = if row == column { 1.0 } else { 0.0 };
633 for inner in column..row {
634 value -= lower_values[row * n + inner] * lower_inverse[inner * n + column];
635 }
636 lower_inverse[row * n + column] = value / lower_values[row * n + row];
637 }
638 }
639
640 let mut inverse_root = Array2::<f64>::zeros((n, n));
642 let root_values = inverse_root
643 .as_slice_mut()
644 .expect("fresh inverse root is standard-layout");
645 for row in 0..n {
646 for column in row..n {
647 root_values[row * n + column] = lower_inverse[column * n + row];
648 }
649 }
650 inverse_root
651 }
652
653 #[inline]
654 fn projected_dense(&self, matrix: &Array2<f64>) -> Array2<f64> {
655 let matrix_factor = gam_linalg::faer_ndarray::fast_ab(matrix, &self.inverse_root);
656 gam_linalg::faer_ndarray::fast_atb(&self.inverse_root, &matrix_factor)
657 }
658
659 #[inline]
660 fn projected_cross(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
661 dense::trace_product(left, right)
662 }
663
664 fn factorize_positive_definite(h: &Array2<f64>) -> Result<Self, String> {
667 use faer::Side;
668 use gam_linalg::faer_ndarray::FaerCholesky;
669
670 let n = h.nrows();
671 if n != h.ncols() {
672 return Err(format!(
673 "DenseCholeskyOperator: expected square matrix, got {}×{}",
674 n,
675 h.ncols()
676 ));
677 }
678 if h.iter().any(|entry| !entry.is_finite()) {
679 return Err("DenseCholeskyOperator: Hessian contains a non-finite entry".to_string());
680 }
681
682 let scale = h
687 .iter()
688 .fold(0.0_f64, |maximum, entry| maximum.max(entry.abs()))
689 .max(1.0);
690 let symmetry_tolerance = 64.0 * f64::EPSILON * n.max(1) as f64 * scale;
691 let mut matrix = h.clone();
692 for row in 0..n {
693 for col in (row + 1)..n {
694 let upper = h[[row, col]];
695 let lower = h[[col, row]];
696 let skew = (upper - lower).abs();
697 if skew > symmetry_tolerance {
698 return Err(format!(
699 "DenseCholeskyOperator: Hessian is not symmetric at ({row}, {col}); \
700 |H_ij-H_ji|={skew:.3e} exceeds the roundoff envelope \
701 {symmetry_tolerance:.3e}"
702 ));
703 }
704 let symmetric = 0.5 * (upper + lower);
705 matrix[[row, col]] = symmetric;
706 matrix[[col, row]] = symmetric;
707 }
708 }
709
710 let chol = matrix
711 .cholesky(Side::Lower)
712 .map_err(|e| format!("DenseCholeskyOperator LLT failed: {e}"))?;
713 let diag = chol.diag();
714 let cached_logdet = 2.0 * diag.iter().map(|&d| d.ln()).sum::<f64>();
715 let inverse_root = Self::inverse_transpose_root(&chol.lower_triangular());
716 Ok(Self {
717 chol,
718 cached_logdet,
719 n_dim: n,
720 matrix,
721 inverse_root,
722 })
723 }
724
725 pub fn from_positive_definite(h: &Array2<f64>) -> Result<Self, String> {
730 Self::factorize_positive_definite(h)
731 }
732
733 pub fn from_spd_with_smooth_logdet_agreement(h: &Array2<f64>) -> Result<Self, String> {
742 let operator = Self::factorize_positive_definite(h)?;
743 let n = operator.n_dim;
744 let cached_logdet = operator.cached_logdet;
745
746 let epsilon = spectral_epsilon_for_dim(n);
781 let h_inverse = operator.chol.solve_mat(&Array2::<f64>::eye(n));
782 let floor_gap_bound =
783 epsilon * epsilon * h_inverse.iter().map(|entry| entry * entry).sum::<f64>();
784 let agreement_envelope =
785 crate::rho_optimizer::outer_value_agreement_bound(cached_logdet, cached_logdet);
786 if !(floor_gap_bound <= agreement_envelope) {
787 return Err(format!(
788 "DenseCholeskyOperator declines a {n}-dimensional Hessian: its exact \
789 log-determinant can differ from the smooth-floored log|H| the derivative lanes \
790 price by up to {floor_gap_bound:.3e}, above the {agreement_envelope:.3e} \
791 value-agreement envelope (spectral floor eps={epsilon:.3e})"
792 ));
793 }
794
795 Ok(operator)
796 }
797}
798
799impl HessianFactorization for DenseCholeskyOperator {
800 fn logdet(&self) -> f64 {
801 self.cached_logdet
802 }
803
804 fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
805 Ok(self.matrix.clone())
806 }
807
808 fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
809 let a_factor = gam_linalg::faer_ndarray::fast_ab(a, &self.inverse_root);
810 self.inverse_root
811 .iter()
812 .zip(a_factor.iter())
813 .map(|(&factor, &a_factor)| factor * a_factor)
814 .sum()
815 }
816
817 fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
818 op.trace_projected_factor(&self.inverse_root)
819 }
820
821 fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
822 self.trace_hinv_operator(op)
823 }
824
825 fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
826 self.chol.solvevec(rhs)
827 }
828
829 fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
830 self.chol.solve_mat(rhs)
831 }
832
833 fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
834 let projected_a = self.projected_dense(a);
835 if std::ptr::eq(a, b) {
836 return Self::projected_cross(&projected_a, &projected_a);
837 }
838 let projected_b = self.projected_dense(b);
839 Self::projected_cross(&projected_a, &projected_b)
840 }
841
842 fn trace_hinv_matrix_operator_cross(
843 &self,
844 matrix: &Array2<f64>,
845 op: &dyn HyperOperator,
846 ) -> f64 {
847 let projected_matrix = self.projected_dense(matrix);
848 let projected_operator = op.projected_matrix(&self.inverse_root);
849 Self::projected_cross(&projected_matrix, &projected_operator)
850 }
851
852 fn trace_hinv_operator_cross(
853 &self,
854 left: &dyn HyperOperator,
855 right: &dyn HyperOperator,
856 ) -> f64 {
857 let projected_left = left.projected_matrix(&self.inverse_root);
858 if std::ptr::addr_eq(left, right) {
859 return Self::projected_cross(&projected_left, &projected_left);
860 }
861 let projected_right = right.projected_matrix(&self.inverse_root);
862 Self::projected_cross(&projected_left, &projected_right)
863 }
864
865 fn trace_logdet_block_local(
866 &self,
867 block: &Array2<f64>,
868 scale: f64,
869 start: usize,
870 end: usize,
871 ) -> f64 {
872 assert_eq!(block.dim(), (end - start, end - start));
873 let factor_block = self.inverse_root.slice(ndarray::s![start..end, ..]);
874 let block_factor = gam_linalg::faer_ndarray::fast_ab(block, &factor_block);
875 scale
876 * factor_block
877 .iter()
878 .zip(block_factor.iter())
879 .map(|(&factor, &block_factor)| factor * block_factor)
880 .sum::<f64>()
881 }
882
883 fn trace_logdet_hessian_cross_matrix_operator(
884 &self,
885 h_i: &Array2<f64>,
886 h_j: &dyn HyperOperator,
887 ) -> f64 {
888 -self.trace_hinv_matrix_operator_cross(h_i, h_j)
889 }
890
891 fn trace_logdet_hessian_cross_operator(
892 &self,
893 h_i: &dyn HyperOperator,
894 h_j: &dyn HyperOperator,
895 ) -> f64 {
896 -self.trace_hinv_operator_cross(h_i, h_j)
897 }
898
899 fn active_rank(&self) -> usize {
900 self.n_dim
902 }
903
904 fn dim(&self) -> usize {
905 self.n_dim
906 }
907
908 fn is_dense(&self) -> bool {
909 true
910 }
911
912 fn prefers_stochastic_trace_estimation(&self) -> bool {
913 false
914 }
915}
916
917enum BlockCoupledFactorization {
939 Spectral(DenseSpectralOperator),
940 PositiveDefinite(DenseCholeskyOperator),
941}
942
943impl BlockCoupledFactorization {
944 fn as_factorization(&self) -> &dyn HessianFactorization {
945 match self {
946 Self::Spectral(operator) => operator,
947 Self::PositiveDefinite(operator) => operator,
948 }
949 }
950}
951
952pub struct BlockCoupledOperator {
953 inner: BlockCoupledFactorization,
956}
957
958impl BlockCoupledOperator {
959 pub fn from_joint_hessian_with_mode(
963 joint_hessian: &Array2<f64>,
964 mode: PseudoLogdetMode,
965 ) -> Result<Self, String> {
966 let inner = match mode {
967 PseudoLogdetMode::PositiveDefinite => BlockCoupledFactorization::PositiveDefinite(
968 DenseCholeskyOperator::from_positive_definite(joint_hessian)
969 .map_err(|e| format!("BlockCoupledOperator positive-definite factor: {e}"))?,
970 ),
971 PseudoLogdetMode::Smooth | PseudoLogdetMode::HardPseudo => {
972 BlockCoupledFactorization::Spectral(
973 DenseSpectralOperator::from_symmetric_with_mode(joint_hessian, mode)
974 .map_err(|e| format!("BlockCoupledOperator eigendecomposition: {e}"))?,
975 )
976 }
977 };
978
979 Ok(Self { inner })
980 }
981}
982
983impl HessianFactorization for BlockCoupledOperator {
984 fn logdet(&self) -> f64 {
985 self.inner.as_factorization().logdet()
986 }
987
988 fn as_exact_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
989 match &self.inner {
990 BlockCoupledFactorization::Spectral(operator) => Some(operator),
991 BlockCoupledFactorization::PositiveDefinite(_) => None,
992 }
993 }
994
995 fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
996 self.inner
997 .as_factorization()
998 .assemble_h_dense_for_tangent_projection()
999 }
1000
1001 fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
1002 self.inner.as_factorization().trace_hinv_product(a)
1003 }
1004
1005 fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
1006 self.inner.as_factorization().trace_hinv_operator(op)
1007 }
1008
1009 fn trace_logdet_gradient(&self, a: &Array2<f64>) -> f64 {
1010 self.inner.as_factorization().trace_logdet_gradient(a)
1011 }
1012
1013 fn xt_logdet_kernel_x_diagonal(&self, x: &DesignMatrix) -> Array1<f64> {
1014 self.inner.as_factorization().xt_logdet_kernel_x_diagonal(x)
1015 }
1016
1017 fn trace_logdet_h_k(
1018 &self,
1019 a_k: &Array2<f64>,
1020 third_deriv_correction: Option<&Array2<f64>>,
1021 ) -> f64 {
1022 self.inner
1023 .as_factorization()
1024 .trace_logdet_h_k(a_k, third_deriv_correction)
1025 }
1026
1027 fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
1028 self.inner.as_factorization().trace_logdet_operator(op)
1029 }
1030
1031 fn trace_logdet_block_local(
1032 &self,
1033 block: &Array2<f64>,
1034 scale: f64,
1035 start: usize,
1036 end: usize,
1037 ) -> f64 {
1038 self.inner
1039 .as_factorization()
1040 .trace_logdet_block_local(block, scale, start, end)
1041 }
1042
1043 fn trace_logdet_hessian_cross(&self, h_i: &Array2<f64>, h_j: &Array2<f64>) -> f64 {
1044 self.inner
1045 .as_factorization()
1046 .trace_logdet_hessian_cross(h_i, h_j)
1047 }
1048
1049 fn trace_logdet_hessian_cross_matrix_operator(
1050 &self,
1051 h_i: &Array2<f64>,
1052 h_j: &dyn HyperOperator,
1053 ) -> f64 {
1054 self.inner
1055 .as_factorization()
1056 .trace_logdet_hessian_cross_matrix_operator(h_i, h_j)
1057 }
1058
1059 fn trace_logdet_hessian_cross_operator(
1060 &self,
1061 h_i: &dyn HyperOperator,
1062 h_j: &dyn HyperOperator,
1063 ) -> f64 {
1064 self.inner
1065 .as_factorization()
1066 .trace_logdet_hessian_cross_operator(h_i, h_j)
1067 }
1068
1069 fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
1070 self.inner.as_factorization().solve(rhs)
1071 }
1072
1073 fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
1074 self.inner.as_factorization().solve_multi(rhs)
1075 }
1076
1077 fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
1078 self.inner.as_factorization().trace_hinv_product_cross(a, b)
1079 }
1080
1081 fn trace_hinv_matrix_operator_cross(
1082 &self,
1083 matrix: &Array2<f64>,
1084 op: &dyn HyperOperator,
1085 ) -> f64 {
1086 self.inner
1087 .as_factorization()
1088 .trace_hinv_matrix_operator_cross(matrix, op)
1089 }
1090
1091 fn trace_hinv_operator_cross(
1092 &self,
1093 left: &dyn HyperOperator,
1094 right: &dyn HyperOperator,
1095 ) -> f64 {
1096 self.inner
1097 .as_factorization()
1098 .trace_hinv_operator_cross(left, right)
1099 }
1100
1101 fn active_rank(&self) -> usize {
1102 self.inner.as_factorization().active_rank()
1103 }
1104
1105 fn dim(&self) -> usize {
1106 self.inner.as_factorization().dim()
1107 }
1108
1109 fn is_dense(&self) -> bool {
1110 true
1111 }
1112
1113 fn prefers_stochastic_trace_estimation(&self) -> bool {
1114 false
1115 }
1116
1117 fn logdet_traces_match_hinv_kernel(&self) -> bool {
1118 self.inner
1119 .as_factorization()
1120 .logdet_traces_match_hinv_kernel()
1121 }
1122
1123 fn as_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1124 match &self.inner {
1125 BlockCoupledFactorization::Spectral(operator) => Some(operator),
1126 BlockCoupledFactorization::PositiveDefinite(_) => None,
1127 }
1128 }
1129}
1130
1131pub struct MatrixFreeSpdOperator {
1144 pub(crate) apply: Arc<dyn Fn(&Array1<f64>) -> Array1<f64> + Send + Sync>,
1145 pub(crate) dense_assemble: Option<Arc<dyn Fn() -> Option<Array2<f64>> + Send + Sync>>,
1157 pub(crate) cached_logdet: gam_runtime::resource::RayonSafeOnce<f64>,
1158 pub(crate) n_dim: usize,
1159 pub(crate) dense_spectral: gam_runtime::resource::RayonSafeOnce<Option<DenseSpectralOperator>>,
1169 pub(crate) mode: PseudoLogdetMode,
1178}
1179
1180impl MatrixFreeSpdOperator {
1181 pub(crate) const EXACT_DENSE_SPECTRAL_MAX_BYTES: usize = 512 * 1024 * 1024;
1182 pub(crate) const EXACT_DENSE_SPECTRAL_ARRAYS: usize = 6;
1183
1184 pub fn new_with_mode<F>(dim: usize, apply: F, mode: PseudoLogdetMode) -> Self
1185 where
1186 F: Fn(&Array1<f64>) -> Array1<f64> + Send + Sync + 'static,
1187 {
1188 Self::new_with_mode_and_dense_assemble(dim, apply, mode, None)
1189 }
1190
1191 pub fn new_with_mode_and_dense_assemble<F>(
1196 dim: usize,
1197 apply: F,
1198 mode: PseudoLogdetMode,
1199 dense_assemble: Option<Arc<dyn Fn() -> Option<Array2<f64>> + Send + Sync>>,
1200 ) -> Self
1201 where
1202 F: Fn(&Array1<f64>) -> Array1<f64> + Send + Sync + 'static,
1203 {
1204 let apply = Arc::new(apply);
1205
1206 Self {
1207 apply,
1208 dense_assemble,
1209 cached_logdet: gam_runtime::resource::RayonSafeOnce::new(),
1210 n_dim: dim,
1211 dense_spectral: gam_runtime::resource::RayonSafeOnce::new(),
1212 mode,
1213 }
1214 }
1215
1216 pub(crate) fn exact_dense_spectral_bytes(&self) -> Option<usize> {
1217 self.n_dim
1218 .checked_mul(self.n_dim)?
1219 .checked_mul(std::mem::size_of::<f64>())?
1220 .checked_mul(Self::EXACT_DENSE_SPECTRAL_ARRAYS)
1221 }
1222
1223 pub(crate) fn exact_dense_spectral_budget_ok(&self) -> bool {
1224 match self.exact_dense_spectral_bytes() {
1225 Some(bytes) if bytes <= Self::EXACT_DENSE_SPECTRAL_MAX_BYTES => true,
1226 Some(bytes) => {
1227 log::error!(
1228 "MatrixFreeSpdOperator exact dense spectral materialization requires {:.2} GiB \
1229 for dim={}, exceeding the {:.2} GiB cap",
1230 bytes as f64 / (1024.0 * 1024.0 * 1024.0),
1231 self.n_dim,
1232 Self::EXACT_DENSE_SPECTRAL_MAX_BYTES as f64 / (1024.0 * 1024.0 * 1024.0),
1233 );
1234 false
1235 }
1236 None => {
1237 log::error!(
1238 "MatrixFreeSpdOperator exact dense spectral byte count overflow for dim={}",
1239 self.n_dim
1240 );
1241 false
1242 }
1243 }
1244 }
1245
1246 pub(crate) fn materialize_dense_operator(&self) -> Option<DenseSpectralOperator> {
1247 if !self.exact_dense_spectral_budget_ok() {
1248 return None;
1249 }
1250 let materialize_start = std::time::Instant::now();
1251 let (matrix, matvec_count) =
1257 match self.dense_assemble.as_ref().and_then(|assemble| assemble()) {
1258 Some(mut direct)
1259 if direct.nrows() == self.n_dim
1260 && direct.ncols() == self.n_dim
1261 && direct.iter().all(|v| v.is_finite()) =>
1262 {
1263 for i in 0..self.n_dim {
1267 for j in (i + 1)..self.n_dim {
1268 let avg = 0.5 * (direct[[i, j]] + direct[[j, i]]);
1269 direct[[i, j]] = avg;
1270 direct[[j, i]] = avg;
1271 }
1272 }
1273 (direct, 0usize)
1274 }
1275 _ => {
1276 let mut matrix = Array2::<f64>::zeros((self.n_dim, self.n_dim));
1277 let mut basis = Array1::<f64>::zeros(self.n_dim);
1278 for j in 0..self.n_dim {
1279 basis[j] = 1.0;
1280 let col = (self.apply)(&basis);
1281 basis[j] = 0.0;
1282 if col.len() != self.n_dim || !col.iter().all(|v| v.is_finite()) {
1283 return None;
1284 }
1285 matrix.column_mut(j).assign(&col);
1286 }
1287 for i in 0..self.n_dim {
1288 for j in (i + 1)..self.n_dim {
1289 let avg = 0.5 * (matrix[[i, j]] + matrix[[j, i]]);
1290 matrix[[i, j]] = avg;
1291 matrix[[j, i]] = avg;
1292 }
1293 }
1294 (matrix, self.n_dim)
1295 }
1296 };
1297 let result = match DenseSpectralOperator::from_symmetric_with_mode(&matrix, self.mode) {
1298 Ok(operator) => Some(operator),
1299 Err(err) => {
1300 log::warn!(
1304 "[matrix_free_spd] dense spectral materialization declined at n_dim={}: {err}",
1305 self.n_dim
1306 );
1307 None
1308 }
1309 };
1310 log::info!(
1311 "[STAGE] matrix_free_spd materialize n_dim={} matvec_count={} elapsed={:.3}s",
1312 self.n_dim,
1313 matvec_count,
1314 materialize_start.elapsed().as_secs_f64(),
1315 );
1316 result
1317 }
1318
1319 pub(crate) fn dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1320 self.dense_spectral
1321 .get_or_compute(|| self.materialize_dense_operator())
1322 .as_ref()
1323 }
1324
1325 pub(crate) fn exact_dense_spectral(&self) -> &DenseSpectralOperator {
1326 self.dense_spectral().expect(
1327 "MatrixFreeSpdOperator exact REML algebra requires dense spectral materialization within the configured budget",
1328 )
1329 }
1330
1331 pub(crate) fn use_trace_cg(&self, rel_tol: f64) -> bool {
1332 rel_tol.is_finite()
1333 && rel_tol > 0.0
1334 && self.prefers_stochastic_trace_estimation()
1335 && self.has_matrix_free_trace_cg_operator()
1336 }
1337
1338 pub(crate) fn cg_trace_solve(
1339 &self,
1340 rhs: &Array1<f64>,
1341 rel_tol: f64,
1342 probe_id: Option<u64>,
1343 trace_state: Option<&Arc<Mutex<StochasticTraceState>>>,
1344 ) -> Array1<f64> {
1345 let dim = rhs.len();
1346 if dim != self.n_dim {
1347 return self.solve(rhs);
1348 }
1349
1350 let (initial, warm_start_used) = match (probe_id, trace_state) {
1351 (Some(id), Some(state)) => {
1352 let cached = match state.lock() {
1353 Ok(guard) => guard.cg_warm_starts.get(&id).cloned(),
1354 Err(poisoned) => poisoned.into_inner().cg_warm_starts.get(&id).cloned(),
1355 };
1356 match cached {
1357 Some(x) if x.len() == dim => (x, true),
1358 _ => (Array1::<f64>::zeros(dim), false),
1359 }
1360 }
1361 _ => (Array1::<f64>::zeros(dim), false),
1362 };
1363
1364 let Some((solution, iters, residual_norm)) =
1365 conjugate_gradient_trace_solve(rhs, rel_tol, initial, |v| (self.apply)(v))
1366 else {
1367 return self.solve(rhs);
1368 };
1369
1370 if let Some(state) = trace_state {
1371 let mut guard = match state.lock() {
1372 Ok(guard) => guard,
1373 Err(poisoned) => poisoned.into_inner(),
1374 };
1375 guard.last_linear_residual_norm = Some(
1376 guard
1377 .last_linear_residual_norm
1378 .unwrap_or(0.0)
1379 .max(residual_norm),
1380 );
1381 if let Some(id) = probe_id {
1382 guard.cg_warm_starts.insert(id, solution.clone());
1383 }
1384 }
1385
1386 let probe_label = probe_id
1387 .map(|id| id.to_string())
1388 .unwrap_or_else(|| "untracked".to_string());
1389 log::info!(
1390 "[CG-TRACE] probe_id={} iters={} rel_tol={} warm_start_used={}",
1391 probe_label,
1392 iters,
1393 rel_tol,
1394 warm_start_used
1395 );
1396
1397 solution
1398 }
1399}
1400
1401pub(crate) fn conjugate_gradient_trace_solve<F>(
1402 rhs: &Array1<f64>,
1403 rel_tol: f64,
1404 mut x: Array1<f64>,
1405 apply: F,
1406) -> Option<(Array1<f64>, usize, f64)>
1407where
1408 F: Fn(&Array1<f64>) -> Array1<f64>,
1409{
1410 let dim = rhs.len();
1411 if x.len() != dim {
1412 return None;
1413 }
1414
1415 let rhs_norm_sq = rhs.dot(rhs);
1416 if !rhs_norm_sq.is_finite() {
1417 return None;
1418 }
1419 if rhs_norm_sq <= f64::MIN_POSITIVE {
1420 return Some((Array1::<f64>::zeros(dim), 0, 0.0));
1421 }
1422
1423 let target_sq = (rel_tol * rel_tol * rhs_norm_sq).max(f64::MIN_POSITIVE);
1424 let mut r = rhs.clone();
1425 if x.iter().any(|value| *value != 0.0) {
1426 let ax = apply(&x);
1427 if ax.len() != dim || !ax.iter().all(|value| value.is_finite()) {
1428 return None;
1429 }
1430 r.scaled_add(-1.0, &ax);
1431 }
1432
1433 let mut rs_old = r.dot(&r);
1434 if !rs_old.is_finite() {
1435 return None;
1436 }
1437 if rs_old <= target_sq {
1438 return Some((x, 0, rs_old.max(0.0).sqrt()));
1439 }
1440
1441 let mut p = r.clone();
1442 let mut iters = 0usize;
1443 let mut residual_norm = rs_old.max(0.0).sqrt();
1444 for k in 0..dim.max(1) {
1445 let ap = apply(&p);
1446 if ap.len() != dim || !ap.iter().all(|value| value.is_finite()) {
1447 return None;
1448 }
1449 let denom = p.dot(&ap);
1450 if !denom.is_finite() || denom <= 0.0 {
1451 log::warn!(
1452 "[CG-TRACE] non-positive curvature in trace CG at iter={} denom={}",
1453 k + 1,
1454 denom
1455 );
1456 break;
1457 }
1458 let alpha = rs_old / denom;
1459 if !alpha.is_finite() {
1460 return None;
1461 }
1462 x.scaled_add(alpha, &p);
1463 r.scaled_add(-alpha, &ap);
1464 let rs_new = r.dot(&r);
1465 if !rs_new.is_finite() {
1466 return None;
1467 }
1468 iters = k + 1;
1469 residual_norm = rs_new.max(0.0).sqrt();
1470 if rs_new <= target_sq {
1471 break;
1472 }
1473 let beta = rs_new / rs_old;
1474 if !beta.is_finite() {
1475 return None;
1476 }
1477 p.mapv_inplace(|value| beta * value);
1478 p += &r;
1479 rs_old = rs_new;
1480 }
1481
1482 Some((x, iters, residual_norm))
1483}
1484
1485impl HessianFactorization for MatrixFreeSpdOperator {
1486 fn logdet(&self) -> f64 {
1487 *self
1488 .cached_logdet
1489 .get_or_compute(|| self.exact_dense_spectral().logdet())
1490 }
1491
1492 fn as_exact_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1493 Some(self.exact_dense_spectral())
1494 }
1495
1496 fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
1510 match self.dense_spectral() {
1511 Some(spectral) => spectral.assemble_h_dense_for_tangent_projection(),
1512 None => Err(format!(
1513 "matrix-free SPD backend declined to materialize its dense curvature at \
1514 n_dim={}",
1515 self.n_dim
1516 )),
1517 }
1518 }
1519
1520 fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
1521 self.exact_dense_spectral().trace_hinv_product(a)
1522 }
1523
1524 fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
1525 self.exact_dense_spectral().trace_hinv_operator(op)
1526 }
1527
1528 fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
1529 self.exact_dense_spectral().trace_hinv_product_cross(a, b)
1530 }
1531
1532 fn trace_hinv_matrix_operator_cross(
1533 &self,
1534 matrix: &Array2<f64>,
1535 op: &dyn HyperOperator,
1536 ) -> f64 {
1537 self.exact_dense_spectral()
1538 .trace_hinv_matrix_operator_cross(matrix, op)
1539 }
1540
1541 fn trace_hinv_operator_cross(
1542 &self,
1543 left: &dyn HyperOperator,
1544 right: &dyn HyperOperator,
1545 ) -> f64 {
1546 self.exact_dense_spectral()
1547 .trace_hinv_operator_cross(left, right)
1548 }
1549
1550 fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
1551 let trace_start = std::time::Instant::now();
1552 let result = self.exact_dense_spectral().trace_logdet_operator(op);
1553 log::info!(
1554 "[STAGE] matrix_free_spd trace_logdet_operator implicit={} dim={} elapsed={:.3}s",
1555 op.is_implicit(),
1556 op.dim(),
1557 trace_start.elapsed().as_secs_f64(),
1558 );
1559 result
1560 }
1561
1562 fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
1563 self.exact_dense_spectral().solve(rhs)
1564 }
1565
1566 fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
1567 self.exact_dense_spectral().solve_multi(rhs)
1568 }
1569
1570 fn stochastic_trace_solve(&self, rhs: &Array1<f64>, rel_tol: f64) -> Array1<f64> {
1571 if self.use_trace_cg(rel_tol) {
1572 return self.cg_trace_solve(rhs, rel_tol, None, None);
1573 }
1574 self.solve(rhs)
1575 }
1576
1577 fn stochastic_trace_solve_for_probe(
1578 &self,
1579 rhs: &Array1<f64>,
1580 rel_tol: f64,
1581 probe_id: u64,
1582 trace_state: Option<&Arc<Mutex<StochasticTraceState>>>,
1583 ) -> Array1<f64> {
1584 if self.use_trace_cg(rel_tol) {
1585 return self.cg_trace_solve(rhs, rel_tol, Some(probe_id), trace_state);
1586 }
1587 self.solve(rhs)
1588 }
1589
1590 fn stochastic_trace_solve_multi(&self, rhs: &Array2<f64>, rel_tol: f64) -> Array2<f64> {
1591 if self.use_trace_cg(rel_tol) {
1592 let mut out = Array2::<f64>::zeros(rhs.raw_dim());
1593 for j in 0..rhs.ncols() {
1594 let solved = self.cg_trace_solve(&rhs.column(j).to_owned(), rel_tol, None, None);
1595 out.column_mut(j).assign(&solved);
1596 }
1597 return out;
1598 }
1599 self.solve_multi(rhs)
1600 }
1601
1602 fn trace_logdet_hessian_cross(&self, h_i: &Array2<f64>, h_j: &Array2<f64>) -> f64 {
1603 self.exact_dense_spectral()
1604 .trace_logdet_hessian_cross(h_i, h_j)
1605 }
1606
1607 fn trace_logdet_hessian_cross_matrix_operator(
1608 &self,
1609 h_i: &Array2<f64>,
1610 h_j: &dyn HyperOperator,
1611 ) -> f64 {
1612 self.exact_dense_spectral()
1613 .trace_logdet_hessian_cross_matrix_operator(h_i, h_j)
1614 }
1615
1616 fn trace_logdet_hessian_cross_operator(
1617 &self,
1618 h_i: &dyn HyperOperator,
1619 h_j: &dyn HyperOperator,
1620 ) -> f64 {
1621 self.exact_dense_spectral()
1622 .trace_logdet_hessian_cross_operator(h_i, h_j)
1623 }
1624
1625 fn active_rank(&self) -> usize {
1626 self.n_dim
1627 }
1628
1629 fn dim(&self) -> usize {
1630 self.n_dim
1631 }
1632
1633 fn is_dense(&self) -> bool {
1634 true
1635 }
1636
1637 fn prefers_stochastic_trace_estimation(&self) -> bool {
1650 !self.exact_dense_spectral_budget_ok()
1651 }
1652
1653 fn logdet_traces_match_hinv_kernel(&self) -> bool {
1664 !self.exact_dense_spectral_budget_ok()
1665 }
1666
1667 fn as_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1668 self.dense_spectral()
1669 }
1670
1671 fn has_matrix_free_trace_cg_operator(&self) -> bool {
1672 true
1673 }
1674}
1675
1676pub fn penalty_matrix_root(s: &Array2<f64>) -> Result<Array2<f64>, String> {
1685 use faer::Side;
1686 let n = s.nrows();
1687 if n != s.ncols() {
1688 return Err(RemlError::DimensionMismatch {
1689 reason: format!(
1690 "penalty_matrix_root: expected square matrix, got {}×{}",
1691 n,
1692 s.ncols()
1693 ),
1694 }
1695 .into());
1696 }
1697 if n == 0 {
1698 return Ok(Array2::zeros((0, 0)));
1699 }
1700
1701 let (eigenvalues, eigenvectors) = s
1702 .eigh(Side::Lower)
1703 .map_err(|e| format!("penalty_matrix_root eigendecomposition failed: {e}"))?;
1704
1705 let max_ev = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
1706 let tol = (n.max(1) as f64) * f64::EPSILON * max_ev.max(1e-12);
1707
1708 let active: Vec<usize> = eigenvalues
1709 .iter()
1710 .enumerate()
1711 .filter(|(_, v)| **v > tol)
1712 .map(|(i, _)| i)
1713 .collect();
1714 let rank = active.len();
1715
1716 let mut r = Array2::zeros((rank, n));
1717 for (out_row, &idx) in active.iter().enumerate() {
1718 let scale = eigenvalues[idx].sqrt();
1719 for col in 0..n {
1720 r[[out_row, col]] = scale * eigenvectors[[col, idx]];
1721 }
1722 }
1723 Ok(r)
1724}
1725
1726#[derive(Debug)]
1737struct FixedPenaltyLogdetGeometry {
1738 reduced_roots: Vec<Array2<f64>>,
1740 component_scales: Vec<f64>,
1743 rank: usize,
1744 singleton_unit_logdet: Option<f64>,
1746}
1747
1748impl FixedPenaltyLogdetGeometry {
1749 fn new(components: &[Array2<f64>]) -> Result<Self, String> {
1750 use gam_linalg::faer_ndarray::{FaerEigh, fast_ab};
1751
1752 if components.is_empty() {
1753 return Ok(Self {
1754 reduced_roots: Vec::new(),
1755 component_scales: Vec::new(),
1756 rank: 0,
1757 singleton_unit_logdet: None,
1758 });
1759 }
1760
1761 let p = components[0].nrows();
1762 if components
1763 .iter()
1764 .any(|component| component.nrows() != p || component.ncols() != p)
1765 {
1766 return Err(
1767 "penalty-logdet geometry requires equally-sized square component matrices"
1768 .to_string(),
1769 );
1770 }
1771
1772 let mut ambient_roots = Vec::with_capacity(components.len());
1773 let mut component_scales = Vec::with_capacity(components.len());
1774 let mut component_unit_logdets = Vec::with_capacity(components.len());
1775 let mut structural_projector = Array2::<f64>::zeros((p, p));
1776
1777 for (component_index, component) in components.iter().enumerate() {
1778 if !component.iter().all(|value| value.is_finite()) {
1779 return Err(format!(
1780 "penalty-logdet component {component_index} contains a non-finite entry"
1781 ));
1782 }
1783 let (eigenvalues, eigenvectors) = component.eigh(faer::Side::Lower).map_err(|error| {
1784 format!(
1785 "penalty-logdet component {component_index} eigendecomposition failed: {error}"
1786 )
1787 })?;
1788 let eigenvalue_slice = eigenvalues
1789 .as_slice()
1790 .expect("eigh returns contiguous eigenvalues");
1791 let threshold = positive_eigenvalue_threshold(eigenvalue_slice);
1792 if let Some(negative) = eigenvalues
1793 .iter()
1794 .copied()
1795 .find(|value| *value < -threshold)
1796 {
1797 return Err(format!(
1798 "penalty-logdet component {component_index} is indefinite: eigenvalue {negative} is below the PSD noise band {}",
1799 -threshold
1800 ));
1801 }
1802
1803 let active: Vec<usize> = eigenvalues
1804 .iter()
1805 .enumerate()
1806 .filter_map(|(index, &value)| (value > threshold).then_some(index))
1807 .collect();
1808 let mut root = Array2::<f64>::zeros((active.len(), p));
1809 let mut unit_logdet = 0.0;
1810 for (root_row, &eigen_index) in active.iter().enumerate() {
1811 let eigenvalue = eigenvalues[eigen_index];
1812 unit_logdet += eigenvalue.ln();
1813 let root_scale = eigenvalue.sqrt();
1814 for row in 0..p {
1815 let basis_value = eigenvectors[[row, eigen_index]];
1816 root[[root_row, row]] = root_scale * basis_value;
1817 for col in 0..p {
1818 structural_projector[[row, col]] +=
1819 basis_value * eigenvectors[[col, eigen_index]];
1820 }
1821 }
1822 }
1823 component_scales.push(
1824 active
1825 .iter()
1826 .map(|&index| eigenvalues[index])
1827 .fold(0.0_f64, f64::max),
1828 );
1829 component_unit_logdets.push(unit_logdet);
1830 ambient_roots.push(root);
1831 }
1832
1833 if p == 0 {
1834 return Ok(Self {
1835 reduced_roots: ambient_roots,
1836 component_scales,
1837 rank: 0,
1838 singleton_unit_logdet: (components.len() == 1).then(|| component_unit_logdets[0]),
1839 });
1840 }
1841
1842 let (range_eigenvalues, range_eigenvectors) = structural_projector
1843 .eigh(faer::Side::Lower)
1844 .map_err(|error| {
1845 format!("penalty-logdet structural-range eigendecomposition failed: {error}")
1846 })?;
1847 let range_threshold = positive_eigenvalue_threshold(
1848 range_eigenvalues
1849 .as_slice()
1850 .expect("eigh returns contiguous eigenvalues"),
1851 );
1852 let range_indices: Vec<usize> = range_eigenvalues
1853 .iter()
1854 .enumerate()
1855 .filter_map(|(index, &value)| (value > range_threshold).then_some(index))
1856 .collect();
1857 let rank = range_indices.len();
1858 let mut range_basis = Array2::<f64>::zeros((p, rank));
1859 for (range_col, &eigen_index) in range_indices.iter().enumerate() {
1860 range_basis
1861 .column_mut(range_col)
1862 .assign(&range_eigenvectors.column(eigen_index));
1863 }
1864
1865 let reduced_roots = ambient_roots
1866 .iter()
1867 .map(|root| {
1868 if root.nrows() == 0 || rank == 0 {
1869 Array2::<f64>::zeros((root.nrows(), rank))
1870 } else {
1871 fast_ab(root, &range_basis)
1872 }
1873 })
1874 .collect();
1875
1876 Ok(Self {
1877 reduced_roots,
1878 component_scales,
1879 rank,
1880 singleton_unit_logdet: (components.len() == 1).then(|| component_unit_logdets[0]),
1881 })
1882 }
1883
1884 fn evaluate(
1885 &self,
1886 lambdas: &[f64],
1887 ridge: f64,
1888 ) -> Result<(f64, Array1<f64>, Array2<f64>), String> {
1889 use gam_linalg::faer_ndarray::{FaerQr, fast_ab, fast_atb};
1890
1891 if lambdas.len() != self.reduced_roots.len() {
1892 return Err(format!(
1893 "penalty-logdet geometry has {} components but received {} lambdas",
1894 self.reduced_roots.len(),
1895 lambdas.len()
1896 ));
1897 }
1898 if !(ridge.is_finite() && ridge >= 0.0) {
1899 return Err(format!(
1900 "penalty-logdet ridge must be finite and nonnegative, got {ridge}"
1901 ));
1902 }
1903 for (index, &lambda) in lambdas.iter().enumerate() {
1904 if !(lambda.is_finite() && lambda > 0.0) {
1905 return Err(format!(
1906 "penalty-logdet lambda {index} must be finite and positive, got {lambda}"
1907 ));
1908 }
1909 }
1910
1911 let component_count = lambdas.len();
1912 if self.rank == 0 {
1913 return Ok((
1914 0.0,
1915 Array1::zeros(component_count),
1916 Array2::zeros((component_count, component_count)),
1917 ));
1918 }
1919
1920 if component_count == 1
1924 && ridge == 0.0
1925 && let Some(unit_logdet) = self.singleton_unit_logdet
1926 {
1927 return Ok((
1928 unit_logdet + (self.rank as f64) * lambdas[0].ln(),
1929 Array1::from_elem(1, self.rank as f64),
1930 Array2::zeros((1, 1)),
1931 ));
1932 }
1933
1934 let common_scale = lambdas
1938 .iter()
1939 .zip(&self.component_scales)
1940 .map(|(&lambda, &unit_scale)| lambda * unit_scale)
1941 .chain(std::iter::once(ridge))
1942 .fold(0.0_f64, f64::max);
1943 if !(common_scale.is_finite() && common_scale > 0.0) {
1944 return Err(format!(
1945 "penalty-logdet fixed structural range has nonpositive numerical scale {common_scale}"
1946 ));
1947 }
1948
1949 let root_rows: usize = self.reduced_roots.iter().map(Array2::nrows).sum();
1950 let ridge_rows = usize::from(ridge > 0.0) * self.rank;
1951 let mut stacked = Array2::<f64>::zeros((root_rows + ridge_rows, self.rank));
1952 let mut row_offset = 0;
1953 for (&lambda, root) in lambdas.iter().zip(&self.reduced_roots) {
1954 let scale = (lambda / common_scale).sqrt();
1955 let end = row_offset + root.nrows();
1956 stacked
1957 .slice_mut(ndarray::s![row_offset..end, ..])
1958 .assign(&root.mapv(|value| scale * value));
1959 row_offset = end;
1960 }
1961 if ridge > 0.0 {
1962 let scale = (ridge / common_scale).sqrt();
1963 for index in 0..self.rank {
1964 stacked[[row_offset + index, index]] = scale;
1965 }
1966 }
1967
1968 let (_, upper) = stacked
1969 .qr()
1970 .map_err(|error| format!("penalty-logdet root-scale QR failed: {error}"))?;
1971 if upper.dim() != (self.rank, self.rank) {
1972 return Err(format!(
1973 "penalty-logdet root-scale QR returned {}x{} R for structural rank {}",
1974 upper.nrows(),
1975 upper.ncols(),
1976 self.rank
1977 ));
1978 }
1979
1980 let mut logdet = (self.rank as f64) * common_scale.ln();
1981 for index in 0..self.rank {
1982 let diagonal = upper[[index, index]].abs();
1983 if !(diagonal.is_finite() && diagonal > 0.0) {
1984 return Err(format!(
1985 "penalty-logdet root-scale QR produced invalid diagonal {diagonal} at {index}"
1986 ));
1987 }
1988 logdet += 2.0 * diagonal.ln();
1989 }
1990
1991 let mut inverse_upper = Array2::<f64>::zeros((self.rank, self.rank));
1996 for rhs_col in 0..self.rank {
1997 for reverse_row in 0..self.rank {
1998 let row = self.rank - 1 - reverse_row;
1999 let mut residual = f64::from(row == rhs_col);
2000 for col in (row + 1)..self.rank {
2001 residual -= upper[[row, col]] * inverse_upper[[col, rhs_col]];
2002 }
2003 inverse_upper[[row, rhs_col]] = residual / upper[[row, row]];
2004 }
2005 }
2006
2007 let projected_components: Vec<Array2<f64>> = self
2008 .reduced_roots
2009 .iter()
2010 .map(|root| {
2011 let transformed = fast_ab(root, &inverse_upper);
2012 fast_atb(&transformed, &transformed)
2013 })
2014 .collect();
2015 let scaled_lambdas: Vec<f64> = lambdas.iter().map(|lambda| lambda / common_scale).collect();
2016 let mut first = Array1::<f64>::zeros(component_count);
2017 for k in 0..component_count {
2018 first[k] = scaled_lambdas[k]
2019 * (0..self.rank)
2020 .map(|index| projected_components[k][[index, index]])
2021 .sum::<f64>();
2022 }
2023 let mut second = Array2::<f64>::zeros((component_count, component_count));
2024 for k in 0..component_count {
2025 for l in 0..component_count {
2026 let cross = super::super::penalty_logdet::PenaltyPseudologdet::trace_dense_product(
2027 &projected_components[k],
2028 &projected_components[l],
2029 );
2030 second[[k, l]] = if k == l { first[k] } else { 0.0 }
2031 - scaled_lambdas[k] * scaled_lambdas[l] * cross;
2032 }
2033 }
2034 Ok((logdet, first, second))
2035 }
2036}
2037
2038#[derive(Clone, Debug, PartialEq, Eq)]
2039struct DensePenaltyGeometryKey {
2040 rows: usize,
2041 cols: usize,
2042 values: Vec<u64>,
2043}
2044
2045#[derive(Clone, Debug, PartialEq, Eq)]
2046struct BlockPenaltyGeometryKey {
2047 blocks: Vec<Vec<DensePenaltyGeometryKey>>,
2048 prior_factor_masks: Vec<Vec<bool>>,
2049}
2050
2051impl BlockPenaltyGeometryKey {
2052 fn new(per_block_penalties: &[&[Array2<f64>]], prior_factor_masks: &[Vec<bool>]) -> Self {
2053 Self {
2054 blocks: per_block_penalties
2055 .iter()
2056 .map(|block| {
2057 block
2058 .iter()
2059 .map(|matrix| DensePenaltyGeometryKey {
2060 rows: matrix.nrows(),
2061 cols: matrix.ncols(),
2062 values: matrix.iter().map(|value| value.to_bits()).collect(),
2063 })
2064 .collect()
2065 })
2066 .collect(),
2067 prior_factor_masks: prior_factor_masks.to_vec(),
2068 }
2069 }
2070}
2071
2072#[derive(Debug)]
2073struct PenaltyLogdetGroupGeometry {
2074 coordinate_indices: Vec<usize>,
2075 geometry: FixedPenaltyLogdetGeometry,
2076}
2077
2078#[derive(Debug)]
2079struct PenaltyLogdetBlockGeometry {
2080 coordinate_count: usize,
2081 groups: Vec<PenaltyLogdetGroupGeometry>,
2082}
2083
2084#[derive(Debug)]
2085struct BlockPenaltyLogdetGeometry {
2086 blocks: Vec<PenaltyLogdetBlockGeometry>,
2087}
2088
2089impl BlockPenaltyLogdetGeometry {
2090 fn new(
2091 per_block_penalties: &[&[Array2<f64>]],
2092 prior_factor_masks: &[Vec<bool>],
2093 ) -> Result<Self, String> {
2094 let mut blocks = Vec::with_capacity(per_block_penalties.len());
2095 for (block_index, penalties) in per_block_penalties.iter().enumerate() {
2096 let mask = &prior_factor_masks[block_index];
2097 let coalesced_indices: Vec<usize> = mask
2098 .iter()
2099 .enumerate()
2100 .filter_map(|(index, &is_factor)| (!is_factor).then_some(index))
2101 .collect();
2102 let mut groups = Vec::new();
2103 if !coalesced_indices.is_empty() {
2104 let components: Vec<Array2<f64>> = coalesced_indices
2105 .iter()
2106 .map(|&index| penalties[index].clone())
2107 .collect();
2108 groups.push(PenaltyLogdetGroupGeometry {
2109 coordinate_indices: coalesced_indices,
2110 geometry: FixedPenaltyLogdetGeometry::new(&components)
2111 .map_err(|error| format!("penalty-logdet block {block_index}: {error}"))?,
2112 });
2113 }
2114 for (index, &is_factor) in mask.iter().enumerate() {
2115 if is_factor {
2116 groups.push(PenaltyLogdetGroupGeometry {
2117 coordinate_indices: vec![index],
2118 geometry: FixedPenaltyLogdetGeometry::new(std::slice::from_ref(
2119 &penalties[index],
2120 ))
2121 .map_err(|error| {
2122 format!(
2123 "penalty-logdet block {block_index} prior factor {index}: {error}"
2124 )
2125 })?,
2126 });
2127 }
2128 }
2129 blocks.push(PenaltyLogdetBlockGeometry {
2130 coordinate_count: penalties.len(),
2131 groups,
2132 });
2133 }
2134 Ok(Self { blocks })
2135 }
2136
2137 fn evaluate(
2138 &self,
2139 per_block_rho: &[Array1<f64>],
2140 ridge: f64,
2141 ) -> Result<PenaltyLogdetDerivs, String> {
2142 if per_block_rho.len() != self.blocks.len() {
2143 return Err(format!(
2144 "penalty-logdet geometry has {} blocks but received {} rho blocks",
2145 self.blocks.len(),
2146 per_block_rho.len()
2147 ));
2148 }
2149
2150 struct BlockResult {
2151 offset: usize,
2152 value: f64,
2153 first: Array1<f64>,
2154 second: Array2<f64>,
2155 }
2156
2157 let offsets: Vec<usize> = self
2158 .blocks
2159 .iter()
2160 .scan(0usize, |offset, block| {
2161 let current = *offset;
2162 *offset += block.coordinate_count;
2163 Some(current)
2164 })
2165 .collect();
2166 let evaluate_block = |(block_index, block): (usize, &PenaltyLogdetBlockGeometry)| {
2167 let rho = &per_block_rho[block_index];
2168 if rho.len() != block.coordinate_count {
2169 return Err(format!(
2170 "penalty-logdet block {block_index} has {} components but received {} rho coordinates",
2171 block.coordinate_count,
2172 rho.len()
2173 ));
2174 }
2175 let lambdas = gam_problem::checked_exp_log_strengths(rho.iter().copied())
2176 .map_err(|error| format!("penalty-logdet block {block_index}: {error}"))?;
2177 let mut value = 0.0;
2178 let mut first = Array1::<f64>::zeros(block.coordinate_count);
2179 let mut second = Array2::<f64>::zeros((block.coordinate_count, block.coordinate_count));
2180 for group in &block.groups {
2181 let group_lambdas: Vec<f64> = group
2182 .coordinate_indices
2183 .iter()
2184 .map(|&index| lambdas[index])
2185 .collect();
2186 let (group_value, group_first, group_second) = group
2187 .geometry
2188 .evaluate(&group_lambdas, ridge)
2189 .map_err(|error| format!("penalty-logdet block {block_index}: {error}"))?;
2190 value += group_value;
2191 for (local_k, &global_k) in group.coordinate_indices.iter().enumerate() {
2192 first[global_k] = group_first[local_k];
2193 for (local_l, &global_l) in group.coordinate_indices.iter().enumerate() {
2194 second[[global_k, global_l]] = group_second[[local_k, local_l]];
2195 }
2196 }
2197 }
2198 Ok(BlockResult {
2199 offset: offsets[block_index],
2200 value,
2201 first,
2202 second,
2203 })
2204 };
2205
2206 let block_results: Vec<BlockResult> = if rayon::current_thread_index().is_some() {
2207 self.blocks
2208 .iter()
2209 .enumerate()
2210 .map(evaluate_block)
2211 .collect::<Result<Vec<_>, String>>()?
2212 } else {
2213 self.blocks
2214 .par_iter()
2215 .enumerate()
2216 .map(evaluate_block)
2217 .collect::<Result<Vec<_>, String>>()?
2218 };
2219
2220 let total_coordinates: usize = self.blocks.iter().map(|block| block.coordinate_count).sum();
2221 let mut value = 0.0;
2222 let mut first = Array1::<f64>::zeros(total_coordinates);
2223 let mut second = Array2::<f64>::zeros((total_coordinates, total_coordinates));
2224 for block in block_results {
2225 value += block.value;
2226 for k in 0..block.first.len() {
2227 first[block.offset + k] = block.first[k];
2228 for l in 0..block.first.len() {
2229 second[[block.offset + k, block.offset + l]] = block.second[[k, l]];
2230 }
2231 }
2232 }
2233 Ok(PenaltyLogdetDerivs {
2234 value,
2235 first,
2236 second: Some(second),
2237 })
2238 }
2239}
2240
2241std::thread_local! {
2242 static BLOCK_PENALTY_LOGDET_GEOMETRY:
2246 std::cell::RefCell<Option<(BlockPenaltyGeometryKey, std::sync::Arc<BlockPenaltyLogdetGeometry>)>>
2247 = const { std::cell::RefCell::new(None) };
2248}
2249
2250pub fn compute_block_penalty_logdet_derivs_with_prior_factors(
2273 per_block_rho: &[Array1<f64>],
2274 per_block_penalties: &[&[Array2<f64>]],
2275 prior_factor_mask: Option<&[Vec<bool>]>,
2276 ridge: f64,
2277) -> Result<PenaltyLogdetDerivs, String> {
2278 if per_block_rho.len() != per_block_penalties.len() {
2279 return Err(format!(
2280 "penalty-logdet received {} rho blocks and {} penalty blocks",
2281 per_block_rho.len(),
2282 per_block_penalties.len()
2283 ));
2284 }
2285 let masks = match prior_factor_mask {
2286 Some(masks) => {
2287 if masks.len() != per_block_penalties.len() {
2288 return Err(format!(
2289 "penalty-logdet received {} prior-factor masks for {} penalty blocks",
2290 masks.len(),
2291 per_block_penalties.len()
2292 ));
2293 }
2294 for (block, (mask, penalties)) in masks.iter().zip(per_block_penalties).enumerate() {
2295 if mask.len() != penalties.len() {
2296 return Err(format!(
2297 "penalty-logdet block {block} has {} penalties but {} prior-factor flags",
2298 penalties.len(),
2299 mask.len()
2300 ));
2301 }
2302 }
2303 masks.to_vec()
2304 }
2305 None => per_block_penalties
2306 .iter()
2307 .map(|penalties| vec![false; penalties.len()])
2308 .collect(),
2309 };
2310 let key = BlockPenaltyGeometryKey::new(per_block_penalties, &masks);
2311 let geometry = BLOCK_PENALTY_LOGDET_GEOMETRY.with(|slot| {
2312 slot.borrow()
2313 .as_ref()
2314 .filter(|(cached_key, _)| cached_key == &key)
2315 .map(|(_, geometry)| std::sync::Arc::clone(geometry))
2316 });
2317 let geometry = match geometry {
2318 Some(geometry) => geometry,
2319 None => {
2320 let geometry = std::sync::Arc::new(BlockPenaltyLogdetGeometry::new(
2321 per_block_penalties,
2322 &masks,
2323 )?);
2324 BLOCK_PENALTY_LOGDET_GEOMETRY.with(|slot| {
2325 *slot.borrow_mut() = Some((key, std::sync::Arc::clone(&geometry)));
2326 });
2327 geometry
2328 }
2329 };
2330 geometry.evaluate(per_block_rho, ridge)
2331}
2332
2333