Skip to main content

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}