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}