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 OPERATOR APPLIES spent (diagnostic). Under the family evaluator
136    /// this counts the seed Krylov steps, the per-node certification apply, and
137    /// any single-shift repair — i.e. the true cost, not an iteration count that
138    /// would omit the certifications.
139    pub cg_iterations: usize,
140}
141
142/// Work and shape diagnostics for one evaluation of a frozen rational
143/// log-determinant plan. These describe the exact shifted-solve ladder whose
144/// value and derivative bundle were emitted together.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub struct RationalLogdetEvaluationMetrics {
147    /// Total certified shifted-CG iterations across probe and deflation solves.
148    pub cg_iterations: usize,
149    /// Number of rational quadrature nodes in the frozen plan.
150    pub node_count: usize,
151    /// Number of frozen deflation directions actually realised.
152    pub deflation_rank: usize,
153}
154
155/// Lossless low-rank representation of the derivative of one fixed rational
156/// log-determinant evaluation.
157///
158/// For every symmetric operator direction `D`,
159///
160/// `plan.directional_derivative(eval, D) = (1/r) Σ_a x_a^T D x_a`,
161///
162/// where `x_a` are [`Self::vectors`] and `r` is their count.  The vectors fold
163/// in every quadrature weight, the Hutchinson `1/m`, and the deterministic
164/// deflation block.  Consequently consumers that already assemble arrow
165/// selected-inverse contractions from probe pairs can use `(vectors, vectors)`
166/// without pretending that the vectors are raw probes or unshifted `S^-1`
167/// solves.  This representation is the derivative of the rational SURROGATE,
168/// not an estimator of the derivative of the exact log determinant.
169pub struct RationalLogdetDerivativeBundle {
170    pub vectors: Vec<Array1<f64>>,
171    metrics: RationalLogdetEvaluationMetrics,
172}
173
174impl RationalLogdetDerivativeBundle {
175    /// Diagnostics for the evaluation that produced this derivative bundle.
176    /// Keeping them on the bundle makes it impossible to report work from one
177    /// operator alongside the derivative of another.
178    #[must_use]
179    pub fn evaluation_metrics(&self) -> RationalLogdetEvaluationMetrics {
180        self.metrics
181    }
182
183    /// Apply the represented derivative to a symmetric operator direction.
184    pub fn directional_derivative(
185        &self,
186        dmatvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
187    ) -> Option<f64> {
188        if self.vectors.is_empty() {
189            return None;
190        }
191        let inv_rank = 1.0 / self.vectors.len() as f64;
192        let derivative = self
193            .vectors
194            .iter()
195            .map(|vector| vector.dot(&dmatvec(vector.view())))
196            .sum::<f64>()
197            * inv_rank;
198        derivative.is_finite().then_some(derivative)
199    }
200}
201
202impl RationalLogdetPlan {
203    /// Build a plan for spectrum bracket `[lambda_min, lambda_max]` (rough
204    /// estimates are fine — the window is padded two decades on each side),
205    /// `num_probes` Rademacher probes, and a target quadrature accuracy of
206    /// roughly `rel_tol` on `log det`.
207    pub fn build(
208        dim: usize,
209        num_probes: usize,
210        seed: u64,
211        lambda_min: f64,
212        lambda_max: f64,
213        rel_tol: f64,
214    ) -> Option<Self> {
215        if dim == 0
216            || num_probes == 0
217            || !(lambda_min.is_finite() && lambda_max.is_finite())
218            || lambda_min <= 0.0
219            || lambda_max < lambda_min
220            || !(rel_tol.is_finite() && rel_tol > 0.0 && rel_tol < 1.0)
221        {
222            return None;
223        }
224        // ONE sequential master stream for ALL probes. The former per-probe
225        // initial state `(seed + p)·γ` (γ = the splitmix64 increment) made
226        // probe `p` of seed `s` BIT-IDENTICAL to probe `p+1` of seed `s−1`
227        // (a splitmix stream from x₀ emits the words at x₀+γ, x₀+2γ, …, so
228        // any two starts differing by a multiple of γ are the same stream
229        // shifted), and within one plan made probe `p+1`'s word stream probe
230        // `p`'s shifted by one word — a sliding window sharing sign words
231        // between consecutive probes. Each probe was still individually
232        // uniform Rademacher (Hutchinson stays unbiased), but the probes were
233        // NOT jointly independent: the std_err bookkeeping and any
234        // seed-averaged inference (the wide-κ multiseed discriminator, whose
235        // 96 seeds at unit spacing drew ~128 distinct probe vectors instead
236        // of 3072 and reported a common Hutchinson fluctuation as a "5.57σ
237        // deterministic bias") were invalidated. Sequential consumption from
238        // one hashed master state has no window structure and no cross-seed
239        // stream aliasing; determinism per seed (the CRN contract) is kept.
240        let mut master = splitmix64_hash(seed);
241        let probes = rademacher_block(&mut master, num_probes, dim);
242        // Bracket-centred exp-sinh DE nodes for the shifted representation
243        //
244        //   log x = log c + ∫₀^∞ ( 1/(c+t) − 1/(x+t) ) dt,   c = √(λ_min·λ_max),
245        //
246        // with t(u) = c·exp(π/2·sinh u), dt = t·(π/2)·cosh u du. Centring at the
247        // geometric bracket midpoint keeps the integrand's complex poles
248        // (t = −λ_i, i.e. u where c·exp(π/2·sinh u) = −λ_i) as far from the
249        // real u-axis as the spectrum allows. The nearest pole sits at height
250        // d(λ) ≈ (π/2)/cosh(u_λ), u_λ = asinh((2/π)·ln(λ/c)), which SHRINKS
251        // with the bracket width — the reason a fixed h fails at wide κ. Size
252        // the step from the trapezoid-DE bound err ~ exp(−2π·d_min/h):
253        // h = 2π·d_min/ln(1/tol).
254        //
255        // TRUNCATION WINDOW must be sized by rel_tol, NOT a fixed decade pad. The
256        // dropped tails of the t-integral are, for the EXTREME eigenvalues,
257        //   low : ∫₀^{t_lo}(1/(c+t) − 1/(λ_min+t))dt ≈ (1/c − 1/λ_min)·t_lo
258        //         ≈ −t_lo/λ_min,          bounded by rel_tol ⟺ t_lo = λ_min·rel_tol
259        //   high: ∫_{t_hi}^∞(1/(c+t) − 1/(λ_max+t))dt ≈ (λ_max − c)/t_hi
260        //         ≈ λ_max/t_hi,           bounded by rel_tol ⟺ t_hi = λ_max/rel_tol.
261        // The former fixed two-decade pad (t_lo = (λ_min/c)·1e-2, t_hi =
262        // (λ_max/c)·1e2) left these tails at O(1e-2/c) and O(1e-2) — orders ABOVE
263        // rel_tol — so the estimate lost the extreme (esp. TOP) eigenvalues' tail
264        // mass and was biased LOW, worst at wide κ. The DE transform compresses
265        // the wider t-window into a modest u-range (double-exponential), so the
266        // node count grows only logarithmically.
267        let c = (lambda_min * lambda_max).sqrt();
268        let t_lo = lambda_min * rel_tol;
269        let t_hi = lambda_max / rel_tol;
270        // Invert t(u) = c·exp(π/2·sinh u): u(t) = asinh((2/π)·ln(t/c)). The /c is
271        // load-bearing — t_lo/t_hi below are ABSOLUTE truncation points, so a node
272        // at u_of(t) must land at t, not c·t (which shifts the resolved window by a
273        // full factor of c and under-resolves the extreme-eigenvalue tails). Mirrors
274        // the /c the pole_height ratio uses just below.
275        let u_of = |t: f64| ((2.0 / std::f64::consts::PI) * (t / c).ln()).asinh();
276        let u_lo = u_of(t_lo);
277        let u_hi = u_of(t_hi);
278        // Worst-case pole height over the padded bracket (evaluate at both
279        // ends; the pole of the reference term at t = −c sits at u = 0 with
280        // height π/2, never the minimum).
281        let pole_height = |lam_over_c: f64| -> f64 {
282            let s = (2.0 / std::f64::consts::PI) * lam_over_c.ln();
283            std::f64::consts::FRAC_PI_2 / (1.0 + s * s).sqrt()
284        };
285        let d_min = pole_height(lambda_min / c)
286            .min(pole_height(lambda_max / c))
287            .min(std::f64::consts::FRAC_PI_2);
288        let h_bound = 2.0 * std::f64::consts::PI * d_min / (1.0f64 / rel_tol).ln();
289        let steps = (((u_hi - u_lo) / h_bound).ceil() as usize).max(16);
290        let h = (u_hi - u_lo) / steps as f64;
291        let mut nodes = Vec::with_capacity(steps + 1);
292        for s in 0..=steps {
293            let u = u_lo + h * s as f64;
294            let t = c * (std::f64::consts::FRAC_PI_2 * u.sinh()).exp();
295            let w = h * t * std::f64::consts::FRAC_PI_2 * u.cosh();
296            if t.is_finite() && w.is_finite() && w > 0.0 {
297                nodes.push((t, w));
298            }
299        }
300        if nodes.is_empty() {
301            return None;
302        }
303        Some(Self {
304            dim,
305            probes,
306            nodes,
307            log_center: c.ln(),
308            center: c,
309            deflation: None,
310        })
311    }
312
313    /// `Self::with_two_sided_deflation` with the same diagonal preconditioner
314    /// the evaluations use.
315    ///
316    /// The bottom-tail basis comes from INVERSE iteration — plain CG on the
317    /// UNSHIFTED operator at full `κ` — which is the single worst-conditioned
318    /// solve family in the whole surrogate. Preconditioning it is not optional
319    /// bookkeeping: without it, a wide-diagonal border makes the deflation ladder
320    /// (which doubles the rank until the error bar clears) spend its entire
321    /// budget inside `build_inverse_deflation_basis` (#2576). The basis only
322    /// steers variance reduction, so this cannot bias the value either way.
323    pub fn with_two_sided_deflation_preconditioned(
324        mut self,
325        matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
326        preconditioner: &ShiftedDiagonalPreconditioner,
327        top_rank: usize,
328        bottom_rank: usize,
329        subspace_iters: usize,
330        seed: u64,
331        cg: (f64, usize),
332    ) -> Option<Self> {
333        let (cg_rel_tol, cg_max_iters) = cg;
334        let mut cols = build_deflation_basis(matvec, self.dim, top_rank, subspace_iters, seed);
335        cols.extend(build_inverse_deflation_basis(
336            matvec,
337            preconditioner,
338            self.dim,
339            bottom_rank,
340            subspace_iters,
341            seed,
342            cg_rel_tol,
343            cg_max_iters,
344        )?);
345        // Merge the two orthonormal families into ONE orthonormal basis (the top
346        // and bottom blocks are near-orthogonal but not exactly; the second MGS
347        // pass in `orthonormalize` cleans the cross terms and drops any collapsed
348        // column, so `Q` stays exactly orthonormal — the property term1 needs).
349        let basis = orthonormalize(&cols);
350        self.deflation = (!basis.is_empty()).then_some(DeflationSpec { basis });
351        Some(self)
352    }
353
354    /// Evaluate the surrogate `L̃ ≈ log det S` through `matvec(v) = S·v`.
355    ///
356    /// The whole quadrature ladder is served from ONE Krylov space per
357    /// right-hand side (`solve_shift_family`). A shift adds a multiple of the
358    /// identity and therefore changes no polynomial's span, so
359    /// `K_m(S + t_ℓ I, v)` is the SAME subspace for every node: rebuilding it
360    /// per node — which is what a per-shift ladder does, warm starts and all —
361    /// pays `node_count` times for one piece of information (#2576).
362    ///
363    /// Every returned solution still meets the same true-residual certificate
364    /// `shifted_pcg` enforces, and any that does not is finished by that same
365    /// solve. The functional is untouched: probes, quadrature nodes and the
366    /// frozen deflation basis are the plan's, and only the numerical means of
367    /// inverting each shifted system changes.
368    pub fn evaluate(
369        &self,
370        matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
371        cg_rel_tol: f64,
372        cg_max_iters: usize,
373    ) -> Option<RationalLogdetEval> {
374        self.evaluate_family_preconditioned(
375            matvec,
376            &IDENTITY_SHIFT_PRECONDITIONER,
377            cg_rel_tol,
378            cg_max_iters,
379        )
380    }
381
382    /// [`Self::evaluate`] with a diagonal available to the single-shift repair
383    /// path.
384    ///
385    /// The family solve itself is necessarily undiagonalized — this module's
386    /// diagonal is `1/(diag(S) + t)`, which is shift-DEPENDENT, and applying
387    /// anything shift-dependent is exactly what destroys the shared Krylov space
388    /// the family solve exists to exploit. A shift whose multi-shift iterate
389    /// misses its certificate is finished by a single-shift `shifted_pcg`,
390    /// where one fixed `t` makes the diagonal a legitimate preconditioner again.
391    pub fn evaluate_family_preconditioned(
392        &self,
393        matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
394        repair_preconditioner: &ShiftedDiagonalPreconditioner,
395        cg_rel_tol: f64,
396        cg_max_iters: usize,
397    ) -> Option<RationalLogdetEval> {
398        let shifts: Vec<f64> = self.nodes.iter().map(|(t, _)| *t).collect();
399        let solve_family = |rhs: &Array1<f64>| {
400            solve_shift_family(
401                matvec,
402                repair_preconditioner,
403                &shifts,
404                rhs,
405                cg_rel_tol,
406                cg_max_iters,
407            )
408        };
409        self.evaluate_with_family_solver(&solve_family)
410    }
411
412    /// Evaluate this frozen rational functional with a caller-owned solver for
413    /// the WHOLE shifted family at one right-hand side.
414    ///
415    /// `solve(rhs)` must return one converged solution per quadrature node, in
416    /// `self.nodes` order, and the number of operator applies it spent. This is
417    /// the seam a structured or device-resident family evaluator plugs into
418    /// without touching the criterion.
419    pub fn evaluate_with_family_solver(
420        &self,
421        solve: &(impl Fn(&Array1<f64>) -> Option<(Vec<Array1<f64>>, usize)> + Sync),
422    ) -> Option<RationalLogdetEval> {
423        // FROZEN top-subspace deflation basis Q (empty without a DeflationSpec).
424        let basis: &[Array1<f64>] = self
425            .deflation
426            .as_ref()
427            .map(|d| d.basis.as_slice())
428            .unwrap_or(&[]);
429        let probes_proj = self.projected_probes(basis);
430        let (shifted, applies_probe) =
431            solve_family_block(solve, self.nodes.len(), &probes_proj)?;
432        let (deflation_solves, applies_basis) = if basis.is_empty() {
433            (Vec::new(), 0)
434        } else {
435            solve_family_block(solve, self.nodes.len(), basis)?
436        };
437        self.assemble_eval(
438            probes_proj,
439            basis,
440            shifted,
441            deflation_solves,
442            applies_probe + applies_basis,
443        )
444    }
445
446    /// The PER-SHIFT baseline: one preconditioned CG per quadrature node, walked
447    /// from the largest shift down with warm starts.
448    ///
449    /// This is what the evidence lane ran before #2576's measurement, and it is
450    /// retained as the measurement's control arm — [`super::reduced_schur_logdet_shift_ladder_profile`]
451    /// takes its per-node breakdown, and the family evaluator is required to
452    /// agree with it on the value. It is not the production route: rebuilding a
453    /// Krylov space per node pays `node_count` times for a subspace that does not
454    /// depend on the shift at all (see [`Self::evaluate`]).
455    ///
456    /// The plan is the STATISTICAL functional — probes, quadrature nodes,
457    /// deflation basis — and the shifted inverse is the NUMERICAL means of
458    /// evaluating it. A preconditioner changes only the second, so the value
459    /// this returns is the same function of the operator that
460    /// [`Self::evaluate`] returns, converged to the same certified residual,
461    /// and its `Self::directional_derivative` is still that value's exact
462    /// gradient.
463    pub fn evaluate_preconditioned(
464        &self,
465        matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
466        preconditioner: &ShiftedDiagonalPreconditioner,
467        cg_rel_tol: f64,
468        cg_max_iters: usize,
469    ) -> Option<RationalLogdetEval> {
470        let solve = |shift: f64, rhs: &Array1<f64>, warm: &Array1<f64>| {
471            shifted_pcg(
472                matvec,
473                preconditioner,
474                shift,
475                rhs,
476                warm,
477                cg_rel_tol,
478                cg_max_iters,
479            )
480        };
481        self.evaluate_with_shifted_solver(&solve)
482    }
483
484    /// Evaluate this frozen rational functional with a caller-owned shifted
485    /// linear solver.
486    ///
487    /// The solver must return the converged solution of
488    /// `(S + shift·I)y = rhs` and an iteration count. `warm` is the solution
489    /// from the preceding, larger shift for the same right-hand side. Separating
490    /// the statistical functional (fixed probes, nodes, deflation basis, value
491    /// assembly, and derivative bundle) from the numerical inverse lets callers
492    /// use a structured preconditioner without changing the criterion. In
493    /// particular, an exact-observed-information operator can use its positive
494    /// majorizer only as a preconditioner; storage strategy can no longer require
495    /// a different log-determinant definition.
496    pub fn evaluate_with_shifted_solver(
497        &self,
498        solve: &(impl Fn(
499            f64,
500            &Array1<f64>,
501            &Array1<f64>,
502        ) -> Option<(Array1<f64>, usize)>
503                  + Sync),
504    ) -> Option<RationalLogdetEval> {
505        // Ladder: descending shift (warm starts carry per vector across shifts).
506        let mut order: Vec<usize> = (0..self.nodes.len()).collect();
507        order.sort_by(|&a, &b| {
508            self.nodes[b]
509                .0
510                .partial_cmp(&self.nodes[a].0)
511                .unwrap_or(std::cmp::Ordering::Equal)
512        });
513
514        // FROZEN top-subspace deflation basis Q (empty without a DeflationSpec).
515        // Built once at plan creation from the operator at the plan's rho; reused
516        // verbatim here so the surrogate is one fixed-Q function of rho.
517        let basis: &[Array1<f64>] = self
518            .deflation
519            .as_ref()
520            .map(|d| d.basis.as_slice())
521            .unwrap_or(&[]);
522
523        // Deflation-projected probes u_j = P v_j (raw probes without a basis).
524        let probes_proj = self.projected_probes(basis);
525
526        // Both solve families use the same injected solver and shift ordering.
527        let (shifted, iters_probe) =
528            solve_shift_ladder_with(solve, &self.nodes, &order, &probes_proj)?;
529        let (deflation_solves, iters_basis) = if basis.is_empty() {
530            (Vec::new(), 0)
531        } else {
532            solve_shift_ladder_with(solve, &self.nodes, &order, basis)?
533        };
534        self.assemble_eval(
535            probes_proj,
536            basis,
537            shifted,
538            deflation_solves,
539            iters_probe + iters_basis,
540        )
541    }
542
543    /// Deflation-projected probes `u_j = P v_j = v_j − Q(Qᵀ v_j)` (the raw probes
544    /// bit-for-bit when `basis` is empty: `‖u_j‖² = k`, no term1). Shared by
545    /// [`Self::evaluate`] and the wide-κ discriminator's exact-solve audit arm so
546    /// BOTH project against the identical frozen `Q` the term1 columns use — the
547    /// one place the "exact for any orthonormal Q" proof could silently break is a
548    /// `Q` that differs between the probe projector and term1, so they must draw
549    /// from the same `basis` slice.
550    fn projected_probes(&self, basis: &[Array1<f64>]) -> Vec<Array1<f64>> {
551        self.probes
552            .iter()
553            .map(|v| {
554                let mut u = v.clone();
555                for q in basis {
556                    let c = u.dot(q);
557                    u.scaled_add(-c, q);
558                }
559                u
560            })
561            .collect()
562    }
563
564    /// Assemble the surrogate value, error bar, and carried solves from the two
565    /// shifted-solve ladders — `shifted[ℓ][j]` for the projected probes and
566    /// `deflation_solves[ℓ][i]` for the basis columns, both indexed by node `ℓ`.
567    /// The ONLY solver-dependent inputs are those two ladders, so [`Self::evaluate`]
568    /// (CG) and any exact-solve audit that feeds the same ladders produce
569    /// byte-identical term1/term2/std_err bookkeeping — the property the wide-κ
570    /// discriminator's exact arm relies on to isolate solve error from a structural
571    /// split bias.
572    fn assemble_eval(
573        &self,
574        probes_proj: Vec<Array1<f64>>,
575        basis: &[Array1<f64>],
576        shifted: Vec<Vec<Array1<f64>>>,
577        deflation_solves: Vec<Vec<Array1<f64>>>,
578        total_iters: usize,
579    ) -> Option<RationalLogdetEval> {
580        let m = self.probes.len();
581        let k = self.dim as f64;
582        // term1 = tr(Qᵀ log(S/c) Q) = Σ_i Σ_ℓ w_ℓ (‖q_i‖²/(c+t_ℓ) − q_iᵀ y_{q_iℓ}),
583        // ‖q_i‖² = 1. Deterministic (no probe variance).
584        let mut term1 = 0.0_f64;
585        for (ell, &(t, w)) in self.nodes.iter().enumerate() {
586            let reference = 1.0 / (self.center + t);
587            for (i, q) in basis.iter().enumerate() {
588                term1 += w * (reference - q.dot(&deflation_solves[ell][i]));
589            }
590        }
591
592        // term2 per-probe: e_j = Σ_ℓ w_ℓ (‖u_j‖²/(c+t_ℓ) − u_jᵀ y_{u_jℓ}). The
593        // PER-VECTOR reference norm ‖u_j‖² makes the (k−r) count automatic and
594        // exact. The surrogate value is k·ln c + term1 + mean_j e_j; the
595        // Hutchinson error bar is the spread of the e_j (term1 is deterministic,
596        // so it carries no variance).
597        let u_norm_sq: Vec<f64> = probes_proj.iter().map(|u| u.dot(u)).collect();
598        let mut per_probe = vec![0.0_f64; m];
599        for (ell, &(t, w)) in self.nodes.iter().enumerate() {
600            let inv = 1.0 / (self.center + t);
601            for j in 0..m {
602                per_probe[j] += w * (u_norm_sq[j] * inv - probes_proj[j].dot(&shifted[ell][j]));
603            }
604        }
605        let term2 = per_probe.iter().sum::<f64>() / m as f64;
606        let estimate = k * self.log_center + term1 + term2;
607        let std_err = if m > 1 {
608            let var = per_probe
609                .iter()
610                .map(|e| (e - term2) * (e - term2))
611                .sum::<f64>()
612                / (m as f64 - 1.0);
613            (var / m as f64).sqrt()
614        } else {
615            0.0
616        };
617        if !(estimate.is_finite() && std_err.is_finite()) {
618            return None;
619        }
620        Some(RationalLogdetEval {
621            estimate,
622            std_err,
623            shifted_solves: shifted,
624            deflation_solves,
625            deflation_basis: basis.to_vec(),
626            cg_iterations: total_iters,
627        })
628    }
629
630    /// Collapse [`RationalLogdetEval`]'s complete shifted-solve ladder into a
631    /// lossless weighted low-rank derivative representation.
632    ///
633    /// This is deliberately derived from the same evaluation that produced the
634    /// value.  Re-solving only the raw probes at shift zero would instead encode
635    /// `tr(S^-1 D)`, which is generally NOT the derivative of this fixed-node
636    /// rational surrogate and would reopen the objective/gradient desynchrony
637    /// the surrogate exists to prevent.
638    pub fn into_directional_derivative_bundle(
639        &self,
640        eval: RationalLogdetEval,
641    ) -> Option<RationalLogdetDerivativeBundle> {
642        let metrics = RationalLogdetEvaluationMetrics {
643            cg_iterations: eval.cg_iterations,
644            node_count: self.nodes.len(),
645            deflation_rank: eval.deflation_basis.len(),
646        };
647        let expected_deflation_nodes =
648            usize::from(!eval.deflation_basis.is_empty()) * self.nodes.len();
649        if eval.shifted_solves.len() != self.nodes.len()
650            || eval.deflation_solves.len() != expected_deflation_nodes
651        {
652            return None;
653        }
654        let probe_count = self.probes.len();
655        if probe_count == 0
656            || eval
657                .shifted_solves
658                .iter()
659                .any(|solves| solves.len() != probe_count)
660            || eval
661                .deflation_solves
662                .iter()
663                .any(|solves| solves.len() != eval.deflation_basis.len())
664        {
665            return None;
666        }
667        let term_count = self.nodes.len().checked_mul(
668            probe_count.checked_add(eval.deflation_basis.len())?,
669        )?;
670        if term_count == 0 {
671            return None;
672        }
673        let mut vectors = Vec::with_capacity(term_count);
674        let rank = term_count as f64;
675        let probes = probe_count as f64;
676        let mut deflation_by_node = eval.deflation_solves;
677        if deflation_by_node.is_empty() {
678            deflation_by_node.resize_with(self.nodes.len(), Vec::new);
679        }
680        for ((mut probe_solves, mut deflation_solves), &(_, weight)) in eval
681            .shifted_solves
682            .into_iter()
683            .zip(deflation_by_node)
684            .zip(&self.nodes)
685        {
686            if !(weight.is_finite() && weight > 0.0) {
687                return None;
688            }
689            let probe_scale = (rank * weight / probes).sqrt();
690            let deflation_scale = (rank * weight).sqrt();
691            if !(probe_scale.is_finite() && deflation_scale.is_finite()) {
692                return None;
693            }
694            for mut solve in probe_solves.drain(..) {
695                if solve.len() != self.dim {
696                    return None;
697                }
698                solve *= probe_scale;
699                vectors.push(solve);
700            }
701            for mut solve in deflation_solves.drain(..) {
702                if solve.len() != self.dim {
703                    return None;
704                }
705                solve *= deflation_scale;
706                vectors.push(solve);
707            }
708        }
709        Some(RationalLogdetDerivativeBundle { vectors, metrics })
710    }
711}
712
713/// Plain CG on `(A + t·I) y = b` through the un-shifted `matvec(v) = A·v`,
714/// warm-started from `y0`. Returns the solution and the iteration count only
715/// after the TRUE residual certifies either the stricter RHS-relative residual
716/// or the requested normwise backward error; exhaustion and non-finite/SPD
717/// breakdowns return `None`. The matrix-free backward-error denominator uses
718/// the largest Rayleigh quotient observed over the CG directions. For SPD `A`,
719/// this is a lower bound on `||A||₂`, hence
720///
721/// `||r||₂ / (lambda_observed ||y||₂ + ||b||₂)`
722///
723/// is a conservative upper bound on the usual normwise backward error. This
724/// closes the f64 roundoff gap where `||r||/||b||` cannot reach a requested
725/// tolerance even though the computed solution already solves a nearby system
726/// to that tolerance. When the recursively updated CG residual reaches the
727/// RHS-relative threshold before the true residual does, the recurrence is
728/// restarted from the true residual (reliable residual replacement) rather
729/// than rejecting a recoverable solve. Returning an uncertified
730/// iteration-capped last iterate would make the value consume an uncontrolled
731/// approximate inverse while the derivative formula differentiates an exact
732/// inverse, re-opening the #2080 objective/gradient desynchronisation this
733/// module exists to prevent.
734/// The no-op preconditioner. `shifted_pcg` under it is bit-for-bit the plain CG
735/// this module ran before #2576 — `z == r`, so `rᵀz` is `rᵀr` and the direction
736/// update is `p ← r + βp` exactly — which is why there is ONE shifted-solve
737/// implementation and one convergence certificate here rather than two that
738/// could drift apart.
739pub(crate) const IDENTITY_SHIFT_PRECONDITIONER: ShiftedDiagonalPreconditioner =
740    ShiftedDiagonalPreconditioner { diagonal: None };
741
742/// Diagonal preconditioner for the SHIFTED systems `(A + tI)` a rational
743/// log-determinant plan solves.
744///
745/// The caller supplies the *unshifted* operator's diagonal; the shift is added
746/// per solve, exactly as it is added to the operator. That single fact is what
747/// makes one diagonal serve the whole shift ladder: `diag(A + tI) = diag(A) + t`.
748///
749/// `diagonal: None` is the identity, i.e. the unpreconditioned iteration. On the
750/// overcomplete arrow border the supplied diagonal is the shared block's own —
751/// the atom FIRING-COUNT distribution, orders of magnitude wide — and it is
752/// exactly the spread that stalls an unpreconditioned CG (#2576).
753#[derive(Debug, Clone)]
754pub struct ShiftedDiagonalPreconditioner {
755    diagonal: Option<Array1<f64>>,
756}
757
758impl ShiftedDiagonalPreconditioner {
759    /// Build from the unshifted operator's diagonal. A non-finite or
760    /// non-positive entry means there is no usable scale, and the identity is
761    /// returned rather than a fabricated one: the iteration is then exactly the
762    /// unpreconditioned one, which is still correct for SPD `A`.
763    pub fn from_operator_diagonal(diagonal: &Array1<f64>) -> Self {
764        if diagonal
765            .iter()
766            .any(|value| !(value.is_finite() && *value > 0.0))
767        {
768            return Self { diagonal: None };
769        }
770        Self {
771            diagonal: Some(diagonal.clone()),
772        }
773    }
774
775    pub fn identity() -> Self {
776        Self { diagonal: None }
777    }
778
779    fn apply(&self, residual: &Array1<f64>, shift: f64) -> Array1<f64> {
780        match &self.diagonal {
781            Some(diagonal) => {
782                let mut out = residual.clone();
783                for (value, scale) in out.iter_mut().zip(diagonal.iter()) {
784                    let denominator = scale + shift;
785                    // `from_operator_diagonal` admits only strictly positive
786                    // finite entries and the plan's shifts are non-negative, so
787                    // this can fail only on overflow. Leaving that entry
788                    // unscaled keeps `M` symmetric positive definite (unit on
789                    // that coordinate) instead of emitting an infinity that
790                    // would break the recurrence.
791                    if denominator > 0.0 && denominator.is_finite() {
792                        *value /= denominator;
793                    }
794                }
795                out
796            }
797            None => residual.clone(),
798        }
799    }
800}
801
802/// Preconditioned shifted CG. Identical certificate, restart policy and
803/// refusal contract as [`shifted_cg`] — the preconditioner steers the search
804/// directions and nothing else, so the returned iterate still satisfies the
805/// SAME true-residual / backward-error test before it is accepted.
806fn shifted_pcg(
807    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
808    preconditioner: &ShiftedDiagonalPreconditioner,
809    t: f64,
810    b: &Array1<f64>,
811    y0: &Array1<f64>,
812    rel_tol: f64,
813    max_iters: usize,
814) -> Option<(Array1<f64>, usize)> {
815    // The production solve keeps no steps, so it passes no recorder — and it
816    // runs the SAME non-generic core the recording entry point runs, not a
817    // second monomorphization of it. There is exactly ONE shifted-solve
818    // implementation and one convergence certificate; the trace cannot describe
819    // a different iteration from the one that produced the value.
820    shifted_pcg_core(
821        matvec,
822        preconditioner,
823        t,
824        b,
825        y0,
826        rel_tol,
827        max_iters,
828        None,
829    )
830}
831
832/// One recorded step of the shifted-PCG recurrence: the residual entering the
833/// step and the two CG scalars that step produced.
834///
835/// These three numbers are everything the #2576 diagnosis needs. `residual_norm`
836/// is the convergence curve; `(alpha, beta)` are the CG coefficients, from which
837/// the Lanczos tridiagonal of the PRECONDITIONED shifted operator — and hence
838/// its Ritz spectrum and condition estimate — follow exactly, at no extra
839/// matvec.
840#[derive(Clone, Copy, Debug)]
841pub struct ShiftedPcgStep {
842    /// `‖r_j‖` (recursive CG residual) entering the step.
843    pub residual_norm: f64,
844    /// CG step length `α_j = rᵀz / pᵀAp`.
845    pub alpha: f64,
846    /// CG direction weight `β_j = r_{j+1}ᵀz_{j+1} / rᵀz`.
847    pub beta: f64,
848    /// True when the reliable-residual replacement restarted the recurrence
849    /// immediately before this step. The CG↔Lanczos identity holds only within a
850    /// restart-free run, so a trace reads its tridiagonal off the longest such
851    /// segment.
852    pub restarted: bool,
853}
854
855/// The convergence history of one shifted-PCG solve.
856///
857/// #2576's headline evidence was that loosening the CG tolerance by four orders
858/// of magnitude did not shorten the solve, which is consistent with two opposite
859/// situations — a solve stagnating at its cap, and a solve converging so fast
860/// that the last four decades cost a handful of iterations. Those need opposite
861/// repairs, and nothing in this crate could tell them apart, because the solve
862/// reported only a total iteration count. This records the curve itself.
863#[derive(Clone, Debug, Default)]
864pub struct ShiftedPcgTrace {
865    /// The shift `t` this solve ran at.
866    pub shift: f64,
867    /// `‖b‖`, the denominator of every relative residual below.
868    pub rhs_norm: f64,
869    /// The requested relative-residual target.
870    pub rel_tol: f64,
871    /// One entry per step actually taken.
872    pub steps: Vec<ShiftedPcgStep>,
873    /// True when the solve returned a certified iterate (`shifted_pcg` returning
874    /// `Some`); false when it refused (cap exhaustion or breakdown).
875    pub certified: bool,
876}
877
878impl ShiftedPcgTrace {
879    /// Iterations taken.
880    #[must_use]
881    pub fn iterations(&self) -> usize {
882        self.steps.len()
883    }
884
885    /// The relative residual curve `‖r_j‖/‖b‖`, one point per step.
886    #[must_use]
887    pub fn relative_residuals(&self) -> Vec<f64> {
888        let scale = 1.0 / self.rhs_norm.max(f64::MIN_POSITIVE);
889        self.steps
890            .iter()
891            .map(|step| step.residual_norm * scale)
892            .collect()
893    }
894
895    /// The half-open step range `[start, end)` of the longest restart-free run,
896    /// which is the only segment over which the CG coefficients are the Lanczos
897    /// coefficients of one Krylov space.
898    fn unrestarted_segment(&self) -> (usize, usize) {
899        let mut best = (0usize, 0usize);
900        let mut start = 0usize;
901        for (index, step) in self.steps.iter().enumerate() {
902            if step.restarted && index > start {
903                if index - start > best.1 - best.0 {
904                    best = (start, index);
905                }
906                start = index;
907            }
908        }
909        if self.steps.len() - start > best.1 - best.0 {
910            best = (start, self.steps.len());
911        }
912        best
913    }
914
915    /// The symmetric tridiagonal `T_m` of the preconditioned shifted operator
916    /// `M⁻¹(S + tI)` restricted to the Krylov space the solve built, as
917    /// `(diagonal, off-diagonal)`.
918    ///
919    /// CG is Lanczos in disguise: with the step scalars `α_j`, `β_j` of a
920    /// restart-free run,
921    ///
922    /// ```text
923    /// T[j,j]   = 1/α_j + β_{j-1}/α_{j-1}   (β_{-1} = 0)
924    /// T[j,j+1] = √β_j / α_j
925    /// ```
926    ///
927    /// so the spectrum estimate below costs no matvec at all — it is read off
928    /// numbers the solve already produced. The eigenvalues of `T_m` are the Ritz
929    /// values, and they bracket the part of the spectrum CG has resolved.
930    #[must_use]
931    pub fn lanczos_tridiagonal(&self) -> (Vec<f64>, Vec<f64>) {
932        let (start, end) = self.unrestarted_segment();
933        let steps = &self.steps[start..end];
934        let mut diagonal = Vec::with_capacity(steps.len());
935        let mut off_diagonal = Vec::with_capacity(steps.len().saturating_sub(1));
936        for (j, step) in steps.iter().enumerate() {
937            if !(step.alpha.is_finite() && step.alpha > 0.0) {
938                break;
939            }
940            let previous = if j == 0 {
941                0.0
942            } else {
943                let earlier = &steps[j - 1];
944                if !(earlier.alpha.is_finite() && earlier.alpha > 0.0 && earlier.beta >= 0.0) {
945                    break;
946                }
947                earlier.beta / earlier.alpha
948            };
949            diagonal.push(1.0 / step.alpha + previous);
950            if j + 1 < steps.len() && step.beta >= 0.0 {
951                off_diagonal.push(step.beta.sqrt() / step.alpha);
952            }
953        }
954        off_diagonal.truncate(diagonal.len().saturating_sub(1));
955        (diagonal, off_diagonal)
956    }
957
958    /// The `count` Ritz values spread evenly through the resolved spectrum,
959    /// ascending, plus both extremes. Empty when the solve took no usable step.
960    #[must_use]
961    pub fn ritz_values(&self, count: usize) -> Vec<f64> {
962        let (diagonal, off_diagonal) = self.lanczos_tridiagonal();
963        let m = diagonal.len();
964        if m == 0 {
965            return Vec::new();
966        }
967        let wanted = count.max(2).min(m);
968        (0..wanted)
969            .map(|slot| {
970                let index = if wanted == 1 {
971                    0
972                } else {
973                    (slot * (m - 1)) / (wanted - 1)
974                };
975                tridiagonal_eigenvalue(&diagonal, &off_diagonal, index)
976            })
977            .collect()
978    }
979
980    /// `θ_max/θ_min` over the Ritz values — the condition number of the
981    /// preconditioned shifted operator RESTRICTED to the Krylov space the solve
982    /// explored. This is a lower bound on `κ` of the full operator, and it is the
983    /// conditioning that actually governs this solve's convergence rate, since CG
984    /// only ever sees the spectrum its own Krylov space resolves.
985    #[must_use]
986    pub fn krylov_condition_estimate(&self) -> Option<f64> {
987        let (diagonal, off_diagonal) = self.lanczos_tridiagonal();
988        let m = diagonal.len();
989        if m == 0 {
990            return None;
991        }
992        let low = tridiagonal_eigenvalue(&diagonal, &off_diagonal, 0);
993        let high = tridiagonal_eigenvalue(&diagonal, &off_diagonal, m - 1);
994        (low.is_finite() && low > 0.0 && high.is_finite()).then(|| high / low)
995    }
996
997    /// Iterations the standard CG bound needs to cut the ENERGY norm of the
998    /// error by `rel_tol` at the observed Krylov conditioning:
999    ///
1000    /// ```text
1001    /// ‖e_j‖_A ≤ 2·((√κ−1)/(√κ+1))^j·‖e_0‖_A   ⟹   j ≥ ½·√κ·ln(2/rel_tol)
1002    /// ```
1003    ///
1004    /// The solve's own stopping test is on the relative RESIDUAL, whose bound
1005    /// carries an extra `√κ` inside the logarithm, so this is not an upper bound
1006    /// on that quantity in general — it is the operator's own conditioning
1007    /// expressed in iterations, which is what an acceptance should be denominated
1008    /// in rather than a hard-coded count. What makes it a gate is that the
1009    /// per-shift ladder MISSES it by a wide margin and a one-Krylov-space
1010    /// evaluator meets it; both directions are measured, not assumed.
1011    #[must_use]
1012    pub fn conditioning_iteration_bound(&self) -> Option<f64> {
1013        let kappa = self.krylov_condition_estimate()?;
1014        (kappa >= 1.0).then(|| 0.5 * kappa.sqrt() * (2.0 / self.rel_tol).ln())
1015    }
1016}
1017
1018/// Number of eigenvalues of the symmetric tridiagonal `(diagonal, off_diagonal)`
1019/// strictly below `x`, by the Sturm/LDLᵀ sign count. Exact in exact arithmetic
1020/// and monotone in `x` in floating point, which is what makes the bisection
1021/// below terminate on the right eigenvalue.
1022fn tridiagonal_sturm_count(diagonal: &[f64], off_diagonal: &[f64], x: f64) -> usize {
1023    let mut count = 0usize;
1024    let mut pivot = diagonal[0] - x;
1025    if pivot < 0.0 {
1026        count += 1;
1027    }
1028    for index in 1..diagonal.len() {
1029        let e = off_diagonal[index - 1];
1030        // A zero pivot splits the sequence; the standard remedy is to replace it
1031        // by a quantity of the same sign and negligible magnitude, which leaves
1032        // the count correct and the recurrence finite.
1033        if pivot == 0.0 {
1034            pivot = -f64::EPSILON * (e.abs() + diagonal[index].abs() + 1.0);
1035        }
1036        pivot = diagonal[index] - x - e * e / pivot;
1037        if pivot < 0.0 {
1038            count += 1;
1039        }
1040    }
1041    count
1042}
1043
1044/// The `index`-th smallest eigenvalue of a symmetric tridiagonal, by bisection
1045/// on the Sturm count inside the Gershgorin bracket. `O(m)` per bisection step
1046/// and no dense eigensolver, so a trace thousands of steps long is still cheap
1047/// to interrogate.
1048fn tridiagonal_eigenvalue(diagonal: &[f64], off_diagonal: &[f64], index: usize) -> f64 {
1049    let m = diagonal.len();
1050    if m == 0 {
1051        return f64::NAN;
1052    }
1053    let mut lo = f64::INFINITY;
1054    let mut hi = f64::NEG_INFINITY;
1055    for (row, &d) in diagonal.iter().enumerate() {
1056        let radius = off_diagonal.get(row).copied().unwrap_or(0.0).abs()
1057            + row
1058                .checked_sub(1)
1059                .and_then(|prev| off_diagonal.get(prev).copied())
1060                .unwrap_or(0.0)
1061                .abs();
1062        lo = lo.min(d - radius);
1063        hi = hi.max(d + radius);
1064    }
1065    if !(lo.is_finite() && hi.is_finite()) {
1066        return f64::NAN;
1067    }
1068    // Widen by one ulp-scale so the endpoints are strictly outside the spectrum.
1069    let pad = f64::EPSILON * (lo.abs() + hi.abs()).max(1.0);
1070    let (mut lo, mut hi) = (lo - pad, hi + pad);
1071    // Bisection to the floating-point resolution of the bracket: each step halves
1072    // the interval, so this terminates in at most the exponent range of f64.
1073    for _ in 0..200 {
1074        let mid = 0.5 * (lo + hi);
1075        if !(mid > lo && mid < hi) {
1076            break;
1077        }
1078        if tridiagonal_sturm_count(diagonal, off_diagonal, mid) > index {
1079            hi = mid;
1080        } else {
1081            lo = mid;
1082        }
1083    }
1084    0.5 * (lo + hi)
1085}
1086
1087/// `shifted_pcg` with the recurrence recorded. The two share one body, so the
1088/// trace is of the production iteration, not of a reimplementation of it.
1089pub(crate) fn shifted_pcg_traced(
1090    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
1091    preconditioner: &ShiftedDiagonalPreconditioner,
1092    t: f64,
1093    b: &Array1<f64>,
1094    y0: &Array1<f64>,
1095    rel_tol: f64,
1096    max_iters: usize,
1097) -> (Option<(Array1<f64>, usize)>, ShiftedPcgTrace) {
1098    let mut trace = ShiftedPcgTrace {
1099        shift: t,
1100        rhs_norm: b.dot(b).sqrt(),
1101        rel_tol,
1102        steps: Vec::new(),
1103        certified: false,
1104    };
1105    let outcome = {
1106        let steps = &mut trace.steps;
1107        let mut record = |step: ShiftedPcgStep| steps.push(step);
1108        let record: Option<&mut dyn FnMut(ShiftedPcgStep)> = Some(&mut record);
1109        shifted_pcg_core(
1110            matvec,
1111            preconditioner,
1112            t,
1113            b,
1114            y0,
1115            rel_tol,
1116            max_iters,
1117            record,
1118        )
1119    };
1120    trace.certified = outcome.is_some();
1121    (outcome, trace)
1122}
1123
1124fn shifted_pcg_core(
1125    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
1126    preconditioner: &ShiftedDiagonalPreconditioner,
1127    t: f64,
1128    b: &Array1<f64>,
1129    y0: &Array1<f64>,
1130    rel_tol: f64,
1131    max_iters: usize,
1132    mut record: Option<&mut dyn FnMut(ShiftedPcgStep)>,
1133) -> Option<(Array1<f64>, usize)> {
1134    if !(rel_tol.is_finite() && rel_tol > 0.0) {
1135        return None;
1136    }
1137    let apply = |v: ArrayView1<f64>| -> Array1<f64> {
1138        let mut out = matvec(v);
1139        out.scaled_add(t, &v.to_owned());
1140        out
1141    };
1142    let mut y = y0.clone();
1143    let mut r = b - &apply(y.view());
1144    let b_norm = b.dot(b).sqrt().max(f64::MIN_POSITIVE);
1145    let mut z = preconditioner.apply(&r, t);
1146    let mut p = z.clone();
1147    // `rs` is the PRECONDITIONED inner product `rᵀz` that drives the recurrence;
1148    // `residual_norm_sq` is the plain `rᵀr` every convergence test below reads.
1149    // Under the identity preconditioner they coincide, so this is the same
1150    // iteration `shifted_cg` always ran.
1151    let mut rs = r.dot(&z);
1152    let mut residual_norm_sq = r.dot(&r);
1153    if !(rs.is_finite() && residual_norm_sq.is_finite()) {
1154        return None;
1155    }
1156    let tol = rel_tol * b_norm;
1157    let mut iters = 0usize;
1158    let mut observed_operator_norm = 0.0_f64;
1159    let mut restarted = false;
1160    loop {
1161        if residual_norm_sq.sqrt() <= tol {
1162            // Recursive CG residuals lose their equality to `b - A y` through
1163            // roundoff, especially on the smallest shifts.  A recursive
1164            // convergence report is therefore only a prompt to inspect the
1165            // actual residual.  If it has not converged, restart the Krylov
1166            // recurrence from that exact residual and spend the remaining
1167            // caller-provided iteration budget.  The former terminal check
1168            // returned `None` immediately here, even when one reliable update
1169            // was enough to satisfy the requested contract.
1170            let true_residual = b - &apply(y.view());
1171            let true_rs = true_residual.dot(&true_residual);
1172            if !true_rs.is_finite() {
1173                return None;
1174            }
1175            let true_residual_norm = true_rs.sqrt();
1176            let y_norm = y.dot(&y).sqrt();
1177            if !y_norm.is_finite() {
1178                return None;
1179            }
1180            // Evaluate the backward-error ratio in the log domain. The scale
1181            // `lambda_observed * ||y|| + ||b||` can overflow even when every
1182            // operand and the certified ratio are representable.
1183            let backward_error_certified =
1184                if observed_operator_norm > 0.0 && y_norm > 0.0 {
1185                    let log_operator_solution = observed_operator_norm.ln() + y_norm.ln();
1186                    let log_rhs = b_norm.ln();
1187                    let log_scale = log_operator_solution.max(log_rhs);
1188                    let log_denominator = log_scale
1189                        + ((log_operator_solution - log_scale).exp()
1190                            + (log_rhs - log_scale).exp())
1191                        .ln();
1192                    true_residual_norm.ln() - log_denominator <= rel_tol.ln()
1193                } else {
1194                    false
1195                };
1196            if true_residual_norm <= tol || backward_error_certified {
1197                return Some((y, iters));
1198            }
1199            if iters >= max_iters {
1200                return None;
1201            }
1202            // The restart's `true_rs` is not stored: control falls straight
1203            // into the matvec below, which recomputes `residual_norm_sq` from
1204            // the updated residual before the next convergence test reads it.
1205            r = true_residual;
1206            z = preconditioner.apply(&r, t);
1207            rs = r.dot(&z);
1208            if !rs.is_finite() {
1209                return None;
1210            }
1211            p = z.clone();
1212            restarted = true;
1213        }
1214        if iters >= max_iters {
1215            return None;
1216        }
1217        let ap = apply(p.view());
1218        let denom = p.dot(&ap);
1219        if !(denom.is_finite() && denom > 0.0) {
1220            return None;
1221        }
1222        let p_norm_sq = p.dot(&p);
1223        if !(p_norm_sq.is_finite() && p_norm_sq > 0.0) {
1224            return None;
1225        }
1226        let rayleigh = denom / p_norm_sq;
1227        if rayleigh.is_finite() {
1228            observed_operator_norm = observed_operator_norm.max(rayleigh);
1229        }
1230        // `rs = rᵀM⁻¹r` is zero only when `r` is, and a zero residual reaches
1231        // the certified-exit branch above (`tol > 0` always). Reaching here with
1232        // `rs == 0` means round-off destroyed the SPD-by-construction
1233        // preconditioned inner product; the update would divide by zero.
1234        if rs == 0.0 {
1235            return None;
1236        }
1237        let alpha = rs / denom;
1238        y.scaled_add(alpha, &p);
1239        r.scaled_add(-alpha, &ap);
1240        let residual_norm_before = residual_norm_sq.sqrt();
1241        residual_norm_sq = r.dot(&r);
1242        z = preconditioner.apply(&r, t);
1243        let rs_new = r.dot(&z);
1244        if !(rs_new.is_finite() && residual_norm_sq.is_finite()) {
1245            return None;
1246        }
1247        let beta = rs_new / rs;
1248        if let Some(record) = record.as_deref_mut() {
1249            record(ShiftedPcgStep {
1250                residual_norm: residual_norm_before,
1251                alpha,
1252                beta,
1253                restarted,
1254            });
1255        }
1256        restarted = false;
1257        p = &z + &(&p * beta);
1258        rs = rs_new;
1259        iters += 1;
1260    }
1261}
1262
1263/// Modified Gram-Schmidt orthonormalisation of a column block, DROPPING any
1264/// column whose residual norm collapses (linear dependence / rank deficiency).
1265/// The realised rank is `out.len()`, which may be below the input count.
1266fn orthonormalize(cols: &[Array1<f64>]) -> Vec<Array1<f64>> {
1267    let mut out: Vec<Array1<f64>> = Vec::with_capacity(cols.len());
1268    for col in cols {
1269        let mut v = col.clone();
1270        // TWO MGS passes ("twice is enough", Kahan/Parlett): block-power drives
1271        // the columns of S·Q toward the dominant eigenvector, so the input block
1272        // is ill-conditioned and a SINGLE pass leaves orthogonality error O(κ·ε).
1273        // Q enters the DETERMINISTIC term1 = tr(Qᵀ log(S/c) Q), where any
1274        // QᵀQ ≠ I directly biases the estimate (a slack basis would only widen
1275        // the Hutchinson bar, but a non-orthonormal one shifts the value). The
1276        // second pass restores orthogonality to O(ε). The collapse test uses the
1277        // FIRST-pass residual norm (relative to the pre-orthogonalisation norm) so
1278        // a genuinely dependent column is still dropped, not merely re-cleaned.
1279        let v0_norm = v.dot(&v).sqrt();
1280        for basis in &out {
1281            let proj = v.dot(basis);
1282            v.scaled_add(-proj, basis);
1283        }
1284        let norm_after_first = v.dot(&v).sqrt();
1285        for basis in &out {
1286            let proj = v.dot(basis);
1287            v.scaled_add(-proj, basis);
1288        }
1289        let norm = v.dot(&v).sqrt();
1290        // Numerical rank, not a tuned absolute knob: below √ε of the source
1291        // column's norm, orthogonal residuals carry no stable direction.
1292        let rank_tol = f64::EPSILON.sqrt() * v0_norm;
1293        let collapsed = !(v0_norm.is_finite() && v0_norm > 0.0)
1294            || !(norm_after_first.is_finite())
1295            || norm_after_first <= rank_tol
1296            || !(norm.is_finite())
1297            || norm <= rank_tol;
1298        if !collapsed {
1299            v.mapv_inplace(|x| x / norm);
1300            out.push(v);
1301        }
1302    }
1303    out
1304}
1305
1306/// Draw `ncols` length-`dim` Rademacher (±1) vectors by consuming ONE sequential
1307/// splitmix stream from `master` (LSB-first, 64 signs per word), the bit buffer
1308/// reset per column. Single home for the probe/start-block generation shared by
1309/// [`RationalLogdetPlan::build`], [`build_deflation_basis`], and
1310/// [`build_inverse_deflation_basis`]; consuming from one advancing `master`
1311/// (rather than a per-column `(seed + col)·γ` restart) is what removes the
1312/// cross-column / cross-seed stream aliasing documented in `build`.
1313fn rademacher_block(master: &mut u64, ncols: usize, dim: usize) -> Vec<Array1<f64>> {
1314    (0..ncols)
1315        .map(|_| {
1316            let mut v = Array1::<f64>::zeros(dim);
1317            let mut bits: u64 = 0;
1318            let mut remaining: u32 = 0;
1319            for value in v.iter_mut() {
1320                if remaining == 0 {
1321                    bits = splitmix64(master);
1322                    remaining = 64;
1323                }
1324                *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
1325                bits >>= 1;
1326                remaining -= 1;
1327            }
1328            v
1329        })
1330        .collect()
1331}
1332
1333/// Build the Hutch++ top-subspace basis `Q` (`≤ rank` orthonormal columns) by
1334/// block-power (subspace) iteration on the operator: a `seed`-deterministic
1335/// Rademacher start block, orthonormalised, then `iters` rounds of
1336/// `Q ← orthonormalise(S·Q)`. The result steers toward the top eigenspace so the
1337/// deflated Hutchinson variance is small; the log-det decomposition is EXACT for
1338/// any orthonormal `Q`, so a slack `Q` cannot bias the estimate (only widen the
1339/// error bar). Deterministic for a fixed `(matvec, dim, rank, iters, seed)`.
1340fn build_deflation_basis(
1341    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
1342    dim: usize,
1343    rank: usize,
1344    iters: usize,
1345    seed: u64,
1346) -> Vec<Array1<f64>> {
1347    let r = rank.min(dim);
1348    if r == 0 {
1349        return Vec::new();
1350    }
1351    // One sequential master stream for the whole start block — same
1352    // decorrelation as the probe generation in `RationalLogdetPlan::build`:
1353    // the former per-column start `seed + col·γ + const` (γ = the splitmix64
1354    // increment) made column c+1's word stream column c's shifted by one
1355    // word (sliding-window sharing). Harmless to the EXACTNESS of the
1356    // deflated split (any orthonormal Q is valid), but a correlated start
1357    // block weakens the subspace iteration's coverage of the top eigenspace
1358    // for no reason. Determinism per seed is kept.
1359    let mut master = splitmix64_hash(seed.wrapping_add(0xD1B5_4A32_D192_ED03));
1360    let mut cols = orthonormalize(&rademacher_block(&mut master, r, dim));
1361    for _ in 0..iters {
1362        if cols.is_empty() {
1363            break;
1364        }
1365        let applied: Vec<Array1<f64>> = cols.iter().map(|c| matvec(c.view())).collect();
1366        cols = orthonormalize(&applied);
1367    }
1368    cols
1369}
1370
1371/// Build the BOTTOM (smallest-λ) subspace basis by INVERSE subspace iteration:
1372/// the same block-power as [`build_deflation_basis`] but with the operator
1373/// replaced by `S⁻¹` (applied matrix-free by plain CG through `matvec`), so the
1374/// rounds `Q ← orthonormalise(S⁻¹·Q)` amplify the SMALLEST eigenvalues instead of
1375/// the largest. This is the second arm of the two-sided control variate
1376/// ([`RationalLogdetPlan::with_two_sided_deflation`]): the Hutchinson variance of
1377/// the surrogate rides on the off-diagonal Frobenius mass of `log(S/c)`, which a
1378/// wide spectrum loads SYMMETRICALLY onto both tails (`log(λ_max/c) = +½lnκ` and
1379/// `log(λ_min/c) = −½lnκ`), so peeling only the top leaves the entire bottom-tail
1380/// contribution in the bar. A polynomial filter `(μI − S)` cannot reach the
1381/// bottom on a dense log-uniform spectrum (the relative gap `(μ−λ_1)/(μ−λ_2) ≈ 1`
1382/// gives no separation); genuine bottom amplification needs `S⁻¹`, whence the CG
1383/// inverse iteration here.
1384///
1385/// The solves may use a loose requested tolerance — an approximate bottom `Q`
1386/// only relaxes variance reduction and cannot bias the exact split — but every
1387/// requested solve must still CONVERGE to that tolerance. Exhaustion propagates
1388/// as `None`; silently retaining an un-amplified start column would falsify the
1389/// requested two-sided variance contract. The whole build is a ONE-TIME frozen
1390/// cost per outer solve, never per evaluation.
1391fn build_inverse_deflation_basis(
1392    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
1393    preconditioner: &ShiftedDiagonalPreconditioner,
1394    dim: usize,
1395    rank: usize,
1396    iters: usize,
1397    seed: u64,
1398    cg_rel_tol: f64,
1399    cg_max_iters: usize,
1400) -> Option<Vec<Array1<f64>>> {
1401    let r = rank.min(dim);
1402    if r == 0 {
1403        return Some(Vec::new());
1404    }
1405    // Distinct master stream from the top-basis start (a different additive
1406    // offset into splitmix) so the top and bottom start blocks are not aliased.
1407    let mut master = splitmix64_hash(seed.wrapping_add(0x2545_F491_4F6C_DD1D));
1408    let mut cols = orthonormalize(&rademacher_block(&mut master, r, dim));
1409    let zero = Array1::<f64>::zeros(dim);
1410    for _ in 0..iters {
1411        if cols.is_empty() {
1412            break;
1413        }
1414        // Inverse iteration step: apply S⁻¹ column-wise via plain CG (shift 0 on
1415        // the SPD operator). Every solve must meet the caller's (possibly loose)
1416        // tolerance; an exhausted solve invalidates the requested bottom peel.
1417        let applied: Option<Vec<Array1<f64>>> = cols
1418            .iter()
1419            .map(|c| {
1420                shifted_pcg(
1421                    matvec,
1422                    preconditioner,
1423                    0.0,
1424                    c,
1425                    &zero,
1426                    cg_rel_tol,
1427                    cg_max_iters,
1428                )
1429                .map(|(y, _)| y)
1430            })
1431            .collect();
1432        cols = orthonormalize(&applied?);
1433    }
1434    Some(cols)
1435}
1436
1437/// Solve the WHOLE shifted family `(A + t_ℓ I) y_ℓ = b` from ONE Krylov space.
1438///
1439/// # Why this is not `L` independent solves
1440///
1441/// A shift adds a multiple of the identity, which changes no polynomial's span:
1442///
1443/// ```text
1444/// K_m(A + tI, b) = span{b, (A+tI)b, …, (A+tI)^{m-1}b} = span{b, Ab, …, A^{m-1}b}
1445/// ```
1446///
1447/// — the SAME subspace for every `t`. So the quadrature ladder's `L` systems are
1448/// `L` different projections onto ONE Krylov space, and a single seed run's CG
1449/// coefficients determine every shifted system's through a scalar recurrence.
1450/// With `s = t − σ` the shift relative to the seed `σ`,
1451///
1452/// ```text
1453/// ζ^t_{j+1} = ζ^t_j·ζ^t_{j-1}·α_{j-1}
1454///           / ( α_j·β_{j-1}·(ζ^t_{j-1} − ζ^t_j) + ζ^t_{j-1}·α_{j-1}·(1 + α_j·s) )
1455/// α^t_j = α_j·ζ^t_{j+1}/ζ^t_j,      β^t_j = β_j·(ζ^t_{j+1}/ζ^t_j)²
1456/// p^t_{j+1} = ζ^t_{j+1}·r_{j+1} + β^t_j·p^t_j,   y^t_{j+1} = y^t_j + α^t_j·p^t_j
1457/// ```
1458///
1459/// so each shifted system costs two length-`k` axpys per step and NO matvec of
1460/// its own. The residuals stay collinear, `r^t_j = ζ^t_j·r_j`, which is why the
1461/// seed must be the SMALLEST shift: `|ζ^t_j| ≤ 1` for `t ≥ σ`, so the seed is the
1462/// last system to converge and its stopping test covers the family.
1463///
1464/// (Jegerlehner, "Krylov space solvers for shifted linear systems", 1996;
1465/// Frommer, Glässner, "Restarted GMRES for shifted linear systems", 1998.)
1466///
1467/// # The certificate is unchanged
1468///
1469/// Collinearity is an exact-arithmetic identity; in floating point a shifted
1470/// iterate can carry a residual gap. Every returned solution is therefore
1471/// certified against its own TRUE residual, and any that misses is finished by
1472/// [`shifted_pcg`] warm-started from the multi-shift iterate — the same single
1473/// implementation, the same refusal contract. A family whose seed exhausts its
1474/// budget degrades to exactly the per-shift solves this replaces, never to an
1475/// uncertified iterate.
1476///
1477/// The returned count is MATVECS, not iterations: the seed's steps plus one
1478/// certification apply per shift plus any repair steps. That is the quantity a
1479/// before/after comparison must use, since the whole point is that iterations of
1480/// the shifted systems no longer cost applies. (It is a conservative count
1481/// against the per-shift ladder, which reports only its loop iterations and not
1482/// the two residual applies each of its `node_count` solves also pays.)
1483///
1484/// Memory: one extra length-`k` direction vector per shift for the duration of
1485/// one right-hand side. The evaluation already retains
1486/// `node_count × (probes + deflation_rank)` solution vectors, so this adds a
1487/// `1/(probes + deflation_rank)` fraction to the peak, and it is released before
1488/// the next right-hand side.
1489fn solve_shift_family(
1490    matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
1491    repair_preconditioner: &ShiftedDiagonalPreconditioner,
1492    shifts: &[f64],
1493    b: &Array1<f64>,
1494    rel_tol: f64,
1495    max_iters: usize,
1496) -> Option<(Vec<Array1<f64>>, usize)> {
1497    if shifts.is_empty() || !(rel_tol.is_finite() && rel_tol > 0.0) {
1498        return None;
1499    }
1500    let dim = b.len();
1501    let seed_index = shifts
1502        .iter()
1503        .enumerate()
1504        .filter(|(_, t)| t.is_finite())
1505        .min_by(|(_, a), (_, c)| a.partial_cmp(c).unwrap_or(std::cmp::Ordering::Equal))
1506        .map(|(index, _)| index)?;
1507    let sigma = shifts[seed_index];
1508    if !sigma.is_finite() {
1509        return None;
1510    }
1511    let b_norm = b.dot(b).sqrt().max(f64::MIN_POSITIVE);
1512    let tol = rel_tol * b_norm;
1513
1514    // Seed recurrence on `(A + σI) y = b` from `y = 0`, so `r_0 = b` and every
1515    // shifted system starts from the same residual — the premise of the
1516    // collinearity above, and the reason this path takes no warm start.
1517    let apply_seed = |v: ArrayView1<f64>| -> Array1<f64> {
1518        let mut out = matvec(v);
1519        out.scaled_add(sigma, &v.to_owned());
1520        out
1521    };
1522    let mut r = b.clone();
1523    let mut rs = r.dot(&r);
1524    if !rs.is_finite() {
1525        return None;
1526    }
1527    let mut p = b.clone();
1528    let mut solutions: Vec<Array1<f64>> = vec![Array1::<f64>::zeros(dim); shifts.len()];
1529    let mut directions: Vec<Array1<f64>> = vec![b.clone(); shifts.len()];
1530    // `ζ^t_0 = ζ^t_{-1} = 1`, `α_{-1} = 1`, `β_{-1} = 0` — the initialisation that
1531    // makes the first step reproduce the closed form `ζ^t_1 = 1/(1 + α_0·s)`.
1532    let mut zeta_previous = vec![1.0_f64; shifts.len()];
1533    let mut zeta_current = vec![1.0_f64; shifts.len()];
1534    let mut frozen = vec![false; shifts.len()];
1535    let mut alpha_previous = 1.0_f64;
1536    let mut beta_previous = 0.0_f64;
1537    let mut matvecs = 0usize;
1538
1539    // The seed's budget: the caller's cap, but never more Krylov dimensions than
1540    // the space has. A restart-free CG recurrence has explored all of `K(A, b)`
1541    // after `dim` steps, so anything past that is roundoff rather than progress —
1542    // and a recurrence that restarted to chase it would break the collinearity
1543    // every shifted iterate above is built on. Overshooting the seed is bounded
1544    // this way; correctness never depends on it, because the certification below
1545    // finishes whatever the seed left short.
1546    let seed_budget = max_iters.min(dim.max(1));
1547    while rs.sqrt() > tol && matvecs < seed_budget {
1548        let ap = apply_seed(p.view());
1549        matvecs += 1;
1550        let denom = p.dot(&ap);
1551        if !(denom.is_finite() && denom > 0.0) {
1552            // `pᵀ(A + σI)p ≤ 0` is not ill-conditioning: the seed operator is
1553            // NOT positive definite at the smallest quadrature shift, so the
1554            // conjugate-gradient recurrence has no descent direction and the
1555            // whole rational plan is being built on a bracket the operator does
1556            // not satisfy. That is a different repair from a solve that merely
1557            // ran out of accuracy, and the caller can only report "no finite
1558            // solution" for both — so say which one here (gam#2731).
1559            log::warn!(
1560                "[rational-logdet] shifted-CG seed breakdown at iteration {matvecs}/{seed_budget}:                  pᵀ(A+σI)p = {denom:.6e} is not positive at seed shift σ = {sigma:.6e}                  (dim {dim}, ‖b‖ = {b_norm:.6e}, ‖r‖ = {:.6e}). The seed operator is not                  positive definite on this bracket; the spectral bracket's LOWER end is assumed                  as a fixed fraction of the estimated λ_max, not measured, so a spectrum that                  reaches below it — or below zero — lands here.",
1561                rs.sqrt()
1562            );
1563            return None;
1564        }
1565        let alpha = rs / denom;
1566        if !alpha.is_finite() {
1567            log::warn!(
1568                "[rational-logdet] shifted-CG seed breakdown at iteration {matvecs}/{seed_budget}:                  step length α = rᵀr/pᵀAp = {rs:.6e}/{denom:.6e} is not finite at seed shift                  σ = {sigma:.6e} (dim {dim})"
1569            );
1570            return None;
1571        }
1572        r.scaled_add(-alpha, &ap);
1573        let rs_new = r.dot(&r);
1574        if !rs_new.is_finite() {
1575            log::warn!(
1576                "[rational-logdet] shifted-CG seed breakdown at iteration {matvecs}/{seed_budget}:                  the residual left the finite range (‖r‖² = {rs_new:.6e}) after a step of                  α = {alpha:.6e} at seed shift σ = {sigma:.6e} (dim {dim})"
1577            );
1578            return None;
1579        }
1580        let beta = rs_new / rs;
1581        for (index, &shift) in shifts.iter().enumerate() {
1582            if frozen[index] {
1583                continue;
1584            }
1585            let relative = shift - sigma;
1586            let zeta_j = zeta_current[index];
1587            let zeta_back = zeta_previous[index];
1588            let denominator = alpha * beta_previous * (zeta_back - zeta_j)
1589                + zeta_back * alpha_previous * (1.0 + alpha * relative);
1590            let zeta_next = zeta_j * zeta_back * alpha_previous / denominator;
1591            // A collapsed or non-finite ζ means this shift's residual has fallen
1592            // below what f64 can represent relative to the seed's, i.e. it is
1593            // converged to the arithmetic floor. Freezing it keeps the iterate it
1594            // has; the certification below decides whether that is good enough.
1595            if !(zeta_next.is_finite() && denominator.is_finite() && denominator != 0.0)
1596                || zeta_next == 0.0
1597            {
1598                frozen[index] = true;
1599                continue;
1600            }
1601            let ratio = zeta_next / zeta_j;
1602            let alpha_shifted = alpha * ratio;
1603            let beta_shifted = beta * ratio * ratio;
1604            if !(alpha_shifted.is_finite() && beta_shifted.is_finite()) {
1605                frozen[index] = true;
1606                continue;
1607            }
1608            let direction = &directions[index];
1609            solutions[index].scaled_add(alpha_shifted, direction);
1610            let mut next = &r * zeta_next;
1611            next.scaled_add(beta_shifted, direction);
1612            directions[index] = next;
1613            zeta_previous[index] = zeta_j;
1614            zeta_current[index] = zeta_next;
1615        }
1616        p = &r + &(&p * beta);
1617        rs = rs_new;
1618        alpha_previous = alpha;
1619        beta_previous = beta;
1620    }
1621
1622    // Certification, per shift, against the TRUE residual — the contract
1623    // `shifted_pcg` enforces and this must not weaken. A miss is finished by that
1624    // same solve, warm-started from the multi-shift iterate, on the caller's full
1625    // per-solve budget: that is exactly the per-shift ladder this replaces, so a
1626    // family that helps nowhere degrades to the old cost plus one seed rather
1627    // than to a refusal.
1628    for (index, &shift) in shifts.iter().enumerate() {
1629        let mut residual = matvec(solutions[index].view());
1630        residual.scaled_add(shift, &solutions[index]);
1631        matvecs += 1;
1632        let residual = b - &residual;
1633        let residual_norm_sq = residual.dot(&residual);
1634        if residual_norm_sq.is_finite() && residual_norm_sq.sqrt() <= tol {
1635            continue;
1636        }
1637        let warm = std::mem::replace(&mut solutions[index], Array1::<f64>::zeros(0));
1638        let (repaired, repair_iters) = shifted_pcg(
1639            matvec,
1640            repair_preconditioner,
1641            shift,
1642            b,
1643            &warm,
1644            rel_tol,
1645            max_iters,
1646        )?;
1647        solutions[index] = repaired;
1648        matvecs = matvecs.checked_add(repair_iters)?;
1649    }
1650    Some((solutions, matvecs))
1651}
1652
1653/// Transpose one family solve per right-hand side into the `solves[node][vector]`
1654/// layout the value and derivative assembly read, summing the applies spent.
1655fn solve_family_block(
1656    solve: &(impl Fn(&Array1<f64>) -> Option<(Vec<Array1<f64>>, usize)> + Sync),
1657    node_count: usize,
1658    vectors: &[Array1<f64>],
1659) -> Option<(Vec<Vec<Array1<f64>>>, usize)> {
1660    let mut solves: Vec<Vec<Array1<f64>>> = vec![Vec::with_capacity(vectors.len()); node_count];
1661    let mut total = 0usize;
1662    for rhs in vectors {
1663        let (per_node, applies) = solve(rhs)?;
1664        if per_node.len() != node_count {
1665            return None;
1666        }
1667        total = total.checked_add(applies)?;
1668        for (node, solution) in per_node.into_iter().enumerate() {
1669            if solution.len() != rhs.len() || solution.iter().any(|value| !value.is_finite()) {
1670                return None;
1671            }
1672            solves[node].push(solution);
1673        }
1674    }
1675    Some((solves, total))
1676}
1677
1678/// Solve `(S + t_ℓ I) y = v` for every input vector across the whole shift
1679/// ladder, walking `order` (descending `t`) with per-vector warm starts (the
1680/// solution is smooth in `t`, so the previous shift seeds the next). Returns
1681/// `solves[ℓ][j]` and the total CG iteration count, or `None` on a shifted-CG
1682/// breakdown. Shared by the projected-probe and deflation-basis solve families
1683/// so both warm-start identically.
1684fn solve_shift_ladder_with(
1685    solve: &(impl Fn(
1686        f64,
1687        &Array1<f64>,
1688        &Array1<f64>,
1689    ) -> Option<(Array1<f64>, usize)>
1690              + Sync),
1691    nodes: &[(f64, f64)],
1692    order: &[usize],
1693    vectors: &[Array1<f64>],
1694) -> Option<(Vec<Vec<Array1<f64>>>, usize)> {
1695    let m = vectors.len();
1696    let dim = vectors.first().map(|v| v.len()).unwrap_or(0);
1697    let mut solves: Vec<Vec<Array1<f64>>> = vec![Vec::with_capacity(m); nodes.len()];
1698    let mut warm: Vec<Array1<f64>> = vec![Array1::zeros(dim); m];
1699    let mut total = 0usize;
1700    for &ell in order {
1701        let (shift, _) = nodes[ell];
1702        let mut per = Vec::with_capacity(m);
1703        for (j, rhs) in vectors.iter().enumerate() {
1704            let (solution, iters) = solve(shift, rhs, &warm[j])?;
1705            if solution.len() != dim || solution.iter().any(|value| !value.is_finite()) {
1706                return None;
1707            }
1708            total = total.checked_add(iters)?;
1709            warm[j] = solution.clone();
1710            per.push(solution);
1711        }
1712        solves[ell] = per;
1713    }
1714    Some((solves, total))
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720    use ndarray::array;
1721
1722    fn next_uniform(state: &mut u64, lo: f64, hi: f64) -> f64 {
1723        let bits = splitmix64(state) >> 11;
1724        let unit = (bits as f64) / ((1u64 << 53) as f64);
1725        lo + (hi - lo) * unit
1726    }
1727
1728    /// Random SPD `A = Q diag(λ) Qᵀ` with a prescribed spectrum, returned with
1729    /// its exact `log det` and eigen-pieces for derivative oracles.
1730    fn spd_with_spectrum(dim: usize, lambdas: &[f64], seed: u64) -> (Array2<f64>, f64) {
1731        let mut state = seed;
1732        let mut g = Array2::<f64>::zeros((dim, dim));
1733        for v in g.iter_mut() {
1734            // Box-Muller from two uniforms.
1735            let u1 = next_uniform(&mut state, 1e-12, 1.0);
1736            let u2 = next_uniform(&mut state, 0.0, 1.0);
1737            *v = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
1738        }
1739        // QR via Gram-Schmidt for an orthonormal Q (dim is small in tests).
1740        let mut q = Array2::<f64>::zeros((dim, dim));
1741        for c in 0..dim {
1742            let mut col = g.column(c).to_owned();
1743            for prev in 0..c {
1744                let proj = q.column(prev).dot(&col);
1745                let prev_col = q.column(prev).to_owned();
1746                col.scaled_add(-proj, &prev_col);
1747            }
1748            let norm = col.dot(&col).sqrt();
1749            let col = col / norm;
1750            q.column_mut(c).assign(&col);
1751        }
1752        let mut a = Array2::<f64>::zeros((dim, dim));
1753        for (i, &l) in lambdas.iter().enumerate() {
1754            let qi = q.column(i);
1755            for r in 0..dim {
1756                for c in 0..dim {
1757                    a[[r, c]] += l * qi[r] * qi[c];
1758                }
1759            }
1760        }
1761        let logdet: f64 = lambdas.iter().map(|l| l.ln()).sum();
1762        (a, logdet)
1763    }
1764
1765    #[test]
1766    fn quadrature_is_exact_on_scalar_spectrum() {
1767        // dim=1: Hutchinson is exact (v = ±1), so the only error is quadrature.
1768        for &x in &[1e-6, 1e-3, 0.5, 1.0, 7.3, 1e4, 1e8] {
1769            let plan = RationalLogdetPlan::build(1, 1, 7, x, x, 1e-10).expect("plan");
1770            let a = Array2::from_elem((1, 1), x);
1771            let eval = plan
1772                .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-14, 10_000)
1773                .expect("eval");
1774            let err = (eval.estimate - x.ln()).abs() / x.ln().abs().max(1.0);
1775            assert!(
1776                err < 1e-8,
1777                "quadrature error {err:.3e} at x={x:e} (est {} vs {})",
1778                eval.estimate,
1779                x.ln()
1780            );
1781        }
1782    }
1783
1784    #[test]
1785    fn matches_dense_logdet_within_probe_error_at_wide_kappa() {
1786        // κ = 1e8 spectrum, log-uniform. With m probes the Hutchinson std-err
1787        // scales like sqrt(2 Σ (stuff)/m); assert against a generous multiple
1788        // of the exact dense answer's scale rather than tuning to luck.
1789        let dim = 96;
1790        let mut state = 42u64;
1791        let lambdas: Vec<f64> = (0..dim)
1792            .map(|_| 10f64.powf(next_uniform(&mut state, -4.0, 4.0)))
1793            .collect();
1794        let (a, logdet) = spd_with_spectrum(dim, &lambdas, 1234);
1795        let lmin = lambdas.iter().cloned().fold(f64::INFINITY, f64::min);
1796        let lmax = lambdas.iter().cloned().fold(0.0f64, f64::max);
1797        let plan = RationalLogdetPlan::build(dim, 64, 11, lmin, lmax, 1e-9).expect("plan");
1798        let eval = plan
1799            .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 50_000)
1800            .expect("eval");
1801        // The probe fluctuation on a wide spectrum is genuinely large (Hutchinson
1802        // variance ~ 2·off-diag mass of log S), so assert the estimator against
1803        // its OWN error bar (5σ ⇒ false-failure odds ~1e-6) plus a small
1804        // deterministic quadrature budget — this validates estimate AND bar.
1805        let err = (eval.estimate - logdet).abs();
1806        let budget = 5.0 * eval.std_err + 1e-3 * logdet.abs().max(1.0);
1807        assert!(
1808            err < budget,
1809            "estimate {} vs exact {} — |err| {err:.3e} exceeds 5σ+quad budget {budget:.3e} \
1810             (std_err {:.3e})",
1811            eval.estimate,
1812            logdet,
1813            eval.std_err
1814        );
1815        assert!(
1816            eval.std_err.is_finite() && eval.std_err > 0.0,
1817            "multi-probe eval must report a positive error bar"
1818        );
1819    }
1820
1821    #[test]
1822    fn evaluate_is_deterministic_across_calls() {
1823        let dim = 24;
1824        let lambdas: Vec<f64> = (1..=dim).map(|i| i as f64).collect();
1825        let (a, _) = spd_with_spectrum(dim, &lambdas, 3);
1826        let plan = RationalLogdetPlan::build(dim, 4, 99, 1.0, dim as f64, 1e-8).expect("plan");
1827        let e1 = plan
1828            .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 10_000)
1829            .expect("eval1")
1830            .estimate;
1831        let e2 = plan
1832            .evaluate(&|v: ArrayView1<f64>| a.dot(&v), 1e-12, 10_000)
1833            .expect("eval2")
1834            .estimate;
1835        assert_eq!(e1, e2, "fixed plan must be bit-deterministic");
1836    }
1837
1838    #[test]
1839    fn shifted_cg_refuses_an_unconverged_iteration_cap() {
1840        let a = array![[1.0, 0.0], [0.0, 4.0]];
1841        let b = array![1.0, 1.0];
1842        let zero = Array1::<f64>::zeros(2);
1843        let matvec = |v: ArrayView1<f64>| a.dot(&v);
1844
1845        assert!(
1846            shifted_pcg(&matvec, &IDENTITY_SHIFT_PRECONDITIONER, 0.0, &b, &zero, 1.0e-12, 1)
1847                .is_none(),
1848            "one CG step cannot solve a two-eigenvalue system to 1e-12; the \
1849             iteration-capped last iterate must be refused"
1850        );
1851        let (solved, iterations) =
1852            shifted_pcg(&matvec, &IDENTITY_SHIFT_PRECONDITIONER, 0.0, &b, &zero, 1.0e-12, 2)
1853                .expect("two-dimensional SPD CG must converge in at most two steps");
1854        let residual = &b - &matvec(solved.view());
1855        assert!(
1856            residual.dot(&residual).sqrt() <= 1.0e-12 * b.dot(&b).sqrt(),
1857            "returned shifted solve must satisfy its true-residual contract"
1858        );
1859        assert_eq!(iterations, 2);
1860    }
1861
1862}