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 symmetric_lanczos_eigenpairs_with_original_vectors,
56};
57use gam_linalg::utils::splitmix64;
58use rayon::iter::{IntoParallelIterator, ParallelIterator};
59
60/// Result of a Stochastic Lanczos Quadrature log-determinant estimate.
61#[derive(Debug, Clone, Copy)]
62pub struct SlqLogDet {
63 /// Estimate of `log det A`.
64 pub estimate: f64,
65 /// Standard error of the estimate: the sample standard deviation of the
66 /// per-probe contributions divided by `sqrt(num_probes)`. With a single
67 /// probe this is `0.0` (no spread is observable).
68 pub std_err: f64,
69}
70
71/// Draw a deterministic Rademacher (±1) vector of length `dim` into `z`,
72/// seeded reproducibly by `probe_seed`. Two bits per draw are wasteful but the
73/// per-element top-bit read keeps this trivially correct and stream-stable.
74fn rademacher_into(z: &mut Array1<f64>, probe_seed: u64) {
75 let mut state = probe_seed;
76 let mut bits: u64 = 0;
77 let mut remaining: u32 = 0;
78 for value in z.iter_mut() {
79 if remaining == 0 {
80 bits = splitmix64(&mut state);
81 remaining = 64;
82 }
83 *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
84 bits >>= 1;
85 remaining -= 1;
86 }
87}
88
89/// The fixed Lanczos configuration every SLQ probe runs: `steps` Gauss nodes,
90/// no early residual break (exhaust the Krylov space), and FULL
91/// reorthogonalization (numerically essential — without it Lanczos loses
92/// orthogonality and produces ghost Ritz values that poison the quadrature).
93#[inline]
94fn slq_lanczos_options(steps: usize) -> SymmetricLanczosOptions {
95 SymmetricLanczosOptions {
96 max_steps: steps,
97 residual_tol: 0.0,
98 local_reorthogonalize: false,
99 full_reorthogonalize: true,
100 }
101}
102
103/// Run one Rademacher-probe Lanczos and return the tridiagonal eigenpairs, or
104/// `None` if the Lanczos run declines (non-finite matvec / start). Shared by the
105/// plain [`slq_logdet`] and the unit-deflated [`slq_logdet_unit_deflated`]
106/// estimators so both draw the IDENTICAL probe vector and build the IDENTICAL
107/// Krylov space for a given `(dim, matvec, probe_seed, options)` — the two
108/// estimators then differ ONLY in the spectral function applied to the shared
109/// Ritz pairs. Each probe carries its own Rademacher vector and matvec input
110/// scratch (no shared mutable state), so a `rayon` fan-out over probes is
111/// bit-identical to the serial build.
112fn probe_lanczos_eigenpairs(
113 dim: usize,
114 probe_seed: u64,
115 options: SymmetricLanczosOptions,
116 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
117 lift_original_vectors: bool,
118) -> Option<SymmetricLanczosEigenpairs> {
119 let mut z = Array1::<f64>::zeros(dim);
120 rademacher_into(&mut z, probe_seed);
121 // The workspace Lanczos engine consumes `apply(&[f64], &mut [f64])`; wrap the
122 // ndarray `matvec` into that slice contract with a per-probe input buffer so
123 // probes never share mutable scratch.
124 let mut in_buf = Array1::<f64>::zeros(dim);
125 let mut apply = |x: &[f64], out: &mut [f64]| -> Result<(), String> {
126 in_buf
127 .as_slice_mut()
128 .expect("contiguous probe input buffer")
129 .copy_from_slice(x);
130 let y = matvec(in_buf.view());
131 if y.len() != dim {
132 return Err(format!(
133 "slq_logdet matvec returned length {}, expected {dim}",
134 y.len()
135 ));
136 }
137 out.copy_from_slice(y.as_slice().expect("contiguous matvec output"));
138 Ok(())
139 };
140 let start = z.as_slice().expect("contiguous probe vector");
141 if lift_original_vectors {
142 symmetric_lanczos_eigenpairs_with_original_vectors(dim, start, options, &mut apply).ok()
143 } else {
144 symmetric_lanczos_eigenpairs(dim, start, options, &mut apply).ok()
145 }
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(
220 dim,
221 probe_seed,
222 lanczos_options,
223 matvec,
224 false,
225 ) {
226 Some(pairs) => {
227 norm_sq * clamped_log_quadrature(&pairs.eigenvalues, &pairs.eigenvectors)
228 }
229 // A Lanczos failure (non-finite matvec / start) cannot be silently
230 // averaged in; the dense-Cholesky gate above this call should have
231 // caught a degenerate operator. Treat it as a zero contribution and
232 // let the std-error widen rather than poisoning the mean with NaN.
233 None => 0.0,
234 }
235 })
236 .collect();
237
238 let (estimate, std_err) = slq_mean_std_err(&contributions);
239 SlqLogDet { estimate, std_err }
240}
241
242/// Result of a unit-deflated SLQ log-determinant estimate.
243///
244/// The estimate is `tr(φ(A))` with the unit-deflation spectral function
245/// `φ(θ) = θ ≥ deflate_floor ? ln θ : 0`, i.e. every eigenvalue at or below the
246/// floor is pinned to unit stiffness and contributes `ln 1 = 0` — the exact
247/// matrix-free analogue of the dense
248/// `ReducedSchurPolicy::EvidenceUnitDeflation` convention
249/// (see #2308). `lambda_max_abs` and `deflate_floor` are reported so callers can
250/// audit the scale at which deflation kicked in.
251#[derive(Debug, Clone, Copy)]
252pub struct SlqUnitDeflatedLogDet {
253 /// Estimate of the unit-deflated log-determinant `Σ_{λ ≥ floor} ln λ`.
254 pub estimate: f64,
255 /// Standard error of the estimate across probes (`0.0` for a single probe).
256 pub std_err: f64,
257 /// Estimated spectral radius `max|λ|` — the largest `|Ritz value|` observed
258 /// across every probe. The deflation floor is relative to this scale, so it
259 /// is the matrix-free stand-in for the dense path's `max|λ|`.
260 pub lambda_max_abs: f64,
261 /// The absolute deflation floor actually applied:
262 /// `relative_floor · lambda_max_abs · (1 − hysteresis)`.
263 pub deflate_floor: f64,
264}
265
266impl SlqUnitDeflatedLogDet {
267 /// View as a plain [`SlqLogDet`] (estimate + std-error), dropping the
268 /// deflation metadata — for the evidence plumbing that only consumes the
269 /// scalar log-determinant and its uncertainty band.
270 #[inline]
271 pub fn as_logdet(&self) -> SlqLogDet {
272 SlqLogDet {
273 estimate: self.estimate,
274 std_err: self.std_err,
275 }
276 }
277}
278
279/// Estimate the UNIT-DEFLATED log-determinant of a symmetric operator `A`
280/// available only through matvecs — the matrix-free counterpart of the dense
281/// `EvidenceUnitDeflation` reduced-Schur policy (#2308).
282///
283/// SLQ estimates `tr(f(A))` for ANY spectral function `f` via the same Gauss
284/// quadrature; the plain [`slq_logdet`] uses `f = ln`. Unit deflation is simply
285/// a DIFFERENT `f`:
286///
287/// ```text
288/// φ(θ) = ln θ, θ ≥ deflate_floor
289/// φ(θ) = 0, θ < deflate_floor (pinned to unit stiffness: ln 1 = 0)
290/// ```
291///
292/// so `tr(φ(A)) = Σ_{λ_i ≥ floor} ln λ_i` — every collapsed / near-null / (round-off
293/// or genuinely) negative-curvature direction contributes exactly `0` instead of
294/// the plain estimator's `ln(band) ≈ ln(γ_m·max|θ|)` per such direction. This
295/// matches the dense convention where a sub-floor eigenvalue is pinned to `λ̃ = 1`.
296///
297/// The floor is RELATIVE, exactly as in the dense path:
298/// `deflate_floor = relative_floor · max|λ| · (1 − SPECTRAL_DEFLATION_HYSTERESIS_FRACTION)`,
299/// with `max|λ|` estimated as the largest `|Ritz value|` over all probes — Lanczos
300/// converges to the extreme eigenvalues first, so this is a sharp, deterministic
301/// spectral-radius estimate. A single shared floor is computed BEFORE any
302/// contribution so every probe deflates against the SAME threshold (a per-probe
303/// floor would make the estimate non-linear in the probes and break determinism
304/// of the deflation set).
305///
306/// Determinism, probe fan-out, and the `dim == 0 ⇒ 0` convention are identical to
307/// [`slq_logdet`]; the two share `probe_lanczos_eigenpairs`, so for a fixed
308/// `(dim, matvec, num_probes, lanczos_steps, seed)` the two estimators build
309/// bit-identical Krylov spaces and differ ONLY in the applied spectral function.
310pub fn slq_logdet_unit_deflated(
311 dim: usize,
312 matvec: impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync,
313 num_probes: usize,
314 lanczos_steps: usize,
315 seed: u64,
316 relative_floor: f64,
317) -> SlqUnitDeflatedLogDet {
318 if dim == 0 {
319 return SlqUnitDeflatedLogDet {
320 estimate: 0.0,
321 std_err: 0.0,
322 lambda_max_abs: 0.0,
323 deflate_floor: 0.0,
324 };
325 }
326 let num_probes = num_probes.max(1);
327 let steps = lanczos_steps.max(1).min(dim);
328 let norm_sq = dim as f64;
329 let lanczos_options = slq_lanczos_options(steps);
330
331 // Pass 1 — build every probe's Ritz pairs (the expensive matvec work), fanned
332 // across rayon workers. The ordered buffer keeps the reduction reproducible.
333 let matvec = &matvec;
334 let per_probe: Vec<Option<SymmetricLanczosEigenpairs>> = (0..num_probes)
335 .into_par_iter()
336 .map(|probe| {
337 let probe_seed = seed.wrapping_add(probe as u64);
338 probe_lanczos_eigenpairs(dim, probe_seed, lanczos_options, matvec, false)
339 })
340 .collect();
341
342 // A single shared spectral-radius estimate `max|λ|` over ALL probes' Ritz
343 // values, and from it the ONE deflation floor every probe uses.
344 let lambda_max_abs = per_probe
345 .iter()
346 .flatten()
347 .flat_map(|pairs| pairs.eigenvalues.iter())
348 .filter(|value| value.is_finite())
349 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
350 if !(lambda_max_abs.is_finite() && lambda_max_abs > 0.0) {
351 // No usable spectrum (empty / all-zero / every probe declined): the
352 // unit-deflated determinant of a fully-deflated operator is `Σ 0 = 0`.
353 return SlqUnitDeflatedLogDet {
354 estimate: 0.0,
355 std_err: 0.0,
356 lambda_max_abs: 0.0,
357 deflate_floor: 0.0,
358 };
359 }
360 let deflate_floor =
361 relative_floor * lambda_max_abs * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
362
363 // Pass 2 — apply the unit-deflation spectral function against the shared floor.
364 let contributions: Vec<f64> = per_probe
365 .iter()
366 .map(|maybe_pairs| match maybe_pairs {
367 Some(pairs) => {
368 norm_sq
369 * deflated_log_quadrature(
370 &pairs.eigenvalues,
371 &pairs.eigenvectors,
372 deflate_floor,
373 )
374 }
375 None => 0.0,
376 })
377 .collect();
378
379 let (estimate, std_err) = slq_mean_std_err(&contributions);
380 SlqUnitDeflatedLogDet {
381 estimate,
382 std_err,
383 lambda_max_abs,
384 deflate_floor,
385 }
386}
387
388struct ExactAProbeRitzGeometry {
389 pairs: SymmetricLanczosEigenpairs,
390 majorizer_curvatures: Array1<f64>,
391 clamp_curvatures: Array1<f64>,
392}
393
394/// Matrix-free exact-`A` SLQ with the same typed direction classifier as the
395/// dense and direct-arrow routes (#2515).
396///
397/// A negative Ritz value is only a Rayleigh quotient of the raw observed
398/// information. It is not itself a saddle verdict. This routine lifts every
399/// Ritz direction back through the Lanczos basis, reduces it immediately to
400/// `(v'Bv, v'Ev)`, and then applies [`classify_exact_a_direction`] against the
401/// shared spectral scale. Numerical nulls contribute `log(1) = 0`, bounded
402/// clamp wrinkles contribute `log(v'(A+E)v)`, and only a typed `Saddle` is
403/// refused. The lifted `dimension × steps` block is dropped before the probe
404/// leaves its worker; the retained carrier is two scalar arrays per probe.
405pub(crate) fn slq_logdet_exact_a_classified(
406 dim: usize,
407 matvec: impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync,
408 direction_metrics: impl Fn(ArrayView1<f64>) -> Result<(f64, f64), String> + Sync,
409 num_probes: usize,
410 lanczos_steps: usize,
411 seed: u64,
412) -> Result<SlqLogDet, String> {
413 if dim == 0 {
414 return Ok(SlqLogDet {
415 estimate: 0.0,
416 std_err: 0.0,
417 });
418 }
419 let num_probes = num_probes.max(1);
420 let steps = lanczos_steps.max(1).min(dim);
421 let norm_sq = dim as f64;
422 let lanczos_options = slq_lanczos_options(steps);
423 let matvec = &matvec;
424 let direction_metrics = &direction_metrics;
425 let per_probe = (0..num_probes)
426 .into_par_iter()
427 .map(|probe| {
428 let probe_seed = seed.wrapping_add(probe as u64);
429 let Some(mut pairs) = probe_lanczos_eigenpairs(
430 dim,
431 probe_seed,
432 lanczos_options,
433 matvec,
434 true,
435 ) else {
436 return Ok(None);
437 };
438 let original = pairs.original_eigenvectors.take().ok_or_else(|| {
439 "exact-A SLQ requested lifted Ritz vectors, but Lanczos returned none"
440 .to_string()
441 })?;
442 if original.dim() != (dim, pairs.eigenvalues.len()) {
443 return Err(format!(
444 "exact-A SLQ lifted Ritz block is {:?}, expected ({dim}, {})",
445 original.dim(),
446 pairs.eigenvalues.len(),
447 ));
448 }
449 let mut majorizer_curvatures = Array1::<f64>::zeros(pairs.eigenvalues.len());
450 let mut clamp_curvatures = Array1::<f64>::zeros(pairs.eigenvalues.len());
451 for ritz in 0..pairs.eigenvalues.len() {
452 let (majorizer, clamp) = direction_metrics(original.column(ritz))?;
453 if !(majorizer.is_finite() && clamp.is_finite()) {
454 return Err(format!(
455 "exact-A SLQ Ritz direction {ritz} has non-finite classification metrics \
456 (majorizer={majorizer:e}, clamp={clamp:e})"
457 ));
458 }
459 majorizer_curvatures[ritz] = majorizer;
460 clamp_curvatures[ritz] = clamp;
461 }
462 Ok(Some(ExactAProbeRitzGeometry {
463 pairs,
464 majorizer_curvatures,
465 clamp_curvatures,
466 }))
467 })
468 .collect::<Vec<Result<Option<ExactAProbeRitzGeometry>, String>>>()
469 .into_iter()
470 .collect::<Result<Vec<_>, _>>()?;
471
472 let spectral_norm = per_probe
473 .iter()
474 .flatten()
475 .flat_map(|probe| probe.pairs.eigenvalues.iter())
476 .filter(|value| value.is_finite())
477 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
478 if !(spectral_norm.is_finite() && spectral_norm > 0.0) {
479 return Err("exact-A SLQ produced no finite nonzero Ritz spectrum".to_string());
480 }
481
482 let mut contributions = Vec::with_capacity(num_probes);
483 for (probe_index, maybe_probe) in per_probe.iter().enumerate() {
484 let Some(probe) = maybe_probe else {
485 contributions.push(0.0);
486 continue;
487 };
488 let mut quadrature = 0.0_f64;
489 for ritz in 0..probe.pairs.eigenvalues.len() {
490 let raw = probe.pairs.eigenvalues[ritz];
491 let priced = match classify_exact_a_direction(
492 raw,
493 dim,
494 spectral_norm,
495 probe.majorizer_curvatures[ritz],
496 probe.clamp_curvatures[ritz],
497 ) {
498 ExactADirectionClassification::ResolvedPositive { curvature }
499 | ExactADirectionClassification::ClampBasin { curvature } => Some(curvature),
500 ExactADirectionClassification::NumericalNull => None,
501 ExactADirectionClassification::Saddle { curvature, basin } => {
502 return Err(format!(
503 "matrix-free reduced-Schur {}: probe {probe_index} Ritz direction \
504 {ritz} has raw exact-A curvature {curvature:.6e} and clamp basin \
505 {basin:.6e}; the shared majorizer-metric classifier declares a \
506 genuine saddle (#2515/#2336)",
507 ArrowSchurError::indefinite_evidence_marker(),
508 ));
509 }
510 };
511 if let Some(curvature) = priced {
512 let first_component = probe.pairs.eigenvectors[[0, ritz]];
513 quadrature += first_component * first_component * curvature.ln();
514 }
515 }
516 contributions.push(norm_sq * quadrature);
517 }
518 let (estimate, std_err) = slq_mean_std_err(&contributions);
519 Ok(SlqLogDet { estimate, std_err })
520}
521
522/// Build the low-rank operator correction consumed by the rational exact-A
523/// lane from one deterministic Lanczos eigensystem.
524///
525/// The returned Ritz directions are orthonormal because the source Lanczos run
526/// uses full reorthogonalization. Each shift is exactly `priced - raw` under
527/// the same classifier as [`slq_logdet_exact_a_classified`], so applying the
528/// carrier transforms numerical nulls to unit stiffness and bounded clamp
529/// wrinkles to their basin curvature before any positive-shift solve is built.
530pub(crate) fn exact_a_ritz_conditioning(
531 dim: usize,
532 matvec: impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync,
533 direction_metrics: impl Fn(ArrayView1<f64>) -> Result<(f64, f64), String> + Sync,
534 lanczos_steps: usize,
535 seed: u64,
536) -> Result<ExactAReducedRitzConditioning, String> {
537 if dim == 0 {
538 return Ok(ExactAReducedRitzConditioning {
539 directions: Arc::from([] as [Array1<f64>; 0]),
540 shifts: Arc::from([] as [f64; 0]),
541 });
542 }
543 let options = slq_lanczos_options(lanczos_steps.max(1).min(dim));
544 let mut pairs = probe_lanczos_eigenpairs(dim, seed, options, &matvec, true)
545 .ok_or_else(|| "exact-A rational conditioning Lanczos run declined".to_string())?;
546 let original = pairs.original_eigenvectors.take().ok_or_else(|| {
547 "exact-A rational conditioning requested lifted Ritz vectors, but Lanczos returned none"
548 .to_string()
549 })?;
550 let spectral_norm = pairs
551 .eigenvalues
552 .iter()
553 .filter(|value| value.is_finite())
554 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
555 if !(spectral_norm.is_finite() && spectral_norm > 0.0) {
556 return Err("exact-A rational conditioning produced no usable Ritz spectrum".to_string());
557 }
558 let mut directions = Vec::new();
559 let mut shifts = Vec::new();
560 for ritz in 0..pairs.eigenvalues.len() {
561 let direction = original.column(ritz);
562 let raw = pairs.eigenvalues[ritz];
563 let (majorizer, clamp) = direction_metrics(direction)?;
564 let priced = match classify_exact_a_direction(
565 raw,
566 dim,
567 spectral_norm,
568 majorizer,
569 clamp,
570 ) {
571 ExactADirectionClassification::ResolvedPositive { .. } => None,
572 ExactADirectionClassification::NumericalNull => Some(1.0),
573 ExactADirectionClassification::ClampBasin { curvature } => Some(curvature),
574 ExactADirectionClassification::Saddle { curvature, basin } => {
575 return Err(format!(
576 "matrix-free reduced-Schur {}: rational-ladder Ritz direction {ritz} \
577 has raw exact-A curvature {curvature:.6e} and clamp basin {basin:.6e}; \
578 the shared majorizer-metric classifier declares a genuine saddle \
579 (#2515/#2336)",
580 ArrowSchurError::indefinite_evidence_marker(),
581 ));
582 }
583 };
584 if let Some(priced) = priced {
585 directions.push(direction.to_owned());
586 shifts.push(priced - raw);
587 }
588 }
589 Ok(ExactAReducedRitzConditioning {
590 directions: directions.into(),
591 shifts: shifts.into(),
592 })
593}
594
595/// Gauss quadrature `e₁ᵀ ln(T) e₁ = Σ_i (τ_{i,0})² ln(θ_i)` over the Lanczos
596/// tridiagonal eigenpairs, with `θ_i` floored to the Lanczos rounding band `γ_m·max|θ|` so a
597/// round-off-negative Ritz value (the SPD operator forbids genuine ones) cannot
598/// produce a `NaN`. `eigenvectors` columns are the Ritz vectors `y_i`; `τ_{i,0}`
599/// is their first component.
600fn clamped_log_quadrature(eigenvalues: &Array1<f64>, eigenvectors: &Array2<f64>) -> f64 {
601 // The operator is SPD, so a Ritz value at or below zero is Lanczos
602 // round-off: the tridiagonal's eigenvalues carry an error of order
603 // `γ_m·max|θ|` for `m` Lanczos steps. A Ritz value inside that band is
604 // indistinguishable from the band itself, and is evaluated there — the
605 // arithmetic's own scale, not an absolute `1e-300` that contributed
606 // `ln(1e-300) ≈ −690` per such direction (#2469).
607 let steps = eigenvalues.len();
608 let theta_max = eigenvalues.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
609 let ritz_band = gam_linalg::roundoff::accumulation_growth(steps) * theta_max;
610 let mut quad = 0.0_f64;
611 for i in 0..eigenvalues.len() {
612 let tau0 = eigenvectors[[0, i]];
613 let weight = tau0 * tau0;
614 let lambda = eigenvalues[i].max(ritz_band);
615 quad += weight * lambda.ln();
616 }
617 quad
618}
619
620/// Unit-deflated Gauss quadrature `Σ_i (τ_{i,0})² φ(θ_i)` with the deflation
621/// spectral function `φ(θ) = θ ≥ deflate_floor ? ln θ : 0`. A Ritz value at or
622/// below the floor (collapsed / near-null / round-off- or genuinely-negative)
623/// is pinned to unit stiffness and contributes `ln 1 = 0`; a kept Ritz value
624/// (necessarily `> deflate_floor > 0`) contributes `ln θ`. This is the
625/// matrix-free image of the dense evidence unit deflation (#2308).
626fn deflated_log_quadrature(
627 eigenvalues: &Array1<f64>,
628 eigenvectors: &Array2<f64>,
629 deflate_floor: f64,
630) -> f64 {
631 let mut quad = 0.0_f64;
632 for i in 0..eigenvalues.len() {
633 let lambda = eigenvalues[i];
634 if lambda < deflate_floor {
635 // Deflated direction: pinned to λ̃ = 1, contributes ln 1 = 0.
636 continue;
637 }
638 let tau0 = eigenvectors[[0, i]];
639 let weight = tau0 * tau0;
640 // `deflate_floor > 0`, so a kept Ritz value is strictly positive.
641 quad += weight * lambda.ln();
642 }
643 quad
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 /// Deterministic uniform draw in `[lo, hi)` from a SplitMix64 state — keeps
651 /// the test fixtures reproducible with no external RNG dependency.
652 fn next_uniform(state: &mut u64, lo: f64, hi: f64) -> f64 {
653 // 53-bit mantissa fraction in [0, 1).
654 let bits = splitmix64(state) >> 11;
655 let unit = (bits as f64) / ((1u64 << 53) as f64);
656 lo + (hi - lo) * unit
657 }
658
659 /// Build a random SPD matrix `A = MᵀM + δI` (`dim × dim`) from a fixed seed.
660 /// `m_rows ≥ dim` keeps `MᵀM` well-conditioned; `delta` sets the floor on the
661 /// spectrum (larger `delta` ⇒ better conditioned).
662 fn random_spd(dim: usize, m_rows: usize, delta: f64, seed: u64) -> Array2<f64> {
663 let mut state = seed;
664 let mut m = Array2::<f64>::zeros((m_rows, dim));
665 for value in m.iter_mut() {
666 *value = next_uniform(&mut state, -1.0, 1.0);
667 }
668 let mut a = m.t().dot(&m);
669 for i in 0..dim {
670 a[[i, i]] += delta;
671 }
672 // Symmetrize defensively against round-off.
673 for i in 0..dim {
674 for j in (i + 1)..dim {
675 let avg = 0.5 * (a[[i, j]] + a[[j, i]]);
676 a[[i, j]] = avg;
677 a[[j, i]] = avg;
678 }
679 }
680 a
681 }
682
683 /// Exact `log det A` via the workspace symmetric eigensolver (`Σ ln λ_i`).
684 fn exact_logdet(a: &Array2<f64>) -> f64 {
685 let (evals, _) = a.eigh(Side::Lower).expect("SPD eigendecomposition");
686 evals.iter().map(|&l| l.ln()).sum()
687 }
688
689 fn condition_number(a: &Array2<f64>) -> f64 {
690 let (evals, _) = a.eigh(Side::Lower).expect("SPD eigendecomposition");
691 let max = evals.iter().cloned().fold(f64::MIN, f64::max);
692 let min = evals.iter().cloned().fold(f64::MAX, f64::min);
693 max / min
694 }
695
696 #[test]
697 fn slq_matches_exact_logdet_well_conditioned() {
698 // A spread of dimensions in the 60–200 range, all well-conditioned
699 // (generous δ), checked against the exact eigenvalue log-determinant.
700 for (dim, seed) in [(60usize, 1u64), (120, 2), (200, 3)] {
701 let a = random_spd(dim, dim + 40, 5.0, seed);
702 let exact = exact_logdet(&a);
703 let cond = condition_number(&a);
704
705 let result = slq_logdet(dim, |v| a.dot(&v), 48, 70, 0xA5A5_0000 ^ seed);
706
707 let rel_err = (result.estimate - exact).abs() / exact.abs();
708 eprintln!(
709 "well-conditioned dim={dim} cond={cond:.2e} exact={exact:.6} \
710 est={:.6} rel_err={rel_err:.4e} std_err={:.4e}",
711 result.estimate, result.std_err
712 );
713 assert!(
714 rel_err < 0.05,
715 "dim={dim}: SLQ relative error {rel_err:.4e} exceeds 5% \
716 (exact={exact}, est={})",
717 result.estimate
718 );
719 // The exact value should sit within a few standard errors of the
720 // estimate (the std_err must be a meaningful uncertainty band).
721 assert!(
722 (result.estimate - exact).abs() < 3.0 * result.std_err + 0.05 * exact.abs(),
723 "dim={dim}: estimate not within ~3 std_err of exact \
724 (|Δ|={:.4e}, std_err={:.4e})",
725 (result.estimate - exact).abs(),
726 result.std_err
727 );
728 }
729 }
730
731 #[test]
732 fn slq_handles_moderately_ill_conditioned() {
733 // Smaller δ ⇒ a tighter spectral floor ⇒ a more ill-conditioned A.
734 // More Lanczos steps resolve the wider spectrum.
735 let dim = 150usize;
736 let a = random_spd(dim, dim + 5, 0.05, 7);
737 let exact = exact_logdet(&a);
738 let cond = condition_number(&a);
739 assert!(
740 cond > 1e3,
741 "test fixture should be moderately ill-conditioned, got cond={cond:.2e}"
742 );
743
744 let result = slq_logdet(dim, |v| a.dot(&v), 40, 110, 0xC0FFEE);
745 let rel_err = (result.estimate - exact).abs() / exact.abs();
746 eprintln!(
747 "ill-conditioned dim={dim} cond={cond:.2e} exact={exact:.6} \
748 est={:.6} rel_err={rel_err:.4e} std_err={:.4e}",
749 result.estimate, result.std_err
750 );
751 assert!(
752 rel_err < 0.10,
753 "ill-conditioned dim={dim}: SLQ relative error {rel_err:.4e} \
754 exceeds 10% (cond={cond:.2e}, exact={exact}, est={})",
755 result.estimate
756 );
757 }
758
759 #[test]
760 fn slq_is_deterministic_for_fixed_seed() {
761 let dim = 80usize;
762 let a = random_spd(dim, dim + 20, 2.0, 11);
763 let r1 = slq_logdet(dim, |v| a.dot(&v), 24, 50, 99);
764 let r2 = slq_logdet(dim, |v| a.dot(&v), 24, 50, 99);
765 assert_eq!(
766 r1.estimate, r2.estimate,
767 "SLQ must be bit-reproducible for a fixed seed"
768 );
769 assert_eq!(r1.std_err, r2.std_err);
770 }
771
772 #[test]
773 fn slq_diagonal_operator_matches_closed_form() {
774 // A diagonal operator has a closed-form log-determinant Σ ln d_i; this
775 // exercises the matvec closure path without any matrix assembly.
776 let dim = 100usize;
777 let mut state = 123u64;
778 let diag: Vec<f64> = (0..dim)
779 .map(|_| next_uniform(&mut state, 0.5, 4.0))
780 .collect();
781 let exact: f64 = diag.iter().map(|d| d.ln()).sum();
782
783 let diag_clone = diag.clone();
784 let result = slq_logdet(
785 dim,
786 move |v| {
787 let mut out = v.to_owned();
788 for (o, d) in out.iter_mut().zip(diag_clone.iter()) {
789 *o *= d;
790 }
791 out
792 },
793 32,
794 60,
795 7,
796 );
797 let rel_err = (result.estimate - exact).abs() / exact.abs();
798 eprintln!(
799 "diagonal dim={dim} exact={exact:.6} est={:.6} rel_err={rel_err:.4e}",
800 result.estimate
801 );
802 assert!(
803 rel_err < 0.05,
804 "diagonal operator: relative error {rel_err:.4e} exceeds 5%"
805 );
806 }
807
808 #[test]
809 fn slq_empty_operator_is_zero() {
810 let result = slq_logdet(0, |v| v.to_owned(), 8, 8, 1);
811 assert_eq!(result.estimate, 0.0);
812 assert_eq!(result.std_err, 0.0);
813 }
814
815 #[test]
816 fn std_err_shrinks_with_more_probes() {
817 // The standard error of a Monte-Carlo mean falls ~1/sqrt(num_probes);
818 // many probes should give a tighter band than few.
819 let dim = 120usize;
820 let a = random_spd(dim, dim + 30, 3.0, 21);
821 let few = slq_logdet(dim, |v| a.dot(&v), 6, 60, 5);
822 let many = slq_logdet(dim, |v| a.dot(&v), 96, 60, 5);
823 eprintln!(
824 "std_err few(6)={:.4e} many(96)={:.4e}",
825 few.std_err, many.std_err
826 );
827 assert!(
828 many.std_err < few.std_err,
829 "more probes should reduce std_err (few={:.4e}, many={:.4e})",
830 few.std_err,
831 many.std_err
832 );
833 }
834
835 /// Dense symmetric `A = H diag(λ) H` with `H = I − 2wwᵀ` (‖w‖=1) a Householder
836 /// reflector — orthogonal AND symmetric, so `A`'s eigenvalues are EXACTLY the
837 /// planted `λ` and its eigenvectors are the columns of `H`. Gives a genuinely
838 /// non-diagonal operator with a known, hand-chosen spectrum (unlike
839 /// `random_spd`, whose spectrum would have to be eigendecomposed to learn),
840 /// so a deflation test can plant a specific collapsed direction.
841 fn householder_spectrum_matrix(eigenvalues: &[f64], seed: u64) -> Array2<f64> {
842 let dim = eigenvalues.len();
843 let mut state = seed;
844 let mut w = Array1::<f64>::zeros(dim);
845 for value in w.iter_mut() {
846 *value = next_uniform(&mut state, -1.0, 1.0);
847 }
848 let norm = w.dot(&w).sqrt();
849 w.mapv_inplace(|v| v / norm);
850 // H = I − 2 w wᵀ.
851 let mut h = Array2::<f64>::eye(dim);
852 for i in 0..dim {
853 for j in 0..dim {
854 h[[i, j]] -= 2.0 * w[i] * w[j];
855 }
856 }
857 // A = (H D) H, with D = diag(λ). H is symmetric, so A = H D Hᵀ is symmetric
858 // with eigenpairs (λ_j, H[:, j]).
859 let mut hd = h.clone();
860 for j in 0..dim {
861 for i in 0..dim {
862 hd[[i, j]] *= eigenvalues[j];
863 }
864 }
865 hd.dot(&h)
866 }
867
868 /// #2308 — the matrix-free evidence log|S| MUST obey the same unit-deflation
869 /// convention as the dense reduced-Schur factor: a collapsed / near-null /
870 /// negative-curvature direction is pinned to unit stiffness and contributes
871 /// `ln 1 = 0`, NOT the plain estimator's `ln(γ_m·max|θ|)`.
872 #[test]
873 fn slq_unit_deflation_pins_collapsed_direction_to_unit_2308() {
874 let dim = 48usize;
875 let mut state = 0x2308_0001_u64;
876 let mut eigenvalues = vec![0.0_f64; dim];
877 for e in eigenvalues.iter_mut() {
878 *e = next_uniform(&mut state, 0.5, 12.0);
879 }
880 // One collapsed direction: genuinely negative curvature, |λ| ≪ floor —
881 // exactly the collapsed-decoder mode the evidence deflation targets.
882 eigenvalues[dim - 1] = -3.0e-11;
883
884 let a = householder_spectrum_matrix(&eigenvalues, 0x51A9);
885 let max_abs = eigenvalues.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
886 // The dense convention's floor and the kept-eigenvalue reference log-det.
887 let floor = SPECTRAL_DEFLATION_REL_FLOOR * max_abs
888 * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
889 let reference: f64 = eigenvalues
890 .iter()
891 .filter(|&&l| l >= floor)
892 .map(|&l| l.ln())
893 .sum();
894
895 let deflated = slq_logdet_unit_deflated(
896 dim,
897 |v| a.dot(&v),
898 48,
899 dim,
900 0xD1F,
901 SPECTRAL_DEFLATION_REL_FLOOR,
902 );
903 eprintln!(
904 "unit-deflated est={:.6} reference={:.6} lambda_max_abs={:.6} floor={:.3e}",
905 deflated.estimate, reference, deflated.lambda_max_abs, deflated.deflate_floor
906 );
907 // The spectral-radius estimate recovers max|λ|, and the floor is the dense
908 // path's floor built from it.
909 assert!(
910 (deflated.lambda_max_abs - max_abs).abs() / max_abs < 1e-6,
911 "lambda_max_abs {} should recover planted max|λ| {}",
912 deflated.lambda_max_abs,
913 max_abs
914 );
915 assert!(
916 (deflated.deflate_floor - floor).abs() / floor < 1e-9,
917 "deflate_floor {} should equal the dense relative floor {}",
918 deflated.deflate_floor,
919 floor
920 );
921 let rel = (deflated.estimate - reference).abs() / reference.abs().max(1.0);
922 assert!(
923 rel < 0.02,
924 "unit-deflated SLQ {} must match the kept-eigenvalue reference {} (rel {rel:.3e})",
925 deflated.estimate,
926 reference
927 );
928
929 // Plain SLQ (no deflation) is dragged below by the collapsed direction's
930 // `ln(γ_m·max|θ|)` contribution — the Lanczos arithmetic's own band, about
931 // −30 here (it was `ln(1e-300) ≈ −690` while the floor was a constant) —
932 // the ρ-dependent-Occam-reward bug the matrix-free unit deflation removes.
933 // The quadrature spreads one direction's weight over its nodes, so the
934 // realized drop is bounded below by half the band's log, not by all of it.
935 let plain = slq_logdet(dim, |v| a.dot(&v), 48, dim, 0xD1F);
936 let ritz_band =
937 gam_linalg::roundoff::accumulation_growth(48) * deflated.lambda_max_abs;
938 let collapsed_direction_drop = -ritz_band.ln();
939 eprintln!(
940 "plain est={:.6} collapsed-direction drop ln(band)={:.3}",
941 plain.estimate, -collapsed_direction_drop
942 );
943 assert!(
944 collapsed_direction_drop > 0.0
945 && deflated.estimate - plain.estimate > 0.5 * collapsed_direction_drop,
946 "plain SLQ ({}) must sit below the unit-deflated estimate ({}) by the collapsed \
947 direction's ln(band) ≈ −{collapsed_direction_drop:.1}",
948 plain.estimate,
949 deflated.estimate
950 );
951 }
952
953 /// #2308 — with NO sub-floor direction, unit deflation deflates nothing, so it
954 /// is bit-identical to the plain estimator (same probe stream, every Ritz value
955 /// kept) and equally close to the exact log-determinant.
956 #[test]
957 fn slq_unit_deflation_matches_plain_when_no_nulls_2308() {
958 let dim = 120usize;
959 let a = random_spd(dim, dim + 40, 5.0, 3);
960 let exact = exact_logdet(&a);
961 let deflated = slq_logdet_unit_deflated(
962 dim,
963 |v| a.dot(&v),
964 48,
965 70,
966 0xA5A5,
967 SPECTRAL_DEFLATION_REL_FLOOR,
968 );
969 let plain = slq_logdet(dim, |v| a.dot(&v), 48, 70, 0xA5A5);
970 assert_eq!(
971 deflated.estimate.to_bits(),
972 plain.estimate.to_bits(),
973 "no deflation ⇒ unit-deflated estimate must be bit-identical to plain"
974 );
975 let rel = (deflated.estimate - exact).abs() / exact.abs();
976 assert!(
977 rel < 0.05,
978 "unit-deflated SLQ rel err {rel:.3e} vs exact {exact}"
979 );
980 }
981
982 /// #2308 — degenerate operators: the empty and the fully-collapsed (`A = 0`)
983 /// operator both have a finite unit-deflated log-det of `0` (every direction
984 /// pinned to unit), never `−∞`.
985 #[test]
986 fn slq_unit_deflation_empty_and_degenerate_2308() {
987 let empty = slq_logdet_unit_deflated(
988 0,
989 |v| v.to_owned(),
990 8,
991 8,
992 1,
993 SPECTRAL_DEFLATION_REL_FLOOR,
994 );
995 assert_eq!(empty.estimate, 0.0);
996 assert_eq!(empty.lambda_max_abs, 0.0);
997
998 let dim = 16usize;
999 let zeros = slq_logdet_unit_deflated(
1000 dim,
1001 |v| Array1::<f64>::zeros(v.len()),
1002 8,
1003 dim,
1004 2,
1005 SPECTRAL_DEFLATION_REL_FLOOR,
1006 );
1007 assert!(zeros.estimate.is_finite());
1008 assert_eq!(zeros.estimate, 0.0);
1009 }
1010
1011 /// #2308 — the unit-deflated estimate (value AND floor) is bit-reproducible for
1012 /// a fixed `(dim, matvec, probes, steps, seed)`, as the REML evidence outer
1013 /// loop requires of a differentiated objective.
1014 #[test]
1015 fn slq_unit_deflation_is_deterministic_2308() {
1016 let dim = 40usize;
1017 let mut state = 9u64;
1018 let mut eigenvalues = vec![0.0_f64; dim];
1019 for e in eigenvalues.iter_mut() {
1020 *e = next_uniform(&mut state, 0.3, 8.0);
1021 }
1022 eigenvalues[0] = -1.0e-10;
1023 let a = householder_spectrum_matrix(&eigenvalues, 77);
1024 let r1 = slq_logdet_unit_deflated(
1025 dim,
1026 |v| a.dot(&v),
1027 24,
1028 dim,
1029 99,
1030 SPECTRAL_DEFLATION_REL_FLOOR,
1031 );
1032 let r2 = slq_logdet_unit_deflated(
1033 dim,
1034 |v| a.dot(&v),
1035 24,
1036 dim,
1037 99,
1038 SPECTRAL_DEFLATION_REL_FLOOR,
1039 );
1040 assert_eq!(r1.estimate.to_bits(), r2.estimate.to_bits());
1041 assert_eq!(r1.deflate_floor.to_bits(), r2.deflate_floor.to_bits());
1042 }
1043}