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}