gam_solve/arrow_schur/slq_logdet.rs
1//! Matrix-free log-determinant via Stochastic Lanczos Quadrature (SLQ).
2//!
3//! BIBLIOGRAPHY
4//!
5//! * Ubaru, Chen, Saad, "Fast Estimation of tr(f(A)) via Stochastic Lanczos
6//! Quadrature", SIAM J. Matrix Anal. Appl. 38(4), 2017: the canonical SLQ
7//! estimator for `tr(f(A))` with `f = ln` giving `log det A = tr(ln A)`.
8//! * Bai, Fahey, Golub, "Some large-scale matrix computation problems", J.
9//! Comput. Appl. Math. 74, 1996: Gauss-quadrature view of `uᵀ f(A) u` as
10//! `Σ_i (e₁ᵀ y_i)² f(θ_i)` over the Lanczos tridiagonal eigenpairs `(θ_i,y_i)`.
11//! * Hutchinson, "A stochastic estimator of the trace of the influence matrix",
12//! Comm. Statist. Simulation Comput. 19, 1990: Rademacher probe vectors with
13//! `E[zᵀ M z] = tr(M)` and `‖z‖² = dim`.
14//! * Golub, Meurant, "Matrices, Moments and Quadrature with Applications", 2010:
15//! Lanczos quadrature, the need for reorthogonalization, and error analysis.
16//!
17//! ## What this provides
18//!
19//! [`slq_logdet`] estimates `log det A` for a symmetric positive-definite
20//! operator `A` available ONLY through matrix-vector products `v ↦ A v`. It
21//! never forms or factors `A`, so for the reduced-Schur Laplace normaliser it
22//! replaces the dense `O(k³/3)` Cholesky log-determinant with
23//! `O(num_probes · lanczos_steps · matvec)` work.
24//!
25//! The estimator is `tr(ln A) ≈ (dim / num_probes) Σ_p zₚᵀ ln(A) zₚ` with
26//! Rademacher probes `zₚ`, and each quadratic form `zᵀ ln(A) z` is evaluated by
27//! `m` steps of Lanczos against `A` started from `z/‖z‖`: building the symmetric
28//! tridiagonal `T_m` (with FULL reorthogonalization against the stored basis),
29//! eigendecomposing it, and reading the Gauss quadrature
30//! `‖z‖² Σ_i (τ_{i,0})² ln(θ_i)` where `θ_i` are `T_m`'s eigenvalues and
31//! `τ_{i,0}` is the first component of the `i`-th eigenvector.
32//!
33//! ## Reuse
34//!
35//! The numerically-critical Lanczos recurrence + full reorthogonalization +
36//! tridiagonal eigendecomposition is the workspace primitive
37//! [`gam_linalg::lanczos::symmetric_lanczos_eigenpairs`]; this module is the
38//! Hutchinson outer loop (Rademacher probes, averaging, standard error) on top
39//! of it. The clamped log-quadrature is computed here (rather than via
40//! [`gam_linalg::lanczos::symmetric_lanczos_log_quadrature`], which errors on a
41//! non-positive Ritz value) so a round-off-negative Ritz value floors to a tiny
42//! positive number instead of failing the whole evidence solve.
43//!
44//! ## Determinism
45//!
46//! The probe vectors are drawn from [`gam_linalg::utils::splitmix64`] seeded by
47//! `seed + probe_index`; there is NO system-RNG dependence, so a given
48//! `(dim, matvec, num_probes, lanczos_steps, seed)` always returns the same
49//! estimate. This is required by the evidence path, whose REML outer loop must
50//! be reproducible.
51
52use super::*;
53use gam_linalg::lanczos::{
54 SymmetricLanczosEigenpairs, SymmetricLanczosOptions, symmetric_lanczos_eigenpairs,
55};
56use gam_linalg::utils::splitmix64;
57use rayon::iter::{IntoParallelIterator, ParallelIterator};
58
59/// Result of a Stochastic Lanczos Quadrature log-determinant estimate.
60#[derive(Debug, Clone, Copy)]
61pub struct SlqLogDet {
62 /// Estimate of `log det A`.
63 pub estimate: f64,
64 /// Standard error of the estimate: the sample standard deviation of the
65 /// per-probe contributions divided by `sqrt(num_probes)`. With a single
66 /// probe this is `0.0` (no spread is observable).
67 pub std_err: f64,
68}
69
70/// Floor on Ritz eigenvalues before taking `ln`. The operator is SPD so the
71/// Ritz values `θ_i` are positive in exact arithmetic; this clamps any tiny
72/// negative/zero value produced by round-off so `ln` stays finite. Chosen far
73/// below any physically meaningful curvature scale.
74const RITZ_LN_FLOOR: f64 = 1e-300;
75
76/// Draw a deterministic Rademacher (±1) vector of length `dim` into `z`,
77/// seeded reproducibly by `probe_seed`. Two bits per draw are wasteful but the
78/// per-element top-bit read keeps this trivially correct and stream-stable.
79fn rademacher_into(z: &mut Array1<f64>, probe_seed: u64) {
80 let mut state = probe_seed;
81 let mut bits: u64 = 0;
82 let mut remaining: u32 = 0;
83 for value in z.iter_mut() {
84 if remaining == 0 {
85 bits = splitmix64(&mut state);
86 remaining = 64;
87 }
88 *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
89 bits >>= 1;
90 remaining -= 1;
91 }
92}
93
94/// The fixed Lanczos configuration every SLQ probe runs: `steps` Gauss nodes,
95/// no early residual break (exhaust the Krylov space), and FULL
96/// reorthogonalization (numerically essential — without it Lanczos loses
97/// orthogonality and produces ghost Ritz values that poison the quadrature).
98#[inline]
99fn slq_lanczos_options(steps: usize) -> SymmetricLanczosOptions {
100 SymmetricLanczosOptions {
101 max_steps: steps,
102 residual_tol: 0.0,
103 local_reorthogonalize: false,
104 full_reorthogonalize: true,
105 }
106}
107
108/// Run one Rademacher-probe Lanczos and return the tridiagonal eigenpairs, or
109/// `None` if the Lanczos run declines (non-finite matvec / start). Shared by the
110/// plain [`slq_logdet`] and the unit-deflated [`slq_logdet_unit_deflated`]
111/// estimators so both draw the IDENTICAL probe vector and build the IDENTICAL
112/// Krylov space for a given `(dim, matvec, probe_seed, options)` — the two
113/// estimators then differ ONLY in the spectral function applied to the shared
114/// Ritz pairs. Each probe carries its own Rademacher vector and matvec input
115/// scratch (no shared mutable state), so a `rayon` fan-out over probes is
116/// bit-identical to the serial build.
117fn probe_lanczos_eigenpairs(
118 dim: usize,
119 probe_seed: u64,
120 options: SymmetricLanczosOptions,
121 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
122) -> Option<SymmetricLanczosEigenpairs> {
123 let mut z = Array1::<f64>::zeros(dim);
124 rademacher_into(&mut z, probe_seed);
125 // The workspace Lanczos engine consumes `apply(&[f64], &mut [f64])`; wrap the
126 // ndarray `matvec` into that slice contract with a per-probe input buffer so
127 // probes never share mutable scratch.
128 let mut in_buf = Array1::<f64>::zeros(dim);
129 let mut apply = |x: &[f64], out: &mut [f64]| -> Result<(), String> {
130 in_buf
131 .as_slice_mut()
132 .expect("contiguous probe input buffer")
133 .copy_from_slice(x);
134 let y = matvec(in_buf.view());
135 if y.len() != dim {
136 return Err(format!(
137 "slq_logdet matvec returned length {}, expected {dim}",
138 y.len()
139 ));
140 }
141 out.copy_from_slice(y.as_slice().expect("contiguous matvec output"));
142 Ok(())
143 };
144 let start = z.as_slice().expect("contiguous probe vector");
145 symmetric_lanczos_eigenpairs(dim, start, options, &mut apply).ok()
146}
147
148/// Serial mean and standard error of the per-probe SLQ contributions. Runs over
149/// the `into_par_iter().collect()` ORDERED buffer so the reduction is bit-for-bit
150/// reproducible for a fixed probe set — the determinism the REML evidence outer
151/// loop requires.
152fn slq_mean_std_err(contributions: &[f64]) -> (f64, f64) {
153 let n = contributions.len() as f64;
154 let mean = contributions.iter().sum::<f64>() / n;
155 let std_err = if contributions.len() > 1 {
156 let var = contributions
157 .iter()
158 .map(|c| {
159 let d = c - mean;
160 d * d
161 })
162 .sum::<f64>()
163 / (n - 1.0);
164 (var / n).sqrt()
165 } else {
166 0.0
167 };
168 (mean, std_err)
169}
170
171/// Estimate `log det A` for an SPD operator given only its matrix-vector apply.
172///
173/// * `dim` — dimension of the operator (`A` is `dim × dim`).
174/// * `matvec` — applies `A`: `matvec(v) = A v`, for `v.len() == dim`.
175/// * `num_probes` — number of Rademacher probe vectors (Hutchinson samples).
176/// * `lanczos_steps` — Lanczos iterations per probe (Gauss-quadrature nodes).
177/// * `seed` — base seed; probe `p` uses `seed + p`, so results are reproducible.
178///
179/// Returns the averaged estimate and its standard error. For `dim == 0` the
180/// determinant of the empty operator is `1`, so the log-determinant is `0`.
181///
182/// `lanczos_steps` is internally capped at `dim` (a Krylov subspace cannot
183/// exceed the dimension) and `num_probes` is treated as at least `1`.
184pub fn slq_logdet(
185 dim: usize,
186 matvec: impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync,
187 num_probes: usize,
188 lanczos_steps: usize,
189 seed: u64,
190) -> SlqLogDet {
191 if dim == 0 {
192 return SlqLogDet {
193 estimate: 0.0,
194 std_err: 0.0,
195 };
196 }
197 let num_probes = num_probes.max(1);
198 let steps = lanczos_steps.max(1).min(dim);
199 let norm_sq = dim as f64; // ‖z‖² for a ±1 Rademacher vector of length `dim`.
200 let lanczos_options = slq_lanczos_options(steps);
201
202 // Each Hutchinson probe is a FULLY INDEPENDENT Lanczos run against the same
203 // read-only (`Sync`) operator, so at the K=32k evidence scale — where SLQ
204 // fires precisely because the operator is large (`num_probes`×`lanczos_steps`
205 // matvecs of an `O(k²)` apply) — the probes fan out across rayon workers for
206 // a near-`num_probes`× wall-clock cut on the dominant matvec work. The
207 // contribution a probe computes depends only on `(dim, matvec, probe_seed,
208 // options)`, so it is bit-identical to the serial build.
209 // `into_par_iter().collect()` preserves probe order, and the mean/std-err
210 // reduction runs SERIALLY over that ordered buffer, so the estimate and
211 // std-error are bit-for-bit reproducible for a fixed `(dim, matvec,
212 // num_probes, lanczos_steps, seed)` — the determinism the REML evidence outer
213 // loop requires (see the module `Determinism` note).
214 let matvec = &matvec;
215 let contributions: Vec<f64> = (0..num_probes)
216 .into_par_iter()
217 .map(|probe| {
218 let probe_seed = seed.wrapping_add(probe as u64);
219 match probe_lanczos_eigenpairs(dim, probe_seed, lanczos_options, matvec) {
220 Some(pairs) => {
221 norm_sq * clamped_log_quadrature(&pairs.eigenvalues, &pairs.eigenvectors)
222 }
223 // A Lanczos failure (non-finite matvec / start) cannot be silently
224 // averaged in; the dense-Cholesky gate above this call should have
225 // caught a degenerate operator. Treat it as a zero contribution and
226 // let the std-error widen rather than poisoning the mean with NaN.
227 None => 0.0,
228 }
229 })
230 .collect();
231
232 let (estimate, std_err) = slq_mean_std_err(&contributions);
233 SlqLogDet { estimate, std_err }
234}
235
236/// Result of a unit-deflated SLQ log-determinant estimate.
237///
238/// The estimate is `tr(φ(A))` with the unit-deflation spectral function
239/// `φ(θ) = θ ≥ deflate_floor ? ln θ : 0`, i.e. every eigenvalue at or below the
240/// floor is pinned to unit stiffness and contributes `ln 1 = 0` — the exact
241/// matrix-free analogue of the dense
242/// [`ReducedSchurPolicy::EvidenceUnitDeflation`](super::reduced_solve) convention
243/// (see #2308). `lambda_max_abs` and `deflate_floor` are reported so callers can
244/// audit the scale at which deflation kicked in.
245#[derive(Debug, Clone, Copy)]
246pub struct SlqUnitDeflatedLogDet {
247 /// Estimate of the unit-deflated log-determinant `Σ_{λ ≥ floor} ln λ`.
248 pub estimate: f64,
249 /// Standard error of the estimate across probes (`0.0` for a single probe).
250 pub std_err: f64,
251 /// Estimated spectral radius `max|λ|` — the largest `|Ritz value|` observed
252 /// across every probe. The deflation floor is relative to this scale, so it
253 /// is the matrix-free stand-in for the dense path's `max|λ|`.
254 pub lambda_max_abs: f64,
255 /// The absolute deflation floor actually applied:
256 /// `relative_floor · lambda_max_abs · (1 − hysteresis)`.
257 pub deflate_floor: f64,
258}
259
260impl SlqUnitDeflatedLogDet {
261 /// View as a plain [`SlqLogDet`] (estimate + std-error), dropping the
262 /// deflation metadata — for the evidence plumbing that only consumes the
263 /// scalar log-determinant and its uncertainty band.
264 #[inline]
265 pub fn as_logdet(&self) -> SlqLogDet {
266 SlqLogDet {
267 estimate: self.estimate,
268 std_err: self.std_err,
269 }
270 }
271}
272
273/// Estimate the UNIT-DEFLATED log-determinant of a symmetric operator `A`
274/// available only through matvecs — the matrix-free counterpart of the dense
275/// `EvidenceUnitDeflation` reduced-Schur policy (#2308).
276///
277/// SLQ estimates `tr(f(A))` for ANY spectral function `f` via the same Gauss
278/// quadrature; the plain [`slq_logdet`] uses `f = ln`. Unit deflation is simply
279/// a DIFFERENT `f`:
280///
281/// ```text
282/// φ(θ) = ln θ, θ ≥ deflate_floor
283/// φ(θ) = 0, θ < deflate_floor (pinned to unit stiffness: ln 1 = 0)
284/// ```
285///
286/// so `tr(φ(A)) = Σ_{λ_i ≥ floor} ln λ_i` — every collapsed / near-null / (round-off
287/// or genuinely) negative-curvature direction contributes exactly `0` instead of
288/// the plain estimator's `ln(RITZ_LN_FLOOR) ≈ −690` per deflated direction. This
289/// matches the dense convention where a sub-floor eigenvalue is pinned to `λ̃ = 1`.
290///
291/// The floor is RELATIVE, exactly as in the dense path:
292/// `deflate_floor = relative_floor · max|λ| · (1 − SPECTRAL_DEFLATION_HYSTERESIS_FRACTION)`,
293/// with `max|λ|` estimated as the largest `|Ritz value|` over all probes — Lanczos
294/// converges to the extreme eigenvalues first, so this is a sharp, deterministic
295/// spectral-radius estimate. A single shared floor is computed BEFORE any
296/// contribution so every probe deflates against the SAME threshold (a per-probe
297/// floor would make the estimate non-linear in the probes and break determinism
298/// of the deflation set).
299///
300/// Determinism, probe fan-out, and the `dim == 0 ⇒ 0` convention are identical to
301/// [`slq_logdet`]; the two share [`probe_lanczos_eigenpairs`], so for a fixed
302/// `(dim, matvec, num_probes, lanczos_steps, seed)` the two estimators build
303/// bit-identical Krylov spaces and differ ONLY in the applied spectral function.
304pub fn slq_logdet_unit_deflated(
305 dim: usize,
306 matvec: impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync,
307 num_probes: usize,
308 lanczos_steps: usize,
309 seed: u64,
310 relative_floor: f64,
311) -> SlqUnitDeflatedLogDet {
312 if dim == 0 {
313 return SlqUnitDeflatedLogDet {
314 estimate: 0.0,
315 std_err: 0.0,
316 lambda_max_abs: 0.0,
317 deflate_floor: 0.0,
318 };
319 }
320 let num_probes = num_probes.max(1);
321 let steps = lanczos_steps.max(1).min(dim);
322 let norm_sq = dim as f64;
323 let lanczos_options = slq_lanczos_options(steps);
324
325 // Pass 1 — build every probe's Ritz pairs (the expensive matvec work), fanned
326 // across rayon workers. The ordered buffer keeps the reduction reproducible.
327 let matvec = &matvec;
328 let per_probe: Vec<Option<SymmetricLanczosEigenpairs>> = (0..num_probes)
329 .into_par_iter()
330 .map(|probe| {
331 let probe_seed = seed.wrapping_add(probe as u64);
332 probe_lanczos_eigenpairs(dim, probe_seed, lanczos_options, matvec)
333 })
334 .collect();
335
336 // A single shared spectral-radius estimate `max|λ|` over ALL probes' Ritz
337 // values, and from it the ONE deflation floor every probe uses.
338 let lambda_max_abs = per_probe
339 .iter()
340 .flatten()
341 .flat_map(|pairs| pairs.eigenvalues.iter())
342 .filter(|value| value.is_finite())
343 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
344 if !(lambda_max_abs.is_finite() && lambda_max_abs > 0.0) {
345 // No usable spectrum (empty / all-zero / every probe declined): the
346 // unit-deflated determinant of a fully-deflated operator is `Σ 0 = 0`.
347 return SlqUnitDeflatedLogDet {
348 estimate: 0.0,
349 std_err: 0.0,
350 lambda_max_abs: 0.0,
351 deflate_floor: 0.0,
352 };
353 }
354 let deflate_floor =
355 relative_floor * lambda_max_abs * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
356
357 // Pass 2 — apply the unit-deflation spectral function against the shared floor.
358 let contributions: Vec<f64> = per_probe
359 .iter()
360 .map(|maybe_pairs| match maybe_pairs {
361 Some(pairs) => {
362 norm_sq
363 * deflated_log_quadrature(
364 &pairs.eigenvalues,
365 &pairs.eigenvectors,
366 deflate_floor,
367 )
368 }
369 None => 0.0,
370 })
371 .collect();
372
373 let (estimate, std_err) = slq_mean_std_err(&contributions);
374 SlqUnitDeflatedLogDet {
375 estimate,
376 std_err,
377 lambda_max_abs,
378 deflate_floor,
379 }
380}
381
382/// Gauss quadrature `e₁ᵀ ln(T) e₁ = Σ_i (τ_{i,0})² ln(θ_i)` over the Lanczos
383/// tridiagonal eigenpairs, with `θ_i` floored to [`RITZ_LN_FLOOR`] so a
384/// round-off-negative Ritz value (the SPD operator forbids genuine ones) cannot
385/// produce a `NaN`. `eigenvectors` columns are the Ritz vectors `y_i`; `τ_{i,0}`
386/// is their first component.
387fn clamped_log_quadrature(eigenvalues: &Array1<f64>, eigenvectors: &Array2<f64>) -> f64 {
388 let mut quad = 0.0_f64;
389 for i in 0..eigenvalues.len() {
390 let tau0 = eigenvectors[[0, i]];
391 let weight = tau0 * tau0;
392 let lambda = eigenvalues[i].max(RITZ_LN_FLOOR);
393 quad += weight * lambda.ln();
394 }
395 quad
396}
397
398/// Unit-deflated Gauss quadrature `Σ_i (τ_{i,0})² φ(θ_i)` with the deflation
399/// spectral function `φ(θ) = θ ≥ deflate_floor ? ln θ : 0`. A Ritz value at or
400/// below the floor (collapsed / near-null / round-off- or genuinely-negative)
401/// is pinned to unit stiffness and contributes `ln 1 = 0`; a kept Ritz value
402/// (necessarily `> deflate_floor > 0`) contributes `ln θ`. This is the
403/// matrix-free image of the dense evidence unit deflation (#2308).
404fn deflated_log_quadrature(
405 eigenvalues: &Array1<f64>,
406 eigenvectors: &Array2<f64>,
407 deflate_floor: f64,
408) -> f64 {
409 let mut quad = 0.0_f64;
410 for i in 0..eigenvalues.len() {
411 let lambda = eigenvalues[i];
412 if lambda < deflate_floor {
413 // Deflated direction: pinned to λ̃ = 1, contributes ln 1 = 0.
414 continue;
415 }
416 let tau0 = eigenvectors[[0, i]];
417 let weight = tau0 * tau0;
418 // `deflate_floor > 0`, so a kept Ritz value is strictly positive; the
419 // `RITZ_LN_FLOOR` guard only defends against a round-off boundary case.
420 quad += weight * lambda.max(RITZ_LN_FLOOR).ln();
421 }
422 quad
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 /// Deterministic uniform draw in `[lo, hi)` from a SplitMix64 state — keeps
430 /// the test fixtures reproducible with no external RNG dependency.
431 fn next_uniform(state: &mut u64, lo: f64, hi: f64) -> f64 {
432 // 53-bit mantissa fraction in [0, 1).
433 let bits = splitmix64(state) >> 11;
434 let unit = (bits as f64) / ((1u64 << 53) as f64);
435 lo + (hi - lo) * unit
436 }
437
438 /// Build a random SPD matrix `A = MᵀM + δI` (`dim × dim`) from a fixed seed.
439 /// `m_rows ≥ dim` keeps `MᵀM` well-conditioned; `delta` sets the floor on the
440 /// spectrum (larger `delta` ⇒ better conditioned).
441 fn random_spd(dim: usize, m_rows: usize, delta: f64, seed: u64) -> Array2<f64> {
442 let mut state = seed;
443 let mut m = Array2::<f64>::zeros((m_rows, dim));
444 for value in m.iter_mut() {
445 *value = next_uniform(&mut state, -1.0, 1.0);
446 }
447 let mut a = m.t().dot(&m);
448 for i in 0..dim {
449 a[[i, i]] += delta;
450 }
451 // Symmetrize defensively against round-off.
452 for i in 0..dim {
453 for j in (i + 1)..dim {
454 let avg = 0.5 * (a[[i, j]] + a[[j, i]]);
455 a[[i, j]] = avg;
456 a[[j, i]] = avg;
457 }
458 }
459 a
460 }
461
462 /// Exact `log det A` via the workspace symmetric eigensolver (`Σ ln λ_i`).
463 fn exact_logdet(a: &Array2<f64>) -> f64 {
464 let (evals, _) = a.eigh(Side::Lower).expect("SPD eigendecomposition");
465 evals.iter().map(|&l| l.max(RITZ_LN_FLOOR).ln()).sum()
466 }
467
468 fn condition_number(a: &Array2<f64>) -> f64 {
469 let (evals, _) = a.eigh(Side::Lower).expect("SPD eigendecomposition");
470 let max = evals.iter().cloned().fold(f64::MIN, f64::max);
471 let min = evals.iter().cloned().fold(f64::MAX, f64::min);
472 max / min
473 }
474
475 #[test]
476 fn slq_matches_exact_logdet_well_conditioned() {
477 // A spread of dimensions in the 60–200 range, all well-conditioned
478 // (generous δ), checked against the exact eigenvalue log-determinant.
479 for (dim, seed) in [(60usize, 1u64), (120, 2), (200, 3)] {
480 let a = random_spd(dim, dim + 40, 5.0, seed);
481 let exact = exact_logdet(&a);
482 let cond = condition_number(&a);
483
484 let result = slq_logdet(dim, |v| a.dot(&v), 48, 70, 0xA5A5_0000 ^ seed);
485
486 let rel_err = (result.estimate - exact).abs() / exact.abs();
487 eprintln!(
488 "well-conditioned dim={dim} cond={cond:.2e} exact={exact:.6} \
489 est={:.6} rel_err={rel_err:.4e} std_err={:.4e}",
490 result.estimate, result.std_err
491 );
492 assert!(
493 rel_err < 0.05,
494 "dim={dim}: SLQ relative error {rel_err:.4e} exceeds 5% \
495 (exact={exact}, est={})",
496 result.estimate
497 );
498 // The exact value should sit within a few standard errors of the
499 // estimate (the std_err must be a meaningful uncertainty band).
500 assert!(
501 (result.estimate - exact).abs() < 3.0 * result.std_err + 0.05 * exact.abs(),
502 "dim={dim}: estimate not within ~3 std_err of exact \
503 (|Δ|={:.4e}, std_err={:.4e})",
504 (result.estimate - exact).abs(),
505 result.std_err
506 );
507 }
508 }
509
510 #[test]
511 fn slq_handles_moderately_ill_conditioned() {
512 // Smaller δ ⇒ a tighter spectral floor ⇒ a more ill-conditioned A.
513 // More Lanczos steps resolve the wider spectrum.
514 let dim = 150usize;
515 let a = random_spd(dim, dim + 5, 0.05, 7);
516 let exact = exact_logdet(&a);
517 let cond = condition_number(&a);
518 assert!(
519 cond > 1e3,
520 "test fixture should be moderately ill-conditioned, got cond={cond:.2e}"
521 );
522
523 let result = slq_logdet(dim, |v| a.dot(&v), 40, 110, 0xC0FFEE);
524 let rel_err = (result.estimate - exact).abs() / exact.abs();
525 eprintln!(
526 "ill-conditioned dim={dim} cond={cond:.2e} exact={exact:.6} \
527 est={:.6} rel_err={rel_err:.4e} std_err={:.4e}",
528 result.estimate, result.std_err
529 );
530 assert!(
531 rel_err < 0.10,
532 "ill-conditioned dim={dim}: SLQ relative error {rel_err:.4e} \
533 exceeds 10% (cond={cond:.2e}, exact={exact}, est={})",
534 result.estimate
535 );
536 }
537
538 #[test]
539 fn slq_is_deterministic_for_fixed_seed() {
540 let dim = 80usize;
541 let a = random_spd(dim, dim + 20, 2.0, 11);
542 let r1 = slq_logdet(dim, |v| a.dot(&v), 24, 50, 99);
543 let r2 = slq_logdet(dim, |v| a.dot(&v), 24, 50, 99);
544 assert_eq!(
545 r1.estimate, r2.estimate,
546 "SLQ must be bit-reproducible for a fixed seed"
547 );
548 assert_eq!(r1.std_err, r2.std_err);
549 }
550
551 #[test]
552 fn slq_diagonal_operator_matches_closed_form() {
553 // A diagonal operator has a closed-form log-determinant Σ ln d_i; this
554 // exercises the matvec closure path without any matrix assembly.
555 let dim = 100usize;
556 let mut state = 123u64;
557 let diag: Vec<f64> = (0..dim)
558 .map(|_| next_uniform(&mut state, 0.5, 4.0))
559 .collect();
560 let exact: f64 = diag.iter().map(|d| d.ln()).sum();
561
562 let diag_clone = diag.clone();
563 let result = slq_logdet(
564 dim,
565 move |v| {
566 let mut out = v.to_owned();
567 for (o, d) in out.iter_mut().zip(diag_clone.iter()) {
568 *o *= d;
569 }
570 out
571 },
572 32,
573 60,
574 7,
575 );
576 let rel_err = (result.estimate - exact).abs() / exact.abs();
577 eprintln!(
578 "diagonal dim={dim} exact={exact:.6} est={:.6} rel_err={rel_err:.4e}",
579 result.estimate
580 );
581 assert!(
582 rel_err < 0.05,
583 "diagonal operator: relative error {rel_err:.4e} exceeds 5%"
584 );
585 }
586
587 #[test]
588 fn slq_empty_operator_is_zero() {
589 let result = slq_logdet(0, |v| v.to_owned(), 8, 8, 1);
590 assert_eq!(result.estimate, 0.0);
591 assert_eq!(result.std_err, 0.0);
592 }
593
594 #[test]
595 fn std_err_shrinks_with_more_probes() {
596 // The standard error of a Monte-Carlo mean falls ~1/sqrt(num_probes);
597 // many probes should give a tighter band than few.
598 let dim = 120usize;
599 let a = random_spd(dim, dim + 30, 3.0, 21);
600 let few = slq_logdet(dim, |v| a.dot(&v), 6, 60, 5);
601 let many = slq_logdet(dim, |v| a.dot(&v), 96, 60, 5);
602 eprintln!(
603 "std_err few(6)={:.4e} many(96)={:.4e}",
604 few.std_err, many.std_err
605 );
606 assert!(
607 many.std_err < few.std_err,
608 "more probes should reduce std_err (few={:.4e}, many={:.4e})",
609 few.std_err,
610 many.std_err
611 );
612 }
613
614 /// Dense symmetric `A = H diag(λ) H` with `H = I − 2wwᵀ` (‖w‖=1) a Householder
615 /// reflector — orthogonal AND symmetric, so `A`'s eigenvalues are EXACTLY the
616 /// planted `λ` and its eigenvectors are the columns of `H`. Gives a genuinely
617 /// non-diagonal operator with a known, hand-chosen spectrum (unlike
618 /// `random_spd`, whose spectrum would have to be eigendecomposed to learn),
619 /// so a deflation test can plant a specific collapsed direction.
620 fn householder_spectrum_matrix(eigenvalues: &[f64], seed: u64) -> Array2<f64> {
621 let dim = eigenvalues.len();
622 let mut state = seed;
623 let mut w = Array1::<f64>::zeros(dim);
624 for value in w.iter_mut() {
625 *value = next_uniform(&mut state, -1.0, 1.0);
626 }
627 let norm = w.dot(&w).sqrt();
628 w.mapv_inplace(|v| v / norm);
629 // H = I − 2 w wᵀ.
630 let mut h = Array2::<f64>::eye(dim);
631 for i in 0..dim {
632 for j in 0..dim {
633 h[[i, j]] -= 2.0 * w[i] * w[j];
634 }
635 }
636 // A = (H D) H, with D = diag(λ). H is symmetric, so A = H D Hᵀ is symmetric
637 // with eigenpairs (λ_j, H[:, j]).
638 let mut hd = h.clone();
639 for j in 0..dim {
640 for i in 0..dim {
641 hd[[i, j]] *= eigenvalues[j];
642 }
643 }
644 hd.dot(&h)
645 }
646
647 /// #2308 — the matrix-free evidence log|S| MUST obey the same unit-deflation
648 /// convention as the dense reduced-Schur factor: a collapsed / near-null /
649 /// negative-curvature direction is pinned to unit stiffness and contributes
650 /// `ln 1 = 0`, NOT the plain estimator's `ln(RITZ_LN_FLOOR) ≈ −690`.
651 #[test]
652 fn slq_unit_deflation_pins_collapsed_direction_to_unit_2308() {
653 let dim = 48usize;
654 let mut state = 0x2308_0001_u64;
655 let mut eigenvalues = vec![0.0_f64; dim];
656 for e in eigenvalues.iter_mut() {
657 *e = next_uniform(&mut state, 0.5, 12.0);
658 }
659 // One collapsed direction: genuinely negative curvature, |λ| ≪ floor —
660 // exactly the collapsed-decoder mode the evidence deflation targets.
661 eigenvalues[dim - 1] = -3.0e-11;
662
663 let a = householder_spectrum_matrix(&eigenvalues, 0x51A9);
664 let max_abs = eigenvalues.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
665 // The dense convention's floor and the kept-eigenvalue reference log-det.
666 let floor = SPECTRAL_DEFLATION_REL_FLOOR * max_abs
667 * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
668 let reference: f64 = eigenvalues
669 .iter()
670 .filter(|&&l| l >= floor)
671 .map(|&l| l.ln())
672 .sum();
673
674 let deflated = slq_logdet_unit_deflated(
675 dim,
676 |v| a.dot(&v),
677 48,
678 dim,
679 0xD1F,
680 SPECTRAL_DEFLATION_REL_FLOOR,
681 );
682 eprintln!(
683 "unit-deflated est={:.6} reference={:.6} lambda_max_abs={:.6} floor={:.3e}",
684 deflated.estimate, reference, deflated.lambda_max_abs, deflated.deflate_floor
685 );
686 // The spectral-radius estimate recovers max|λ|, and the floor is the dense
687 // path's floor built from it.
688 assert!(
689 (deflated.lambda_max_abs - max_abs).abs() / max_abs < 1e-6,
690 "lambda_max_abs {} should recover planted max|λ| {}",
691 deflated.lambda_max_abs,
692 max_abs
693 );
694 assert!(
695 (deflated.deflate_floor - floor).abs() / floor < 1e-9,
696 "deflate_floor {} should equal the dense relative floor {}",
697 deflated.deflate_floor,
698 floor
699 );
700 let rel = (deflated.estimate - reference).abs() / reference.abs().max(1.0);
701 assert!(
702 rel < 0.02,
703 "unit-deflated SLQ {} must match the kept-eigenvalue reference {} (rel {rel:.3e})",
704 deflated.estimate,
705 reference
706 );
707
708 // Plain SLQ (no deflation) is dragged HUNDREDS of units below by the
709 // collapsed direction's `ln(RITZ_LN_FLOOR)` contribution — the exact
710 // ρ-dependent-Occam-reward bug the matrix-free unit deflation removes.
711 let plain = slq_logdet(dim, |v| a.dot(&v), 48, dim, 0xD1F);
712 eprintln!("plain est={:.6}", plain.estimate);
713 assert!(
714 deflated.estimate - plain.estimate > 100.0,
715 "plain SLQ ({}) must sit far below the unit-deflated estimate ({})",
716 plain.estimate,
717 deflated.estimate
718 );
719 }
720
721 /// #2308 — with NO sub-floor direction, unit deflation deflates nothing, so it
722 /// is bit-identical to the plain estimator (same probe stream, every Ritz value
723 /// kept) and equally close to the exact log-determinant.
724 #[test]
725 fn slq_unit_deflation_matches_plain_when_no_nulls_2308() {
726 let dim = 120usize;
727 let a = random_spd(dim, dim + 40, 5.0, 3);
728 let exact = exact_logdet(&a);
729 let deflated = slq_logdet_unit_deflated(
730 dim,
731 |v| a.dot(&v),
732 48,
733 70,
734 0xA5A5,
735 SPECTRAL_DEFLATION_REL_FLOOR,
736 );
737 let plain = slq_logdet(dim, |v| a.dot(&v), 48, 70, 0xA5A5);
738 assert_eq!(
739 deflated.estimate.to_bits(),
740 plain.estimate.to_bits(),
741 "no deflation ⇒ unit-deflated estimate must be bit-identical to plain"
742 );
743 let rel = (deflated.estimate - exact).abs() / exact.abs();
744 assert!(
745 rel < 0.05,
746 "unit-deflated SLQ rel err {rel:.3e} vs exact {exact}"
747 );
748 }
749
750 /// #2308 — degenerate operators: the empty and the fully-collapsed (`A = 0`)
751 /// operator both have a finite unit-deflated log-det of `0` (every direction
752 /// pinned to unit), never `−∞`.
753 #[test]
754 fn slq_unit_deflation_empty_and_degenerate_2308() {
755 let empty = slq_logdet_unit_deflated(
756 0,
757 |v| v.to_owned(),
758 8,
759 8,
760 1,
761 SPECTRAL_DEFLATION_REL_FLOOR,
762 );
763 assert_eq!(empty.estimate, 0.0);
764 assert_eq!(empty.lambda_max_abs, 0.0);
765
766 let dim = 16usize;
767 let zeros = slq_logdet_unit_deflated(
768 dim,
769 |v| Array1::<f64>::zeros(v.len()),
770 8,
771 dim,
772 2,
773 SPECTRAL_DEFLATION_REL_FLOOR,
774 );
775 assert!(zeros.estimate.is_finite());
776 assert_eq!(zeros.estimate, 0.0);
777 }
778
779 /// #2308 — the unit-deflated estimate (value AND floor) is bit-reproducible for
780 /// a fixed `(dim, matvec, probes, steps, seed)`, as the REML evidence outer
781 /// loop requires of a differentiated objective.
782 #[test]
783 fn slq_unit_deflation_is_deterministic_2308() {
784 let dim = 40usize;
785 let mut state = 9u64;
786 let mut eigenvalues = vec![0.0_f64; dim];
787 for e in eigenvalues.iter_mut() {
788 *e = next_uniform(&mut state, 0.3, 8.0);
789 }
790 eigenvalues[0] = -1.0e-10;
791 let a = householder_spectrum_matrix(&eigenvalues, 77);
792 let r1 = slq_logdet_unit_deflated(
793 dim,
794 |v| a.dot(&v),
795 24,
796 dim,
797 99,
798 SPECTRAL_DEFLATION_REL_FLOOR,
799 );
800 let r2 = slq_logdet_unit_deflated(
801 dim,
802 |v| a.dot(&v),
803 24,
804 dim,
805 99,
806 SPECTRAL_DEFLATION_REL_FLOOR,
807 );
808 assert_eq!(r1.estimate.to_bits(), r2.estimate.to_bits());
809 assert_eq!(r1.deflate_floor.to_bits(), r2.deflate_floor.to_bits());
810 }
811}