Skip to main content

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}