gam_solve/arrow_schur/reduced_solve.rs
1//! The reduced `K x K` shared-system solve: dense Schur assembly (direct and
2//! square-root BA), the Schur matvec, the Jacobi/cluster/Schwarz
3//! preconditioners, Steihaug-PCG, and the [`ArrowSchurError`] type.
4
5use super::*;
6
7/// Host budget for a dense reduced Schur `k × k` f64 matrix (#1017). Above this
8/// the dense assembly is refused with a loud `SchurFactorFailed` rather than
9/// OOM-killing the host. 8 GiB ⇒ `k ≈ 32768`; every currently-feasible SAE border
10/// (k ≤ 5120 ⇒ 0.2 GiB) is well under it, while the qwen LLM border (k = 98304 ⇒
11/// 77 GiB) is correctly rejected as matrix-free-only.
12pub(crate) const DENSE_SCHUR_BYTES_BUDGET: u128 = 8 * 1024 * 1024 * 1024;
13
14/// Reduce one contiguous device tile's rows into a private `-Σ leftᵀ·right`
15/// partial (`k×k`).
16///
17/// The tile stacks its per-row `left_i` / `right_i` factors (each `d×k`) into
18/// two `(Σ_i d_i × k)` matrices and tries a single per-ordinal `AᵀB` device
19/// GEMM (`gam_gpu::try_fast_atb_on_ordinal`), which runs on the device this
20/// worker thread already bound — one big GPU GEMM per tile rather than `n` small
21/// CPU ones. When the device primitive declines (no GPU, shape below policy,
22/// transient failure) the tile reduces with the exact CPU `block_gemm_subtract`
23/// loop, so the result is unchanged. The partial is negated so the caller's
24/// `schur += partial` reproduces the serial `schur -= Σ contribution`.
25pub(crate) fn tile_schur_partial<B: BatchedBlockSolver>(
26 sys: &ArrowSchurSystem,
27 htt_factors: &ArrowFactorSlab,
28 backend: &B,
29 kind: SchurReductionKind,
30 ordinal: usize,
31 range: Range<usize>,
32) -> Result<Array2<f64>, ArrowSchurError> {
33 let k = sys.k;
34
35 // Build the per-row contribution factors once; both the GPU stacked-GEMM
36 // and the CPU fallback consume them.
37 let mut factors: Vec<(Array2<f64>, Array2<f64>)> = Vec::with_capacity(range.len());
38 let mut total_d = 0usize;
39 for i in range.clone() {
40 let (left, right) = row_schur_contribution_factors(
41 sys,
42 i,
43 &sys.rows[i],
44 htt_factors.factor(i),
45 backend,
46 kind,
47 )?;
48 total_d += left.nrows();
49 factors.push((left, right));
50 }
51
52 // Stack into (total_d × k) left/right matrices for one device AᵀB GEMM on
53 // this tile's bound ordinal. `try_fast_atb_on_ordinal` returns leftᵀ·right
54 // (k×k); negate into the partial. At an SAE-shaped whole-fit tile with
55 // n=2000 rows, k=2048 shared columns, M=12 local rows per observation, and
56 // K=8 candidate/atom batches, the stacked GEMM is
57 // 2*(n*M)*k^2 = 201_326_592_000 flops per batch, or
58 // 1_610_612_736_000 flops across K=8, so the policy work gate is cleared
59 // even though the observation count is far below the old row floor.
60 if total_d > 0 && k > 0 {
61 let mut left_stack = Array2::<f64>::zeros((total_d, k));
62 let mut right_stack = Array2::<f64>::zeros((total_d, k));
63 let mut base = 0usize;
64 for (left, right) in &factors {
65 let di = left.nrows();
66 left_stack
67 .slice_mut(ndarray::s![base..base + di, ..])
68 .assign(left);
69 right_stack
70 .slice_mut(ndarray::s![base..base + di, ..])
71 .assign(right);
72 base += di;
73 }
74 if let Some(product) =
75 gam_gpu::try_fast_atb_on_ordinal(ordinal, left_stack.view(), right_stack.view())
76 {
77 return Ok(product.mapv(|v| -v));
78 }
79 }
80
81 // CPU fallback: exact per-row block_gemm_subtract into a zero-seeded partial.
82 let mut partial = Array2::<f64>::zeros((k, k));
83 for (left, right) in &factors {
84 backend.block_gemm_subtract(&mut partial, left, right);
85 }
86 Ok(partial)
87}
88
89/// Reduce the per-row Schur contributions `Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)`
90/// out of `schur` (seeded with `H_ββ + ρ_β·I`).
91///
92/// The per-row contributions are independent — exactly the "sum over independent
93/// arrow-tip blocks" axis the device pool partitions. When more than one GPU is
94/// usable, [`gam_gpu::pool::balanced_partition`] splits the `0..n` rows into
95/// per-device contiguous tiles; each tile is reduced on its own scoped thread
96/// (binding that ordinal's context so the per-row GEMM-subtract offloads to its
97/// device) into a private `k×k` partial, and the partials are summed back into
98/// `schur` in tile order. The tiles are contiguous, ordered to cover `0..n`, and
99/// folded back in that same order, so within each tile the per-row accumulation
100/// order is preserved and the only departure from the serial loop is the
101/// inter-tile reassociation of the reduction sum — the established
102/// reduction-order equivalence the device pool already operates under, well
103/// inside the Newton solve's tolerance.
104///
105/// With a single device (or no GPU) the row loop runs serially in place, which
106/// is bit-for-bit the original behaviour.
107pub(crate) fn reduce_row_schur_contributions<B: BatchedBlockSolver + Sync>(
108 sys: &ArrowSchurSystem,
109 htt_factors: &ArrowFactorSlab,
110 backend: &B,
111 kind: SchurReductionKind,
112 schur: &mut Array2<f64>,
113 gpu_policy: gam_gpu::GpuPolicy,
114) -> Result<(), ArrowSchurError> {
115 let n = sys.rows.len();
116 let k = sys.k;
117
118 // Size gate BEFORE the device probe (startup-tax ordering fix): the
119 // multi-GPU tile path exists to overlap the per-row `leftᵀ·right` GEMMs
120 // (≈ `2·d·k²` flops each, `2·n·d·k²` total) across the pool, and each
121 // tile's GEMMs still pass through the policy-gated dispatch shims — which
122 // refuse every op when the WHOLE assembly is below
123 // `MIN_CALIBRATABLE_GEMM_FLOPS`, the smallest floor any reachable policy
124 // can carry. Such a shape would only inherit the tile split's inter-tile
125 // reassociation (the documented, tolerance-bounded departure) while doing
126 // 100% CPU work, so route it to the serial/rayon reference path below
127 // WITHOUT resolving GPU availability (whose first call creates a CUDA
128 // primary context on every GPU). Shapes clearing the floor probe and tile
129 // exactly as before.
130 let assembly_work = 2u128 * (n as u128) * (sys.d as u128) * (k as u128) * (k as u128);
131 let tiles = if assembly_work < gam_gpu::GpuDispatchPolicy::MIN_CALIBRATABLE_GEMM_FLOPS {
132 None
133 } else {
134 gam_gpu::device_runtime::GpuRuntime::resolve(gpu_policy)
135 .map_err(|error| ArrowSchurError::SchurFactorFailed {
136 reason: format!("GPU runtime resolution failed during Schur reduction: {error}"),
137 })?
138 .and_then(|rt| {
139 let tiles = gam_gpu::pool::balanced_partition(rt, n);
140 // Engage the device stacked-GEMM reduction when a MULTI-GPU pool can
141 // overlap tiles, OR — the single-GPU gap this closes — when the one
142 // stacked `(total_d×k)ᵀ(total_d×k)` GEMM clears the runtime's own
143 // `gemm_min_flops`, so `try_fast_atb_on_ordinal` will actually offload
144 // it instead of declining back to CPU. This reduction is the dense
145 // build's O(n·d·k²) cost (measured on an H100 as ~28% of the fit in
146 // `block_gemm_subtract`), and on a single GPU it previously always ran
147 // on the CPU because the tile path required `len() > 1` — the device
148 // sat idle. `assembly_work` IS the stacked GEMM's flop count (2·k²·Σd),
149 // so this is exactly `try_fast_atb`'s own offload predicate; below the
150 // GEMM floor the launch/staging tax loses to the CPU, so we keep the
151 // deterministic CPU rayon fold there. Small K (e.g. K=8) never clears
152 // the floor and stays on the CPU — magic-by-default crossover, no flag.
153 let engage = tiles.len() > 1 || assembly_work >= rt.policy().gemm_min_flops as u128;
154 (engage && !tiles.is_empty()).then_some(tiles)
155 })
156 };
157
158 let Some(tiles) = tiles else {
159 // Single-device / CPU. The per-row contributions `-Σ_i leftᵀ·right` fold
160 // into the `k×k` `schur` independently — the same dense-assembly axis the
161 // multi-GPU tile path partitions, and the dense-Direct analog of the
162 // per-row matvec / streaming `accumulate_chunk` loops already parallelized
163 // for #1017. At the SAE Direct-solve shape (`n` in the thousands, wide
164 // border `k`) this O(n·d·k²) reduction is the dense assembly's whole cost
165 // and was the last serial CPU step on the dense-Schur build.
166 //
167 // Fan it across rayon over fixed row chunks: each chunk reduces its rows
168 // (in row order) into a private zero-seeded `k×k` partial, then the
169 // partials are folded into `schur` in CHUNK order. The per-chunk row order
170 // and the inter-chunk fold order are both fixed independent of thread
171 // scheduling, so the f64 reduction is **bit-identical run-to-run** (the
172 // #1017 determinism gate). NOTE: bit-identical run-to-run does NOT make
173 // it bit-identical to the in-place serial loop — the chunk-boundary
174 // reassociation of the reduction sum is a genuine f64 departure (the
175 // established equivalence `accumulate_chunk` / the per-row matvec operate
176 // under, well inside the Newton solve's tolerance). It bounds candidate-
177 // to-candidate drift to that reassociation margin, so the criterion
178 // ranking is stable EXCEPT for candidates tying within the margin, where
179 // the winner can flip; it is not an exact no-move guarantee (#1211). For
180 // an exact-order guarantee, take the serial path. Stay in-place serial
181 // below the row floor and when already inside a rayon worker (the topology
182 // race fans candidates with `run_topology_race_parallel`) to avoid
183 // nested-rayon oversubscription — the same guard the matvec uses.
184 let n_rows = sys.rows.len();
185 let parallel =
186 n_rows >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
187 if parallel {
188 use rayon::prelude::*;
189 const CHUNK: usize = 64;
190 let partials: Result<Vec<Array2<f64>>, ArrowSchurError> = (0..n_rows)
191 .into_par_iter()
192 .chunks(CHUNK)
193 .map(|idxs| {
194 let mut partial = Array2::<f64>::zeros((k, k));
195 for i in idxs {
196 subtract_row_schur_contribution(
197 sys,
198 i,
199 &sys.rows[i],
200 htt_factors.factor(i),
201 backend,
202 kind,
203 &mut partial,
204 )?;
205 }
206 Ok(partial)
207 })
208 .collect();
209 // Deterministic ordered fold: chunk partials hold `-Σ contribution`
210 // over their rows, so `schur += partial` reproduces the serial
211 // `schur -= Σ contribution` in fixed (chunk, a, b) order.
212 for partial in &partials? {
213 for a in 0..k {
214 for b in 0..k {
215 schur[[a, b]] += partial[[a, b]];
216 }
217 }
218 }
219 return Ok(());
220 }
221 // Serial in-place reduction (original order) — bit-for-bit reference.
222 for (i, row) in sys.rows.iter().enumerate() {
223 subtract_row_schur_contribution(
224 sys,
225 i,
226 row,
227 htt_factors.factor(i),
228 backend,
229 kind,
230 schur,
231 )?;
232 }
233 return Ok(());
234 };
235
236 // Multi-GPU: one private `-Σ leftᵀ·right` partial per contiguous device
237 // tile. Each tile runs on its own scoped worker thread that binds its
238 // ordinal's context and issues a single stacked AᵀB GEMM on that device, so
239 // the tiles' GEMMs overlap across the pool. Folding the partials back into
240 // the H_ββ-seeded `schur` reproduces the serial reduction (up to inter-tile
241 // reassociation).
242 let partials: Result<Vec<Array2<f64>>, ArrowSchurError> = std::thread::scope(|scope| {
243 let handles: Vec<_> = tiles
244 .iter()
245 .map(|(ordinal, range)| {
246 let ordinal = *ordinal;
247 let range = range.clone();
248 scope.spawn(move || {
249 // Bind this ordinal's CUDA context on this worker thread so
250 // the per-row GPU GEMM shims issued from `tile_schur_partial`
251 // offload to that device. A missing context or bind failure
252 // is intentionally consumed without escalation — the shims
253 // no-op back to CPU and the math is unchanged. Off Linux
254 // runtime resolution is always absent, so this branch
255 // is unreachable and the bind is omitted entirely.
256 #[cfg(target_os = "linux")]
257 {
258 if let Some(ctx) = gam_gpu::device_runtime::cuda_context_for(ordinal) {
259 if ctx.bind_to_thread().is_err() {
260 // Fall through: this tile reduces on the CPU.
261 }
262 }
263 }
264 tile_schur_partial(sys, htt_factors, backend, kind, ordinal, range)
265 })
266 })
267 .collect();
268 handles
269 .into_iter()
270 .map(|handle| {
271 handle
272 .join()
273 .map_err(|_| ArrowSchurError::SchurFactorFailed {
274 reason: "schur-reduction tile thread panicked".to_string(),
275 })?
276 })
277 .collect()
278 });
279 let partials = partials?;
280
281 // Fold partials into `schur` in tile order (contiguous, covering 0..n) so
282 // the per-tile and inter-tile accumulation order is the row order; each
283 // partial holds `-Σ contribution` over its rows, so `schur += partial`
284 // reproduces `schur -= Σ contribution`.
285 for partial in &partials {
286 for a in 0..k {
287 for b in 0..k {
288 schur[[a, b]] += partial[[a, b]];
289 }
290 }
291 }
292 Ok(())
293}
294
295pub(crate) fn build_dense_schur_direct<B: BatchedBlockSolver + Sync>(
296 sys: &ArrowSchurSystem,
297 htt_factors: &ArrowFactorSlab,
298 ridge_beta: f64,
299 backend: &B,
300 gpu_policy: gam_gpu::GpuPolicy,
301) -> Result<Array2<f64>, ArrowSchurError> {
302 let k = sys.k;
303 // Materialise H_ββ via the BetaPenaltyOp trait (#296): DensePenaltyOp
304 // for the legacy dense path, structured ops for SAE / Kronecker smooths.
305 let op = sys.effective_penalty_op();
306 if op.dim() != k {
307 return Err(ArrowSchurError::SchurFactorFailed {
308 reason: "Direct BA requires a K×K shared H_ββ penalty operator".to_string(),
309 });
310 }
311 // Fail LOUD, never OOM-kill (#1017): the dense reduced Schur is `k × k` f64.
312 // At SAE LLM borders (qwen `k = 98304` ⇒ 77 GiB) materialising it would crash
313 // the host. The matrix-free device PCG already solves the *step* without it
314 // (`try_device_arrow_direct_sae_pcg`); only the joint-Hessian log-det still
315 // routes here. A matrix-free determinant-lemma log-det (the proper follow-up)
316 // is not yet wired, so refuse the allocation with an actionable error rather
317 // than degrading silently into an OOM. The budget is generous so every
318 // currently-feasible border (k ≤ 5120 ⇒ 0.2 GiB) is unaffected.
319 let dense_bytes = (k as u128).saturating_mul(k as u128).saturating_mul(8);
320 if dense_bytes > DENSE_SCHUR_BYTES_BUDGET {
321 return Err(ArrowSchurError::SchurFactorFailed {
322 reason: format!(
323 "dense reduced Schur is {k}×{k} f64 = {} MiB, exceeding the {} MiB host budget; \
324 this border is matrix-free-only (the device PCG solves the step without the dense \
325 Schur) and a matrix-free determinant-lemma log-det is the required follow-up",
326 dense_bytes / (1024 * 1024),
327 DENSE_SCHUR_BYTES_BUDGET / (1024 * 1024),
328 ),
329 });
330 }
331 let mut schur = op.to_dense();
332 for j in 0..k {
333 schur[[j, j]] += ridge_beta;
334 }
335 reduce_row_schur_contributions(
336 sys,
337 htt_factors,
338 backend,
339 SchurReductionKind::Direct,
340 &mut schur,
341 gpu_policy,
342 )?;
343 symmetrize_upper_from_lower(&mut schur);
344 Ok(schur)
345}
346
347pub(crate) fn build_dense_schur_sqrt_ba<B: BatchedBlockSolver + Sync>(
348 sys: &ArrowSchurSystem,
349 htt_factors: &ArrowFactorSlab,
350 ridge_beta: f64,
351 backend: &B,
352 gpu_policy: gam_gpu::GpuPolicy,
353) -> Result<Array2<f64>, ArrowSchurError> {
354 let k = sys.k;
355 // Materialise H_ββ via the BetaPenaltyOp trait (#296).
356 let op = sys.effective_penalty_op();
357 if op.dim() != k {
358 return Err(ArrowSchurError::SchurFactorFailed {
359 reason: "Square-Root BA direct solve requires a K×K shared H_ββ penalty operator"
360 .to_string(),
361 });
362 }
363 // Same fail-loud host-memory contract as the Direct reduction (#1017). The
364 // square-root BA route still materialises the same dense `k×k` reduced
365 // Schur; letting this path bypass the budget would preserve an OOM-class
366 // fallback even after Direct learned to refuse matrix-free-only borders.
367 let dense_bytes = (k as u128).saturating_mul(k as u128).saturating_mul(8);
368 if dense_bytes > DENSE_SCHUR_BYTES_BUDGET {
369 return Err(ArrowSchurError::SchurFactorFailed {
370 reason: format!(
371 "square-root BA dense reduced Schur is {k}×{k} f64 = {} MiB, exceeding the \
372 {} MiB host budget; this border is matrix-free-only",
373 dense_bytes / (1024 * 1024),
374 DENSE_SCHUR_BYTES_BUDGET / (1024 * 1024),
375 ),
376 });
377 }
378 let mut schur = op.to_dense();
379 for j in 0..k {
380 schur[[j, j]] += ridge_beta;
381 }
382 reduce_row_schur_contributions(
383 sys,
384 htt_factors,
385 backend,
386 SchurReductionKind::SqrtBa,
387 &mut schur,
388 gpu_policy,
389 )?;
390 symmetrize_upper_from_lower(&mut schur);
391 Ok(schur)
392}
393
394/// Certified Carson–Higham mixed-precision solve of the reduced dense Schur
395/// system `S Δβ = rhs` (#1014), specialized to the streaming/residency path.
396///
397/// Returns `Some(Δβ)` when certified mixed precision is enabled AND the κ gate
398/// admits the f32 factorization AND the f64 backward-error certificate closes;
399/// `None` in every other case so the caller falls back to the exact f64
400/// triangular solve. The f64 `factor` (whose diagonal carries the exact
401/// `log|S|`) is supplied by the caller and never re-derived here — the logdet
402/// the evidence path reads stays f64 by construction.
403///
404/// Method: store the f64 Cholesky factor as f32, solve in f32, then refine with
405/// residuals `r = rhs − S·x` computed in f64 against the f64 `S`. With
406/// `κ(S)·u_f32 < margin` the refinement contracts at rate `κ·u`, and the
407/// terminating certificate is the normwise backward error
408/// `‖r‖∞ / (‖S‖∞‖x‖∞ + ‖rhs‖∞) ≤ tol`. A non-decreasing residual or an
409/// unmet certificate after `max_refinement_steps` returns `None`.
410pub(crate) fn mixed_precision_reduced_beta(
411 schur: &Array2<f64>,
412 factor: &Array2<f64>,
413 rhs: &Array1<f64>,
414 options: &ArrowSolveOptions,
415) -> Option<Array1<f64>> {
416 let ArrowSolvePrecisionPolicy::CertifiedMixed {
417 max_refinement_steps,
418 residual_relative_tolerance,
419 kappa_unit_roundoff_margin,
420 } = options.solve_precision
421 else {
422 return None;
423 };
424 // The reduced-system mixed-precision path is the dense reduced solve only;
425 // a trust-region-truncated step takes the Steihaug branch below in f64.
426 if options.trust_region.radius.is_finite() {
427 return None;
428 }
429 let n = schur.nrows();
430 if n == 0 {
431 return None;
432 }
433
434 // κ gate: the f32 factorization is only admissible when κ(S)·u_f32 leaves
435 // the refinement contraction headroom the certificate needs.
436 let kappa = cholesky_factor_kappa_estimate(factor);
437 if !kappa.is_finite() || kappa * F32_UNIT_ROUNDOFF >= kappa_unit_roundoff_margin {
438 return None;
439 }
440
441 let factor_f32 = factor.mapv(|v| v as f32);
442 let s_inf = matrix_inf_norm(schur);
443 let rhs_inf = rhs.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
444 let certificate_tol = residual_relative_tolerance
445 .max(MIXED_PRECISION_CERTIFICATE_EPSILON_MULTIPLIER * f64::EPSILON);
446
447 // f32 solve of the seed system, then f64-residual refinement steps.
448 let mut x = cholesky_solve_lower_f32(&factor_f32, &rhs.mapv(|v| v as f32)).mapv(|v| v as f64);
449 let mut last_residual = f64::INFINITY;
450 for _ in 0..=max_refinement_steps {
451 // Residual r = rhs − S·x in f64 against the f64 model.
452 let sx = schur.dot(&x);
453 let mut r = rhs.clone();
454 r -= &sx;
455 let r_inf = r.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
456 let x_inf = x.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
457 let denom = s_inf * x_inf + rhs_inf;
458 let backward_error = if denom > 0.0 { r_inf / denom } else { 0.0 };
459 if backward_error <= certificate_tol {
460 return Some(x);
461 }
462 // Refinement must make monotone progress, else hand back to f64.
463 if !(r_inf < last_residual) {
464 return None;
465 }
466 last_residual = r_inf;
467 // Correction solve in f32 against the f32 factor: S·δ = r.
468 let delta = cholesky_solve_lower_f32(&factor_f32, &r.mapv(|v| v as f32)).mapv(|v| v as f64);
469 x += δ
470 }
471 None
472}
473
474/// Infinity norm (max absolute row sum) of a dense matrix.
475pub(crate) fn matrix_inf_norm(a: &Array2<f64>) -> f64 {
476 let mut max_row = 0.0_f64;
477 for row in a.rows() {
478 let s: f64 = row.iter().map(|v| v.abs()).sum();
479 if s > max_row {
480 max_row = s;
481 }
482 }
483 max_row
484}
485
486/// Spectral positive-definiteness floor for the reduced Schur complement
487/// `S` (#1026 SAE co-collapse SOLVE-path cure).
488///
489/// Reached only after the genuine Cholesky of `S` has REFUSED it (an indefinite
490/// reduced Schur: collapsed atoms drive a per-row `H_tt` near-singular, so the
491/// accumulated `Σ_i H_tβᵀ (H_tt)⁻¹ H_tβ` over-subtracts `H_ββ + ridge_β·I` into a
492/// matrix with a non-positive eigenvalue). Rather than reject and let the LM
493/// loop inflate `ridge_β` over EVERY β direction (the #1026 "crawl"), we
494/// symmetric-eigendecompose `S` and clamp every eigenvalue UP to
495/// `floor·max(λ)`. This is Levenberg–Marquardt restricted to exactly the
496/// indefinite/collapsed subspace: a well-separated positive direction
497/// (`λ ≫ floor·max λ`) keeps its EXACT eigenvalue (`λ.max(floor·max λ) = λ`), so
498/// the Newton step in the healthy β subspace is unchanged, while only the
499/// collapsed directions get the minimal positive stiffness needed for a PD
500/// solve. Returns the floored, symmetric, strictly-PD matrix, or `None` if `S`
501/// has no usable scale (non-finite / all-zero spectrum), in which case the
502/// caller keeps the strict refusal.
503///
504/// Mirrors the per-row evidence floor
505/// [`super::factorization::factor_spectral_deflated_criterion_row`]; the only
506/// difference is the floored VALUE — a small positive `floor·max λ` (Tikhonov,
507/// for an accurate solve) here, vs unit stiffness `+1` (`log 1 = 0`) there (for
508/// the quotient log-det).
509pub(crate) fn spectral_pd_floored_schur(
510 schur: &Array2<f64>,
511 relative_floor: f64,
512) -> Option<(Array2<f64>, Array2<f64>)> {
513 spectral_pd_floored_schur_with_factor(schur, relative_floor)
514}
515
516/// Shared body for [`spectral_pd_floored_schur`]: symmetrise, eigendecompose,
517/// condition the spectrum, and return BOTH
518/// the conditioned matrix `Σ λ̃_i v_i v_iᵀ` (consumed by Steihaug / matvec /
519/// mixed-precision refinement) and its lower Cholesky factor.
520///
521/// The factor is built DIRECTLY from the conditioned spectral form — QR of
522/// `W = diag(√λ̃)·Vᵀ` gives `A = WᵀW = RᵀR`, so `L = Rᵀ` — never by
523/// re-factorising the reconstructed matrix. Reconstruct-then-refactor fails
524/// under extreme eigenvalue spread: with `λ_max ~ 1e57` the `Σ λ̃ v vᵀ`
525/// reconstruction carries `O(ε·λ_max)` round-off, which swamps unit-deflated
526/// (`λ̃ = 1`) and floored (`λ̃ = floor·λ_max`) directions and re-poisons the
527/// second Cholesky — the #2230 "spectral PD-floor reconstruction still non-PD"
528/// refusal at a ρ whose conditioned evidence is perfectly well-defined. The QR
529/// route factors the exact conditioned spectrum, so it succeeds whenever the
530/// policy produced strictly positive `λ̃` (always, by construction).
531fn spectral_pd_floored_schur_with_factor(
532 schur: &Array2<f64>,
533 relative_floor: f64,
534) -> Option<(Array2<f64>, Array2<f64>)> {
535 let n = schur.nrows();
536 if n == 0 || schur.ncols() != n || !(relative_floor.is_finite() && relative_floor > 0.0) {
537 return None;
538 }
539 // Symmetrise defensively (the assembled Schur is symmetric up to reduction
540 // order; the eig routine assumes exact symmetry).
541 let mut sym = Array2::<f64>::zeros((n, n));
542 for i in 0..n {
543 for j in 0..n {
544 let v = 0.5 * (schur[[i, j]] + schur[[j, i]]);
545 if !v.is_finite() {
546 return None;
547 }
548 sym[[i, j]] = v;
549 }
550 }
551 let (evals, evecs) = sym.eigh(Side::Lower).ok()?;
552 let max_abs = evals.iter().fold(
553 0.0_f64,
554 |acc, &v| if v.is_finite() { acc.max(v.abs()) } else { acc },
555 );
556 if !(max_abs.is_finite() && max_abs > 0.0) {
557 return None;
558 }
559 let floor = relative_floor * max_abs;
560 // Newton-step policy (LM): clamp every eigenvalue UP to a strictly positive
561 // `floor` — healthy positive directions (`λ ≫ floor`) keep their EXACT
562 // eigenvalue, collapsed/indefinite directions get the minimal stiffness for
563 // a stable `Δβ`.
564 let mut conditioned = Array2::<f64>::zeros((n, n));
565 let mut weighted_vt = Array2::<f64>::zeros((n, n));
566 for eig_idx in 0..evals.len() {
567 let lambda = evals[eig_idx];
568 let lambda_conditioned = if lambda.is_finite() {
569 lambda.max(floor)
570 } else {
571 floor
572 };
573 let sqrt_lambda = lambda_conditioned.sqrt();
574 for i in 0..n {
575 let vi = evecs[[i, eig_idx]];
576 weighted_vt[[eig_idx, i]] = sqrt_lambda * vi;
577 if vi == 0.0 {
578 continue;
579 }
580 for j in 0..n {
581 conditioned[[i, j]] += lambda_conditioned * vi * evecs[[j, eig_idx]];
582 }
583 }
584 }
585 let factor =
586 spectral_qr_cholesky_factor(&weighted_vt).or_else(|| cholesky_lower(&conditioned).ok())?;
587 Some((conditioned, factor))
588}
589
590/// Original-coordinate unit-deflation for an evidence reduced Schur.
591///
592/// The rank decision and unit pin are made in the caller's β coordinates. A
593/// Jacobi congruence is appropriate for a Newton solve but would turn a unit
594/// eigenvalue in scaled coordinates into a scale-dependent stiffness after
595/// unscaling, corrupting both `log 1 = 0` and the cached null-space metadata.
596fn factor_evidence_unit_deflated_schur(
597 schur: &Array2<f64>,
598 relative_floor: f64,
599) -> Option<DenseReducedSchurFactorization> {
600 let n = schur.nrows();
601 if n == 0 || schur.ncols() != n || !(relative_floor.is_finite() && relative_floor > 0.0) {
602 return None;
603 }
604 let mut sym = Array2::<f64>::zeros((n, n));
605 for i in 0..n {
606 for j in 0..n {
607 let value = 0.5 * (schur[[i, j]] + schur[[j, i]]);
608 if !value.is_finite() {
609 return None;
610 }
611 sym[[i, j]] = value;
612 }
613 }
614 let (raw_evals, evecs) = sym.eigh(Side::Lower).ok()?;
615 let max_abs = raw_evals.iter().fold(0.0_f64, |acc, &value| {
616 if value.is_finite() {
617 acc.max(value.abs())
618 } else {
619 acc
620 }
621 });
622 if !(max_abs.is_finite() && max_abs > 0.0) {
623 return None;
624 }
625 let deflate_floor = relative_floor * max_abs * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
626 let deflated: Vec<bool> = raw_evals
627 .iter()
628 .map(|&value| !value.is_finite() || value < deflate_floor)
629 .collect();
630
631 // Preserve the ordinary equilibrated-Cholesky bit path in the interior.
632 // If Cholesky alone is numerically unable to factor a spectrally healthy
633 // operator, the spectral QR below still factors the identical raw spectrum.
634 if !deflated.iter().any(|&is_deflated| is_deflated)
635 && let Ok(interior) = factor_dense_reduced_schur(schur, ReducedSchurPolicy::StrictNewton)
636 {
637 return Some(interior);
638 }
639
640 let mut cond_evals = raw_evals.clone();
641 let mut conditioned = Array2::<f64>::zeros((n, n));
642 let mut weighted_vt = Array2::<f64>::zeros((n, n));
643 for eig_idx in 0..n {
644 if deflated[eig_idx] {
645 cond_evals[eig_idx] = 1.0;
646 }
647 let lambda = cond_evals[eig_idx];
648 if !(lambda.is_finite() && lambda > 0.0) {
649 return None;
650 }
651 let sqrt_lambda = lambda.sqrt();
652 for i in 0..n {
653 let vi = evecs[[i, eig_idx]];
654 weighted_vt[[eig_idx, i]] = sqrt_lambda * vi;
655 if vi != 0.0 {
656 for j in 0..n {
657 conditioned[[i, j]] += lambda * vi * evecs[[j, eig_idx]];
658 }
659 }
660 }
661 }
662 let factor = spectral_qr_cholesky_factor(&weighted_vt)?;
663 let beta_deflation =
664 deflated
665 .iter()
666 .any(|&is_deflated| is_deflated)
667 .then(|| BetaSchurDeflationSpectrum {
668 evecs,
669 raw_evals,
670 cond_evals,
671 deflated: deflated.into(),
672 });
673 Some(DenseReducedSchurFactorization {
674 factor,
675 conditioned_schur: beta_deflation.as_ref().map(|_| conditioned),
676 beta_deflation,
677 })
678}
679
680/// Lower Cholesky factor of `A = WᵀW` computed from `W` itself: QR gives
681/// `W = QR ⇒ A = RᵀR`, so the factor is `L = Rᵀ` (rows sign-fixed to a positive
682/// diagonal). `W` here is `diag(√λ̃)·Vᵀ` with every `λ̃ > 0`, so `W` has full
683/// rank and the factor exists exactly; returns `None` only if the QR itself
684/// declines or produces a non-finite / zero pivot, in which case the caller
685/// falls back to factoring the reconstructed matrix (the historical path).
686fn spectral_qr_cholesky_factor(weighted_vt: &Array2<f64>) -> Option<Array2<f64>> {
687 let n = weighted_vt.nrows();
688 let (_q, r) = weighted_vt.qr().ok()?;
689 if r.nrows() != n || r.ncols() != n {
690 return None;
691 }
692 let mut l = Array2::<f64>::zeros((n, n));
693 for i in 0..n {
694 let d = r[[i, i]];
695 if !d.is_finite() || d == 0.0 {
696 return None;
697 }
698 let s = if d < 0.0 { -1.0 } else { 1.0 };
699 for j in i..n {
700 let v = s * r[[i, j]];
701 if !v.is_finite() {
702 return None;
703 }
704 l[[j, i]] = v;
705 }
706 }
707 Some(l)
708}
709
710/// Jacobi/Van der Sluis diagonal equilibration scale for a symmetric matrix
711/// (#2015): `d_a = sqrt(schur[a,a])`, floored at `√JACOBI_DIAGONAL_PD_FLOOR` so
712/// a numerically-empty diagonal entry never divides by ~0. This is a PURE
713/// numerical-conditioning aid for [`factor_dense_reduced_schur`] below — it is
714/// never returned or exposed, and it changes no value any caller of that
715/// function sees, only the accuracy of computing it.
716fn jacobi_diagonal_scale(schur: &Array2<f64>) -> Array1<f64> {
717 let n = schur.nrows();
718 let floor_sqrt = JACOBI_DIAGONAL_PD_FLOOR.sqrt();
719 let mut d = Array1::<f64>::zeros(n);
720 for a in 0..n {
721 let diag = schur[[a, a]];
722 d[a] = if diag.is_finite() && diag > JACOBI_DIAGONAL_PD_FLOOR {
723 diag.sqrt()
724 } else {
725 floor_sqrt
726 };
727 }
728 d
729}
730
731/// Factor the dense reduced Schur complement `S`, returning its lower Cholesky
732/// factor, the conditioned operator when policy changed it, and authoritative
733/// β-null metadata for evidence unit deflation.
734///
735/// #2015 — SOLVER-LEVEL conditioning fix (design: issue 2015 comment
736/// 4949898801). A real activation+behavior augmented target can carry output
737/// column-norm spreads of ~1e4 (joint Hessian condition number ≈ 1e8), which a
738/// PLAIN `cholesky_lower(schur)` is not designed to survive: the recursive
739/// `L_ii = sqrt(S_ii − Σ_{j<i} L_ij²)` step loses precision (or falsely
740/// refuses a genuinely PD matrix) when the diagonal spans many orders of
741/// magnitude. Equilibrate FIRST: `D = diag(d)` with `d_a = sqrt(S_aa)`
742/// ([`jacobi_diagonal_scale`] — Van der Sluis equilibration, provably within a
743/// factor of `n` of the OPTIMAL diagonal preconditioner for a symmetric
744/// matrix), factor `S̃ = D⁻¹SD⁻¹` (unit diagonal by construction) with the
745/// EXACT SAME Cholesky/spectral-floor logic below, then undo the equilibration
746/// on the way out.
747///
748/// This is NOT a reparametrization of any objective or estimand (contrast the
749/// REVERTED #2015 attempt that divided the FIT TARGET's columns, which
750/// changed what "best fit" means for a homoscedastic residual). `D` is
751/// diagonal, so `L := D·L̃` is STILL lower-triangular, and
752/// `L·Lᵀ = D·S̃·Dᵀ = D·(D⁻¹SD⁻¹)·D = S` exactly — `L` is a bit-exact valid
753/// Cholesky factor of the CALLER'S ORIGINAL `schur`, just computed via a
754/// numerically superior route. Undoing the scale is one exact elementwise
755/// multiply (`factor[i,j] *= d[i]`, `floored[i,j] *= d[i]*d[j]`) — no further
756/// precision is lost recovering original units. Evidence unit deflation
757/// deliberately bypasses this congruence and works in the original β
758/// coordinates so a unit-pinned null contributes exactly `log 1`.
759///
760/// GPU cross-reference: the device/GPU dense-reference path
761/// (`gam_solve::gpu_kernels::arrow_schur::solve_arrow_newton_step_dense_reference`)
762/// factors the full joint `(t, β)` system independently of this function and
763/// does NOT yet get this equilibration. Both paths are exact; the GPU path is
764/// simply not yet as well-conditioned on an ill-scaled system. Porting the
765/// same technique there is a deliberate follow-up, not part of this change.
766///
767/// Newton-step damping and evidence quotient deflation are deliberately
768/// different policies: Tikhonov directions retain a small positive curvature
769/// for a stable step, while evidence-null directions are pinned to unit
770/// stiffness so their log-determinant contribution is exactly zero.
771#[derive(Debug, Clone, Copy, PartialEq)]
772pub(crate) enum ReducedSchurPolicy {
773 StrictNewton,
774 NewtonTikhonov { relative_floor: f64 },
775 EvidenceUnitDeflation { relative_floor: f64 },
776}
777
778impl ReducedSchurPolicy {
779 pub(crate) fn newton(relative_floor: Option<f64>) -> Self {
780 match relative_floor {
781 Some(relative_floor) => Self::NewtonTikhonov { relative_floor },
782 None => Self::StrictNewton,
783 }
784 }
785}
786
787#[derive(Debug)]
788pub(crate) struct DenseReducedSchurFactorization {
789 pub(crate) factor: Array2<f64>,
790 pub(crate) conditioned_schur: Option<Array2<f64>>,
791 pub(crate) beta_deflation: Option<BetaSchurDeflationSpectrum>,
792}
793
794pub(crate) fn factor_dense_reduced_schur(
795 schur: &Array2<f64>,
796 policy: ReducedSchurPolicy,
797) -> Result<DenseReducedSchurFactorization, ArrowSchurError> {
798 let newton_relative_floor = match policy {
799 ReducedSchurPolicy::StrictNewton => None,
800 ReducedSchurPolicy::NewtonTikhonov { relative_floor } => Some(relative_floor),
801 ReducedSchurPolicy::EvidenceUnitDeflation { relative_floor } => {
802 return factor_evidence_unit_deflated_schur(schur, relative_floor).ok_or_else(|| {
803 ArrowSchurError::SchurFactorFailed {
804 reason: "evidence reduced Schur unit-deflation declined (no usable spectrum)"
805 .to_string(),
806 }
807 });
808 }
809 };
810 let n = schur.nrows();
811 let d = jacobi_diagonal_scale(schur);
812 let mut schur_scaled = Array2::<f64>::zeros((n, n));
813 for i in 0..n {
814 for j in 0..n {
815 schur_scaled[[i, j]] = schur[[i, j]] / (d[i] * d[j]);
816 }
817 }
818 let (factor_scaled, floored_scaled) = match cholesky_lower(&schur_scaled) {
819 Ok(factor) => (factor, None),
820 Err(e) => {
821 // #1026/#1038 — every dense reduced-Schur factorization in the SAE
822 // path must honor the same opt-in spectral floor. Otherwise
823 // auxiliary entry points (mixed precision and cross-row ordered Beta--Bernoulli
824 // preconditioning) can reject the collapsed dead-atom subspace even
825 // though the main direct solve would floor it and continue.
826 //
827 // #1803 — Newton-step callers use the Levenberg-Marquardt PD floor
828 // (`spectral_pd_floored_schur`) so `Δβ` is stable. Evidence/log-det
829 // callers (`unit_deflate_null_directions`) instead deflate
830 // quotient/null directions to unit stiffness so they contribute the
831 // ρ-independent `log 1 = 0` to the Laplace normaliser rather than a
832 // ρ-dependent Occam reward for collapsed decoders.
833 //
834 // #2015 — this spectral floor runs on the EQUILIBRATED `schur_scaled`,
835 // so `relative_floor` (a FRACTION of the operator's own max
836 // eigenvalue) reads a numerically trustworthy spectrum instead of one
837 // dominated by the raw column-scale spread; the floored
838 // reconstruction is undone back to original units below exactly like
839 // the plain factor.
840 match newton_relative_floor {
841 Some(relative_floor) => {
842 match spectral_pd_floored_schur(&schur_scaled, relative_floor) {
843 Some((floored, floored_factor)) => (floored_factor, Some(floored)),
844 None => {
845 return Err(ArrowSchurError::SchurFactorFailed {
846 reason: format!(
847 "reduced Schur non-PD ({e}); spectral PD-floor declined \
848 (no usable spectrum)"
849 ),
850 });
851 }
852 }
853 }
854 None => {
855 return Err(ArrowSchurError::SchurFactorFailed { reason: e });
856 }
857 }
858 }
859 };
860 // Undo the equilibration exactly: L = D·L̃ (row i scaled by d_i); the
861 // floored reconstruction (when present) scales back as D·S̃_floor·D.
862 let mut factor = factor_scaled;
863 for i in 0..n {
864 let di = d[i];
865 for j in 0..=i {
866 factor[[i, j]] *= di;
867 }
868 }
869 let floored_schur = floored_scaled.map(|mut floored| {
870 for i in 0..n {
871 for j in 0..n {
872 floored[[i, j]] *= d[i] * d[j];
873 }
874 }
875 floored
876 });
877 Ok(DenseReducedSchurFactorization {
878 factor,
879 conditioned_schur: floored_schur,
880 beta_deflation: None,
881 })
882}
883
884pub(crate) fn solve_dense_reduced_system(
885 schur: &Array2<f64>,
886 rhs_beta: &Array1<f64>,
887 options: &ArrowSolveOptions,
888 metric_weights: Option<&MetricWeights>,
889) -> Result<(Array1<f64>, Option<Array2<f64>>, ArrowPcgDiagnostics), ArrowSchurError> {
890 let policy = ReducedSchurPolicy::newton(options.newton_schur_tikhonov_rel_floor);
891 let DenseReducedSchurFactorization {
892 factor,
893 conditioned_schur: floored_schur,
894 beta_deflation: _,
895 } = factor_dense_reduced_schur(schur, policy)?;
896 if let Some(floored) = floored_schur {
897 let direct = mixed_precision_reduced_beta(&floored, &factor, rhs_beta, options)
898 .unwrap_or_else(|| cholesky_solve_vector(&factor, rhs_beta));
899 if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights) {
900 return Ok((direct, Some(factor), ArrowPcgDiagnostics::default()));
901 }
902 let identity = IdentityPreconditioner;
903 let (delta, diag) = steihaug_dense_system(
904 &floored,
905 rhs_beta,
906 &identity,
907 &ArrowPcgOptions {
908 max_iterations: options.trust_region.max_iterations,
909 relative_tolerance: options.trust_region.steihaug_relative_tolerance,
910 },
911 &options.trust_region,
912 metric_weights,
913 )?;
914 return Ok((delta, Some(factor), diag));
915 }
916 // Ill-conditioned-but-PD Schur guard. The per-row factor checks reject
917 // any single barely-PD H_tt^(i) block, but the reduced Schur complement
918 // S = H_ββ + ridge_β·I − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)
919 // accumulates the (H_tt^(i))⁻¹ contributions of every row in finite
920 // precision. With many weak-but-admissible rows those terms can sum to a
921 // Schur matrix whose Cholesky succeeds yet whose condition number is far
922 // past the safe inversion regime, so `cholesky_solve_vector` yields an
923 // inaccurate Δβ that is silently propagated to the Newton step. Apply the
924 // same diagonal-ratio κ proxy used per-row to the reduced factor and treat
925 // an over-threshold estimate as a Schur-stability failure: `SchurFactorFailed`
926 // is already recoverable in `solve_with_lm_escalation_inner`, so this lifts
927 // `ridge_beta` and re-forms a better-conditioned Schur. This guard is
928 // exclusive to the dense Direct / SqrtBA path (the only caller of this
929 // function); the inexact-PCG path tolerates higher κ(S) and is unaffected.
930 let schur_kappa = cholesky_factor_kappa_estimate(&factor);
931 if !schur_kappa.is_finite() || schur_kappa > safe_spd_kappa_max(schur.nrows()) {
932 // #1026 — over-complete SAE dictionaries park surplus atoms dead
933 // (β_k → 0), so the reduced Schur is PD (the Cholesky above succeeded)
934 // but ILL-CONDITIONED: the dead decoder subspace carries near-zero
935 // eigenvalues while the live subspace is healthy. The kappa gate's
936 // concern is an inaccurate Δβ from accumulated (H_tt)⁻¹ contamination —
937 // but on the dead subspace the correct Δβ IS ≈0 (those atoms have no
938 // signal), so the only "inaccuracy" is in directions whose true step is
939 // zero. When the spectral PD-floor is enabled (the SAE solve path),
940 // clamp exactly those collapsed directions up to `floor·max(λ)` and
941 // solve against the floored Schur: the live subspace keeps its EXACT
942 // Newton component, the dead subspace is damped to ≈0, and κ is bounded
943 // so Δβ is accurate where it matters. This is the same conditioning the
944 // non-PD branch above applies; here it also covers the PD-but-ill-
945 // conditioned case so the LM loop does not exhaust `ridge_β` trying to
946 // (futilely) lift a fundamentally rank-deficient dead-atom subspace.
947 // Without the floor (BA / non-SAE callers) the strict refusal stands.
948 if let Some(relative_floor) = options.newton_schur_tikhonov_rel_floor
949 && let Some((floored, floored_factor)) =
950 spectral_pd_floored_schur(schur, relative_floor)
951 {
952 let direct = mixed_precision_reduced_beta(&floored, &floored_factor, rhs_beta, options)
953 .unwrap_or_else(|| cholesky_solve_vector(&floored_factor, rhs_beta));
954 if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights)
955 {
956 return Ok((direct, Some(floored_factor), ArrowPcgDiagnostics::default()));
957 }
958 let identity = IdentityPreconditioner;
959 let (delta, diag) = steihaug_dense_system(
960 &floored,
961 rhs_beta,
962 &identity,
963 &ArrowPcgOptions {
964 max_iterations: options.trust_region.max_iterations,
965 relative_tolerance: options.trust_region.steihaug_relative_tolerance,
966 },
967 &options.trust_region,
968 metric_weights,
969 )?;
970 return Ok((delta, Some(floored_factor), diag));
971 }
972 return Err(ArrowSchurError::SchurFactorFailed {
973 reason: format!(
974 "reduced Schur complement Cholesky succeeded but is ill-conditioned \
975 (kappa_estimate={schur_kappa:e}); accumulated per-row \
976 (H_tt)⁻¹ contamination would yield an inaccurate Δβ"
977 ),
978 });
979 }
980 // Reduced-system solve. The f64 `factor` is always retained and returned —
981 // its diagonal is the EXACT `log|S|` the evidence path reads, so the logdet
982 // stays f64 regardless of how Δβ is computed (#1014 invariant). When the
983 // streaming/residency path enabled certified mixed precision, the Δβ solve
984 // itself runs f32-then-f64-refined (κ-gated, with the f64 triangular solve
985 // as the automatic fallback); the certificate is the f64 backward error.
986 let direct = mixed_precision_reduced_beta(schur, &factor, rhs_beta, options)
987 .unwrap_or_else(|| cholesky_solve_vector(&factor, rhs_beta));
988 if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights) {
989 return Ok((direct, Some(factor), ArrowPcgDiagnostics::default()));
990 }
991
992 // Ceres-style trust-region correction: once the dense BA solve proposes a
993 // step outside the trust ball, Steihaug-CG returns the boundary point
994 // without requiring a second dense factorization.
995 let identity = IdentityPreconditioner;
996 let (delta, diag) = steihaug_dense_system(
997 schur,
998 rhs_beta,
999 &identity,
1000 &ArrowPcgOptions {
1001 max_iterations: options.trust_region.max_iterations,
1002 relative_tolerance: options.trust_region.steihaug_relative_tolerance,
1003 },
1004 &options.trust_region,
1005 metric_weights,
1006 )?;
1007 Ok((delta, Some(factor), diag))
1008}
1009
1010/// Solve an externally accumulated dense reduced β system
1011/// `S Δβ = rhs_β` with the same LM-style ridge escalation the full-batch
1012/// driver applies: on a `SchurFactorFailed` (non-PD or ill-conditioned `S`),
1013/// geometrically grow a proximal ridge on `S`'s diagonal and retry.
1014///
1015/// Used by the SAE streaming joint fit, which accumulates `S` and `rhs_β` over
1016/// re-materialized row chunks (via [`StreamingArrowSchur::take_accumulators`])
1017/// and must solve the single global reduced system without a per-row
1018/// `ArrowSchurSystem`. `S` is symmetrized from its lower triangle before each
1019/// factorization. `base_ridge_beta` is folded into the caller's `S` already;
1020/// this routine only adds the *escalation* ridge on top.
1021pub fn solve_streaming_reduced_beta(
1022 s_acc: &Array2<f64>,
1023 rhs_beta: &Array1<f64>,
1024 options: &ArrowSolveOptions,
1025) -> Result<Array1<f64>, ArrowSchurError> {
1026 let mut proximal_ridge = 0.0_f64;
1027 let mut last_err: Option<ArrowSchurError> = None;
1028 for attempt in 0..=DEFAULT_PROXIMAL_MAX_ATTEMPTS {
1029 let mut schur = s_acc.clone();
1030 symmetrize_upper_from_lower(&mut schur);
1031 if proximal_ridge > 0.0 {
1032 for j in 0..schur.nrows() {
1033 schur[[j, j]] += proximal_ridge;
1034 }
1035 }
1036 // Reduced K-system on device: Jacobi-preconditioned CG over the dense
1037 // symmetric `S`. The `O(K²)` `S·p` matvec runs device-side; only the
1038 // K-vectors cross the boundary per CG iteration. This is the dominant
1039 // cost of the streaming SAE joint fit at `K = 100K`. Any device-side
1040 // failure (`Unavailable`, non-PD Jacobi diagonal) falls through to the
1041 // CPU `solve_dense_reduced_system`, which then drives the same proximal
1042 // ridge escalation. A genuine device PD failure is non-recoverable for
1043 // this attempt's `schur`, so we let the CPU path re-confirm and escalate.
1044 if gam_gpu::device_runtime::GpuRuntime::resolve(options.gpu_policy)
1045 .map_err(|error| ArrowSchurError::SchurFactorFailed {
1046 reason: format!("GPU runtime resolution failed before reduced solve: {error}"),
1047 })?
1048 .is_some()
1049 {
1050 match crate::gpu_kernels::arrow_schur::solve_reduced_beta_pcg(
1051 &schur,
1052 rhs_beta,
1053 options.trust_region.max_iterations,
1054 options.trust_region.steihaug_relative_tolerance,
1055 ) {
1056 Ok(delta_beta) => return Ok(delta_beta),
1057 Err(crate::gpu_kernels::arrow_schur::ArrowSchurGpuFailure::Unavailable) => {}
1058 Err(_) => {
1059 // Device declined this `schur` (e.g. non-PD Jacobi diag);
1060 // let the CPU path confirm and escalate the proximal ridge.
1061 }
1062 }
1063 }
1064 match solve_dense_reduced_system(&schur, rhs_beta, options, None) {
1065 Ok((delta_beta, _factor, _diag)) => return Ok(delta_beta),
1066 Err(err) => {
1067 let recoverable = matches!(
1068 err,
1069 ArrowSchurError::SchurFactorFailed { .. }
1070 | ArrowSchurError::PcgFailed { .. }
1071 | ArrowSchurError::UnboundedNegativeCurvature { .. }
1072 );
1073 last_err = Some(err);
1074 if !recoverable || attempt == DEFAULT_PROXIMAL_MAX_ATTEMPTS {
1075 break;
1076 }
1077 proximal_ridge = if proximal_ridge == 0.0 {
1078 DEFAULT_PROXIMAL_INITIAL_RIDGE
1079 } else {
1080 proximal_ridge * DEFAULT_PROXIMAL_RIDGE_GROWTH
1081 };
1082 }
1083 }
1084 }
1085 Err(last_err.expect("escalation loop set last_err on failure"))
1086}
1087
1088pub(crate) fn step_inside_trust_region(
1089 step: ArrayView1<'_, f64>,
1090 radius: f64,
1091 metric_weights: Option<&MetricWeights>,
1092) -> bool {
1093 !radius.is_finite() || metric_norm(step, metric_weights) <= radius
1094}
1095
1096/// Below this row count the per-row Schur loop stays sequential: the rayon
1097/// fan-out (chunk dispatch + the deterministic per-chunk length-`K` reduction)
1098/// costs more than it saves for the handful-of-rows arrow systems that dominate
1099/// the non-SAE callers. Above it — the SAE LLM shape (`n` in the thousands,
1100/// wide border `k`) that issue #1017 names — the per-row `H_βt (H_tt)⁻¹ H_tβ x`
1101/// contributions are the matvec's whole cost and parallelize cleanly.
1102pub(crate) const SCHUR_MATVEC_PARALLEL_ROW_MIN: usize = 256;
1103
1104/// Below this border width `k` the dense `H_ββ` penalty-prologue GEMV stays
1105/// sequential: parallelizing a `k×k` matvec only pays once `k²` is large enough
1106/// to dwarf the rayon fan-out, which for the arrow callers with narrow borders
1107/// it never is. At the SAE LLM border (`k` in the low thousands) the `O(k²)`
1108/// prologue is ≈4M flops/CG-iteration and was the serial Amdahl ceiling on the
1109/// otherwise per-row-parallel matvec (#1017), so it crosses this threshold and
1110/// fans out. 512 keeps the prologue serial for every non-SAE arrow system while
1111/// engaging it for the wide SAE/Qwen borders the issue targets.
1112pub(crate) const SCHUR_PROLOGUE_PARALLEL_K_MIN: usize = 512;
1113
1114/// Device-residency CPU analogue for the SAE reduced-Schur matvec (#1017).
1115///
1116/// In the production SAE joint fit the per-row cross-block factors as
1117/// `H_tβ^(i) = L_i P_i`, where `L_i` (`q_i × p`) is the row's local
1118/// assignment/coordinate Jacobian and `P_i` (`p × K`, sparse) gathers the
1119/// active atoms' decoder blocks (`P_i x = Σ_s φ_s · x[base_s .. base_s+p]`).
1120/// The reduced-Schur point-elimination contribution of one row is therefore
1121///
1122/// ```text
1123/// S_i x = H_βt^(i) (H_tt^(i)+ρ_t I)⁻¹ H_tβ^(i) x
1124/// = P_iᵀ · [ L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i ] · P_i x
1125/// = P_iᵀ G_i (P_i x), G_i := L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i (p×p).
1126/// ```
1127///
1128/// The block `G_i = L_iᵀ Y_i` depends only on the assembled per-row blocks and
1129/// the (already-computed, solve-stable) `H_tt` factor — NOT on the CG iterate
1130/// `x`. The generic [`schur_matvec`] re-walks `apply_jbeta → apply_l →
1131/// solve(d×d) → apply_l_t → scatter` on every CG iteration; this object **stages
1132/// the factors `(L_i, Y_i)` once per CG solve** (the "upload X once" residency
1133/// mechanism, applied on CPU to the matvec rather than a dense factorization),
1134/// turning each subsequent matvec into a sparse gather → two `di×p` GEMVs →
1135/// sparse scatter, with no per-iteration triangular solve and no operator-closure
1136/// re-walk. It never materialises the dense `p×p` product: `di ≪ p` for SAE
1137/// rows, so the factored apply is `2·support_i·p + 2·di·p` flops/row — the two
1138/// `di·p` GEMVs PLUS the `support_i·p` sparse gather (`P_i x`) and `support_i·p`
1139/// sparse scatter (`P_iᵀ prod`) — versus the dense `p²` block apply, and
1140/// `O(n·di·p)` memory (vs `O(n·p²)` ≈ 67 GB at the Qwen shape — the dense form
1141/// is OOM). For dense/full active support `support_i` can scale with the active
1142/// β-columns, so the gather/scatter term is NOT negligible and is counted here.
1143///
1144/// Numerically identical to the generic path up to floating-point reassociation
1145/// (it differentiates and accumulates the SAME quotient). It is deterministic
1146/// run-to-run and within the reassociation margin of the serial path, so the
1147/// criterion ranking across topology candidates is stable except for candidates
1148/// separated by less than that f64 margin, where reassociation can flip the
1149/// near-tie winner — it is NOT an exact no-move guarantee (#1211).
1150pub struct SaeResidentReducedSchur {
1151 /// Decoder output dimension `p` (the side length of every `G_i = L_iᵀ Y_i`).
1152 pub(crate) p: usize,
1153 /// Per-row **factored** residency: `(L_i, Y_i)`, each stored row-major as a
1154 /// `di × p` slab (`L_i` = local Jacobian, `Y_i = (H_tt^(i)+ρ_t I)⁻¹ L_i`).
1155 /// The reduced block is `G_i = L_iᵀ Y_i` (`p×p`, symmetric PSD), but it has
1156 /// rank ≤ `di` and `di ≪ p` for SAE rows (the per-row latent dim is 1–2
1157 /// while `p` is the decoder block width, ~2048). Materialising the dense
1158 /// `p×p` block would cost `O(n·p²)` memory (≈67 GB at the Qwen shape) and
1159 /// `p²` flops per matvec/row; the factored form costs `O(n·di·p)` memory and
1160 /// `2·support_i·p + 2·di·p` flops/row, applying `G_i v = L_iᵀ (Y_i v)`
1161 /// (sparse gather over `support_i` atoms → `di`-length GEMV → `p`-length
1162 /// GEMV → sparse scatter over `support_i` atoms). The `2·support_i·p`
1163 /// gather/scatter term is part of the per-row cost — for dense/full support
1164 /// `support_i` scales with active β-columns — and is not dropped. A row with
1165 /// empty active support / degenerate dims gets `di = 0` and is skipped.
1166 /// `(di, L_i, Y_i)` per row; `L_i`/`Y_i` are `di·p`-length row-major buffers.
1167 pub(crate) rows: Vec<ResidentRowFactor>,
1168 /// Per-row active atom support `(β-block base index, φ weight)`, shared with
1169 /// the assembler's [`DeviceSaePcgData`] (no re-clone of the index lists).
1170 pub(crate) a_phi: Arc<[Vec<(usize, f64)>]>,
1171 /// #1033: per-row local Jacobian `L_i` (row-major `di × p`), SHARED via `Arc`
1172 /// with the assembler's [`DeviceSaePcgData`] rather than copied into each
1173 /// `ResidentRowFactor`. The staged factor previously held its own verbatim
1174 /// row-major copy of `data.local_jac[row]` — a second full `O(n·di·p)` slab
1175 /// for zero benefit (the bytes and the `di × p` layout are identical). The
1176 /// matvec now reads `L_i = &self.local_jac[row]` directly; only the SOLVED
1177 /// factor `Y_i = (H_tt+ρI)⁻¹ L_i` (genuinely new data) stays per-row. Reads
1178 /// are byte-for-byte the former `rf.l` (same slab, same `r·p + c` indexing),
1179 /// so the matvec/preconditioner output is bit-identical.
1180 pub(crate) local_jac: Arc<[Vec<f64>]>,
1181}
1182
1183/// Factored per-row residency block: `G_i = L_iᵀ Y_i` kept as its `di×p` factors
1184/// so the matvec never materialises the dense `p×p` product. The local Jacobian
1185/// factor `L_i` is NOT stored here — it is shared via
1186/// [`SaeResidentReducedSchur::local_jac`] (`&local_jac[row]`); only the solved
1187/// `Y_i` is per-row. See [`SaeResidentReducedSchur`].
1188pub(crate) struct ResidentRowFactor {
1189 /// Row latent dimension `di` (the inner contraction width). `0` ⇒ skipped.
1190 pub(crate) di: usize,
1191 /// `Y_i = (H_tt^(i)+ρ_t I)⁻¹ L_i` row-major `di × p`. Empty when `di == 0`.
1192 pub(crate) y: Vec<f64>,
1193}
1194
1195impl SaeResidentReducedSchur {
1196 /// Stage the per-row `G_i = L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i` blocks once, from
1197 /// the SAE structure (`DeviceSaePcgData`: `p`, per-row `a_phi`, per-row
1198 /// row-major `local_jac` = `L_i`) and the already-factored `H_tt` slab.
1199 ///
1200 /// Returns `None` when the structure does not match (degenerate `p`, row
1201 /// count mismatch) so the caller falls back to the generic matvec. Row
1202 /// builds are independent and run under the same deterministic rayon
1203 /// discipline as the matvec (each `G_i` is self-contained — no cross-row
1204 /// reduction — so there is no ordering subtlety).
1205 /// `ridge_t` is NOT a parameter: it is already folded into the factored
1206 /// blocks `htt_factors` carry (they factor `H_tt^(i) + ridge_t·I` — see
1207 /// `factor_blocks`), so solving against the factor yields `(H_tt^(i)+ρ_t I)⁻¹`
1208 /// exactly. The residency block is a pure function of the factor and `L_i`.
1209 pub(crate) fn build<B: BatchedBlockSolver + Sync>(
1210 sys: &ArrowSchurSystem,
1211 htt_factors: &ArrowFactorSlab,
1212 backend: &B,
1213 ) -> Option<Self> {
1214 let data = sys.device_sae_pcg.as_ref()?;
1215 let p = data.p;
1216 let n = sys.rows.len();
1217 if p == 0
1218 || sys.htbeta_dense_supplement
1219 || data.a_phi.len() != n
1220 || data.local_jac.len() != n
1221 {
1222 return None;
1223 }
1224 let empty = || ResidentRowFactor {
1225 di: 0,
1226 y: Vec::new(),
1227 };
1228 let build_row = |row: usize| -> ResidentRowFactor {
1229 let di = sys.row_dims[row];
1230 let jac = &data.local_jac[row];
1231 // q_i = len/p; must match the row's latent dimension di.
1232 if p == 0 || jac.len() != di * p || di == 0 {
1233 return empty();
1234 }
1235 // L_i as a (di × p) matrix (row-major in `local_jac`).
1236 let l_i = match ArrayView2::from_shape((di, p), jac.as_slice()) {
1237 Ok(v) => v.to_owned(),
1238 Err(_) => return empty(),
1239 };
1240 // Solve (H_tt+ρ_t I) Y = L_i for Y (di × p): one batched back-solve
1241 // over the p columns against the cached factor. Stage `(L_i, Y_i)`
1242 // — NOT the dense `p×p` product `G_i = L_iᵀ Y_i` — so storage and the
1243 // matvec stay `O(di·p)` instead of `O(p²)` (`di ≪ p` for SAE rows).
1244 let y = backend.solve_block_matrix(htt_factors.factor(row), l_i.view());
1245 // Flatten the SOLVED factor to a `di × p` row-major buffer (iteration
1246 // over a standard-layout view is row-major regardless of the source
1247 // strides, so the hot loop can index `r*p + c` directly). `L_i` is NOT
1248 // copied — the matvec reads it from the shared `local_jac` slab (it is
1249 // byte-for-byte `data.local_jac[row]`).
1250 let y_flat: Vec<f64> = y.iter().copied().collect();
1251 ResidentRowFactor { di, y: y_flat }
1252 };
1253 let rows: Vec<ResidentRowFactor> =
1254 if n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
1255 use rayon::prelude::*;
1256 (0..n).into_par_iter().map(build_row).collect()
1257 } else {
1258 (0..n).map(build_row).collect()
1259 };
1260 Some(Self {
1261 p,
1262 rows,
1263 a_phi: data.a_phi_shared(),
1264 local_jac: data.local_jac_shared(),
1265 })
1266 }
1267
1268 /// Accumulate one row's `S_i x = P_iᵀ G_i (P_i x) = P_iᵀ L_iᵀ Y_i (P_i x)`
1269 /// into `acc` (length `K`). `gather`/`prod` are caller-owned length-`p`
1270 /// buffers and `w` a caller-owned `≥ max_i di`-length buffer, all reused
1271 /// across rows to keep the hot loop allocation-free. The matvec applies the
1272 /// factored block in four steps: sparse gather `P_i x = Σ_s φ_s·x[base_s..]`
1273 /// (`support_i·p` flops), `w = Y_i·(P_i x)` (`di`-length, `di·p` flops),
1274 /// `prod = L_iᵀ·w` (`p`-length, `di·p` flops), and sparse scatter
1275 /// `acc += P_iᵀ prod` (`support_i·p` flops) — `2·support_i·p + 2·di·p`
1276 /// total, never the dense `p²` product. The gather/scatter `2·support_i·p`
1277 /// term is counted: it is not dominated by the GEMVs when the active support
1278 /// is wide.
1279 #[inline]
1280 pub(crate) fn row_into(
1281 &self,
1282 row: usize,
1283 x: &Array1<f64>,
1284 acc: &mut Array1<f64>,
1285 gather: &mut [f64],
1286 prod: &mut [f64],
1287 w: &mut [f64],
1288 ) {
1289 let rf = &self.rows[row];
1290 let di = rf.di;
1291 if di == 0 {
1292 return;
1293 }
1294 let p = self.p;
1295 let support = &self.a_phi[row];
1296 if support.is_empty() {
1297 return;
1298 }
1299 // Slice `x`/`acc` ONCE so the per-support gather/scatter (the dominant
1300 // `support·p` terms for wide active support) run over contiguous `f64`
1301 // slices — the compiler can prove unit stride and emit vectorized FMA,
1302 // where the former `x[base+j]`/`acc[base+j]` ndarray element indexing
1303 // forced a per-element strided lookup + bounds check that blocked
1304 // autovectorization. Every accumulation order is unchanged, so the
1305 // result is bit-identical to the ndarray-indexed form.
1306 let x_slice = x.as_slice().expect("resident matvec x must be contiguous");
1307 // P_i x = Σ_s φ_s · x[base_s .. base_s+p] (length p).
1308 let gather = &mut gather[..p];
1309 for v in gather.iter_mut() {
1310 *v = 0.0;
1311 }
1312 for &(base, phi) in support {
1313 if phi == 0.0 {
1314 continue;
1315 }
1316 let xrow = &x_slice[base..base + p];
1317 for (g, &xv) in gather.iter_mut().zip(xrow) {
1318 *g += phi * xv;
1319 }
1320 }
1321 // w = Y_i · (P_i x) (di × p GEMV → length di). Y_i row-major di×p.
1322 for r in 0..di {
1323 let yrow = &rf.y[r * p..r * p + p];
1324 let mut s = 0.0_f64;
1325 for (&yv, &gv) in yrow.iter().zip(gather.iter()) {
1326 s += yv * gv;
1327 }
1328 w[r] = s;
1329 }
1330 // prod = L_iᵀ · w (p × di GEMV → length p). L_i row-major di×p, so
1331 // L_iᵀ[j,r] = L_i[r,j]; accumulate column-by-column over the di rows.
1332 // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte the
1333 // former per-row `rf.l` copy.
1334 let l_i = &self.local_jac[row];
1335 let prod = &mut prod[..p];
1336 for v in prod.iter_mut() {
1337 *v = 0.0;
1338 }
1339 for r in 0..di {
1340 let lrow = &l_i[r * p..r * p + p];
1341 let wr = w[r];
1342 for (pj, &lj) in prod.iter_mut().zip(lrow) {
1343 *pj += lj * wr;
1344 }
1345 }
1346 // acc += P_iᵀ prod = scatter φ_s · prod into base_s blocks.
1347 let acc_slice = acc
1348 .as_slice_mut()
1349 .expect("resident matvec acc must be contiguous");
1350 for &(base, phi) in support {
1351 if phi == 0.0 {
1352 continue;
1353 }
1354 let arow = &mut acc_slice[base..base + p];
1355 for (a, &pv) in arow.iter_mut().zip(prod.iter()) {
1356 *a += phi * pv;
1357 }
1358 }
1359 }
1360
1361 /// Max row latent dim `di` across resident rows — the size of the `w`
1362 /// scratch the matvec needs for the inner `Y_i·(P_i x)` GEMV.
1363 pub(crate) fn max_di(&self) -> usize {
1364 self.rows.iter().map(|r| r.di).max().unwrap_or(0)
1365 }
1366}
1367
1368/// Reduced-Schur matvec `out = S·x` with an optional pre-staged SAE residency
1369/// operator. When `resident` is `Some`, the per-row point-elimination term is
1370/// applied through the resident `p×p` blocks (#1017 CPU residency); otherwise it
1371/// falls back to the generic per-row `apply → solve → transpose` path. Both
1372/// routes accumulate the SAME reduced operator
1373/// `S = H_ββ + ρ_β I − Σ_i H_βt^(i)(H_tt^(i))⁻¹H_tβ^(i)`.
1374pub(crate) fn schur_matvec<B: BatchedBlockSolver + Sync>(
1375 sys: &ArrowSchurSystem,
1376 htt_factors: &ArrowFactorSlab,
1377 ridge_beta: f64,
1378 x: &Array1<f64>,
1379 out: &mut Array1<f64>,
1380 backend: &B,
1381 resident: Option<&SaeResidentReducedSchur>,
1382) {
1383 // `steihaug_cg` reuses one output buffer across iterations and requires
1384 // `matvec` to ASSIGN every entry of `out` (the contract `dense_matvec`
1385 // upholds). This routine builds `S·x` purely by accumulation
1386 // (`penalty_matvec_add`, `out[a] += ridge·x`, `out[a] -= neg_contrib`), so it
1387 // MUST clear `out` first. Without this, iteration n>0 returns `S·x` plus the
1388 // previous call's `S·p`, the PCG solves a corrupted reduced system, and the
1389 // resulting Newton step is inconsistent with the assembled gradient
1390 // (g·δ ≈ 0 — a non-descent direction that defeats the line search).
1391 out.fill(0.0);
1392 let k = sys.k;
1393 // Top-level (not nested in a rayon worker) and big enough to amortize the
1394 // fan-out: the single gate that authorizes BOTH the dense penalty-prologue
1395 // GEMV and the per-row point-elimination loop to go parallel. The topology
1396 // race fans candidates with `run_topology_race_parallel`, so inside a worker
1397 // both stay sequential (no nested-rayon oversubscription).
1398 let parallel =
1399 sys.rows.len() >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
1400 // Route the penalty-side (H_ββ + ridge·I) x product through the prologue:
1401 // no Arc-clone hot-path cost when penalty_op is None (falls back to hbb
1402 // inline); the dense fallback fans across cores at the wide SAE border (#1017).
1403 {
1404 let x_slice = x.as_slice().expect("x must be contiguous");
1405 let out_slice = out.as_slice_mut().expect("out must be contiguous");
1406 sys.penalty_ridge_prologue_into(x_slice, ridge_beta, out_slice, parallel);
1407 }
1408 // The reduced-Schur point-elimination term: `out -= Σ_i H_βt^(i) (H_tt^(i))⁻¹
1409 // H_tβ^(i) x`. Each row contributes an independent length-`K` vector, so for
1410 // the SAE LLM shape (#1017) this is the matvec's whole cost and is
1411 // embarrassingly parallel — reduced below through the deterministic pairwise
1412 // tree (see the block-fold comment) rather than a chunk-order fold.
1413 let p = resident.map(|r| r.p).unwrap_or(0);
1414 // #2228 determinism: the per-row length-`k` contributions
1415 // (`Σ_i H_βt^(i)(H_tt^(i))⁻¹ H_tβ^(i) x`) are reduced through the length-only
1416 // pairwise tree so the result is bit-identical across thread count AND to the
1417 // sequential fold — parallel and nested-serial evaluation agree to the last
1418 // bit, removing the #1017/#1211 chunk-reassociation margin that let the
1419 // criterion ranking depend on the driver. The tree self-serializes below
1420 // `BASE_CHUNK` rows (a base block is folded directly with no `rayon::join`),
1421 // so small systems and nested topology-race calls stay single-threaded
1422 // without a separate branch that could associate the round-off differently.
1423 // The resident path gathers → factored `di×p` GEMVs → scatter; the direct
1424 // path does a per-row block solve — both ADD their row's contribution into a
1425 // block-local accumulator, so splitting the row sum across the tree is exact.
1426 let n_rows = sys.rows.len();
1427 let contribution = gam_linalg::pairwise_reduce::par_deterministic_block_fold(
1428 n_rows,
1429 |range: core::ops::Range<usize>| {
1430 let mut acc = Array1::<f64>::zeros(k);
1431 if let Some(res) = resident {
1432 let mut gather = vec![0.0_f64; p];
1433 let mut prod = vec![0.0_f64; p];
1434 let mut w = vec![0.0_f64; res.max_di()];
1435 for i in range {
1436 res.row_into(i, x, &mut acc, &mut gather, &mut prod, &mut w);
1437 }
1438 } else {
1439 let mut local = Array1::<f64>::zeros(sys.d);
1440 for i in range {
1441 schur_matvec_row_into(sys, htt_factors, x, backend, i, &mut local, &mut acc);
1442 }
1443 }
1444 acc
1445 },
1446 |mut a: Array1<f64>, b: Array1<f64>| {
1447 a += &b;
1448 a
1449 },
1450 );
1451 if let Some(acc) = contribution {
1452 for a in 0..k {
1453 out[a] -= acc[a];
1454 }
1455 }
1456}
1457
1458/// #1017: the reduced-Schur operator `v ↦ S·v` staged ONCE per criterion
1459/// evaluation and reused across EVERY shifted / warm-started solve of the
1460/// rational-logdet (and SLQ) ladder — the widened-lifetime residency the #1017
1461/// device design calls for.
1462///
1463/// The rational-logdet criterion (`matrix_free_arrow_evidence_log_det_surrogate`)
1464/// walks SEVERAL shift ladders inside ONE evaluation: the `λ_max` power iteration
1465/// ([`reduced_schur_lambda_max`]), the pilot / deflation-derived plan build
1466/// ([`rational_reduced_schur_plan_derived`]), the value [`RationalLogdetPlan::
1467/// evaluate`], and the `(probes, S⁻¹·probes)` gradient bundle
1468/// ([`reduced_schur_inverse_probe_solves`]). Each formerly re-captured its own
1469/// inline `schur_matvec` closure over `(sys, htt_factors, ρ_β, backend,
1470/// resident)`. On CPU those captures are free; on the device lane they are the
1471/// per-solve FLATTEN — every ladder would re-marshal and re-upload the
1472/// ridge-independent operands (the factored `H_tt` slab, the framed `G ⊗ W`, the
1473/// dense per-row cross blocks) that are INVARIANT across the whole evaluation.
1474///
1475/// This object is the single operator every ladder borrows: the invariant state
1476/// (`sys`, the factored `H_tt` slab, the `ρ_β` border, the pre-staged CPU
1477/// [`SaeResidentReducedSchur`] frame, and — when engaged — a device-resident
1478/// [`GpuSchurMatvec`] whose per-row factors upload ONCE) lives for the whole
1479/// evaluation, so a shifted solve reuses the resident operator instead of
1480/// re-staging it. Every `apply` accumulates the SAME reduced operator
1481/// `S = (H_ββ + ρ_β I) − Σ_i H_βt^(i)(H_tt^(i)+ρ_t I)⁻¹H_tβ^(i)` regardless of
1482/// lane. With `gpu_matvec == None` (every current construction) the result is
1483/// byte-for-byte the pre-context inline `schur_matvec` closure; the `gpu_matvec`
1484/// seam is where a device operator, built once per evaluation, is threaded through
1485/// the ladder (the reported #1017 next increment).
1486pub(crate) struct ReducedSchurOperator<'a, B: BatchedBlockSolver + Sync> {
1487 sys: &'a ArrowSchurSystem,
1488 htt_factors: &'a ArrowFactorSlab,
1489 ridge_beta: f64,
1490 backend: &'a B,
1491 resident: Option<&'a SaeResidentReducedSchur>,
1492 gpu_matvec: Option<&'a GpuSchurMatvec>,
1493}
1494
1495impl<'a, B: BatchedBlockSolver + Sync> ReducedSchurOperator<'a, B> {
1496 /// The CPU/host operator — the byte-identical default. Every shifted solve in
1497 /// the evaluation reuses the same pre-staged `resident` frame (or the generic
1498 /// per-row `apply → solve → transpose` when `resident` is `None`).
1499 pub(crate) fn new(
1500 sys: &'a ArrowSchurSystem,
1501 htt_factors: &'a ArrowFactorSlab,
1502 ridge_beta: f64,
1503 backend: &'a B,
1504 resident: Option<&'a SaeResidentReducedSchur>,
1505 ) -> Self {
1506 Self {
1507 sys,
1508 htt_factors,
1509 ridge_beta,
1510 backend,
1511 resident,
1512 gpu_matvec: None,
1513 }
1514 }
1515
1516 /// Attach a device-resident [`GpuSchurMatvec`] (built ONCE per evaluation) so
1517 /// the whole ladder applies `S·v` on device without a per-solve re-upload.
1518 /// #1017 next increment: the caller that owns the device operand upload builds
1519 /// the operator once and calls this; until then every construction is CPU
1520 /// (`gpu_matvec == None`), so the lane stays byte-identical.
1521 pub(crate) fn with_gpu_matvec(mut self, gpu_matvec: Option<&'a GpuSchurMatvec>) -> Self {
1522 self.gpu_matvec = gpu_matvec;
1523 self
1524 }
1525
1526 /// `out = S·x`. Both lanes CLEAR and fully assign `out`, so a fresh zeroed
1527 /// buffer per apply is correct (and the shift-ladder CG contract is upheld).
1528 #[inline]
1529 pub(crate) fn apply_into(&self, x: &Array1<f64>, out: &mut Array1<f64>) {
1530 let Some(quotient) = self.sys.beta_gauge_quotient.as_ref() else {
1531 if let Some(gpu) = self.gpu_matvec {
1532 gpu(x, out);
1533 } else {
1534 schur_matvec(
1535 self.sys,
1536 self.htt_factors,
1537 self.ridge_beta,
1538 x,
1539 out,
1540 self.backend,
1541 self.resident,
1542 );
1543 }
1544 return;
1545 };
1546
1547 // Evidence operator on the quotient: `P S P + Q Q^T`. Apply the
1548 // original reduced Schur only to `P x`, project its result once more,
1549 // then add the unit Faddeev--Popov pin. The same arithmetic is used by
1550 // dense `pin_reduced_schur`, so SLQ/rational-logdet values and dense
1551 // Cholesky values represent the identical operator.
1552 let projected_x = quotient.project_complement(x.view());
1553 if let Some(gpu) = self.gpu_matvec {
1554 gpu(&projected_x, out);
1555 } else {
1556 schur_matvec(
1557 self.sys,
1558 self.htt_factors,
1559 self.ridge_beta,
1560 &projected_x,
1561 out,
1562 self.backend,
1563 self.resident,
1564 );
1565 }
1566 let mut projected_out = quotient.project_complement(out.view());
1567 for direction in quotient.directions.iter() {
1568 projected_out.scaled_add(direction.dot(x), direction);
1569 }
1570 out.assign(&projected_out);
1571 }
1572
1573 /// `S·v` into a fresh length-`k` vector — the shift-ladder matvec-closure form
1574 /// (`|v: ArrayView1| op.apply(v)`). Byte-for-byte the inline
1575 /// `let x = v.to_owned(); schur_matvec(…, &x, &mut zeros(k), …)` it replaces.
1576 #[inline]
1577 pub(crate) fn apply(&self, v: ArrayView1<f64>) -> Array1<f64> {
1578 let x = v.to_owned();
1579 let mut out = Array1::<f64>::zeros(self.sys.k);
1580 self.apply_into(&x, &mut out);
1581 out
1582 }
1583
1584 /// `S·x` into a fresh vector from an already-owned `&Array1` (no redundant copy
1585 /// of a vector the caller already owns) — the power-iteration / CG-solve form.
1586 #[inline]
1587 pub(crate) fn apply_owned(&self, x: &Array1<f64>) -> Array1<f64> {
1588 let mut out = Array1::<f64>::zeros(self.sys.k);
1589 self.apply_into(x, &mut out);
1590 out
1591 }
1592}
1593
1594/// Matrix-free reduced-Schur log-determinant `log|S|` via Stochastic Lanczos
1595/// Quadrature on the exact `schur_matvec` apply `v ↦ S·v`, where
1596/// `S = (H_ββ + ρ_β I) − Σ_i H_βt^(i)(H_tt^(i)+ρ_t I)⁻¹H_tβ^(i)` is the SPD
1597/// reduced Schur. **The dense `k×k` `S` is NEVER formed.**
1598///
1599/// This is the memory-matrix-free evidence path for the massive-K manifold SAE.
1600/// The dense evidence routes assemble `S` explicitly (`O(k²)` ≈ 8 GB at the
1601/// K=32k border) and Cholesky-factor it (`O(k³/3)`) purely to read `Σ 2·log Lᵢᵢ`;
1602/// that dense assembly + factor is the massive-K wall (both dense evidence
1603/// routes REFUSE above the in-core budget). Here peak memory is `O(k)` — the SLQ
1604/// Rademacher probe and Lanczos basis vectors — and the cost is
1605/// `O(num_probes·lanczos_steps · matvec)`, each matvec the same `O(n·d·k)`
1606/// reduced-Schur apply the PCG hot loop already runs. Deterministic for a fixed
1607/// `(sys, htt_factors, ρ_β, resident, num_probes, lanczos_steps, seed)` so the
1608/// REML evidence outer loop stays reproducible.
1609///
1610/// `htt_factors` are the per-row `(H_tt^(i)+ρ_t I)` Cholesky factors; `resident`
1611/// is the optional pre-staged SAE residency operator (`None` for the framed /
1612/// closure `H_tβ` path). SLQ is an ESTIMATE — the same accuracy contract the
1613/// device seam already accepts for `k ≥ SCHUR_SLQ_LOGDET_MIN_DIM`; callers that
1614/// need the exact dense log-det at small `k` must stay on the dense route.
1615///
1616/// Crate-internal because the `resident` parameter carries the `pub(crate)`
1617/// [`SaeResidentReducedSchur`] operator; cross-crate callers use the
1618/// [`matrix_free_arrow_evidence_log_det`] convenience, which stages residency
1619/// internally and exposes no crate-private type.
1620pub(crate) fn slq_reduced_schur_log_det<B: BatchedBlockSolver + Sync>(
1621 sys: &ArrowSchurSystem,
1622 htt_factors: &ArrowFactorSlab,
1623 ridge_beta: f64,
1624 backend: &B,
1625 resident: Option<&SaeResidentReducedSchur>,
1626 gpu_matvec: Option<&GpuSchurMatvec>,
1627 evidence_policy: ArrowEvidencePolicy,
1628 num_probes: usize,
1629 lanczos_steps: usize,
1630 seed: u64,
1631) -> SlqLogDet {
1632 let k = sys.k;
1633 // Stage the reduced-Schur operator ONCE; every probe/Lanczos apply reuses the
1634 // pre-staged residency (no per-apply operator re-capture). The probes fan
1635 // across rayon workers (in `slq_logdet`), and `schur_matvec`'s own row
1636 // parallelism is guarded off inside a worker, so there is no nested
1637 // oversubscription. When `gpu_matvec` is `Some` (the #1017 Phase-3 device
1638 // seam, built once for the whole evidence evaluation), EVERY Rademacher-probe
1639 // Lanczos apply runs through the single resident device `S·v`; when `None`
1640 // the byte-identical CPU `schur_matvec` lane is taken.
1641 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
1642 .with_gpu_matvec(gpu_matvec);
1643 // The evidence log|S| must obey the SAME conditioning convention as the dense
1644 // reduced-Schur factor (#2308). Under `UnitDeflation` a collapsed / near-null
1645 // decoder direction is pinned to unit stiffness (`ln 1 = 0`), so the SLQ
1646 // estimate uses the unit-deflated spectral function `φ(θ)=θ≥floor ? ln θ : 0`
1647 // instead of the plain `ln` (which would floor a sub-null Ritz value to
1648 // `RITZ_LN_FLOOR`, contributing `≈ −690` per collapsed direction and a
1649 // ρ-dependent Occam reward). `Strict` / `PositiveDefinite` keep the plain SPD
1650 // estimator — they never form an undamped evidence with nulls.
1651 match evidence_policy {
1652 ArrowEvidencePolicy::UnitDeflation { relative_floor } => slq_logdet_unit_deflated(
1653 k,
1654 |v| op.apply(v),
1655 num_probes,
1656 lanczos_steps,
1657 seed,
1658 relative_floor,
1659 )
1660 .as_logdet(),
1661 ArrowEvidencePolicy::Strict | ArrowEvidencePolicy::PositiveDefinite => {
1662 slq_logdet(k, |v| op.apply(v), num_probes, lanczos_steps, seed)
1663 }
1664 }
1665}
1666
1667/// One-call matrix-free arrow evidence log-determinant for an assembled system.
1668///
1669/// Factors the per-row `H_tt^(i)+ρ_t I` blocks (accumulating
1670/// `log_det_tt = Σ_i Σ_axis 2·log Lᵢᵢ` from the Cholesky diagonals — the cheap
1671/// `O(n·d³)` t-tier term), stages the SAE residency operator when the system
1672/// carries `device_sae_pcg` full-`B` data, and estimates `log|S|` via
1673/// [`slq_reduced_schur_log_det`] with NO dense `k×k` Schur formed at any point.
1674///
1675/// Returns `(log_det_tt, log|S| SLQ estimate)`; the undamped joint evidence
1676/// log-det the Laplace normaliser needs is their sum. Uses the identical
1677/// [`factor_blocks_for_system`] the dense Direct evidence path uses (same gauge
1678/// deflation), so `log_det_tt` matches the dense convention exactly and only the
1679/// `k×k` Schur term is replaced by its matrix-free SLQ estimate.
1680pub fn matrix_free_arrow_evidence_log_det(
1681 sys: &ArrowSchurSystem,
1682 ridge_t: f64,
1683 ridge_beta: f64,
1684 options: &ArrowSolveOptions,
1685 num_probes: usize,
1686 lanczos_steps: usize,
1687 seed: u64,
1688) -> Result<(f64, SlqLogDet), ArrowSchurError> {
1689 let backend = CpuBatchedBlockSolver;
1690 let factorization = factor_blocks_for_system(
1691 sys,
1692 ridge_t,
1693 options.evidence_policy.factors_undamped_evidence(),
1694 &backend,
1695 options.gpu_policy,
1696 )?;
1697 let htt_factors = factorization.factors;
1698 let mut log_det_tt = 0.0_f64;
1699 for row in 0..htt_factors.len() {
1700 let factor = htt_factors.factor(row);
1701 for axis in 0..factor.nrows() {
1702 log_det_tt += 2.0 * factor[[axis, axis]].ln();
1703 }
1704 }
1705 // #1017 Phase-3: build the reduced-Schur device `S·v` ONCE for the whole SLQ
1706 // evaluation. Every Rademacher-probe Lanczos apply then rides that single
1707 // resident operator (uploaded/pre-factored once) instead of re-capturing the
1708 // CPU `schur_matvec` per apply. The device operator carries its own residency,
1709 // so the CPU `SaeResidentReducedSchur` frame is only staged on the CPU lane.
1710 let device_matvec = maybe_build_evidence_gpu_matvec(
1711 sys,
1712 ridge_t,
1713 ridge_beta,
1714 options,
1715 num_probes.saturating_mul(lanczos_steps),
1716 )?;
1717 let gpu_matvec: Option<&GpuSchurMatvec> =
1718 options.gpu_matvec.as_ref().or(device_matvec.as_ref());
1719 let resident = if gpu_matvec.is_none() {
1720 SaeResidentReducedSchur::build(sys, &htt_factors, &backend)
1721 } else {
1722 None
1723 };
1724 let slq = slq_reduced_schur_log_det(
1725 sys,
1726 &htt_factors,
1727 ridge_beta,
1728 &backend,
1729 resident.as_ref(),
1730 gpu_matvec,
1731 options.evidence_policy,
1732 num_probes,
1733 lanczos_steps,
1734 seed,
1735 );
1736 Ok((log_det_tt, slq))
1737}
1738
1739/// #1017 Phase-3: build the reduced-Schur device matvec ONCE for a matrix-free
1740/// evidence log-det evaluation, so the whole rational-logdet + SLQ ladder applies
1741/// `S·v` through a single device-resident operator (uploaded / pre-factored once)
1742/// rather than re-capturing the CPU `schur_matvec` per probe / shifted solve. The
1743/// PCG numerics are identical whether the matvec runs on host or device (same
1744/// reduced Schur operator, same f64 accumulation), so engaging it changes only
1745/// where the `Σ_i H_βt(H_tt)⁻¹H_tβ` flops execute.
1746///
1747/// Same admission contract as the PCG matvec offload ([`maybe_inject_gpu_schur_matvec`]):
1748/// declines (returns `None`, so every apply stays on the byte-identical CPU lane)
1749/// when cross-row penalties or streaming are present, the work predicate rejects
1750/// the shape, or no live device is present. `apply_budget` is the amortising apply
1751/// count for the shape predicate — the reduced-Schur matvec is `O(n·d·k)` per
1752/// apply and the evidence ladder runs that apply across every probe / Lanczos /
1753/// shifted-CG step, so a large budget is the honest amortisation the offload
1754/// break-even is measured against.
1755pub(crate) fn maybe_build_evidence_gpu_matvec(
1756 sys: &ArrowSchurSystem,
1757 ridge_t: f64,
1758 ridge_beta: f64,
1759 options: &ArrowSolveOptions,
1760 apply_budget: usize,
1761) -> Result<Option<GpuSchurMatvec>, ArrowSchurError> {
1762 // A caller-supplied operator (threaded through `options.gpu_matvec`) already
1763 // owns its residency; the caller passes it directly, so never double-build.
1764 if options.gpu_matvec.is_some() {
1765 return Ok(None);
1766 }
1767 if !sys.cross_row_penalties.is_empty() || options.streaming_chunk_size.is_some() {
1768 return Ok(None);
1769 }
1770 // Size gate BEFORE the device probe (startup-tax ordering): the predicate
1771 // reads only associated constants, so a shape it rejects skips
1772 // runtime availability resolution (whose first call creates a CUDA primary context on
1773 // every GPU); an admitted shape probes exactly as the PCG seam does.
1774 if !gam_gpu::GpuDispatchPolicy::default().reduced_schur_matvec_should_offload(
1775 sys.rows.len(),
1776 sys.k,
1777 sys.d,
1778 apply_budget.max(1),
1779 ) {
1780 return Ok(None);
1781 }
1782 if gam_gpu::device_runtime::GpuRuntime::resolve(options.gpu_policy)
1783 .map_err(|error| ArrowSchurError::SchurFactorFailed {
1784 reason: format!("evidence GPU runtime resolution failed: {error}"),
1785 })?
1786 .is_none()
1787 {
1788 return Ok(None);
1789 }
1790 // #1017: framed matrix-free system with resident device operands — prefer the
1791 // device-resident DETERMINISTIC reduced-Schur apply (upload operands once,
1792 // cross only x/out per apply, atomics-free so the SLQ log|S| determinism
1793 // contract holds) over the CPU row-procedural closure `gpu_schur_matvec_backend`
1794 // returns for `htbeta_matvec` systems. Declines (no device / shape / non-PD at
1795 // this ridge) fall through to the backend/CPU path. Non-Linux/CPU: this always
1796 // returns `None` (no `device_sae_pcg`), so the lane is byte-identical.
1797 if sys.device_sae_pcg.is_some() {
1798 if let Some(matvec) =
1799 crate::gpu_kernels::arrow_schur::build_framed_resident_evidence_matvec(
1800 sys,
1801 ridge_t,
1802 ridge_beta,
1803 apply_budget.max(1),
1804 )
1805 .map_err(|failure| {
1806 device_failure_as_arrow_error("resident evidence matvec build", failure)
1807 })?
1808 {
1809 return Ok(Some(matvec));
1810 }
1811 }
1812 crate::gpu_kernels::arrow_schur::gpu_schur_matvec_backend(sys, ridge_t, ridge_beta)
1813 .map(Some)
1814 .map_err(|failure| device_failure_as_arrow_error("evidence matvec build", failure))
1815}
1816
1817/// Fixed configuration for the #2080 rational-surrogate evidence lane: the probe
1818/// count, seeds, quadrature/CG tolerances, and derived-rank deflation budget the
1819/// [`SurrogateLaneState`] plan is (re)built with. The caller (the SAE streaming
1820/// criterion) supplies these once; `deflation_target_std_err_rel` is the derived
1821/// bar `0.1 · STALL_REL_TOL` (see [`rational_reduced_schur_plan_derived`]).
1822#[derive(Clone)]
1823pub struct SurrogateLaneConfig {
1824 pub num_probes: usize,
1825 pub seed: u64,
1826 pub rel_tol: f64,
1827 pub power_iters: usize,
1828 pub cg_rel_tol: f64,
1829 pub cg_max_iters: usize,
1830 pub deflation_max_rank: usize,
1831 pub deflation_subspace_iters: usize,
1832 pub deflation_target_std_err_rel: f64,
1833}
1834
1835/// Per-outer-solve state for the #2080 rational-surrogate evidence lane. Holds
1836/// the FROZEN derived-rank plan — probes, bracket-centred quadrature, and Hutch++
1837/// `Q`, all fixed once at the entry ρ so value and gradient stay a single
1838/// functional across the ρ sweep — plus the config to (re)build it when the
1839/// reduced-Schur dimension changes (a basin mutation between outer solves).
1840/// Threaded as `Option<&mut _>` through the streaming criterion; `None` keeps the
1841/// bit-identical SLQ path.
1842pub struct SurrogateLaneState {
1843 plan: Option<RationalLogdetPlan>,
1844 cfg: SurrogateLaneConfig,
1845 /// When set, the next matrix-free evidence eval also computes the shared
1846 /// `(probes, S⁻¹·probes)` bundle for EFS/MacKay proposal traces and stashes
1847 /// it in `inverse_probes`. It is never an outer gradient artifact: the fixed
1848 /// rational value's derivative is `logdet_derivative_bundle` below.
1849 request_inverse_probes: bool,
1850 /// The last-computed shared bundle: the FROZEN plan's probes `v_j` and their
1851 /// `S⁻¹ v_j` (t = 0) solves at the current operator. One bundle drives every
1852 /// selected-inverse trace `tr(S⁻¹·M) ≈ (1/m)Σ_j (S⁻¹v_j)ᵀ(M v_j)` off the
1853 /// same frozen raw probes as the value plan. This is useful for EFS trace
1854 /// proposals but is not the derivative of the shifted rational value.
1855 inverse_probes: Option<(Vec<Array1<f64>>, Vec<Array1<f64>>)>,
1856 /// Request/stash the lossless weighted derivative representation emitted by
1857 /// the next rational value evaluation. Unlike `inverse_probes`, this is the
1858 /// derivative of the fixed rational surrogate itself (all shifted solves and
1859 /// frozen-Q columns), and is the only bundle admissible for its outer
1860 /// gradient.
1861 request_logdet_derivative_bundle: bool,
1862 logdet_derivative_bundle: Option<RationalLogdetDerivativeBundle>,
1863 /// The previous ρ's `S⁻¹ v_j` solves, kept as the CG warm-start for the next
1864 /// bundle solve. `S⁻¹` is smooth in ρ, so a neighbouring-ρ solution is a near
1865 /// seed (common-random-numbers reuse — the discipline that makes the
1866 /// surrogate's shifted ladder cheap); the converged solve is unchanged to
1867 /// `cg_rel_tol`, only its iteration count drops. Cleared when the plan rebuilds
1868 /// (basin border change ⇒ the old-dim seeds are meaningless).
1869 warm_inverse_probes: Option<Vec<Array1<f64>>>,
1870}
1871
1872impl SurrogateLaneState {
1873 /// A lane with no plan yet — the first evaluation builds and freezes it.
1874 pub fn new(cfg: SurrogateLaneConfig) -> Self {
1875 Self {
1876 plan: None,
1877 cfg,
1878 request_inverse_probes: false,
1879 inverse_probes: None,
1880 request_logdet_derivative_bundle: false,
1881 logdet_derivative_bundle: None,
1882 warm_inverse_probes: None,
1883 }
1884 }
1885
1886 /// The frozen plan, once built (for the gradient lane, which contracts
1887 /// against the SAME `Q` the value used).
1888 pub fn plan(&self) -> Option<&RationalLogdetPlan> {
1889 self.plan.as_ref()
1890 }
1891
1892 /// Ask the next matrix-free evidence eval to also emit the shared
1893 /// `(probes, S⁻¹·probes)` bundle. Clears any stale bundle so a failed or
1894 /// skipped eval cannot hand back last call's solves.
1895 pub fn request_inverse_probes(&mut self) {
1896 self.request_inverse_probes = true;
1897 self.inverse_probes = None;
1898 }
1899
1900 /// Take the shared bundle produced by the most recent eval, if requested and
1901 /// computed. Consumes it so a later gradient read cannot reuse stale solves.
1902 pub fn take_inverse_probes(&mut self) -> Option<(Vec<Array1<f64>>, Vec<Array1<f64>>)> {
1903 self.request_inverse_probes = false;
1904 self.inverse_probes.take()
1905 }
1906
1907 /// Ask the next rational value evaluation to retain its complete weighted
1908 /// derivative representation. Clears stale output eagerly so a failed value
1909 /// cannot be paired with a previous operator's gradient.
1910 pub fn request_logdet_derivative_bundle(&mut self) {
1911 self.request_logdet_derivative_bundle = true;
1912 self.logdet_derivative_bundle = None;
1913 }
1914
1915 /// Consume the derivative representation produced by the most recent
1916 /// requested rational value evaluation.
1917 pub fn take_logdet_derivative_bundle(&mut self) -> Option<RationalLogdetDerivativeBundle> {
1918 self.request_logdet_derivative_bundle = false;
1919 self.logdet_derivative_bundle.take()
1920 }
1921}
1922
1923/// Split arrow-Schur evidence `log|H| = Σ log|H_tt| + log|S|` where the reduced
1924/// Schur term is estimated by the #2080 rational surrogate rather than SLQ, on
1925/// ONE shared factorization. The build-once companion to
1926/// [`matrix_free_arrow_evidence_log_det`]:
1927///
1928/// - `lane = None` runs the identical [`slq_reduced_schur_log_det`] path — a
1929/// bit-for-bit fallback so a caller that has not opted in is unchanged.
1930/// - `lane = Some(state)` builds (or, when the reduced-Schur dimension is
1931/// unchanged, reuses) the frozen derived-rank [`RationalLogdetPlan`] and
1932/// evaluates it against the current operator. The plan's `Q`/probes/quadrature
1933/// are fixed at first build, so only the matrix-free `S·v` apply moves with ρ —
1934/// the value and its [`RationalLogdetPlan::directional_derivative`] gradient
1935/// remain one functional.
1936///
1937/// Returns `(log_det_tt, log_det_schur)`; the caller adds them for the evidence.
1938pub fn matrix_free_arrow_evidence_log_det_surrogate(
1939 sys: &ArrowSchurSystem,
1940 ridge_t: f64,
1941 ridge_beta: f64,
1942 options: &ArrowSolveOptions,
1943 slq_num_probes: usize,
1944 slq_lanczos_steps: usize,
1945 slq_seed: u64,
1946 lane: Option<&mut SurrogateLaneState>,
1947) -> Result<(f64, f64), ArrowSchurError> {
1948 let backend = CpuBatchedBlockSolver;
1949 let factorization = factor_blocks_for_system(
1950 sys,
1951 ridge_t,
1952 options.evidence_policy.factors_undamped_evidence(),
1953 &backend,
1954 options.gpu_policy,
1955 )?;
1956 let htt_factors = factorization.factors;
1957 let mut log_det_tt = 0.0_f64;
1958 for row in 0..htt_factors.len() {
1959 let factor = htt_factors.factor(row);
1960 for axis in 0..factor.nrows() {
1961 log_det_tt += 2.0 * factor[[axis, axis]].ln();
1962 }
1963 }
1964 // #1017 Phase-3: one device-resident reduced-Schur `S·v` for the WHOLE
1965 // evaluation — the surrogate value ladder (two-sided deflation: block-power on
1966 // S + inverse subspace iteration on S⁻¹ via matrix-free CG), the λ_max bracket
1967 // power iteration, the SLQ probes, AND the S⁻¹·probe bundle all ride this
1968 // single operator (uploaded / pre-factored once). Sized against the surrogate's
1969 // per-evaluation apply budget (probe count × shifted-CG ladder depth). The
1970 // device operator carries its own residency, so the CPU `SaeResidentReducedSchur`
1971 // frame is only staged on the CPU lane.
1972 let cfg_apply_budget = lane
1973 .as_ref()
1974 .map(|s| s.cfg.num_probes.saturating_mul(s.cfg.cg_max_iters))
1975 .unwrap_or_else(|| slq_num_probes.saturating_mul(slq_lanczos_steps));
1976 let device_matvec =
1977 maybe_build_evidence_gpu_matvec(sys, ridge_t, ridge_beta, options, cfg_apply_budget)?;
1978 let gpu_matvec: Option<&GpuSchurMatvec> =
1979 options.gpu_matvec.as_ref().or(device_matvec.as_ref());
1980 let resident = if gpu_matvec.is_none() {
1981 SaeResidentReducedSchur::build(sys, &htt_factors, &backend)
1982 } else {
1983 None
1984 };
1985
1986 let log_det_schur = match lane {
1987 None => {
1988 let slq = slq_reduced_schur_log_det(
1989 sys,
1990 &htt_factors,
1991 ridge_beta,
1992 &backend,
1993 resident.as_ref(),
1994 gpu_matvec,
1995 options.evidence_policy,
1996 slq_num_probes,
1997 slq_lanczos_steps,
1998 slq_seed,
1999 );
2000 slq.estimate
2001 }
2002 Some(state) => {
2003 let dim = sys.k;
2004 // (Re)build the frozen plan when absent or dimension-mismatched (a
2005 // basin mutation changed the border); otherwise reuse the frozen Q.
2006 let need_build = state.plan.as_ref().map_or(true, |p| p.dim != dim);
2007 if need_build {
2008 let cfg = state.cfg.clone();
2009 let plan = rational_reduced_schur_plan_derived(
2010 sys,
2011 &htt_factors,
2012 ridge_beta,
2013 &backend,
2014 resident.as_ref(),
2015 gpu_matvec,
2016 cfg.num_probes,
2017 cfg.seed,
2018 cfg.rel_tol,
2019 cfg.power_iters,
2020 cfg.cg_rel_tol,
2021 cfg.cg_max_iters,
2022 cfg.deflation_max_rank,
2023 cfg.deflation_subspace_iters,
2024 cfg.deflation_target_std_err_rel,
2025 )
2026 .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2027 reason: format!(
2028 "rational log-det surrogate plan build failed for reduced Schur dim {dim}"
2029 ),
2030 })?;
2031 state.plan = Some(plan);
2032 // The old-dim S⁻¹·probes are meaningless against the new border.
2033 state.warm_inverse_probes = None;
2034 }
2035 let plan = state
2036 .plan
2037 .as_ref()
2038 .expect("plan installed just above when absent");
2039 let want_bundle = state.request_inverse_probes;
2040 let want_logdet_derivative = state.request_logdet_derivative_bundle;
2041 // Value, its lossless shifted derivative representation, and any
2042 // EFS-only `(probes, S⁻¹·probes)` trace bundle are computed under one
2043 // borrow of the frozen plan and stashed after that borrow ends. The
2044 // EFS bundle uses raw probes; the outer gradient consumes only the
2045 // weighted shifted derivative bundle.
2046 let (estimate, derivative_bundle, bundle) = {
2047 // #1017: ONE reduced-Schur operator for the whole value ladder —
2048 // the frozen plan walks its shift ladder through this single
2049 // resident apply instead of re-capturing a `schur_matvec` closure
2050 // per shifted solve. When `gpu_matvec` is `Some` (Phase-3 device
2051 // seam, built once above) every shifted apply runs on device; when
2052 // `None` the byte-identical CPU `schur_matvec` lane is taken.
2053 let op = ReducedSchurOperator::new(
2054 sys,
2055 &htt_factors,
2056 ridge_beta,
2057 &backend,
2058 resident.as_ref(),
2059 )
2060 .with_gpu_matvec(gpu_matvec);
2061 let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2062 let eval = plan
2063 .evaluate(&matvec, state.cfg.cg_rel_tol, state.cfg.cg_max_iters)
2064 .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2065 reason: "rational log-det surrogate evaluation returned non-finite"
2066 .to_string(),
2067 })?;
2068 let estimate = eval.estimate;
2069 let derivative_bundle = if want_logdet_derivative {
2070 Some(
2071 plan.into_directional_derivative_bundle(eval)
2072 .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2073 reason: "rational log-det derivative bundle assembly failed"
2074 .to_string(),
2075 })?,
2076 )
2077 } else {
2078 None
2079 };
2080 let bundle = if want_bundle {
2081 let sinv = reduced_schur_inverse_probe_solves(
2082 sys,
2083 &htt_factors,
2084 ridge_beta,
2085 &backend,
2086 resident.as_ref(),
2087 gpu_matvec,
2088 &plan.probes,
2089 state.warm_inverse_probes.as_deref(),
2090 state.cfg.cg_rel_tol,
2091 state.cfg.cg_max_iters,
2092 )
2093 .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2094 reason: "rational surrogate inverse-probe bundle solve failed".to_string(),
2095 })?;
2096 Some((plan.probes.clone(), sinv))
2097 } else {
2098 None
2099 };
2100 (estimate, derivative_bundle, bundle)
2101 };
2102 if want_logdet_derivative {
2103 state.logdet_derivative_bundle = derivative_bundle;
2104 state.request_logdet_derivative_bundle = false;
2105 }
2106 if want_bundle {
2107 // Keep the fresh solves as the next ρ's warm-start seed (CRN),
2108 // then hand the bundle to the gradient lane.
2109 if let Some((_, sinv)) = &bundle {
2110 state.warm_inverse_probes = Some(sinv.clone());
2111 }
2112 state.inverse_probes = bundle;
2113 state.request_inverse_probes = false;
2114 }
2115 estimate
2116 }
2117 };
2118 Ok((log_det_tt, log_det_schur))
2119}
2120
2121/// Power-iteration estimate of the largest eigenvalue `λ_max` of the SPD reduced
2122/// Schur `S` through the matrix-free [`schur_matvec`] apply — the upper end of
2123/// the spectral bracket the #2080 rational log-det surrogate
2124/// ([`RationalLogdetPlan`]) needs to size its bracket-centred DE quadrature.
2125///
2126/// Deterministic: the start vector is a fixed SplitMix64 Rademacher draw from
2127/// `seed`, so a given `(sys, htt_factors, ρ_β, resident, iters, seed)` always
2128/// returns the same estimate — the surrogate bracket must be reproducible for the
2129/// REML outer loop, exactly like the SLQ probes. `iters` power steps refine the
2130/// Rayleigh quotient `vᵀ S v` (each step is one `schur_matvec`); a handful
2131/// suffice because the surrogate only needs a bracket good to a factor, not a
2132/// converged eigenvalue (the quadrature window is padded two decades each side).
2133///
2134/// Returns `None` for a degenerate operator (`k == 0`) or a non-finite /
2135/// non-positive Rayleigh quotient (an SPD operator forbids the latter, so it
2136/// signals a caller bug or a non-finite operator, both of which must surface
2137/// rather than be silently bracketed).
2138pub fn reduced_schur_lambda_max<B: BatchedBlockSolver + Sync>(
2139 sys: &ArrowSchurSystem,
2140 htt_factors: &ArrowFactorSlab,
2141 ridge_beta: f64,
2142 backend: &B,
2143 resident: Option<&SaeResidentReducedSchur>,
2144 gpu_matvec: Option<&GpuSchurMatvec>,
2145 iters: usize,
2146 seed: u64,
2147) -> Option<f64> {
2148 let k = sys.k;
2149 if k == 0 {
2150 return None;
2151 }
2152 // Deterministic Rademacher start (same stream discipline as the surrogate
2153 // probes): a ±1 vector never lands orthogonal to the top eigenspace.
2154 let mut v = Array1::<f64>::zeros(k);
2155 {
2156 let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
2157 let mut bits: u64 = 0;
2158 let mut remaining: u32 = 0;
2159 for value in v.iter_mut() {
2160 if remaining == 0 {
2161 bits = gam_linalg::utils::splitmix64(&mut state);
2162 remaining = 64;
2163 }
2164 *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
2165 bits >>= 1;
2166 remaining -= 1;
2167 }
2168 }
2169 let inv_norm0 = v.dot(&v).sqrt().recip();
2170 if !inv_norm0.is_finite() {
2171 return None;
2172 }
2173 v.mapv_inplace(|x| x * inv_norm0);
2174 // One resident operator reused across every power-iteration apply — device
2175 // seam threaded so the bracket estimate rides the SAME resident `S·v` the
2176 // ladder/probes use.
2177 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2178 .with_gpu_matvec(gpu_matvec);
2179 let apply = |x: &Array1<f64>| -> Array1<f64> { op.apply_owned(x) };
2180 for _ in 0..iters.max(1) {
2181 let sv = apply(&v);
2182 let norm = sv.dot(&sv).sqrt();
2183 if !(norm.is_finite() && norm > 0.0) {
2184 break;
2185 }
2186 v = sv / norm;
2187 }
2188 // Rayleigh quotient on the converged iterate (v stays unit).
2189 let sv = apply(&v);
2190 let lambda = v.dot(&sv);
2191 (lambda.is_finite() && lambda > 0.0).then_some(lambda)
2192}
2193
2194/// Matrix-free reduced-Schur log-determinant `log|S|` via the #2080 fixed
2195/// rational surrogate ([`RationalLogdetPlan`]) on the exact [`schur_matvec`]
2196/// apply — the desync-safe companion to [`slq_reduced_schur_log_det`]. **The
2197/// dense `k×k` `S` is NEVER formed.**
2198///
2199/// Returns the built plan and its evaluation so the caller can (a) read
2200/// `eval.estimate` = the surrogate value `L̃ ≈ log|S|` (with `eval.std_err` the
2201/// honest Hutchinson error bar), and (b) later contract the SAME shifted-solve
2202/// bundle against any per-ρ-coordinate Schur-derivative operator `∂S` via
2203/// [`rational_reduced_schur_directional`]. Because both the value and that
2204/// derivative are the exact value / gradient of the ONE deterministic function
2205/// `L̃(ρ)` (fixed probes, fixed quadrature), the outer optimiser descends a
2206/// function whose gradient is its own — the objective↔gradient desync class the
2207/// bare SLQ value re-opened (a stochastic value paired with the analytic exact
2208/// gradient) is closed by construction, not by tolerance tuning.
2209///
2210/// The spectral bracket is estimated matrix-free: `λ_max` by power iteration
2211/// ([`reduced_schur_lambda_max`]), `λ_min` from the deflation-floor convention
2212/// `SPECTRAL_DEFLATION_REL_FLOOR·λ_max` (the operative lower bound of the
2213/// unit-deflated spectrum). Deterministic for a fixed
2214/// `(sys, htt_factors, ρ_β, resident, num_probes, seed, rel_tol, power_iters,
2215/// cg_rel_tol, cg_max_iters)`.
2216///
2217/// `None` when `k == 0`, the bracket estimate is degenerate, the plan cannot be
2218/// built, or a shifted CG solve breaks down on a non-finite operator.
2219pub fn rational_reduced_schur_log_det<B: BatchedBlockSolver + Sync>(
2220 sys: &ArrowSchurSystem,
2221 htt_factors: &ArrowFactorSlab,
2222 ridge_beta: f64,
2223 backend: &B,
2224 resident: Option<&SaeResidentReducedSchur>,
2225 gpu_matvec: Option<&GpuSchurMatvec>,
2226 num_probes: usize,
2227 seed: u64,
2228 rel_tol: f64,
2229 power_iters: usize,
2230 cg_rel_tol: f64,
2231 cg_max_iters: usize,
2232) -> Option<(RationalLogdetPlan, RationalLogdetEval)> {
2233 let k = sys.k;
2234 if k == 0 {
2235 return None;
2236 }
2237 let lambda_max = reduced_schur_lambda_max(
2238 sys,
2239 htt_factors,
2240 ridge_beta,
2241 backend,
2242 resident,
2243 gpu_matvec,
2244 power_iters,
2245 seed,
2246 )?;
2247 // λ_min from the deflation floor: after unit-deflation the operative spectrum
2248 // is bounded below by `SPECTRAL_DEFLATION_REL_FLOOR·λ_max` (or 1.0), so this
2249 // is a sound lower bracket for the quadrature window sizing. The window is
2250 // padded two decades below `λ_min` inside `RationalLogdetPlan::build`, so a
2251 // conservative (too-small) floor only widens the resolved range, never biases
2252 // the estimate.
2253 let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
2254 let plan = RationalLogdetPlan::build(k, num_probes, seed, lambda_min, lambda_max, rel_tol)?;
2255 // One resident operator; the plan's shift ladder reuses it across every
2256 // shifted solve. The probes fan across rayon workers (in `evaluate`), and
2257 // `schur_matvec`'s own row parallelism is guarded off inside a worker, so
2258 // there is no nested oversubscription.
2259 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2260 .with_gpu_matvec(gpu_matvec);
2261 let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2262 let eval = plan.evaluate(&matvec, cg_rel_tol, cg_max_iters)?;
2263 Some((plan, eval))
2264}
2265
2266/// Build the FROZEN #2080 surrogate plan for one outer solve, with the Hutch++
2267/// deflation rank DERIVED from a pilot evaluation — the build-once companion to
2268/// per-ρ [`RationalLogdetPlan::evaluate`]. Returns just the plan (probes +
2269/// quadrature + frozen Hutch++ `Q`); the caller evaluates it at each ρ, so the
2270/// expensive rank derivation (several re-solves) is paid ONCE per outer solve,
2271/// not per criterion evaluation.
2272///
2273/// Derived rank (the #2080 lead ruling): a rank-0 pilot fixes the log-det scale,
2274/// the target bar is `deflation_target_std_err_rel · (|log|S|_pilot| + 1)` — one
2275/// order under the smallest tolerance the criterion feeds (the caller passes
2276/// `0.1 · STALL_REL_TOL`; `log|S|` is the criterion's dominant term at wide `k`
2277/// so `|log|S||+1` is the right objective scale to `O(1)` and the `0.1` margin
2278/// absorbs the loss/Occam remainder). The peel rank grows on a doubling schedule
2279/// until the Hutchinson error bar clears the target. `deflation_max_rank` is a
2280/// resource-admission ceiling, not permission to return an under-certified
2281/// estimate: exhausting it before the bar clears returns `None` and the caller
2282/// surfaces a typed evidence failure. `deflation_max_rank == 0` explicitly
2283/// requests the bare-Hutchinson plan; a pilot already under target also returns
2284/// it. Deterministic for fixed inputs (`Q` and probes are seed-derived). The
2285/// returned plan's `Q` is FROZEN, so
2286/// [`RationalLogdetPlan::directional_derivative`] on its evaluations is the exact
2287/// surrogate gradient.
2288pub fn rational_reduced_schur_plan_derived<B: BatchedBlockSolver + Sync>(
2289 sys: &ArrowSchurSystem,
2290 htt_factors: &ArrowFactorSlab,
2291 ridge_beta: f64,
2292 backend: &B,
2293 resident: Option<&SaeResidentReducedSchur>,
2294 gpu_matvec: Option<&GpuSchurMatvec>,
2295 num_probes: usize,
2296 seed: u64,
2297 rel_tol: f64,
2298 power_iters: usize,
2299 cg_rel_tol: f64,
2300 cg_max_iters: usize,
2301 deflation_max_rank: usize,
2302 deflation_subspace_iters: usize,
2303 deflation_target_std_err_rel: f64,
2304) -> Option<RationalLogdetPlan> {
2305 let k = sys.k;
2306 if k == 0
2307 || !(cg_rel_tol.is_finite() && cg_rel_tol > 0.0 && cg_rel_tol < 1.0)
2308 || !(deflation_target_std_err_rel.is_finite() && deflation_target_std_err_rel >= 0.0)
2309 {
2310 return None;
2311 }
2312 let lambda_max = reduced_schur_lambda_max(
2313 sys,
2314 htt_factors,
2315 ridge_beta,
2316 backend,
2317 resident,
2318 gpu_matvec,
2319 power_iters,
2320 seed,
2321 )?;
2322 let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
2323 let base_plan =
2324 RationalLogdetPlan::build(k, num_probes, seed, lambda_min, lambda_max, rel_tol)?;
2325 // One resident operator across the pilot, every deflation re-solve, and the
2326 // subspace-iteration `with_two_sided_deflation` applies — the whole rank-derivation
2327 // ladder (the two-sided deflation: block-power on S + inverse subspace
2328 // iteration on S⁻¹) reuses the same staged residency / device `S·v`.
2329 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2330 .with_gpu_matvec(gpu_matvec);
2331 let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2332 // Rank-0 pilot: fixes the |log|S|| scale and is the answer outright when no
2333 // deflation is requested or the bare bar already clears the target.
2334 let pilot = base_plan.evaluate(&matvec, cg_rel_tol, cg_max_iters)?;
2335 if deflation_max_rank == 0 {
2336 return Some(base_plan);
2337 }
2338 let target = deflation_target_std_err_rel * (pilot.estimate.abs() + 1.0);
2339 if pilot.std_err <= target {
2340 return Some(base_plan);
2341 }
2342 // Grow from the smallest nonzero peel rank (doubling ⇒ log-many re-solves)
2343 // until the bar clears. The caller's cap is a resource ceiling; reaching it
2344 // with an over-target bar refuses the surrogate rather than silently
2345 // weakening the requested statistical-accuracy contract.
2346 let cap = deflation_max_rank.min(k);
2347 let mut rank = 1usize;
2348 // Basis iteration only steers Q for variance reduction. Derive its looser
2349 // true-residual tolerance from the evaluation solve's tolerance instead of
2350 // carrying an unrelated fixed knob: √tol is strictly looser while still
2351 // converging as the bottom-tail builder now requires.
2352 let basis_cg_rel_tol = cg_rel_tol.sqrt();
2353 loop {
2354 let r = rank.min(cap);
2355 // Split the peel budget across BOTH spectral tails at equal total rank:
2356 // the Hutchinson bar rides on ‖offdiag(P log(S/c) P)‖_F, whose mass sits
2357 // symmetrically on the λ_max AND λ_min tails (|log(λ/c)| peaks equally at
2358 // both ends of the bracket since c is its geometric midpoint), so top-only
2359 // deflation stalls at ~½ the removable variance
2360 // (`two_sided_deflation_drops_wide_kappa_std_err_below_two_percent`).
2361 // The bottom-tail basis comes from inverse iteration — CG on the UNSHIFTED
2362 // operator at full κ — so it gets its own LOOSE budget, not the
2363 // evaluation-grade `cg_rel_tol`: an approximate bottom `Q` only relaxes
2364 // the variance reduction, never biases the value (the split is exact for
2365 // any orthonormal `Q`), while an evaluation-grade solve there would burn
2366 // √κ-scale iterations per basis column for no accuracy in return.
2367 let plan = base_plan.clone().with_two_sided_deflation(
2368 &matvec,
2369 r.div_ceil(2),
2370 r / 2,
2371 deflation_subspace_iters,
2372 seed,
2373 (basis_cg_rel_tol, cg_max_iters),
2374 )?;
2375 let eval = plan.evaluate(&matvec, cg_rel_tol, cg_max_iters)?;
2376 if eval.std_err <= target {
2377 return Some(plan);
2378 }
2379 if r >= cap {
2380 return None;
2381 }
2382 rank = rank.saturating_mul(2);
2383 }
2384}
2385
2386/// Contract the surrogate's shifted-solve bundle from
2387/// [`rational_reduced_schur_log_det`] against a reduced-Schur derivative operator
2388/// `∂S` (supplied through its matvec `dmatvec(v) = (∂S)·v`) to obtain the EXACT
2389/// ρ-derivative of the surrogate value:
2390/// `∂L̃ = (1/m)·Σ_{j,ℓ} w_ℓ · y_{jℓ}ᵀ (∂S) y_{jℓ}`, `y_{jℓ} = (S+t_ℓ I)⁻¹ v_j`.
2391///
2392/// This is the true gradient of the SAME function the value came from — value
2393/// and gradient can never desync. Thin reduced-Schur wrapper over
2394/// [`RationalLogdetPlan::directional_derivative`]; the `∂S` matvec is the
2395/// per-ρ-coordinate Schur-derivative operator the SAE trace channels assemble
2396/// row-locally (`(∂S)·y = (∂H_ββ)y − Σ_i[ (∂H_βt^(i))(H_tt⁻¹H_tβ y) −
2397/// H_βt H_tt⁻¹(∂H_tt^(i))H_tt⁻¹H_tβ y + H_βt H_tt⁻¹(∂H_tβ^(i))y ]`).
2398pub fn rational_reduced_schur_directional(
2399 plan: &RationalLogdetPlan,
2400 eval: &RationalLogdetEval,
2401 dmatvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
2402) -> Option<f64> {
2403 plan.directional_derivative(eval, dmatvec)
2404}
2405
2406/// Plain CG solve `S y = b` on the SPD reduced Schur through the matrix-free
2407/// [`schur_matvec`] apply (the `t = 0`, unshifted companion to the surrogate's
2408/// shifted solves), warm-started from `y0`. Yields `y = S⁻¹ b` — the operator
2409/// every `tr(S⁻¹·M)` gradient / adjoint channel contracts against at massive K.
2410/// `None` on a non-finite breakdown (SPD `S` ⇒ that signals a caller bug or a
2411/// non-finite operator, both of which must surface rather than be swallowed).
2412fn reduced_schur_cg_solve<B: BatchedBlockSolver + Sync>(
2413 sys: &ArrowSchurSystem,
2414 htt_factors: &ArrowFactorSlab,
2415 ridge_beta: f64,
2416 backend: &B,
2417 resident: Option<&SaeResidentReducedSchur>,
2418 gpu_matvec: Option<&GpuSchurMatvec>,
2419 b: &Array1<f64>,
2420 y0: &Array1<f64>,
2421 cg_rel_tol: f64,
2422 cg_max_iters: usize,
2423) -> Option<Array1<f64>> {
2424 // One resident operator reused across every CG apply of this solve — device
2425 // seam threaded so the inverse-subspace S⁻¹·probe solves ride the resident op.
2426 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2427 .with_gpu_matvec(gpu_matvec);
2428 let apply = |v: &Array1<f64>| -> Array1<f64> { op.apply_owned(v) };
2429 let quotient = sys.beta_gauge_quotient.as_ref();
2430 let b = match quotient {
2431 Some(quotient) => quotient.project_complement(b.view()),
2432 None => b.clone(),
2433 };
2434 let mut y = match quotient {
2435 Some(quotient) => quotient.project_complement(y0.view()),
2436 None => y0.clone(),
2437 };
2438 let mut r = &b - &apply(&y);
2439 let b_norm = b.dot(&b).sqrt().max(f64::MIN_POSITIVE);
2440 let mut p = r.clone();
2441 let mut rs = r.dot(&r);
2442 if !rs.is_finite() {
2443 return None;
2444 }
2445 let tol = cg_rel_tol * b_norm;
2446 let mut iters = 0usize;
2447 while rs.sqrt() > tol && iters < cg_max_iters {
2448 let ap = apply(&p);
2449 let denom = p.dot(&ap);
2450 if !(denom.is_finite() && denom > 0.0) {
2451 return None;
2452 }
2453 let alpha = rs / denom;
2454 y.scaled_add(alpha, &p);
2455 r.scaled_add(-alpha, &ap);
2456 let rs_new = r.dot(&r);
2457 if !rs_new.is_finite() {
2458 return None;
2459 }
2460 p = &r + &(&p * (rs_new / rs));
2461 rs = rs_new;
2462 iters += 1;
2463 }
2464 Some(match quotient {
2465 Some(quotient) => quotient.project_complement(y.view()),
2466 None => y,
2467 })
2468}
2469
2470/// Matrix-free single-rhs reduced-Schur solve `S⁻¹ rhs` (`t = 0`) via CG on
2471/// [`schur_matvec`], warm-started from `warm` (or cold). The base primitive for
2472/// the selected-inverse gradient channels whose `S⁻¹` argument is NOT the fixed
2473/// probe family but a per-call probe-derived vector (e.g. `(H⁻¹)_tt`'s
2474/// `H_βt(H_tt)⁻¹z` term in the ARD latent-block diagonal, and the per-row
2475/// `(H⁻¹)_tβ` blocks the θ-adjoint / assignment-strength traces contract) — those
2476/// cannot reuse the `(probes, S⁻¹·probes)` bundle, so they solve `S⁻¹` on demand
2477/// through this. `None` on a CG breakdown (SPD `S` forbids it, so it signals a
2478/// non-finite operator or caller bug).
2479pub fn reduced_schur_inverse_apply<B: BatchedBlockSolver + Sync>(
2480 sys: &ArrowSchurSystem,
2481 htt_factors: &ArrowFactorSlab,
2482 ridge_beta: f64,
2483 backend: &B,
2484 resident: Option<&SaeResidentReducedSchur>,
2485 gpu_matvec: Option<&GpuSchurMatvec>,
2486 rhs: &Array1<f64>,
2487 warm: Option<&Array1<f64>>,
2488 cg_rel_tol: f64,
2489 cg_max_iters: usize,
2490) -> Option<Array1<f64>> {
2491 let zero = Array1::<f64>::zeros(sys.k);
2492 let y0 = warm.unwrap_or(&zero);
2493 reduced_schur_cg_solve(
2494 sys,
2495 htt_factors,
2496 ridge_beta,
2497 backend,
2498 resident,
2499 gpu_matvec,
2500 rhs,
2501 y0,
2502 cg_rel_tol,
2503 cg_max_iters,
2504 )
2505}
2506
2507fn matrix_free_cache_factor_slab(cache: &ArrowFactorCache) -> &ArrowFactorSlab {
2508 match &cache.htt_factors_undamped {
2509 ArrowUndampedFactors::SameAsDamped => &cache.htt_factors,
2510 ArrowUndampedFactors::Owned(factors) => factors,
2511 }
2512}
2513
2514fn validate_matrix_free_arrow_pair(
2515 sys: &ArrowSchurSystem,
2516 cache: &ArrowFactorCache,
2517 operation: &str,
2518) -> Result<(), ArrowSchurError> {
2519 if cache.ridge_t != 0.0 || cache.ridge_beta != 0.0 || !cache.schur_factor_is_undamped {
2520 return Err(ArrowSchurError::SchurFactorFailed {
2521 reason: format!(
2522 "{operation} requires an undamped evidence cache; got ridge_t={}, \
2523 ridge_beta={}, schur_factor_is_undamped={}",
2524 cache.ridge_t, cache.ridge_beta, cache.schur_factor_is_undamped
2525 ),
2526 });
2527 }
2528 if sys.k != cache.k
2529 || sys.rows.len() != cache.n_rows()
2530 || sys.row_dims.as_ref() != cache.row_dims.as_ref()
2531 || sys.row_offsets.as_ref() != cache.row_offsets.as_ref()
2532 {
2533 return Err(ArrowSchurError::SchurFactorFailed {
2534 reason: format!(
2535 "{operation} system/cache layout mismatch: system (rows={}, k={}, offsets={:?}) \
2536 vs cache (rows={}, k={}, offsets={:?})",
2537 sys.rows.len(),
2538 sys.k,
2539 sys.row_offsets,
2540 cache.n_rows(),
2541 cache.k,
2542 cache.row_offsets,
2543 ),
2544 });
2545 }
2546 if sys.row_hessian_fingerprint != cache.row_hessian_fingerprint
2547 || sys.manifold_mode_fingerprint != cache.manifold_mode_fingerprint
2548 {
2549 return Err(ArrowSchurError::SchurFactorFailed {
2550 reason: format!(
2551 "{operation} refuses a stale matrix-free system/cache pair \
2552 (row fingerprint {} vs {}, manifold fingerprint {} vs {})",
2553 sys.row_hessian_fingerprint,
2554 cache.row_hessian_fingerprint,
2555 sys.manifold_mode_fingerprint,
2556 cache.manifold_mode_fingerprint,
2557 ),
2558 });
2559 }
2560 if !sys.cross_row_penalties.is_empty() {
2561 return Err(ArrowSchurError::SchurFactorFailed {
2562 reason: format!(
2563 "{operation} supports the row-block bordered arrow only; cross-row latent \
2564 curvature requires its own matrix-free inverse carrier"
2565 ),
2566 });
2567 }
2568 if !cache.htbeta_available() && cache.k > 0 {
2569 return Err(ArrowSchurError::SchurFactorFailed {
2570 reason: format!("{operation} requires the cached H_tbeta operator"),
2571 });
2572 }
2573 Ok(())
2574}
2575
2576fn cholesky_factor_operator_apply(
2577 factor: ArrayView2<'_, f64>,
2578 vector: ArrayView1<'_, f64>,
2579) -> Array1<f64> {
2580 let n = factor.nrows();
2581 let mut transposed = Array1::<f64>::zeros(n);
2582 for col in 0..n {
2583 let mut value = 0.0_f64;
2584 for row in col..n {
2585 value += factor[[row, col]] * vector[row];
2586 }
2587 transposed[col] = value;
2588 }
2589 let mut out = Array1::<f64>::zeros(n);
2590 for row in 0..n {
2591 let mut value = 0.0_f64;
2592 for col in 0..=row {
2593 value += factor[[row, col]] * transposed[col];
2594 }
2595 out[row] = value;
2596 }
2597 out
2598}
2599
2600/// Apply the undamped full bordered-arrow evidence operator without forming its
2601/// dense reduced Schur complement.
2602///
2603/// The cache supplies the authoritative conditioned row factors and `H_tbeta`
2604/// operator. The system supplies the matrix-free shared block. Rather than read
2605/// raw `H_betabeta` directly, this reconstructs it from
2606/// `S + H_betat A^-1 H_tbeta`, where `S` is applied through the same quotient-
2607/// aware reduced operator used by the matrix-free log-determinant. Value,
2608/// selected-inverse traces, and this IFT operator therefore describe one `B`.
2609pub fn matrix_free_arrow_operator_apply(
2610 sys: &ArrowSchurSystem,
2611 cache: &ArrowFactorCache,
2612 vector_t: ArrayView1<'_, f64>,
2613 vector_beta: ArrayView1<'_, f64>,
2614) -> Result<(Array1<f64>, Array1<f64>), ArrowSchurError> {
2615 validate_matrix_free_arrow_pair(sys, cache, "matrix_free_arrow_operator_apply")?;
2616 if vector_t.len() != cache.delta_t_len() || vector_beta.len() != cache.k {
2617 return Err(ArrowSchurError::SchurFactorFailed {
2618 reason: format!(
2619 "matrix_free_arrow_operator_apply vector shapes (t={}, beta={}) != ({}, {})",
2620 vector_t.len(),
2621 vector_beta.len(),
2622 cache.delta_t_len(),
2623 cache.k,
2624 ),
2625 });
2626 }
2627
2628 let factors = matrix_free_cache_factor_slab(cache);
2629 let backend = CpuBatchedBlockSolver;
2630 let reduced = ReducedSchurOperator::new(sys, factors, 0.0, &backend, None);
2631 let mut out_beta = reduced.apply(vector_beta);
2632 let mut out_t = Array1::<f64>::zeros(cache.delta_t_len());
2633 for row in 0..cache.n_rows() {
2634 let dim = cache.row_dims[row];
2635 let start = cache.row_offsets[row];
2636 let row_vector = vector_t.slice(ndarray::s![start..start + dim]);
2637 let factor = cache.undamped_factor(row);
2638 let row_applied = cholesky_factor_operator_apply(factor, row_vector);
2639 for axis in 0..dim {
2640 out_t[start + axis] = row_applied[axis];
2641 }
2642
2643 if cache.k == 0 {
2644 continue;
2645 }
2646 let mut cross = Array1::<f64>::zeros(dim);
2647 if !cache.apply_htbeta_row(row, vector_beta, &mut cross) {
2648 return Err(ArrowSchurError::SchurFactorFailed {
2649 reason: format!("matrix_free_arrow_operator_apply H_tbeta row {row} apply failed"),
2650 });
2651 }
2652 for axis in 0..dim {
2653 out_t[start + axis] += cross[axis];
2654 }
2655 if !cache.apply_htbeta_row_transpose(row, row_vector, &mut out_beta, None) {
2656 return Err(ArrowSchurError::SchurFactorFailed {
2657 reason: format!("matrix_free_arrow_operator_apply H_betat row {row} apply failed"),
2658 });
2659 }
2660
2661 // `out_beta` already contains `S * vector_beta`; add the eliminated
2662 // `H_betat A^-1 H_tbeta * vector_beta` term to recover H_betabeta.
2663 let solved_cross = cholesky_solve_vector(factor, cross.view());
2664 if !cache.apply_htbeta_row_transpose(row, solved_cross.view(), &mut out_beta, None) {
2665 return Err(ArrowSchurError::SchurFactorFailed {
2666 reason: format!(
2667 "matrix_free_arrow_operator_apply Schur reconstruction row {row} failed"
2668 ),
2669 });
2670 }
2671 }
2672 Ok((out_t, out_beta))
2673}
2674
2675/// Solve the undamped full bordered-arrow evidence system for an arbitrary RHS
2676/// using the matrix-free reduced-Schur CG primitive and exact row backsolves.
2677///
2678/// This is the matrix-free sibling of `ArrowFactorCache::full_inverse_apply`.
2679/// It never materializes `S` or `S^-1`; the beta solve uses the same
2680/// quotient-aware `S` operator as the rational log-determinant, then the latent
2681/// block is recovered by standard arrow back-substitution.
2682pub fn matrix_free_arrow_inverse_apply(
2683 sys: &ArrowSchurSystem,
2684 cache: &ArrowFactorCache,
2685 rhs_t: ArrayView1<'_, f64>,
2686 rhs_beta: ArrayView1<'_, f64>,
2687 cg_rel_tol: f64,
2688 cg_max_iters: usize,
2689) -> Result<(Array1<f64>, Array1<f64>), ArrowSchurError> {
2690 validate_matrix_free_arrow_pair(sys, cache, "matrix_free_arrow_inverse_apply")?;
2691 if rhs_t.len() != cache.delta_t_len() || rhs_beta.len() != cache.k {
2692 return Err(ArrowSchurError::SchurFactorFailed {
2693 reason: format!(
2694 "matrix_free_arrow_inverse_apply rhs shapes (t={}, beta={}) != ({}, {})",
2695 rhs_t.len(),
2696 rhs_beta.len(),
2697 cache.delta_t_len(),
2698 cache.k,
2699 ),
2700 });
2701 }
2702 if !(cg_rel_tol.is_finite() && cg_rel_tol > 0.0) || cg_max_iters == 0 {
2703 return Err(ArrowSchurError::PcgFailed {
2704 reason: format!(
2705 "matrix_free_arrow_inverse_apply requires positive finite CG tolerance and \
2706 iteration count; got rel_tol={cg_rel_tol}, max_iters={cg_max_iters}"
2707 ),
2708 });
2709 }
2710
2711 let factors = matrix_free_cache_factor_slab(cache);
2712 let backend = CpuBatchedBlockSolver;
2713 let mut latent_forward = Array1::<f64>::zeros(cache.delta_t_len());
2714 let mut eliminated = Array1::<f64>::zeros(cache.k);
2715 for row in 0..cache.n_rows() {
2716 let dim = cache.row_dims[row];
2717 let start = cache.row_offsets[row];
2718 let solved = cholesky_solve_vector(
2719 cache.undamped_factor(row),
2720 rhs_t.slice(ndarray::s![start..start + dim]),
2721 );
2722 for axis in 0..dim {
2723 latent_forward[start + axis] = solved[axis];
2724 }
2725 if cache.k > 0
2726 && !cache.apply_htbeta_row_transpose(row, solved.view(), &mut eliminated, None)
2727 {
2728 return Err(ArrowSchurError::SchurFactorFailed {
2729 reason: format!("matrix_free_arrow_inverse_apply H_betat row {row} apply failed"),
2730 });
2731 }
2732 }
2733 // The transpose helper accumulates the eliminated term positively.
2734 let mut reduced_rhs = rhs_beta.to_owned();
2735 reduced_rhs -= &eliminated;
2736
2737 let solved_beta = if cache.k == 0 {
2738 Array1::<f64>::zeros(0)
2739 } else {
2740 reduced_schur_inverse_apply(
2741 sys,
2742 factors,
2743 0.0,
2744 &backend,
2745 None,
2746 None,
2747 &reduced_rhs,
2748 None,
2749 cg_rel_tol,
2750 cg_max_iters,
2751 )
2752 .ok_or_else(|| ArrowSchurError::PcgFailed {
2753 reason: format!(
2754 "matrix_free_arrow_inverse_apply reduced-Schur solve failed \
2755 (dim={}, rel_tol={cg_rel_tol}, max_iters={cg_max_iters})",
2756 cache.k
2757 ),
2758 })?
2759 };
2760
2761 let mut solved_t = latent_forward;
2762 for row in 0..cache.n_rows() {
2763 let dim = cache.row_dims[row];
2764 let start = cache.row_offsets[row];
2765 if cache.k == 0 {
2766 continue;
2767 }
2768 let mut cross = Array1::<f64>::zeros(dim);
2769 if !cache.apply_htbeta_row(row, solved_beta.view(), &mut cross) {
2770 return Err(ArrowSchurError::SchurFactorFailed {
2771 reason: format!("matrix_free_arrow_inverse_apply H_tbeta row {row} apply failed"),
2772 });
2773 }
2774 let correction = cholesky_solve_vector(cache.undamped_factor(row), cross.view());
2775 for axis in 0..dim {
2776 solved_t[start + axis] -= correction[axis];
2777 }
2778 }
2779 Ok((solved_t, solved_beta))
2780}
2781
2782/// The `S⁻¹ v_j` bundle for a fixed probe set: solves `S y_j = v_j` (`t = 0`) on
2783/// the matrix-free reduced Schur for each probe `v_j`, warm-started per-probe
2784/// from `warm` when supplied (e.g. the surrogate's smallest-shift solves, which
2785/// already sit close to `S⁻¹ v_j`). Computed ONCE per outer solve and reused
2786/// across every `tr(S⁻¹·M)` channel, so the whole massive-K ρ-gradient +
2787/// θ-adjoint rides on one probe family — one functional, desync closed.
2788///
2789/// `probes` are the surrogate plan's Rademacher probes (`RationalLogdetPlan::
2790/// probes`); pass the SAME set the value used so the trace estimates are
2791/// consistent with it. `None` on any CG breakdown.
2792pub fn reduced_schur_inverse_probe_solves<B: BatchedBlockSolver + Sync>(
2793 sys: &ArrowSchurSystem,
2794 htt_factors: &ArrowFactorSlab,
2795 ridge_beta: f64,
2796 backend: &B,
2797 resident: Option<&SaeResidentReducedSchur>,
2798 gpu_matvec: Option<&GpuSchurMatvec>,
2799 probes: &[Array1<f64>],
2800 warm: Option<&[Array1<f64>]>,
2801 cg_rel_tol: f64,
2802 cg_max_iters: usize,
2803) -> Option<Vec<Array1<f64>>> {
2804 let k = sys.k;
2805 let zero = Array1::<f64>::zeros(k);
2806 let mut out = Vec::with_capacity(probes.len());
2807 for (j, v) in probes.iter().enumerate() {
2808 let y0 = warm.and_then(|w| w.get(j)).unwrap_or(&zero);
2809 let y = reduced_schur_cg_solve(
2810 sys,
2811 htt_factors,
2812 ridge_beta,
2813 backend,
2814 resident,
2815 gpu_matvec,
2816 v,
2817 y0,
2818 cg_rel_tol,
2819 cg_max_iters,
2820 )?;
2821 out.push(y);
2822 }
2823 Some(out)
2824}
2825
2826/// Hutchinson estimate `tr(S⁻¹ M) ≈ (1/m) Σ_j (S⁻¹ v_j)ᵀ (M v_j)` for the reduced
2827/// Schur `S` and a SYMMETRIC channel operator `M` supplied by its matvec
2828/// `m_matvec(v) = M·v`. `sinv_probes[j] = S⁻¹ v_j` is the bundle from
2829/// [`reduced_schur_inverse_probe_solves`] and `probes` the matching probe set.
2830///
2831/// The general umbrella (#2080): every dense-`S⁻¹` consumer in the SAE outer
2832/// gradient — the per-row selected-inverse deflation corrections
2833/// (`M = Σ_i G_iᵀ C_i G_i`), the direct β–β contractions (`M = ∂H_ββ` channel),
2834/// and the θ-adjoint — is ultimately a `tr(S⁻¹·M)` with `M·v` computable
2835/// row-locally without forming `M`. Estimating them all from the SAME
2836/// `(probes, S⁻¹ v_j)` pair keeps the value, ρ-gradient, and θ-adjoint one
2837/// functional. Unbiased for the ±1 Rademacher probes (`E[vᵀ S⁻¹ M v] =
2838/// tr(S⁻¹ M)`). `None` on a length mismatch or a non-finite accumulation.
2839pub fn hutchinson_reduced_schur_inverse_trace(
2840 probes: &[Array1<f64>],
2841 sinv_probes: &[Array1<f64>],
2842 m_matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
2843) -> Option<f64> {
2844 let m = probes.len();
2845 if m == 0 || sinv_probes.len() != m {
2846 return None;
2847 }
2848 let mut acc = 0.0_f64;
2849 for (v, y) in probes.iter().zip(sinv_probes) {
2850 let mv = m_matvec(v.view());
2851 acc += y.dot(&mv);
2852 }
2853 acc /= m as f64;
2854 acc.is_finite().then_some(acc)
2855}
2856
2857/// Accumulate one row's reduced-Schur point-elimination contribution
2858/// `H_βt^(i) (H_tt^(i))⁻¹ H_tβ^(i) x` (length `K`) into `acc`.
2859///
2860/// `local` is caller-owned `≥ sys.d`-length scratch (reused across rows to keep
2861/// the hot loop allocation-free); only `..di` is touched. `acc` is **added to**,
2862/// never cleared, so the caller controls whether contributions sum into a chunk
2863/// partial (parallel path) or a per-row buffer (sequential path).
2864#[inline]
2865pub(crate) fn schur_matvec_row_into<B: BatchedBlockSolver>(
2866 sys: &ArrowSchurSystem,
2867 htt_factors: &ArrowFactorSlab,
2868 x: &Array1<f64>,
2869 backend: &B,
2870 i: usize,
2871 local: &mut Array1<f64>,
2872 acc: &mut Array1<f64>,
2873) {
2874 let row = &sys.rows[i];
2875 let di = sys.row_dims[i];
2876 // H_tβ^(i) · x → local[..di], routed through sys.htbeta_matvec
2877 // when the dense block is absent.
2878 let mut local_i = local.slice_mut(ndarray::s![..di]).to_owned();
2879 local_i.fill(0.0);
2880 sys_htbeta_apply_row(sys, i, row, x.view(), &mut local_i);
2881 let solved = backend.solve_block_vector(htt_factors.factor(i), local_i.view());
2882 // H_βt^(i) · solved accumulates into acc (length k). Routed through
2883 // sys.htbeta_matvec when needed.
2884 sys_htbeta_accumulate_transpose(sys, i, row, solved.view(), acc);
2885}
2886
2887/// One per-term block factor for the block-Jacobi Schur preconditioner.
2888///
2889/// Carries either a dense Cholesky factor (for PD blocks ≤ 256 columns) or
2890/// the scalar inverses for that block's diagonal as a fallback.
2891#[derive(Clone)]
2892pub(crate) enum BlockFactor {
2893 /// Cholesky L stored column-major via faer. `range` identifies the
2894 /// columns in the full K-vector this block covers.
2895 Chol {
2896 factor: FaerLlt<f64>,
2897 range: Range<usize>,
2898 },
2899 /// Scalar fallback: per-element `1/s_aa` for each column in `range`.
2900 Scalar {
2901 inv: Array1<f64>,
2902 range: Range<usize>,
2903 },
2904}
2905
2906impl std::fmt::Debug for BlockFactor {
2907 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2908 match self {
2909 BlockFactor::Chol { range, .. } => {
2910 write!(f, "BlockFactor::Chol {{ range: {:?} }}", range)
2911 }
2912 BlockFactor::Scalar { inv, range } => {
2913 write!(
2914 f,
2915 "BlockFactor::Scalar {{ inv.len: {}, range: {:?} }}",
2916 inv.len(),
2917 range
2918 )
2919 }
2920 }
2921 }
2922}
2923
2924/// Block-Jacobi Schur preconditioner for BA's inexact reduced-system PCG.
2925///
2926/// When [`ArrowSchurSystem::block_offsets`] is populated (via
2927/// [`ArrowSchurSystem::set_block_offsets`]) and the largest block has ≤ 256
2928/// columns, builds one small dense Schur block per term, factors it with
2929/// Cholesky (faer LLT), and applies the preconditioner as per-block
2930/// triangular solves. Non-PD blocks fall back to scalar diagonal inversion
2931/// for that block only. When `block_offsets` is empty or the largest block
2932/// exceeds 256 columns the preconditioner reduces to pure scalar-diagonal
2933/// Jacobi (pre-#283 behaviour), so callers that have not called
2934/// `set_block_offsets` are unaffected.
2935///
2936/// The `block_offsets` plumbing is compatible with issue #287 (custom
2937/// `ParameterBlockSpec` families): those callers supply ranges derived from
2938/// their own block layout.
2939#[derive(Debug, Clone)]
2940pub struct JacobiPreconditioner {
2941 pub(crate) blocks: Vec<BlockFactor>,
2942}
2943
2944/// Maximum block size for which we attempt dense block-Jacobi factorization.
2945pub(crate) const BLOCK_JACOBI_MAX_BLOCK: usize = 256;
2946
2947/// Positive-definiteness floor on a Schur-complement Jacobi diagonal entry.
2948/// A diagonal at or below this value (or non-finite) signals a non-PD reduced
2949/// system: the preconditioner cannot invert it, so the PCG solve fails loudly
2950/// and demands operator regularization rather than returning a garbage scale.
2951pub(crate) const JACOBI_DIAGONAL_PD_FLOOR: f64 = 1e-18;
2952
2953impl JacobiPreconditioner {
2954 /// Build the block-Jacobi (or scalar fallback) preconditioner from the
2955 /// Arrow-Schur system without materializing the full dense Schur
2956 /// complement.
2957 ///
2958 /// When `sys.block_offsets` is non-empty and `max(block_size) ≤ 256`,
2959 /// each block gets a dense `b×b` Schur sub-matrix formed, factored, and
2960 /// stored. Otherwise every column gets its own scalar entry.
2961 pub(crate) fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
2962 sys: &ArrowSchurSystem,
2963 htt_factors: &ArrowFactorSlab,
2964 ridge_beta: f64,
2965 backend: &B,
2966 resident: Option<&SaeResidentReducedSchur>,
2967 ) -> Result<Self, ArrowSchurError> {
2968 let use_block = !sys.block_offsets.is_empty()
2969 && sys
2970 .block_offsets
2971 .iter()
2972 .map(|r| r.end.saturating_sub(r.start))
2973 .max()
2974 .unwrap_or(0)
2975 <= BLOCK_JACOBI_MAX_BLOCK;
2976 if use_block {
2977 if let Some(res) = resident {
2978 Self::build_block_jacobi_resident(sys, ridge_beta, res)
2979 } else {
2980 Self::build_block_jacobi(sys, htt_factors, ridge_beta, backend)
2981 }
2982 } else if let Some(res) = resident {
2983 // #1017 — SAE residency scalar Jacobi. The generic scalar build
2984 // probes `H_tβ^(i) e_a` and re-solves `(H_tt^(i))⁻¹` once for EVERY
2985 // (row, β-column) pair: `O(n·K)` triangular solves and `O(n·K·p)`
2986 // operator-probe work per Newton step, with `K = K_atoms·p` in the
2987 // tens of thousands at LLM shapes. The reduced-Schur diagonal is the
2988 // same quotient the resident `(L_i, Y_i)` factors already carry, so
2989 // read the diagonal straight off them in one support-sparse pass —
2990 // no probe, no per-column solve.
2991 Self::build_scalar_jacobi_resident(sys, ridge_beta, res)
2992 } else {
2993 Self::build_scalar_jacobi(sys, htt_factors, ridge_beta, backend)
2994 }
2995 }
2996
2997 /// Build scalar-diagonal Jacobi: one `BlockFactor::Scalar` of length 1
2998 /// per column. Matches pre-#283 semantics.
2999 ///
3000 /// When `sys.htbeta_matvec` is set and per-row `htbeta` slabs are absent,
3001 /// each column is probed via the matvec (one call per column per row).
3002 pub(crate) fn build_scalar_jacobi<B: BatchedBlockSolver + Sync>(
3003 sys: &ArrowSchurSystem,
3004 htt_factors: &ArrowFactorSlab,
3005 ridge_beta: f64,
3006 backend: &B,
3007 ) -> Result<Self, ArrowSchurError> {
3008 let k = sys.k;
3009 // Extract diagonal of H_ββ via penalty_diagonal_add (#296):
3010 // no Arc-clone; falls back to hbb_diag or hbb[[a,a]] inline.
3011 let mut diag = Array1::<f64>::zeros(k);
3012 {
3013 let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
3014 sys.penalty_diagonal_add(diag_slice);
3015 }
3016 for a in 0..k {
3017 diag[a] += ridge_beta;
3018 }
3019 // Per-row body: subtract this row's `Σ_a (H_tβ^(i)e_a)ᵀ(H_tt^(i))⁻¹
3020 // (H_tβ^(i)e_a)` contribution into a caller-provided length-`K` diagonal
3021 // accumulator (`-=`). For each column `a`, probe the cross-block (or read
3022 // the dense slab) and compute the scalar point-elimination quotient. The
3023 // `O(K)` solves per row are the build's whole cost; the row contributions
3024 // are independent length-`K` vectors, so a worker sums a chunk into a
3025 // private `diag_part` and the caller folds the partials back in chunk
3026 // order — bit-identical run-to-run (the #1017 preconditioner gate).
3027 let row_into = |i: usize, row: &ArrowRowBlock, diag_part: &mut Array1<f64>| {
3028 let di = sys.row_dims[i];
3029 // Dense-slab fast path (#1017): when the per-row cross-block is a
3030 // materialized `di × k` slab (no matrix-free operator), the entire
3031 // reduced-Schur diagonal contribution for this row is
3032 // `Σ_c H_tβ[c,a] · ((H_tt)⁻¹ H_tβ)[c,a]`. The generic loop below
3033 // re-solved `(H_tt)⁻¹` once PER COLUMN — `O(k)` block solves + `O(k)`
3034 // allocations per row, i.e. `O(n·k)` tiny solves per Newton step
3035 // (the dominant fixed per-solve cost at the SAE wide-border shape,
3036 // k in the tens of thousands). Solve all `k` columns in ONE batched
3037 // block solve instead, then take the column dots. Reassociates the
3038 // diagonal within the documented #1211 preconditioner margin (same as
3039 // the resident no-probe path), and the preconditioner only steers the
3040 // PCG iterate, which still terminates at the PCG tolerance.
3041 if sys.htbeta_matvec.is_none() && row.htbeta.dim() == (di, k) {
3042 let solved = backend.solve_block_matrix(htt_factors.factor(i), row.htbeta.view());
3043 for a in 0..k {
3044 let mut acc = 0.0;
3045 for c in 0..di {
3046 acc += row.htbeta[[c, a]] * solved[[c, a]];
3047 }
3048 diag_part[a] -= acc;
3049 }
3050 return;
3051 }
3052 // Matrix-free path: probe column a. `e_a` stays all-zero between
3053 // columns — set the single active entry and reset it after the probe,
3054 // so we never pay the `O(k)` `e_a.fill(0.0)` per column (that fill was
3055 // `O(n·k²)`). `sys_htbeta_apply_row` zeroes `col_i` internally.
3056 let mut col_i = Array1::<f64>::zeros(di);
3057 let mut e_a = Array1::<f64>::zeros(k);
3058 for a in 0..k {
3059 e_a[a] = 1.0;
3060 sys_htbeta_apply_row(sys, i, row, e_a.view(), &mut col_i);
3061 e_a[a] = 0.0;
3062 let solved = backend.solve_block_vector(htt_factors.factor(i), col_i.view());
3063 let mut acc = 0.0;
3064 for c in 0..di {
3065 acc += col_i[c] * solved[c];
3066 }
3067 diag_part[a] -= acc;
3068 }
3069 };
3070 let n = sys.rows.len();
3071 let parallel =
3072 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
3073 if parallel {
3074 use rayon::prelude::*;
3075 const CHUNK: usize = 64;
3076 let partials: Vec<Array1<f64>> = (0..n)
3077 .into_par_iter()
3078 .chunks(CHUNK)
3079 .map(|idxs| {
3080 let mut diag_part = Array1::<f64>::zeros(k);
3081 for i in idxs {
3082 row_into(i, &sys.rows[i], &mut diag_part);
3083 }
3084 diag_part
3085 })
3086 .collect();
3087 // Deterministic ordered reduction: fold chunk partials left-to-right.
3088 for part in &partials {
3089 for a in 0..k {
3090 diag[a] += part[a];
3091 }
3092 }
3093 } else {
3094 for (i, row) in sys.rows.iter().enumerate() {
3095 row_into(i, row, &mut diag);
3096 }
3097 }
3098 let mut blocks = Vec::with_capacity(k);
3099 for a in 0..k {
3100 let v = diag[a];
3101 if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
3102 return Err(ArrowSchurError::PcgFailed {
3103 reason: format!(
3104 "invalid Schur Jacobi diagonal at index {a}: {v}; \
3105 operator regularization is required"
3106 ),
3107 });
3108 }
3109 blocks.push(BlockFactor::Scalar {
3110 inv: Array1::from_elem(1, 1.0 / v),
3111 range: a..a + 1,
3112 });
3113 }
3114 Ok(Self { blocks })
3115 }
3116
3117 /// Build scalar-diagonal Jacobi from the pre-staged SAE residency factors
3118 /// `(L_i, Y_i)` (#1017).
3119 ///
3120 /// The generic [`Self::build_scalar_jacobi`] forms each reduced-Schur
3121 /// diagonal entry `S_aa = H_ββ,aa + ρ − Σ_i (H_tβ^(i) e_a)ᵀ(H_tt^(i))⁻¹(H_tβ^(i) e_a)`
3122 /// by probing the cross-block operator with the unit vector `e_a` and
3123 /// re-solving `(H_tt^(i))⁻¹` for every `(row, column)` pair — `O(n·K)`
3124 /// triangular solves per Newton step. For the SAE Kronecker cross-block the
3125 /// `a`-th column lives on exactly one active support entry: `a = beta_base + j`
3126 /// for some `(beta_base, φ) ∈ a_phi[i]` and output channel `j ∈ 0..p`, with
3127 /// `H_tβ^(i) e_a = φ · L_i[:, j]`. The point-elimination quotient is then
3128 ///
3129 /// ```text
3130 /// (H_tβ^(i) e_a)ᵀ (H_tt^(i))⁻¹ (H_tβ^(i) e_a)
3131 /// = φ² · L_i[:, j]ᵀ (H_tt^(i))⁻¹ L_i[:, j]
3132 /// = φ² · (L_i[:, j] · Y_i[:, j]), Y_i := (H_tt^(i))⁻¹ L_i.
3133 /// ```
3134 ///
3135 /// so the whole diagonal is accumulated in ONE support-sparse pass over the
3136 /// resident factors — no probe, no per-column solve, the staged `Y_i` reused
3137 /// from the matvec residency. The result is the SAME quotient the generic
3138 /// path computes (up to float reassociation of the row sum), so the PCG
3139 /// preconditioner is unchanged up to that f64 margin. Since the preconditioner
3140 /// only steers the iterate (which still terminates at the PCG tolerance), the
3141 /// criterion ranking is stable except for candidates within that margin,
3142 /// where the near-tie winner can flip — not an exact no-move guarantee (#1211).
3143 pub(crate) fn build_scalar_jacobi_resident(
3144 sys: &ArrowSchurSystem,
3145 ridge_beta: f64,
3146 resident: &SaeResidentReducedSchur,
3147 ) -> Result<Self, ArrowSchurError> {
3148 let k = sys.k;
3149 let p = resident.p;
3150 let n = resident.rows.len();
3151 // Seed with diag(H_ββ) + ridge — same penalty source the generic path
3152 // reads, so the only difference is how the point-elimination term is
3153 // gathered.
3154 let mut diag = Array1::<f64>::zeros(k);
3155 {
3156 let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
3157 sys.penalty_diagonal_add(diag_slice);
3158 }
3159 for a in 0..k {
3160 diag[a] += ridge_beta;
3161 }
3162 // Per-row point-elimination diagonal: for each active support entry
3163 // `(beta_base, φ)` and channel `j`, subtract `φ² · L_i[:, j]·Y_i[:, j]`
3164 // into `diag[beta_base + j]`. `L_i`/`Y_i` are row-major `di × p`, so the
3165 // `j`-th column dot is `Σ_r L_i[r·p + j]·Y_i[r·p + j]`.
3166 //
3167 // The accumulation is into a SHARED `diag` (rows scatter into overlapping
3168 // `beta_base + j` columns), so — like the generic `build_scalar_jacobi`
3169 // and the `schur_matvec` row loop (#1017) — parallelism uses worker-private
3170 // length-`K` partials folded back in chunk order: each chunk is a
3171 // contiguous ascending row range and rows within it stay ascending, so the
3172 // chunk-ordered fold reproduces the serial `row = 0..n` subtraction order
3173 // bit-for-bit run-to-run (the #1017 determinism gate). Run-to-run
3174 // bit-identity does not extend to bit-identity with the in-place serial
3175 // accumulation, so the preconditioner — and any criterion ranking it
3176 // steers — is stable only up to the chunk-reassociation margin; a near-tie
3177 // winner inside that margin can flip (#1211).
3178 // This build runs once per inexact-PCG solve = O(inner-Newton-iters)
3179 // per fit; at the SAE LLM shape (thousands of rows, wide border `k`) the
3180 // per-row support sweep is the build's whole cost and was on one core.
3181 // The per-channel column dot `col_dot[j] = Σ_r L_i[r·p+j]·Y_i[r·p+j]`
3182 // (the diagonal of `G_i = L_iᵀ(H_tt)⁻¹L_i`) depends ONLY on the row `i`,
3183 // not on the support entry `(beta_base, φ)`. The previous loop recomputed
3184 // it once per support entry — a row with `m` active atoms paid `m·p`
3185 // column dots over `di`. Hoist it: compute the `p` column dots once per
3186 // row into reusable `col_dot` scratch, then each support entry is a pure
3187 // scatter `diag[beta_base+j] -= φ²·col_dot[j]`. Bit-for-bit identical:
3188 // each `col_dot[j]` is the same `r`-ascending sum, and `φ²·col_dot[j]`
3189 // yields identical bits whether `col_dot[j]` was just computed or cached.
3190 let row_into = |row: usize, diag_part: &mut [f64], col_dot: &mut [f64]| {
3191 let rf = &resident.rows[row];
3192 let di = rf.di;
3193 if di == 0 {
3194 return;
3195 }
3196 let support = &resident.a_phi[row];
3197 if support.is_empty() {
3198 return;
3199 }
3200 // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte
3201 // the former per-row `rf.l` copy.
3202 let l_i = &resident.local_jac[row];
3203 for (j, slot) in col_dot.iter_mut().enumerate().take(p) {
3204 let mut acc = 0.0_f64;
3205 for r in 0..di {
3206 let idx = r * p + j;
3207 acc += l_i[idx] * rf.y[idx];
3208 }
3209 *slot = acc;
3210 }
3211 for &(beta_base, phi) in support {
3212 if phi == 0.0 {
3213 continue;
3214 }
3215 let phi2 = phi * phi;
3216 for j in 0..p {
3217 diag_part[beta_base + j] -= phi2 * col_dot[j];
3218 }
3219 }
3220 };
3221 let parallel =
3222 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
3223 if parallel {
3224 use rayon::prelude::*;
3225 const CHUNK: usize = 64;
3226 let partials: Vec<Array1<f64>> = (0..n)
3227 .into_par_iter()
3228 .chunks(CHUNK)
3229 .map(|idxs| {
3230 let mut diag_part = Array1::<f64>::zeros(k);
3231 let mut col_dot = vec![0.0_f64; p];
3232 let slice = diag_part
3233 .as_slice_mut()
3234 .expect("diag_part must be contiguous");
3235 for i in idxs {
3236 row_into(i, slice, &mut col_dot);
3237 }
3238 diag_part
3239 })
3240 .collect();
3241 // Deterministic ordered reduction: fold chunk partials left-to-right
3242 // (each partial already holds the per-row terms subtracted, so add
3243 // them into `diag` in chunk order to mirror the serial subtraction).
3244 for part in &partials {
3245 for a in 0..k {
3246 diag[a] += part[a];
3247 }
3248 }
3249 } else {
3250 let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
3251 let mut col_dot = vec![0.0_f64; p];
3252 for row in 0..n {
3253 row_into(row, diag_slice, &mut col_dot);
3254 }
3255 }
3256 let mut blocks = Vec::with_capacity(k);
3257 for a in 0..k {
3258 let v = diag[a];
3259 if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
3260 return Err(ArrowSchurError::PcgFailed {
3261 reason: format!(
3262 "invalid SAE-resident Schur Jacobi diagonal at index {a}: {v}; \
3263 operator regularization is required"
3264 ),
3265 });
3266 }
3267 blocks.push(BlockFactor::Scalar {
3268 inv: Array1::from_elem(1, 1.0 / v),
3269 range: a..a + 1,
3270 });
3271 }
3272 Ok(Self { blocks })
3273 }
3274
3275 /// Build block-Jacobi from the pre-staged SAE residency factors `(L_i, Y_i)`.
3276 ///
3277 /// This is the block analogue of [`Self::build_scalar_jacobi_resident`].
3278 /// When SAE block offsets are small enough to select BetaBlockJacobi (for
3279 /// example per-atom decoder blocks with `basis_size·p <= 256`), the generic
3280 /// block builder materializes every row's dense `(d_i × K)` `H_tβ` by probing
3281 /// the matrix-free operator, then re-solves `(H_tt)⁻¹` for each block column.
3282 /// The resident factors already carry `G_i = L_iᵀ(H_tt)⁻¹L_i`, so each block
3283 /// is assembled by scattering only the active support pairs inside that block:
3284 ///
3285 /// ```text
3286 /// S_block -= Σ_i Σ_(s,t in block support) φ_s φ_t · G_i[channel_s, channel_t]
3287 /// ```
3288 ///
3289 /// It computes the same block-diagonal restriction as the generic path, but
3290 /// avoids the full-row `H_tβ` materialization and per-column triangular solves.
3291 pub(crate) fn build_block_jacobi_resident(
3292 sys: &ArrowSchurSystem,
3293 ridge_beta: f64,
3294 resident: &SaeResidentReducedSchur,
3295 ) -> Result<Self, ArrowSchurError> {
3296 let block_offsets = &sys.block_offsets;
3297 let p = resident.p;
3298 let mut schur_blocks: Vec<Array2<f64>> = Vec::with_capacity(block_offsets.len());
3299 for (block_idx, range) in block_offsets.iter().enumerate() {
3300 let b = range.end - range.start;
3301 let mut schur_block = Array2::<f64>::zeros((b, b));
3302 sys.penalty_block_add(
3303 BetaBlockId(block_idx),
3304 block_offsets.as_ref(),
3305 &mut schur_block,
3306 );
3307 for bi in 0..b {
3308 schur_block[[bi, bi]] += ridge_beta;
3309 }
3310 schur_blocks.push(schur_block);
3311 }
3312
3313 let row_into = |row: usize, blocks: &mut [Array2<f64>]| {
3314 let rf = &resident.rows[row];
3315 let di = rf.di;
3316 if di == 0 {
3317 return;
3318 }
3319 let support = &resident.a_phi[row];
3320 if support.is_empty() {
3321 return;
3322 }
3323 // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte
3324 // the former per-row `rf.l` copy.
3325 let l_i = &resident.local_jac[row];
3326 for (block_idx, range) in block_offsets.iter().enumerate() {
3327 let block = &mut blocks[block_idx];
3328 for &(base_left, phi_left) in support {
3329 if phi_left == 0.0 {
3330 continue;
3331 }
3332 let left_start = base_left.max(range.start);
3333 let left_end = (base_left + p).min(range.end);
3334 if left_start >= left_end {
3335 continue;
3336 }
3337 for &(base_right, phi_right) in support {
3338 if phi_right == 0.0 {
3339 continue;
3340 }
3341 let right_start = base_right.max(range.start);
3342 let right_end = (base_right + p).min(range.end);
3343 if right_start >= right_end {
3344 continue;
3345 }
3346 let phi = phi_left * phi_right;
3347 for gi in left_start..left_end {
3348 let li = gi - range.start;
3349 let ch_i = gi - base_left;
3350 for gj in right_start..right_end {
3351 let lj = gj - range.start;
3352 let ch_j = gj - base_right;
3353 let mut gij = 0.0_f64;
3354 for r in 0..di {
3355 gij += l_i[r * p + ch_i] * rf.y[r * p + ch_j];
3356 }
3357 block[[li, lj]] -= phi * gij;
3358 }
3359 }
3360 }
3361 }
3362 }
3363 };
3364
3365 let n = resident.rows.len();
3366 let parallel =
3367 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
3368 if parallel {
3369 use rayon::prelude::*;
3370 const CHUNK: usize = 64;
3371 let n_blocks = block_offsets.len();
3372 let block_dims: Vec<usize> = block_offsets.iter().map(|r| r.end - r.start).collect();
3373 let partials: Vec<Vec<Array2<f64>>> = (0..n)
3374 .into_par_iter()
3375 .chunks(CHUNK)
3376 .map(|idxs| {
3377 let mut local: Vec<Array2<f64>> = block_dims
3378 .iter()
3379 .map(|&b| Array2::<f64>::zeros((b, b)))
3380 .collect();
3381 for i in idxs {
3382 row_into(i, &mut local);
3383 }
3384 local
3385 })
3386 .collect();
3387 for local in &partials {
3388 for bidx in 0..n_blocks {
3389 schur_blocks[bidx] += &local[bidx];
3390 }
3391 }
3392 } else {
3393 for row in 0..n {
3394 row_into(row, &mut schur_blocks);
3395 }
3396 }
3397
3398 let mut blocks = Vec::with_capacity(block_offsets.len());
3399 for (block_idx, range) in block_offsets.iter().enumerate() {
3400 let b = range.end - range.start;
3401 let schur_block = &schur_blocks[block_idx];
3402 let factor_opt = {
3403 use faer::Side;
3404 let view = FaerArrayView::new(schur_block);
3405 FaerLlt::new(view.as_ref(), Side::Lower).ok()
3406 };
3407 if let Some(llt) = factor_opt {
3408 blocks.push(BlockFactor::Chol {
3409 factor: llt,
3410 range: range.clone(),
3411 });
3412 } else {
3413 let mut inv = Array1::<f64>::zeros(b);
3414 for bi in 0..b {
3415 let v = schur_block[[bi, bi]];
3416 if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
3417 return Err(ArrowSchurError::PcgFailed {
3418 reason: format!(
3419 "SAE-resident block Jacobi scalar fallback: non-PD diagonal at \
3420 global index {}: {v}; regularization required",
3421 range.start + bi
3422 ),
3423 });
3424 }
3425 inv[bi] = 1.0 / v;
3426 }
3427 blocks.push(BlockFactor::Scalar {
3428 inv,
3429 range: range.clone(),
3430 });
3431 }
3432 }
3433 Ok(Self { blocks })
3434 }
3435
3436 /// Build term-block Jacobi: one dense `b×b` Schur block per term in
3437 /// `sys.block_offsets`.
3438 pub(crate) fn build_block_jacobi<B: BatchedBlockSolver + Sync>(
3439 sys: &ArrowSchurSystem,
3440 htt_factors: &ArrowFactorSlab,
3441 ridge_beta: f64,
3442 backend: &B,
3443 ) -> Result<Self, ArrowSchurError> {
3444 let block_offsets = &sys.block_offsets;
3445
3446 // Initialise every b×b Schur sub-block from H_ββ + ridge·I via
3447 // penalty_block_add (#296): routes to penalty_op or falls back to
3448 // hbb / hbb_diag inline without Arc-clone per loop iteration. These are
3449 // the block-diagonal restrictions of the reduced Schur complement; the
3450 // per-row cross-block contributions are accumulated in the row sweep
3451 // below.
3452 let mut schur_blocks: Vec<Array2<f64>> = Vec::with_capacity(block_offsets.len());
3453 for (block_idx, range) in block_offsets.iter().enumerate() {
3454 let b = range.end - range.start;
3455 let mut schur_block = Array2::<f64>::zeros((b, b));
3456 sys.penalty_block_add(
3457 BetaBlockId(block_idx),
3458 block_offsets.as_ref(),
3459 &mut schur_block,
3460 );
3461 for bi in 0..b {
3462 schur_block[[bi, bi]] += ridge_beta;
3463 }
3464 schur_blocks.push(schur_block);
3465 }
3466
3467 // Subtract Schur contributions:
3468 // S_kk -= H_βt_k^(i) (H_tt^(i))^{-1} H_tβ_k^(i)
3469 //
3470 // Materialize each row's (d_i × K) cross-block ONCE and scatter its
3471 // contribution into every block-diagonal sub-block — mirroring the
3472 // row-outer structure of `build_dense_schur_direct`. The previous
3473 // block-outer form re-materialized every row for each β-block
3474 // (O(n_blocks · n · K) probes); for the matrix-free softmax cross-block
3475 // each materialize is itself O(K²), so that nesting made the
3476 // preconditioner build quadratically more expensive than the direct
3477 // dense Schur it preconditions. sys_htbeta_materialize_row handles the
3478 // Kronecker / htbeta_matvec path transparently.
3479 // Per-row body: materialize the row's `(d_i × K)` cross-block once and
3480 // subtract its `H_βt_k^(i)(H_tt^(i))⁻¹H_tβ_k^(i)` contribution into EACH
3481 // block-diagonal sub-block. Writes INTO a caller-provided `blocks`
3482 // accumulator (`-=`) so a rayon worker can subtract a chunk's rows into
3483 // a worker-private zero-seeded `Vec<Array2>` and the caller folds the
3484 // chunk partials back in chunk order — bit-identical run-to-run
3485 // regardless of thread scheduling (the #1017 verification gate). This
3486 // is deterministic and within the chunk-reassociation margin of serial,
3487 // so the preconditioner, hence the criterion ranking, is stable except
3488 // for near-tie candidates inside that f64 margin — not an exact no-move
3489 // guarantee (#1211).
3490 let row_into = |i: usize,
3491 row: &ArrowRowBlock,
3492 blocks: &mut [Array2<f64>]|
3493 -> Result<(), ArrowSchurError> {
3494 let di = sys.row_dims[i];
3495 let htbeta_full = sys_htbeta_materialize_row(sys, i, row)?;
3496 for (block_idx, range) in block_offsets.iter().enumerate() {
3497 let b = range.end - range.start;
3498 let mut solved_cols = Array2::<f64>::zeros((di, b));
3499 for bj in 0..b {
3500 let gj = range.start + bj;
3501 let rhs = htbeta_full.column(gj).to_owned();
3502 let solved = backend.solve_block_vector(htt_factors.factor(i), rhs.view());
3503 for c in 0..di {
3504 solved_cols[[c, bj]] = solved[c];
3505 }
3506 }
3507 let schur_block = &mut blocks[block_idx];
3508 for bi in 0..b {
3509 let gi = range.start + bi;
3510 for bj in 0..b {
3511 let mut acc = 0.0;
3512 for c in 0..di {
3513 acc += htbeta_full[[c, gi]] * solved_cols[[c, bj]];
3514 }
3515 schur_block[[bi, bj]] -= acc;
3516 }
3517 }
3518 }
3519 Ok(())
3520 };
3521 // Each row materializes an `O(K²)` cross-block (Kronecker) plus `Σ_k b_k`
3522 // triangular solves — the preconditioner build's whole per-row cost at
3523 // the SAE LLM shape (#1017), and the rows are independent. Fan over fixed
3524 // row chunks above the threshold, staying serial for the handful-of-rows
3525 // non-SAE callers and inside a rayon worker (topology-race nesting guard)
3526 // — the same gate `schur_matvec` uses.
3527 let n = sys.rows.len();
3528 let parallel =
3529 n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
3530 if parallel {
3531 use rayon::prelude::*;
3532 const CHUNK: usize = 64;
3533 let n_blocks = block_offsets.len();
3534 let block_dims: Vec<usize> = block_offsets.iter().map(|r| r.end - r.start).collect();
3535 let partials: Vec<Vec<Array2<f64>>> = (0..n)
3536 .into_par_iter()
3537 .chunks(CHUNK)
3538 .map(|idxs| {
3539 let mut local: Vec<Array2<f64>> = block_dims
3540 .iter()
3541 .map(|&b| Array2::<f64>::zeros((b, b)))
3542 .collect();
3543 for i in idxs {
3544 row_into(i, &sys.rows[i], &mut local)?;
3545 }
3546 Ok::<_, ArrowSchurError>(local)
3547 })
3548 .collect::<Result<Vec<_>, _>>()?;
3549 // Deterministic ordered reduction: fold chunk partials left-to-right.
3550 for local in &partials {
3551 for bidx in 0..n_blocks {
3552 schur_blocks[bidx] += &local[bidx];
3553 }
3554 }
3555 } else {
3556 for (i, row) in sys.rows.iter().enumerate() {
3557 row_into(i, row, &mut schur_blocks)?;
3558 }
3559 }
3560
3561 // Factor each accumulated block: LLT, with scalar-diagonal fallback for
3562 // a block that comes out non-PD at this ridge.
3563 let mut blocks = Vec::with_capacity(block_offsets.len());
3564 for (block_idx, range) in block_offsets.iter().enumerate() {
3565 let b = range.end - range.start;
3566 let schur_block = &schur_blocks[block_idx];
3567 let factor_opt = {
3568 use faer::Side;
3569 let view = FaerArrayView::new(schur_block);
3570 FaerLlt::new(view.as_ref(), Side::Lower).ok()
3571 };
3572 if let Some(llt) = factor_opt {
3573 blocks.push(BlockFactor::Chol {
3574 factor: llt,
3575 range: range.clone(),
3576 });
3577 } else {
3578 // Non-PD block: fall back to scalar diagonal for this block.
3579 let mut inv = Array1::<f64>::zeros(b);
3580 for bi in 0..b {
3581 let v = schur_block[[bi, bi]];
3582 if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
3583 return Err(ArrowSchurError::PcgFailed {
3584 reason: format!(
3585 "block Jacobi scalar fallback: non-PD diagonal at \
3586 global index {}: {v}; regularization required",
3587 range.start + bi
3588 ),
3589 });
3590 }
3591 inv[bi] = 1.0 / v;
3592 }
3593 blocks.push(BlockFactor::Scalar {
3594 inv,
3595 range: range.clone(),
3596 });
3597 }
3598 }
3599 Ok(Self { blocks })
3600 }
3601
3602 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
3603 let mut out = Array1::<f64>::zeros(r.len());
3604 for block in &self.blocks {
3605 match block {
3606 BlockFactor::Scalar { inv, range } => {
3607 for (local, gi) in range.clone().enumerate() {
3608 out[gi] = inv[local] * r[gi];
3609 }
3610 }
3611 BlockFactor::Chol { factor, range } => {
3612 let b = range.end - range.start;
3613 let mut rhs = Array1::<f64>::zeros(b);
3614 for (local, gi) in range.clone().enumerate() {
3615 rhs[local] = r[gi];
3616 }
3617 use faer::linalg::solvers::Solve;
3618 let stride = rhs.strides()[0];
3619 let len = rhs.len();
3620 // SAFETY: rhs is a uniquely-borrowed contiguous Array1
3621 // with positive stride (standard layout).
3622 let rhs_mat =
3623 unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
3624 let solved = factor.solve(rhs_mat);
3625 for (local, gi) in range.clone().enumerate() {
3626 out[gi] = solved[(local, 0)];
3627 }
3628 }
3629 }
3630 }
3631 out
3632 }
3633}
3634
3635// ---------------------------------------------------------------------------
3636// Preconditioner ladder: SchurPreconditionerKind, ClusterJacobi,
3637// AdditiveSchwarz (issue #299)
3638// ---------------------------------------------------------------------------
3639
3640/// Which Schur preconditioner to use in the inexact-PCG path.
3641///
3642/// Ladder ordered by cost / effectiveness:
3643/// - `Diagonal`: scalar Jacobi (pre-#283 behaviour).
3644/// - `BetaBlockJacobi`: block-Jacobi per `block_offsets` term (#287).
3645/// - `ClusterJacobi`: one dense block per beta-graph connected component.
3646/// - `AdditiveSchwarz { overlap }`: component + `overlap`-hop expansion,
3647/// overlapping columns averaged by partition-of-unity weights (full dense
3648/// local-inverse apply per subdomain).
3649/// - `DiagAssembledSchwarz { overlap }`: the cheap Schwarz variant (#299) —
3650/// same overlapping decomposition, but each subdomain contributes only the
3651/// diagonal of its local inverse `(A_k⁻¹)_ii`, assembled additively with
3652/// partition-of-unity weights into a single `O(K)`-apply diagonal.
3653/// - `BlockIncompleteCholesky`: level-0 incomplete Cholesky (#299). Within each
3654/// connected component of the β-coupling graph the dense reduced-Schur block
3655/// `S[C,C]` is assembled once, its structural-nonzero pattern is taken as the
3656/// level-0 fill pattern, and a no-fill incomplete Cholesky `S ≈ L̃ L̃ᵀ` is
3657/// formed keeping ONLY that pattern (Saad, *Iterative Methods*, IC(0)). Apply
3658/// is a sparse triangular forward/back solve over `nnz(S[C,C])`, so for a
3659/// large component with internal sparsity it is far cheaper to build and apply
3660/// than `ClusterJacobi`'s full dense Cholesky (which fills the whole `b×b`
3661/// factor) while retaining the inter-block coupling that ClusterJacobi keeps
3662/// but the diagonal/Schwarz tiers discard. A non-PD incomplete pivot degrades
3663/// that component to the scalar reciprocal diagonal.
3664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3665pub enum SchurPreconditionerKind {
3666 Diagonal,
3667 BetaBlockJacobi,
3668 ClusterJacobi,
3669 /// Cluster-Jacobi whose blocks come from the bounded co-visibility PARTITION
3670 /// (`BetaCouplingGraph::covisibility_cluster_partition`) rather than the
3671 /// connected-component partition. At real over-complete widths the co-firing
3672 /// graph is a single giant component, so plain `ClusterJacobi` exceeds the
3673 /// size cap and degrades to scalar Jacobi; this tier splits that component
3674 /// into bounded strongly-co-firing clusters so the dense per-cluster factor
3675 /// conditions the cross-atom coupling scalar Jacobi cannot see.
3676 CoVisibilityClusterJacobi,
3677 AdditiveSchwarz {
3678 overlap: usize,
3679 },
3680 DiagAssembledSchwarz {
3681 overlap: usize,
3682 },
3683 BlockIncompleteCholesky,
3684}
3685
3686/// Escalate beyond BetaBlockJacobi only when K exceeds this value and PCG
3687/// exhausted `max_iterations`.
3688pub(crate) const PRECOND_ESCALATE_K_THRESHOLD: usize = 100;
3689
3690/// #1026 matrix-free Schur curvature-floor (the unbounded-PCG analogue of the
3691/// dense `spectral_pd_floored_schur`). On `pᵀSp ≤ 0` in the unbounded SAE inner
3692/// PCG, the operator ridge is lifted by the minimal amount that restores
3693/// positive curvature along the offending direction, plus this fractional
3694/// margin (so the next CG iterate sits strictly inside the positive cone, not on
3695/// the `0` knife-edge).
3696pub(crate) const SCHUR_CURVATURE_FLOOR_MARGIN: f64 = 1.0e-2;
3697/// Lower bound on the curvature-floor ridge bump, relative to the rhs scale, so
3698/// a `pᵀSp` that rounds to exactly `0` still gets a strictly positive bump.
3699pub(crate) const SCHUR_CURVATURE_FLOOR_REL_FLOOR: f64 = 1.0e-12;
3700/// Ceiling on the accumulated curvature-floor ridge, relative to the rhs scale.
3701/// Beyond this the operator is treated as un-conditionable by a minimal floor
3702/// and the recoverable failure is handed to the outer LM loop (which re-forms
3703/// the whole system at a heavier ridge). Generous so that a large collapsed
3704/// over-subtraction `(H_tβ)²/H_tt` is still reachable.
3705pub(crate) const SCHUR_CURVATURE_FLOOR_REL_CEILING: f64 = 1.0e12;
3706/// Multiplicative growth for the DIAGONAL-refusal ridge escalation (no
3707/// `(curvature, ‖p‖²)` deficit is available there), matching the per-row
3708/// `factor_one_row_result` `RIDGE_GROWTH_FACTOR`.
3709pub(crate) const SCHUR_CURVATURE_FLOOR_DIAG_GROWTH: f64 = 10.0;
3710/// Max curvature-floor ridge-lift attempts before deferring to the outer LM
3711/// loop. The diagonal-refusal path grows ×10 per attempt, so this bounds the
3712/// reachable ridge at `rhs_scale · 10^(attempts)` — ample for any realistic
3713/// over-subtraction while still bounded.
3714pub(crate) const SCHUR_CURVATURE_FLOOR_MAX_ATTEMPTS: usize = 24;
3715
3716/// Cholesky or scalar factor for one cluster of the beta-coefficient graph.
3717#[derive(Clone)]
3718pub(crate) enum ClusterFactor {
3719 Chol {
3720 cols: Vec<usize>,
3721 factor: FaerLlt<f64>,
3722 },
3723 Scalar {
3724 cols: Vec<usize>,
3725 inv: Vec<f64>,
3726 },
3727}
3728
3729impl std::fmt::Debug for ClusterFactor {
3730 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3731 match self {
3732 ClusterFactor::Chol { cols, .. } => {
3733 write!(f, "ClusterFactor::Chol {{ cols.len: {} }}", cols.len())
3734 }
3735 ClusterFactor::Scalar { cols, inv } => write!(
3736 f,
3737 "ClusterFactor::Scalar {{ cols.len: {}, inv.len: {} }}",
3738 cols.len(),
3739 inv.len()
3740 ),
3741 }
3742 }
3743}
3744
3745/// Maximum columns per cluster before scalar fallback.
3746pub(crate) const CLUSTER_JACOBI_MAX_CLUSTER: usize = 512;
3747
3748/// Host-memory budget for ONE cluster's dense reduced-Schur Cholesky factor
3749/// (the `b×b` f64 `L` the cluster-Jacobi preconditioner stores and applies).
3750///
3751/// The co-visibility cluster partition caps a cluster's total column count `b`
3752/// at the largest value whose factor fits this budget, `b_max = ⌊√(budget/8)⌋`
3753/// (`8b²` bytes for an `f64` `b×b` factor). This DERIVES the cluster-size cap
3754/// from the factor's memory footprint rather than asserting a bare number:
3755/// beyond `b_max` the dense factor's `O(b²)` apply also throttles the CG
3756/// iteration budget, so the cap is the point past which a single dense block
3757/// stops being the right preconditioner and the partition must split instead.
3758/// 2 MiB ⇒ `b_max = 512`, pinned equal to [`CLUSTER_JACOBI_MAX_CLUSTER`] by
3759/// [`tests::covisibility_cap_is_derived_from_factor_budget`] so the co-visibility
3760/// partition and the legacy scalar-fallback ceiling agree by construction.
3761pub(crate) const CLUSTER_SCHUR_FACTOR_BYTES_BUDGET: u128 = 2 * 1024 * 1024;
3762
3763/// Derived co-visibility cluster-size cap (columns): the largest `b` whose dense
3764/// `b×b` f64 Cholesky factor fits [`CLUSTER_SCHUR_FACTOR_BYTES_BUDGET`]. See that
3765/// constant for the memory justification. Never below 1.
3766pub(crate) fn covisibility_cluster_max_cols() -> usize {
3767 let b = ((CLUSTER_SCHUR_FACTOR_BYTES_BUDGET / 8) as f64)
3768 .sqrt()
3769 .floor() as usize;
3770 b.max(1)
3771}
3772
3773/// Maximum columns in a single connected component for which the IC(0)
3774/// preconditioner assembles the dense `S[C,C]` to derive its sparsity pattern.
3775/// IC(0) is cheap to APPLY at any size, but the pattern is read from the dense
3776/// assembly, which is `O(b²)` memory; beyond this the component falls back to
3777/// the scalar reciprocal diagonal (the same ceiling concern as
3778/// `CLUSTER_JACOBI_MAX_CLUSTER`, lifted because the IC(0) FACTOR is sparse).
3779pub(crate) const IC0_MAX_COMPONENT: usize = 4096;
3780
3781/// Relative threshold below which an assembled `S[i,j]` is treated as a
3782/// structural zero when deriving the IC(0) level-0 pattern. Scaled by
3783/// `sqrt(|S_ii|·|S_jj|)` so it is invariant to column scaling; this prunes
3784/// entries that are pure FMA round-off (a genuinely decoupled `(i,j)` pair
3785/// assembles to ~0) so they do not enter the kept fill pattern.
3786pub(crate) const IC0_PATTERN_REL_DROP: f64 = 1.0e-13;
3787
3788/// Assemble the dense `b×b` reduced-Schur block for the column set `cols`:
3789/// `S[cols, cols] = H_ββ[cols, cols] + ridge·I − Σ_i H_tβ[cols]ᵀ (H_tt^i)⁻¹ H_tβ[cols]`.
3790///
3791/// Shared by `ClusterJacobiPreconditioner::build_from_column_groups` (which
3792/// Cholesky-factors the returned block) and `DiagAssembledSchwarzPreconditioner`
3793/// (which inverts each subdomain block and keeps only its diagonal). The result
3794/// is the LOWER triangle filled by the row reduction; callers that need the full
3795/// symmetric block must `symmetrize_upper_from_lower`.
3796///
3797/// The per-row Schur contribution is fanned over fixed 64-row chunks above
3798/// `SCHUR_MATVEC_PARALLEL_ROW_MIN` and folded left-to-right so the assembly is
3799/// bit-identical to the serial path (and run-to-run deterministic), exactly as
3800/// in `build_block_jacobi` (#1017).
3801pub(crate) fn assemble_local_schur_block<B: BatchedBlockSolver + Sync>(
3802 sys: &ArrowSchurSystem,
3803 htt_factors: &ArrowFactorSlab,
3804 ridge_beta: f64,
3805 backend: &B,
3806 cols: &[usize],
3807) -> Array2<f64> {
3808 let b = cols.len();
3809 let mut s_block = Array2::<f64>::zeros((b, b));
3810 // Initialise from H_ββ via penalty_subblock_add (#296): routes through
3811 // penalty_op or falls back to hbb / hbb_diag inline.
3812 sys.penalty_subblock_add(cols, &mut s_block);
3813 for bi in 0..b {
3814 s_block[[bi, bi]] += ridge_beta;
3815 }
3816 let cluster_row_into = |row_idx: usize, row: &ArrowRowBlock, acc: &mut Array2<f64>| {
3817 // Materialize the b needed cross-block columns through the ROUTED
3818 // `H_tβ` convention (`sys_htbeta_apply_row`: matrix-free operator plus
3819 // any dense supplement) at the row's OWN width `di` — never a raw
3820 // `row.htbeta` read at the global `sys.d`: matvec-backed rows carry
3821 // absent/zero-sized slabs by contract (a raw read is wrong or panics),
3822 // and per-row widths vary.
3823 let di = sys.row_dims[row_idx];
3824 let mut e_g = Array1::<f64>::zeros(sys.k);
3825 let mut col_i = Array1::<f64>::zeros(di);
3826 let mut cols_mat = Array2::<f64>::zeros((di, b));
3827 let mut solved_cols = Array2::<f64>::zeros((di, b));
3828 for bj in 0..b {
3829 let gj = cols[bj];
3830 e_g[gj] = 1.0;
3831 sys_htbeta_apply_row(sys, row_idx, row, e_g.view(), &mut col_i);
3832 e_g[gj] = 0.0;
3833 let solved = backend.solve_block_vector(htt_factors.factor(row_idx), col_i.view());
3834 for c in 0..di {
3835 cols_mat[[c, bj]] = col_i[c];
3836 solved_cols[[c, bj]] = solved[c];
3837 }
3838 }
3839 for bi in 0..b {
3840 for bj in 0..b {
3841 let mut dot = 0.0;
3842 for c in 0..di {
3843 dot += cols_mat[[c, bi]] * solved_cols[[c, bj]];
3844 }
3845 acc[[bi, bj]] -= dot;
3846 }
3847 }
3848 };
3849 let n = sys.rows.len();
3850 let parallel = n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
3851 if parallel {
3852 use rayon::prelude::*;
3853 const CHUNK: usize = 64;
3854 let partials: Vec<Array2<f64>> = (0..n)
3855 .into_par_iter()
3856 .chunks(CHUNK)
3857 .map(|idxs| {
3858 let mut local = Array2::<f64>::zeros((b, b));
3859 for i in idxs {
3860 cluster_row_into(i, &sys.rows[i], &mut local);
3861 }
3862 local
3863 })
3864 .collect();
3865 for local in &partials {
3866 s_block += local;
3867 }
3868 } else {
3869 for (row_idx, row) in sys.rows.iter().enumerate() {
3870 cluster_row_into(row_idx, row, &mut s_block);
3871 }
3872 }
3873 s_block
3874}
3875
3876/// Column groups for the bounded co-visibility cluster preconditioner.
3877///
3878/// Builds the weighted co-firing graph over `sys.block_offsets` and returns the
3879/// column sets of its bounded co-visibility partition
3880/// (`BetaCouplingGraph::covisibility_cluster_partition`), each capped at
3881/// [`covisibility_cluster_max_cols`] columns. With no registered block offsets
3882/// there is no block structure to cluster, so the whole `0..k` border is one
3883/// group (identical to the component-partition builders' `block_offsets`-empty
3884/// case). Each group's columns are sorted ascending.
3885pub(crate) fn covisibility_column_groups(sys: &ArrowSchurSystem) -> Vec<Vec<usize>> {
3886 if sys.block_offsets.is_empty() {
3887 return vec![(0..sys.k).collect()];
3888 }
3889 let graph = BetaCouplingGraph::build(
3890 &sys.block_offsets,
3891 &sys.rows
3892 .iter()
3893 .map(|r| r.htbeta.clone())
3894 .collect::<Vec<_>>(),
3895 );
3896 graph
3897 .covisibility_cluster_partition(&sys.block_offsets, covisibility_cluster_max_cols())
3898 .iter()
3899 .map(|blocks| {
3900 let mut cols: Vec<usize> = blocks
3901 .iter()
3902 .flat_map(|&b| sys.block_offsets[b].clone())
3903 .collect();
3904 cols.sort_unstable();
3905 cols
3906 })
3907 .collect()
3908}
3909
3910/// Dense Schur block per connected component of the beta-coupling graph.
3911///
3912/// Nodes = beta blocks (`block_offsets`); edges = rows where two blocks
3913/// co-occur with nonzero `H_t_beta` entries. One Cholesky factor per
3914/// connected component; applied as a triangular solve.
3915#[derive(Debug, Clone)]
3916pub struct ClusterJacobiPreconditioner {
3917 pub(crate) clusters: Vec<ClusterFactor>,
3918}
3919
3920impl ClusterJacobiPreconditioner {
3921 pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
3922 sys: &ArrowSchurSystem,
3923 htt_factors: &ArrowFactorSlab,
3924 ridge_beta: f64,
3925 backend: &B,
3926 ) -> Result<Self, ArrowSchurError> {
3927 if sys.block_offsets.is_empty() {
3928 let cols: Vec<usize> = (0..sys.k).collect();
3929 return Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &[cols]);
3930 }
3931 let graph = BetaCouplingGraph::build(
3932 &sys.block_offsets,
3933 &sys.rows
3934 .iter()
3935 .map(|r| r.htbeta.clone())
3936 .collect::<Vec<_>>(),
3937 );
3938 let col_groups: Vec<Vec<usize>> = graph
3939 .component_partition()
3940 .iter()
3941 .map(|comp_blocks| {
3942 let mut cols: Vec<usize> = comp_blocks
3943 .iter()
3944 .flat_map(|&b| sys.block_offsets[b].clone())
3945 .collect();
3946 cols.sort_unstable();
3947 cols
3948 })
3949 .collect();
3950 Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
3951 }
3952
3953 /// Cluster-Jacobi from the bounded CO-VISIBILITY partition (Kushal & Agarwal,
3954 /// CVPR 2012) — the default above the size cap.
3955 ///
3956 /// [`Self::from_arrow_schur`] groups β-blocks by CONNECTED COMPONENT of the
3957 /// co-firing graph. At real over-complete SAE widths that graph is a single
3958 /// giant component (transitive co-firing), so the lone component's column
3959 /// count exceeds [`CLUSTER_JACOBI_MAX_CLUSTER`] and
3960 /// [`Self::build_from_column_groups`] degrades the whole tier to the scalar
3961 /// reciprocal diagonal — the scaling ceiling (cross-atom coupling through
3962 /// co-activating atoms with overlapping ambient subspaces is dropped, and PCG
3963 /// iteration counts blow up). This builder instead partitions the co-firing
3964 /// graph into clusters bounded by [`covisibility_cluster_max_cols`], keeping
3965 /// the strongest co-firing edges inside a cluster, so each cluster's dense
3966 /// Cholesky conditions the strong cross-atom coupling the scalar diagonal
3967 /// misses while staying inside the per-factor memory budget.
3968 ///
3969 /// With no registered `block_offsets` (or a graph that fits the cap in one
3970 /// piece) the partition is a single group and this coincides with
3971 /// [`Self::from_arrow_schur`]. Because the preconditioner only steers the CG
3972 /// iterate over the SAME reduced operator, the solve converges to the SAME
3973 /// reduced-system solution regardless of the partition — REML-neutral.
3974 pub(crate) fn from_arrow_schur_covisibility<B: BatchedBlockSolver + Sync>(
3975 sys: &ArrowSchurSystem,
3976 htt_factors: &ArrowFactorSlab,
3977 ridge_beta: f64,
3978 backend: &B,
3979 ) -> Result<Self, ArrowSchurError> {
3980 let col_groups = covisibility_column_groups(sys);
3981 Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
3982 }
3983
3984 pub(crate) fn build_from_column_groups<B: BatchedBlockSolver + Sync>(
3985 sys: &ArrowSchurSystem,
3986 htt_factors: &ArrowFactorSlab,
3987 ridge_beta: f64,
3988 backend: &B,
3989 col_groups: &[Vec<usize>],
3990 ) -> Result<Self, ArrowSchurError> {
3991 let mut clusters = Vec::with_capacity(col_groups.len());
3992 for cols in col_groups {
3993 let b = cols.len();
3994 if b == 0 {
3995 continue;
3996 }
3997 if b > CLUSTER_JACOBI_MAX_CLUSTER {
3998 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
3999 clusters.push(ClusterFactor::Scalar {
4000 cols: cols.clone(),
4001 inv,
4002 });
4003 continue;
4004 }
4005 let mut s_block =
4006 assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
4007 symmetrize_upper_from_lower(&mut s_block);
4008 let factor_opt = {
4009 use faer::Side;
4010 let view = FaerArrayView::new(&s_block);
4011 FaerLlt::new(view.as_ref(), Side::Lower).ok()
4012 };
4013 if let Some(llt) = factor_opt {
4014 clusters.push(ClusterFactor::Chol {
4015 cols: cols.clone(),
4016 factor: llt,
4017 });
4018 } else {
4019 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
4020 clusters.push(ClusterFactor::Scalar {
4021 cols: cols.clone(),
4022 inv,
4023 });
4024 }
4025 }
4026 Ok(Self { clusters })
4027 }
4028
4029 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
4030 let mut out = Array1::<f64>::zeros(r.len());
4031 for cluster in &self.clusters {
4032 apply_cluster(cluster, r, &mut out, &ClusterApplyMode::Overwrite);
4033 }
4034 out
4035 }
4036}
4037
4038/// Additive Schwarz: base components expanded by `overlap` graph-hops;
4039/// overlapping columns averaged by partition-of-unity weights.
4040#[derive(Debug, Clone)]
4041pub struct AdditiveSchwarzPreconditioner {
4042 pub(crate) clusters: Vec<ClusterFactor>,
4043 pub(crate) weights: Vec<f64>,
4044}
4045
4046impl AdditiveSchwarzPreconditioner {
4047 pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
4048 sys: &ArrowSchurSystem,
4049 htt_factors: &ArrowFactorSlab,
4050 ridge_beta: f64,
4051 backend: &B,
4052 overlap: usize,
4053 ) -> Result<Self, ArrowSchurError> {
4054 if sys.block_offsets.is_empty() {
4055 let cols: Vec<usize> = (0..sys.k).collect();
4056 let inner = ClusterJacobiPreconditioner::build_from_column_groups(
4057 sys,
4058 htt_factors,
4059 ridge_beta,
4060 backend,
4061 &[cols],
4062 )?;
4063 return Ok(Self {
4064 clusters: inner.clusters,
4065 weights: vec![1.0f64; sys.k],
4066 });
4067 }
4068 let graph = BetaCouplingGraph::build(
4069 &sys.block_offsets,
4070 &sys.rows
4071 .iter()
4072 .map(|r| r.htbeta.clone())
4073 .collect::<Vec<_>>(),
4074 );
4075 let col_groups: Vec<Vec<usize>> = graph
4076 .component_partition()
4077 .iter()
4078 .map(|seed| {
4079 let mut current = seed.clone();
4080 for _ in 0..overlap {
4081 current = graph.expand_one_hop(¤t);
4082 }
4083 let mut cols: Vec<usize> = current
4084 .iter()
4085 .flat_map(|&b| sys.block_offsets[b].clone())
4086 .collect();
4087 cols.sort_unstable();
4088 cols.dedup();
4089 cols
4090 })
4091 .collect();
4092 let mut counts = vec![0u32; sys.k];
4093 for cols in &col_groups {
4094 for &gi in cols {
4095 counts[gi] += 1;
4096 }
4097 }
4098 let weights: Vec<f64> = counts
4099 .iter()
4100 .map(|&c| if c == 0 { 1.0 } else { 1.0 / c as f64 })
4101 .collect();
4102 let inner = ClusterJacobiPreconditioner::build_from_column_groups(
4103 sys,
4104 htt_factors,
4105 ridge_beta,
4106 backend,
4107 &col_groups,
4108 )?;
4109 Ok(Self {
4110 clusters: inner.clusters,
4111 weights,
4112 })
4113 }
4114
4115 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
4116 let mut out = Array1::<f64>::zeros(r.len());
4117 for cluster in &self.clusters {
4118 apply_cluster(
4119 cluster,
4120 r,
4121 &mut out,
4122 &ClusterApplyMode::Accumulate {
4123 weights: &self.weights,
4124 },
4125 );
4126 }
4127 out
4128 }
4129}
4130
4131/// Diagonal-assembled additive Schwarz (#299).
4132///
4133/// The cheap Schwarz variant the domain-decomposition literature recommends as
4134/// the default for sparse-coupling β-graphs: instead of storing and applying a
4135/// dense Cholesky factor per overlapping subdomain (as
4136/// [`AdditiveSchwarzPreconditioner`] does), it inverts each overlapping
4137/// subdomain Schur block ONCE at build time and keeps only the **diagonal of the
4138/// local inverse** `(A_k⁻¹)_ii`. Those per-subdomain diagonal contributions are
4139/// then assembled additively across overlapping subdomains with partition-of-
4140/// unity weights into a single global diagonal `m`, applied as `out[i] = m[i]·r[i]`.
4141///
4142/// This is strictly richer than scalar Jacobi (`1/S_ii`): the local inverse
4143/// diagonal `(A_k⁻¹)_ii` folds in the off-diagonal coupling WITHIN the subdomain,
4144/// so a strongly-coupled column gets a smaller (better-damped) effective scale
4145/// than its bare reciprocal diagonal would give — while the apply stays `O(K)`
4146/// (one multiply per column), unlike the `O(Σ b_k²)` triangular solves of dense
4147/// Schwarz. For `overlap = 0` and one column per subdomain it reduces exactly to
4148/// scalar Jacobi.
4149#[derive(Debug, Clone)]
4150pub struct DiagAssembledSchwarzPreconditioner {
4151 /// Global per-column multiplier `m[i]`; `out[i] = m[i] · r[i]`.
4152 pub(crate) inv_diag: Vec<f64>,
4153}
4154
4155impl DiagAssembledSchwarzPreconditioner {
4156 pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
4157 sys: &ArrowSchurSystem,
4158 htt_factors: &ArrowFactorSlab,
4159 ridge_beta: f64,
4160 backend: &B,
4161 overlap: usize,
4162 ) -> Result<Self, ArrowSchurError> {
4163 // Build the overlapping subdomain column groups exactly like
4164 // AdditiveSchwarz (component partition + `overlap` graph-hop expansion),
4165 // so the two Schwarz variants decompose the β space identically and
4166 // differ only in how each subdomain's local inverse is applied.
4167 let col_groups: Vec<Vec<usize>> = if sys.block_offsets.is_empty() {
4168 vec![(0..sys.k).collect()]
4169 } else {
4170 let graph = BetaCouplingGraph::build(
4171 &sys.block_offsets,
4172 &sys.rows
4173 .iter()
4174 .map(|r| r.htbeta.clone())
4175 .collect::<Vec<_>>(),
4176 );
4177 graph
4178 .component_partition()
4179 .iter()
4180 .map(|seed| {
4181 let mut current = seed.clone();
4182 for _ in 0..overlap {
4183 current = graph.expand_one_hop(¤t);
4184 }
4185 let mut cols: Vec<usize> = current
4186 .iter()
4187 .flat_map(|&b| sys.block_offsets[b].clone())
4188 .collect();
4189 cols.sort_unstable();
4190 cols.dedup();
4191 cols
4192 })
4193 .collect()
4194 };
4195 Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
4196 }
4197
4198 pub(crate) fn build_from_column_groups<B: BatchedBlockSolver + Sync>(
4199 sys: &ArrowSchurSystem,
4200 htt_factors: &ArrowFactorSlab,
4201 ridge_beta: f64,
4202 backend: &B,
4203 col_groups: &[Vec<usize>],
4204 ) -> Result<Self, ArrowSchurError> {
4205 // Partition-of-unity weights: a column shared by `c` subdomains gets each
4206 // of its `c` diagonal contributions scaled by `1/c`, so the assembled
4207 // diagonal is a convex combination (and reduces to a single contribution
4208 // for non-overlapping columns).
4209 let mut counts = vec![0u32; sys.k];
4210 for cols in col_groups {
4211 for &gi in cols {
4212 counts[gi] += 1;
4213 }
4214 }
4215 let mut accum = vec![0.0f64; sys.k];
4216 for cols in col_groups {
4217 let b = cols.len();
4218 if b == 0 {
4219 continue;
4220 }
4221 // For large subdomains, the dense inverse is too costly; fall back to
4222 // the global scalar Schur diagonal inverse `1/S_ii` for those columns
4223 // (the diag-assembled variant then coincides with scalar Jacobi over
4224 // that subdomain, which is exactly the intended cheap degradation).
4225 if b > CLUSTER_JACOBI_MAX_CLUSTER {
4226 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
4227 for (local, &gi) in cols.iter().enumerate() {
4228 let w = if counts[gi] == 0 {
4229 1.0
4230 } else {
4231 1.0 / counts[gi] as f64
4232 };
4233 accum[gi] += w * inv[local];
4234 }
4235 continue;
4236 }
4237 let mut s_block =
4238 assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
4239 symmetrize_upper_from_lower(&mut s_block);
4240 // Diagonal of the local inverse `(A_k⁻¹)_ii`, obtained by solving
4241 // `A_k X = I` through the same faer Cholesky used elsewhere; on a
4242 // non-PD local block, degrade to the scalar reciprocal diagonal.
4243 let local_inv_diag = match local_inverse_diagonal(&s_block) {
4244 Some(diag) => diag,
4245 None => {
4246 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
4247 inv
4248 }
4249 };
4250 for (local, &gi) in cols.iter().enumerate() {
4251 let w = if counts[gi] == 0 {
4252 1.0
4253 } else {
4254 1.0 / counts[gi] as f64
4255 };
4256 accum[gi] += w * local_inv_diag[local];
4257 }
4258 }
4259 // A column never covered by any subdomain (only possible for `k` columns
4260 // with no block_offsets coverage) keeps a neutral unit scale.
4261 for (gi, &c) in counts.iter().enumerate() {
4262 if c == 0 {
4263 accum[gi] = 1.0;
4264 }
4265 }
4266 for (gi, m) in accum.iter().enumerate() {
4267 if !m.is_finite() || *m <= 0.0 {
4268 return Err(ArrowSchurError::PcgFailed {
4269 reason: format!(
4270 "diag-assembled Schwarz: non-positive assembled diagonal at index {gi}: {m}"
4271 ),
4272 });
4273 }
4274 }
4275 Ok(Self { inv_diag: accum })
4276 }
4277
4278 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
4279 let mut out = Array1::<f64>::zeros(r.len());
4280 for (gi, &m) in self.inv_diag.iter().enumerate() {
4281 out[gi] = m * r[gi];
4282 }
4283 out
4284 }
4285}
4286
4287/// Diagonal of `A⁻¹` for a small dense SPD block `A`, via the same faer
4288/// Cholesky used by the cluster/Schwarz factors. Returns `None` if `A` is not
4289/// positive-definite (caller degrades to the scalar reciprocal diagonal).
4290pub(crate) fn local_inverse_diagonal(a: &Array2<f64>) -> Option<Vec<f64>> {
4291 let b = a.nrows();
4292 let llt = {
4293 use faer::Side;
4294 let view = FaerArrayView::new(a);
4295 FaerLlt::new(view.as_ref(), Side::Lower).ok()?
4296 };
4297 use faer::linalg::solvers::Solve;
4298 let mut diag = Vec::with_capacity(b);
4299 for col in 0..b {
4300 // Solve `A x = e_col`; the `col`-th entry of `x` is `(A⁻¹)_{col,col}`.
4301 let mut rhs = Array1::<f64>::zeros(b);
4302 rhs[col] = 1.0;
4303 let stride = rhs.strides()[0];
4304 let len = rhs.len();
4305 // SAFETY: `rhs` is a uniquely-borrowed contiguous `Array1<f64>` of `len`
4306 // elements with positive row stride; a single column never dereferences
4307 // the column stride, so `0` is sound.
4308 let rhs_mat = unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
4309 let solved = llt.solve(rhs_mat);
4310 diag.push(solved[(col, 0)]);
4311 }
4312 Some(diag)
4313}
4314
4315/// How a cluster factor's contribution is written into the output vector.
4316///
4317/// `Overwrite` assigns `out[gi] = value` (non-overlapping clusters, each global
4318/// column touched by exactly one cluster). `Accumulate` adds the partition-of-unity
4319/// weighted contribution `out[gi] += weights[gi] * value` (overlapping Schwarz
4320/// clusters, where a column may belong to several clusters).
4321pub(crate) enum ClusterApplyMode<'w> {
4322 Overwrite,
4323 Accumulate { weights: &'w [f64] },
4324}
4325
4326impl ClusterApplyMode<'_> {
4327 #[inline]
4328 pub(crate) fn write(&self, out: &mut Array1<f64>, gi: usize, value: f64) {
4329 match self {
4330 ClusterApplyMode::Overwrite => out[gi] = value,
4331 ClusterApplyMode::Accumulate { weights } => out[gi] += weights[gi] * value,
4332 }
4333 }
4334}
4335
4336/// Apply a single cluster factor to the residual `r`, writing into `out`
4337/// according to `mode` (overwrite for non-overlapping clusters, weighted
4338/// accumulate for overlapping Schwarz clusters).
4339pub(crate) fn apply_cluster(
4340 cluster: &ClusterFactor,
4341 r: &Array1<f64>,
4342 out: &mut Array1<f64>,
4343 mode: &ClusterApplyMode<'_>,
4344) {
4345 match cluster {
4346 ClusterFactor::Scalar { cols, inv } => {
4347 for (local, &gi) in cols.iter().enumerate() {
4348 mode.write(out, gi, inv[local] * r[gi]);
4349 }
4350 }
4351 ClusterFactor::Chol { cols, factor } => {
4352 let b = cols.len();
4353 let mut rhs = Array1::<f64>::zeros(b);
4354 for (local, &gi) in cols.iter().enumerate() {
4355 rhs[local] = r[gi];
4356 }
4357 use faer::linalg::solvers::Solve;
4358 let stride = rhs.strides()[0];
4359 let len = rhs.len();
4360 // SAFETY: rhs is uniquely-borrowed contiguous Array1 with positive stride.
4361 let rhs_mat = unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
4362 let solved = factor.solve(rhs_mat);
4363 for (local, &gi) in cols.iter().enumerate() {
4364 mode.write(out, gi, solved[(local, 0)]);
4365 }
4366 }
4367 }
4368}
4369
4370/// One connected-component factor of the block IC(0) preconditioner.
4371///
4372/// `IncompleteChol` holds a sparse lower-triangular `L̃` in column-compressed
4373/// form over the component's local indices: `col_ptr[j]..col_ptr[j+1]` indexes
4374/// into `(row_idx, val)` for column `j` (rows `>= j`, diagonal first). `cols`
4375/// maps a local index back to its global β column. `Scalar` is the non-PD /
4376/// oversized degradation, identical in meaning to [`ClusterFactor::Scalar`].
4377#[derive(Clone)]
4378pub(crate) enum Ic0Factor {
4379 IncompleteChol {
4380 cols: Vec<usize>,
4381 col_ptr: Vec<usize>,
4382 row_idx: Vec<usize>,
4383 val: Vec<f64>,
4384 },
4385 Scalar {
4386 cols: Vec<usize>,
4387 inv: Vec<f64>,
4388 },
4389}
4390
4391impl std::fmt::Debug for Ic0Factor {
4392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4393 match self {
4394 Ic0Factor::IncompleteChol { cols, val, .. } => write!(
4395 f,
4396 "Ic0Factor::IncompleteChol {{ cols.len: {}, nnz: {} }}",
4397 cols.len(),
4398 val.len()
4399 ),
4400 Ic0Factor::Scalar { cols, .. } => {
4401 write!(f, "Ic0Factor::Scalar {{ cols.len: {} }}", cols.len())
4402 }
4403 }
4404 }
4405}
4406
4407/// Level-0 incomplete-Cholesky Schur preconditioner (#299).
4408///
4409/// One sparse incomplete-Cholesky factor per connected component of the
4410/// β-coupling graph. Within a component the dense `S[C,C]` is assembled, its
4411/// structural-nonzero pattern `P = { (i,j) : |S_ij| > drop·sqrt(S_ii S_jj) }`
4412/// is taken as the level-0 fill set, and the no-fill incomplete Cholesky
4413/// `S ≈ L̃ L̃ᵀ` is formed keeping only `P` (drop any update landing outside it).
4414/// See [`SchurPreconditionerKind::BlockIncompleteCholesky`].
4415#[derive(Debug, Clone)]
4416pub struct BlockIncompleteCholeskyPreconditioner {
4417 pub(crate) components: Vec<Ic0Factor>,
4418}
4419
4420impl BlockIncompleteCholeskyPreconditioner {
4421 pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
4422 sys: &ArrowSchurSystem,
4423 htt_factors: &ArrowFactorSlab,
4424 ridge_beta: f64,
4425 backend: &B,
4426 ) -> Result<Self, ArrowSchurError> {
4427 // Column grouping mirrors ClusterJacobi: one group per connected
4428 // component of the β-coupling graph (whole-K single group when no
4429 // block_offsets are registered), so IC(0) preconditions exactly the
4430 // coupling ClusterJacobi keeps, but with a sparse (no-fill) factor.
4431 let col_groups: Vec<Vec<usize>> = if sys.block_offsets.is_empty() {
4432 vec![(0..sys.k).collect()]
4433 } else {
4434 let graph = BetaCouplingGraph::build(
4435 &sys.block_offsets,
4436 &sys.rows
4437 .iter()
4438 .map(|r| r.htbeta.clone())
4439 .collect::<Vec<_>>(),
4440 );
4441 graph
4442 .component_partition()
4443 .iter()
4444 .map(|comp| {
4445 let mut cols: Vec<usize> = comp
4446 .iter()
4447 .flat_map(|&blk| sys.block_offsets[blk].clone())
4448 .collect();
4449 cols.sort_unstable();
4450 cols.dedup();
4451 cols
4452 })
4453 .collect()
4454 };
4455
4456 let mut components = Vec::with_capacity(col_groups.len());
4457 for cols in &col_groups {
4458 let b = cols.len();
4459 if b == 0 {
4460 continue;
4461 }
4462 if b > IC0_MAX_COMPONENT {
4463 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
4464 components.push(Ic0Factor::Scalar {
4465 cols: cols.clone(),
4466 inv,
4467 });
4468 continue;
4469 }
4470 let mut s_block =
4471 assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
4472 symmetrize_upper_from_lower(&mut s_block);
4473 match incomplete_cholesky_level0(&s_block) {
4474 Some((col_ptr, row_idx, val)) => components.push(Ic0Factor::IncompleteChol {
4475 cols: cols.clone(),
4476 col_ptr,
4477 row_idx,
4478 val,
4479 }),
4480 None => {
4481 // Non-PD incomplete pivot: degrade this component to the
4482 // scalar reciprocal diagonal (mirrors the ClusterJacobi
4483 // non-PD fallback), which is always applicable for a
4484 // PD-floored Schur diagonal.
4485 let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
4486 components.push(Ic0Factor::Scalar {
4487 cols: cols.clone(),
4488 inv,
4489 });
4490 }
4491 }
4492 }
4493 Ok(Self { components })
4494 }
4495
4496 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
4497 let mut out = Array1::<f64>::zeros(r.len());
4498 for comp in &self.components {
4499 match comp {
4500 Ic0Factor::Scalar { cols, inv } => {
4501 for (local, &gi) in cols.iter().enumerate() {
4502 out[gi] = inv[local] * r[gi];
4503 }
4504 }
4505 Ic0Factor::IncompleteChol {
4506 cols,
4507 col_ptr,
4508 row_idx,
4509 val,
4510 } => {
4511 let b = cols.len();
4512 // Gather the local residual, solve `L̃ L̃ᵀ z = r_local` by a
4513 // sparse forward solve (`L̃ y = r`) then a sparse back solve
4514 // (`L̃ᵀ z = y`), then scatter `z` back to global columns.
4515 let mut z = vec![0.0f64; b];
4516 for (local, &gi) in cols.iter().enumerate() {
4517 z[local] = r[gi];
4518 }
4519 // Forward solve `L̃ y = r` (overwrite z with y). Column-major
4520 // CSC: row_idx[col_ptr[j]] == j (diagonal stored first).
4521 for j in 0..b {
4522 let dstart = col_ptr[j];
4523 let diag = val[dstart];
4524 z[j] /= diag;
4525 let yj = z[j];
4526 for k in (dstart + 1)..col_ptr[j + 1] {
4527 z[row_idx[k]] -= val[k] * yj;
4528 }
4529 }
4530 // Back solve `L̃ᵀ z = y` (overwrite z). Walk columns in
4531 // reverse; the below-diagonal entries of column j are the
4532 // off-diagonal entries of row j of L̃ᵀ.
4533 for j in (0..b).rev() {
4534 let dstart = col_ptr[j];
4535 let mut acc = z[j];
4536 for k in (dstart + 1)..col_ptr[j + 1] {
4537 acc -= val[k] * z[row_idx[k]];
4538 }
4539 z[j] = acc / val[dstart];
4540 }
4541 for (local, &gi) in cols.iter().enumerate() {
4542 out[gi] = z[local];
4543 }
4544 }
4545 }
4546 }
4547 out
4548 }
4549}
4550
4551/// Level-0 incomplete Cholesky of a dense SPD-ish block `a` (`b×b`, symmetric).
4552///
4553/// Returns the lower factor `L̃` in column-compressed (CSC) form
4554/// `(col_ptr, row_idx, val)` where each column lists its diagonal entry FIRST
4555/// followed by the strictly-below-diagonal entries, in increasing row order.
4556/// The kept pattern is the level-0 set `P` = structural nonzeros of `a` (a
4557/// relative drop threshold prunes round-off). IC(0) computes the standard
4558/// Cholesky recurrence but DROPS any value at a position outside `P`, so the
4559/// factor has exactly `nnz(tril(P))` entries — no fill. Returns `None` on a
4560/// non-positive pivot (caller degrades to scalar diagonal).
4561///
4562/// Reference: Y. Saad, *Iterative Methods for Sparse Linear Systems*, 2nd ed.,
4563/// §10.3.2 (IC(0)). This is the left-looking, pattern-restricted variant.
4564pub(crate) fn incomplete_cholesky_level0(
4565 a: &Array2<f64>,
4566) -> Option<(Vec<usize>, Vec<usize>, Vec<f64>)> {
4567 let b = a.nrows();
4568 assert_eq!(a.ncols(), b, "incomplete Cholesky needs a square block");
4569
4570 // ---- derive the level-0 lower-triangular pattern from `a` --------------
4571 // Per column j, the kept below-or-on-diagonal rows i>=j with a structurally
4572 // nonzero a[i,j]. The diagonal is always kept.
4573 let mut col_ptr = vec![0usize; b + 1];
4574 let mut row_idx: Vec<usize> = Vec::new();
4575 // value buffer, parallel to row_idx, initialised from tril(a) on the pattern
4576 let mut val: Vec<f64> = Vec::new();
4577 // For O(1) "is (i,j) in pattern + where" lookups during the recurrence, keep
4578 // a per-column map from global row -> position in that column's value slice.
4579 let mut col_pos: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(b);
4580 for j in 0..b {
4581 let ajj = a[[j, j]];
4582 let scale_j = ajj.abs().max(0.0).sqrt();
4583 let mut map = std::collections::HashMap::new();
4584 // diagonal first
4585 map.insert(j, val.len());
4586 row_idx.push(j);
4587 val.push(ajj);
4588 for i in (j + 1)..b {
4589 let aij = a[[i, j]];
4590 let scale_i = a[[i, i]].abs().sqrt();
4591 let thresh = IC0_PATTERN_REL_DROP * scale_i * scale_j;
4592 if aij.abs() > thresh {
4593 map.insert(i, val.len());
4594 row_idx.push(i);
4595 val.push(aij);
4596 }
4597 }
4598 col_pos.push(map);
4599 col_ptr[j + 1] = val.len();
4600 }
4601
4602 // ---- IC(0) recurrence, left-looking over columns -----------------------
4603 // For column j: subtract the contributions of all prior columns k<j that
4604 // have BOTH a nonzero at row j (so they touch the diagonal/the column) — the
4605 // multiplier L[j,k] — and a nonzero at the rows i of column j's pattern.
4606 // Any update whose target (i,j) is OUTSIDE the kept pattern is dropped.
4607 for j in 0..b {
4608 // Diagonal: a[j,j] - Σ_{k<j} L[j,k]². Each prior column k<j contributes
4609 // its row-j entry L[j,k] (looked up by row, so the column index is not
4610 // needed); columns without a row-j entry contribute nothing.
4611 let dpos = col_ptr[j];
4612 let mut diag = val[dpos];
4613 for mapk in &col_pos[..j] {
4614 if let Some(&pjk) = mapk.get(&j) {
4615 let ljk = val[pjk];
4616 diag -= ljk * ljk;
4617 }
4618 }
4619 if !diag.is_finite() || diag <= JACOBI_DIAGONAL_PD_FLOOR {
4620 return None;
4621 }
4622 let ljj = diag.sqrt();
4623 val[dpos] = ljj;
4624 // Below-diagonal of column j: L[i,j] = (a[i,j] - Σ_{k<j} L[i,k] L[j,k]) / L[j,j]
4625 for p in (dpos + 1)..col_ptr[j + 1] {
4626 let i = row_idx[p];
4627 let mut s = val[p];
4628 for mapk in &col_pos[..j] {
4629 if let (Some(&pik), Some(&pjk)) = (mapk.get(&i), mapk.get(&j)) {
4630 s -= val[pik] * val[pjk];
4631 }
4632 }
4633 val[p] = s / ljj;
4634 }
4635 }
4636 Some((col_ptr, row_idx, val))
4637}
4638
4639/// One row of the #299 preconditioner-ladder iteration study: the converged
4640/// PCG iteration count and stop reason for a single preconditioner tier.
4641#[derive(Debug, Clone, Copy)]
4642pub struct PrecondLadderRow {
4643 /// PCG iterations to convergence (or to the `MaxIter` cutoff).
4644 pub iterations: usize,
4645 /// Whether the PCG converged (vs hit `MaxIter` / negative curvature).
4646 pub converged: bool,
4647 /// Final relative residual reported by the PCG.
4648 pub final_relative_residual: f64,
4649}
4650
4651/// Full #299 ladder iteration study on one reduced-Schur system: run the SAME
4652/// preconditioned CG (same `rhs`, tolerances, trust radius) once per ladder tier
4653/// and report the iteration count of each. This is the public seam the
4654/// `tests/owed_299.rs` iteration-reduction gate drives — it keeps the internal
4655/// `run_pcg_with_preconditioner` / preconditioner constructors `pub(crate)`
4656/// while exposing exactly the per-tier measurement the issue asks for.
4657///
4658/// Tiers (in escalation order): scalar `Diagonal`, `BetaBlockJacobi`,
4659/// `ClusterJacobi`, `AdditiveSchwarz{overlap:1}`, `DiagAssembledSchwarz{1}`, and
4660/// `BlockIncompleteCholesky`. A tier whose build fails (e.g. non-PD reduced
4661/// Schur with no curvature floor) reports `None` for that entry; every healthy
4662/// SPD reduced system populates all six.
4663pub fn arrow_precond_ladder_iteration_study(
4664 sys: &ArrowSchurSystem,
4665 ridge_beta: f64,
4666 rhs: &Array1<f64>,
4667 pcg: &ArrowPcgOptions,
4668 trust: &ArrowTrustRegionOptions,
4669) -> Result<Vec<(SchurPreconditionerKind, Option<PrecondLadderRow>)>, ArrowSchurError> {
4670 let backend = CpuBatchedBlockSolver;
4671 let htt_factors = backend.factor_blocks(&sys.rows, 0.0, sys.d, false)?;
4672
4673 let run = |apply: &dyn Fn(&Array1<f64>) -> Array1<f64>| -> Option<PrecondLadderRow> {
4674 let (_sol, diag) = run_pcg_with_preconditioner(
4675 sys,
4676 &htt_factors,
4677 ridge_beta,
4678 rhs,
4679 |r| apply(r),
4680 pcg,
4681 trust,
4682 &backend,
4683 None,
4684 None,
4685 None,
4686 )
4687 .ok()?;
4688 Some(PrecondLadderRow {
4689 iterations: diag.iterations,
4690 converged: matches!(diag.stopping_reason, PcgStopReason::Converged),
4691 final_relative_residual: diag.final_relative_residual,
4692 })
4693 };
4694
4695 let mut out: Vec<(SchurPreconditionerKind, Option<PrecondLadderRow>)> = Vec::with_capacity(7);
4696
4697 // Scalar Diagonal Jacobi: force the scalar path by clearing block_offsets on
4698 // a clone so the build does not pick up the per-block dense Schur blocks.
4699 let diag_row = {
4700 let mut bare = sys.clone();
4701 bare.set_block_offsets(std::sync::Arc::from([] as [Range<usize>; 0]));
4702 let bare_factors = backend.factor_blocks(&bare.rows, 0.0, bare.d, false)?;
4703 JacobiPreconditioner::from_arrow_schur(&bare, &bare_factors, ridge_beta, &backend, None)
4704 .ok()
4705 .and_then(|p| {
4706 run_pcg_with_preconditioner(
4707 &bare,
4708 &bare_factors,
4709 ridge_beta,
4710 rhs,
4711 |r| p.apply(r),
4712 pcg,
4713 trust,
4714 &backend,
4715 None,
4716 None,
4717 None,
4718 )
4719 .ok()
4720 .map(|(_s, diag)| PrecondLadderRow {
4721 iterations: diag.iterations,
4722 converged: matches!(diag.stopping_reason, PcgStopReason::Converged),
4723 final_relative_residual: diag.final_relative_residual,
4724 })
4725 })
4726 };
4727 out.push((SchurPreconditionerKind::Diagonal, diag_row));
4728
4729 let block_row =
4730 JacobiPreconditioner::from_arrow_schur(sys, &htt_factors, ridge_beta, &backend, None)
4731 .ok()
4732 .and_then(|p| run(&|r| p.apply(r)));
4733 out.push((SchurPreconditionerKind::BetaBlockJacobi, block_row));
4734
4735 let cluster_row =
4736 ClusterJacobiPreconditioner::from_arrow_schur(sys, &htt_factors, ridge_beta, &backend)
4737 .ok()
4738 .and_then(|p| run(&|r| p.apply(r)));
4739 out.push((SchurPreconditionerKind::ClusterJacobi, cluster_row));
4740
4741 let covis_row = ClusterJacobiPreconditioner::from_arrow_schur_covisibility(
4742 sys,
4743 &htt_factors,
4744 ridge_beta,
4745 &backend,
4746 )
4747 .ok()
4748 .and_then(|p| run(&|r| p.apply(r)));
4749 out.push((
4750 SchurPreconditionerKind::CoVisibilityClusterJacobi,
4751 covis_row,
4752 ));
4753
4754 let schwarz_row =
4755 AdditiveSchwarzPreconditioner::from_arrow_schur(sys, &htt_factors, ridge_beta, &backend, 1)
4756 .ok()
4757 .and_then(|p| run(&|r| p.apply(r)));
4758 out.push((
4759 SchurPreconditionerKind::AdditiveSchwarz { overlap: 1 },
4760 schwarz_row,
4761 ));
4762
4763 let diag_schwarz_row = DiagAssembledSchwarzPreconditioner::from_arrow_schur(
4764 sys,
4765 &htt_factors,
4766 ridge_beta,
4767 &backend,
4768 1,
4769 )
4770 .ok()
4771 .and_then(|p| run(&|r| p.apply(r)));
4772 out.push((
4773 SchurPreconditionerKind::DiagAssembledSchwarz { overlap: 1 },
4774 diag_schwarz_row,
4775 ));
4776
4777 let ic0_row = BlockIncompleteCholeskyPreconditioner::from_arrow_schur(
4778 sys,
4779 &htt_factors,
4780 ridge_beta,
4781 &backend,
4782 )
4783 .ok()
4784 .and_then(|p| run(&|r| p.apply(r)));
4785 out.push((SchurPreconditionerKind::BlockIncompleteCholesky, ic0_row));
4786
4787 Ok(out)
4788}
4789
4790/// Build scalar diagonal inverses for a set of global column indices.
4791///
4792/// Used when a cluster is non-PD or exceeds `CLUSTER_JACOBI_MAX_CLUSTER`.
4793pub(crate) fn build_schur_scalar_inv<B: BatchedBlockSolver>(
4794 sys: &ArrowSchurSystem,
4795 htt_factors: &ArrowFactorSlab,
4796 ridge_beta: f64,
4797 backend: &B,
4798 cols: &[usize],
4799) -> Result<Vec<f64>, ArrowSchurError> {
4800 let mut result = Vec::with_capacity(cols.len());
4801 // Extract the penalty diagonal for all K columns once, then index per-column.
4802 let mut full_diag = Array1::<f64>::zeros(sys.k);
4803 {
4804 let diag_slice = full_diag.as_slice_mut().expect("full_diag contiguous");
4805 sys.penalty_diagonal_add(diag_slice);
4806 }
4807 // Probe each needed column through the ROUTED `H_tβ` convention at each
4808 // row's own width (see `assemble_local_schur_block` for why a raw
4809 // `row.htbeta` read at the global `sys.d` is wrong here).
4810 let mut e_g = Array1::<f64>::zeros(sys.k);
4811 for &gi in cols {
4812 let mut s = full_diag[gi] + ridge_beta;
4813 e_g[gi] = 1.0;
4814 for (row_idx, row) in sys.rows.iter().enumerate() {
4815 let di = sys.row_dims[row_idx];
4816 let mut col_vec = Array1::<f64>::zeros(di);
4817 sys_htbeta_apply_row(sys, row_idx, row, e_g.view(), &mut col_vec);
4818 let solved = backend.solve_block_vector(htt_factors.factor(row_idx), col_vec.view());
4819 let mut acc = 0.0;
4820 for c in 0..di {
4821 acc += col_vec[c] * solved[c];
4822 }
4823 s -= acc;
4824 }
4825 e_g[gi] = 0.0;
4826 if !s.is_finite() || s <= JACOBI_DIAGONAL_PD_FLOOR {
4827 return Err(ArrowSchurError::PcgFailed {
4828 reason: format!(
4829 "cluster Schur scalar fallback: non-PD diagonal at index {gi}: {s}"
4830 ),
4831 });
4832 }
4833 result.push(1.0 / s);
4834 }
4835 Ok(result)
4836}
4837
4838/// Inexact PCG with automatic preconditioner-ladder escalation.
4839///
4840/// Starts with `JacobiPreconditioner` (Diagonal or BetaBlockJacobi).
4841/// If PCG hits `MaxIter` and `k > PRECOND_ESCALATE_K_THRESHOLD`,
4842/// escalates to `ClusterJacobi`; if still `MaxIter`, escalates to
4843/// `AdditiveSchwarz { overlap: 1 }`.
4844pub(crate) fn steihaug_pcg_auto<B: BatchedBlockSolver + Sync>(
4845 sys: &ArrowSchurSystem,
4846 htt_factors: &ArrowFactorSlab,
4847 ridge_beta: f64,
4848 rhs: &Array1<f64>,
4849 pcg: &ArrowPcgOptions,
4850 trust: &ArrowTrustRegionOptions,
4851 backend: &B,
4852 gpu_matvec: Option<&GpuSchurMatvec>,
4853 metric_weights: Option<&MetricWeights>,
4854 curvature_floor: Option<f64>,
4855) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
4856 // #1017 CPU residency: stage the per-row reduced-Schur factors `(L_i, Y_i)`
4857 // (NOT the dense `p×p` block — `di ≪ p`, so the factored form is `O(n·di·p)`
4858 // memory and `2·support_i·p + 2·di·p` flops/row including the sparse
4859 // gather/scatter over the active support) once, up
4860 // front, when the SAE structure is installed and the matvec runs on host
4861 // (CPU). The GPU matvec carries its own residency, so skip when it is engaged.
4862 // The same staged operator is reused across the whole preconditioner ladder
4863 // (Jacobi → ClusterJacobi → AdditiveSchwarz) — built once, not per tier.
4864 let resident = if gpu_matvec.is_none() {
4865 SaeResidentReducedSchur::build(sys, htt_factors, backend)
4866 } else {
4867 None
4868 };
4869 // #2228 — a β-gauge-quotiented system has a reduced Schur that is singular
4870 // along the gauge orbit, and every preconditioner in the ladder below
4871 // (block-Jacobi, cluster, Schwarz, IC(0)) is formed from the UN-pinned
4872 // operator, so it would misprice — or refuse as non-PD — that orbit
4873 // direction. The matvec now applies the Faddeev–Popov pin `P S P + Q Qᵀ`,
4874 // which is SPD and well-conditioned on the identifiable complement (the gauge
4875 // dimension is tiny — one direction per circle/torus phase), so an identity
4876 // preconditioner converges without a bespoke pinned diagonal. Route straight
4877 // through it and skip the diagonal ladder, whose preconditioners assume the
4878 // un-pinned Schur; the `None`-quotient path below is byte-identical.
4879 if sys.beta_gauge_quotient.is_some() {
4880 let identity = IdentityPreconditioner;
4881 let (step, diag) = run_pcg_with_preconditioner(
4882 sys,
4883 htt_factors,
4884 ridge_beta,
4885 rhs,
4886 |r| identity.apply(r),
4887 pcg,
4888 trust,
4889 backend,
4890 gpu_matvec,
4891 metric_weights,
4892 resident.as_ref(),
4893 )?;
4894 // Mirror the non-gauge contract: below the escalation threshold a MaxIter
4895 // stop is accepted (the ladder returns it as `Ok`); above it the ladder
4896 // would escalate the preconditioner, but the cluster/Schwarz/IC(0) tiers
4897 // assume the un-pinned Schur and cannot precondition the gauge pin, so
4898 // surface a recoverable failure and let the outer LM loop escalate the
4899 // ridge instead (a bespoke pinned-diagonal preconditioner is the follow-up).
4900 if diag.stopping_reason == PcgStopReason::MaxIter
4901 && sys.k > PRECOND_ESCALATE_K_THRESHOLD
4902 {
4903 return Err(ArrowSchurError::PcgFailed {
4904 reason: format!(
4905 "gauge-pinned Schur PCG (identity preconditioner) exhausted its \
4906 iteration budget without converging; final relative residual = {:e}",
4907 diag.final_relative_residual
4908 ),
4909 });
4910 }
4911 return Ok((step, diag));
4912 }
4913 // #1026 — curvature-floor retry on the Jacobi tier. The unbounded SAE inner
4914 // PCG (trust radius = ∞) fails on `pᵀSp ≤ 0` when the reduced Schur is
4915 // indefinite (K≥4 co-collapse: a near-singular per-row `H_tt` over-subtracts
4916 // `S`). Instead of letting that failure propagate to the outer LM loop —
4917 // which inflates `ridge_β` over EVERY β direction and makes the inner Newton
4918 // crawl — floor the OPERATOR by the minimal ridge `δ = |pᵀSp|/‖p‖² · (1+ε)`
4919 // that restores positive curvature along the offending direction, rebuild the
4920 // Jacobi preconditioner at the lifted ridge, and retry. This is the
4921 // matrix-free analogue of the dense `spectral_pd_floored_schur`: the healthy
4922 // β subspace (where curvature is already positive) is essentially untouched
4923 // by a tiny `δ`, while the collapsed direction gets exactly the stiffness it
4924 // needs to make a real descent step. A PD reduced Schur never hits `pᵀSp ≤ 0`,
4925 // so this loop is a strict no-op there (bit-for-bit unchanged). Bounded by a
4926 // small attempt cap and a relative ridge ceiling; on exhaustion the original
4927 // recoverable failure still reaches the outer LM loop.
4928 let mut effective_ridge = ridge_beta;
4929 let mut x0_diag0: Option<(Array1<f64>, ArrowPcgDiagnostics)> = None;
4930 let mut last_curvature_err: Option<ArrowSchurError> = None;
4931 let rhs_scale = metric_norm(rhs.view(), metric_weights).max(1.0);
4932 let ridge_ceiling = ridge_beta.max(SCHUR_CURVATURE_FLOOR_REL_CEILING * rhs_scale);
4933 for _attempt in 0..=SCHUR_CURVATURE_FLOOR_MAX_ATTEMPTS {
4934 // The Jacobi preconditioner build itself refuses a non-PD Schur diagonal
4935 // (`PcgFailed: invalid Schur Jacobi diagonal`) — the SAME co-collapse
4936 // signature reached BEFORE the CG loop, since `S_ii = H_ββ,ii − Σ …` goes
4937 // negative. Treat that build failure as a curvature deficit too: when the
4938 // floor is enabled, lift the ridge and retry; otherwise propagate.
4939 let jacobi = match JacobiPreconditioner::from_arrow_schur(
4940 sys,
4941 htt_factors,
4942 effective_ridge,
4943 backend,
4944 resident.as_ref(),
4945 ) {
4946 Ok(jacobi) => jacobi,
4947 Err(err @ ArrowSchurError::PcgFailed { .. }) => {
4948 if curvature_floor.is_none() {
4949 return Err(err);
4950 }
4951 // A diagonal refusal carries no `(curvature, ‖p‖²)` deficit, and
4952 // the over-subtraction magnitude `Σ H_tβᵀ(H_tt)⁻¹H_tβ` is
4953 // unbounded relative to `rhs_scale`, so a small additive bump
4954 // would crawl. Escalate the ridge MULTIPLICATIVELY (×10, matching
4955 // the per-row `factor_one_row_result` RIDGE_GROWTH_FACTOR), seeded
4956 // at `rhs_scale`, so even a large deficit (the collapsed
4957 // `(H_tβ)²/H_tt` over-subtraction) is reached in a handful of
4958 // attempts. The ceiling + attempt cap still bound it; on
4959 // exhaustion the recoverable failure reaches the outer LM loop.
4960 // Jump straight to a meaningful scale on the FIRST refusal rather
4961 // than crawling ×10 from a tiny `ridge_beta`: each rebuild is a full
4962 // block-Jacobi factorization (the massive-K preconditioner hotspot),
4963 // and a large collapsed deficit (`Σ H_tβᵀ(H_tt)⁻¹H_tβ` over-subtraction,
4964 // O(1)-scale) otherwise costs ~log10(deficit / ridge_beta) rebuilds.
4965 // Seeding the first bump at `rhs_scale` covers it in one or two, then
4966 // escalates multiplicatively; the ceiling + attempt cap still bound it.
4967 let next = if effective_ridge > 0.0 {
4968 (effective_ridge * SCHUR_CURVATURE_FLOOR_DIAG_GROWTH).max(rhs_scale)
4969 } else {
4970 rhs_scale
4971 };
4972 last_curvature_err = Some(err);
4973 if !next.is_finite() || next > ridge_ceiling {
4974 break;
4975 }
4976 effective_ridge = next;
4977 continue;
4978 }
4979 Err(other) => return Err(other),
4980 };
4981 match run_pcg_with_preconditioner(
4982 sys,
4983 htt_factors,
4984 effective_ridge,
4985 rhs,
4986 |r| jacobi.apply(r),
4987 pcg,
4988 trust,
4989 backend,
4990 gpu_matvec,
4991 metric_weights,
4992 resident.as_ref(),
4993 ) {
4994 Ok(result) => {
4995 x0_diag0 = Some(result);
4996 break;
4997 }
4998 Err(ArrowSchurError::UnboundedNegativeCurvature {
4999 curvature,
5000 direction_norm_sq,
5001 }) => {
5002 // Only floor when the caller opted in (SAE solve path); otherwise
5003 // propagate the raw negative-curvature signal so BA / non-SAE
5004 // unbounded solves keep their existing failure contract.
5005 let Some(relative_floor) = curvature_floor else {
5006 return Err(ArrowSchurError::UnboundedNegativeCurvature {
5007 curvature,
5008 direction_norm_sq,
5009 });
5010 };
5011 // Minimal ridge to make `pᵀ(S+δI)p = |curvature| + δ·‖p‖² > 0`,
5012 // with a margin so the next CG iterate has strictly positive
5013 // curvature rather than sitting on the `0` knife-edge.
5014 let deficit = if direction_norm_sq > 0.0 {
5015 curvature.abs() / direction_norm_sq
5016 } else {
5017 0.0
5018 };
5019 let bump = (deficit * (1.0 + SCHUR_CURVATURE_FLOOR_MARGIN))
5020 .max(relative_floor.max(SCHUR_CURVATURE_FLOOR_REL_FLOOR) * rhs_scale);
5021 let next = (effective_ridge + bump).max(effective_ridge * 2.0);
5022 last_curvature_err = Some(ArrowSchurError::UnboundedNegativeCurvature {
5023 curvature,
5024 direction_norm_sq,
5025 });
5026 if !next.is_finite() || next > ridge_ceiling {
5027 break;
5028 }
5029 effective_ridge = next;
5030 }
5031 Err(other) => return Err(other),
5032 }
5033 }
5034 let (x0, diag0) = match x0_diag0 {
5035 Some(result) => result,
5036 None => {
5037 // The curvature floor could not condition the operator within the
5038 // ceiling; hand the recoverable failure to the outer LM loop, which
5039 // re-forms the system at a heavier ridge.
5040 return Err(last_curvature_err.unwrap_or(ArrowSchurError::PcgFailed {
5041 reason: "unbounded Schur PCG negative curvature unresolved by curvature floor"
5042 .to_string(),
5043 }));
5044 }
5045 };
5046 if sys.k <= PRECOND_ESCALATE_K_THRESHOLD || diag0.stopping_reason != PcgStopReason::MaxIter {
5047 return Ok((x0, diag0));
5048 }
5049 // Escalation tiers reuse the curvature-floored `effective_ridge` so the
5050 // operator they precondition is the SAME (PD-floored) one the Jacobi tier
5051 // settled on; a still-negative-curvature signal here is handed to the outer
5052 // LM loop (it only arises if the floored Jacobi tier merely ran out of
5053 // iterations yet a coarser preconditioner still finds an indefinite
5054 // direction — rare; the LM loop re-forms at a heavier ridge).
5055 // Default cluster tier: the bounded CO-VISIBILITY partition, not the
5056 // connected-component partition. At the SAE widths this ladder targets the
5057 // co-firing graph is one giant component, so the component partition exceeds
5058 // the size cap and `from_arrow_schur` degrades to scalar Jacobi (the ceiling
5059 // this tier exists to lift). `from_arrow_schur_covisibility` splits that
5060 // component into bounded strongly-co-firing clusters whose dense factors
5061 // condition the cross-atom coupling scalar Jacobi drops. The component
5062 // partition stays selectable via `from_arrow_schur` (used by the ladder
5063 // study and its regression gates). Both precondition the SAME operator, so
5064 // the converged step — and the REML optimum — is unchanged.
5065 let cluster = ClusterJacobiPreconditioner::from_arrow_schur_covisibility(
5066 sys,
5067 htt_factors,
5068 effective_ridge,
5069 backend,
5070 )?;
5071 let (x1, diag1) = run_pcg_with_preconditioner(
5072 sys,
5073 htt_factors,
5074 effective_ridge,
5075 rhs,
5076 |r| cluster.apply(r),
5077 pcg,
5078 trust,
5079 backend,
5080 gpu_matvec,
5081 metric_weights,
5082 resident.as_ref(),
5083 )?;
5084 if diag1.stopping_reason != PcgStopReason::MaxIter {
5085 return Ok((x1, diag1));
5086 }
5087 let schwarz = AdditiveSchwarzPreconditioner::from_arrow_schur(
5088 sys,
5089 htt_factors,
5090 effective_ridge,
5091 backend,
5092 1,
5093 )?;
5094 let (x2, diag2) = run_pcg_with_preconditioner(
5095 sys,
5096 htt_factors,
5097 effective_ridge,
5098 rhs,
5099 |r| schwarz.apply(r),
5100 pcg,
5101 trust,
5102 backend,
5103 gpu_matvec,
5104 metric_weights,
5105 resident.as_ref(),
5106 )?;
5107 if diag2.stopping_reason != PcgStopReason::MaxIter {
5108 return Ok((x2, diag2));
5109 }
5110 // Final tier — diagonal-assembled additive Schwarz (#299), the cheap-apply
5111 // Schwarz variant. When the dense-block AdditiveSchwarz still ran out of
5112 // iterations its O(Σ b_k²) apply may have throttled the iteration budget on
5113 // a wide subdomain; the diag-assembled variant keeps Schwarz's overlapping
5114 // local-inverse conditioning but applies in O(K), so it can take more CG
5115 // iterations within the same wall budget. Same overlap (1) and same
5116 // curvature-floored ridge as the dense-block tier.
5117 let diag_schwarz = DiagAssembledSchwarzPreconditioner::from_arrow_schur(
5118 sys,
5119 htt_factors,
5120 effective_ridge,
5121 backend,
5122 1,
5123 )?;
5124 let (x3, diag3) = run_pcg_with_preconditioner(
5125 sys,
5126 htt_factors,
5127 effective_ridge,
5128 rhs,
5129 |r| diag_schwarz.apply(r),
5130 pcg,
5131 trust,
5132 backend,
5133 gpu_matvec,
5134 metric_weights,
5135 resident.as_ref(),
5136 )?;
5137 if diag3.stopping_reason != PcgStopReason::MaxIter {
5138 return Ok((x3, diag3));
5139 }
5140 // Richest tier — level-0 incomplete Cholesky (#299). ClusterJacobi keeps the
5141 // full DENSE Cholesky of each component (so on a single large connected
5142 // component it fills the whole `b×b` factor and its `O(b²)` apply throttles
5143 // the CG iteration budget), while the diagonal/Schwarz tiers drop most
5144 // inter-block coupling. IC(0) keeps the component's full structural coupling
5145 // but only the level-0 (no-fill) pattern, so its sparse triangular apply is
5146 // `O(nnz(S[C,C]))` — it can take more CG iterations within the same wall
5147 // budget AND conditions the off-diagonal coupling the cheap tiers discard.
5148 // Last in the ladder so it is only paid when every cheaper tier stalled.
5149 let ic0 = BlockIncompleteCholeskyPreconditioner::from_arrow_schur(
5150 sys,
5151 htt_factors,
5152 effective_ridge,
5153 backend,
5154 )?;
5155 let (x4, diag4) = run_pcg_with_preconditioner(
5156 sys,
5157 htt_factors,
5158 effective_ridge,
5159 rhs,
5160 |r| ic0.apply(r),
5161 pcg,
5162 trust,
5163 backend,
5164 gpu_matvec,
5165 metric_weights,
5166 resident.as_ref(),
5167 )?;
5168 // All five preconditioner tiers (Jacobi -> ClusterJacobi -> AdditiveSchwarz
5169 // -> DiagAssembledSchwarz -> BlockIncompleteCholesky) exhausted their
5170 // iteration budget without driving the residual below tolerance. Returning a
5171 // truncated iterate as `Ok` would feed an arbitrarily-large-residual step
5172 // into the Newton driver, where the PCG diagnostics are discarded. Surface a
5173 // recoverable failure instead so `solve_with_lm_escalation_inner` escalates
5174 // the proximal ridge: better conditioning is precisely what a stalled PCG on
5175 // an ill-conditioned reduced system needs.
5176 if diag4.stopping_reason == PcgStopReason::MaxIter {
5177 return Err(ArrowSchurError::PcgFailed {
5178 reason: format!(
5179 "Schur PCG exhausted all preconditioner tiers (Jacobi, ClusterJacobi, \
5180 AdditiveSchwarz, DiagAssembledSchwarz, BlockIncompleteCholesky) at MaxIter; \
5181 final relative residual = {:e}",
5182 diag4.final_relative_residual
5183 ),
5184 });
5185 }
5186 Ok((x4, diag4))
5187}
5188
5189/// Run Steihaug-CG with a generic preconditioner closure.
5190/// Routes matvec through GPU when `gpu_matvec` is set.
5191pub(crate) fn run_pcg_with_preconditioner<ApplyPrec, B: BatchedBlockSolver + Sync>(
5192 sys: &ArrowSchurSystem,
5193 htt_factors: &ArrowFactorSlab,
5194 ridge_beta: f64,
5195 rhs: &Array1<f64>,
5196 apply_prec: ApplyPrec,
5197 pcg: &ArrowPcgOptions,
5198 trust: &ArrowTrustRegionOptions,
5199 backend: &B,
5200 gpu_matvec: Option<&GpuSchurMatvec>,
5201 metric_weights: Option<&MetricWeights>,
5202 resident: Option<&SaeResidentReducedSchur>,
5203) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError>
5204where
5205 ApplyPrec: FnMut(&Array1<f64>) -> Array1<f64>,
5206{
5207 let max_iters = pcg.max_iterations.min(trust.max_iterations);
5208 let tol = pcg
5209 .relative_tolerance
5210 .max(trust.steihaug_relative_tolerance);
5211 // #2228 — route the fit-step matvec through `ReducedSchurOperator`, which
5212 // applies the Faddeev–Popov pin `v ↦ P S P v + Q Qᵀ v` when the system carries
5213 // a β-gauge quotient and is byte-for-byte the bare `gpu_matvec` / `schur_matvec`
5214 // apply when it does not. This gauge-fixes the wide-`p` InexactPCG Newton step
5215 // exactly like the dense Direct/SqrtBA modes while leaving the `None`-quotient
5216 // lane (every non-SAE-fit caller) unchanged.
5217 let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
5218 .with_gpu_matvec(gpu_matvec);
5219 steihaug_cg(
5220 rhs,
5221 |p, out| op.apply_into(p, out),
5222 apply_prec,
5223 max_iters,
5224 tol,
5225 trust.radius,
5226 metric_weights,
5227 )
5228}
5229
5230#[derive(Debug, Clone, Copy)]
5231pub(crate) struct IdentityPreconditioner;
5232
5233impl IdentityPreconditioner {
5234 pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5235 r.clone()
5236 }
5237}
5238
5239pub(crate) fn steihaug_dense_system(
5240 schur: &Array2<f64>,
5241 rhs: &Array1<f64>,
5242 preconditioner: &IdentityPreconditioner,
5243 pcg: &ArrowPcgOptions,
5244 trust: &ArrowTrustRegionOptions,
5245 metric_weights: Option<&MetricWeights>,
5246) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
5247 steihaug_cg(
5248 rhs,
5249 |p, out| dense_matvec(schur, p, out),
5250 |r| preconditioner.apply(r),
5251 pcg.max_iterations,
5252 pcg.relative_tolerance,
5253 trust.radius,
5254 metric_weights,
5255 )
5256}
5257
5258pub(crate) fn steihaug_cg<MatVec, ApplyPrec>(
5259 rhs: &Array1<f64>,
5260 mut matvec: MatVec,
5261 mut apply_preconditioner: ApplyPrec,
5262 max_iterations: usize,
5263 relative_tolerance: f64,
5264 trust_radius: f64,
5265 metric_weights: Option<&MetricWeights>,
5266) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError>
5267where
5268 MatVec: FnMut(&Array1<f64>, &mut Array1<f64>),
5269 ApplyPrec: FnMut(&Array1<f64>) -> Array1<f64>,
5270{
5271 let n = rhs.len();
5272 if let Some(weights) = metric_weights {
5273 assert_eq!(
5274 weights.len(),
5275 n,
5276 "Steihaug-CG metric weight length must match solve dimension"
5277 );
5278 }
5279 let radius = if trust_radius.is_finite() && trust_radius > 0.0 {
5280 trust_radius
5281 } else {
5282 f64::INFINITY
5283 };
5284 let rhs_norm = metric_norm(rhs.view(), metric_weights);
5285 if rhs_norm == 0.0 {
5286 return Ok((Array1::<f64>::zeros(n), ArrowPcgDiagnostics::default()));
5287 }
5288 let tol = (relative_tolerance.max(0.0) * rhs_norm).max(PCG_ABSOLUTE_TOLERANCE_FLOOR);
5289 let mut x = Array1::<f64>::zeros(n);
5290 let mut r = rhs.clone();
5291 let mut z = apply_preconditioner(&r);
5292 let mut diag = ArrowPcgDiagnostics {
5293 precond_apply_calls: 1,
5294 ..ArrowPcgDiagnostics::default()
5295 };
5296 let mut p = z.clone();
5297 let mut rz = metric_dot(&r, &z, metric_weights);
5298 if rz <= 0.0 || !rz.is_finite() {
5299 if radius.is_finite() {
5300 diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
5301 diag.stopping_reason = PcgStopReason::TrustRegion;
5302 return Ok((step_to_trust_boundary(&x, &r, radius, metric_weights), diag));
5303 }
5304 // Unbounded (radius = ∞) non-positive preconditioned residual: the
5305 // reduced Schur is indefinite at the very first direction. Surface the
5306 // typed curvature-floor signal so `steihaug_pcg_auto` floors the
5307 // operator minimally and retries, instead of failing into a global
5308 // `ridge_β` ramp. `rz = rᵀM⁻¹r` is a preconditioner-metric curvature;
5309 // report it with the residual norm² as the direction scale.
5310 return Err(ArrowSchurError::UnboundedNegativeCurvature {
5311 curvature: rz,
5312 direction_norm_sq: metric_dot(&r, &r, metric_weights),
5313 });
5314 }
5315 if metric_norm(r.view(), metric_weights) <= tol {
5316 diag.final_relative_residual = 0.0;
5317 diag.stopping_reason = PcgStopReason::Converged;
5318 return Ok((x, diag));
5319 }
5320 let mut ap = Array1::<f64>::zeros(n);
5321 // Reused candidate scratch — avoid per-iteration clone of x.
5322 let mut candidate = Array1::<f64>::zeros(n);
5323 for _ in 0..max_iterations {
5324 matvec(&p, &mut ap);
5325 diag.matvec_calls += 1;
5326 diag.iterations += 1;
5327 let pap = metric_dot(&p, &ap, metric_weights);
5328 if pap <= 0.0 || !pap.is_finite() {
5329 if radius.is_finite() {
5330 diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
5331 diag.stopping_reason = PcgStopReason::TrustRegion;
5332 return Ok((step_to_trust_boundary(&x, &p, radius, metric_weights), diag));
5333 }
5334 // Unbounded negative curvature `pᵀSp ≤ 0`: the reduced Schur is
5335 // indefinite along `p` (the #1026 co-collapse direction). Surface
5336 // the typed signal carrying `pᵀSp` and `‖p‖²` so the caller floors
5337 // the operator by the minimal ridge `δ = |pᵀSp|/‖p‖²` (which makes
5338 // `pᵀ(S+δI)p = 0⁺`) plus a margin, and retries.
5339 return Err(ArrowSchurError::UnboundedNegativeCurvature {
5340 curvature: pap,
5341 direction_norm_sq: metric_dot(&p, &p, metric_weights),
5342 });
5343 }
5344 let alpha = rz / pap;
5345 for i in 0..n {
5346 candidate[i] = x[i] + alpha * p[i];
5347 }
5348 if radius.is_finite() && metric_norm(candidate.view(), metric_weights) >= radius {
5349 diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
5350 diag.stopping_reason = PcgStopReason::TrustRegion;
5351 return Ok((step_to_trust_boundary(&x, &p, radius, metric_weights), diag));
5352 }
5353 x.assign(&candidate);
5354 for i in 0..n {
5355 r[i] -= alpha * ap[i];
5356 }
5357 if metric_norm(r.view(), metric_weights) <= tol {
5358 diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
5359 diag.stopping_reason = PcgStopReason::Converged;
5360 return Ok((x, diag));
5361 }
5362 z = apply_preconditioner(&r);
5363 diag.precond_apply_calls += 1;
5364 let rz_next = metric_dot(&r, &z, metric_weights);
5365 if rz_next <= 0.0 || !rz_next.is_finite() {
5366 return Err(ArrowSchurError::PcgFailed {
5367 reason: "non-positive or non-finite PCG residual".to_string(),
5368 });
5369 }
5370 let beta = rz_next / rz;
5371 for i in 0..n {
5372 p[i] = z[i] + beta * p[i];
5373 }
5374 rz = rz_next;
5375 }
5376 diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
5377 diag.stopping_reason = PcgStopReason::MaxIter;
5378 Ok((x, diag))
5379}
5380
5381pub(crate) fn step_to_trust_boundary(
5382 x: &Array1<f64>,
5383 p: &Array1<f64>,
5384 radius: f64,
5385 metric_weights: Option<&MetricWeights>,
5386) -> Array1<f64> {
5387 let pp = metric_dot(p, p, metric_weights);
5388 if pp == 0.0 {
5389 return x.clone();
5390 }
5391 let xp = metric_dot(x, p, metric_weights);
5392 let xx = metric_dot(x, x, metric_weights);
5393 let disc = (xp * xp + pp * (radius * radius - xx)).max(0.0);
5394 let tau = (-xp + disc.sqrt()) / pp;
5395 let mut out = x.clone();
5396 for i in 0..out.len() {
5397 out[i] += tau * p[i];
5398 }
5399 out
5400}
5401
5402pub(crate) fn dense_matvec(a: &Array2<f64>, x: &Array1<f64>, out: &mut Array1<f64>) {
5403 let n = a.nrows();
5404 for i in 0..n {
5405 let mut acc = 0.0;
5406 for j in 0..n {
5407 acc += a[[i, j]] * x[j];
5408 }
5409 out[i] = acc;
5410 }
5411}
5412
5413pub(crate) fn dot(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
5414 let mut acc = 0.0;
5415 for i in 0..a.len() {
5416 acc += a[i] * b[i];
5417 }
5418 acc
5419}
5420
5421pub(crate) fn metric_dot(
5422 a: &Array1<f64>,
5423 b: &Array1<f64>,
5424 metric_weights: Option<&MetricWeights>,
5425) -> f64 {
5426 assert_eq!(a.len(), b.len());
5427 match metric_weights {
5428 Some(weights) => {
5429 assert_eq!(weights.len(), a.len());
5430 let mut acc = 0.0;
5431 for i in 0..a.len() {
5432 acc += weights[i] * a[i] * b[i];
5433 }
5434 acc
5435 }
5436 None => dot(a, b),
5437 }
5438}
5439
5440pub(crate) fn metric_norm(v: ArrayView1<'_, f64>, metric_weights: Option<&MetricWeights>) -> f64 {
5441 let mut acc = 0.0;
5442 match metric_weights {
5443 Some(weights) => {
5444 assert_eq!(weights.len(), v.len());
5445 for i in 0..v.len() {
5446 acc += weights[i] * v[i] * v[i];
5447 }
5448 }
5449 None => {
5450 for x in v.iter() {
5451 acc += x * x;
5452 }
5453 }
5454 }
5455 acc.sqrt()
5456}
5457
5458pub(crate) fn symmetrize_upper_from_lower(a: &mut Array2<f64>) {
5459 let n = a.nrows().min(a.ncols());
5460 for i in 0..n {
5461 for j in 0..i {
5462 let v = 0.5 * (a[[i, j]] + a[[j, i]]);
5463 a[[i, j]] = v;
5464 a[[j, i]] = v;
5465 }
5466 }
5467}
5468
5469/// Errors raised by [`ArrowSchurSystem::solve`].
5470#[derive(Debug, Clone)]
5471pub enum ArrowSchurError {
5472 /// A per-row `H_tt^(i)` block was not positive-definite at the
5473 /// supplied ridge. Indicates an under-regularized latent block —
5474 /// typically a gauge-free fit without an identifiability penalty.
5475 PerRowFactorFailed { row: usize, reason: String },
5476 /// A per-row `H_tt^(i)` block factored, but the Cholesky factor failed
5477 /// the safe-inversion guard for the Schur reduction. This can be either
5478 /// an excessive diagonal-ratio condition-number estimate or a numerically
5479 /// tiny pivot relative to the row block scale. Cholesky technically
5480 /// succeeded, but the inverse used in
5481 /// `S = H_ββ − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)` is contaminated
5482 /// by spectral terms on the order of `κ_i`; functionally
5483 /// equivalent to a PSD-fail for Schur stability. The LM outer
5484 /// wrapper escalates `ridge_t` identically to `PerRowFactorFailed`.
5485 PerRowFactorIllConditioned { row: usize, kappa_estimate: f64 },
5486 /// The Schur complement was not positive-definite. Indicates a
5487 /// near-collinear decoder or a degenerate weighting; the LM outer
5488 /// wrapper should escalate `ridge_beta` and retry.
5489 SchurFactorFailed { reason: String },
5490 /// The BA inexact-step PCG solve failed before producing a usable
5491 /// Steihaug trust-region step.
5492 PcgFailed { reason: String },
5493 /// The UNBOUNDED (trust-radius = ∞) Schur PCG encountered negative
5494 /// curvature `pᵀSp ≤ 0` (or a non-positive preconditioned residual): the
5495 /// reduced Schur is indefinite, the #1026 K≥4 co-collapse signature where
5496 /// a near-singular per-row `H_tt` over-subtracts `S`. With no trust radius
5497 /// there is no boundary to step to, so CG cannot proceed. `curvature` is
5498 /// the offending `pᵀSp` and `direction_norm_sq` the `‖p‖²` of the
5499 /// negative-curvature direction; the caller floors the operator with the
5500 /// minimal ridge `δ = (|curvature|/‖p‖² )·(1+ε)` that restores positive
5501 /// curvature along `p` and retries (matrix-free analogue of the dense
5502 /// `spectral_pd_floored_schur`), rather than blindly inflating `ridge_β`.
5503 UnboundedNegativeCurvature {
5504 curvature: f64,
5505 direction_norm_sq: f64,
5506 },
5507 /// Adaptive proximal damping could not produce an Armijo-accepted
5508 /// nonlinear step.
5509 AdaptiveCorrectionFailed { reason: String },
5510}
5511
5512impl std::fmt::Display for ArrowSchurError {
5513 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5514 match self {
5515 ArrowSchurError::PerRowFactorFailed { row, reason } => write!(
5516 f,
5517 "arrow-Schur: per-row H_tt^({row}) Cholesky failed: {reason}"
5518 ),
5519 ArrowSchurError::PerRowFactorIllConditioned {
5520 row,
5521 kappa_estimate,
5522 } => write!(
5523 f,
5524 "arrow-Schur: per-row H_tt^({row}) Cholesky succeeded but failed \
5525 the safe-inversion guard (kappa_estimate={kappa_estimate:e}); \
5526 Schur reduction would be numerically contaminated"
5527 ),
5528 ArrowSchurError::SchurFactorFailed { reason } => {
5529 write!(f, "arrow-Schur: Schur complement Cholesky failed: {reason}")
5530 }
5531 ArrowSchurError::PcgFailed { reason } => {
5532 write!(f, "arrow-Schur: Schur PCG failed: {reason}")
5533 }
5534 ArrowSchurError::UnboundedNegativeCurvature {
5535 curvature,
5536 direction_norm_sq,
5537 } => write!(
5538 f,
5539 "arrow-Schur: unbounded Schur PCG hit negative curvature pᵀSp={curvature:e} \
5540 (‖p‖²={direction_norm_sq:e}); reduced Schur is indefinite (co-collapse), \
5541 retry with a curvature-floor ridge"
5542 ),
5543 ArrowSchurError::AdaptiveCorrectionFailed { reason } => {
5544 write!(
5545 f,
5546 "arrow-Schur: adaptive proximal correction failed: {reason}"
5547 )
5548 }
5549 }
5550 }
5551}
5552
5553impl std::error::Error for ArrowSchurError {}
5554
5555// ---------------------------------------------------------------------------
5556// Cholesky helpers (kept local to avoid a new public-API dependency on the
5557// linalg crate. The systems here are tiny per-row (d × d, d ∈ {1..16}) and
5558// modest at the Schur level (K × K, K ∈ {basis size}). For production SAE
5559// scales the Schur factor should switch to faer; this module's `cholesky_lower`
5560// is the obvious replacement site.)
5561// ---------------------------------------------------------------------------
5562
5563pub(crate) fn cholesky_lower(a: &Array2<f64>) -> Result<Array2<f64>, String> {
5564 let n = a.nrows();
5565 if a.ncols() != n {
5566 return Err(format!("cholesky_lower: non-square {}×{}", n, a.ncols()));
5567 }
5568 if let Some((idx, _)) = a.iter().enumerate().find(|(_, v)| !v.is_finite()) {
5569 return Err(format!(
5570 "cholesky_lower: non-finite entry at linear index {idx}"
5571 ));
5572 }
5573
5574 // CPU factorization seam (#1017): device routing happens explicitly in the
5575 // arrow-Schur solve before reaching this reference/fallback primitive. At
5576 // the SAE border width the reduced Schur is a
5577 // dense `k×k` (k≈2k–4k) whose scalar triple-loop factorization is O(k³/3)
5578 // and neither blocked nor SIMD-vectorized — the dominant per-Newton-step
5579 // cost on a CPU-only host. faer's blocked LLT computes the SAME `A = L Lᵀ`
5580 // (to O(κ·ε), the slack the reduced solve/log-det already tolerate) an order
5581 // of magnitude faster. Restrict it to `k ≥ FAER_CHOLESKY_MIN` so the many
5582 // tiny per-row `d×d` blocks (d≤~8, factorization.rs) and the small dense
5583 // test fixtures keep the exact scalar loop — bit-for-bit their historical
5584 // factor — where faer's setup overhead would not pay off anyway. If faer
5585 // declines (a non-PD blocked pivot) fall through to the scalar loop so the
5586 // PD/non-PD verdict and its typed error stay exactly the historical ones
5587 // (`factor_dense_reduced_schur`'s spectral-floor fallback keys only on Ok vs
5588 // Err, so the boundary behavior is unchanged).
5589 const FAER_CHOLESKY_MIN: usize = 128;
5590 if n >= FAER_CHOLESKY_MIN {
5591 let view = gam_linalg::faer_ndarray::FaerArrayView::new(a);
5592 if let Ok(llt) = gam_linalg::faer_ndarray::FaerLlt::new(view.as_ref(), faer::Side::Lower) {
5593 let l_faer = llt.L();
5594 let mut l = Array2::<f64>::zeros((n, n));
5595 for i in 0..n {
5596 for j in 0..=i {
5597 l[[i, j]] = l_faer[(i, j)];
5598 }
5599 }
5600 return Ok(l);
5601 }
5602 }
5603
5604 let mut l = Array2::<f64>::zeros((n, n));
5605 for i in 0..n {
5606 for j in 0..=i {
5607 let mut sum = a[[i, j]];
5608 for kk in 0..j {
5609 sum -= l[[i, kk]] * l[[j, kk]];
5610 }
5611 if i == j {
5612 if !sum.is_finite() || sum <= 0.0 {
5613 return Err(format!(
5614 "non-PD pivot {sum} at index {i} (matrix is not positive definite)"
5615 ));
5616 }
5617 l[[i, j]] = sum.sqrt();
5618 } else {
5619 l[[i, j]] = sum / l[[j, j]];
5620 }
5621 }
5622 }
5623 Ok(l)
5624}