Skip to main content

gam_solve/reml/reml_outer_engine/
hessian_operator_trait.rs

1use super::*;
2
3// ═══════════════════════════════════════════════════════════════════════════
4//  Core traits
5// ═══════════════════════════════════════════════════════════════════════════
6
7/// Fit-level stochastic trace state shared by all adaptive Hutchinson batches.
8///
9/// `monotone_probe_floor` pins the CRN prefix length across batches. The
10/// `cg_warm_starts` map stores the previous H⁻¹ solve for the same deterministic
11/// probe id so the next outer evaluation can initialize matrix-free trace CG
12/// from the matching probe only.
13#[derive(Debug, Default)]
14pub struct StochasticTraceState {
15    pub monotone_probe_floor: usize,
16    pub cg_warm_starts: HashMap<u64, Array1<f64>>,
17    pub solve_rel_tol_override: Option<f64>,
18    pub last_linear_residual_norm: Option<f64>,
19    pub last_probe_sigma_sq: Option<f64>,
20    pub last_probe_count: usize,
21}
22
23/// Abstract interface for Hessian linear algebra operations.
24///
25/// All operations use the SAME internal decomposition, ensuring spectral
26/// consistency between logdet (used in cost) and trace/solve (used in gradient).
27///
28/// Implementors:
29/// - `DenseSpectralOperator`: eigendecomposition of dense H
30/// - `DenseCholeskyOperator`: exact LLT of dense positive-definite H
31/// - Sparse Cholesky operators (external implementations)
32/// - `BlockCoupledOperator`: one LLT or spectral factorization of joint H
33/// Minimum operator dimension at which the Hutch++ stochastic trace estimator is
34/// preferred over materializing an implicit operator densely. Below this, the
35/// `2·m_s + m_h` Hutch++ matvecs do not beat `dim` dense H⁻¹ HVPs, so the dense
36/// fallback is cheaper.
37pub(crate) const HUTCHPP_TRACE_MIN_DIM: usize = 128;
38
39/// Build the Hutch++ stochastic-trace configuration for an operator of the given
40/// dimension. The sketch dimension grows with `dim` (one column per 32 of
41/// dimension, bounded to `[4, 16]`), and the probe budget tracks the sketch so
42/// the estimator's variance and cost stay balanced across problem sizes. Shared
43/// by every implicit-operator trace path so they cannot drift apart.
44pub(crate) fn hutchpp_config_for_dim(dim: usize) -> StochasticTraceConfig {
45    const SKETCH_DIM_PER: usize = 32;
46    const SKETCH_DIM_MIN: usize = 4;
47    const SKETCH_DIM_MAX: usize = 16;
48    const PROBES_PER_SKETCH: usize = 4;
49    const PROBES_MAX_FLOOR: usize = 32;
50    const PROBES_MIN_FLOOR: usize = 8;
51    let sketch = (dim / SKETCH_DIM_PER).clamp(SKETCH_DIM_MIN, SKETCH_DIM_MAX);
52    let mut config = StochasticTraceConfig::default();
53    config.hutchpp_sketch_dim = Some(sketch);
54    config.n_probes_max = (sketch * PROBES_PER_SKETCH).max(PROBES_MAX_FLOOR);
55    config.n_probes_min = sketch.max(PROBES_MIN_FLOOR);
56    config
57}
58
59pub trait HessianFactorization: Send + Sync {
60    /// log|H|₊ — pseudo-logdet using only active eigenvalues/pivots.
61    fn logdet(&self) -> f64;
62
63    /// tr(H₊⁻¹ A) — trace of pseudo-inverse times a symmetric matrix.
64    /// Uses the SAME decomposition as `logdet`.
65    fn trace_hinv_product(&self, a: &Array2<f64>) -> f64;
66
67    /// Exact dense spectral representation, when this backend has one.
68    ///
69    /// Outer-Hessian assembly uses this to batch all logdet-Hessian cross
70    /// traces in the eigenbasis. For CTN scale-dimension fits this avoids
71    /// projecting the same implicit ψ drift once per upper-triangular pair.
72    fn as_exact_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
73        None
74    }
75
76    /// Assemble the raw dense Hessian represented by this backend for
77    /// active-constraint tangent projection.
78    ///
79    /// Backends that do not store either a dense spectral decomposition or an
80    /// explicit factorization should keep the default error.
81    fn assemble_h_dense_for_tangent_projection(&self) -> Result<Array2<f64>, String> {
82        Err("backend does not support tangent projection".to_string())
83    }
84
85    /// tr(H₊⁻¹ B) for an operator-backed Hessian drift.
86    ///
87    /// Default implementation materializes `B` densely. Backends with
88    /// native operator traces (notably sparse Cholesky) should override it.
89    ///
90    /// For HVP-only (implicit) operators on large problems we route
91    /// through Hutch++ — the Meyer–Musco split estimator achieves O(1/ε)
92    /// matvecs vs O(1/ε²) for plain Hutchinson, and avoids the O(p²)
93    /// memory + O(p) HVP cost of materializing the operator densely.
94    fn trace_hinv_operator(&self, op: &dyn HyperOperator) -> f64 {
95        // Hutch++ fast path for the warn-and-materialize default. Only
96        // backends that fall through to this default reach here;
97        // backends with native operator traces override it. We require
98        // an implicit operator (so materialization is expensive) and a
99        // moderately-large dim (so 2 m_s + m_h matvecs beats `dim`
100        // dense HVPs).
101        if op.is_implicit() && self.dim() >= HUTCHPP_TRACE_MIN_DIM {
102            let config = hutchpp_config_for_dim(self.dim());
103            return hutchpp_estimate_trace_hinv_operator(self, op, &config);
104        }
105        if op.is_implicit() {
106            log::warn!(
107                "trace_hinv_operator: materializing implicit HyperOperator — \
108                 backend should provide a matrix-free override"
109            );
110        }
111        self.trace_hinv_product(&op.to_dense())
112    }
113
114    /// H⁻¹ v — linear solve using the active decomposition.
115    fn solve(&self, rhs: &Array1<f64>) -> Array1<f64>;
116
117    /// H⁻¹ M — multi-column solve.
118    fn solve_multi(&self, rhs: &Array2<f64>) -> Array2<f64>;
119
120    /// H⁻¹ v for stochastic trace probes.
121    ///
122    /// Exact backends use the normal solve. Matrix-free backends may override
123    /// this to use a looser PCG tolerance when the caller's Monte Carlo error
124    /// dominates the linear-solve error.
125    fn stochastic_trace_solve(&self, rhs: &Array1<f64>, rel_tol: f64) -> Array1<f64> {
126        assert!(
127            rel_tol.is_finite() && rel_tol > 0.0,
128            "stochastic trace solve tolerance must be positive and finite"
129        );
130        self.solve(rhs)
131    }
132
133    /// H⁻¹ v for a deterministic stochastic trace probe id.
134    ///
135    /// Backends with matrix-free CG may use `probe_id` to warm-start from the
136    /// previous solve of the same CRN probe. The default exact backend ignores
137    /// the id and uses the normal stochastic trace solve.
138    fn stochastic_trace_solve_for_probe(
139        &self,
140        rhs: &Array1<f64>,
141        rel_tol: f64,
142        probe_id: u64,
143        state: Option<&Arc<Mutex<StochasticTraceState>>>,
144    ) -> Array1<f64> {
145        // Default exact backend has no matrix-free CG, so per-probe warm
146        // starts are inapplicable. If a previous matrix-free backend left
147        // a warm-start vector for this `probe_id` in the shared state,
148        // drop it so a later matrix-free run does not consume a vector
149        // that was generated against a different operator factorization.
150        if let Some(state_arc) = state
151            && let Ok(mut guard) = state_arc.lock()
152        {
153            guard.cg_warm_starts.remove(&probe_id);
154        }
155        self.stochastic_trace_solve(rhs, rel_tol)
156    }
157
158    /// H⁻¹ M for stochastic trace probes.
159    fn stochastic_trace_solve_multi(&self, rhs: &Array2<f64>, rel_tol: f64) -> Array2<f64> {
160        assert!(
161            rel_tol.is_finite() && rel_tol > 0.0,
162            "stochastic trace multi-solve tolerance must be positive and finite"
163        );
164        self.solve_multi(rhs)
165    }
166
167    /// Whether this backend exposes a matrix-free operator usable by trace CG.
168    fn has_matrix_free_trace_cg_operator(&self) -> bool {
169        false
170    }
171
172    /// tr(H⁻¹ A H⁻¹ B) for dense symmetric Hessian drifts.
173    ///
174    /// This is the second-order trace object used by EFS denominators and the
175    /// ψ-block trace Gram preconditioner. The default implementation computes
176    /// both solved column stacks exactly and contracts them as
177    /// `tr((H⁻¹A)(H⁻¹B))`.
178    fn trace_hinv_product_cross(&self, a: &Array2<f64>, b: &Array2<f64>) -> f64 {
179        let solved_a = self.solve_multi(a);
180        if std::ptr::eq(a, b) {
181            return dense::trace_product(&solved_a, &solved_a);
182        }
183        let solved_b = self.solve_multi(b);
184        dense::trace_product(&solved_a, &solved_b)
185    }
186
187    /// tr(H⁻¹ A H⁻¹ B) for a dense drift `A` and an operator-backed drift `B`.
188    ///
189    /// Default implementation materializes the operator and dispatches to the
190    /// dense cross-trace path. Matrix-free and sparse backends should override
191    /// this to avoid dense operator materialization.
192    fn trace_hinv_matrix_operator_cross(
193        &self,
194        matrix: &Array2<f64>,
195        op: &dyn HyperOperator,
196    ) -> f64 {
197        if op.is_implicit() && self.dim() >= HUTCHPP_TRACE_MIN_DIM {
198            let config = hutchpp_config_for_dim(self.dim());
199            // Wrap the dense LHS in a matrix-backed HyperOperator so the
200            // shared cross routine can call mul_vec_into on it.
201            let lhs = DenseMatrixHyperOperator {
202                matrix: matrix.clone(),
203            };
204            return hutchpp_estimate_trace_hinv_operator_cross(self, &lhs, op, &config);
205        }
206        if op.is_implicit() {
207            log::warn!(
208                "trace_hinv_matrix_operator_cross: materializing implicit HyperOperator — \
209                 backend should provide a matrix-free override"
210            );
211        }
212        self.trace_hinv_product_cross(matrix, &op.to_dense())
213    }
214
215    /// tr(H⁻¹ A H⁻¹ B) for operator-backed Hessian drifts.
216    ///
217    /// Default implementation materializes both operators densely. Backends
218    /// with native operator-aware cross traces should override this.
219    fn trace_hinv_operator_cross(
220        &self,
221        left: &dyn HyperOperator,
222        right: &dyn HyperOperator,
223    ) -> f64 {
224        let l_implicit = left.is_implicit();
225        let r_implicit = right.is_implicit();
226        if (l_implicit || r_implicit) && self.dim() >= HUTCHPP_TRACE_MIN_DIM {
227            let config = hutchpp_config_for_dim(self.dim());
228            // Same-operator self-cross is PSD; the squared form is the
229            // exact algorithm for that case (lower variance, no sign).
230            if std::ptr::eq(
231                left as *const dyn HyperOperator as *const (),
232                right as *const dyn HyperOperator as *const (),
233            ) {
234                return hutchpp_estimate_trace_hinv_op_squared(self, left, &config);
235            }
236            return hutchpp_estimate_trace_hinv_operator_cross(self, left, right, &config);
237        }
238        if l_implicit || r_implicit {
239            log::warn!(
240                "trace_hinv_operator_cross: materializing implicit HyperOperator(s) — \
241                 backend should provide a matrix-free override"
242            );
243        }
244        self.trace_hinv_product_cross(&left.to_dense(), &right.to_dense())
245    }
246
247    /// tr(G_ε(H) A) — trace for the logdet gradient ∂_i log|R_ε(H)|.
248    ///
249    /// For non-spectral backends (Cholesky), G_ε = H⁻¹ and this reduces to
250    /// `trace_hinv_product`. For spectral regularization, G_ε uses eigenvalues
251    /// `φ'(σ_a) = 1/√(σ_a² + 4ε²)` instead of `1/r_ε(σ_a)`.
252    fn trace_logdet_gradient(&self, a: &Array2<f64>) -> f64 {
253        self.trace_hinv_product(a)
254    }
255
256    /// diag(X · G_ε(H) · Xᵀ) — the leverage corresponding to `trace_logdet_gradient`.
257    /// `trace_logdet_gradient(Xᵀ diag(w) X) = Σᵢ wᵢ · h^G[i]`.
258    ///
259    /// Streams the rows of `X` through the design's `try_row_chunk` so
260    /// operator-backed (Lazy) designs never materialize the full (n×p)
261    /// block at large scale.
262    fn xt_logdet_kernel_x_diagonal(&self, x: &DesignMatrix) -> Array1<f64> {
263        assert!(self.logdet_traces_match_hinv_kernel());
264        let n = x.nrows();
265        let p = x.ncols();
266
267        let block = {
268            const TARGET_CHUNK_FLOATS: usize = 1 << 16;
269            (TARGET_CHUNK_FLOATS / p.max(1)).clamp(1, n.max(1))
270        };
271
272        let mut h = Array1::<f64>::zeros(n);
273        let mut start = 0usize;
274        while start < n {
275            let end = (start + block).min(n);
276            let rows = x.try_row_chunk(start..end).unwrap_or_else(|err| {
277                // SAFETY: `try_row_chunk` only fails on operator implementation
278                // bugs — the `start..end` range is constructed from
279                // `0..n = 0..x.nrows()` with `end = (start+block).min(n)`,
280                // so it is always a valid sub-range of `x`. A failure here
281                // means the operator violated its row-chunk contract.
282                // SAFETY: row range built from 0..x.nrows(); failure means operator broke its contract.
283                reml_contract_panic(format!(
284                    "xt_logdet_kernel_x_diagonal: row chunk failed: {err}"
285                ))
286            });
287            let chunk_t = rows.t().to_owned();
288            let z_chunk = self.solve_multi(&chunk_t);
289            for (i, (row, z_col)) in rows
290                .outer_iter()
291                .zip(z_chunk.columns().into_iter())
292                .enumerate()
293            {
294                let mut acc = 0.0;
295                for (row_value, z_value) in row.iter().copied().zip(z_col.iter().copied()) {
296                    acc += row_value * z_value;
297                }
298                h[start + i] = acc;
299            }
300            start = end;
301        }
302        h
303    }
304
305    /// tr(G_ε(H) B) for an operator-backed Hessian drift.
306    ///
307    /// Default implementation materializes `B` densely. For Cholesky-based
308    /// backends this equals `trace_hinv_operator`.
309    ///
310    /// When `logdet_traces_match_hinv_kernel()` is true (Cholesky-style
311    /// backends where `trace_logdet_gradient(A) = trace_hinv_product(A)`)
312    /// and the operator is implicit on a moderate-or-large problem, route
313    /// through Hutch++ to avoid the dense materialization. Spectral
314    /// backends override this to false (their logdet trace uses
315    /// regularized eigenvalue weights, not `H⁻¹`), so they keep the
316    /// materialize path or provide their own override.
317    fn trace_logdet_operator(&self, op: &dyn HyperOperator) -> f64 {
318        if op.is_implicit()
319            && self.dim() >= HUTCHPP_TRACE_MIN_DIM
320            && self.logdet_traces_match_hinv_kernel()
321        {
322            let config = hutchpp_config_for_dim(self.dim());
323            return hutchpp_estimate_trace_hinv_operator(self, op, &config);
324        }
325        if op.is_implicit() {
326            log::warn!(
327                "trace_logdet_operator: materializing implicit HyperOperator — \
328                 backend should provide a matrix-free override"
329            );
330        }
331        self.trace_logdet_gradient(&op.to_dense())
332    }
333
334    /// Efficient computation of tr(G_ε(H) Hₖ) for the logdet gradient.
335    ///
336    /// Default implementation: forms the correction and calls `trace_logdet_gradient`.
337    fn trace_logdet_h_k(
338        &self,
339        a_k: &Array2<f64>,
340        third_deriv_correction: Option<&Array2<f64>>,
341    ) -> f64 {
342        let base = self.trace_logdet_gradient(a_k);
343        match third_deriv_correction {
344            Some(c) => base + self.trace_logdet_gradient(c),
345            None => base,
346        }
347    }
348
349    /// tr(G_ε(H) · A_block) where A_block is a p_block × p_block matrix
350    /// embedded at rows/columns [start..end].
351    ///
352    /// This avoids materializing the full p×p matrix for block-structured
353    /// penalties. The default implementation builds the full matrix and
354    /// delegates to `trace_logdet_gradient`; spectral backends override
355    /// this with O(p_block × active_rank) work.
356    /// `tr(G_ε(H) · A_block)` for a PSD `A_block = rootᵀroot` given by its
357    /// ROOT rather than by the squared block.
358    ///
359    /// Same quantity as [`Self::trace_logdet_block_local`] with
360    /// `block = rootᵀroot`, and NOT the same arithmetic. The squared form
361    /// evaluates `Σ_j g_jᵀ A g_j`, whose per-column error is `O(ε‖A‖‖g_j‖²)`;
362    /// the `g_j` are scaled by `σ_j(H)^{-1/2}`, so the sum carries
363    /// `O(ε·‖A‖/σ_min(H))` — `O(ε·κ(H))` on a trace bounded by `rank(A)`. On
364    /// the `s(pc1,k=5)+s(pc2,k=5)` witness of #2644 that is `2.2e-16 · 6.2e11 /
365    /// 0.16 ≈ 8.5e-4` per coordinate, against a stationarity bound of `3.0e-3`.
366    ///
367    /// The Gram form `‖root · G_block‖_F²` squares the residual instead
368    /// (`O(ε²‖A‖/σ_min)`) and cannot come out negative. See
369    /// `PenaltyCoordinate::scaled_block_root`.
370    ///
371    /// The default squares the root so every backend answers correctly;
372    /// spectral backends override it.
373    fn trace_logdet_block_root(
374        &self,
375        root: ndarray::ArrayView2<'_, f64>,
376        start: usize,
377        end: usize,
378    ) -> f64 {
379        let block = root.t().dot(&root);
380        self.trace_logdet_block_local(&block, 1.0, start, end)
381    }
382
383    fn trace_logdet_block_local(
384        &self,
385        block: &Array2<f64>,
386        scale: f64,
387        start: usize,
388        end: usize,
389    ) -> f64 {
390        let p = self.dim();
391        let mut full = Array2::<f64>::zeros((p, p));
392        let bs = end - start;
393        for i in 0..bs {
394            for j in 0..bs {
395                full[[start + i, start + j]] = scale * block[[i, j]];
396            }
397        }
398        self.trace_logdet_gradient(&full)
399    }
400
401    /// Cross-trace for the logdet Hessian:
402    /// `∂²_{ij} log|R_ε(H)| = tr(G_ε Ḧ_{ij}) + spectral_cross(Ḣ_i, Ḣ_j)`.
403    ///
404    /// This method computes the `spectral_cross(Ḣ_i, Ḣ_j)` part, which for
405    /// non-spectral backends equals `-tr(H⁻¹ Ḣ_j H⁻¹ Ḣ_i)`.
406    ///
407    /// For spectral regularization, the divided-difference kernel Γ_{ab} replaces
408    /// the simple product of inverses.
409    fn trace_logdet_hessian_cross(&self, h_i: &Array2<f64>, h_j: &Array2<f64>) -> f64 {
410        // Default: standard formula -tr(H⁻¹ Ḣ_j H⁻¹ Ḣ_i) = -⟨Y_j^T, Y_i⟩_F
411        // where Y_i = H⁻¹ Ḣ_i.
412        let y_i = self.solve_multi(h_i);
413        if std::ptr::eq(h_i, h_j) {
414            return -dense::trace_product(&y_i, &y_i);
415        }
416        let y_j = self.solve_multi(h_j);
417        -dense::trace_product(&y_j, &y_i)
418    }
419
420    /// Operator-backed mixed form of `trace_logdet_hessian_cross`.
421    ///
422    /// The default materializes the operator; spectral and sparse backends
423    /// override this to keep the exact analytic cross trace matrix-free.
424    fn trace_logdet_hessian_cross_matrix_operator(
425        &self,
426        h_i: &Array2<f64>,
427        h_j: &dyn HyperOperator,
428    ) -> f64 {
429        self.trace_logdet_hessian_cross(h_i, &h_j.to_dense())
430    }
431
432    /// Operator-backed form of `trace_logdet_hessian_cross`.
433    ///
434    /// The default materializes both operators; exact backends override this
435    /// when they can contract the logdet-Hessian kernel against operator
436    /// projections directly.
437    fn trace_logdet_hessian_cross_operator(
438        &self,
439        h_i: &dyn HyperOperator,
440        h_j: &dyn HyperOperator,
441    ) -> f64 {
442        self.trace_logdet_hessian_cross(&h_i.to_dense(), &h_j.to_dense())
443    }
444
445    /// Number of active dimensions (rank of pseudo-inverse).
446    fn active_rank(&self) -> usize;
447
448    /// Full dimension of H.
449    fn dim(&self) -> usize;
450
451    /// Whether this operator is backed by a dense factorization.
452    ///
453    /// Dense operators (eigendecomposition) have O(p²) trace cost per matrix,
454    /// making stochastic trace estimation worthwhile for large p.  Sparse
455    /// operators (Cholesky) have O(nnz) solve cost, so exact column-by-column
456    /// traces are already cheap and stochastic estimation is not needed.
457    fn is_dense(&self) -> bool {
458        false
459    }
460
461    /// Whether the unified evaluator should batch large trace computations
462    /// through the stochastic Hutchinson path for this operator.
463    ///
464    /// Dense eigendecomposition backends prefer this once `p` is large because
465    /// exact per-coordinate traces are O(p²). Matrix-free iterative backends
466    /// have the same preference even though they do not store a dense factor.
467    fn prefers_stochastic_trace_estimation(&self) -> bool {
468        self.is_dense()
469    }
470
471    /// Whether stochastic Hutchinson estimates based on `H⁻¹` are valid for
472    /// logdet-gradient / logdet-Hessian trace terms on this backend.
473    ///
474    /// This is true for plain SPD-logdet operators where
475    /// `trace_logdet_gradient(A) = tr(H⁻¹ A)` and
476    /// `trace_logdet_hessian_cross(A, B) = -tr(H⁻¹ A H⁻¹ B)`.
477    ///
478    /// Smooth spectral regularization does not satisfy those identities, so
479    /// dense spectral backends must override this to `false`.
480    fn logdet_traces_match_hinv_kernel(&self) -> bool {
481        true
482    }
483
484    /// Access the dense spectral backend when this operator is powered by a
485    /// single eigendecomposition.
486    fn as_dense_spectral(&self) -> Option<&DenseSpectralOperator> {
487        None
488    }
489}
490
491/// Representative curvature scale for a Hessian operator.
492///
493/// Returns the geometric mean of the active Hessian eigenvalues,
494/// `exp(log|H|_+ / rank(H))`. This has the same physical units as a Hessian
495/// diagonal entry but is basis-invariant, cheap after the operator has computed
496/// its log-determinant, and well-defined for both dense spectral and
497/// matrix-free operator paths.
498pub fn hessian_factorization_geometric_scale(op: &dyn HessianFactorization) -> Option<f64> {
499    let rank = op.active_rank();
500    if rank == 0 {
501        return None;
502    }
503    let logdet = op.logdet();
504    if !logdet.is_finite() {
505        return None;
506    }
507    let scale = (logdet / rank as f64).exp();
508    if scale.is_finite() && scale > 0.0 {
509        Some(scale)
510    } else {
511        None
512    }
513}