gam_solve/arrow_schur/rational_logdet.rs
1//! Desync-safe stochastic log-determinant: a FIXED rational surrogate whose
2//! value and parameter-gradient are the same deterministic functional (#2080).
3//!
4//! The wide-`p` REML criterion needs `½·log det S(ρ)` for the reduced evidence
5//! Schur `S` (border dim `k = Σ M_k·p`), whose *dense assembly* is the
6//! dominant per-eval cost at LLM widths (`O(n·q·k²)`, an order of magnitude
7//! above even the `O(k³)` Cholesky at #2230 shapes). Plain SLQ
8//! ([`super::slq_logdet`]) removes the assembly but re-opens the
9//! objective↔gradient desync class: a stochastic VALUE paired with the exact
10//! analytic gradient hands the outer line search a gradient of a *different*
11//! function, and fresh probes per eval turn the criterion into noise on the
12//! scale of the stall tolerances.
13//!
14//! This module closes that structurally. Fix, per outer solve:
15//!
16//! * a probe block `V = [v_1 … v_m]` (Rademacher, common random numbers across
17//! every ρ evaluation), and
18//! * a fixed quadrature `{(t_ℓ, w_ℓ)}` for the integral representation
19//!
20//! `log x = ∫₀^∞ ( 1/(1+t) − 1/(x+t) ) dt`,
21//!
22//! and define the SURROGATE
23//!
24//! `L̃(ρ) = Σ_ℓ w_ℓ · [ k/(1+t_ℓ) − (1/m)·Σ_j v_jᵀ (S(ρ)+t_ℓ I)⁻¹ v_j ]`.
25//!
26//! `L̃` is a smooth deterministic function of ρ (probes and nodes never move),
27//! `E_V[L̃] = Σ_ℓ w_ℓ·[k/(1+t_ℓ) − tr(S+t_ℓ)⁻¹] ≈ log det S` to quadrature
28//! accuracy, and its EXACT ρ-derivative along a direction `∂S` is
29//!
30//! `∂L̃ = (1/m)·Σ_j Σ_ℓ w_ℓ · y_{jℓ}ᵀ (∂S) y_{jℓ}`, `y_{jℓ} = (S+t_ℓ I)⁻¹ v_j`
31//!
32//! — computable from the SAME shifted solves as the value. The outer optimizer
33//! therefore descends a function whose gradient is its own: the desync class is
34//! closed by construction, not by tolerance tuning. Probe-set bias is a
35//! terminal concern (the fluctuation is a fixed smooth `O(m^{-1/2})`
36//! perturbation of the criterion surface), certified once at the accepted ρ̂
37//! by an independent probe block or one dense factorization.
38//!
39//! Quadrature: the half-line integral is mapped by the exp-sinh
40//! double-exponential substitution `t = c·exp(sinh(u)·π/2)` and truncated
41//! trapezoid in `u`. The integrand `g(t) = k/(1+t) − tr(S+t)⁻¹` is analytic on
42//! `t > 0`, finite at `t → 0⁺`, and decays like `1/t²`, so the DE-trapezoid
43//! error decays double-exponentially in the node count; the node window is
44//! sized from the caller's spectral bracket `[λ_min, λ_max]` so the transition
45//! region of every eigenvalue is inside the resolved range.
46//!
47//! Shifted solves: each `(S + t_ℓ I) y = v` is SPD with conditioning
48//! `(λ_max+t)/(λ_min+t)` — large shifts converge in a handful of CG steps, and
49//! the ladder is walked from the LARGEST shift down with warm starts (`y(t)` is
50//! smooth in `t`), so only the smallest-shift solves pay meaningful iteration
51//! counts. The apply is only ever consumed through a caller-provided matvec, so
52//! `S` is never formed.
53
54use super::prelude::*;
55use gam_linalg::utils::{splitmix64, splitmix64_hash};
56
57/// Top-subspace (Hutch++) deflation configuration for the surrogate. When a plan
58/// carries one, [`RationalLogdetPlan::evaluate`] peels an `r`-dimensional
59/// orthonormal subspace `Q` of the heavy (top) directions from the operator and
60/// splits the log-determinant by the EXACT identity (no invariance assumed)
61///
62/// `tr log(S/c) = tr(Qᵀ log(S/c) Q) + tr(P log(S/c) P)`, `P = I − QQᵀ`,
63///
64/// evaluating the first block deterministically over the `r` basis columns and
65/// the second by Hutchinson over the PROJECTED probes `u_j = P v_j` (each with
66/// its own reference norm `‖u_j‖²`, so the `k − r` bookkeeping is automatic).
67/// The Hutchinson variance then rides only on the off-diagonal mass of
68/// `P log(S/c) P` — small once `Q` captures the heavy directions — collapsing the
69/// error bar that raw probes carry on a wide spectrum. The decomposition is
70/// EXACT for ANY orthonormal `Q`; the subspace iteration only steers `Q` toward
71/// the top space to reduce variance, it can never bias the estimate.
72///
73/// The basis is FROZEN here (built once by [`RationalLogdetPlan::with_deflation`]
74/// from the operator at the plan's ρ), NOT rebuilt per evaluation. This is what
75/// keeps value and gradient the SAME functional: with the estimated `term2`, the
76/// sum `term1 + term2` is `Q`-dependent, so a `Q` that moved with ρ would put an
77/// un-modelled `∂Q/∂ρ` term in the true gradient. A frozen `Q` makes the
78/// fixed-`Q` directional derivative EXACT for the surrogate, at the cost of `Q`
79/// going slightly stale as the line search moves ρ (which only relaxes the
80/// variance reduction — never biases the value, since the decomposition is exact
81/// for any fixed orthonormal `Q`).
82#[derive(Clone)]
83pub struct DeflationSpec {
84 /// Frozen orthonormal top-subspace basis `Q` (columns `q_i`), built once from
85 /// the operator. Reused verbatim across every ρ evaluation (CRN).
86 pub basis: Vec<Array1<f64>>,
87}
88
89/// Fixed probes + fixed quadrature for one outer solve. Build once (per ρ
90/// search), reuse for every criterion/gradient evaluation so the surrogate is
91/// one deterministic function of ρ.
92#[derive(Clone)]
93pub struct RationalLogdetPlan {
94 /// Operator dimension `k`.
95 pub dim: usize,
96 /// Rademacher probe block, `m` columns of length `dim` (CRN across ρ).
97 pub probes: Vec<Array1<f64>>,
98 /// Quadrature nodes `(t_ℓ, w_ℓ)` for `∫₀^∞ g(t) dt`, ordered ascending in
99 /// `t` (the solve ladder walks them descending).
100 pub nodes: Vec<(f64, f64)>,
101 /// `ln c` for the bracket-centred representation: the estimate is
102 /// `k·ln c + Σ_ℓ w_ℓ·[k/(c+t_ℓ) − tr-est (S+t_ℓ)⁻¹]`.
103 pub log_center: f64,
104 /// The bracket centre `c = √(λ_min·λ_max)` itself.
105 pub center: f64,
106 /// Optional top-subspace (Hutch++) deflation. `None` (the default from
107 /// [`Self::build`]) reproduces the bare-Hutchinson path bit-for-bit; set via
108 /// [`Self::with_deflation`].
109 pub deflation: Option<DeflationSpec>,
110}
111
112/// One evaluation of the surrogate: the value and the per-(probe, node) solve
113/// bundle `y_{jℓ}` needed to contract the exact gradient against any `∂S`
114/// direction without re-solving.
115pub struct RationalLogdetEval {
116 /// `L̃ ≈ log det S` (surrogate value; deterministic given the plan).
117 pub estimate: f64,
118 /// Hutchinson standard error: sample sd of the per-probe estimates over
119 /// `√m`. Zero for a single probe. The QUADRATURE part of the error is not
120 /// in this bar (it is deterministic and bounded by the plan's `rel_tol`).
121 pub std_err: f64,
122 /// `y_{jℓ} = (S + t_ℓ I)⁻¹ u_j`, outer index `ℓ` (node), inner `j` (probe).
123 /// `u_j = P v_j` are the deflation-PROJECTED probes when the plan carries a
124 /// [`DeflationSpec`] (`u_j = v_j` — the raw probes — otherwise).
125 pub shifted_solves: Vec<Vec<Array1<f64>>>,
126 /// `y_{q_iℓ} = (S + t_ℓ I)⁻¹ q_i` for each deflation-basis column `q_i`,
127 /// outer index `ℓ` (node), inner `i` (basis column). Empty without deflation.
128 /// Carried so the directional derivative contracts the deterministic
129 /// `tr(Qᵀ log(S/c) Q)` block against `∂S` from the SAME shifted solves.
130 pub deflation_solves: Vec<Vec<Array1<f64>>>,
131 /// The orthonormal deflation basis `Q` (columns `q_i`) actually realised for
132 /// this evaluation; empty without deflation, and possibly shorter than the
133 /// requested rank if the block collapsed.
134 pub deflation_basis: Vec<Array1<f64>>,
135 /// Total CG iterations spent (diagnostic).
136 pub cg_iterations: usize,
137}
138
139/// Lossless low-rank representation of the derivative of one fixed rational
140/// log-determinant evaluation.
141///
142/// For every symmetric operator direction `D`,
143///
144/// `plan.directional_derivative(eval, D) = (1/r) Σ_a x_a^T D x_a`,
145///
146/// where `x_a` are [`Self::vectors`] and `r` is their count. The vectors fold
147/// in every quadrature weight, the Hutchinson `1/m`, and the deterministic
148/// deflation block. Consequently consumers that already assemble arrow
149/// selected-inverse contractions from probe pairs can use `(vectors, vectors)`
150/// without pretending that the vectors are raw probes or unshifted `S^-1`
151/// solves. This representation is the derivative of the rational SURROGATE,
152/// not an estimator of the derivative of the exact log determinant.
153pub struct RationalLogdetDerivativeBundle {
154 pub vectors: Vec<Array1<f64>>,
155}
156
157impl RationalLogdetDerivativeBundle {
158 /// Apply the represented derivative to a symmetric operator direction.
159 pub fn directional_derivative(
160 &self,
161 dmatvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
162 ) -> Option<f64> {
163 if self.vectors.is_empty() {
164 return None;
165 }
166 let inv_rank = 1.0 / self.vectors.len() as f64;
167 let derivative = self
168 .vectors
169 .iter()
170 .map(|vector| vector.dot(&dmatvec(vector.view())))
171 .sum::<f64>()
172 * inv_rank;
173 derivative.is_finite().then_some(derivative)
174 }
175}
176
177impl RationalLogdetPlan {
178 /// Build a plan for spectrum bracket `[lambda_min, lambda_max]` (rough
179 /// estimates are fine — the window is padded two decades on each side),
180 /// `num_probes` Rademacher probes, and a target quadrature accuracy of
181 /// roughly `rel_tol` on `log det`.
182 pub fn build(
183 dim: usize,
184 num_probes: usize,
185 seed: u64,
186 lambda_min: f64,
187 lambda_max: f64,
188 rel_tol: f64,
189 ) -> Option<Self> {
190 if dim == 0
191 || num_probes == 0
192 || !(lambda_min.is_finite() && lambda_max.is_finite())
193 || lambda_min <= 0.0
194 || lambda_max < lambda_min
195 || !(rel_tol.is_finite() && rel_tol > 0.0 && rel_tol < 1.0)
196 {
197 return None;
198 }
199 // ONE sequential master stream for ALL probes. The former per-probe
200 // initial state `(seed + p)·γ` (γ = the splitmix64 increment) made
201 // probe `p` of seed `s` BIT-IDENTICAL to probe `p+1` of seed `s−1`
202 // (a splitmix stream from x₀ emits the words at x₀+γ, x₀+2γ, …, so
203 // any two starts differing by a multiple of γ are the same stream
204 // shifted), and within one plan made probe `p+1`'s word stream probe
205 // `p`'s shifted by one word — a sliding window sharing sign words
206 // between consecutive probes. Each probe was still individually
207 // uniform Rademacher (Hutchinson stays unbiased), but the probes were
208 // NOT jointly independent: the std_err bookkeeping and any
209 // seed-averaged inference (the wide-κ multiseed discriminator, whose
210 // 96 seeds at unit spacing drew ~128 distinct probe vectors instead
211 // of 3072 and reported a common Hutchinson fluctuation as a "5.57σ
212 // deterministic bias") were invalidated. Sequential consumption from
213 // one hashed master state has no window structure and no cross-seed
214 // stream aliasing; determinism per seed (the CRN contract) is kept.
215 let mut master = splitmix64_hash(seed);
216 let probes = rademacher_block(&mut master, num_probes, dim);
217 // Bracket-centred exp-sinh DE nodes for the shifted representation
218 //
219 // log x = log c + ∫₀^∞ ( 1/(c+t) − 1/(x+t) ) dt, c = √(λ_min·λ_max),
220 //
221 // with t(u) = c·exp(π/2·sinh u), dt = t·(π/2)·cosh u du. Centring at the
222 // geometric bracket midpoint keeps the integrand's complex poles
223 // (t = −λ_i, i.e. u where c·exp(π/2·sinh u) = −λ_i) as far from the
224 // real u-axis as the spectrum allows. The nearest pole sits at height
225 // d(λ) ≈ (π/2)/cosh(u_λ), u_λ = asinh((2/π)·ln(λ/c)), which SHRINKS
226 // with the bracket width — the reason a fixed h fails at wide κ. Size
227 // the step from the trapezoid-DE bound err ~ exp(−2π·d_min/h):
228 // h = 2π·d_min/ln(1/tol).
229 //
230 // TRUNCATION WINDOW must be sized by rel_tol, NOT a fixed decade pad. The
231 // dropped tails of the t-integral are, for the EXTREME eigenvalues,
232 // low : ∫₀^{t_lo}(1/(c+t) − 1/(λ_min+t))dt ≈ (1/c − 1/λ_min)·t_lo
233 // ≈ −t_lo/λ_min, bounded by rel_tol ⟺ t_lo = λ_min·rel_tol
234 // high: ∫_{t_hi}^∞(1/(c+t) − 1/(λ_max+t))dt ≈ (λ_max − c)/t_hi
235 // ≈ λ_max/t_hi, bounded by rel_tol ⟺ t_hi = λ_max/rel_tol.
236 // The former fixed two-decade pad (t_lo = (λ_min/c)·1e-2, t_hi =
237 // (λ_max/c)·1e2) left these tails at O(1e-2/c) and O(1e-2) — orders ABOVE
238 // rel_tol — so the estimate lost the extreme (esp. TOP) eigenvalues' tail
239 // mass and was biased LOW, worst at wide κ. The DE transform compresses
240 // the wider t-window into a modest u-range (double-exponential), so the
241 // node count grows only logarithmically.
242 let c = (lambda_min * lambda_max).sqrt();
243 let t_lo = lambda_min * rel_tol;
244 let t_hi = lambda_max / rel_tol;
245 // Invert t(u) = c·exp(π/2·sinh u): u(t) = asinh((2/π)·ln(t/c)). The /c is
246 // load-bearing — t_lo/t_hi below are ABSOLUTE truncation points, so a node
247 // at u_of(t) must land at t, not c·t (which shifts the resolved window by a
248 // full factor of c and under-resolves the extreme-eigenvalue tails). Mirrors
249 // the /c the pole_height ratio uses just below.
250 let u_of = |t: f64| ((2.0 / std::f64::consts::PI) * (t / c).ln()).asinh();
251 let u_lo = u_of(t_lo);
252 let u_hi = u_of(t_hi);
253 // Worst-case pole height over the padded bracket (evaluate at both
254 // ends; the pole of the reference term at t = −c sits at u = 0 with
255 // height π/2, never the minimum).
256 let pole_height = |lam_over_c: f64| -> f64 {
257 let s = (2.0 / std::f64::consts::PI) * lam_over_c.ln();
258 std::f64::consts::FRAC_PI_2 / (1.0 + s * s).sqrt()
259 };
260 let d_min = pole_height(lambda_min / c)
261 .min(pole_height(lambda_max / c))
262 .min(std::f64::consts::FRAC_PI_2);
263 let h_bound = 2.0 * std::f64::consts::PI * d_min / (1.0f64 / rel_tol).ln();
264 let steps = (((u_hi - u_lo) / h_bound).ceil() as usize).max(16);
265 let h = (u_hi - u_lo) / steps as f64;
266 let mut nodes = Vec::with_capacity(steps + 1);
267 for s in 0..=steps {
268 let u = u_lo + h * s as f64;
269 let t = c * (std::f64::consts::FRAC_PI_2 * u.sinh()).exp();
270 let w = h * t * std::f64::consts::FRAC_PI_2 * u.cosh();
271 if t.is_finite() && w.is_finite() && w > 0.0 {
272 nodes.push((t, w));
273 }
274 }
275 if nodes.is_empty() {
276 return None;
277 }
278 Some(Self {
279 dim,
280 probes,
281 nodes,
282 log_center: c.ln(),
283 center: c,
284 deflation: None,
285 })
286 }
287
288 /// Attach top-subspace (Hutch++) deflation, FREEZING an orthonormal basis `Q`
289 /// of up to `rank` columns built now from `matvec` by `subspace_iters`
290 /// block-power steps (`Q ← orthonormalise(S·Q)`) from a `seed`-deterministic
291 /// Rademacher start. The frozen `Q` is reused for every subsequent
292 /// [`Self::evaluate`], so the surrogate stays one deterministic function of ρ
293 /// with the fixed-`Q` directional derivative as its EXACT gradient (see
294 /// [`DeflationSpec`]). Build this at the plan's ρ, from the same operator the
295 /// evaluations use. `rank = 0` (or a fully-collapsed block) yields the
296 /// bare-Hutchinson plan unchanged.
297 pub fn with_deflation(
298 mut self,
299 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
300 rank: usize,
301 subspace_iters: usize,
302 seed: u64,
303 ) -> Self {
304 let basis = build_deflation_basis(matvec, self.dim, rank, subspace_iters, seed);
305 self.deflation = (!basis.is_empty()).then_some(DeflationSpec { basis });
306 self
307 }
308
309 /// Attach TWO-SIDED spectral deflation: freeze an orthonormal basis `Q`
310 /// spanning BOTH the `top_rank` largest-λ directions (block power on `S`) and
311 /// the `bottom_rank` smallest-λ directions (inverse iteration on `S⁻¹`, matrix-
312 /// free via CG), merged and re-orthonormalised into one basis.
313 ///
314 /// This is the wide-κ variance-reduction lever. The surrogate's Hutchinson bar
315 /// is `√(2·‖offdiag(P·log(S/c)·P)‖_F²)` — purely off-diagonal, so a
316 /// diagonal/scalar control variate buys NOTHING (Rademacher already resolves
317 /// the diagonal exactly). The off-diagonal mass of `log(S/c)` is loaded
318 /// SYMMETRICALLY onto the two spectral tails (`|log(λ/c)|` peaks at both
319 /// `λ_max` and `λ_min`), so the one-sided [`Self::with_deflation`] removes only
320 /// half of it and stalls near `½·lnκ`-scale error bars at wide κ. Peeling both
321 /// tails is a rank-`(top+bottom)` low-rank control variate whose deterministic
322 /// `tr(Qᵀ log(S/c) Q)` block (term1) is computed exactly and whose complement
323 /// carries only the interior — small — off-diagonal mass. At EQUAL total rank
324 /// this cuts the wide-κ bar by ≈`√2`·(tail/interior ratio) over one-sided
325 /// deflation; the decomposition stays EXACT for any orthonormal `Q`, so the
326 /// value is never biased (only the bar shrinks). `top_rank = bottom_rank = 0`
327 /// reduces to the bare-Hutchinson plan.
328 ///
329 /// The `cg` budget `(rel_tol, max_iters)` bounds the inverse-iteration solves;
330 /// it may be loose (an approximate bottom `Q` only relaxes the variance
331 /// reduction, never biases the estimate). Build this once at the plan's ρ, from
332 /// the same operator the evaluations use.
333 pub fn with_two_sided_deflation(
334 mut self,
335 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
336 top_rank: usize,
337 bottom_rank: usize,
338 subspace_iters: usize,
339 seed: u64,
340 cg: (f64, usize),
341 ) -> Option<Self> {
342 let (cg_rel_tol, cg_max_iters) = cg;
343 let mut cols = build_deflation_basis(matvec, self.dim, top_rank, subspace_iters, seed);
344 cols.extend(build_inverse_deflation_basis(
345 matvec,
346 self.dim,
347 bottom_rank,
348 subspace_iters,
349 seed,
350 cg_rel_tol,
351 cg_max_iters,
352 )?);
353 // Merge the two orthonormal families into ONE orthonormal basis (the top
354 // and bottom blocks are near-orthogonal but not exactly; the second MGS
355 // pass in `orthonormalize` cleans the cross terms and drops any collapsed
356 // column, so `Q` stays exactly orthonormal — the property term1 needs).
357 let basis = orthonormalize(&cols);
358 self.deflation = (!basis.is_empty()).then_some(DeflationSpec { basis });
359 Some(self)
360 }
361
362 /// Evaluate the surrogate `L̃ ≈ log det S` through `matvec(v) = S·v`.
363 ///
364 /// Each shifted system is solved by plain CG to normwise backward error
365 /// `cg_rel_tol`, walking the shift ladder from the largest `t` (near-trivial
366 /// solves) down to the smallest, warm-starting each solve from the previous
367 /// shift's solution for the same probe. A stricter RHS-relative residual
368 /// also terminates the solve when it is attainable.
369 pub fn evaluate(
370 &self,
371 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
372 cg_rel_tol: f64,
373 cg_max_iters: usize,
374 ) -> Option<RationalLogdetEval> {
375 // Ladder: descending t (warm starts carry per vector across shifts).
376 let mut order: Vec<usize> = (0..self.nodes.len()).collect();
377 order.sort_by(|&a, &b| {
378 self.nodes[b]
379 .0
380 .partial_cmp(&self.nodes[a].0)
381 .unwrap_or(std::cmp::Ordering::Equal)
382 });
383
384 // FROZEN top-subspace deflation basis Q (empty without a DeflationSpec).
385 // Built once at plan creation from the operator at the plan's ρ; reused
386 // verbatim here so the surrogate is one fixed-Q function of ρ.
387 let basis: &[Array1<f64>] = self
388 .deflation
389 .as_ref()
390 .map(|d| d.basis.as_slice())
391 .unwrap_or(&[]);
392
393 // Deflation-projected probes u_j = P v_j (raw probes without a basis).
394 let probes_proj = self.projected_probes(basis);
395
396 // Shifted solves for the projected probes and (if any) the basis columns.
397 let (shifted, iters_probe) = solve_shift_ladder(
398 matvec,
399 &self.nodes,
400 &order,
401 &probes_proj,
402 cg_rel_tol,
403 cg_max_iters,
404 )?;
405 let (deflation_solves, iters_basis) = if basis.is_empty() {
406 (Vec::new(), 0)
407 } else {
408 solve_shift_ladder(matvec, &self.nodes, &order, basis, cg_rel_tol, cg_max_iters)?
409 };
410 self.assemble_eval(
411 probes_proj,
412 basis,
413 shifted,
414 deflation_solves,
415 iters_probe + iters_basis,
416 )
417 }
418
419 /// Deflation-projected probes `u_j = P v_j = v_j − Q(Qᵀ v_j)` (the raw probes
420 /// bit-for-bit when `basis` is empty: `‖u_j‖² = k`, no term1). Shared by
421 /// [`Self::evaluate`] and the wide-κ discriminator's exact-solve audit arm so
422 /// BOTH project against the identical frozen `Q` the term1 columns use — the
423 /// one place the "exact for any orthonormal Q" proof could silently break is a
424 /// `Q` that differs between the probe projector and term1, so they must draw
425 /// from the same `basis` slice.
426 fn projected_probes(&self, basis: &[Array1<f64>]) -> Vec<Array1<f64>> {
427 self.probes
428 .iter()
429 .map(|v| {
430 let mut u = v.clone();
431 for q in basis {
432 let c = u.dot(q);
433 u.scaled_add(-c, q);
434 }
435 u
436 })
437 .collect()
438 }
439
440 /// Assemble the surrogate value, error bar, and carried solves from the two
441 /// shifted-solve ladders — `shifted[ℓ][j]` for the projected probes and
442 /// `deflation_solves[ℓ][i]` for the basis columns, both indexed by node `ℓ`.
443 /// The ONLY solver-dependent inputs are those two ladders, so [`Self::evaluate`]
444 /// (CG) and any exact-solve audit that feeds the same ladders produce
445 /// byte-identical term1/term2/std_err bookkeeping — the property the wide-κ
446 /// discriminator's exact arm relies on to isolate solve error from a structural
447 /// split bias.
448 fn assemble_eval(
449 &self,
450 probes_proj: Vec<Array1<f64>>,
451 basis: &[Array1<f64>],
452 shifted: Vec<Vec<Array1<f64>>>,
453 deflation_solves: Vec<Vec<Array1<f64>>>,
454 total_iters: usize,
455 ) -> Option<RationalLogdetEval> {
456 let m = self.probes.len();
457 let k = self.dim as f64;
458 // term1 = tr(Qᵀ log(S/c) Q) = Σ_i Σ_ℓ w_ℓ (‖q_i‖²/(c+t_ℓ) − q_iᵀ y_{q_iℓ}),
459 // ‖q_i‖² = 1. Deterministic (no probe variance).
460 let mut term1 = 0.0_f64;
461 for (ell, &(t, w)) in self.nodes.iter().enumerate() {
462 let reference = 1.0 / (self.center + t);
463 for (i, q) in basis.iter().enumerate() {
464 term1 += w * (reference - q.dot(&deflation_solves[ell][i]));
465 }
466 }
467
468 // term2 per-probe: e_j = Σ_ℓ w_ℓ (‖u_j‖²/(c+t_ℓ) − u_jᵀ y_{u_jℓ}). The
469 // PER-VECTOR reference norm ‖u_j‖² makes the (k−r) count automatic and
470 // exact. The surrogate value is k·ln c + term1 + mean_j e_j; the
471 // Hutchinson error bar is the spread of the e_j (term1 is deterministic,
472 // so it carries no variance).
473 let u_norm_sq: Vec<f64> = probes_proj.iter().map(|u| u.dot(u)).collect();
474 let mut per_probe = vec![0.0_f64; m];
475 for (ell, &(t, w)) in self.nodes.iter().enumerate() {
476 let inv = 1.0 / (self.center + t);
477 for j in 0..m {
478 per_probe[j] += w * (u_norm_sq[j] * inv - probes_proj[j].dot(&shifted[ell][j]));
479 }
480 }
481 let term2 = per_probe.iter().sum::<f64>() / m as f64;
482 let estimate = k * self.log_center + term1 + term2;
483 let std_err = if m > 1 {
484 let var = per_probe
485 .iter()
486 .map(|e| (e - term2) * (e - term2))
487 .sum::<f64>()
488 / (m as f64 - 1.0);
489 (var / m as f64).sqrt()
490 } else {
491 0.0
492 };
493 if !(estimate.is_finite() && std_err.is_finite()) {
494 return None;
495 }
496 Some(RationalLogdetEval {
497 estimate,
498 std_err,
499 shifted_solves: shifted,
500 deflation_solves,
501 deflation_basis: basis.to_vec(),
502 cg_iterations: total_iters,
503 })
504 }
505
506 /// Exact derivative of the surrogate along a Hessian direction: given
507 /// `dmatvec(v) = (∂S)·v`, returns `∂L̃ = (1/m)·Σ_{j,ℓ} w_ℓ · y_{jℓ}ᵀ(∂S)y_{jℓ}`.
508 ///
509 /// This is the true gradient of the SAME function [`Self::evaluate`]
510 /// returned — value and gradient can never desync.
511 pub fn directional_derivative(
512 &self,
513 eval: &RationalLogdetEval,
514 dmatvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
515 ) -> Option<f64> {
516 let m = self.probes.len() as f64;
517 // Projected-probe block (Hutchinson, averaged over m) and the
518 // deterministic deflation block (Σ over the r basis columns, NOT
519 // averaged) — the exact derivative of `term2` and `term1` respectively.
520 // The `k·ln c` term is ρ-independent and contributes nothing.
521 let mut acc_probe = 0.0;
522 let mut acc_defl = 0.0;
523 for (ell, &(_, w)) in self.nodes.iter().enumerate() {
524 for y in &eval.shifted_solves[ell] {
525 let dy = dmatvec(y.view());
526 acc_probe += w * y.dot(&dy);
527 }
528 if let Some(defl) = eval.deflation_solves.get(ell) {
529 for y in defl {
530 let dy = dmatvec(y.view());
531 acc_defl += w * y.dot(&dy);
532 }
533 }
534 }
535 let acc = acc_defl + acc_probe / m;
536 acc.is_finite().then_some(acc)
537 }
538
539 /// Collapse [`RationalLogdetEval`]'s complete shifted-solve ladder into a
540 /// lossless weighted low-rank derivative representation.
541 ///
542 /// This is deliberately derived from the same evaluation that produced the
543 /// value. Re-solving only the raw probes at shift zero would instead encode
544 /// `tr(S^-1 D)`, which is generally NOT the derivative of this fixed-node
545 /// rational surrogate and would reopen the objective/gradient desynchrony
546 /// the surrogate exists to prevent.
547 pub fn into_directional_derivative_bundle(
548 &self,
549 eval: RationalLogdetEval,
550 ) -> Option<RationalLogdetDerivativeBundle> {
551 let expected_deflation_nodes =
552 usize::from(!eval.deflation_basis.is_empty()) * self.nodes.len();
553 if eval.shifted_solves.len() != self.nodes.len()
554 || eval.deflation_solves.len() != expected_deflation_nodes
555 {
556 return None;
557 }
558 let probe_count = self.probes.len();
559 if probe_count == 0
560 || eval
561 .shifted_solves
562 .iter()
563 .any(|solves| solves.len() != probe_count)
564 || eval
565 .deflation_solves
566 .iter()
567 .any(|solves| solves.len() != eval.deflation_basis.len())
568 {
569 return None;
570 }
571 let term_count = self.nodes.len().checked_mul(
572 probe_count.checked_add(eval.deflation_basis.len())?,
573 )?;
574 if term_count == 0 {
575 return None;
576 }
577 let mut vectors = Vec::with_capacity(term_count);
578 let rank = term_count as f64;
579 let probes = probe_count as f64;
580 let mut deflation_by_node = eval.deflation_solves;
581 if deflation_by_node.is_empty() {
582 deflation_by_node.resize_with(self.nodes.len(), Vec::new);
583 }
584 for ((mut probe_solves, mut deflation_solves), &(_, weight)) in eval
585 .shifted_solves
586 .into_iter()
587 .zip(deflation_by_node)
588 .zip(&self.nodes)
589 {
590 if !(weight.is_finite() && weight > 0.0) {
591 return None;
592 }
593 let probe_scale = (rank * weight / probes).sqrt();
594 let deflation_scale = (rank * weight).sqrt();
595 if !(probe_scale.is_finite() && deflation_scale.is_finite()) {
596 return None;
597 }
598 for mut solve in probe_solves.drain(..) {
599 if solve.len() != self.dim {
600 return None;
601 }
602 solve *= probe_scale;
603 vectors.push(solve);
604 }
605 for mut solve in deflation_solves.drain(..) {
606 if solve.len() != self.dim {
607 return None;
608 }
609 solve *= deflation_scale;
610 vectors.push(solve);
611 }
612 }
613 Some(RationalLogdetDerivativeBundle { vectors })
614 }
615}
616
617/// Plain CG on `(A + t·I) y = b` through the un-shifted `matvec(v) = A·v`,
618/// warm-started from `y0`. Returns the solution and the iteration count only
619/// after the TRUE residual certifies either the stricter RHS-relative residual
620/// or the requested normwise backward error; exhaustion and non-finite/SPD
621/// breakdowns return `None`. The matrix-free backward-error denominator uses
622/// the largest Rayleigh quotient observed over the CG directions. For SPD `A`,
623/// this is a lower bound on `||A||₂`, hence
624///
625/// `||r||₂ / (lambda_observed ||y||₂ + ||b||₂)`
626///
627/// is a conservative upper bound on the usual normwise backward error. This
628/// closes the f64 roundoff gap where `||r||/||b||` cannot reach a requested
629/// tolerance even though the computed solution already solves a nearby system
630/// to that tolerance. When the recursively updated CG residual reaches the
631/// RHS-relative threshold before the true residual does, the recurrence is
632/// restarted from the true residual (reliable residual replacement) rather
633/// than rejecting a recoverable solve. Returning an uncertified
634/// iteration-capped last iterate would make the value consume an uncontrolled
635/// approximate inverse while the derivative formula differentiates an exact
636/// inverse, re-opening the #2080 objective/gradient desynchronisation this
637/// module exists to prevent.
638fn shifted_cg(
639 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
640 t: f64,
641 b: &Array1<f64>,
642 y0: &Array1<f64>,
643 rel_tol: f64,
644 max_iters: usize,
645) -> Option<(Array1<f64>, usize)> {
646 if !(rel_tol.is_finite() && rel_tol > 0.0) {
647 return None;
648 }
649 let apply = |v: ArrayView1<f64>| -> Array1<f64> {
650 let mut out = matvec(v);
651 out.scaled_add(t, &v.to_owned());
652 out
653 };
654 let mut y = y0.clone();
655 let mut r = b - &apply(y.view());
656 let b_norm = b.dot(b).sqrt().max(f64::MIN_POSITIVE);
657 let mut p = r.clone();
658 let mut rs = r.dot(&r);
659 if !rs.is_finite() {
660 return None;
661 }
662 let tol = rel_tol * b_norm;
663 let mut iters = 0usize;
664 let mut observed_operator_norm = 0.0_f64;
665 loop {
666 if rs.sqrt() <= tol {
667 // Recursive CG residuals lose their equality to `b - A y` through
668 // roundoff, especially on the smallest shifts. A recursive
669 // convergence report is therefore only a prompt to inspect the
670 // actual residual. If it has not converged, restart the Krylov
671 // recurrence from that exact residual and spend the remaining
672 // caller-provided iteration budget. The former terminal check
673 // returned `None` immediately here, even when one reliable update
674 // was enough to satisfy the requested contract.
675 let true_residual = b - &apply(y.view());
676 let true_rs = true_residual.dot(&true_residual);
677 if !true_rs.is_finite() {
678 return None;
679 }
680 let true_residual_norm = true_rs.sqrt();
681 let y_norm = y.dot(&y).sqrt();
682 if !y_norm.is_finite() {
683 return None;
684 }
685 // Evaluate the backward-error ratio in the log domain. The scale
686 // `lambda_observed * ||y|| + ||b||` can overflow even when every
687 // operand and the certified ratio are representable.
688 let backward_error_certified =
689 if observed_operator_norm > 0.0 && y_norm > 0.0 {
690 let log_operator_solution = observed_operator_norm.ln() + y_norm.ln();
691 let log_rhs = b_norm.ln();
692 let log_scale = log_operator_solution.max(log_rhs);
693 let log_denominator = log_scale
694 + ((log_operator_solution - log_scale).exp()
695 + (log_rhs - log_scale).exp())
696 .ln();
697 true_residual_norm.ln() - log_denominator <= rel_tol.ln()
698 } else {
699 false
700 };
701 if true_residual_norm <= tol || backward_error_certified {
702 return Some((y, iters));
703 }
704 if iters >= max_iters {
705 return None;
706 }
707 r = true_residual;
708 rs = true_rs;
709 p = r.clone();
710 }
711 if iters >= max_iters {
712 return None;
713 }
714 let ap = apply(p.view());
715 let denom = p.dot(&ap);
716 if !(denom.is_finite() && denom > 0.0) {
717 return None;
718 }
719 let p_norm_sq = p.dot(&p);
720 if !(p_norm_sq.is_finite() && p_norm_sq > 0.0) {
721 return None;
722 }
723 let rayleigh = denom / p_norm_sq;
724 if rayleigh.is_finite() {
725 observed_operator_norm = observed_operator_norm.max(rayleigh);
726 }
727 let alpha = rs / denom;
728 y.scaled_add(alpha, &p);
729 r.scaled_add(-alpha, &ap);
730 let rs_new = r.dot(&r);
731 if !rs_new.is_finite() {
732 return None;
733 }
734 p = &r + &(&p * (rs_new / rs));
735 rs = rs_new;
736 iters += 1;
737 }
738}
739
740/// Modified Gram-Schmidt orthonormalisation of a column block, DROPPING any
741/// column whose residual norm collapses (linear dependence / rank deficiency).
742/// The realised rank is `out.len()`, which may be below the input count.
743fn orthonormalize(cols: &[Array1<f64>]) -> Vec<Array1<f64>> {
744 let mut out: Vec<Array1<f64>> = Vec::with_capacity(cols.len());
745 for col in cols {
746 let mut v = col.clone();
747 // TWO MGS passes ("twice is enough", Kahan/Parlett): block-power drives
748 // the columns of S·Q toward the dominant eigenvector, so the input block
749 // is ill-conditioned and a SINGLE pass leaves orthogonality error O(κ·ε).
750 // Q enters the DETERMINISTIC term1 = tr(Qᵀ log(S/c) Q), where any
751 // QᵀQ ≠ I directly biases the estimate (a slack basis would only widen
752 // the Hutchinson bar, but a non-orthonormal one shifts the value). The
753 // second pass restores orthogonality to O(ε). The collapse test uses the
754 // FIRST-pass residual norm (relative to the pre-orthogonalisation norm) so
755 // a genuinely dependent column is still dropped, not merely re-cleaned.
756 let v0_norm = v.dot(&v).sqrt();
757 for basis in &out {
758 let proj = v.dot(basis);
759 v.scaled_add(-proj, basis);
760 }
761 let norm_after_first = v.dot(&v).sqrt();
762 for basis in &out {
763 let proj = v.dot(basis);
764 v.scaled_add(-proj, basis);
765 }
766 let norm = v.dot(&v).sqrt();
767 // Numerical rank, not a tuned absolute knob: below √ε of the source
768 // column's norm, orthogonal residuals carry no stable direction.
769 let rank_tol = f64::EPSILON.sqrt() * v0_norm;
770 let collapsed = !(v0_norm.is_finite() && v0_norm > 0.0)
771 || !(norm_after_first.is_finite())
772 || norm_after_first <= rank_tol
773 || !(norm.is_finite())
774 || norm <= rank_tol;
775 if !collapsed {
776 v.mapv_inplace(|x| x / norm);
777 out.push(v);
778 }
779 }
780 out
781}
782
783/// Draw `ncols` length-`dim` Rademacher (±1) vectors by consuming ONE sequential
784/// splitmix stream from `master` (LSB-first, 64 signs per word), the bit buffer
785/// reset per column. Single home for the probe/start-block generation shared by
786/// [`RationalLogdetPlan::build`], [`build_deflation_basis`], and
787/// [`build_inverse_deflation_basis`]; consuming from one advancing `master`
788/// (rather than a per-column `(seed + col)·γ` restart) is what removes the
789/// cross-column / cross-seed stream aliasing documented in `build`.
790fn rademacher_block(master: &mut u64, ncols: usize, dim: usize) -> Vec<Array1<f64>> {
791 (0..ncols)
792 .map(|_| {
793 let mut v = Array1::<f64>::zeros(dim);
794 let mut bits: u64 = 0;
795 let mut remaining: u32 = 0;
796 for value in v.iter_mut() {
797 if remaining == 0 {
798 bits = splitmix64(master);
799 remaining = 64;
800 }
801 *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
802 bits >>= 1;
803 remaining -= 1;
804 }
805 v
806 })
807 .collect()
808}
809
810/// Build the Hutch++ top-subspace basis `Q` (`≤ rank` orthonormal columns) by
811/// block-power (subspace) iteration on the operator: a `seed`-deterministic
812/// Rademacher start block, orthonormalised, then `iters` rounds of
813/// `Q ← orthonormalise(S·Q)`. The result steers toward the top eigenspace so the
814/// deflated Hutchinson variance is small; the log-det decomposition is EXACT for
815/// any orthonormal `Q`, so a slack `Q` cannot bias the estimate (only widen the
816/// error bar). Deterministic for a fixed `(matvec, dim, rank, iters, seed)`.
817fn build_deflation_basis(
818 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
819 dim: usize,
820 rank: usize,
821 iters: usize,
822 seed: u64,
823) -> Vec<Array1<f64>> {
824 let r = rank.min(dim);
825 if r == 0 {
826 return Vec::new();
827 }
828 // One sequential master stream for the whole start block — same
829 // decorrelation as the probe generation in `RationalLogdetPlan::build`:
830 // the former per-column start `seed + col·γ + const` (γ = the splitmix64
831 // increment) made column c+1's word stream column c's shifted by one
832 // word (sliding-window sharing). Harmless to the EXACTNESS of the
833 // deflated split (any orthonormal Q is valid), but a correlated start
834 // block weakens the subspace iteration's coverage of the top eigenspace
835 // for no reason. Determinism per seed is kept.
836 let mut master = splitmix64_hash(seed.wrapping_add(0xD1B5_4A32_D192_ED03));
837 let mut cols = orthonormalize(&rademacher_block(&mut master, r, dim));
838 for _ in 0..iters {
839 if cols.is_empty() {
840 break;
841 }
842 let applied: Vec<Array1<f64>> = cols.iter().map(|c| matvec(c.view())).collect();
843 cols = orthonormalize(&applied);
844 }
845 cols
846}
847
848/// Build the BOTTOM (smallest-λ) subspace basis by INVERSE subspace iteration:
849/// the same block-power as [`build_deflation_basis`] but with the operator
850/// replaced by `S⁻¹` (applied matrix-free by plain CG through `matvec`), so the
851/// rounds `Q ← orthonormalise(S⁻¹·Q)` amplify the SMALLEST eigenvalues instead of
852/// the largest. This is the second arm of the two-sided control variate
853/// ([`RationalLogdetPlan::with_two_sided_deflation`]): the Hutchinson variance of
854/// the surrogate rides on the off-diagonal Frobenius mass of `log(S/c)`, which a
855/// wide spectrum loads SYMMETRICALLY onto both tails (`log(λ_max/c) = +½lnκ` and
856/// `log(λ_min/c) = −½lnκ`), so peeling only the top leaves the entire bottom-tail
857/// contribution in the bar. A polynomial filter `(μI − S)` cannot reach the
858/// bottom on a dense log-uniform spectrum (the relative gap `(μ−λ_1)/(μ−λ_2) ≈ 1`
859/// gives no separation); genuine bottom amplification needs `S⁻¹`, whence the CG
860/// inverse iteration here.
861///
862/// The solves may use a loose requested tolerance — an approximate bottom `Q`
863/// only relaxes variance reduction and cannot bias the exact split — but every
864/// requested solve must still CONVERGE to that tolerance. Exhaustion propagates
865/// as `None`; silently retaining an un-amplified start column would falsify the
866/// requested two-sided variance contract. The whole build is a ONE-TIME frozen
867/// cost per outer solve, never per evaluation.
868fn build_inverse_deflation_basis(
869 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
870 dim: usize,
871 rank: usize,
872 iters: usize,
873 seed: u64,
874 cg_rel_tol: f64,
875 cg_max_iters: usize,
876) -> Option<Vec<Array1<f64>>> {
877 let r = rank.min(dim);
878 if r == 0 {
879 return Some(Vec::new());
880 }
881 // Distinct master stream from the top-basis start (a different additive
882 // offset into splitmix) so the top and bottom start blocks are not aliased.
883 let mut master = splitmix64_hash(seed.wrapping_add(0x2545_F491_4F6C_DD1D));
884 let mut cols = orthonormalize(&rademacher_block(&mut master, r, dim));
885 let zero = Array1::<f64>::zeros(dim);
886 for _ in 0..iters {
887 if cols.is_empty() {
888 break;
889 }
890 // Inverse iteration step: apply S⁻¹ column-wise via plain CG (shift 0 on
891 // the SPD operator). Every solve must meet the caller's (possibly loose)
892 // tolerance; an exhausted solve invalidates the requested bottom peel.
893 let applied: Option<Vec<Array1<f64>>> = cols
894 .iter()
895 .map(|c| shifted_cg(matvec, 0.0, c, &zero, cg_rel_tol, cg_max_iters).map(|(y, _)| y))
896 .collect();
897 cols = orthonormalize(&applied?);
898 }
899 Some(cols)
900}
901
902/// Solve `(S + t_ℓ I) y = v` for every input vector across the whole shift
903/// ladder, walking `order` (descending `t`) with per-vector warm starts (the
904/// solution is smooth in `t`, so the previous shift seeds the next). Returns
905/// `solves[ℓ][j]` and the total CG iteration count, or `None` on a shifted-CG
906/// breakdown. Shared by the projected-probe and deflation-basis solve families
907/// so both warm-start identically.
908fn solve_shift_ladder(
909 matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
910 nodes: &[(f64, f64)],
911 order: &[usize],
912 vectors: &[Array1<f64>],
913 cg_rel_tol: f64,
914 cg_max_iters: usize,
915) -> Option<(Vec<Vec<Array1<f64>>>, usize)> {
916 let m = vectors.len();
917 let dim = vectors.first().map(|v| v.len()).unwrap_or(0);
918 let mut solves: Vec<Vec<Array1<f64>>> = vec![Vec::with_capacity(m); nodes.len()];
919 let mut warm: Vec<Array1<f64>> = vec![Array1::zeros(dim); m];
920 let mut total = 0usize;
921 for &ell in order {
922 let (t, _) = nodes[ell];
923 let mut per = Vec::with_capacity(m);
924 for (j, v) in vectors.iter().enumerate() {
925 let (y, iters) = shifted_cg(matvec, t, v, &warm[j], cg_rel_tol, cg_max_iters)?;
926 total += iters;
927 warm[j] = y.clone();
928 per.push(y);
929 }
930 solves[ell] = per;
931 }
932 Some((solves, total))
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938 use ndarray::array;
939
940 fn next_uniform(state: &mut u64, lo: f64, hi: f64) -> f64 {
941 let bits = splitmix64(state) >> 11;
942 let unit = (bits as f64) / ((1u64 << 53) as f64);
943 lo + (hi - lo) * unit
944 }
945
946 /// Random SPD `A = Q diag(λ) Qᵀ` with a prescribed spectrum, returned with
947 /// its exact `log det` and eigen-pieces for derivative oracles.
948 fn spd_with_spectrum(dim: usize, lambdas: &[f64], seed: u64) -> (Array2<f64>, f64) {
949 let mut state = seed;
950 let mut g = Array2::<f64>::zeros((dim, dim));
951 for v in g.iter_mut() {
952 // Box-Muller from two uniforms.
953 let u1 = next_uniform(&mut state, 1e-12, 1.0);
954 let u2 = next_uniform(&mut state, 0.0, 1.0);
955 *v = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
956 }
957 // QR via Gram-Schmidt for an orthonormal Q (dim is small in tests).
958 let mut q = Array2::<f64>::zeros((dim, dim));
959 for c in 0..dim {
960 let mut col = g.column(c).to_owned();
961 for prev in 0..c {
962 let proj = q.column(prev).dot(&col);
963 let prev_col = q.column(prev).to_owned();
964 col.scaled_add(-proj, &prev_col);
965 }
966 let norm = col.dot(&col).sqrt();
967 let col = col / norm;
968 q.column_mut(c).assign(&col);
969 }
970 let mut a = Array2::<f64>::zeros((dim, dim));
971 for (i, &l) in lambdas.iter().enumerate() {
972 let qi = q.column(i);
973 for r in 0..dim {
974 for c in 0..dim {
975 a[[r, c]] += l * qi[r] * qi[c];
976 }
977 }
978 }
979 let logdet: f64 = lambdas.iter().map(|l| l.ln()).sum();
980 (a, logdet)
981 }
982
983 #[test]
984 fn quadrature_is_exact_on_scalar_spectrum() {
985 // dim=1: Hutchinson is exact (v = ±1), so the only error is quadrature.
986 for &x in &[1e-6, 1e-3, 0.5, 1.0, 7.3, 1e4, 1e8] {
987 let plan = RationalLogdetPlan::build(1, 1, 7, x, x, 1e-10).expect("plan");
988 let a = Array2::from_elem((1, 1), x);
989 let eval = plan
990 .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-14, 10_000)
991 .expect("eval");
992 let err = (eval.estimate - x.ln()).abs() / x.ln().abs().max(1.0);
993 assert!(
994 err < 1e-8,
995 "quadrature error {err:.3e} at x={x:e} (est {} vs {})",
996 eval.estimate,
997 x.ln()
998 );
999 }
1000 }
1001
1002 #[test]
1003 fn matches_dense_logdet_within_probe_error_at_wide_kappa() {
1004 // κ = 1e8 spectrum, log-uniform. With m probes the Hutchinson std-err
1005 // scales like sqrt(2 Σ (stuff)/m); assert against a generous multiple
1006 // of the exact dense answer's scale rather than tuning to luck.
1007 let dim = 96;
1008 let mut state = 42u64;
1009 let lambdas: Vec<f64> = (0..dim)
1010 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1011 .collect();
1012 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 1234);
1013 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1014 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1015 let plan = RationalLogdetPlan::build(dim, 64, 11, lmin, lmax, 1e-9).expect("plan");
1016 let eval = plan
1017 .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 50_000)
1018 .expect("eval");
1019 // The probe fluctuation on a wide spectrum is genuinely large (Hutchinson
1020 // variance ~ 2·off-diag mass of log S), so assert the estimator against
1021 // its OWN error bar (5σ ⇒ false-failure odds ~1e-6) plus a small
1022 // deterministic quadrature budget — this validates estimate AND bar.
1023 let err = (eval.estimate - logdet).abs();
1024 let budget = 5.0 * eval.std_err + 1e-3 * logdet.abs().max(1.0);
1025 assert!(
1026 err < budget,
1027 "estimate {} vs exact {} — |err| {err:.3e} exceeds 5σ+quad budget {budget:.3e} \
1028 (std_err {:.3e})",
1029 eval.estimate,
1030 logdet,
1031 eval.std_err
1032 );
1033 assert!(
1034 eval.std_err.is_finite() && eval.std_err > 0.0,
1035 "multi-probe eval must report a positive error bar"
1036 );
1037 }
1038
1039 #[test]
1040 fn directional_derivative_matches_fd_of_the_surrogate_itself() {
1041 // THE contract: the reported gradient is the exact derivative of the
1042 // SURROGATE (same probes, same nodes), not of the true log det. Central
1043 // FD of evaluate() along a random SPD direction must agree tightly.
1044 let dim = 40;
1045 let mut state = 9u64;
1046 let lambdas: Vec<f64> = (0..dim)
1047 .map(|_| 10f64.powf(next_uniform(&mut state, -2.0, 2.0)))
1048 .collect();
1049 let (a, _) = spd_with_spectrum(dim, &lambdas, 77);
1050 let d_lambdas: Vec<f64> = (0..dim)
1051 .map(|_| next_uniform(&mut state, 0.1, 1.0))
1052 .collect();
1053 let (da, _) = spd_with_spectrum(dim, &d_lambdas, 78);
1054 let plan = RationalLogdetPlan::build(dim, 8, 5, 1e-2, 1e2, 1e-9).expect("plan");
1055 let eval = plan
1056 .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-13, 20_000)
1057 .expect("eval");
1058 let grad = plan
1059 .directional_derivative(&eval, &|v: ArrayView1<f64>| da.dot(&v))
1060 .expect("grad");
1061 let h = 1e-5;
1062 let a_plus = &a + &(&da * h);
1063 let a_minus = &a - &(&da * h);
1064 let f_plus = plan
1065 .evaluate(&|v: ArrayView1<f64>| a_plus.dot(&v), 1e-13, 20_000)
1066 .expect("eval+")
1067 .estimate;
1068 let f_minus = plan
1069 .evaluate(&|v: ArrayView1<f64>| a_minus.dot(&v), 1e-13, 20_000)
1070 .expect("eval-")
1071 .estimate;
1072 let fd = (f_plus - f_minus) / (2.0 * h);
1073 let rel = (grad - fd).abs() / fd.abs().max(1e-12);
1074 assert!(
1075 rel < 1e-5,
1076 "surrogate gradient {grad:.9e} vs its own FD {fd:.9e} (rel {rel:.3e})"
1077 );
1078 // Sign sanity: derivative of log det along an SPD direction is positive.
1079 assert!(
1080 grad > 0.0,
1081 "SPD direction must increase log det, got {grad}"
1082 );
1083 }
1084
1085 #[test]
1086 fn fixed_probe_derivative_bundle_matches_rational_directional_not_raw_inverse() {
1087 // Use an intentionally coarse, fixed quadrature window so the rational
1088 // surrogate's derivative is decisively different from the exact
1089 // shift-zero trace. The production bundle must reproduce the former:
1090 // substituting `(v, S^-1 v)` here would make this regression fail.
1091 let diagonal = array![0.2, 3.0, 17.0];
1092 let direction = array![1.0, 2.0, 4.0];
1093 let matvec = |v: ArrayView1<f64>| &diagonal * &v;
1094 let dmatvec = |v: ArrayView1<f64>| &direction * &v;
1095 let plan = RationalLogdetPlan::build(3, 3, 71, 0.2, 17.0, 0.25)
1096 .expect("fixed rational plan");
1097 let eval = plan
1098 .evaluate(&matvec, 1.0e-13, 64)
1099 .expect("fixed rational evaluation");
1100 let authority = plan
1101 .directional_derivative(&eval, &dmatvec)
1102 .expect("rational directional derivative");
1103 let bundle = plan
1104 .into_directional_derivative_bundle(eval)
1105 .expect("lossless rational derivative bundle");
1106 let represented = bundle
1107 .directional_derivative(&dmatvec)
1108 .expect("represented directional derivative");
1109 let scale = authority.abs().max(1.0);
1110 assert!(
1111 (represented - authority).abs() <= 64.0 * f64::EPSILON * scale,
1112 "lossless bundle derivative {represented:.16e} != rational authority \
1113 {authority:.16e}"
1114 );
1115
1116 // Rademacher probes resolve this diagonal exact-inverse trace exactly;
1117 // it is therefore a clean stand-in for the obsolete raw t=0 bundle.
1118 let raw_shift_zero = direction
1119 .iter()
1120 .zip(diagonal.iter())
1121 .map(|(&d, &s)| d / s)
1122 .sum::<f64>();
1123 assert!(
1124 (raw_shift_zero - authority).abs() > 1.0e-4,
1125 "fixture must separate the rational derivative ({authority:.9e}) from the \
1126 raw shift-zero inverse trace ({raw_shift_zero:.9e})"
1127 );
1128 }
1129
1130 #[test]
1131 fn evaluate_is_deterministic_across_calls() {
1132 let dim = 24;
1133 let lambdas: Vec<f64> = (1..=dim).map(|i| i as f64).collect();
1134 let (a, _) = spd_with_spectrum(dim, &lambdas, 3);
1135 let plan = RationalLogdetPlan::build(dim, 4, 99, 1.0, dim as f64, 1e-8).expect("plan");
1136 let e1 = plan
1137 .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 10_000)
1138 .expect("eval1")
1139 .estimate;
1140 let e2 = plan
1141 .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 10_000)
1142 .expect("eval2")
1143 .estimate;
1144 assert_eq!(e1, e2, "fixed plan must be bit-deterministic");
1145 }
1146
1147 #[test]
1148 fn shifted_cg_refuses_an_unconverged_iteration_cap() {
1149 let a = array![[1.0, 0.0], [0.0, 4.0]];
1150 let b = array![1.0, 1.0];
1151 let zero = Array1::<f64>::zeros(2);
1152 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1153
1154 assert!(
1155 shifted_cg(&matvec, 0.0, &b, &zero, 1.0e-12, 1).is_none(),
1156 "one CG step cannot solve a two-eigenvalue system to 1e-12; the \
1157 iteration-capped last iterate must be refused"
1158 );
1159 let (solved, iterations) = shifted_cg(&matvec, 0.0, &b, &zero, 1.0e-12, 2)
1160 .expect("two-dimensional SPD CG must converge in at most two steps");
1161 let residual = &b - &matvec(solved.view());
1162 assert!(
1163 residual.dot(&residual).sqrt() <= 1.0e-12 * b.dot(&b).sqrt(),
1164 "returned shifted solve must satisfy its true-residual contract"
1165 );
1166 assert_eq!(iterations, 2);
1167 }
1168
1169 #[test]
1170 fn two_sided_deflation_propagates_bottom_solve_nonconvergence() {
1171 let a = array![[1.0, 0.0], [0.0, 4.0]];
1172 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1173 let plan =
1174 RationalLogdetPlan::build(2, 2, 17, 1.0, 4.0, 1.0e-9).expect("valid rational plan");
1175 assert!(
1176 plan.with_two_sided_deflation(&matvec, 0, 1, 1, 91, (1.0e-12, 0))
1177 .is_none(),
1178 "a requested bottom-tail inverse solve may not silently fall back to \
1179 the unamplified start column"
1180 );
1181 }
1182
1183 #[test]
1184 fn full_rank_deflation_is_exact_no_hutchinson() {
1185 // Deflating the ENTIRE space (rank = dim) makes P = 0: every probe
1186 // projects to zero, term2 vanishes with no variance, and the estimate is
1187 // the deterministic quadrature tr log(S/c) over a full orthonormal basis
1188 // = exact log det. Pins the term1 / decomposition bookkeeping as
1189 // UNBIASED (a wrong reference count or projector would shift it).
1190 let dim = 28;
1191 let lambdas: Vec<f64> = (1..=dim).map(|i| 0.3 + 0.7 * i as f64).collect();
1192 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 31);
1193 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1194 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1195 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1196 let plan = RationalLogdetPlan::build(dim, 4, 5, lmin, lmax, 1e-11)
1197 .expect("plan")
1198 .with_deflation(&matvec, dim, 2, 123);
1199 let eval = plan.evaluate(&matvec, 1e-14, 20_000).expect("eval");
1200 assert_eq!(
1201 eval.deflation_basis.len(),
1202 dim,
1203 "full-rank block must realise dim orthonormal columns"
1204 );
1205 assert!(
1206 eval.std_err < 1e-8,
1207 "full deflation leaves ~no Hutchinson variance (P ≈ 0), got std_err={:.3e}",
1208 eval.std_err
1209 );
1210 let rel = (eval.estimate - logdet).abs() / logdet.abs().max(1.0);
1211 assert!(
1212 rel < 1e-6,
1213 "full-rank deflation must be exact to quadrature: rel {rel:.3e} \
1214 (est {} vs {logdet})",
1215 eval.estimate
1216 );
1217 }
1218
1219 #[test]
1220 fn full_rank_deflation_is_exact_at_wide_kappa_deterministic_bias_localizer() {
1221 // #2080 DEFINITIVE deterministic-bias localizer at WIDE κ. The sibling
1222 // `full_rank_deflation_is_exact_no_hutchinson` pins the split at κ≈20
1223 // (narrow); this pins it on the SAME κ≈1e8 log-uniform spectrum the wide-κ
1224 // multiseed discriminator uses. Deflating the ENTIRE space (rank = dim)
1225 // makes P = 0: term2 (Hutchinson) vanishes with NO variance, so the estimate
1226 // is the PURE deterministic quadrature of `tr log(S/c)` over a full
1227 // orthonormal basis — and `tr(Qᵀ M Q) = tr(M)` for ANY orthonormal full-rank
1228 // `Q`, so this is independent of which basis the block power realises.
1229 //
1230 // This is the discriminator the multiseed test could not be: if the wide-κ
1231 // "+2.87 (5.57σ)" were a genuine quadrature or split DEFECT it would surface
1232 // HERE, at rel ≈ 5%, with ZERO probe noise to hide behind. It does not — the
1233 // exp-sinh DE quadrature resolves the [λ_min, λ_max] = 1e8 bracket to ~1e-10
1234 // per eigenvalue and the term1/term2 decomposition is exact by construction.
1235 // A nonzero value here (rel ≥ 1e-6) is the ONLY thing that would justify
1236 // "quadrature/split derivation work"; its passing localises the multiseed
1237 // residual entirely to Hutchinson VARIANCE on the deflated complement (fixed
1238 // by more probes / deeper rank / a control variate, NOT a re-derivation).
1239 // Uses the EXACT dense-Cholesky arm so there is not even CG error to blame.
1240 let dim = 96;
1241 let mut state = 2026u64;
1242 let lambdas: Vec<f64> = (0..dim)
1243 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1244 .collect();
1245 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 4321);
1246 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1247 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1248 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1249 let plan = RationalLogdetPlan::build(dim, 8, 5, lmin, lmax, 1e-9)
1250 .expect("plan")
1251 .with_deflation(&matvec, dim, 2, 555);
1252 let eval = evaluate_exact(&plan, &a);
1253 assert_eq!(
1254 eval.deflation_basis.len(),
1255 dim,
1256 "the rank=dim block power must realise a full orthonormal basis even at \
1257 κ≈1e8 (got {}); if it collapses, term2 is nonzero and this stops being a \
1258 zero-variance deterministic check",
1259 eval.deflation_basis.len()
1260 );
1261 assert!(
1262 eval.std_err < 1e-8,
1263 "full deflation must leave ~no Hutchinson variance (P ≈ 0) at wide κ, got \
1264 std_err={:.3e}",
1265 eval.std_err
1266 );
1267 let rel = (eval.estimate - logdet).abs() / logdet.abs().max(1.0);
1268 assert!(
1269 rel < 1e-6,
1270 "wide-κ full-rank deflation must be exact to quadrature — a nonzero value \
1271 is the ONLY signature of a genuine deterministic quadrature/split bias: \
1272 rel {rel:.3e} (est {} vs exact {logdet})",
1273 eval.estimate
1274 );
1275 }
1276
1277 #[test]
1278 fn deflation_cuts_error_bar_and_stays_accurate_at_wide_kappa() {
1279 // κ = 1e8 log-uniform: raw Hutchinson carries a large bar; peeling the
1280 // top-16 directions collapses it while the estimate stays accurate (the
1281 // decomposition is exact for any Q, term2 unbiased for the projected
1282 // probes).
1283 let dim = 96;
1284 let mut state = 2026u64;
1285 let lambdas: Vec<f64> = (0..dim)
1286 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1287 .collect();
1288 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 4321);
1289 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1290 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1291 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1292 let plain = RationalLogdetPlan::build(dim, 32, 17, lmin, lmax, 1e-9).expect("plan");
1293 let defl = plain.clone().with_deflation(&matvec, 16, 3, 555);
1294 let e_plain = plain.evaluate(&matvec, 1e-12, 50_000).expect("plain");
1295 let e_defl = defl.evaluate(&matvec, 1e-12, 50_000).expect("defl");
1296 let rel = (e_defl.estimate - logdet).abs() / logdet.abs().max(1.0);
1297 eprintln!(
1298 "wide-κ: plain std_err={:.3e} defl std_err={:.3e} defl rel={:.3e}",
1299 e_plain.std_err, e_defl.std_err, rel
1300 );
1301 assert!(
1302 rel < 0.05,
1303 "deflated estimate rel err {rel:.3e} (est {} vs exact {logdet})",
1304 e_defl.estimate
1305 );
1306 assert!(
1307 e_defl.std_err < e_plain.std_err,
1308 "deflation must shrink the Hutchinson error bar (plain {:.3e} vs defl {:.3e})",
1309 e_plain.std_err,
1310 e_defl.std_err
1311 );
1312 }
1313
1314 /// Exact (dense Cholesky) audit arm: solve every shifted system
1315 /// `(A + t_ℓ I) y = v` directly, replacing the CG ladder, but feed the results
1316 /// through the SAME [`RationalLogdetPlan::assemble_eval`] the production
1317 /// `evaluate` uses — identical probes, nodes, frozen `Q`, and term1/term2
1318 /// bookkeeping. Any gap between this and the exact log-det is then split/Q
1319 /// structure (or quadrature/probe), never CG solve error, since there is none.
1320 fn evaluate_exact(plan: &RationalLogdetPlan, a: &Array2<f64>) -> RationalLogdetEval {
1321 use gam_linalg::triangular::{
1322 CholeskyGuard, cholesky_factor_in_place, cholesky_solve_vector,
1323 };
1324 let basis: &[Array1<f64>] = plan
1325 .deflation
1326 .as_ref()
1327 .map(|d| d.basis.as_slice())
1328 .unwrap_or(&[]);
1329 let probes_proj = plan.projected_probes(basis);
1330 let exact_ladder = |vectors: &[Array1<f64>]| -> Vec<Vec<Array1<f64>>> {
1331 plan.nodes
1332 .iter()
1333 .map(|&(t, _)| {
1334 let mut at = a.clone();
1335 for i in 0..a.nrows() {
1336 at[[i, i]] += t;
1337 }
1338 let l = cholesky_factor_in_place(at.view(), CholeskyGuard::FiniteStrict)
1339 .expect("shifted SPD system must factor");
1340 vectors
1341 .iter()
1342 .map(|v| cholesky_solve_vector(l.view(), v.view()))
1343 .collect()
1344 })
1345 .collect()
1346 };
1347 let shifted = exact_ladder(&probes_proj);
1348 let deflation_solves = if basis.is_empty() {
1349 Vec::new()
1350 } else {
1351 exact_ladder(basis)
1352 };
1353 plan.assemble_eval(probes_proj, basis, shifted, deflation_solves, 0)
1354 .expect("exact assemble")
1355 }
1356
1357 #[test]
1358 fn deflation_wide_kappa_bias_cg_convergence_discriminator() {
1359 // #2080 loop discriminator (battery_5e59e646b). With BOTH the quadrature
1360 // window fix and the `/c` node-placement fix landed,
1361 // `deflation_cuts_error_bar_and_stays_accurate_at_wide_kappa` still fails
1362 // ~10% low (est ≈51.54 vs exact ≈57.39) — NEARLY byte-identical to the
1363 // pre-fix 51.529 — while `full_rank_deflation_is_exact_no_hutchinson` now
1364 // PASSES. So pure quadrature is exonerated and the residual bias appears
1365 // ONLY with deflation at wide κ. Prime suspect: CG under-resolution of the
1366 // SMALL-shift solves on the κ=1e8 operator — those directions carry most of
1367 // the log-det magnitude, and a `cg_rel_tol` scaled to ‖b‖ can stop before
1368 // resolving the small-eigenvalue mass, dropping it deterministically (which
1369 // is why the FD gates, sharing the same value path, still pass — a
1370 // self-consistent bias).
1371 //
1372 // Discriminator: rerun the EXACT failing fixture with `cg_rel_tol`
1373 // tightened 1e-12→1e-15 and `cg_max_iters` ×10. If the deflated estimate
1374 // becomes accurate the bias is SOLVE-CONVERGENCE (production fix = an honest
1375 // per-shift iteration/tolerance budget from the shift-dependent conditioning
1376 // `κ_ℓ = (λmax + t)/(λmin + t)`); if it stays ~10% low (Δest ≈ 0) the bias is
1377 // STRUCTURAL in the deflated split and needs a fresh derivation. The loop
1378 // battery verdicts this test's pass/fail.
1379 let dim = 96;
1380 let mut state = 2026u64;
1381 let lambdas: Vec<f64> = (0..dim)
1382 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1383 .collect();
1384 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 4321);
1385 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1386 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1387 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1388 let plain = RationalLogdetPlan::build(dim, 32, 17, lmin, lmax, 1e-9).expect("plan");
1389 let defl = plain.clone().with_deflation(&matvec, 16, 3, 555);
1390 // Three arms on the SAME deflated plan: loose CG (the failing fixture's
1391 // budget), tightened CG, and EXACT dense-Cholesky solves. The exact arm is
1392 // the DEFINITIVE one — it carries no CG error, so its residual is purely
1393 // split/Q/quadrature structure.
1394 let e_loose = defl.evaluate(&matvec, 1e-12, 50_000).expect("loose");
1395 let e_tight = defl.evaluate(&matvec, 1e-15, 500_000).expect("tight");
1396 let e_exact = evaluate_exact(&defl, &a);
1397 let rel_loose = (e_loose.estimate - logdet).abs() / logdet.abs().max(1.0);
1398 let rel_tight = (e_tight.estimate - logdet).abs() / logdet.abs().max(1.0);
1399 let rel_exact = (e_exact.estimate - logdet).abs() / logdet.abs().max(1.0);
1400 // Structural suspect 1: is the loose bias many σ (systematic) or within the
1401 // Hutchinson bar (a probe fluctuation)? gap ≈ 5.85 vs the reported std_err.
1402 let gap = logdet - e_loose.estimate;
1403 let sigma_ratio = gap.abs() / e_loose.std_err.max(1e-300);
1404 eprintln!(
1405 "wide-κ 3-arm discriminator: exact_logdet={logdet:.6}\n \
1406 loose(1e-12,50k) est={:.6} rel={rel_loose:.3e} std_err={:.3e}\n \
1407 tight(1e-15,500k) est={:.6} rel={rel_tight:.3e} Δvs_loose={:.3e}\n \
1408 EXACT(cholesky) est={:.6} rel={rel_exact:.3e} Δvs_loose={:.3e}\n \
1409 gap={gap:.4} = {sigma_ratio:.1}σ (loose std_err)",
1410 e_loose.estimate,
1411 e_loose.std_err,
1412 e_tight.estimate,
1413 (e_tight.estimate - e_loose.estimate).abs(),
1414 e_exact.estimate,
1415 (e_exact.estimate - e_loose.estimate).abs(),
1416 );
1417
1418 // Structural suspect 2: the "exact for any orthonormal Q" proof breaks only
1419 // if the probe projector and term1 use a DIFFERENT or non-orthonormal Q.
1420 // Verify the realised Q is the frozen basis, is orthonormal, and that the
1421 // projected probes are truly Q-orthogonal (P = I − QQᵀ applied).
1422 let frozen: &[Array1<f64>] = defl
1423 .deflation
1424 .as_ref()
1425 .map(|d| d.basis.as_slice())
1426 .unwrap_or(&[]);
1427 assert_eq!(
1428 e_exact.deflation_basis.len(),
1429 frozen.len(),
1430 "exact arm must realise the same frozen Q rank as the plan"
1431 );
1432 for (qe, qf) in e_exact.deflation_basis.iter().zip(frozen) {
1433 assert!(
1434 (qe - qf).mapv(f64::abs).sum() < 1e-12,
1435 "term1's Q must be the plan's frozen Q (no drift)"
1436 );
1437 }
1438 for (i, qi) in frozen.iter().enumerate() {
1439 for (j, qj) in frozen.iter().enumerate() {
1440 let expect = if i == j { 1.0 } else { 0.0 };
1441 assert!(
1442 (qi.dot(qj) - expect).abs() < 1e-9,
1443 "frozen Q must be orthonormal: QᵀQ[{i},{j}] = {}",
1444 qi.dot(qj)
1445 );
1446 }
1447 }
1448 let proj = defl.projected_probes(frozen);
1449 for u in &proj {
1450 for q in frozen {
1451 assert!(
1452 u.dot(q).abs() < 1e-9,
1453 "projected probe must be Q-orthogonal (same P as term1)"
1454 );
1455 }
1456 }
1457
1458 // DEFINITIVE verdict via the exact arm (no CG error possible):
1459 // GREEN (rel_exact < 0.05) ⇒ the deflated split / frozen-Q structure is
1460 // SOUND; ALL residual bias in the CG path is solve error → the fix is
1461 // per-shift preconditioning (the κ·ε≈2e-8 residual floor at κ=1e8 means
1462 // a tighter cg_rel_tol alone cannot reach it; Jacobi / shifted-system
1463 // preconditioning is the lever, not tolerance).
1464 // RED with rel_exact ≈ rel_loose ⇒ the bias is STRUCTURAL in the deflated
1465 // split with the frozen subspace-iteration Q at wide κ → fresh derivation.
1466 // The loop verdicts this test's pass/fail alongside the three logged arms.
1467 assert!(
1468 rel_exact < 0.05,
1469 "EXACT-solve deflated estimate rel err {rel_exact:.3e} (est {} vs exact {logdet}); \
1470 CG loose rel {rel_loose:.3e}, tight rel {rel_tight:.3e}. With no CG error possible, \
1471 rel_exact ≈ rel_loose ⇒ the wide-κ bias is STRUCTURAL in the deflated split, not \
1472 solve convergence",
1473 e_exact.estimate
1474 );
1475 }
1476
1477 #[test]
1478 fn deflation_wide_kappa_variance_vs_bias_multiseed() {
1479 // #2080 DEFINITIVE variance-vs-bias split for the wide-κ "10% low" verdict.
1480 //
1481 // The sibling `..._cg_convergence_discriminator` proves the residual is NOT
1482 // CG error (loose/tight/EXACT-Cholesky arms are byte-identical) and reports
1483 // gap ≈ 5.85 = 0.6σ against a std_err of 9.25 (≈16% of |logdet|=57.4). A
1484 // SINGLE-seed draw at 0.6σ cannot distinguish a structural split/quadrature
1485 // BIAS from an unlucky Hutchinson VARIANCE draw — the two have completely
1486 // different production fixes (fresh derivation vs. more probes / deeper
1487 // deflation / a control variate). This test settles it by AVERAGING the
1488 // estimate over K independent probe seeds while holding the deterministic
1489 // pieces fixed:
1490 // • frozen Q (seed 555, rank 16) — term1 = tr(Qᵀ log(S/c) Q) is IDENTICAL
1491 // across seeds, so it drops out of the seed-to-seed spread;
1492 // • EXACT Cholesky solves — zero CG error, as the sibling established;
1493 // • only the 32 Rademacher probes (and thus term2's Hutchinson draw) vary.
1494 // Hutchinson is UNBIASED over Rademacher probes, so
1495 // E_seed[estimate] = k·log c + term1 + E_seed[term2]
1496 // → k·log c + tr_quad(Qᵀ·Q) + tr_quad(P·P)
1497 // = the QUADRATURE approximation of tr log S.
1498 // Hence bias_of_mean isolates the DETERMINISTIC (split+quadrature) error
1499 // with the Hutchinson variance averaged away as 1/√K. Verdict:
1500 // |mean − logdet| ≲ 3·se_mean ⇒ VARIANCE-dominated: the split+quadrature
1501 // are unbiased at κ=1e8; the fix is variance reduction (more probes,
1502 // deeper deflation rank, or a control variate), NOT a re-derivation.
1503 // |mean − logdet| ≫ se_mean ⇒ a genuine deterministic bias remains
1504 // (quadrature under-resolution or a split defect) → derivation work.
1505 let dim = 96;
1506 let mut state = 2026u64;
1507 let lambdas: Vec<f64> = (0..dim)
1508 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1509 .collect();
1510 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 4321);
1511 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1512 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1513 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1514
1515 // Fixed frozen Q (identical to the sibling's), varied ONLY by probe seed.
1516 let k_seeds = 96usize;
1517 let mut ests = Vec::with_capacity(k_seeds);
1518 let mut internal_bars = Vec::with_capacity(k_seeds);
1519 // Guard the PRECONDITION this discriminator rests on: the K·m probe vectors
1520 // must be JOINTLY INDEPENDENT across the unit-spaced seeds. The former
1521 // per-probe RNG init `(seed + p)·γ` aliased them — a splitmix stream from
1522 // `x₀` and one from `x₀ + γ` are the same stream shifted, so
1523 // `(seed=9000+s, probe=p)` and `(9000+s−1, p+1)` were BIT-IDENTICAL and the
1524 // 96 seeds drew only ~128 distinct vectors of 96·32=3072. Averaging
1525 // correlated draws does not reduce variance as 1/√K, so `se_mean` collapsed
1526 // and a shared Hutchinson fluctuation was reported as a "5.57σ deterministic
1527 // bias". Fingerprint every probe's sign pattern (dim ≤ 128) and require near
1528 // all distinct, so an RNG regression that re-aliases the seeds fails HERE
1529 // rather than resurfacing as a phantom quadrature/split bias.
1530 let mut probe_fingerprints: std::collections::HashSet<u128> =
1531 std::collections::HashSet::new();
1532 for s in 0..k_seeds {
1533 let plan = RationalLogdetPlan::build(dim, 32, 9000 + s as u64, lmin, lmax, 1e-9)
1534 .expect("plan")
1535 .with_deflation(&matvec, 16, 3, 555);
1536 for probe in &plan.probes {
1537 let mut fp = 0u128;
1538 for (i, &x) in probe.iter().enumerate() {
1539 if x > 0.0 {
1540 fp |= 1u128 << i;
1541 }
1542 }
1543 probe_fingerprints.insert(fp);
1544 }
1545 let e = evaluate_exact(&plan, &a);
1546 ests.push(e.estimate);
1547 internal_bars.push(e.std_err);
1548 }
1549 let total_pairs = k_seeds * 32;
1550 let distinct = probe_fingerprints.len();
1551 assert!(
1552 distinct as f64 > 0.95 * total_pairs as f64,
1553 "probe vectors must be jointly independent across seeds for this \
1554 variance-vs-bias split to be valid: only {distinct} distinct of \
1555 {total_pairs} (seed, probe) pairs — the RNG has re-aliased unit-spaced \
1556 seeds (expected ~{total_pairs}), so any reported σ is meaningless"
1557 );
1558 let n = ests.len() as f64;
1559 let mean = ests.iter().sum::<f64>() / n;
1560 let var = ests.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
1561 let sd = var.sqrt();
1562 let se_mean = sd / n.sqrt();
1563 let mean_internal_bar = internal_bars.iter().sum::<f64>() / n;
1564 let bias = mean - logdet;
1565 let bias_frac = bias.abs() / logdet.abs().max(1.0);
1566 let bias_sigma = bias.abs() / se_mean.max(1e-300);
1567 eprintln!(
1568 "wide-κ variance-vs-bias ({k_seeds} seeds, fixed Q, EXACT solves): exact={logdet:.6}\n \
1569 mean={mean:.6} bias={bias:+.6} ({bias_frac:.3e} rel, {bias_sigma:.2}σ of the mean)\n \
1570 seed-to-seed sd={sd:.4} se_mean={se_mean:.4} ⟨internal std_err⟩={mean_internal_bar:.4}\n \
1571 VERDICT: {}",
1572 if bias_sigma < 3.0 {
1573 "VARIANCE-dominated — split+quadrature UNBIASED at κ=1e8; fix = variance reduction (probes/rank/control-variate), NOT re-derivation"
1574 } else {
1575 "genuine DETERMINISTIC bias survives probe-averaging — quadrature/split derivation work needed"
1576 }
1577 );
1578 // Cross-check: the internal per-eval Hutchinson bar must PREDICT the
1579 // observed seed-to-seed spread (both estimate the same term2 variance);
1580 // a gross mismatch would mean the reported std_err is itself miscalibrated.
1581 assert!(
1582 (mean_internal_bar / sd).ln().abs() < 1.0,
1583 "internal std_err ({mean_internal_bar:.3}) must track the empirical seed spread ({sd:.3}) \
1584 within a factor e; a mismatch means the surrogate's error bar is miscalibrated"
1585 );
1586 // The definitive verdict for #2080's deflation lane: with the split and
1587 // quadrature deterministic and solves exact, the probe-averaged estimate is
1588 // an UNBIASED estimator of tr log S. If this holds, the single-seed "10%"
1589 // is variance and the sibling discriminator's STRUCTURAL framing is too
1590 // strong — the production lever is variance reduction, not a new derivation.
1591 assert!(
1592 bias_sigma < 3.0 || bias_frac < 0.02,
1593 "probe-averaged estimate is biased by {bias:+.4} ({bias_frac:.3e} rel, {bias_sigma:.2}σ): \
1594 deterministic split/quadrature bias survives — genuine derivation work, not variance"
1595 );
1596 }
1597
1598 #[test]
1599 fn two_sided_deflation_drops_wide_kappa_std_err_below_two_percent() {
1600 // #2080 wide-κ VARIANCE-REDUCTION deliverable. The multiseed discriminator
1601 // established the surrogate is UNBIASED at κ=1e8 but too NOISY: the wide-κ
1602 // Hutchinson bar is ~14% of |logdet| with one-sided top deflation — too
1603 // loose for the outer REML to trust one evaluation. Root cause (see
1604 // `build_inverse_deflation_basis`): the bar is `√(2‖offdiag(P log(S/c) P)‖_F²)`,
1605 // and `log(S/c)`'s off-diagonal mass sits SYMMETRICALLY on both spectral
1606 // tails, so one-sided (top-only) deflation removes only half of it. Peeling
1607 // BOTH tails — the two-sided low-rank control variate — collapses the bar.
1608 //
1609 // This measures the bar three ways on IDENTICAL probes through the EXACT
1610 // dense-Cholesky estimator arm (so `std_err` reflects the ESTIMATOR variance,
1611 // not CG solve noise) and asserts the two-sided bar (a) falls below 2% of
1612 // |logdet|, and (b) beats ONE-sided deflation AT EQUAL TOTAL RANK — the
1613 // apples-to-apples proof that the win is the two-sidedness, not merely more
1614 // deflated columns. The value stays unbiased throughout (exact split for any
1615 // orthonormal Q), checked against the estimator's own 5σ bar.
1616 let dim = 96;
1617 let mut state = 2026u64;
1618 let lambdas: Vec<f64> = (0..dim)
1619 .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1620 .collect();
1621 let (a, logdet) = spd_with_spectrum(dim, &lambdas, 4321);
1622 let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1623 let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1624 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1625
1626 // Common 256-probe block (CRN); the three plans differ ONLY in the frozen Q.
1627 let base = RationalLogdetPlan::build(dim, 256, 17, lmin, lmax, 1e-9).expect("plan");
1628 let top16 = base.clone().with_deflation(&matvec, 16, 3, 555); // current wide-κ config
1629 let top64 = base.clone().with_deflation(&matvec, 64, 3, 555); // one-sided, EQUAL total rank
1630 let two = base
1631 .clone()
1632 .with_two_sided_deflation(&matvec, 32, 32, 3, 555, (1e-3, 5000))
1633 .expect("bottom-tail inverse iteration must converge"); // 32 top + 32 bottom
1634
1635 let e16 = evaluate_exact(&top16, &a);
1636 let e64 = evaluate_exact(&top64, &a);
1637 let e2 = evaluate_exact(&two, &a);
1638 let f = |se: f64| se / logdet.abs().max(1.0);
1639 eprintln!(
1640 "wide-κ variance reduction (256 probes, EXACT estimator): |logdet|={:.4}\n \
1641 top-only r16 (current): std_err={:.4} ({:.4} of |ld|)\n \
1642 top-only r64 (eq-rank): std_err={:.4} ({:.4} of |ld|)\n \
1643 two-sided 32+32: std_err={:.4} ({:.4} of |ld|) rel={:.4}\n \
1644 => vs top-r16 {:.2}×, vs eq-rank top-r64 {:.2}×",
1645 logdet.abs(),
1646 e16.std_err,
1647 f(e16.std_err),
1648 e64.std_err,
1649 f(e64.std_err),
1650 e2.std_err,
1651 f(e2.std_err),
1652 (e2.estimate - logdet).abs() / logdet.abs().max(1.0),
1653 e16.std_err / e2.std_err.max(1e-300),
1654 e64.std_err / e2.std_err.max(1e-300),
1655 );
1656 assert_eq!(
1657 e2.deflation_basis.len(),
1658 64,
1659 "two-sided block must realise 32 top + 32 bottom orthonormal columns (got {})",
1660 e2.deflation_basis.len()
1661 );
1662 // (a) below the 2%-of-|logdet| production target.
1663 assert!(
1664 e2.std_err < 0.02 * logdet.abs(),
1665 "two-sided wide-κ std_err {:.4} must fall below 2% of |logdet| ({:.4})",
1666 e2.std_err,
1667 0.02 * logdet.abs()
1668 );
1669 // (b) beats ONE-sided deflation at EQUAL total rank (the two-sidedness is
1670 // the lever, not the column count) — calibrated ratio ≈ 2.75×, assert ≥ 2×.
1671 assert!(
1672 e2.std_err < 0.5 * e64.std_err,
1673 "two-sided ({:.4}) must beat equal-rank one-sided ({:.4}) by ≥2× — the win is \
1674 peeling BOTH tails, not merely deflating more columns",
1675 e2.std_err,
1676 e64.std_err
1677 );
1678 // Value stays unbiased (exact split for any orthonormal Q): the estimate is
1679 // within its own honest 5σ bar of the exact log-det.
1680 assert!(
1681 (e2.estimate - logdet).abs() < 5.0 * e2.std_err,
1682 "two-sided estimate {:.4} must stay within 5σ ({:.4}) of exact {:.4} — variance \
1683 reduction must not bias the value",
1684 e2.estimate,
1685 5.0 * e2.std_err,
1686 logdet
1687 );
1688 }
1689
1690 #[test]
1691 fn deflated_directional_derivative_matches_fd_of_surrogate() {
1692 // The value↔gradient no-desync contract WITH deflation: the fixed-Q
1693 // directional derivative is the exact derivative of the surrogate value
1694 // because Q is FROZEN. Building the plan's Q once from `a` and reusing it
1695 // for the a ± h·da evaluations holds Q fixed, so the central FD matches
1696 // the analytic gradient tightly.
1697 let dim = 40;
1698 let mut state = 9u64;
1699 let lambdas: Vec<f64> = (0..dim)
1700 .map(|_| 10f64.powf(next_uniform(&mut state, -2.0, 2.0)))
1701 .collect();
1702 let (a, _) = spd_with_spectrum(dim, &lambdas, 77);
1703 let d_lambdas: Vec<f64> = (0..dim)
1704 .map(|_| next_uniform(&mut state, 0.1, 1.0))
1705 .collect();
1706 let (da, _) = spd_with_spectrum(dim, &d_lambdas, 78);
1707 let matvec = |v: ArrayView1<f64>| a.dot(&v);
1708 let plan = RationalLogdetPlan::build(dim, 8, 5, 1e-2, 1e2, 1e-9)
1709 .expect("plan")
1710 .with_deflation(&matvec, 6, 3, 4242);
1711 assert!(
1712 plan.deflation.as_ref().is_some_and(|d| !d.basis.is_empty()),
1713 "deflation basis must have been frozen"
1714 );
1715 let eval = plan.evaluate(&matvec, 1e-13, 20_000).expect("eval");
1716 let grad = plan
1717 .directional_derivative(&eval, &|v: ArrayView1<f64>| da.dot(&v))
1718 .expect("grad");
1719 let h = 1e-5;
1720 let a_plus = &a + &(&da * h);
1721 let a_minus = &a - &(&da * h);
1722 let f_plus = plan
1723 .evaluate(&|v: ArrayView1<f64>| a_plus.dot(&v), 1e-13, 20_000)
1724 .expect("eval+")
1725 .estimate;
1726 let f_minus = plan
1727 .evaluate(&|v: ArrayView1<f64>| a_minus.dot(&v), 1e-13, 20_000)
1728 .expect("eval-")
1729 .estimate;
1730 let fd = (f_plus - f_minus) / (2.0 * h);
1731 let rel = (grad - fd).abs() / fd.abs().max(1e-12);
1732 assert!(
1733 rel < 1e-5,
1734 "deflated surrogate gradient {grad:.9e} vs its own FD {fd:.9e} (rel {rel:.3e})"
1735 );
1736 assert!(
1737 grad > 0.0,
1738 "SPD direction must increase log det, got {grad}"
1739 );
1740 }
1741}