Skip to main content

gam_solve/reml/reml_outer_engine/
sparse_cholesky_backends.rs

1use super::*;
2
3// ═══════════════════════════════════════════════════════════════════════════
4//  Sparse Cholesky HessianFactorization implementation
5// ═══════════════════════════════════════════════════════════════════════════
6
7/// Sparse Cholesky Hessian operator.
8///
9/// Wraps an existing `SparseExactFactor` and provides logdet, trace, and solve
10/// from the same Cholesky factorization.
11pub struct SparseCholeskyOperator {
12    /// The sparse Cholesky factorization.
13    pub(crate) factor: std::sync::Arc<gam_linalg::sparse_exact::SparseExactFactor>,
14    /// Takahashi selected inverse (precomputed H^{-1} entries on the filled pattern of L).
15    /// When available, trace computations use direct lookups instead of column solves.
16    pub(crate) takahashi: Option<std::sync::Arc<gam_linalg::sparse_exact::TakahashiInverse>>,
17    /// Precomputed log-determinant from the Cholesky diagonal.
18    pub(crate) cached_logdet: f64,
19    /// Dimension of H.
20    pub(crate) n_dim: usize,
21}
22
23impl SparseCholeskyOperator {
24    /// Create from an existing sparse factorization and its precomputed logdet.
25    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                // SAFETY: `SparseCholeskyOperator` is constructed only with a
129                // successfully-factorized SPD `self.factor`. The sparse SPD
130                // multi-RHS solve only fails on factor corruption or RHS
131                // shape mismatch; the RHS comes from `mul_basis_columns_into`
132                // matching the factor's dimension, so failure here means
133                // the cached factor was corrupted after construction —
134                // a hard invariant violation.
135                // SAFETY: self.factor is validated SPD; sparse-SPD solve only fails on factor corruption.
136                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                // SAFETY: `self.factor` is the validated SPD Cholesky factor
236                // (set only after successful factorization); the RHS shape
237                // is `n_dim × cols` by construction. A sparse-SPD multi-RHS
238                // failure here would mean factor corruption, which the
239                // construction invariant forbids.
240                // SAFETY: self.factor is validated SPD; matrix/operator multi-solve only fails on corruption.
241                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                // SAFETY: same invariant — `self.factor` is the validated
288                // SPD factor and `op_rhs_block` is allocated as
289                // `n_dim × chunk`, so dimensions are compatible by
290                // construction. Any failure indicates factor corruption.
291                // SAFETY: self.factor is validated SPD; block-operator multi-solve only fails on corruption.
292                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                // SAFETY: same invariant — `self.factor` is validated SPD
305                // and `eye_rhs_block` was just filled as an identity-block
306                // RHS sized `n_dim × chunk`. Failure indicates factor
307                // corruption, forbidden by the construction invariant.
308                // SAFETY: self.factor is validated SPD; identity-RHS multi-solve only fails on corruption.
309                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                // SAFETY: `solve_operator_column_range_rows_exact` only
359                // forwards `solve_sparse_spdmulti` errors. `self.factor` is
360                // the validated SPD Cholesky factor; column ranges come
361                // from the operator's own `block_local_data` (or fall back
362                // to `0..n_dim`), so failure indicates factor corruption.
363                // SAFETY: self.factor is validated SPD; operator cross-left solve only fails on corruption.
364                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                    // SAFETY: mirrors the left-solve invariant above —
381                    // `self.factor` is validated SPD and the column range
382                    // is taken from `right`'s own `block_local_data`,
383                    // so failure indicates factor corruption.
384                    // SAFETY: self.factor is validated SPD; operator cross-right solve only fails on corruption.
385                    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        // When Takahashi is available, use direct entry lookup for tr(H^{-1} A).
427        // This is O(p^2) via dense A iteration but avoids p column solves.
428        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                // SAFETY: `self.factor` is the validated SPD Cholesky factor
447                // (created by `SparseCholeskyOperator::new` only after a
448                // successful factorization); a single-square multi-RHS solve
449                // here can only fail on factor corruption, which the
450                // construction invariant forbids.
451                // SAFETY: self.factor is validated SPD; single-square multi-solve only fails on corruption.
452                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            // For other non-implicit operators: materialize and use Takahashi lookups
465            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        // SAFETY: `self.factor` is the validated SPD Cholesky factor stored
479        // at construction time; a triangular solve against an already-built
480        // factor can only fail on factor corruption, which the
481        // `SparseCholeskyOperator` construction invariant forbids.
482        gam_linalg::sparse_exact::solve_sparse_spd(&self.factor, rhs)
483            // SAFETY: self.factor is validated SPD; triangular solve only fails on corruption.
484            .unwrap_or_else(|e| panic!("SparseCholeskyOperator exact solve failed: {e}"))
485    }
486
487    fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
488        // SAFETY: same SPD-factor invariant as `solve` above — `self.factor`
489        // was created from a successful Cholesky factorization, so a
490        // multi-RHS solve can only fail on factor corruption.
491        gam_linalg::sparse_exact::solve_sparse_spdmulti(&self.factor, rhs)
492            // SAFETY: self.factor is validated SPD; multi-RHS solve only fails on corruption.
493            .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        // For general dense matrices, column solves are better than materializing
498        // full Z from Takahashi (O(p * nnz) vs O(p³)). Takahashi cross-traces
499        // are only used for block-local operators via trace_hinv_operator_cross.
500        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        // For mixed dense-matrix × block-local-operator, column solves are
514        // still better than materializing full Z. Only use Takahashi when both
515        // sides are block-local (handled in trace_hinv_operator_cross).
516        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        // Takahashi fast path: when both operators are block-local to the same
525        // block, compute tr(Z A Z B) using only the block of Z = H⁻¹.
526        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            // Same block: tr(Z_block * A_local * Z_block * B_local)
533            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            // tr(ZA * ZB) = sum_ij (ZA)_ij * (ZB^T)_ij
539            return (&za * &zb.t()).sum();
540        }
541        // Different blocks: column solves are better than materializing
542        // full p×p Z. Fall through to exact path.
543        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
571// BlockCoupledDerivativeProvider was removed — its functionality is now handled
572// by the `deriv_provider` trait (HessianDerivativeProvider), with concrete
573// implementations like JointModelDerivProvider and SurvivalDerivProvider
574// capturing the full correction including Jacobian sensitivity, weight
575// sensitivity, and basis sensitivity.
576
577// ═══════════════════════════════════════════════════════════════════════════
578//  Cholesky-backed exact positive-definite HessianFactorization
579// ═══════════════════════════════════════════════════════════════════════════
580
581/// Dense Cholesky-backed [`HessianFactorization`] for positive-definite Hessians.
582///
583/// A single LLT factor supplies the exact positive-definite log-determinant,
584/// solves, first-order traces, and second-order cross traces. Consequently every
585/// derivative lane prices the same scalar `log|H|`; no eigenspace threshold or
586/// pseudo-spectral floor is involved.
587///
588/// LLT costs `O(p³/3)` flops versus
589/// the `O(9·p³)` full eigendecomposition of [`DenseSpectralOperator`], giving
590/// a multi-× speedup at the small and medium dense dimensions where exact outer
591/// derivatives are required.
592pub struct DenseCholeskyOperator {
593    /// LLT Cholesky factor.
594    pub(crate) chol: gam_linalg::faer_ndarray::FaerCholeskyFactor,
595    /// `2 · Σ ln(diag L)` — cached at construction time.
596    pub(crate) cached_logdet: f64,
597    /// Full parameter dimension.
598    pub(crate) n_dim: usize,
599    /// Exact symmetric matrix represented by `chol`, retained for the uncommon
600    /// active-constraint tangent-projection surface.
601    pub(crate) matrix: Array2<f64>,
602    /// Upper-triangular `F = L⁻ᵀ`, where `H = L Lᵀ`, so
603    /// `H⁻¹ = F Fᵀ`. Operator traces use exact projected contractions through
604    /// this factor instead of materializing each Hessian drift.
605    pub(crate) inverse_root: Array2<f64>,
606}
607
608impl DenseCholeskyOperator {
609    /// Replace the cached `2·Σ ln(diag L)` with a value computed at ROOT
610    /// scale. The Cholesky factor itself is untouched — it is the operator's
611    /// solve/trace kernel, and only the log-determinant scalar is
612    /// `O(ε·κ(H))`-limited (#2644).
613    pub(crate) fn install_root_scale_logdet(&mut self, value: f64) {
614        self.cached_logdet = value;
615    }
616
617    /// Construct `L⁻ᵀ` by stable triangular substitution without forming
618    /// `H⁻¹`. This is the exact projection factor needed by
619    /// `tr(H⁻¹A) = tr(FᵀAF)` and
620    /// `tr(H⁻¹AH⁻¹B) = tr((FᵀAF)(FᵀBF))`.
621    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        // Solve L R = I one column at a time. Only row >= column can be
629        // nonzero because both L and R are lower triangular.
630        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        // F = Rᵀ.
641        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    /// Factorize `h` via LLT and cache the exact positive-definite
665    /// log-determinant.
666    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        // A Hessian represents a symmetric bilinear form. LLT libraries consume
683        // one triangle, so accepting an asymmetric matrix would silently make
684        // the represented operator depend on storage convention. Admit only
685        // roundoff-scale skew, then factor and retain the same averaged matrix.
686        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    /// Exact factorization for [`PseudoLogdetMode::PositiveDefinite`].
726    ///
727    /// Failure is the requested definiteness certificate: callers must refuse
728    /// the candidate rather than floor a saddle or singular mode.
729    pub fn from_positive_definite(h: &Array2<f64>) -> Result<Self, String> {
730        Self::factorize_positive_definite(h)
731    }
732
733    /// Smooth-logdet value-lane shortcut, admitted only when its exact
734    /// log-determinant is certified to agree with the smooth spectral scalar.
735    ///
736    /// Returns `Err` if `h` is not SPD or if its exact log-determinant would not
737    /// agree with the smooth-floored one every derivative lane prices
738    /// (gam#2457, below).
739    /// On refusal, the caller routes the evaluation to
740    /// [`DenseSpectralOperator`], which owns the floored convention.
741    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        // gam#2457 — THIS OPERATOR AND THE SPECTRAL ONE PRICE DIFFERENT SCALARS.
747        //
748        // The LLT returns the exact `Σ ln σ_j`.  Every derivative-bearing lane
749        // reaches [`DenseSpectralOperator`] instead, whose smooth floor makes
750        // its log-determinant `Σ ln r_ε(σ_j)` with
751        // `r_ε(σ) = ½(σ + √(σ² + 4ε²))` and `ε = spectral_epsilon` — and the
752        // analytic gradient `tr(G_ε Ḣ)` and its Hessian are the exact
753        // derivatives of THAT floored object.  So the floored log-determinant
754        // is the criterion, and this fast path is a legitimate shortcut only
755        // where the two coincide.  Where they do not, the outer objective
756        // returns one value to `OuterEvalOrder::Value` (line-search probes,
757        // the terminal value certificate) and another to `ValueAndGradient`
758        // (the trust-region model, the certificate's analytic sample) at the
759        // SAME ρ — measured at 663× the value-agreement envelope on
760        // `kappa_zero_fit_recovers_planted_flat_signal`, whose `H = XᵀWX + S_λ`
761        // carries an eigenvalue at ≈7ε once λ = e^−8.9 stops regularizing it.
762        //
763        // The gap is bounded without ever forming the spectrum.  For SPD `H`,
764        // `√(1 + 4t) ≤ 1 + 2t` gives `r_ε(σ)/σ ≤ 1 + ε²/σ²`, and `ln(1+t) ≤ t`,
765        // so
766        //
767        //     0 ≤ Σ_j ln(r_ε(σ_j)/σ_j) ≤ ε² · Σ_j σ_j⁻² = ε² · tr(H⁻²)
768        //
769        // and `tr(H⁻²) = ‖H⁻¹‖_F²` comes straight out of the factorization
770        // already in hand.  The bound is tight in the regime that matters (a
771        // single near-floor eigenvalue dominates both sides), so gating on it
772        // costs the speedup only where the floor genuinely bites.
773        //
774        // Admit the fast path exactly when that certified gap is inside the
775        // same relative envelope the outer audit applies to the scalar this
776        // log-determinant feeds — ONE predicate, named once, reused rather than
777        // re-derived.  The decline is one-sided: it can cost an LLT speedup, it
778        // can never admit a value the derivative lanes disagree with.  Both
779        // call sites already handle `Err` by building the spectral operator.
780        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        // LLT succeeded ⟹ all pivots are positive ⟹ full rank.
901        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
917// ═══════════════════════════════════════════════════════════════════════════
918//  Block-coupled HessianFactorization for joint multi-block models
919// ═══════════════════════════════════════════════════════════════════════════
920
921/// Block-coupled Hessian operator for joint multi-block models (GAMLSS, survival).
922///
923/// Retains block-structure metadata around one factorization of the full
924/// assembled joint Hessian. Strictly positive-definite models use LLT; models
925/// with genuine quotient or smooth pseudo-logdet semantics use one spectral
926/// decomposition. Every [`HessianFactorization`] operation delegates to that
927/// same inner factorization.
928///
929/// # Block structure
930///
931/// A joint model with B parameter blocks has a joint Hessian of dimension
932/// `p_total = sum_b p_b`. Each block occupies rows/columns
933/// # When to use
934///
935/// Use `BlockCoupledOperator` whenever building an [`InnerSolution`] for a joint
936/// multi-block model. It replaces the pattern of constructing a raw
937/// `DenseSpectralOperator` and manually tracking block ranges separately.
938enum 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    /// One exact factorization over the full joint Hessian. Positive-definite
954    /// models use LLT; pseudo-logdet modes retain their spectral semantics.
955    inner: BlockCoupledFactorization,
956}
957
958impl BlockCoupledOperator {
959    /// Construct from an assembled joint Hessian using the supplied
960    /// [`PseudoLogdetMode`]. Positive-definite mode uses an exact LLT;
961    /// pseudo-logdet modes use a single eigendecomposition.
962    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
1131// ═══════════════════════════════════════════════════════════════════════════
1132//  Matrix-free SPD HessianFactorization implementation
1133// ═══════════════════════════════════════════════════════════════════════════
1134
1135/// Operator-backed SPD Hessian with exact spectral REML algebra.
1136///
1137/// The operator closure is still useful for construction paths that naturally
1138/// expose HVPs, but REML cost/gradient/Hessian terms must all come from one
1139/// exact decomposition so `∂ log|H| = tr(H⁻¹ ∂H)` holds.  We therefore
1140/// materialize the coefficient Hessian by canonical-basis HVPs under an
1141/// explicit memory cap and delegate logdet, traces, and solves to
1142/// `DenseSpectralOperator`.
1143pub struct MatrixFreeSpdOperator {
1144    pub(crate) apply: Arc<dyn Fn(&Array1<f64>) -> Array1<f64> + Send + Sync>,
1145    // Optional single-pass dense assembly of the SAME penalized operator that
1146    // `apply` realizes matrix-free, i.e. `H_unpen + S_λ + scale·H_Φ`. When the
1147    // operator source can structurally build its full dense matrix in one
1148    // chunked BLAS-3 `XᵀWX` row pass (BMS's `hessian_dense_forced` +
1149    // construction-site penalty/Jeffreys assembly), `materialize_dense_operator`
1150    // calls THIS instead of `dim` canonical-basis matvecs — each of which is a
1151    // full n-row pass through the matrix-free operator. One n-pass replaces
1152    // `dim` n-passes for the LAML logdet factorization. The closure must return
1153    // a matrix numerically identical (up to symmetrization) to the matvec
1154    // reconstruction `H·I`; `None` means no direct build is available and the
1155    // matvec path is used (the result is bit-for-bit the prior behavior).
1156    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    // `RayonSafeOnce`, not `OnceLock`: `materialize_dense_operator` invokes
1160    // `apply`, which for operator-source joint Hessians dispatches a nested
1161    // `into_par_iter` (e.g. `exact_newton_joint_hessian_matvec_from_cache`).
1162    // With a plain `OnceLock`, concurrent rayon workers entering
1163    // `solve`/`logdet` from inside an outer par_iter would park on the
1164    // OnceLock's OS condvar; the leader's nested par_iter would then starve
1165    // for workers. `RayonSafeOnce` keeps init lock-free — racers may
1166    // duplicate the dim²-matvec build, but the first to publish wins and
1167    // steady-state matches `OnceLock`.
1168    pub(crate) dense_spectral: gam_runtime::resource::RayonSafeOnce<Option<DenseSpectralOperator>>,
1169    // Pseudo-logdet convention threaded from the family. The dense outer path
1170    // already plumbs `PseudoLogdetMode` into `BlockCoupledOperator`; the
1171    // matrix-free path materializes a `DenseSpectralOperator` lazily and must
1172    // use the same convention so that `logdet`, `trace_hinv_product`, the
1173    // IFT response `H⁻¹ g`, and every cross-trace agree with the dense path.
1174    // Without this, families that declare `HardPseudo` (BMS, GAMLSS) silently
1175    // get Smooth full-spectrum semantics on the matrix-free path, and outer
1176    // gradients are inflated by `1/σ_j` over numerical null directions.
1177    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    /// Like `new_with_mode`, but additionally accepts an optional single-pass
1192    /// dense assembly of the same penalized operator. When present and it yields
1193    /// a matrix, `materialize_dense_operator` uses it instead of the `dim`
1194    /// canonical-basis matvecs. See the field doc on `dense_assemble`.
1195    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        // Fast path: structural single-pass dense assembly of the SAME penalized
1252        // operator (`H_unpen + S_λ + scale·H_Φ`). One chunked BLAS-3 `XᵀWX`
1253        // row pass replaces `n_dim` canonical-basis matvecs, each a full n-row
1254        // pass through the matrix-free operator. The matvec fallback below is the
1255        // exact same algebra column-for-column, so the spectrum/logdet match.
1256        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                    // Symmetrize defensively; the direct build is structurally
1264                    // symmetric but reduction-order f.p. noise can desync mirror
1265                    // entries, exactly as the matvec path symmetrizes below.
1266                    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                // `None` here silently demotes the caller to a slower path.
1301                // Say why, or the demotion is indistinguishable from "not
1302                // requested" in a profile.
1303                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    /// The curvature this backend already materializes for every exact REML
1497    /// algebra path (gam#979).
1498    ///
1499    /// The trait's default refusal is for backends that have no dense form at
1500    /// all. This one has one: `as_exact_dense_spectral` above hands it out
1501    /// unconditionally, and `dense_spectral()` caches it. Inheriting the
1502    /// default therefore refused a matrix the operator builds anyway — and the
1503    /// refusal is not a fallback to something slower. Its one consumer,
1504    /// `try_tangent_projected_evaluate`, needs `Z' M Z` for the mode response
1505    /// at an ACTIVE-CONSTRAINT iterate and turns the error into a REFUSED
1506    /// TRIAL POINT. The large-scale CTN preprocessor's cone constraints are
1507    /// active at nearly every trial, so its outer κ search spent entire BFGS
1508    /// restarts on "infeasible probes" that were only ever this.
1509    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    /// The operator delegates `logdet`, `trace_hinv_*`, `trace_logdet_*`,
1638    /// `solve`, and `solve_multi` to a lazily-built `DenseSpectralOperator`
1639    /// whenever the exact-dense materialization fits the configured byte cap
1640    /// (see `exact_dense_spectral_budget_ok` / `EXACT_DENSE_SPECTRAL_MAX_BYTES`).
1641    /// In that regime the algebra is exact spectral — there is no stochastic
1642    /// preference to advertise, and forcing the caller to take the Hutchinson
1643    /// path would replace an O(p²) exact reduction with O(k·apply) noisy probes.
1644    ///
1645    /// When the budget is exceeded the dense factor cannot be built and the
1646    /// CG trace-solve path added in 2bd6af68 is the only feasible route; the
1647    /// flag flips to `true` so `stochastic_trace_solve*` callers route through
1648    /// `cg_trace_solve` instead of crashing in `exact_dense_spectral().expect`.
1649    fn prefers_stochastic_trace_estimation(&self) -> bool {
1650        !self.exact_dense_spectral_budget_ok()
1651    }
1652
1653    /// Mirror the `prefers_stochastic_trace_estimation` gate: when the dense
1654    /// factor is reachable the operator's logdet / trace_hinv reductions all
1655    /// resolve through `DenseSpectralOperator`, whose
1656    /// `logdet_traces_match_hinv_kernel` is `false` for the smooth-spectral
1657    /// regularization variants we run. Reporting `true` here would let the
1658    /// outer evaluator route logdet-gradient/Hessian traces through the
1659    /// Hutchinson `H⁻¹` kernel which does not satisfy
1660    /// `∂ log|H| = tr(H⁻¹ ∂H)` under smooth-spectral. The CG-only regime
1661    /// (budget exceeded) lacks a dense reference so falling back to the
1662    /// stochastic kernel is acceptable as a best-effort estimate.
1663    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
1676// ═══════════════════════════════════════════════════════════════════════════
1677//  Helpers for custom family → InnerSolution conversion
1678// ═══════════════════════════════════════════════════════════════════════════
1679
1680/// Compute the square root of a symmetric positive semidefinite penalty matrix.
1681///
1682/// Returns R such that S = RᵀR, with R having `rank(S)` rows.
1683/// Uses eigendecomposition: S = U Λ U^T → R = Λ_+^{1/2} U_+^T.
1684pub 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/// Immutable, λ-independent geometry of one fixed collection of PSD penalty
1727/// components.
1728///
1729/// The structural range is built once from the sum of the component range
1730/// projectors, so it is invariant to each component's arbitrary physical
1731/// scale.  Every outer evaluation then factorizes the stacked, λ-scaled roots
1732/// in that fixed range with QR.  This retains the root-scale conditioning of
1733/// [`PenaltyPseudologdet`](super::super::penalty_logdet::PenaltyPseudologdet)'s
1734/// SVD construction without repeating component eigendecompositions or a
1735/// substantially more expensive SVD at every trial ρ.
1736#[derive(Debug)]
1737struct FixedPenaltyLogdetGeometry {
1738    /// Unit component roots expressed in the fixed structural range.
1739    reduced_roots: Vec<Array2<f64>>,
1740    /// Largest unit eigenvalue of each component. Used only to choose a common
1741    /// numerical scale before QR; it does not alter the represented penalty.
1742    component_scales: Vec<f64>,
1743    rank: usize,
1744    /// Exact unit pseudo-logdet when this geometry contains one component.
1745    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        // A singleton un-ridged factor is exactly affine in log λ. Besides
1921        // avoiding any factorization, this returns the algebraic zero Hessian
1922        // rather than a roundoff-sized approximation to zero.
1923        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        // Scale the entire stacked root by one common physical precision.
1935        // The represented matrix is unchanged after adding rank·log(scale) to
1936        // the QR logdet, while every root entry stays near unit magnitude.
1937        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        // RᵀR is the common-scale-normalized precision. R⁻¹R⁻ᵀ is therefore
1992        // its inverse, and all ρ derivatives can be evaluated with the
1993        // dimensionless weights λ/common_scale without ever squaring the
1994        // condition number in an assembled matrix.
1995        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    /// One exact penalty-layout geometry per calling thread. Outer iterations
2243    /// execute serially on one driver thread, so this retains the current fit's
2244    /// immutable geometry without a process-global, ever-growing model cache.
2245    static BLOCK_PENALTY_LOGDET_GEOMETRY:
2246        std::cell::RefCell<Option<(BlockPenaltyGeometryKey, std::sync::Arc<BlockPenaltyLogdetGeometry>)>>
2247        = const { std::cell::RefCell::new(None) };
2248}
2249
2250/// `compute_block_penalty_logdet_derivs` with per-penalty prior-factor
2251/// structure.
2252///
2253/// `prior_factor_mask[b][k] == true` declares block `b`'s penalty `k` an
2254/// INDEPENDENT Gaussian prior factor rather than an additive piece of one
2255/// smooth prior. The evidence normalizer of one Gaussian with precision
2256/// `Σ_k λ_k S_k` is the coalesced `log|Σ_k λ_k S_k|₊` (the default, and the
2257/// correct convention for multi-penalty smooths), but a PRODUCT of
2258/// independent factors `∏_k N(0, (λ_k S_k)⁻¹)` contributes
2259///
2260/// ```text
2261/// Σ_k log|λ_k S_k|₊ = Σ_k ( rank(S_k)·ρ_k + log|S_k|₊ ),
2262/// ```
2263///
2264/// which differs from the coalesced form exactly when factors overlap: two
2265/// factors with precision λ on one scalar coefficient carry
2266/// `λ^{1/2}·λ^{1/2} = λ`, while coalescing their quadratics into `2λβ²` and
2267/// taking one normalizer yields `(2λ)^{1/2}` — losing `½ log λ` from the
2268/// outer ρ-posterior (hierarchical coefficient groups, audit finding 40).
2269/// Each masked penalty therefore becomes its own singleton pseudo-logdet
2270/// block; unmasked penalties within the block coalesce as before. `None`
2271/// masks (or an all-false mask) reproduce the coalesced behaviour exactly.
2272pub 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// ═══════════════════════════════════════════════════════════════════════════
2334//  Stochastic trace estimation via Rademacher probes
2335// ═══════════════════════════════════════════════════════════════════════════
2336//
2337// For large-scale models, computing tr(H⁻¹ A_k) exactly via the full p×p
2338// eigendecomposition or column-by-column sparse solves costs O(p²) per
2339// coordinate k.  Stochastic trace estimation gives an unbiased estimate
2340// using only matrix–vector products (solves), at cost O(M·p) where M is the
2341// number of random probe vectors (typically 10–200).
2342//
2343// The Girard–Hutchinson estimator:
2344//
2345//   tr(H⁻¹ A_k) ≈ (1/M) Σ_m  z_mᵀ H⁻¹ A_k z_m
2346//
2347// where z_m are i.i.d. random vectors with E[zzᵀ] = I.
2348//
2349// Rademacher probes (entries ±1 with equal probability) have strictly
2350// lower variance than Gaussian probes:
2351//   Var_Rad = 2(‖S‖²_F − Σ_i S²_{ii})
2352//   Var_Gau = 2‖S‖²_F
2353// where S = sym(H⁻¹ A_k).  The diagonal variance term is always removed.
2354//
2355// Key efficiency: ONE H⁻¹ solve per probe, shared across ALL k
2356// coordinates.  For each probe z we compute w = H⁻¹z once, then for each k
2357// we get q_k = zᵀ(A_k w) with a cheap matrix–vector multiply.