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 value-only HessianFactorization (logdet + solve, no traces)
579// ═══════════════════════════════════════════════════════════════════════════
580
581/// Dense Cholesky-backed [`HessianFactorization`] for `EvalMode::ValueOnly` paths.
582///
583/// When the penalized Hessian is known to be SPD (no Firth bias reduction, no
584/// hard linear constraints, no `HardPseudo` mode), the REML/LAML cost needs
585/// only two Hessian services:
586///
587/// - `logdet()` — used directly in the `½ log|H|` cost term.
588/// - `solve(rhs)` / `solve_multi(rhs)` — used for the optional IFT
589///   cost correction `−½ rᵀ H⁻¹ r`.
590///
591/// An LLT Cholesky factorization delivers both in `O(p³/3)` flops versus
592/// the `O(9·p³)` full eigendecomposition of [`DenseSpectralOperator`], giving
593/// a multi-× speedup per outer REML line-search probe.
594///
595/// Gradient traces (`trace_hinv_product`) are satisfied via column-by-column
596/// forward/back solves so that the operator remains valid if the evaluator
597/// ever reaches a gradient path unexpectedly. Under normal use
598/// `EvalMode::ValueOnly` returns before any trace call.
599pub struct DenseCholeskyValueOnlyOperator {
600    /// LLT Cholesky factor.
601    pub(crate) chol: gam_linalg::faer_ndarray::FaerCholeskyFactor,
602    /// `2 · Σ ln(diag L)` — cached at construction time.
603    pub(crate) cached_logdet: f64,
604    /// Full parameter dimension.
605    pub(crate) n_dim: usize,
606}
607
608impl DenseCholeskyValueOnlyOperator {
609    /// Factorize `h` (assumed SPD) via LLT and cache the log-determinant.
610    ///
611    /// Returns `Err` if `h` is not square, not SPD, contains non-finite
612    /// entries, **or** if its exact log-determinant would not agree with the
613    /// smooth-floored one every derivative lane prices (gam#2457, below).
614    /// Callers fall back to [`DenseSpectralOperator`] on failure, which is the
615    /// operator that owns the floored convention.
616    pub fn from_spd(h: &Array2<f64>) -> Result<Self, String> {
617        use faer::Side;
618        use gam_linalg::faer_ndarray::FaerCholesky;
619
620        let n = h.nrows();
621        if n != h.ncols() {
622            return Err(format!(
623                "DenseCholeskyValueOnlyOperator: expected square matrix, got {}×{}",
624                n,
625                h.ncols()
626            ));
627        }
628        let chol = h
629            .cholesky(Side::Lower)
630            .map_err(|e| format!("DenseCholeskyValueOnlyOperator LLT failed: {e}"))?;
631        let diag = chol.diag();
632        let cached_logdet = 2.0 * diag.iter().map(|&d| d.ln()).sum::<f64>();
633
634        // gam#2457 — THIS OPERATOR AND THE SPECTRAL ONE PRICE DIFFERENT SCALARS.
635        //
636        // The LLT returns the exact `Σ ln σ_j`.  Every derivative-bearing lane
637        // reaches [`DenseSpectralOperator`] instead, whose smooth floor makes
638        // its log-determinant `Σ ln r_ε(σ_j)` with
639        // `r_ε(σ) = ½(σ + √(σ² + 4ε²))` and `ε = spectral_epsilon` — and the
640        // analytic gradient `tr(G_ε Ḣ)` and its Hessian are the exact
641        // derivatives of THAT floored object.  So the floored log-determinant
642        // is the criterion, and this fast path is a legitimate shortcut only
643        // where the two coincide.  Where they do not, the outer objective
644        // returns one value to `OuterEvalOrder::Value` (line-search probes,
645        // the terminal value certificate) and another to `ValueAndGradient`
646        // (the trust-region model, the certificate's analytic sample) at the
647        // SAME ρ — measured at 663× the value-agreement envelope on
648        // `kappa_zero_fit_recovers_planted_flat_signal`, whose `H = XᵀWX + S_λ`
649        // carries an eigenvalue at ≈7ε once λ = e^−8.9 stops regularizing it.
650        //
651        // The gap is bounded without ever forming the spectrum.  For SPD `H`,
652        // `√(1 + 4t) ≤ 1 + 2t` gives `r_ε(σ)/σ ≤ 1 + ε²/σ²`, and `ln(1+t) ≤ t`,
653        // so
654        //
655        //     0 ≤ Σ_j ln(r_ε(σ_j)/σ_j) ≤ ε² · Σ_j σ_j⁻² = ε² · tr(H⁻²)
656        //
657        // and `tr(H⁻²) = ‖H⁻¹‖_F²` comes straight out of the factorization
658        // already in hand.  The bound is tight in the regime that matters (a
659        // single near-floor eigenvalue dominates both sides), so gating on it
660        // costs the speedup only where the floor genuinely bites.
661        //
662        // Admit the fast path exactly when that certified gap is inside the
663        // same relative envelope the outer audit applies to the scalar this
664        // log-determinant feeds — ONE predicate, named once, reused rather than
665        // re-derived.  The decline is one-sided: it can cost an LLT speedup, it
666        // can never admit a value the derivative lanes disagree with.  Both
667        // call sites already handle `Err` by building the spectral operator.
668        let epsilon = spectral_epsilon_for_dim(n);
669        let h_inverse = chol.solve_mat(&Array2::<f64>::eye(n));
670        let floor_gap_bound =
671            epsilon * epsilon * h_inverse.iter().map(|entry| entry * entry).sum::<f64>();
672        let agreement_envelope =
673            crate::rho_optimizer::outer_value_agreement_bound(cached_logdet, cached_logdet);
674        if !(floor_gap_bound <= agreement_envelope) {
675            return Err(format!(
676                "DenseCholeskyValueOnlyOperator declines a {n}-dimensional Hessian: its exact \
677                 log-determinant can differ from the smooth-floored log|H| the derivative lanes \
678                 price by up to {floor_gap_bound:.3e}, above the {agreement_envelope:.3e} \
679                 value-agreement envelope (spectral floor eps={epsilon:.3e})"
680            ));
681        }
682
683        Ok(Self {
684            chol,
685            cached_logdet,
686            n_dim: n,
687        })
688    }
689}
690
691impl HessianFactorization for DenseCholeskyValueOnlyOperator {
692    fn logdet(&self) -> f64 {
693        self.cached_logdet
694    }
695
696    fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
697        // tr(H⁻¹ A) = Σ_j [H⁻¹ A]_jj.
698        // Compute H⁻¹ A via multi-column solve and sum the diagonal.
699        let hinv_a = self.chol.solve_mat(a);
700        hinv_a.diag().iter().sum()
701    }
702
703    fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
704        self.chol.solvevec(rhs)
705    }
706
707    fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
708        self.chol.solve_mat(rhs)
709    }
710
711    fn active_rank(&self) -> usize {
712        // LLT succeeded ⟹ all pivots are positive ⟹ full rank.
713        self.n_dim
714    }
715
716    fn dim(&self) -> usize {
717        self.n_dim
718    }
719}
720
721// ═══════════════════════════════════════════════════════════════════════════
722//  Block-coupled HessianFactorization for joint multi-block models
723// ═══════════════════════════════════════════════════════════════════════════
724
725/// Block-coupled Hessian operator for joint multi-block models (GAMLSS, survival).
726///
727/// Wraps a [`DenseSpectralOperator`] over the full assembled joint Hessian while
728/// retaining block-structure metadata. All [`HessianFactorization`] trait methods
729/// delegate to the inner spectral decomposition, ensuring a single
730/// eigendecomposition governs logdet, trace, and solve.
731///
732/// # Block structure
733///
734/// A joint model with B parameter blocks has a joint Hessian of dimension
735/// `p_total = sum_b p_b`. Each block occupies rows/columns
736/// # When to use
737///
738/// Use `BlockCoupledOperator` whenever building an [`InnerSolution`] for a joint
739/// multi-block model. It replaces the pattern of constructing a raw
740/// `DenseSpectralOperator` and manually tracking block ranges separately.
741pub struct BlockCoupledOperator {
742    /// Inner spectral operator over the full joint Hessian.
743    pub(crate) inner: DenseSpectralOperator,
744}
745
746impl BlockCoupledOperator {
747    /// Construct from an assembled joint Hessian using the supplied
748    /// [`PseudoLogdetMode`].  Internally performs a single
749    /// eigendecomposition of `joint_hessian`.
750    pub fn from_joint_hessian_with_mode(
751        joint_hessian: &Array2<f64>,
752        mode: PseudoLogdetMode,
753    ) -> Result<Self, String> {
754        let inner = DenseSpectralOperator::from_symmetric_with_mode(joint_hessian, mode)
755            .map_err(|e| format!("BlockCoupledOperator eigendecomposition: {e}"))?;
756
757        Ok(Self { inner })
758    }
759}
760
761impl HessianFactorization for BlockCoupledOperator {
762    fn logdet(&self) -> f64 {
763        self.inner.logdet()
764    }
765
766    fn as_exact_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
767        self.inner.as_exact_dense_spectral()
768    }
769
770    fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
771        self.inner.assemble_h_dense_for_tangent_projection()
772    }
773
774    fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
775        self.inner.trace_hinv_product(a)
776    }
777
778    fn trace_logdet_gradient(&self, a: &Array2<f64>) -> f64 {
779        self.inner.trace_logdet_gradient(a)
780    }
781
782    fn xt_logdet_kernel_x_diagonal(&self, x: &DesignMatrix) -> Array1<f64> {
783        self.inner.xt_logdet_kernel_x_diagonal(x)
784    }
785
786    fn trace_logdet_h_k(
787        &self,
788        a_k: &Array2<f64>,
789        third_deriv_correction: Option<&Array2<f64>>,
790    ) -> f64 {
791        self.inner.trace_logdet_h_k(a_k, third_deriv_correction)
792    }
793
794    fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
795        self.inner.trace_logdet_operator(op)
796    }
797
798    fn trace_logdet_hessian_cross(&self, h_i: &Array2<f64>, h_j: &Array2<f64>) -> f64 {
799        self.inner.trace_logdet_hessian_cross(h_i, h_j)
800    }
801
802    fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
803        self.inner.solve(rhs)
804    }
805
806    fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
807        self.inner.solve_multi(rhs)
808    }
809
810    fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
811        self.inner.trace_hinv_product_cross(a, b)
812    }
813
814    fn trace_hinv_matrix_operator_cross(
815        &self,
816        matrix: &Array2<f64>,
817        op: &dyn HyperOperator,
818    ) -> f64 {
819        self.inner.trace_hinv_matrix_operator_cross(matrix, op)
820    }
821
822    fn trace_hinv_operator_cross(
823        &self,
824        left: &dyn HyperOperator,
825        right: &dyn HyperOperator,
826    ) -> f64 {
827        self.inner.trace_hinv_operator_cross(left, right)
828    }
829
830    fn active_rank(&self) -> usize {
831        self.inner.active_rank()
832    }
833
834    fn dim(&self) -> usize {
835        self.inner.dim()
836    }
837
838    fn is_dense(&self) -> bool {
839        true
840    }
841
842    fn prefers_stochastic_trace_estimation(&self) -> bool {
843        false
844    }
845
846    fn logdet_traces_match_hinv_kernel(&self) -> bool {
847        false
848    }
849
850    fn as_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
851        Some(&self.inner)
852    }
853}
854
855// ═══════════════════════════════════════════════════════════════════════════
856//  Matrix-free SPD HessianFactorization implementation
857// ═══════════════════════════════════════════════════════════════════════════
858
859/// Operator-backed SPD Hessian with exact spectral REML algebra.
860///
861/// The operator closure is still useful for construction paths that naturally
862/// expose HVPs, but REML cost/gradient/Hessian terms must all come from one
863/// exact decomposition so `∂ log|H| = tr(H⁻¹ ∂H)` holds.  We therefore
864/// materialize the coefficient Hessian by canonical-basis HVPs under an
865/// explicit memory cap and delegate logdet, traces, and solves to
866/// `DenseSpectralOperator`.
867pub struct MatrixFreeSpdOperator {
868    pub(crate) apply: Arc<dyn Fn(&Array1<f64>) -> Array1<f64> + Send + Sync>,
869    // Optional single-pass dense assembly of the SAME penalized operator that
870    // `apply` realizes matrix-free, i.e. `H_unpen + S_λ + scale·H_Φ`. When the
871    // operator source can structurally build its full dense matrix in one
872    // chunked BLAS-3 `XᵀWX` row pass (BMS's `hessian_dense_forced` +
873    // construction-site penalty/Jeffreys assembly), `materialize_dense_operator`
874    // calls THIS instead of `dim` canonical-basis matvecs — each of which is a
875    // full n-row pass through the matrix-free operator. One n-pass replaces
876    // `dim` n-passes for the LAML logdet factorization. The closure must return
877    // a matrix numerically identical (up to symmetrization) to the matvec
878    // reconstruction `H·I`; `None` means no direct build is available and the
879    // matvec path is used (the result is bit-for-bit the prior behavior).
880    pub(crate) dense_assemble: Option<Arc<dyn Fn() -> Option<Array2<f64>> + Send + Sync>>,
881    pub(crate) cached_logdet: gam_runtime::resource::RayonSafeOnce<f64>,
882    pub(crate) n_dim: usize,
883    // `RayonSafeOnce`, not `OnceLock`: `materialize_dense_operator` invokes
884    // `apply`, which for operator-source joint Hessians dispatches a nested
885    // `into_par_iter` (e.g. `exact_newton_joint_hessian_matvec_from_cache`).
886    // With a plain `OnceLock`, concurrent rayon workers entering
887    // `solve`/`logdet` from inside an outer par_iter would park on the
888    // OnceLock's OS condvar; the leader's nested par_iter would then starve
889    // for workers. `RayonSafeOnce` keeps init lock-free — racers may
890    // duplicate the dim²-matvec build, but the first to publish wins and
891    // steady-state matches `OnceLock`.
892    pub(crate) dense_spectral: gam_runtime::resource::RayonSafeOnce<Option<DenseSpectralOperator>>,
893    // Pseudo-logdet convention threaded from the family. The dense outer path
894    // already plumbs `PseudoLogdetMode` into `BlockCoupledOperator`; the
895    // matrix-free path materializes a `DenseSpectralOperator` lazily and must
896    // use the same convention so that `logdet`, `trace_hinv_product`, the
897    // IFT response `H⁻¹ g`, and every cross-trace agree with the dense path.
898    // Without this, families that declare `HardPseudo` (BMS, GAMLSS) silently
899    // get Smooth full-spectrum semantics on the matrix-free path, and outer
900    // gradients are inflated by `1/σ_j` over numerical null directions.
901    pub(crate) mode: PseudoLogdetMode,
902}
903
904impl MatrixFreeSpdOperator {
905    pub(crate) const EXACT_DENSE_SPECTRAL_MAX_BYTES: usize = 512 * 1024 * 1024;
906    pub(crate) const EXACT_DENSE_SPECTRAL_ARRAYS: usize = 6;
907
908    pub fn new_with_mode<F>(dim: usize, apply: F, mode: PseudoLogdetMode) -> Self
909    where
910        F: Fn(&Array1<f64>) -> Array1<f64> + Send + Sync + 'static,
911    {
912        Self::new_with_mode_and_dense_assemble(dim, apply, mode, None)
913    }
914
915    /// Like [`new_with_mode`], but additionally accepts an optional single-pass
916    /// dense assembly of the same penalized operator. When present and it yields
917    /// a matrix, `materialize_dense_operator` uses it instead of the `dim`
918    /// canonical-basis matvecs. See the field doc on `dense_assemble`.
919    pub fn new_with_mode_and_dense_assemble<F>(
920        dim: usize,
921        apply: F,
922        mode: PseudoLogdetMode,
923        dense_assemble: Option<Arc<dyn Fn() -> Option<Array2<f64>> + Send + Sync>>,
924    ) -> Self
925    where
926        F: Fn(&Array1<f64>) -> Array1<f64> + Send + Sync + 'static,
927    {
928        let apply = Arc::new(apply);
929
930        Self {
931            apply,
932            dense_assemble,
933            cached_logdet: gam_runtime::resource::RayonSafeOnce::new(),
934            n_dim: dim,
935            dense_spectral: gam_runtime::resource::RayonSafeOnce::new(),
936            mode,
937        }
938    }
939
940    pub(crate) fn exact_dense_spectral_bytes(&self) -> Option<usize> {
941        self.n_dim
942            .checked_mul(self.n_dim)?
943            .checked_mul(std::mem::size_of::<f64>())?
944            .checked_mul(Self::EXACT_DENSE_SPECTRAL_ARRAYS)
945    }
946
947    pub(crate) fn exact_dense_spectral_budget_ok(&self) -> bool {
948        match self.exact_dense_spectral_bytes() {
949            Some(bytes) if bytes <= Self::EXACT_DENSE_SPECTRAL_MAX_BYTES => true,
950            Some(bytes) => {
951                log::error!(
952                    "MatrixFreeSpdOperator exact dense spectral materialization requires {:.2} GiB \
953                     for dim={}, exceeding the {:.2} GiB cap",
954                    bytes as f64 / (1024.0 * 1024.0 * 1024.0),
955                    self.n_dim,
956                    Self::EXACT_DENSE_SPECTRAL_MAX_BYTES as f64 / (1024.0 * 1024.0 * 1024.0),
957                );
958                false
959            }
960            None => {
961                log::error!(
962                    "MatrixFreeSpdOperator exact dense spectral byte count overflow for dim={}",
963                    self.n_dim
964                );
965                false
966            }
967        }
968    }
969
970    pub(crate) fn materialize_dense_operator(&self) -> Option<DenseSpectralOperator> {
971        if !self.exact_dense_spectral_budget_ok() {
972            return None;
973        }
974        let materialize_start = std::time::Instant::now();
975        // Fast path: structural single-pass dense assembly of the SAME penalized
976        // operator (`H_unpen + S_λ + scale·H_Φ`). One chunked BLAS-3 `XᵀWX`
977        // row pass replaces `n_dim` canonical-basis matvecs, each a full n-row
978        // pass through the matrix-free operator. The matvec fallback below is the
979        // exact same algebra column-for-column, so the spectrum/logdet match.
980        let (matrix, matvec_count) =
981            match self.dense_assemble.as_ref().and_then(|assemble| assemble()) {
982                Some(mut direct)
983                    if direct.nrows() == self.n_dim
984                        && direct.ncols() == self.n_dim
985                        && direct.iter().all(|v| v.is_finite()) =>
986                {
987                    // Symmetrize defensively; the direct build is structurally
988                    // symmetric but reduction-order f.p. noise can desync mirror
989                    // entries, exactly as the matvec path symmetrizes below.
990                    for i in 0..self.n_dim {
991                        for j in (i + 1)..self.n_dim {
992                            let avg = 0.5 * (direct[[i, j]] + direct[[j, i]]);
993                            direct[[i, j]] = avg;
994                            direct[[j, i]] = avg;
995                        }
996                    }
997                    (direct, 0usize)
998                }
999                _ => {
1000                    let mut matrix = Array2::<f64>::zeros((self.n_dim, self.n_dim));
1001                    let mut basis = Array1::<f64>::zeros(self.n_dim);
1002                    for j in 0..self.n_dim {
1003                        basis[j] = 1.0;
1004                        let col = (self.apply)(&basis);
1005                        basis[j] = 0.0;
1006                        if col.len() != self.n_dim || !col.iter().all(|v| v.is_finite()) {
1007                            return None;
1008                        }
1009                        matrix.column_mut(j).assign(&col);
1010                    }
1011                    for i in 0..self.n_dim {
1012                        for j in (i + 1)..self.n_dim {
1013                            let avg = 0.5 * (matrix[[i, j]] + matrix[[j, i]]);
1014                            matrix[[i, j]] = avg;
1015                            matrix[[j, i]] = avg;
1016                        }
1017                    }
1018                    (matrix, self.n_dim)
1019                }
1020            };
1021        let result = DenseSpectralOperator::from_symmetric_with_mode(&matrix, self.mode).ok();
1022        log::info!(
1023            "[STAGE] matrix_free_spd materialize n_dim={} matvec_count={} elapsed={:.3}s",
1024            self.n_dim,
1025            matvec_count,
1026            materialize_start.elapsed().as_secs_f64(),
1027        );
1028        result
1029    }
1030
1031    pub(crate) fn dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1032        self.dense_spectral
1033            .get_or_compute(|| self.materialize_dense_operator())
1034            .as_ref()
1035    }
1036
1037    pub(crate) fn exact_dense_spectral(&self) -> &DenseSpectralOperator {
1038        self.dense_spectral().expect(
1039            "MatrixFreeSpdOperator exact REML algebra requires dense spectral materialization within the configured budget",
1040        )
1041    }
1042
1043    pub(crate) fn use_trace_cg(&self, rel_tol: f64) -> bool {
1044        rel_tol.is_finite()
1045            && rel_tol > 0.0
1046            && self.prefers_stochastic_trace_estimation()
1047            && self.has_matrix_free_trace_cg_operator()
1048    }
1049
1050    pub(crate) fn cg_trace_solve(
1051        &self,
1052        rhs: &Array1<f64>,
1053        rel_tol: f64,
1054        probe_id: Option<u64>,
1055        trace_state: Option<&Arc<Mutex<StochasticTraceState>>>,
1056    ) -> Array1<f64> {
1057        let dim = rhs.len();
1058        if dim != self.n_dim {
1059            return self.solve(rhs);
1060        }
1061
1062        let (initial, warm_start_used) = match (probe_id, trace_state) {
1063            (Some(id), Some(state)) => {
1064                let cached = match state.lock() {
1065                    Ok(guard) => guard.cg_warm_starts.get(&id).cloned(),
1066                    Err(poisoned) => poisoned.into_inner().cg_warm_starts.get(&id).cloned(),
1067                };
1068                match cached {
1069                    Some(x) if x.len() == dim => (x, true),
1070                    _ => (Array1::<f64>::zeros(dim), false),
1071                }
1072            }
1073            _ => (Array1::<f64>::zeros(dim), false),
1074        };
1075
1076        let Some((solution, iters, residual_norm)) =
1077            conjugate_gradient_trace_solve(rhs, rel_tol, initial, |v| (self.apply)(v))
1078        else {
1079            return self.solve(rhs);
1080        };
1081
1082        if let Some(state) = trace_state {
1083            let mut guard = match state.lock() {
1084                Ok(guard) => guard,
1085                Err(poisoned) => poisoned.into_inner(),
1086            };
1087            guard.last_linear_residual_norm = Some(
1088                guard
1089                    .last_linear_residual_norm
1090                    .unwrap_or(0.0)
1091                    .max(residual_norm),
1092            );
1093            if let Some(id) = probe_id {
1094                guard.cg_warm_starts.insert(id, solution.clone());
1095            }
1096        }
1097
1098        let probe_label = probe_id
1099            .map(|id| id.to_string())
1100            .unwrap_or_else(|| "untracked".to_string());
1101        log::info!(
1102            "[CG-TRACE] probe_id={} iters={} rel_tol={} warm_start_used={}",
1103            probe_label,
1104            iters,
1105            rel_tol,
1106            warm_start_used
1107        );
1108
1109        solution
1110    }
1111}
1112
1113pub(crate) fn conjugate_gradient_trace_solve<F>(
1114    rhs: &Array1<f64>,
1115    rel_tol: f64,
1116    mut x: Array1<f64>,
1117    apply: F,
1118) -> Option<(Array1<f64>, usize, f64)>
1119where
1120    F: Fn(&Array1<f64>) -> Array1<f64>,
1121{
1122    let dim = rhs.len();
1123    if x.len() != dim {
1124        return None;
1125    }
1126
1127    let rhs_norm_sq = rhs.dot(rhs);
1128    if !rhs_norm_sq.is_finite() {
1129        return None;
1130    }
1131    if rhs_norm_sq <= f64::MIN_POSITIVE {
1132        return Some((Array1::<f64>::zeros(dim), 0, 0.0));
1133    }
1134
1135    let target_sq = (rel_tol * rel_tol * rhs_norm_sq).max(f64::MIN_POSITIVE);
1136    let mut r = rhs.clone();
1137    if x.iter().any(|value| *value != 0.0) {
1138        let ax = apply(&x);
1139        if ax.len() != dim || !ax.iter().all(|value| value.is_finite()) {
1140            return None;
1141        }
1142        r.scaled_add(-1.0, &ax);
1143    }
1144
1145    let mut rs_old = r.dot(&r);
1146    if !rs_old.is_finite() {
1147        return None;
1148    }
1149    if rs_old <= target_sq {
1150        return Some((x, 0, rs_old.max(0.0).sqrt()));
1151    }
1152
1153    let mut p = r.clone();
1154    let mut iters = 0usize;
1155    let mut residual_norm = rs_old.max(0.0).sqrt();
1156    for k in 0..dim.max(1) {
1157        let ap = apply(&p);
1158        if ap.len() != dim || !ap.iter().all(|value| value.is_finite()) {
1159            return None;
1160        }
1161        let denom = p.dot(&ap);
1162        if !denom.is_finite() || denom <= 0.0 {
1163            log::warn!(
1164                "[CG-TRACE] non-positive curvature in trace CG at iter={} denom={}",
1165                k + 1,
1166                denom
1167            );
1168            break;
1169        }
1170        let alpha = rs_old / denom;
1171        if !alpha.is_finite() {
1172            return None;
1173        }
1174        x.scaled_add(alpha, &p);
1175        r.scaled_add(-alpha, &ap);
1176        let rs_new = r.dot(&r);
1177        if !rs_new.is_finite() {
1178            return None;
1179        }
1180        iters = k + 1;
1181        residual_norm = rs_new.max(0.0).sqrt();
1182        if rs_new <= target_sq {
1183            break;
1184        }
1185        let beta = rs_new / rs_old;
1186        if !beta.is_finite() {
1187            return None;
1188        }
1189        p.mapv_inplace(|value| beta * value);
1190        p += &r;
1191        rs_old = rs_new;
1192    }
1193
1194    Some((x, iters, residual_norm))
1195}
1196
1197impl HessianFactorization for MatrixFreeSpdOperator {
1198    fn logdet(&self) -> f64 {
1199        *self
1200            .cached_logdet
1201            .get_or_compute(|| self.exact_dense_spectral().logdet())
1202    }
1203
1204    fn as_exact_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1205        Some(self.exact_dense_spectral())
1206    }
1207
1208    fn trace_hinv_product(&self, a: &Array2<f64>) -> f64 {
1209        self.exact_dense_spectral().trace_hinv_product(a)
1210    }
1211
1212    fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
1213        self.exact_dense_spectral().trace_hinv_operator(op)
1214    }
1215
1216    fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
1217        self.exact_dense_spectral().trace_hinv_product_cross(a, b)
1218    }
1219
1220    fn trace_hinv_matrix_operator_cross(
1221        &self,
1222        matrix: &Array2<f64>,
1223        op: &dyn HyperOperator,
1224    ) -> f64 {
1225        self.exact_dense_spectral()
1226            .trace_hinv_matrix_operator_cross(matrix, op)
1227    }
1228
1229    fn trace_hinv_operator_cross(
1230        &self,
1231        left: &dyn HyperOperator,
1232        right: &dyn HyperOperator,
1233    ) -> f64 {
1234        self.exact_dense_spectral()
1235            .trace_hinv_operator_cross(left, right)
1236    }
1237
1238    fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
1239        let trace_start = std::time::Instant::now();
1240        let result = self.exact_dense_spectral().trace_logdet_operator(op);
1241        log::info!(
1242            "[STAGE] matrix_free_spd trace_logdet_operator implicit={} dim={} elapsed={:.3}s",
1243            op.is_implicit(),
1244            op.dim(),
1245            trace_start.elapsed().as_secs_f64(),
1246        );
1247        result
1248    }
1249
1250    fn solve(&self, rhs: &Array1<f64>) -> Array1<f64> {
1251        self.exact_dense_spectral().solve(rhs)
1252    }
1253
1254    fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64> {
1255        self.exact_dense_spectral().solve_multi(rhs)
1256    }
1257
1258    fn stochastic_trace_solve(&self, rhs: &Array1<f64>, rel_tol: f64) -> Array1<f64> {
1259        if self.use_trace_cg(rel_tol) {
1260            return self.cg_trace_solve(rhs, rel_tol, None, None);
1261        }
1262        self.solve(rhs)
1263    }
1264
1265    fn stochastic_trace_solve_for_probe(
1266        &self,
1267        rhs: &Array1<f64>,
1268        rel_tol: f64,
1269        probe_id: u64,
1270        trace_state: Option<&Arc<Mutex<StochasticTraceState>>>,
1271    ) -> Array1<f64> {
1272        if self.use_trace_cg(rel_tol) {
1273            return self.cg_trace_solve(rhs, rel_tol, Some(probe_id), trace_state);
1274        }
1275        self.solve(rhs)
1276    }
1277
1278    fn stochastic_trace_solve_multi(&self, rhs: &Array2<f64>, rel_tol: f64) -> Array2<f64> {
1279        if self.use_trace_cg(rel_tol) {
1280            let mut out = Array2::<f64>::zeros(rhs.raw_dim());
1281            for j in 0..rhs.ncols() {
1282                let solved = self.cg_trace_solve(&rhs.column(j).to_owned(), rel_tol, None, None);
1283                out.column_mut(j).assign(&solved);
1284            }
1285            return out;
1286        }
1287        self.solve_multi(rhs)
1288    }
1289
1290    fn trace_logdet_hessian_cross(&self, h_i: &Array2<f64>, h_j: &Array2<f64>) -> f64 {
1291        self.exact_dense_spectral()
1292            .trace_logdet_hessian_cross(h_i, h_j)
1293    }
1294
1295    fn trace_logdet_hessian_cross_matrix_operator(
1296        &self,
1297        h_i: &Array2<f64>,
1298        h_j: &dyn HyperOperator,
1299    ) -> f64 {
1300        self.exact_dense_spectral()
1301            .trace_logdet_hessian_cross_matrix_operator(h_i, h_j)
1302    }
1303
1304    fn trace_logdet_hessian_cross_operator(
1305        &self,
1306        h_i: &dyn HyperOperator,
1307        h_j: &dyn HyperOperator,
1308    ) -> f64 {
1309        self.exact_dense_spectral()
1310            .trace_logdet_hessian_cross_operator(h_i, h_j)
1311    }
1312
1313    fn active_rank(&self) -> usize {
1314        self.n_dim
1315    }
1316
1317    fn dim(&self) -> usize {
1318        self.n_dim
1319    }
1320
1321    fn is_dense(&self) -> bool {
1322        true
1323    }
1324
1325    /// The operator delegates `logdet`, `trace_hinv_*`, `trace_logdet_*`,
1326    /// `solve`, and `solve_multi` to a lazily-built `DenseSpectralOperator`
1327    /// whenever the exact-dense materialization fits the configured byte cap
1328    /// (see `exact_dense_spectral_budget_ok` / `EXACT_DENSE_SPECTRAL_MAX_BYTES`).
1329    /// In that regime the algebra is exact spectral — there is no stochastic
1330    /// preference to advertise, and forcing the caller to take the Hutchinson
1331    /// path would replace an O(p²) exact reduction with O(k·apply) noisy probes.
1332    ///
1333    /// When the budget is exceeded the dense factor cannot be built and the
1334    /// CG trace-solve path added in 2bd6af68 is the only feasible route; the
1335    /// flag flips to `true` so `stochastic_trace_solve*` callers route through
1336    /// `cg_trace_solve` instead of crashing in `exact_dense_spectral().expect`.
1337    fn prefers_stochastic_trace_estimation(&self) -> bool {
1338        !self.exact_dense_spectral_budget_ok()
1339    }
1340
1341    /// Mirror the `prefers_stochastic_trace_estimation` gate: when the dense
1342    /// factor is reachable the operator's logdet / trace_hinv reductions all
1343    /// resolve through `DenseSpectralOperator`, whose
1344    /// `logdet_traces_match_hinv_kernel` is `false` for the smooth-spectral
1345    /// regularization variants we run. Reporting `true` here would let the
1346    /// outer evaluator route logdet-gradient/Hessian traces through the
1347    /// Hutchinson `H⁻¹` kernel which does not satisfy
1348    /// `∂ log|H| = tr(H⁻¹ ∂H)` under smooth-spectral. The CG-only regime
1349    /// (budget exceeded) lacks a dense reference so falling back to the
1350    /// stochastic kernel is acceptable as a best-effort estimate.
1351    fn logdet_traces_match_hinv_kernel(&self) -> bool {
1352        !self.exact_dense_spectral_budget_ok()
1353    }
1354
1355    fn as_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
1356        self.dense_spectral()
1357    }
1358
1359    fn has_matrix_free_trace_cg_operator(&self) -> bool {
1360        true
1361    }
1362}
1363
1364// ═══════════════════════════════════════════════════════════════════════════
1365//  Helpers for custom family → InnerSolution conversion
1366// ═══════════════════════════════════════════════════════════════════════════
1367
1368/// Compute the square root of a symmetric positive semidefinite penalty matrix.
1369///
1370/// Returns R such that S = RᵀR, with R having `rank(S)` rows.
1371/// Uses eigendecomposition: S = U Λ U^T → R = Λ_+^{1/2} U_+^T.
1372pub fn penalty_matrix_root(s: &Array2<f64>) -> Result<Array2<f64>, String> {
1373    use faer::Side;
1374    let n = s.nrows();
1375    if n != s.ncols() {
1376        return Err(RemlError::DimensionMismatch {
1377            reason: format!(
1378                "penalty_matrix_root: expected square matrix, got {}×{}",
1379                n,
1380                s.ncols()
1381            ),
1382        }
1383        .into());
1384    }
1385    if n == 0 {
1386        return Ok(Array2::zeros((0, 0)));
1387    }
1388
1389    let (eigenvalues, eigenvectors) = s
1390        .eigh(Side::Lower)
1391        .map_err(|e| format!("penalty_matrix_root eigendecomposition failed: {e}"))?;
1392
1393    let max_ev = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
1394    let tol = (n.max(1) as f64) * f64::EPSILON * max_ev.max(1e-12);
1395
1396    let active: Vec<usize> = eigenvalues
1397        .iter()
1398        .enumerate()
1399        .filter(|(_, v)| **v > tol)
1400        .map(|(i, _)| i)
1401        .collect();
1402    let rank = active.len();
1403
1404    let mut r = Array2::zeros((rank, n));
1405    for (out_row, &idx) in active.iter().enumerate() {
1406        let scale = eigenvalues[idx].sqrt();
1407        for col in 0..n {
1408            r[[out_row, col]] = scale * eigenvectors[[col, idx]];
1409        }
1410    }
1411    Ok(r)
1412}
1413
1414/// Compute the exact pseudo-logdet log|S|₊ and its ρ-derivatives for a
1415/// blockwise penalty structure.
1416///
1417/// For each block, eigendecomposes S_b = Σ λ_k S_k, identifies the positive
1418/// eigenspace (structural nullspace detected from the eigenspectrum), and
1419/// computes exact derivatives on that subspace:
1420///
1421/// - L(S) = Σ_{σ_i > ε} log σ_i
1422/// - ∂/∂ρₖ L = tr(S⁺ Aₖ)
1423/// - ∂²/(∂ρₖ∂ρₗ) L = δ_{kl} ∂_k L − tr(S⁺ Aₗ S⁺ Aₖ)
1424///
1425/// For S(ρ) = Σ exp(ρ_k) S_k with S_k ⪰ 0, the nullspace N(S) = ∩_k N(S_k)
1426/// is structurally fixed (independent of ρ), so L is C∞ in ρ and these are
1427/// its exact derivatives.
1428///
1429/// `per_block_rho[b]` contains the log-lambdas for block b.
1430/// `per_block_penalties[b]` contains the penalty matrices for block b.
1431/// `ridge` is an additional ridge for logdet stability (0 if not applicable).
1432pub fn compute_block_penalty_logdet_derivs(
1433    per_block_rho: &[Array1<f64>],
1434    per_block_penalties: &[&[Array2<f64>]],
1435    ridge: f64,
1436) -> Result<PenaltyLogdetDerivs, String> {
1437    compute_block_penalty_logdet_derivs_with_prior_factors(
1438        per_block_rho,
1439        per_block_penalties,
1440        None,
1441        ridge,
1442    )
1443}
1444
1445/// [`compute_block_penalty_logdet_derivs`] with per-penalty prior-factor
1446/// structure.
1447///
1448/// `prior_factor_mask[b][k] == true` declares block `b`'s penalty `k` an
1449/// INDEPENDENT Gaussian prior factor rather than an additive piece of one
1450/// smooth prior. The evidence normalizer of one Gaussian with precision
1451/// `Σ_k λ_k S_k` is the coalesced `log|Σ_k λ_k S_k|₊` (the default, and the
1452/// correct convention for multi-penalty smooths), but a PRODUCT of
1453/// independent factors `∏_k N(0, (λ_k S_k)⁻¹)` contributes
1454///
1455/// ```text
1456/// Σ_k log|λ_k S_k|₊ = Σ_k ( rank(S_k)·ρ_k + log|S_k|₊ ),
1457/// ```
1458///
1459/// which differs from the coalesced form exactly when factors overlap: two
1460/// factors with precision λ on one scalar coefficient carry
1461/// `λ^{1/2}·λ^{1/2} = λ`, while coalescing their quadratics into `2λβ²` and
1462/// taking one normalizer yields `(2λ)^{1/2}` — losing `½ log λ` from the
1463/// outer ρ-posterior (hierarchical coefficient groups, audit finding 40).
1464/// Each masked penalty therefore becomes its own singleton pseudo-logdet
1465/// block; unmasked penalties within the block coalesce as before. `None`
1466/// masks (or an all-false mask) reproduce the coalesced behaviour exactly.
1467pub fn compute_block_penalty_logdet_derivs_with_prior_factors(
1468    per_block_rho: &[Array1<f64>],
1469    per_block_penalties: &[&[Array2<f64>]],
1470    prior_factor_mask: Option<&[Vec<bool>]>,
1471    ridge: f64,
1472) -> Result<PenaltyLogdetDerivs, String> {
1473    use super::super::penalty_logdet::PenaltyPseudologdet;
1474
1475    let total_k: usize = per_block_rho.iter().map(|r| r.len()).sum();
1476    let block_offsets: Vec<usize> = per_block_rho
1477        .iter()
1478        .scan(0usize, |at, rho| {
1479            let current = *at;
1480            *at += rho.len();
1481            Some(current)
1482        })
1483        .collect();
1484
1485    struct BlockPenaltyLogdetResult {
1486        pub(crate) offset: usize,
1487        pub(crate) value: f64,
1488        pub(crate) first: Array1<f64>,
1489        pub(crate) second: Array2<f64>,
1490    }
1491
1492    let compute_block = |(b, block_rho): (usize, &Array1<f64>)| {
1493        let penalties = per_block_penalties[b];
1494        let kb = block_rho.len();
1495        if penalties.is_empty() || kb == 0 {
1496            return Ok(BlockPenaltyLogdetResult {
1497                offset: block_offsets[b],
1498                value: 0.0,
1499                first: Array1::zeros(kb),
1500                second: Array2::zeros((kb, kb)),
1501            });
1502        }
1503        let lambdas = gam_problem::checked_exp_log_strengths(block_rho.iter().copied())
1504            .map_err(|error| format!("penalty-logdet block {b}: {error}"))?;
1505        let mask = prior_factor_mask.map(|m| m[b].as_slice());
1506        let factor_indices: Vec<usize> = (0..kb)
1507            .filter(|&k| mask.is_some_and(|m| m.get(k).copied().unwrap_or(false)))
1508            .collect();
1509
1510        if factor_indices.is_empty() {
1511            // Single eigendecomposition via canonical PenaltyPseudologdet.
1512            //
1513            // No metadata-based structural-nullity hint: the classifier derives
1514            // the positive eigenspace from the assembled spectrum alone (issues
1515            // #192/#318).
1516            let pld = PenaltyPseudologdet::from_components(penalties, &lambdas, ridge)
1517                .map_err(|e| format!("penalty logdet failed for block {b}: {e}"))?;
1518
1519            let value = pld.value();
1520            let (first, second) = pld.rho_derivatives(penalties, &lambdas);
1521            return Ok(BlockPenaltyLogdetResult {
1522                offset: block_offsets[b],
1523                value,
1524                first,
1525                second,
1526            });
1527        }
1528
1529        // Independent-factor structure: the normalizer factorizes over the
1530        // coalesced smooth part and each factor's own singleton logdet, so
1531        // value/first/second are assembled block-diagonally in ρ-coordinate
1532        // space (no cross terms between factors, exactly as the product
1533        // prior dictates).
1534        let mut value = 0.0;
1535        let mut first = Array1::<f64>::zeros(kb);
1536        let mut second = Array2::<f64>::zeros((kb, kb));
1537
1538        let coalesced_indices: Vec<usize> =
1539            (0..kb).filter(|k| !factor_indices.contains(k)).collect();
1540        if !coalesced_indices.is_empty() {
1541            let sub_pens: Vec<Array2<f64>> = coalesced_indices
1542                .iter()
1543                .map(|&k| penalties[k].clone())
1544                .collect();
1545            let sub_lambdas: Vec<f64> = coalesced_indices.iter().map(|&k| lambdas[k]).collect();
1546            let pld = PenaltyPseudologdet::from_components(&sub_pens, &sub_lambdas, ridge)
1547                .map_err(|e| format!("penalty logdet failed for block {b}: {e}"))?;
1548            value += pld.value();
1549            let (sub_first, sub_second) = pld.rho_derivatives(&sub_pens, &sub_lambdas);
1550            for (i, &k) in coalesced_indices.iter().enumerate() {
1551                first[k] = sub_first[i];
1552                for (j, &l) in coalesced_indices.iter().enumerate() {
1553                    second[[k, l]] = sub_second[[i, j]];
1554                }
1555            }
1556        }
1557        for &k in &factor_indices {
1558            let factor_pen = std::slice::from_ref(&penalties[k]);
1559            let factor_lambda = [lambdas[k]];
1560            let pld = PenaltyPseudologdet::from_components(factor_pen, &factor_lambda, ridge)
1561                .map_err(|e| {
1562                    format!("penalty logdet failed for block {b} prior factor {k}: {e}")
1563                })?;
1564            // log|λ_k S_k|₊ = rank·ρ_k + log|S_k|₊: first derivative is the
1565            // factor rank, second derivative vanishes — both come out of the
1566            // same exact singleton kernel, no special-casing.
1567            value += pld.value();
1568            let (sub_first, sub_second) = pld.rho_derivatives(factor_pen, &factor_lambda);
1569            first[k] = sub_first[0];
1570            second[[k, k]] = sub_second[[0, 0]];
1571        }
1572        Ok(BlockPenaltyLogdetResult {
1573            offset: block_offsets[b],
1574            value,
1575            first,
1576            second,
1577        })
1578    };
1579
1580    let block_results: Vec<BlockPenaltyLogdetResult> = if rayon::current_thread_index().is_some() {
1581        per_block_rho
1582            .iter()
1583            .enumerate()
1584            .map(compute_block)
1585            .collect::<Result<Vec<_>, String>>()?
1586    } else {
1587        per_block_rho
1588            .par_iter()
1589            .enumerate()
1590            .map(compute_block)
1591            .collect::<Result<Vec<_>, String>>()?
1592    };
1593
1594    let mut log_det_total = 0.0;
1595    let mut first = Array1::zeros(total_k);
1596    let mut second = Array2::zeros((total_k, total_k));
1597    for block in block_results {
1598        log_det_total += block.value;
1599        let kb = block.first.len();
1600        for k in 0..kb {
1601            first[block.offset + k] = block.first[k];
1602        }
1603        for k in 0..kb {
1604            for l in 0..kb {
1605                second[[block.offset + k, block.offset + l]] = block.second[[k, l]];
1606            }
1607        }
1608    }
1609
1610    Ok(PenaltyLogdetDerivs {
1611        value: log_det_total,
1612        first,
1613        second: Some(second),
1614    })
1615}
1616
1617// ═══════════════════════════════════════════════════════════════════════════
1618//  Stochastic trace estimation via Rademacher probes
1619// ═══════════════════════════════════════════════════════════════════════════
1620//
1621// For large-scale models, computing tr(H⁻¹ A_k) exactly via the full p×p
1622// eigendecomposition or column-by-column sparse solves costs O(p²) per
1623// coordinate k.  Stochastic trace estimation gives an unbiased estimate
1624// using only matrix–vector products (solves), at cost O(M·p) where M is the
1625// number of random probe vectors (typically 10–200).
1626//
1627// The Girard–Hutchinson estimator:
1628//
1629//   tr(H⁻¹ A_k) ≈ (1/M) Σ_m  z_mᵀ H⁻¹ A_k z_m
1630//
1631// where z_m are i.i.d. random vectors with E[zzᵀ] = I.
1632//
1633// Rademacher probes (entries ±1 with equal probability) have strictly
1634// lower variance than Gaussian probes:
1635//   Var_Rad = 2(‖S‖²_F − Σ_i S²_{ii})
1636//   Var_Gau = 2‖S‖²_F
1637// where S = sym(H⁻¹ A_k).  The diagonal variance term is always removed.
1638//
1639// Key efficiency: ONE H⁻¹ solve per probe, shared across ALL k
1640// coordinates.  For each probe z we compute w = H⁻¹z once, then for each k
1641// we get q_k = zᵀ(A_k w) with a cheap matrix–vector multiply.