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    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}