Skip to main content

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 let Err(err) = ctx.bind_to_thread() {
260                                log::debug!(
261                                    "arrow-schur tile {ordinal}: CUDA context bind failed ({err}); \
262                                     this tile reduces on the CPU"
263                                );
264                            }
265                        }
266                    }
267                    tile_schur_partial(sys, htt_factors, backend, kind, ordinal, range)
268                })
269            })
270            .collect();
271        handles
272            .into_iter()
273            .map(|handle| {
274                handle
275                    .join()
276                    .map_err(|_| ArrowSchurError::SchurFactorFailed {
277                        reason: "schur-reduction tile thread panicked".to_string(),
278                    })?
279            })
280            .collect()
281    });
282    let partials = partials?;
283
284    // Fold partials into `schur` in tile order (contiguous, covering 0..n) so
285    // the per-tile and inter-tile accumulation order is the row order; each
286    // partial holds `-Σ contribution` over its rows, so `schur += partial`
287    // reproduces `schur -= Σ contribution`.
288    for partial in &partials {
289        for a in 0..k {
290            for b in 0..k {
291                schur[[a, b]] += partial[[a, b]];
292            }
293        }
294    }
295    Ok(())
296}
297
298pub(crate) fn build_dense_schur_direct<B: BatchedBlockSolver + Sync>(
299    sys: &ArrowSchurSystem,
300    htt_factors: &ArrowFactorSlab,
301    ridge_beta: f64,
302    backend: &B,
303    gpu_policy: gam_gpu::GpuPolicy,
304) -> Result<Array2<f64>, ArrowSchurError> {
305    let k = sys.k;
306    // Materialise H_ββ via the BetaPenaltyOp trait (#296): DensePenaltyOp
307    // for the legacy dense path, structured ops for SAE / Kronecker smooths.
308    let op = sys.effective_penalty_op();
309    if op.dim() != k {
310        return Err(ArrowSchurError::SchurFactorFailed {
311            reason: "Direct BA requires a K×K shared H_ββ penalty operator".to_string(),
312        });
313    }
314    // Fail LOUD, never OOM-kill (#1017): the dense reduced Schur is `k × k` f64.
315    // At SAE LLM borders (qwen `k = 98304` ⇒ 77 GiB) materialising it would crash
316    // the host. Direct deliberately uses this one canonical dense Schur for both
317    // the Newton step and evidence; large-border matrix-free solves belong to
318    // InexactPCG (and automatic selection routes them there). Refuse an explicit
319    // oversized Direct request with an actionable error rather than duplicating
320    // ownership or degrading silently into an OOM. The budget is generous so
321    // every currently-feasible border (k ≤ 5120 ⇒ 0.2 GiB) is unaffected.
322    let dense_bytes = (k as u128).saturating_mul(k as u128).saturating_mul(8);
323    if dense_bytes > DENSE_SCHUR_BYTES_BUDGET {
324        return Err(ArrowSchurError::SchurFactorFailed {
325            reason: format!(
326                "dense reduced Schur is {k}×{k} f64 = {} MiB, exceeding the {} MiB host budget; \
327                 Direct requires one canonical dense Schur for its step and evidence; select \
328                 InexactPCG for a matrix-free large-border step",
329                dense_bytes / (1024 * 1024),
330                DENSE_SCHUR_BYTES_BUDGET / (1024 * 1024),
331            ),
332        });
333    }
334    let mut schur = op.to_dense();
335    for j in 0..k {
336        schur[[j, j]] += ridge_beta;
337    }
338    reduce_row_schur_contributions(
339        sys,
340        htt_factors,
341        backend,
342        SchurReductionKind::Direct,
343        &mut schur,
344        gpu_policy,
345    )?;
346    symmetrize_upper_from_lower(&mut schur);
347    Ok(schur)
348}
349
350pub(crate) fn build_dense_schur_sqrt_ba<B: BatchedBlockSolver + Sync>(
351    sys: &ArrowSchurSystem,
352    htt_factors: &ArrowFactorSlab,
353    ridge_beta: f64,
354    backend: &B,
355    gpu_policy: gam_gpu::GpuPolicy,
356) -> Result<Array2<f64>, ArrowSchurError> {
357    let k = sys.k;
358    // Materialise H_ββ via the BetaPenaltyOp trait (#296).
359    let op = sys.effective_penalty_op();
360    if op.dim() != k {
361        return Err(ArrowSchurError::SchurFactorFailed {
362            reason: "Square-Root BA direct solve requires a K×K shared H_ββ penalty operator"
363                .to_string(),
364        });
365    }
366    // Same fail-loud host-memory contract as the Direct reduction (#1017).  The
367    // square-root BA route still materialises the same dense `k×k` reduced
368    // Schur; letting this path bypass the budget would preserve an OOM-class
369    // fallback even after Direct learned to refuse matrix-free-only borders.
370    let dense_bytes = (k as u128).saturating_mul(k as u128).saturating_mul(8);
371    if dense_bytes > DENSE_SCHUR_BYTES_BUDGET {
372        return Err(ArrowSchurError::SchurFactorFailed {
373            reason: format!(
374                "square-root BA dense reduced Schur is {k}×{k} f64 = {} MiB, exceeding the \
375                 {} MiB host budget; this border is matrix-free-only",
376                dense_bytes / (1024 * 1024),
377                DENSE_SCHUR_BYTES_BUDGET / (1024 * 1024),
378            ),
379        });
380    }
381    let mut schur = op.to_dense();
382    for j in 0..k {
383        schur[[j, j]] += ridge_beta;
384    }
385    reduce_row_schur_contributions(
386        sys,
387        htt_factors,
388        backend,
389        SchurReductionKind::SqrtBa,
390        &mut schur,
391        gpu_policy,
392    )?;
393    symmetrize_upper_from_lower(&mut schur);
394    Ok(schur)
395}
396
397/// Certified Carson–Higham mixed-precision solve of the reduced dense Schur
398/// system `S Δβ = rhs` (#1014), specialized to the streaming/residency path.
399///
400/// Returns `Some(Δβ)` when certified mixed precision is enabled AND the κ gate
401/// admits the f32 factorization AND the f64 backward-error certificate closes;
402/// `None` in every other case so the caller falls back to the exact f64
403/// triangular solve. The f64 `factor` (whose diagonal carries the exact
404/// `log|S|`) is supplied by the caller and never re-derived here — the logdet
405/// the evidence path reads stays f64 by construction.
406///
407/// Method: store the f64 Cholesky factor as f32, solve in f32, then refine with
408/// residuals `r = rhs − S·x` computed in f64 against the f64 `S`. With
409/// `κ(S)·u_f32 < margin` the refinement contracts at rate `κ·u`, and the
410/// terminating certificate is the normwise backward error
411/// `‖r‖∞ / (‖S‖∞‖x‖∞ + ‖rhs‖∞) ≤ tol`. A non-decreasing residual or an
412/// unmet certificate after `max_refinement_steps` returns `None`.
413pub(crate) fn mixed_precision_reduced_beta(
414    schur: &Array2<f64>,
415    factor: &Array2<f64>,
416    rhs: &Array1<f64>,
417    options: &ArrowSolveOptions,
418) -> Option<Array1<f64>> {
419    let ArrowSolvePrecisionPolicy::CertifiedMixed {
420        max_refinement_steps,
421        residual_relative_tolerance,
422        kappa_unit_roundoff_margin,
423    } = options.solve_precision
424    else {
425        return None;
426    };
427    // The reduced-system mixed-precision path is the dense reduced solve only;
428    // a trust-region-truncated step takes the Steihaug branch below in f64.
429    if options.trust_region.radius.is_finite() {
430        return None;
431    }
432    let n = schur.nrows();
433    if n == 0 {
434        return None;
435    }
436
437    // κ gate: the f32 factorization is only admissible when κ(S)·u_f32 leaves
438    // the refinement contraction headroom the certificate needs.
439    let kappa = cholesky_factor_kappa_estimate(factor);
440    if !kappa.is_finite() || kappa * F32_UNIT_ROUNDOFF >= kappa_unit_roundoff_margin {
441        return None;
442    }
443
444    let factor_f32 = factor.mapv(|v| v as f32);
445    let s_inf = matrix_inf_norm(schur);
446    let rhs_inf = rhs.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
447    let certificate_tol = residual_relative_tolerance
448        .max(MIXED_PRECISION_CERTIFICATE_EPSILON_MULTIPLIER * f64::EPSILON);
449
450    // f32 solve of the seed system, then f64-residual refinement steps.
451    let mut x = cholesky_solve_lower_f32(&factor_f32, &rhs.mapv(|v| v as f32)).mapv(|v| v as f64);
452    let mut last_residual = f64::INFINITY;
453    for _ in 0..=max_refinement_steps {
454        // Residual r = rhs − S·x in f64 against the f64 model.
455        let sx = schur.dot(&x);
456        let mut r = rhs.clone();
457        r -= &sx;
458        let r_inf = r.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
459        let x_inf = x.iter().fold(0.0_f64, |a, &b| a.max(b.abs()));
460        let denom = s_inf * x_inf + rhs_inf;
461        let backward_error = if denom > 0.0 { r_inf / denom } else { 0.0 };
462        if backward_error <= certificate_tol {
463            return Some(x);
464        }
465        // Refinement must make monotone progress, else hand back to f64.
466        if !(r_inf < last_residual) {
467            return None;
468        }
469        last_residual = r_inf;
470        // Correction solve in f32 against the f32 factor: S·δ = r.
471        let delta = cholesky_solve_lower_f32(&factor_f32, &r.mapv(|v| v as f32)).mapv(|v| v as f64);
472        x += &delta;
473    }
474    None
475}
476
477/// Infinity norm (max absolute row sum) of a dense matrix.
478pub(crate) fn matrix_inf_norm(a: &Array2<f64>) -> f64 {
479    let mut max_row = 0.0_f64;
480    for row in a.rows() {
481        let s: f64 = row.iter().map(|v| v.abs()).sum();
482        if s > max_row {
483            max_row = s;
484        }
485    }
486    max_row
487}
488
489/// Spectral positive-definiteness floor for the reduced Schur complement
490/// `S` (#1026 SAE co-collapse SOLVE-path cure).
491///
492/// Reached only after the genuine Cholesky of `S` has REFUSED it (an indefinite
493/// reduced Schur: collapsed atoms drive a per-row `H_tt` near-singular, so the
494/// accumulated `Σ_i H_tβᵀ (H_tt)⁻¹ H_tβ` over-subtracts `H_ββ + ridge_β·I` into a
495/// matrix with a non-positive eigenvalue). Rather than reject and let the LM
496/// loop inflate `ridge_β` over EVERY β direction (the #1026 "crawl"), we
497/// symmetric-eigendecompose `S` and clamp every eigenvalue UP to
498/// `floor·max(λ)`. This is Levenberg–Marquardt restricted to exactly the
499/// indefinite/collapsed subspace: a well-separated positive direction
500/// (`λ ≫ floor·max λ`) keeps its EXACT eigenvalue (`λ.max(floor·max λ) = λ`), so
501/// the Newton step in the healthy β subspace is unchanged, while only the
502/// collapsed directions get the minimal positive stiffness needed for a PD
503/// solve. Returns the floored, symmetric, strictly-PD matrix, or `None` if `S`
504/// has no usable scale (non-finite / all-zero spectrum), in which case the
505/// caller keeps the strict refusal.
506///
507/// Mirrors the per-row evidence floor
508/// [`super::factorization::factor_spectral_deflated_criterion_row_with_geometry`]; the only
509/// difference is the floored VALUE — a small positive `floor·max λ` (Tikhonov,
510/// for an accurate solve) here, vs unit stiffness `+1` (`log 1 = 0`) there (for
511/// the quotient log-det).
512pub(crate) fn spectral_pd_floored_schur(
513    schur: &Array2<f64>,
514    relative_floor: f64,
515) -> Option<(Array2<f64>, Array2<f64>)> {
516    spectral_pd_floored_schur_with_factor(schur, relative_floor)
517}
518
519/// Shared body for [`spectral_pd_floored_schur`]: symmetrise, eigendecompose,
520/// condition the spectrum, and return BOTH
521/// the conditioned matrix `Σ λ̃_i v_i v_iᵀ` (consumed by Steihaug / matvec /
522/// mixed-precision refinement) and its lower Cholesky factor.
523///
524/// The factor is built DIRECTLY from the conditioned spectral form — QR of
525/// `W = diag(√λ̃)·Vᵀ` gives `A = WᵀW = RᵀR`, so `L = Rᵀ` — never by
526/// re-factorising the reconstructed matrix. Reconstruct-then-refactor fails
527/// under extreme eigenvalue spread: with `λ_max ~ 1e57` the `Σ λ̃ v vᵀ`
528/// reconstruction carries `O(ε·λ_max)` round-off, which swamps unit-deflated
529/// (`λ̃ = 1`) and floored (`λ̃ = floor·λ_max`) directions and re-poisons the
530/// second Cholesky — the #2230 "spectral PD-floor reconstruction still non-PD"
531/// refusal at a ρ whose conditioned evidence is perfectly well-defined. The QR
532/// route factors the exact conditioned spectrum, so it succeeds whenever the
533/// policy produced strictly positive `λ̃` (always, by construction).
534fn spectral_pd_floored_schur_with_factor(
535    schur: &Array2<f64>,
536    relative_floor: f64,
537) -> Option<(Array2<f64>, Array2<f64>)> {
538    let n = schur.nrows();
539    if n == 0 || schur.ncols() != n || !(relative_floor.is_finite() && relative_floor > 0.0) {
540        return None;
541    }
542    // Symmetrise defensively (the assembled Schur is symmetric up to reduction
543    // order; the eig routine assumes exact symmetry).
544    let mut sym = Array2::<f64>::zeros((n, n));
545    for i in 0..n {
546        for j in 0..n {
547            let v = 0.5 * (schur[[i, j]] + schur[[j, i]]);
548            if !v.is_finite() {
549                return None;
550            }
551            sym[[i, j]] = v;
552        }
553    }
554    let (evals, evecs) = sym.eigh(Side::Lower).ok()?;
555    let max_abs = evals.iter().fold(
556        0.0_f64,
557        |acc, &v| if v.is_finite() { acc.max(v.abs()) } else { acc },
558    );
559    if !(max_abs.is_finite() && max_abs > 0.0) {
560        return None;
561    }
562    let floor = relative_floor * max_abs;
563    // Newton-step policy (LM): clamp every eigenvalue UP to a strictly positive
564    // `floor` — healthy positive directions (`λ ≫ floor`) keep their EXACT
565    // eigenvalue, collapsed/indefinite directions get the minimal stiffness for
566    // a stable `Δβ`.
567    let mut conditioned = Array2::<f64>::zeros((n, n));
568    let mut weighted_vt = Array2::<f64>::zeros((n, n));
569    for eig_idx in 0..evals.len() {
570        let lambda = evals[eig_idx];
571        let lambda_conditioned = if lambda.is_finite() {
572            lambda.max(floor)
573        } else {
574            floor
575        };
576        let sqrt_lambda = lambda_conditioned.sqrt();
577        for i in 0..n {
578            let vi = evecs[[i, eig_idx]];
579            weighted_vt[[eig_idx, i]] = sqrt_lambda * vi;
580            if vi == 0.0 {
581                continue;
582            }
583            for j in 0..n {
584                conditioned[[i, j]] += lambda_conditioned * vi * evecs[[j, eig_idx]];
585            }
586        }
587    }
588    let factor =
589        spectral_qr_cholesky_factor(&weighted_vt).or_else(|| cholesky_lower(&conditioned).ok())?;
590    Some((conditioned, factor))
591}
592
593/// Original-coordinate unit-deflation for an evidence reduced Schur.
594///
595/// The rank decision and unit pin are made in the caller's β coordinates. A
596/// Jacobi congruence is appropriate for a Newton solve but would turn a unit
597/// eigenvalue in scaled coordinates into a scale-dependent stiffness after
598/// unscaling, corrupting both `log 1 = 0` and the cached null-space metadata.
599fn factor_evidence_unit_deflated_schur(
600    schur: &Array2<f64>,
601    relative_floor: f64,
602    refuse_resolved_indefinite: bool,
603    exact_a: Option<&ExactAReducedClassification>,
604) -> Result<DenseReducedSchurFactorization, ArrowSchurError> {
605    let declined = |reason: &str| ArrowSchurError::SchurFactorFailed {
606        reason: format!("evidence reduced Schur unit-deflation declined ({reason})"),
607    };
608    let n = schur.nrows();
609    if n == 0 || schur.ncols() != n || !(relative_floor.is_finite() && relative_floor > 0.0) {
610        return Err(declined("empty, non-square, or invalid relative floor"));
611    }
612    let mut sym = Array2::<f64>::zeros((n, n));
613    for i in 0..n {
614        for j in 0..n {
615            let value = 0.5 * (schur[[i, j]] + schur[[j, i]]);
616            if !value.is_finite() {
617                return Err(declined("non-finite entry"));
618            }
619            sym[[i, j]] = value;
620        }
621    }
622    let (raw_evals, evecs) = sym
623        .eigh(Side::Lower)
624        .map_err(|_| declined("symmetric eigendecomposition failed"))?;
625    let max_abs = raw_evals.iter().fold(0.0_f64, |acc, &value| {
626        if value.is_finite() {
627            acc.max(value.abs())
628        } else {
629            acc
630        }
631    });
632    if !(max_abs.is_finite() && max_abs > 0.0) {
633        return Err(declined("no usable spectrum"));
634    }
635    if let Some(geometry) = exact_a
636        && (geometry.majorizer_metric.dim() != (n, n)
637            || geometry.clamp_metric.dim() != (n, n))
638    {
639        return Err(declined(
640            "exact-A majorizer/clamp metrics do not match the reduced Schur",
641        ));
642    }
643    if refuse_resolved_indefinite && exact_a.is_none() {
644        return Err(declined(
645            "exact-A evidence classification requires its raw B/delta/clamp carrier",
646        ));
647    }
648    let deflate_floor = relative_floor * max_abs * (1.0 - SPECTRAL_DEFLATION_HYSTERESIS_FRACTION);
649    let mut conditioning = vec![BetaSchurSpectralConditioning::Raw; raw_evals.len()];
650    let mut cond_evals = raw_evals.clone();
651    let mut classification_changed = false;
652    for eig_idx in 0..raw_evals.len() {
653        let value = raw_evals[eig_idx];
654        if let Some(geometry) = exact_a {
655            let direction = evecs.column(eig_idx);
656            let majorizer_curvature =
657                direction.dot(&geometry.majorizer_metric.dot(&direction));
658            let clamp_curvature = direction.dot(&geometry.clamp_metric.dot(&direction));
659            match classify_exact_a_direction(
660                value,
661                n,
662                max_abs,
663                majorizer_curvature,
664                clamp_curvature,
665            ) {
666                ExactADirectionClassification::ResolvedPositive { curvature } => {
667                    cond_evals[eig_idx] = curvature;
668                }
669                ExactADirectionClassification::NumericalNull => {
670                    conditioning[eig_idx] = BetaSchurSpectralConditioning::UnitDeflated;
671                    cond_evals[eig_idx] = 1.0;
672                    classification_changed = true;
673                }
674                ExactADirectionClassification::ClampBasin { curvature } => {
675                    conditioning[eig_idx] = BetaSchurSpectralConditioning::ClampBasin;
676                    cond_evals[eig_idx] = curvature;
677                    classification_changed = true;
678                }
679                ExactADirectionClassification::Saddle { curvature, basin } => {
680                    return Err(ArrowSchurError::SchurFactorFailed {
681                        reason: format!(
682                            "reduced-Schur {}: direction {eig_idx} has raw exact-A curvature \
683                             {curvature:.6e} and clamp basin {basin:.6e}; the shared \
684                             majorizer-metric classifier declares a genuine saddle (#2515/#2336)",
685                            ArrowSchurError::indefinite_evidence_marker(),
686                        ),
687                    });
688                }
689            }
690        } else if !value.is_finite() || value < deflate_floor {
691            conditioning[eig_idx] = BetaSchurSpectralConditioning::UnitDeflated;
692            cond_evals[eig_idx] = 1.0;
693            classification_changed = true;
694        }
695    }
696
697    // Preserve the ordinary equilibrated-Cholesky bit path in the interior.
698    // If Cholesky alone is numerically unable to factor a spectrally healthy
699    // operator, the spectral QR below still factors the identical raw spectrum.
700    if !classification_changed
701        && let Ok(interior) = factor_dense_reduced_schur(schur, ReducedSchurPolicy::StrictNewton)
702    {
703        return Ok(interior);
704    }
705
706    let mut conditioned = Array2::<f64>::zeros((n, n));
707    let mut weighted_vt = Array2::<f64>::zeros((n, n));
708    for eig_idx in 0..n {
709        let lambda = cond_evals[eig_idx];
710        if !(lambda.is_finite() && lambda > 0.0) {
711            return Err(declined("conditioned eigenvalue is not finite and positive"));
712        }
713        let sqrt_lambda = lambda.sqrt();
714        for i in 0..n {
715            let vi = evecs[[i, eig_idx]];
716            weighted_vt[[eig_idx, i]] = sqrt_lambda * vi;
717            if vi != 0.0 {
718                for j in 0..n {
719                    conditioned[[i, j]] += lambda * vi * evecs[[j, eig_idx]];
720                }
721            }
722        }
723    }
724    let factor = spectral_qr_cholesky_factor(&weighted_vt)
725        .ok_or_else(|| declined("spectral QR Cholesky of the conditioned spectrum declined"))?;
726    let beta_conditioning = classification_changed.then(|| BetaSchurConditioningSpectrum {
727        evecs,
728        raw_evals,
729        cond_evals,
730        conditioning: conditioning.into(),
731    });
732    Ok(DenseReducedSchurFactorization {
733        factor,
734        conditioned_schur: beta_conditioning.as_ref().map(|_| conditioned),
735        beta_conditioning,
736    })
737}
738
739/// Lower Cholesky factor of `A = WᵀW` computed from `W` itself: QR gives
740/// `W = QR ⇒ A = RᵀR`, so the factor is `L = Rᵀ` (rows sign-fixed to a positive
741/// diagonal). `W` here is `diag(√λ̃)·Vᵀ` with every `λ̃ > 0`, so `W` has full
742/// rank and the factor exists exactly; returns `None` only if the QR itself
743/// declines or produces a non-finite / zero pivot, in which case the caller
744/// falls back to factoring the reconstructed matrix (the historical path).
745fn spectral_qr_cholesky_factor(weighted_vt: &Array2<f64>) -> Option<Array2<f64>> {
746    let n = weighted_vt.nrows();
747    let (_q, r) = weighted_vt.qr().ok()?;
748    if r.nrows() != n || r.ncols() != n {
749        return None;
750    }
751    let mut l = Array2::<f64>::zeros((n, n));
752    for i in 0..n {
753        let d = r[[i, i]];
754        if !d.is_finite() || d == 0.0 {
755            return None;
756        }
757        let s = if d < 0.0 { -1.0 } else { 1.0 };
758        for j in i..n {
759            let v = s * r[[i, j]];
760            if !v.is_finite() {
761                return None;
762            }
763            l[[j, i]] = v;
764        }
765    }
766    Some(l)
767}
768
769/// Jacobi/Van der Sluis diagonal equilibration scale for a symmetric matrix
770/// (#2015): `d_a = sqrt(|schur[a,a]|)`, floored at `√JACOBI_DIAGONAL_PD_FLOOR`
771/// so a numerically-empty diagonal entry never divides by ~0. This is a PURE
772/// numerical-conditioning aid for [`factor_dense_reduced_schur`] below — it is
773/// never returned or exposed, and it changes no value any caller of that
774/// function sees, only the accuracy of computing it.
775///
776/// #2822 — the scale is the diagonal's MAGNITUDE, not the signed entry. Van der
777/// Sluis is stated for a positive-definite matrix, where the two agree
778/// (`|S_aa| = S_aa`, and `abs` on a positive finite double is exact), so this is
779/// BIT-IDENTICAL on every matrix that reaches the Cholesky success path — a
780/// positive-definite matrix has no non-positive diagonal. It differs only on the
781/// matrices that fall through to the spectral floor, and there it is the whole
782/// point.
783///
784/// Reading the SIGNED entry made the equilibration ANTI-equilibrating on exactly
785/// the operators the floor exists for. A collapsed reduced Schur carries a
786/// NEGATIVE diagonal; `S_aa > JACOBI_DIAGONAL_PD_FLOOR` is then false, so that
787/// direction was scaled by the substitute `√1e-18 = 1e-9` — dividing an entry of
788/// magnitude `|S_aa|` by `1e-18` and AMPLIFYING it by eighteen decades instead of
789/// normalising it to unit magnitude. `spectral_pd_floored_schur` then reads
790/// `floor = relative_floor · max|λ|` off that inflated spectrum, so the floor is
791/// eighteen decades too high and clamps the HEALTHY directions with it.
792///
793/// Measured on the `owed_1026` mixed-collapse fixture `S = diag(+5, −99)`, whose
794/// healthy Newton step is exactly `Δβ_0 = −g/S = 10/5 = 2`: the signed form gave
795/// `d = (√5, 1e-9)`, `S̃ = diag(1, −9.9e19)`, `floor = 1e-8 · 9.9e19 = 9.9e11`,
796/// so the healthy `λ̃ = 1` was clamped to `9.9e11`, `S_floored,00 = 9.9e11·5 =
797/// 4.95e12` and `Δβ_0 = 2.0202020202e-12` — the live subspace wrong by twelve
798/// orders of magnitude, against a documented contract that it keeps its EXACT
799/// eigenvalue. With the magnitude, `S̃ = diag(1, −1)`, `floor = 1e-8`, the healthy
800/// direction keeps `λ̃ = 1` and `Δβ_0 = 2` exactly, while the collapsed direction
801/// still receives its minimal positive stiffness.
802fn jacobi_diagonal_scale(schur: &Array2<f64>) -> Array1<f64> {
803    let n = schur.nrows();
804    let floor_sqrt = JACOBI_DIAGONAL_PD_FLOOR.sqrt();
805    let mut d = Array1::<f64>::zeros(n);
806    for a in 0..n {
807        let magnitude = schur[[a, a]].abs();
808        d[a] = if magnitude.is_finite() && magnitude > JACOBI_DIAGONAL_PD_FLOOR {
809            magnitude.sqrt()
810        } else {
811            floor_sqrt
812        };
813    }
814    d
815}
816
817/// Factor the dense reduced Schur complement `S`, returning its lower Cholesky
818/// factor, the conditioned operator when policy changed it, and authoritative
819/// β-null metadata for evidence unit deflation.
820///
821/// #2015 — SOLVER-LEVEL conditioning fix (design: issue 2015 comment
822/// 4949898801). A real activation+behavior augmented target can carry output
823/// column-norm spreads of ~1e4 (joint Hessian condition number ≈ 1e8), which a
824/// PLAIN `cholesky_lower(schur)` is not designed to survive: the recursive
825/// `L_ii = sqrt(S_ii − Σ_{j<i} L_ij²)` step loses precision (or falsely
826/// refuses a genuinely PD matrix) when the diagonal spans many orders of
827/// magnitude. Equilibrate FIRST: `D = diag(d)` with `d_a = sqrt(|S_aa|)`
828/// ([`jacobi_diagonal_scale`] — Van der Sluis equilibration, provably within a
829/// factor of `n` of the OPTIMAL diagonal preconditioner for a symmetric
830/// matrix), factor `S̃ = D⁻¹SD⁻¹` (unit diagonal by construction) with the
831/// EXACT SAME Cholesky/spectral-floor logic below, then undo the equilibration
832/// on the way out.
833///
834/// This is NOT a reparametrization of any objective or estimand (contrast the
835/// REVERTED #2015 attempt that divided the FIT TARGET's columns, which
836/// changed what "best fit" means for a homoscedastic residual). `D` is
837/// diagonal, so `L := D·L̃` is STILL lower-triangular, and
838/// `L·Lᵀ = D·S̃·Dᵀ = D·(D⁻¹SD⁻¹)·D = S` exactly — `L` is a bit-exact valid
839/// Cholesky factor of the CALLER'S ORIGINAL `schur`, just computed via a
840/// numerically superior route. Undoing the scale is one exact elementwise
841/// multiply (`factor[i,j] *= d[i]`, `floored[i,j] *= d[i]*d[j]`) — no further
842/// precision is lost recovering original units. Evidence unit deflation
843/// deliberately bypasses this congruence and works in the original β
844/// coordinates so a unit-pinned null contributes exactly `log 1`.
845///
846/// GPU cross-reference: the device/GPU dense-reference path
847/// (`gam_solve::gpu_kernels::arrow_schur::solve_arrow_newton_step_dense_reference`)
848/// factors the full joint `(t, β)` system independently of this function and
849/// does NOT yet get this equilibration. Both paths are exact; the GPU path is
850/// simply not yet as well-conditioned on an ill-scaled system. Porting the
851/// same technique there is a deliberate follow-up, not part of this change.
852///
853/// Newton-step damping and evidence quotient deflation are deliberately
854/// different policies: Tikhonov directions retain a small positive curvature
855/// for a stable step, while evidence-null directions are pinned to unit
856/// stiffness so their log-determinant contribution is exactly zero.
857#[derive(Debug, Clone, Copy, PartialEq)]
858pub(crate) enum ReducedSchurPolicy {
859    StrictNewton,
860    NewtonTikhonov { relative_floor: f64 },
861    EvidenceUnitDeflation {
862        relative_floor: f64,
863        /// #2515 — refuse a RESOLVED negative direction instead of unit-pinning
864        /// it. See [`ArrowEvidencePolicy::UnitDeflationRefusingIndefinite`].
865        refuse_resolved_indefinite: bool,
866    },
867}
868
869impl ReducedSchurPolicy {
870    pub(crate) fn newton(relative_floor: Option<f64>) -> Self {
871        match relative_floor {
872            Some(relative_floor) => Self::NewtonTikhonov { relative_floor },
873            None => Self::StrictNewton,
874        }
875    }
876}
877
878#[derive(Debug)]
879pub(crate) struct DenseReducedSchurFactorization {
880    pub(crate) factor: Array2<f64>,
881    pub(crate) conditioned_schur: Option<Array2<f64>>,
882    pub(crate) beta_conditioning: Option<BetaSchurConditioningSpectrum>,
883}
884
885/// Majorizer and clamp quadratic forms on the exact-A Schur graph
886/// `t(beta) = -A_tt^{-1} A_tbeta beta`.  They let the reduced route feed the
887/// same scalar direction classifier as the dense joint route instead of
888/// substituting a local relative eigenvalue test (#2515).
889pub(crate) struct ExactAReducedClassification {
890    pub(crate) majorizer_metric: Array2<f64>,
891    pub(crate) clamp_metric: Array2<f64>,
892}
893
894pub(crate) fn exact_a_reduced_classification(
895    sys: &ArrowSchurSystem,
896    htt_factors: &ArrowFactorSlab,
897) -> Result<Option<ExactAReducedClassification>, ArrowSchurError> {
898    let Some(geometry) = sys.exact_a_classification.as_ref() else {
899        return Ok(None);
900    };
901    if geometry.rows.len() != sys.rows.len() {
902        return Err(ArrowSchurError::SchurFactorFailed {
903            reason: format!(
904                "exact-A classification carries {} rows for an {}-row system",
905                geometry.rows.len(),
906                sys.rows.len(),
907            ),
908        });
909    }
910    let k = sys.k;
911    let mut majorizer_metric = sys.effective_penalty_op().to_dense();
912    let mut clamp_metric = Array2::<f64>::zeros((k, k));
913    for (row_idx, row) in sys.rows.iter().enumerate() {
914        let q = sys.row_dims[row_idx];
915        let operands = &geometry.rows[row_idx];
916        if operands.delta_tt.dim() != (q, q)
917            || operands.delta_tbeta.nrows() != q
918            || operands.delta_tbeta.ncols() != geometry.border_indices.len()
919            || operands.clamp_diag.len() != q
920        {
921            return Err(ArrowSchurError::SchurFactorFailed {
922                reason: format!(
923                    "exact-A classification row {row_idx} is incompatible with row width {q} and border width {k}",
924                ),
925            });
926        }
927        let a_tbeta = sys_htbeta_materialize_row(sys, row_idx, row)?;
928        let mut b_tbeta = a_tbeta.clone();
929        for (carrier_col, &system_col) in geometry.border_indices.iter().enumerate() {
930            if system_col >= k {
931                return Err(ArrowSchurError::SchurFactorFailed {
932                    reason: format!(
933                        "exact-A classification border index {system_col} exceeds width {k}",
934                    ),
935                });
936            }
937            for local in 0..q {
938                b_tbeta[[local, system_col]] -=
939                    operands.delta_tbeta[[local, carrier_col]];
940            }
941        }
942        let b_tt = &row.htt - &operands.delta_tt;
943        let mut graph = Array2::<f64>::zeros((q, k));
944        for col in 0..k {
945            let solved = cholesky_solve_vector(htt_factors.factor(row_idx), a_tbeta.column(col));
946            for local in 0..q {
947                graph[[local, col]] = -solved[local];
948            }
949        }
950        majorizer_metric += &graph.t().dot(&b_tt.dot(&graph));
951        majorizer_metric += &graph.t().dot(&b_tbeta);
952        majorizer_metric += &b_tbeta.t().dot(&graph);
953        let weighted_graph = Array2::from_shape_fn((q, k), |(local, col)| {
954            operands.clamp_diag[local] * graph[[local, col]]
955        });
956        clamp_metric += &graph.t().dot(&weighted_graph);
957    }
958    Ok(Some(ExactAReducedClassification {
959        majorizer_metric,
960        clamp_metric,
961    }))
962}
963
964/// Matrix-free scalar sibling of [`exact_a_reduced_classification`].
965///
966/// For a reduced-border direction `beta`, lift the Schur graph direction
967/// `t = -A_tt^-1 A_tbeta beta` through the already classified row factors and
968/// evaluate the two quadratic forms the shared exact-A classifier needs:
969/// `v'B_raw v` and `v'E v`, `v = (t, beta)`.  No dense `K × K` metric is formed.
970pub(crate) fn exact_a_reduced_direction_metrics(
971    sys: &ArrowSchurSystem,
972    htt_factors: &ArrowFactorSlab,
973    ridge_beta: f64,
974    direction: ArrayView1<'_, f64>,
975) -> Result<(f64, f64), ArrowSchurError> {
976    let geometry = sys.exact_a_classification.as_ref().ok_or_else(|| {
977        ArrowSchurError::SchurFactorFailed {
978            reason: "exact-A reduced direction classification requires its raw B/delta/clamp carrier"
979                .to_string(),
980        }
981    })?;
982    if direction.len() != sys.k || geometry.rows.len() != sys.rows.len() {
983        return Err(ArrowSchurError::SchurFactorFailed {
984            reason: format!(
985                "exact-A reduced direction classification has direction width {}, border {}, \
986                 and {} carrier rows for {} system rows",
987                direction.len(),
988                sys.k,
989                geometry.rows.len(),
990                sys.rows.len(),
991            ),
992        });
993    }
994
995    // The reduced evidence operator is `P S P + Q Q'` when a beta gauge is
996    // installed.  Classify `P beta` in the physical B/E metrics and count the
997    // structural gauge pin in B at its exact unit stiffness.
998    let physical_direction = match sys.beta_gauge_quotient.as_ref() {
999        Some(quotient) => quotient.project_complement(direction),
1000        None => direction.to_owned(),
1001    };
1002    let gauge_stiffness = sys.beta_gauge_quotient.as_ref().map_or(0.0, |quotient| {
1003        quotient
1004            .directions
1005            .iter()
1006            .map(|gauge| {
1007                let coefficient = gauge.dot(&direction);
1008                coefficient * coefficient
1009            })
1010            .sum()
1011    });
1012    let beta_slice = physical_direction
1013        .as_slice()
1014        .expect("owned exact-A classification direction is contiguous");
1015    let mut penalty_action = vec![0.0_f64; sys.k];
1016    sys.penalty_matvec_add(beta_slice, &mut penalty_action);
1017    let mut majorizer_curvature = physical_direction
1018        .iter()
1019        .zip(penalty_action.iter())
1020        .map(|(&left, &right)| left * right)
1021        .sum::<f64>()
1022        + ridge_beta * physical_direction.dot(&physical_direction)
1023        + gauge_stiffness;
1024    let mut clamp_curvature = 0.0_f64;
1025
1026    for (row_index, row) in sys.rows.iter().enumerate() {
1027        let q = sys.row_dims[row_index];
1028        let operands = &geometry.rows[row_index];
1029        if operands.delta_tt.dim() != (q, q)
1030            || operands.delta_tbeta.nrows() != q
1031            || operands.delta_tbeta.ncols() != geometry.border_indices.len()
1032            || operands.clamp_diag.len() != q
1033        {
1034            return Err(ArrowSchurError::SchurFactorFailed {
1035                reason: format!(
1036                    "exact-A reduced direction classification row {row_index} is incompatible \
1037                     with latent width {q} and border carrier width {}",
1038                    geometry.border_indices.len(),
1039                ),
1040            });
1041        }
1042        let mut a_cross = Array1::<f64>::zeros(q);
1043        sys_htbeta_apply_row(
1044            sys,
1045            row_index,
1046            row,
1047            physical_direction.view(),
1048            &mut a_cross,
1049        );
1050        let mut graph = cholesky_solve_vector(htt_factors.factor(row_index), a_cross.view());
1051        graph.mapv_inplace(|value| -value);
1052        let mut b_cross = a_cross;
1053        for (carrier_column, &system_column) in geometry.border_indices.iter().enumerate() {
1054            if system_column >= sys.k {
1055                return Err(ArrowSchurError::SchurFactorFailed {
1056                    reason: format!(
1057                        "exact-A reduced direction classification border index {system_column} \
1058                         exceeds width {}",
1059                        sys.k,
1060                    ),
1061                });
1062            }
1063            for local in 0..q {
1064                b_cross[local] -= operands.delta_tbeta[[local, carrier_column]]
1065                    * physical_direction[system_column];
1066            }
1067        }
1068        let b_tt = &row.htt - &operands.delta_tt;
1069        majorizer_curvature += graph.dot(&b_tt.dot(&graph)) + 2.0 * graph.dot(&b_cross);
1070        clamp_curvature += graph
1071            .iter()
1072            .zip(operands.clamp_diag.iter())
1073            .map(|(&value, &clamp)| clamp * value * value)
1074            .sum::<f64>();
1075    }
1076    Ok((majorizer_curvature, clamp_curvature))
1077}
1078
1079pub(crate) fn factor_dense_reduced_schur(
1080    schur: &Array2<f64>,
1081    policy: ReducedSchurPolicy,
1082) -> Result<DenseReducedSchurFactorization, ArrowSchurError> {
1083    factor_dense_reduced_schur_with_exact_a(schur, policy, None)
1084}
1085
1086pub(crate) fn factor_dense_reduced_schur_with_exact_a(
1087    schur: &Array2<f64>,
1088    policy: ReducedSchurPolicy,
1089    exact_a: Option<&ExactAReducedClassification>,
1090) -> Result<DenseReducedSchurFactorization, ArrowSchurError> {
1091    let newton_relative_floor = match policy {
1092        ReducedSchurPolicy::StrictNewton => None,
1093        ReducedSchurPolicy::NewtonTikhonov { relative_floor } => Some(relative_floor),
1094        ReducedSchurPolicy::EvidenceUnitDeflation {
1095            relative_floor,
1096            refuse_resolved_indefinite,
1097        } => {
1098            return factor_evidence_unit_deflated_schur(
1099                schur,
1100                relative_floor,
1101                refuse_resolved_indefinite,
1102                exact_a,
1103            );
1104        }
1105    };
1106    let n = schur.nrows();
1107    let d = jacobi_diagonal_scale(schur);
1108    let mut schur_scaled = Array2::<f64>::zeros((n, n));
1109    for i in 0..n {
1110        for j in 0..n {
1111            schur_scaled[[i, j]] = schur[[i, j]] / (d[i] * d[j]);
1112        }
1113    }
1114    let (factor_scaled, floored_scaled) = match cholesky_lower(&schur_scaled) {
1115        Ok(factor) => (factor, None),
1116        Err(e) => {
1117            // #1026/#1038 — every dense reduced-Schur factorization in the SAE
1118            // path must honor the same opt-in spectral floor. Otherwise
1119            // auxiliary entry points (mixed precision and cross-row ordered Beta--Bernoulli
1120            // preconditioning) can reject the collapsed dead-atom subspace even
1121            // though the main direct solve would floor it and continue.
1122            //
1123            // #1803 — Newton-step callers use the Levenberg-Marquardt PD floor
1124            // (`spectral_pd_floored_schur`) so `Δβ` is stable. Evidence/log-det
1125            // callers (`unit_deflate_null_directions`) instead deflate
1126            // quotient/null directions to unit stiffness so they contribute the
1127            // ρ-independent `log 1 = 0` to the Laplace normaliser rather than a
1128            // ρ-dependent Occam reward for collapsed decoders.
1129            //
1130            // #2015 — this spectral floor runs on the EQUILIBRATED `schur_scaled`,
1131            // so `relative_floor` (a FRACTION of the operator's own max
1132            // eigenvalue) reads a numerically trustworthy spectrum instead of one
1133            // dominated by the raw column-scale spread; the floored
1134            // reconstruction is undone back to original units below exactly like
1135            // the plain factor.
1136            match newton_relative_floor {
1137                Some(relative_floor) => {
1138                    match spectral_pd_floored_schur(&schur_scaled, relative_floor) {
1139                        Some((floored, floored_factor)) => (floored_factor, Some(floored)),
1140                        None => {
1141                            return Err(ArrowSchurError::SchurFactorFailed {
1142                                reason: format!(
1143                                    "reduced Schur non-PD ({e}); spectral PD-floor declined \
1144                                 (no usable spectrum)"
1145                                ),
1146                            });
1147                        }
1148                    }
1149                }
1150                None => {
1151                    return Err(ArrowSchurError::SchurFactorFailed { reason: e });
1152                }
1153            }
1154        }
1155    };
1156    // Undo the equilibration exactly: L = D·L̃ (row i scaled by d_i); the
1157    // floored reconstruction (when present) scales back as D·S̃_floor·D.
1158    let mut factor = factor_scaled;
1159    for i in 0..n {
1160        let di = d[i];
1161        for j in 0..=i {
1162            factor[[i, j]] *= di;
1163        }
1164    }
1165    let floored_schur = floored_scaled.map(|mut floored| {
1166        for i in 0..n {
1167            for j in 0..n {
1168                floored[[i, j]] *= d[i] * d[j];
1169            }
1170        }
1171        floored
1172    });
1173    Ok(DenseReducedSchurFactorization {
1174        factor,
1175        conditioned_schur: floored_schur,
1176        beta_conditioning: None,
1177    })
1178}
1179
1180pub(crate) fn solve_dense_reduced_system(
1181    schur: &Array2<f64>,
1182    rhs_beta: &Array1<f64>,
1183    options: &ArrowSolveOptions,
1184    metric_weights: Option<&MetricWeights>,
1185) -> Result<(Array1<f64>, Option<Array2<f64>>, ArrowPcgDiagnostics), ArrowSchurError> {
1186    let policy = ReducedSchurPolicy::newton(options.newton_schur_tikhonov_rel_floor);
1187    let DenseReducedSchurFactorization {
1188        factor,
1189        conditioned_schur: floored_schur,
1190        beta_conditioning: _,
1191    } = factor_dense_reduced_schur(schur, policy)?;
1192    if let Some(floored) = floored_schur {
1193        let direct = mixed_precision_reduced_beta(&floored, &factor, rhs_beta, options)
1194            .unwrap_or_else(|| cholesky_solve_vector(&factor, rhs_beta));
1195        if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights) {
1196            return Ok((direct, Some(factor), ArrowPcgDiagnostics::default()));
1197        }
1198        let identity = IdentityPreconditioner;
1199        let (delta, diag) = steihaug_dense_system(
1200            &floored,
1201            rhs_beta,
1202            &identity,
1203            &ArrowPcgOptions {
1204                max_iterations: options.trust_region.max_iterations,
1205                relative_tolerance: options.trust_region.steihaug_relative_tolerance,
1206            },
1207            &options.trust_region,
1208            metric_weights,
1209        )?;
1210        return Ok((delta, Some(factor), diag));
1211    }
1212    // Ill-conditioned-but-PD Schur guard. The per-row factor checks reject
1213    // any single barely-PD H_tt^(i) block, but the reduced Schur complement
1214    //     S = H_ββ + ridge_β·I − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)
1215    // accumulates the (H_tt^(i))⁻¹ contributions of every row in finite
1216    // precision. With many weak-but-admissible rows those terms can sum to a
1217    // Schur matrix whose Cholesky succeeds yet whose condition number is far
1218    // past the safe inversion regime, so `cholesky_solve_vector` yields an
1219    // inaccurate Δβ that is silently propagated to the Newton step. Apply the
1220    // same diagonal-ratio κ proxy used per-row to the reduced factor and treat
1221    // an over-threshold estimate as a Schur-stability failure: `SchurFactorFailed`
1222    // is already recoverable in `solve_with_lm_escalation_inner`, so this lifts
1223    // `ridge_beta` and re-forms a better-conditioned Schur. This guard is
1224    // exclusive to the dense Direct / SqrtBA path (the only caller of this
1225    // function); the inexact-PCG path tolerates higher κ(S) and is unaffected.
1226    let schur_kappa = cholesky_factor_kappa_estimate(&factor);
1227    if !schur_kappa.is_finite() || schur_kappa > safe_spd_kappa_max(schur.nrows()) {
1228        // #1026 — over-complete SAE dictionaries park surplus atoms dead
1229        // (β_k → 0), so the reduced Schur is PD (the Cholesky above succeeded)
1230        // but ILL-CONDITIONED: the dead decoder subspace carries near-zero
1231        // eigenvalues while the live subspace is healthy. The kappa gate's
1232        // concern is an inaccurate Δβ from accumulated (H_tt)⁻¹ contamination —
1233        // but on the dead subspace the correct Δβ IS ≈0 (those atoms have no
1234        // signal), so the only "inaccuracy" is in directions whose true step is
1235        // zero. When the spectral PD-floor is enabled (the SAE solve path),
1236        // clamp exactly those collapsed directions up to `floor·max(λ)` and
1237        // solve against the floored Schur: the live subspace keeps its EXACT
1238        // Newton component, the dead subspace is damped to ≈0, and κ is bounded
1239        // so Δβ is accurate where it matters. This is the same conditioning the
1240        // non-PD branch above applies; here it also covers the PD-but-ill-
1241        // conditioned case so the LM loop does not exhaust `ridge_β` trying to
1242        // (futilely) lift a fundamentally rank-deficient dead-atom subspace.
1243        // Without the floor (BA / non-SAE callers) the strict refusal stands.
1244        if let Some(relative_floor) = options.newton_schur_tikhonov_rel_floor
1245            && let Some((floored, floored_factor)) =
1246                spectral_pd_floored_schur(schur, relative_floor)
1247        {
1248            let direct = mixed_precision_reduced_beta(&floored, &floored_factor, rhs_beta, options)
1249                .unwrap_or_else(|| cholesky_solve_vector(&floored_factor, rhs_beta));
1250            if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights)
1251            {
1252                return Ok((direct, Some(floored_factor), ArrowPcgDiagnostics::default()));
1253            }
1254            let identity = IdentityPreconditioner;
1255            let (delta, diag) = steihaug_dense_system(
1256                &floored,
1257                rhs_beta,
1258                &identity,
1259                &ArrowPcgOptions {
1260                    max_iterations: options.trust_region.max_iterations,
1261                    relative_tolerance: options.trust_region.steihaug_relative_tolerance,
1262                },
1263                &options.trust_region,
1264                metric_weights,
1265            )?;
1266            return Ok((delta, Some(floored_factor), diag));
1267        }
1268        return Err(ArrowSchurError::SchurFactorFailed {
1269            reason: format!(
1270                "reduced Schur complement Cholesky succeeded but is ill-conditioned \
1271                     (kappa_estimate={schur_kappa:e}); accumulated per-row \
1272                     (H_tt)⁻¹ contamination would yield an inaccurate Δβ"
1273            ),
1274        });
1275    }
1276    // Reduced-system solve. The f64 `factor` is always retained and returned —
1277    // its diagonal is the EXACT `log|S|` the evidence path reads, so the logdet
1278    // stays f64 regardless of how Δβ is computed (#1014 invariant). When the
1279    // streaming/residency path enabled certified mixed precision, the Δβ solve
1280    // itself runs f32-then-f64-refined (κ-gated, with the f64 triangular solve
1281    // as the automatic fallback); the certificate is the f64 backward error.
1282    let direct = mixed_precision_reduced_beta(schur, &factor, rhs_beta, options)
1283        .unwrap_or_else(|| cholesky_solve_vector(&factor, rhs_beta));
1284    if step_inside_trust_region(direct.view(), options.trust_region.radius, metric_weights) {
1285        return Ok((direct, Some(factor), ArrowPcgDiagnostics::default()));
1286    }
1287
1288    // Ceres-style trust-region correction: once the dense BA solve proposes a
1289    // step outside the trust ball, Steihaug-CG returns the boundary point
1290    // without requiring a second dense factorization.
1291    let identity = IdentityPreconditioner;
1292    let (delta, diag) = steihaug_dense_system(
1293        schur,
1294        rhs_beta,
1295        &identity,
1296        &ArrowPcgOptions {
1297            max_iterations: options.trust_region.max_iterations,
1298            relative_tolerance: options.trust_region.steihaug_relative_tolerance,
1299        },
1300        &options.trust_region,
1301        metric_weights,
1302    )?;
1303    Ok((delta, Some(factor), diag))
1304}
1305
1306pub(crate) fn step_inside_trust_region(
1307    step: ArrayView1<'_, f64>,
1308    radius: f64,
1309    metric_weights: Option<&MetricWeights>,
1310) -> bool {
1311    !radius.is_finite() || metric_norm(step, metric_weights) <= radius
1312}
1313
1314/// Below this row count the per-row Schur loop stays sequential: the rayon
1315/// fan-out (chunk dispatch + the deterministic per-chunk length-`K` reduction)
1316/// costs more than it saves for the handful-of-rows arrow systems that dominate
1317/// the non-SAE callers. Above it — the SAE LLM shape (`n` in the thousands,
1318/// wide border `k`) that issue #1017 names — the per-row `H_βt (H_tt)⁻¹ H_tβ x`
1319/// contributions are the matvec's whole cost and parallelize cleanly.
1320pub(crate) const SCHUR_MATVEC_PARALLEL_ROW_MIN: usize = 256;
1321
1322/// Below this border width `k` the dense `H_ββ` penalty-prologue GEMV stays
1323/// sequential: parallelizing a `k×k` matvec only pays once `k²` is large enough
1324/// to dwarf the rayon fan-out, which for the arrow callers with narrow borders
1325/// it never is. At the SAE LLM border (`k` in the low thousands) the `O(k²)`
1326/// prologue is ≈4M flops/CG-iteration and was the serial Amdahl ceiling on the
1327/// otherwise per-row-parallel matvec (#1017), so it crosses this threshold and
1328/// fans out. 512 keeps the prologue serial for every non-SAE arrow system while
1329/// engaging it for the wide SAE/Qwen borders the issue targets.
1330pub(crate) const SCHUR_PROLOGUE_PARALLEL_K_MIN: usize = 512;
1331
1332/// Device-residency CPU analogue for the SAE reduced-Schur matvec (#1017).
1333///
1334/// In the production SAE joint fit the per-row cross-block factors as
1335/// `H_tβ^(i) = L_i P_i`, where `L_i` (`q_i × p`) is the row's local
1336/// assignment/coordinate Jacobian and `P_i` (`p × K`, sparse) gathers the
1337/// active atoms' decoder blocks (`P_i x = Σ_s φ_s · x[base_s .. base_s+p]`).
1338/// The reduced-Schur point-elimination contribution of one row is therefore
1339///
1340/// ```text
1341/// S_i x = H_βt^(i) (H_tt^(i)+ρ_t I)⁻¹ H_tβ^(i) x
1342///       = P_iᵀ · [ L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i ] · P_i x
1343///       = P_iᵀ G_i (P_i x),      G_i := L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i   (p×p).
1344/// ```
1345///
1346/// The block `G_i = L_iᵀ Y_i` depends only on the assembled per-row blocks and
1347/// the (already-computed, solve-stable) `H_tt` factor — NOT on the CG iterate
1348/// `x`. The generic `schur_matvec` re-walks `apply_jbeta → apply_l →
1349/// solve(d×d) → apply_l_t → scatter` on every CG iteration; this object **stages
1350/// the factors `(L_i, Y_i)` once per CG solve** (the "upload X once" residency
1351/// mechanism, applied on CPU to the matvec rather than a dense factorization),
1352/// turning each subsequent matvec into a sparse gather → two `di×p` GEMVs →
1353/// sparse scatter, with no per-iteration triangular solve and no operator-closure
1354/// re-walk. It never materialises the dense `p×p` product: `di ≪ p` for SAE
1355/// rows, so the factored apply is `2·support_i·p + 2·di·p` flops/row — the two
1356/// `di·p` GEMVs PLUS the `support_i·p` sparse gather (`P_i x`) and `support_i·p`
1357/// sparse scatter (`P_iᵀ prod`) — versus the dense `p²` block apply, and
1358/// `O(n·di·p)` memory (vs `O(n·p²)` ≈ 67 GB at the Qwen shape — the dense form
1359/// is OOM). For dense/full active support `support_i` can scale with the active
1360/// β-columns, so the gather/scatter term is NOT negligible and is counted here.
1361///
1362/// Numerically identical to the generic path up to floating-point reassociation
1363/// (it differentiates and accumulates the SAME quotient). It is deterministic
1364/// run-to-run and within the reassociation margin of the serial path, so the
1365/// criterion ranking across topology candidates is stable except for candidates
1366/// separated by less than that f64 margin, where reassociation can flip the
1367/// near-tie winner — it is NOT an exact no-move guarantee (#1211).
1368pub struct SaeResidentReducedSchur {
1369    /// Decoder output dimension `p` (the side length of every `G_i = L_iᵀ Y_i`).
1370    pub(crate) p: usize,
1371    /// Per-row **factored** residency: `(L_i, Y_i)`, each stored row-major as a
1372    /// `di × p` slab (`L_i` = local Jacobian, `Y_i = (H_tt^(i)+ρ_t I)⁻¹ L_i`).
1373    /// The reduced block is `G_i = L_iᵀ Y_i` (`p×p`, symmetric PSD), but it has
1374    /// rank ≤ `di` and `di ≪ p` for SAE rows (the per-row latent dim is 1–2
1375    /// while `p` is the decoder block width, ~2048). Materialising the dense
1376    /// `p×p` block would cost `O(n·p²)` memory (≈67 GB at the Qwen shape) and
1377    /// `p²` flops per matvec/row; the factored form costs `O(n·di·p)` memory and
1378    /// `2·support_i·p + 2·di·p` flops/row, applying `G_i v = L_iᵀ (Y_i v)`
1379    /// (sparse gather over `support_i` atoms → `di`-length GEMV → `p`-length
1380    /// GEMV → sparse scatter over `support_i` atoms). The `2·support_i·p`
1381    /// gather/scatter term is part of the per-row cost — for dense/full support
1382    /// `support_i` scales with active β-columns — and is not dropped. A row with
1383    /// empty active support / degenerate dims gets `di = 0` and is skipped.
1384    /// `(di, L_i, Y_i)` per row; `L_i`/`Y_i` are `di·p`-length row-major buffers.
1385    pub(crate) rows: Vec<ResidentRowFactor>,
1386    /// Per-row active atom support `(β-block base index, φ weight)`, shared with
1387    /// the assembler's [`DeviceSaePcgData`] (no re-clone of the index lists).
1388    pub(crate) a_phi: Arc<[Vec<(usize, f64)>]>,
1389    /// #1033: per-row local Jacobian `L_i` (row-major `di × p`), SHARED via `Arc`
1390    /// with the assembler's [`DeviceSaePcgData`] rather than copied into each
1391    /// `ResidentRowFactor`. The staged factor previously held its own verbatim
1392    /// row-major copy of `data.local_jac[row]` — a second full `O(n·di·p)` slab
1393    /// for zero benefit (the bytes and the `di × p` layout are identical). The
1394    /// matvec now reads `L_i = &self.local_jac[row]` directly; only the SOLVED
1395    /// factor `Y_i = (H_tt+ρI)⁻¹ L_i` (genuinely new data) stays per-row. Reads
1396    /// are byte-for-byte the former `rf.l` (same slab, same `r·p + c` indexing),
1397    /// so the matvec/preconditioner output is bit-identical.
1398    pub(crate) local_jac: Arc<[Vec<f64>]>,
1399}
1400
1401/// Factored per-row residency block: `G_i = L_iᵀ Y_i` kept as its `di×p` factors
1402/// so the matvec never materialises the dense `p×p` product. The local Jacobian
1403/// factor `L_i` is NOT stored here — it is shared via
1404/// [`SaeResidentReducedSchur::local_jac`] (`&local_jac[row]`); only the solved
1405/// `Y_i` is per-row. See [`SaeResidentReducedSchur`].
1406pub(crate) struct ResidentRowFactor {
1407    /// Row latent dimension `di` (the inner contraction width). `0` ⇒ skipped.
1408    pub(crate) di: usize,
1409    /// `Y_i = (H_tt^(i)+ρ_t I)⁻¹ L_i` row-major `di × p`. Empty when `di == 0`.
1410    pub(crate) y: Vec<f64>,
1411}
1412
1413impl SaeResidentReducedSchur {
1414    /// Stage the per-row `G_i = L_iᵀ (H_tt^(i)+ρ_t I)⁻¹ L_i` blocks once, from
1415    /// the SAE structure (`DeviceSaePcgData`: `p`, per-row `a_phi`, per-row
1416    /// row-major `local_jac` = `L_i`) and the already-factored `H_tt` slab.
1417    ///
1418    /// Returns `None` when the structure does not match (degenerate `p`, row
1419    /// count mismatch) so the caller falls back to the generic matvec. Row
1420    /// builds are independent and run under the same deterministic rayon
1421    /// discipline as the matvec (each `G_i` is self-contained — no cross-row
1422    /// reduction — so there is no ordering subtlety).
1423    /// `ridge_t` is NOT a parameter: it is already folded into the factored
1424    /// blocks `htt_factors` carry (they factor `H_tt^(i) + ridge_t·I` — see
1425    /// `factor_blocks`), so solving against the factor yields `(H_tt^(i)+ρ_t I)⁻¹`
1426    /// exactly. The residency block is a pure function of the factor and `L_i`.
1427    pub(crate) fn build<B: BatchedBlockSolver + Sync>(
1428        sys: &ArrowSchurSystem,
1429        htt_factors: &ArrowFactorSlab,
1430        backend: &B,
1431    ) -> Option<Self> {
1432        let data = sys.device_sae_pcg.as_ref()?;
1433        let p = data.p;
1434        let n = sys.rows.len();
1435        if p == 0
1436            || sys.htbeta_dense_supplement
1437            || data.a_phi.len() != n
1438            || data.local_jac.len() != n
1439        {
1440            return None;
1441        }
1442        let empty = || ResidentRowFactor {
1443            di: 0,
1444            y: Vec::new(),
1445        };
1446        let build_row = |row: usize| -> ResidentRowFactor {
1447            let di = sys.row_dims[row];
1448            let jac = &data.local_jac[row];
1449            // q_i = len/p; must match the row's latent dimension di.
1450            if p == 0 || jac.len() != di * p || di == 0 {
1451                return empty();
1452            }
1453            // L_i as a (di × p) matrix (row-major in `local_jac`).
1454            let l_i = match ArrayView2::from_shape((di, p), jac.as_slice()) {
1455                Ok(v) => v.to_owned(),
1456                Err(_) => return empty(),
1457            };
1458            // Solve (H_tt+ρ_t I) Y = L_i for Y (di × p): one batched back-solve
1459            // over the p columns against the cached factor. Stage `(L_i, Y_i)`
1460            // — NOT the dense `p×p` product `G_i = L_iᵀ Y_i` — so storage and the
1461            // matvec stay `O(di·p)` instead of `O(p²)` (`di ≪ p` for SAE rows).
1462            let y = backend.solve_block_matrix(htt_factors.factor(row), l_i.view());
1463            // Flatten the SOLVED factor to a `di × p` row-major buffer (iteration
1464            // over a standard-layout view is row-major regardless of the source
1465            // strides, so the hot loop can index `r*p + c` directly). `L_i` is NOT
1466            // copied — the matvec reads it from the shared `local_jac` slab (it is
1467            // byte-for-byte `data.local_jac[row]`).
1468            let y_flat: Vec<f64> = y.iter().copied().collect();
1469            ResidentRowFactor { di, y: y_flat }
1470        };
1471        let rows: Vec<ResidentRowFactor> =
1472            if n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none() {
1473                use rayon::prelude::*;
1474                (0..n).into_par_iter().map(build_row).collect()
1475            } else {
1476                (0..n).map(build_row).collect()
1477            };
1478        Some(Self {
1479            p,
1480            rows,
1481            a_phi: data.a_phi_shared(),
1482            local_jac: data.local_jac_shared(),
1483        })
1484    }
1485
1486    /// Accumulate one row's `S_i x = P_iᵀ G_i (P_i x) = P_iᵀ L_iᵀ Y_i (P_i x)`
1487    /// into `acc` (length `K`). `gather`/`prod` are caller-owned length-`p`
1488    /// buffers and `w` a caller-owned `≥ max_i di`-length buffer, all reused
1489    /// across rows to keep the hot loop allocation-free. The matvec applies the
1490    /// factored block in four steps: sparse gather `P_i x = Σ_s φ_s·x[base_s..]`
1491    /// (`support_i·p` flops), `w = Y_i·(P_i x)` (`di`-length, `di·p` flops),
1492    /// `prod = L_iᵀ·w` (`p`-length, `di·p` flops), and sparse scatter
1493    /// `acc += P_iᵀ prod` (`support_i·p` flops) — `2·support_i·p + 2·di·p`
1494    /// total, never the dense `p²` product. The gather/scatter `2·support_i·p`
1495    /// term is counted: it is not dominated by the GEMVs when the active support
1496    /// is wide.
1497    #[inline]
1498    pub(crate) fn row_into(
1499        &self,
1500        row: usize,
1501        x: &Array1<f64>,
1502        acc: &mut Array1<f64>,
1503        gather: &mut [f64],
1504        prod: &mut [f64],
1505        w: &mut [f64],
1506    ) {
1507        let rf = &self.rows[row];
1508        let di = rf.di;
1509        if di == 0 {
1510            return;
1511        }
1512        let p = self.p;
1513        let support = &self.a_phi[row];
1514        if support.is_empty() {
1515            return;
1516        }
1517        // Slice `x`/`acc` ONCE so the per-support gather/scatter (the dominant
1518        // `support·p` terms for wide active support) run over contiguous `f64`
1519        // slices — the compiler can prove unit stride and emit vectorized FMA,
1520        // where the former `x[base+j]`/`acc[base+j]` ndarray element indexing
1521        // forced a per-element strided lookup + bounds check that blocked
1522        // autovectorization. Every accumulation order is unchanged, so the
1523        // result is bit-identical to the ndarray-indexed form.
1524        let x_slice = x.as_slice().expect("resident matvec x must be contiguous");
1525        // P_i x = Σ_s φ_s · x[base_s .. base_s+p]   (length p).
1526        let gather = &mut gather[..p];
1527        for v in gather.iter_mut() {
1528            *v = 0.0;
1529        }
1530        for &(base, phi) in support {
1531            if phi == 0.0 {
1532                continue;
1533            }
1534            let xrow = &x_slice[base..base + p];
1535            for (g, &xv) in gather.iter_mut().zip(xrow) {
1536                *g += phi * xv;
1537            }
1538        }
1539        // w = Y_i · (P_i x)   (di × p GEMV → length di).  Y_i row-major di×p.
1540        for r in 0..di {
1541            let yrow = &rf.y[r * p..r * p + p];
1542            let mut s = 0.0_f64;
1543            for (&yv, &gv) in yrow.iter().zip(gather.iter()) {
1544                s += yv * gv;
1545            }
1546            w[r] = s;
1547        }
1548        // prod = L_iᵀ · w   (p × di GEMV → length p).  L_i row-major di×p, so
1549        // L_iᵀ[j,r] = L_i[r,j]; accumulate column-by-column over the di rows.
1550        // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte the
1551        // former per-row `rf.l` copy.
1552        let l_i = &self.local_jac[row];
1553        let prod = &mut prod[..p];
1554        for v in prod.iter_mut() {
1555            *v = 0.0;
1556        }
1557        for r in 0..di {
1558            let lrow = &l_i[r * p..r * p + p];
1559            let wr = w[r];
1560            for (pj, &lj) in prod.iter_mut().zip(lrow) {
1561                *pj += lj * wr;
1562            }
1563        }
1564        // acc += P_iᵀ prod = scatter φ_s · prod into base_s blocks.
1565        let acc_slice = acc
1566            .as_slice_mut()
1567            .expect("resident matvec acc must be contiguous");
1568        for &(base, phi) in support {
1569            if phi == 0.0 {
1570                continue;
1571            }
1572            let arow = &mut acc_slice[base..base + p];
1573            for (a, &pv) in arow.iter_mut().zip(prod.iter()) {
1574                *a += phi * pv;
1575            }
1576        }
1577    }
1578
1579    /// Max row latent dim `di` across resident rows — the size of the `w`
1580    /// scratch the matvec needs for the inner `Y_i·(P_i x)` GEMV.
1581    pub(crate) fn max_di(&self) -> usize {
1582        self.rows.iter().map(|r| r.di).max().unwrap_or(0)
1583    }
1584}
1585
1586/// Reduced-Schur matvec `out = S·x` with an optional pre-staged SAE residency
1587/// operator. When `resident` is `Some`, the per-row point-elimination term is
1588/// applied through the resident `p×p` blocks (#1017 CPU residency); otherwise it
1589/// falls back to the generic per-row `apply → solve → transpose` path. Both
1590/// routes accumulate the SAME reduced operator
1591/// `S = H_ββ + ρ_β I − Σ_i H_βt^(i)(H_tt^(i))⁻¹H_tβ^(i)`.
1592pub(crate) fn schur_matvec<B: BatchedBlockSolver + Sync>(
1593    sys: &ArrowSchurSystem,
1594    htt_factors: &ArrowFactorSlab,
1595    ridge_beta: f64,
1596    x: &Array1<f64>,
1597    out: &mut Array1<f64>,
1598    backend: &B,
1599    resident: Option<&SaeResidentReducedSchur>,
1600) {
1601    // `steihaug_cg` reuses one output buffer across iterations and requires
1602    // `matvec` to ASSIGN every entry of `out` (the contract `dense_matvec`
1603    // upholds). This routine builds `S·x` purely by accumulation
1604    // (`penalty_matvec_add`, `out[a] += ridge·x`, `out[a] -= neg_contrib`), so it
1605    // MUST clear `out` first. Without this, iteration n>0 returns `S·x` plus the
1606    // previous call's `S·p`, the PCG solves a corrupted reduced system, and the
1607    // resulting Newton step is inconsistent with the assembled gradient
1608    // (g·δ ≈ 0 — a non-descent direction that defeats the line search).
1609    out.fill(0.0);
1610    let k = sys.k;
1611    // Top-level (not nested in a rayon worker) and big enough to amortize the
1612    // fan-out: the single gate that authorizes BOTH the dense penalty-prologue
1613    // GEMV and the per-row point-elimination loop to go parallel. The topology
1614    // race fans candidates with `run_topology_race_parallel`, so inside a worker
1615    // both stay sequential (no nested-rayon oversubscription).
1616    let parallel =
1617        sys.rows.len() >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
1618    // Route the penalty-side (H_ββ + ridge·I) x product through the prologue:
1619    // no Arc-clone hot-path cost when penalty_op is None (falls back to hbb
1620    // inline); the dense fallback fans across cores at the wide SAE border (#1017).
1621    {
1622        let x_slice = x.as_slice().expect("x must be contiguous");
1623        let out_slice = out.as_slice_mut().expect("out must be contiguous");
1624        sys.penalty_ridge_prologue_into(x_slice, ridge_beta, out_slice, parallel);
1625    }
1626    // The reduced-Schur point-elimination term: `out -= Σ_i H_βt^(i) (H_tt^(i))⁻¹
1627    // H_tβ^(i) x`. Each row contributes an independent length-`K` vector, so for
1628    // the SAE LLM shape (#1017) this is the matvec's whole cost and is
1629    // embarrassingly parallel — reduced below through the deterministic pairwise
1630    // tree (see the block-fold comment) rather than a chunk-order fold.
1631    let p = resident.map(|r| r.p).unwrap_or(0);
1632    // #2228 determinism: the per-row length-`k` contributions
1633    // (`Σ_i H_βt^(i)(H_tt^(i))⁻¹ H_tβ^(i) x`) are reduced through the length-only
1634    // pairwise tree so the result is bit-identical across thread count AND to the
1635    // sequential fold — parallel and nested-serial evaluation agree to the last
1636    // bit, removing the #1017/#1211 chunk-reassociation margin that let the
1637    // criterion ranking depend on the driver. The tree self-serializes below
1638    // `BASE_CHUNK` rows (a base block is folded directly with no `rayon::join`),
1639    // so small systems and nested topology-race calls stay single-threaded
1640    // without a separate branch that could associate the round-off differently.
1641    // The resident path gathers → factored `di×p` GEMVs → scatter; the direct
1642    // path does a per-row block solve — both ADD their row's contribution into a
1643    // block-local accumulator, so splitting the row sum across the tree is exact.
1644    let n_rows = sys.rows.len();
1645    let contribution = gam_linalg::pairwise_reduce::par_deterministic_block_fold(
1646        n_rows,
1647        |range: core::ops::Range<usize>| {
1648            let mut acc = Array1::<f64>::zeros(k);
1649            if let Some(res) = resident {
1650                let mut gather = vec![0.0_f64; p];
1651                let mut prod = vec![0.0_f64; p];
1652                let mut w = vec![0.0_f64; res.max_di()];
1653                for i in range {
1654                    res.row_into(i, x, &mut acc, &mut gather, &mut prod, &mut w);
1655                }
1656            } else {
1657                let mut local = Array1::<f64>::zeros(sys.d);
1658                for i in range {
1659                    schur_matvec_row_into(sys, htt_factors, x, backend, i, &mut local, &mut acc);
1660                }
1661            }
1662            acc
1663        },
1664        |mut a: Array1<f64>, b: Array1<f64>| {
1665            a += &b;
1666            a
1667        },
1668    );
1669    if let Some(acc) = contribution {
1670        for a in 0..k {
1671            out[a] -= acc[a];
1672        }
1673    }
1674}
1675
1676/// #1017: the reduced-Schur operator `v ↦ S·v` staged ONCE per criterion
1677/// evaluation and reused across EVERY shifted / warm-started solve of the
1678/// rational-logdet (and SLQ) ladder — the widened-lifetime residency the #1017
1679/// device design calls for.
1680///
1681/// The rational-logdet criterion (`matrix_free_arrow_evidence_log_det_surrogate`)
1682/// walks SEVERAL shift ladders inside ONE evaluation: the `λ_max` power iteration
1683/// ([`reduced_schur_lambda_max`]), the pilot / deflation-derived plan build
1684/// ([`rational_reduced_schur_plan_derived`]), the value [`RationalLogdetPlan::
1685/// evaluate`], and the `(probes, S⁻¹·probes)` gradient bundle
1686/// ([`reduced_schur_inverse_probe_solves`]). Each formerly re-captured its own
1687/// inline `schur_matvec` closure over `(sys, htt_factors, ρ_β, backend,
1688/// resident)`. On CPU those captures are free; on the device lane they are the
1689/// per-solve FLATTEN — every ladder would re-marshal and re-upload the
1690/// ridge-independent operands (the factored `H_tt` slab, the framed `G ⊗ W`, the
1691/// dense per-row cross blocks) that are INVARIANT across the whole evaluation.
1692///
1693/// This object is the single operator every ladder borrows: the invariant state
1694/// (`sys`, the factored `H_tt` slab, the `ρ_β` border, the pre-staged CPU
1695/// [`SaeResidentReducedSchur`] frame, and — when engaged — a device-resident
1696/// [`GpuSchurMatvec`] whose per-row factors upload ONCE) lives for the whole
1697/// evaluation, so a shifted solve reuses the resident operator instead of
1698/// re-staging it. Every `apply` accumulates the SAME reduced operator
1699/// `S = (H_ββ + ρ_β I) − Σ_i H_βt^(i)(H_tt^(i)+ρ_t I)⁻¹H_tβ^(i)` regardless of
1700/// lane. With `gpu_matvec == None` (every current construction) the result is
1701/// byte-for-byte the pre-context inline `schur_matvec` closure; the `gpu_matvec`
1702/// seam is where a device operator, built once per evaluation, is threaded through
1703/// the ladder (the reported #1017 next increment).
1704pub(crate) struct ReducedSchurOperator<'a, B: BatchedBlockSolver + Sync> {
1705    sys: &'a ArrowSchurSystem,
1706    htt_factors: &'a ArrowFactorSlab,
1707    ridge_beta: f64,
1708    backend: &'a B,
1709    resident: Option<&'a SaeResidentReducedSchur>,
1710    gpu_matvec: Option<&'a GpuSchurMatvec>,
1711}
1712
1713impl<'a, B: BatchedBlockSolver + Sync> ReducedSchurOperator<'a, B> {
1714    /// The CPU/host operator — the byte-identical default. Every shifted solve in
1715    /// the evaluation reuses the same pre-staged `resident` frame (or the generic
1716    /// per-row `apply → solve → transpose` when `resident` is `None`).
1717    pub(crate) fn new(
1718        sys: &'a ArrowSchurSystem,
1719        htt_factors: &'a ArrowFactorSlab,
1720        ridge_beta: f64,
1721        backend: &'a B,
1722        resident: Option<&'a SaeResidentReducedSchur>,
1723    ) -> Self {
1724        Self {
1725            sys,
1726            htt_factors,
1727            ridge_beta,
1728            backend,
1729            resident,
1730            gpu_matvec: None,
1731        }
1732    }
1733
1734    /// Attach a device-resident [`GpuSchurMatvec`] (built ONCE per evaluation) so
1735    /// the whole ladder applies `S·v` on device without a per-solve re-upload.
1736    /// #1017 next increment: the caller that owns the device operand upload builds
1737    /// the operator once and calls this; until then every construction is CPU
1738    /// (`gpu_matvec == None`), so the lane stays byte-identical.
1739    pub(crate) fn with_gpu_matvec(mut self, gpu_matvec: Option<&'a GpuSchurMatvec>) -> Self {
1740        self.gpu_matvec = gpu_matvec;
1741        self
1742    }
1743
1744    /// `out = S·x`. Both lanes CLEAR and fully assign `out`, so a fresh zeroed
1745    /// buffer per apply is correct (and the shift-ladder CG contract is upheld).
1746    #[inline]
1747    pub(crate) fn apply_into(&self, x: &Array1<f64>, out: &mut Array1<f64>) {
1748        if let Some(quotient) = self.sys.beta_gauge_quotient.as_ref() {
1749            // Evidence operator on the quotient: `P S P + Q Q^T`.  Apply the
1750            // original reduced Schur only to `P x`, project its result once more,
1751            // then add the unit Faddeev--Popov pin. The same arithmetic is used by
1752            // dense `pin_reduced_schur`, so SLQ/rational-logdet values and dense
1753            // Cholesky values represent the identical operator.
1754            let projected_x = quotient.project_complement(x.view());
1755            if let Some(gpu) = self.gpu_matvec {
1756                gpu(&projected_x, out);
1757            } else {
1758                schur_matvec(
1759                    self.sys,
1760                    self.htt_factors,
1761                    self.ridge_beta,
1762                    &projected_x,
1763                    out,
1764                    self.backend,
1765                    self.resident,
1766                );
1767            }
1768            let mut projected_out = quotient.project_complement(out.view());
1769            for direction in quotient.directions.iter() {
1770                projected_out.scaled_add(direction.dot(x), direction);
1771            }
1772            out.assign(&projected_out);
1773        } else {
1774            if let Some(gpu) = self.gpu_matvec {
1775                gpu(x, out);
1776            } else {
1777                schur_matvec(
1778                    self.sys,
1779                    self.htt_factors,
1780                    self.ridge_beta,
1781                    x,
1782                    out,
1783                    self.backend,
1784                    self.resident,
1785                );
1786            }
1787        }
1788
1789        if let Some(conditioning) = self.sys.exact_a_reduced_conditioning.as_ref() {
1790            assert_eq!(conditioning.directions.len(), conditioning.shifts.len());
1791            for (direction, &shift) in conditioning
1792                .directions
1793                .iter()
1794                .zip(conditioning.shifts.iter())
1795            {
1796                assert_eq!(direction.len(), self.sys.k);
1797                out.scaled_add(shift * direction.dot(x), direction);
1798            }
1799        }
1800    }
1801
1802    /// `S·v` into a fresh length-`k` vector — the shift-ladder matvec-closure form
1803    /// (`|v: ArrayView1| op.apply(v)`). Byte-for-byte the inline
1804    /// `let x = v.to_owned(); schur_matvec(…, &x, &mut zeros(k), …)` it replaces.
1805    #[inline]
1806    pub(crate) fn apply(&self, v: ArrayView1<f64>) -> Array1<f64> {
1807        let x = v.to_owned();
1808        let mut out = Array1::<f64>::zeros(self.sys.k);
1809        self.apply_into(&x, &mut out);
1810        out
1811    }
1812
1813    /// `S·x` into a fresh vector from an already-owned `&Array1` (no redundant copy
1814    /// of a vector the caller already owns) — the power-iteration / CG-solve form.
1815    #[inline]
1816    pub(crate) fn apply_owned(&self, x: &Array1<f64>) -> Array1<f64> {
1817        let mut out = Array1::<f64>::zeros(self.sys.k);
1818        self.apply_into(x, &mut out);
1819        out
1820    }
1821}
1822
1823/// Matrix-free reduced-Schur log-determinant `log|S|` via Stochastic Lanczos
1824/// Quadrature on the exact `schur_matvec` apply `v ↦ S·v`, where
1825/// `S = (H_ββ + ρ_β I) − Σ_i H_βt^(i)(H_tt^(i)+ρ_t I)⁻¹H_tβ^(i)` is the SPD
1826/// reduced Schur. **The dense `k×k` `S` is NEVER formed.**
1827///
1828/// This is the memory-matrix-free evidence path for the massive-K manifold SAE.
1829/// The dense evidence routes assemble `S` explicitly (`O(k²)` ≈ 8 GB at the
1830/// K=32k border) and Cholesky-factor it (`O(k³/3)`) purely to read `Σ 2·log Lᵢᵢ`;
1831/// that dense assembly + factor is the massive-K wall (both dense evidence
1832/// routes REFUSE above the in-core budget). Here peak memory is `O(k)` — the SLQ
1833/// Rademacher probe and Lanczos basis vectors — and the cost is
1834/// `O(num_probes·lanczos_steps · matvec)`, each matvec the same `O(n·d·k)`
1835/// reduced-Schur apply the PCG hot loop already runs. Deterministic for a fixed
1836/// `(sys, htt_factors, ρ_β, resident, num_probes, lanczos_steps, seed)` so the
1837/// REML evidence outer loop stays reproducible.
1838///
1839/// `htt_factors` are the per-row `(H_tt^(i)+ρ_t I)` Cholesky factors; `resident`
1840/// is the optional pre-staged SAE residency operator (`None` for the framed /
1841/// closure `H_tβ` path). SLQ is an ESTIMATE; callers that need the exact dense
1842/// log-det at small `k` must stay on the dense route.
1843///
1844/// Crate-internal because the `resident` parameter carries the `pub(crate)`
1845/// [`SaeResidentReducedSchur`] operator; cross-crate callers use the
1846/// [`matrix_free_arrow_evidence_log_det`] convenience, which stages residency
1847/// internally and exposes no crate-private type.
1848pub(crate) fn slq_reduced_schur_log_det<B: BatchedBlockSolver + Sync>(
1849    sys: &ArrowSchurSystem,
1850    htt_factors: &ArrowFactorSlab,
1851    ridge_beta: f64,
1852    backend: &B,
1853    resident: Option<&SaeResidentReducedSchur>,
1854    gpu_matvec: Option<&GpuSchurMatvec>,
1855    evidence_policy: ArrowEvidencePolicy,
1856    num_probes: usize,
1857    lanczos_steps: usize,
1858    seed: u64,
1859) -> Result<SlqLogDet, ArrowSchurError> {
1860    let k = sys.k;
1861    // Stage the reduced-Schur operator ONCE; every probe/Lanczos apply reuses the
1862    // pre-staged residency (no per-apply operator re-capture). The probes fan
1863    // across rayon workers (in `slq_logdet`), and `schur_matvec`'s own row
1864    // parallelism is guarded off inside a worker, so there is no nested
1865    // oversubscription. When `gpu_matvec` is `Some` (the #1017 Phase-3 device
1866    // seam, built once for the whole evidence evaluation), EVERY Rademacher-probe
1867    // Lanczos apply runs through the single resident device `S·v`; when `None`
1868    // the byte-identical CPU `schur_matvec` lane is taken.
1869    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
1870        .with_gpu_matvec(gpu_matvec);
1871    // The evidence log|S| must obey the SAME conditioning convention as the dense
1872    // reduced-Schur factor (#2308). Under `UnitDeflation` a collapsed / near-null
1873    // decoder direction is pinned to unit stiffness (`ln 1 = 0`), so the SLQ
1874    // estimate uses the unit-deflated spectral function `φ(θ)=θ≥floor ? ln θ : 0`
1875    // instead of the plain `ln` (which would floor a sub-null Ritz value to
1876    // `RITZ_LN_FLOOR`, contributing `≈ −690` per collapsed direction and a
1877    // ρ-dependent Occam reward). `Strict` / `PositiveDefinite` keep the plain SPD
1878    // estimator — they never form an undamped evidence with nulls.
1879    match evidence_policy {
1880        ArrowEvidencePolicy::UnitDeflation { relative_floor } => Ok(slq_logdet_unit_deflated(
1881            k,
1882            |v| op.apply(v),
1883            num_probes,
1884            lanczos_steps,
1885            seed,
1886            relative_floor,
1887        )
1888        .as_logdet()),
1889        // #2515 — a negative Ritz value is a Rayleigh quotient of raw A, not a
1890        // saddle verdict. Lift each Ritz direction and ask the same typed
1891        // B-metric/clamp-basin classifier as the dense and direct-arrow routes.
1892        ArrowEvidencePolicy::UnitDeflationRefusingIndefinite {
1893            relative_floor: _,
1894        } => {
1895            if sys.exact_a_classification.is_none() {
1896                return Err(ArrowSchurError::SchurFactorFailed {
1897                    reason: "matrix-free exact-A evidence policy requires the raw B/delta/clamp \
1898                             classification carrier"
1899                        .to_string(),
1900                });
1901            }
1902            slq_logdet_exact_a_classified(
1903                k,
1904                |v| op.apply(v),
1905                |direction| {
1906                    exact_a_reduced_direction_metrics(
1907                        sys,
1908                        htt_factors,
1909                        ridge_beta,
1910                        direction,
1911                    )
1912                    .map_err(|error| error.to_string())
1913                },
1914                num_probes,
1915                lanczos_steps,
1916                seed,
1917            )
1918            .map_err(|reason| ArrowSchurError::SchurFactorFailed { reason })
1919        }
1920        ArrowEvidencePolicy::Strict | ArrowEvidencePolicy::PositiveDefinite => {
1921            Ok(slq_logdet(k, |v| op.apply(v), num_probes, lanczos_steps, seed))
1922        }
1923    }
1924}
1925
1926/// #1017 Phase-3: build the reduced-Schur device matvec ONCE for a matrix-free
1927/// evidence log-det evaluation, so the whole rational-logdet + SLQ ladder applies
1928/// `S·v` through a single device-resident operator (uploaded / pre-factored once)
1929/// rather than re-capturing the CPU `schur_matvec` per probe / shifted solve. The
1930/// PCG numerics are identical whether the matvec runs on host or device (same
1931/// reduced Schur operator, same f64 accumulation), so engaging it changes only
1932/// where the `Σ_i H_βt(H_tt)⁻¹H_tβ` flops execute.
1933///
1934/// Same admission contract as the PCG matvec offload ([`maybe_inject_gpu_schur_matvec`]):
1935/// declines (returns `None`, so every apply stays on the byte-identical CPU lane)
1936/// when cross-row penalties or streaming are present, the work predicate rejects
1937/// the shape, or no live device is present. `apply_budget` is the amortising apply
1938/// count for the shape predicate — the reduced-Schur matvec is `O(n·d·k)` per
1939/// apply and the evidence ladder runs that apply across every probe / Lanczos /
1940/// shifted-CG step, so a large budget is the honest amortisation the offload
1941/// break-even is measured against.
1942pub(crate) fn maybe_build_evidence_gpu_matvec(
1943    sys: &ArrowSchurSystem,
1944    ridge_t: f64,
1945    ridge_beta: f64,
1946    options: &ArrowSolveOptions,
1947    apply_budget: usize,
1948) -> Result<Option<GpuSchurMatvec>, ArrowSchurError> {
1949    // A caller-supplied operator (threaded through `options.gpu_matvec`) already
1950    // owns its residency; the caller passes it directly, so never double-build.
1951    if options.gpu_matvec.is_some() {
1952        return Ok(None);
1953    }
1954    if !sys.cross_row_penalties.is_empty() || options.streaming_chunk_size.is_some() {
1955        return Ok(None);
1956    }
1957    // Size gate BEFORE the device probe (startup-tax ordering): the predicate
1958    // reads only associated constants, so a shape it rejects skips
1959    // runtime availability resolution (whose first call creates a CUDA primary context on
1960    // every GPU); an admitted shape probes exactly as the PCG seam does.
1961    if !gam_gpu::GpuDispatchPolicy::default().reduced_schur_matvec_should_offload(
1962        sys.rows.len(),
1963        sys.k,
1964        sys.d,
1965        apply_budget.max(1),
1966    ) {
1967        return Ok(None);
1968    }
1969    if gam_gpu::device_runtime::GpuRuntime::resolve(options.gpu_policy)
1970        .map_err(|error| ArrowSchurError::SchurFactorFailed {
1971            reason: format!("evidence GPU runtime resolution failed: {error}"),
1972        })?
1973        .is_none()
1974    {
1975        return Ok(None);
1976    }
1977    // #1017: framed matrix-free system with resident device operands — prefer the
1978    // device-resident DETERMINISTIC reduced-Schur apply (upload operands once,
1979    // cross only x/out per apply, atomics-free so the SLQ log|S| determinism
1980    // contract holds) over the CPU row-procedural closure `gpu_schur_matvec_backend`
1981    // returns for `htbeta_matvec` systems. Declines (no device / shape / non-PD at
1982    // this ridge) fall through to the backend/CPU path. Non-Linux/CPU: this always
1983    // returns `None` (no `device_sae_pcg`), so the lane is byte-identical.
1984    // `Unavailable` is the device saying "not this shape/config", which is a
1985    // DECLINE and not a fault: every other exit from this function reports a
1986    // decline as `Ok(None)`, the CPU lane, and the sibling device seam at
1987    // `solve_reduced_beta_pcg` above already falls through on the same variant.
1988    // Surfacing it as an error made a host WITH a GPU fail where a CPU-only host
1989    // returned `Ok(None)` at the runtime probe and passed. Genuine faults
1990    // (`RidgeBumpRequired`, `SchurFactorFailed`) still surface.
1991    if sys.device_sae_pcg.is_some() {
1992        match crate::gpu_kernels::arrow_schur::build_framed_resident_evidence_matvec(
1993            sys,
1994            ridge_t,
1995            ridge_beta,
1996            apply_budget.max(1),
1997        ) {
1998            Ok(Some(matvec)) => return Ok(Some(matvec)),
1999            Ok(None) => {}
2000            Err(crate::gpu_kernels::arrow_schur::ArrowSchurGpuFailure::Unavailable) => {
2001                log::debug!("resident evidence matvec build: device unavailable; CPU matvec");
2002            }
2003            Err(failure) => {
2004                return Err(device_failure_as_arrow_error(
2005                    "resident evidence matvec build",
2006                    failure,
2007                ));
2008            }
2009        }
2010    }
2011    match crate::gpu_kernels::arrow_schur::gpu_schur_matvec_backend(sys, ridge_t, ridge_beta) {
2012        Ok(matvec) => Ok(Some(matvec)),
2013        Err(crate::gpu_kernels::arrow_schur::ArrowSchurGpuFailure::Unavailable) => Ok(None),
2014        Err(failure) => Err(device_failure_as_arrow_error("evidence matvec build", failure)),
2015    }
2016}
2017
2018/// Fixed configuration for the #2080 rational-surrogate evidence lane: the probe
2019/// count, seeds, quadrature/CG tolerances, and derived-rank deflation budget the
2020/// [`SurrogateLaneState`] plan is (re)built with. The caller (the SAE streaming
2021/// criterion) supplies these once; `deflation_target_std_err_rel` is the derived
2022/// bar `0.1 · STALL_REL_TOL` (see [`rational_reduced_schur_plan_derived`]).
2023#[derive(Clone)]
2024pub struct SurrogateLaneConfig {
2025    pub num_probes: usize,
2026    pub seed: u64,
2027    pub rel_tol: f64,
2028    pub power_iters: usize,
2029    pub cg_rel_tol: f64,
2030    pub cg_max_iters: usize,
2031    pub deflation_max_rank: usize,
2032    pub deflation_subspace_iters: usize,
2033    pub deflation_target_std_err_rel: f64,
2034}
2035
2036/// Per-outer-solve state for the #2080 rational-surrogate evidence lane. Holds
2037/// the FROZEN derived-rank plan — probes, bracket-centred quadrature, and Hutch++
2038/// `Q`, all fixed once at the entry ρ so value and gradient stay a single
2039/// functional across the ρ sweep — plus the config to (re)build it when the
2040/// reduced-Schur dimension changes (a basin mutation between outer solves).
2041/// Threaded as `Option<&mut _>` through the streaming criterion; `None` keeps the
2042/// bit-identical SLQ path.
2043pub struct SurrogateLaneState {
2044    plan: Option<RationalLogdetPlan>,
2045    cfg: SurrogateLaneConfig,
2046    /// When set, the next matrix-free evidence eval also computes the shared
2047    /// `(probes, S⁻¹·probes)` bundle for EFS/MacKay proposal traces and stashes
2048    /// it in `inverse_probes`. It is never an outer gradient artifact: the fixed
2049    /// rational value's derivative is `logdet_derivative_bundle` below.
2050    request_inverse_probes: bool,
2051    /// The last-computed shared bundle: the FROZEN plan's probes `v_j` and their
2052    /// `S⁻¹ v_j` (t = 0) solves at the current operator. One bundle drives every
2053    /// selected-inverse trace `tr(S⁻¹·M) ≈ (1/m)Σ_j (S⁻¹v_j)ᵀ(M v_j)` off the
2054    /// same frozen raw probes as the value plan. This is useful for EFS trace
2055    /// proposals but is not the derivative of the shifted rational value.
2056    inverse_probes: Option<(Vec<Array1<f64>>, Vec<Array1<f64>>)>,
2057    /// Request/stash the lossless weighted derivative representation emitted by
2058    /// the next rational value evaluation.  Unlike `inverse_probes`, this is the
2059    /// derivative of the fixed rational surrogate itself (all shifted solves and
2060    /// frozen-Q columns), and is the only bundle admissible for its outer
2061    /// gradient.
2062    request_logdet_derivative_bundle: bool,
2063    logdet_derivative_bundle: Option<RationalLogdetDerivativeBundle>,
2064    /// The previous ρ's `S⁻¹ v_j` solves, kept as the CG warm-start for the next
2065    /// bundle solve. `S⁻¹` is smooth in ρ, so a neighbouring-ρ solution is a near
2066    /// seed (common-random-numbers reuse — the discipline that makes the
2067    /// surrogate's shifted ladder cheap); the converged solve is unchanged to
2068    /// `cg_rel_tol`, only its iteration count drops. Cleared when the plan rebuilds
2069    /// (basin border change ⇒ the old-dim seeds are meaningless).
2070    warm_inverse_probes: Option<Vec<Array1<f64>>>,
2071}
2072
2073impl SurrogateLaneState {
2074    /// A lane with no plan yet — the first evaluation builds and freezes it.
2075    pub fn new(cfg: SurrogateLaneConfig) -> Self {
2076        Self {
2077            plan: None,
2078            cfg,
2079            request_inverse_probes: false,
2080            inverse_probes: None,
2081            request_logdet_derivative_bundle: false,
2082            logdet_derivative_bundle: None,
2083            warm_inverse_probes: None,
2084        }
2085    }
2086
2087    /// The frozen plan, once built (for the gradient lane, which contracts
2088    /// against the SAME `Q` the value used).
2089    pub fn plan(&self) -> Option<&RationalLogdetPlan> {
2090        self.plan.as_ref()
2091    }
2092
2093    /// Ask the next matrix-free evidence eval to also emit the shared
2094    /// `(probes, S⁻¹·probes)` bundle. Clears any stale bundle so a failed or
2095    /// skipped eval cannot hand back last call's solves.
2096    pub fn request_inverse_probes(&mut self) {
2097        self.request_inverse_probes = true;
2098        self.inverse_probes = None;
2099    }
2100
2101    /// Take the shared bundle produced by the most recent eval, if requested and
2102    /// computed. Consumes it so a later gradient read cannot reuse stale solves.
2103    pub fn take_inverse_probes(&mut self) -> Option<(Vec<Array1<f64>>, Vec<Array1<f64>>)> {
2104        self.request_inverse_probes = false;
2105        self.inverse_probes.take()
2106    }
2107
2108    /// Ask the next rational value evaluation to retain its complete weighted
2109    /// derivative representation. Clears stale output eagerly so a failed value
2110    /// cannot be paired with a previous operator's gradient.
2111    pub fn request_logdet_derivative_bundle(&mut self) {
2112        self.request_logdet_derivative_bundle = true;
2113        self.logdet_derivative_bundle = None;
2114    }
2115
2116    /// Consume the derivative representation produced by the most recent
2117    /// requested rational value evaluation.
2118    pub fn take_logdet_derivative_bundle(&mut self) -> Option<RationalLogdetDerivativeBundle> {
2119        self.request_logdet_derivative_bundle = false;
2120        self.logdet_derivative_bundle.take()
2121    }
2122}
2123
2124/// Split arrow-Schur evidence `log|H| = Σ log|H_tt| + log|S|` where the reduced
2125/// Schur term is estimated by the #2080 rational surrogate rather than SLQ, on
2126/// ONE shared factorization. The build-once companion to
2127/// `matrix_free_arrow_evidence_log_det`:
2128///
2129/// - `lane = None` runs the identical `slq_reduced_schur_log_det` path — a
2130///   bit-for-bit fallback so a caller that has not opted in is unchanged.
2131/// - `lane = Some(state)` builds (or, when the reduced-Schur dimension is
2132///   unchanged, reuses) the frozen derived-rank [`RationalLogdetPlan`] and
2133///   evaluates it against the current operator. The plan's `Q`/probes/quadrature
2134///   are fixed at first build, so only the matrix-free `S·v` apply moves with ρ —
2135///   the value and its `RationalLogdetPlan::directional_derivative` gradient
2136///   remain one functional.
2137///
2138/// Returns `(log_det_tt, log_det_schur)`; the caller adds them for the evidence.
2139pub fn matrix_free_arrow_evidence_log_det_surrogate(
2140    sys: &ArrowSchurSystem,
2141    ridge_t: f64,
2142    ridge_beta: f64,
2143    options: &ArrowSolveOptions,
2144    slq_num_probes: usize,
2145    slq_lanczos_steps: usize,
2146    slq_seed: u64,
2147    lane: Option<&mut SurrogateLaneState>,
2148) -> Result<(f64, f64), ArrowSchurError> {
2149    let (log_det_tt, log_det_schur, _factors) = matrix_free_arrow_evidence_log_det_surrogate_core(
2150        sys,
2151        ridge_t,
2152        ridge_beta,
2153        options,
2154        slq_num_probes,
2155        slq_lanczos_steps,
2156        slq_seed,
2157        lane,
2158    )?;
2159    Ok((log_det_tt, log_det_schur))
2160}
2161
2162/// One matrix-free evidence value together with the exact row geometry that
2163/// produced it. The reduced-Schur derivative bundle emitted through
2164/// [`SurrogateLaneState`] is only meaningful when lifted through these same row
2165/// factors and cross blocks; retaining a different operator's factor cache
2166/// silently differentiates a different log determinant.
2167pub struct MatrixFreeArrowEvidenceEvaluation {
2168    pub log_det_tt: f64,
2169    pub log_det_schur: f64,
2170    pub factor_cache: ArrowFactorCache,
2171}
2172
2173impl MatrixFreeArrowEvidenceEvaluation {
2174    #[must_use]
2175    pub fn log_det(&self) -> f64 {
2176        self.log_det_tt + self.log_det_schur
2177    }
2178}
2179
2180/// Gradient-bearing form of
2181/// [`matrix_free_arrow_evidence_log_det_surrogate`]. Value, rational derivative,
2182/// and row factors are emitted by one factorization; consumers therefore cannot
2183/// pair the derivative of one reduced operator with another operator's row
2184/// elimination geometry.
2185pub fn matrix_free_arrow_evidence_evaluation(
2186    sys: &ArrowSchurSystem,
2187    ridge_t: f64,
2188    ridge_beta: f64,
2189    options: &ArrowSolveOptions,
2190    slq_num_probes: usize,
2191    slq_lanczos_steps: usize,
2192    slq_seed: u64,
2193    lane: &mut SurrogateLaneState,
2194) -> Result<MatrixFreeArrowEvidenceEvaluation, ArrowSchurError> {
2195    if ridge_t != 0.0 || ridge_beta != 0.0 {
2196        return Err(ArrowSchurError::SchurFactorFailed {
2197            reason: format!(
2198                "gradient-bearing evidence must be undamped, got ridge_t={ridge_t:e}, \
2199                 ridge_beta={ridge_beta:e}"
2200            ),
2201        });
2202    }
2203    let (log_det_tt, log_det_schur, factorization) =
2204        matrix_free_arrow_evidence_log_det_surrogate_core(
2205            sys,
2206            ridge_t,
2207            ridge_beta,
2208            options,
2209            slq_num_probes,
2210            slq_lanczos_steps,
2211            slq_seed,
2212            Some(lane),
2213        )?;
2214    let factor_cache = ArrowFactorCache {
2215        htt_factors: factorization.factors,
2216        htt_factors_undamped: ArrowUndampedFactors::SameAsDamped,
2217        schur_factor: None,
2218        schur_factor_is_undamped: true,
2219        beta_schur_conditioning: None,
2220        joint_hessian_log_det: Some(log_det_tt + log_det_schur),
2221        solver_mode: options.mode,
2222        ridge_t,
2223        ridge_beta,
2224        htbeta: ArrowHtbetaCache::from_system(sys)?,
2225        d: sys.d,
2226        row_dims: Arc::clone(&sys.row_dims),
2227        row_offsets: Arc::clone(&sys.row_offsets),
2228        k: sys.k,
2229        manifold_mode_fingerprint: sys.manifold_mode_fingerprint,
2230        row_hessian_fingerprint: sys.current_row_hessian_fingerprint(),
2231        pcg_diagnostics: ArrowPcgDiagnostics::default(),
2232        gauge_deflated_directions: factorization.gauge_deflated_directions,
2233        deflated_row_directions: factorization.deflated_row_directions.into(),
2234        deflation_row_spectra: factorization.deflation_row_spectra.into(),
2235        beta_gauge_quotient: sys.beta_gauge_quotient.clone(),
2236    };
2237    Ok(MatrixFreeArrowEvidenceEvaluation {
2238        log_det_tt,
2239        log_det_schur,
2240        factor_cache,
2241    })
2242}
2243
2244fn matrix_free_arrow_evidence_log_det_surrogate_core(
2245    sys: &ArrowSchurSystem,
2246    ridge_t: f64,
2247    ridge_beta: f64,
2248    options: &ArrowSolveOptions,
2249    slq_num_probes: usize,
2250    slq_lanczos_steps: usize,
2251    slq_seed: u64,
2252    lane: Option<&mut SurrogateLaneState>,
2253) -> Result<(f64, f64, ArrowBlockFactorization), ArrowSchurError> {
2254    let backend = CpuBatchedBlockSolver;
2255    let factorization = factor_blocks_for_system(
2256        sys,
2257        ridge_t,
2258        options.evidence_policy,
2259        &backend,
2260        options.gpu_policy,
2261    )?;
2262    let htt_factors = factorization.factors.clone();
2263    let mut log_det_tt = 0.0_f64;
2264    for row in 0..htt_factors.len() {
2265        let factor = htt_factors.factor(row);
2266        for axis in 0..factor.nrows() {
2267            log_det_tt += 2.0 * factor[[axis, axis]].ln();
2268        }
2269    }
2270    // #1017 Phase-3: one device-resident reduced-Schur `S·v` for the WHOLE
2271    // evaluation — the surrogate value ladder (two-sided deflation: block-power on
2272    // S + inverse subspace iteration on S⁻¹ via matrix-free CG), the λ_max bracket
2273    // power iteration, the SLQ probes, AND the S⁻¹·probe bundle all ride this
2274    // single operator (uploaded / pre-factored once). Sized against the surrogate's
2275    // per-evaluation apply budget (probe count × shifted-CG ladder depth). The
2276    // device operator carries its own residency, so the CPU `SaeResidentReducedSchur`
2277    // frame is only staged on the CPU lane.
2278    let cfg_apply_budget = lane
2279        .as_ref()
2280        .map(|s| s.cfg.num_probes.saturating_mul(s.cfg.cg_max_iters))
2281        .unwrap_or_else(|| slq_num_probes.saturating_mul(slq_lanczos_steps));
2282    let device_matvec =
2283        maybe_build_evidence_gpu_matvec(sys, ridge_t, ridge_beta, options, cfg_apply_budget)?;
2284    let gpu_matvec: Option<&GpuSchurMatvec> =
2285        options.gpu_matvec.as_ref().or(device_matvec.as_ref());
2286    let resident = if gpu_matvec.is_none() {
2287        SaeResidentReducedSchur::build(sys, &htt_factors, &backend)
2288    } else {
2289        None
2290    };
2291
2292    // The rational ladder solves shifted SPD systems, so its operator must
2293    // already carry the SAME exact-A spectral classification that SLQ applies
2294    // inside its quadrature.  Build the low-rank Ritz correction once per
2295    // evaluation on the raw reduced operator, then install it on an
2296    // evaluation-local system clone consumed by every power/CG/value/derivative
2297    // apply.  Majorizer and plain-SPD lanes retain the original system exactly.
2298    let rational_exact_a = lane.is_some()
2299        && matches!(
2300            options.evidence_policy,
2301            ArrowEvidencePolicy::UnitDeflationRefusingIndefinite { .. }
2302        );
2303    let classified_system = if rational_exact_a {
2304        if sys.exact_a_classification.is_none() {
2305            return Err(ArrowSchurError::SchurFactorFailed {
2306                reason: "rational exact-A evidence policy requires the raw B/delta/clamp \
2307                         classification carrier"
2308                    .to_string(),
2309            });
2310        }
2311        let raw_op = ReducedSchurOperator::new(
2312            sys,
2313            &htt_factors,
2314            ridge_beta,
2315            &backend,
2316            resident.as_ref(),
2317        )
2318        .with_gpu_matvec(gpu_matvec);
2319        let conditioning = exact_a_ritz_conditioning(
2320            sys.k,
2321            |direction| raw_op.apply(direction),
2322            |direction| {
2323                exact_a_reduced_direction_metrics(sys, &htt_factors, ridge_beta, direction)
2324                    .map_err(|error| error.to_string())
2325            },
2326            slq_lanczos_steps,
2327            slq_seed,
2328        )
2329        .map_err(|reason| ArrowSchurError::SchurFactorFailed { reason })?;
2330        let mut classified = sys.clone();
2331        classified.exact_a_reduced_conditioning = Some(conditioning);
2332        Some(classified)
2333    } else {
2334        None
2335    };
2336    let evidence_system = classified_system.as_ref().unwrap_or(sys);
2337
2338    let log_det_schur = match lane {
2339        None => {
2340            let slq = slq_reduced_schur_log_det(
2341                sys,
2342                &htt_factors,
2343                ridge_beta,
2344                &backend,
2345                resident.as_ref(),
2346                gpu_matvec,
2347                options.evidence_policy,
2348                slq_num_probes,
2349                slq_lanczos_steps,
2350                slq_seed,
2351            );
2352            slq?.estimate
2353        }
2354        Some(state) => {
2355            let dim = evidence_system.k;
2356            // (Re)build the frozen plan when absent or dimension-mismatched (a
2357            // basin mutation changed the border); otherwise reuse the frozen Q.
2358            let need_build = state.plan.as_ref().map_or(true, |p| p.dim != dim);
2359            let mut entry_evaluation = None;
2360            if need_build {
2361                let cfg = state.cfg.clone();
2362                let derived = rational_reduced_schur_plan_derived(
2363                    evidence_system,
2364                    &htt_factors,
2365                    ridge_beta,
2366                    &backend,
2367                    resident.as_ref(),
2368                    gpu_matvec,
2369                    cfg.num_probes,
2370                    cfg.seed,
2371                    cfg.rel_tol,
2372                    cfg.power_iters,
2373                    cfg.cg_rel_tol,
2374                    cfg.cg_max_iters,
2375                    cfg.deflation_max_rank,
2376                    cfg.deflation_subspace_iters,
2377                    cfg.deflation_target_std_err_rel,
2378                )
2379                .map_err(|reason| ArrowSchurError::SchurFactorFailed {
2380                    reason: format!(
2381                        "rational log-det surrogate plan build failed for reduced Schur dim \
2382                         {dim}: {reason}"
2383                    ),
2384                })?;
2385                state.plan = Some(derived.plan);
2386                entry_evaluation = Some(derived.entry_evaluation);
2387                // The old-dim S⁻¹·probes are meaningless against the new border.
2388                state.warm_inverse_probes = None;
2389            }
2390            let plan = state
2391                .plan
2392                .as_ref()
2393                .expect("plan installed just above when absent");
2394            let want_bundle = state.request_inverse_probes;
2395            let want_logdet_derivative = state.request_logdet_derivative_bundle;
2396            // Value, its lossless shifted derivative representation, and any
2397            // EFS-only `(probes, S⁻¹·probes)` trace bundle are computed under one
2398            // borrow of the frozen plan and stashed after that borrow ends. The
2399            // EFS bundle uses raw probes; the outer gradient consumes only the
2400            // weighted shifted derivative bundle.
2401            let (estimate, derivative_bundle, bundle) = {
2402                // #1017: ONE reduced-Schur operator for the whole value ladder —
2403                // the frozen plan walks its shift ladder through this single
2404                // resident apply instead of re-capturing a `schur_matvec` closure
2405                // per shifted solve. When `gpu_matvec` is `Some` (Phase-3 device
2406                // seam, built once above) every shifted apply runs on device; when
2407                // `None` the byte-identical CPU `schur_matvec` lane is taken.
2408                let op = ReducedSchurOperator::new(
2409                    evidence_system,
2410                    &htt_factors,
2411                    ridge_beta,
2412                    &backend,
2413                    resident.as_ref(),
2414                )
2415                .with_gpu_matvec(gpu_matvec);
2416                let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2417                // #2576: the EXACT diag(S) is available here and cheap
2418                // (`reduced_schur_shifted_preconditioner_resident`), and it was
2419                // MEASURED not to help — 5189 iterations against this shared
2420                // block's 5138, because the elimination term carries the same
2421                // firing-count structure as `H_ββ` and the two very nearly
2422                // cancel to a uniform rescaling, which CG is invariant to. See
2423                // `exact_schur_diagonal_is_a_near_uniform_rescaling_of_the_shared_block_2576`.
2424                let precond =
2425                    reduced_schur_shifted_preconditioner(evidence_system, ridge_beta);
2426                // The derived-plan builder already certified this exact plan on
2427                // this exact entry operator with this exact preconditioner. Keep
2428                // that evaluation as the first value/derivative payload instead
2429                // of immediately walking the whole shifted-PCG ladder a second
2430                // time. Subsequent ρ values evaluate the frozen plan normally.
2431                let eval = match entry_evaluation.take() {
2432                    Some(eval) => eval,
2433                    None => plan
2434                        .evaluate_family_preconditioned(
2435                            &matvec,
2436                            &precond,
2437                            state.cfg.cg_rel_tol,
2438                            state.cfg.cg_max_iters,
2439                        )
2440                        .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2441                            reason: "rational log-det surrogate evaluation returned non-finite"
2442                                .to_string(),
2443                        })?,
2444                };
2445                let estimate = eval.estimate;
2446                let derivative_bundle = if want_logdet_derivative {
2447                    Some(
2448                        plan.into_directional_derivative_bundle(eval)
2449                            .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2450                                reason: "rational log-det derivative bundle assembly failed"
2451                                    .to_string(),
2452                            })?,
2453                    )
2454                } else {
2455                    None
2456                };
2457                let bundle = if want_bundle {
2458                    let (sinv, cg_report) = reduced_schur_inverse_probe_solves(
2459                        evidence_system,
2460                        &htt_factors,
2461                        ridge_beta,
2462                        &backend,
2463                        resident.as_ref(),
2464                        gpu_matvec,
2465                        &plan.probes,
2466                        state.warm_inverse_probes.as_deref(),
2467                        state.cfg.cg_rel_tol,
2468                        state.cfg.cg_max_iters,
2469                    )
2470                    .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
2471                        reason: "rational surrogate inverse-probe bundle solve failed".to_string(),
2472                    })?;
2473                    if !cg_report.converged() {
2474                        log::warn!(
2475                            "rational surrogate inverse-probe bundle: weakest reduced-Schur CG \
2476                             reached relative residual {:.3e} against tolerance {:.3e} after \
2477                             {} of {} iterations (preconditioner {:?}); every trace contracted \
2478                             against this bundle inherits that error",
2479                            cg_report.relative_residual,
2480                            cg_report.tolerance,
2481                            cg_report.iterations,
2482                            cg_report.max_iterations,
2483                            cg_report.preconditioner,
2484                        );
2485                    }
2486                    Some((plan.probes.clone(), sinv))
2487                } else {
2488                    None
2489                };
2490                (estimate, derivative_bundle, bundle)
2491            };
2492            if want_logdet_derivative {
2493                state.logdet_derivative_bundle = derivative_bundle;
2494                state.request_logdet_derivative_bundle = false;
2495            }
2496            if want_bundle {
2497                // Keep the fresh solves as the next ρ's warm-start seed (CRN),
2498                // then hand the bundle to the gradient lane.
2499                if let Some((_, sinv)) = &bundle {
2500                    state.warm_inverse_probes = Some(sinv.clone());
2501                }
2502                state.inverse_probes = bundle;
2503                state.request_inverse_probes = false;
2504            }
2505            estimate
2506        }
2507    };
2508    Ok((log_det_tt, log_det_schur, factorization))
2509}
2510
2511/// Power-iteration estimate of the largest eigenvalue `λ_max` of the SPD reduced
2512/// Schur `S` through the matrix-free `schur_matvec` apply — the upper end of
2513/// the spectral bracket the #2080 rational log-det surrogate
2514/// ([`RationalLogdetPlan`]) needs to size its bracket-centred DE quadrature.
2515///
2516/// Deterministic: the start vector is a fixed SplitMix64 Rademacher draw from
2517/// `seed`, so a given `(sys, htt_factors, ρ_β, resident, iters, seed)` always
2518/// returns the same estimate — the surrogate bracket must be reproducible for the
2519/// REML outer loop, exactly like the SLQ probes. `iters` power steps refine the
2520/// Rayleigh quotient `vᵀ S v` (each step is one `schur_matvec`); a handful
2521/// suffice because the surrogate only needs a bracket good to a factor, not a
2522/// converged eigenvalue (the quadrature window is padded two decades each side).
2523///
2524/// Returns `None` for a degenerate operator (`k == 0`) or a non-finite /
2525/// non-positive Rayleigh quotient (an SPD operator forbids the latter, so it
2526/// signals a caller bug or a non-finite operator, both of which must surface
2527/// rather than be silently bracketed).
2528pub fn reduced_schur_lambda_max<B: BatchedBlockSolver + Sync>(
2529    sys: &ArrowSchurSystem,
2530    htt_factors: &ArrowFactorSlab,
2531    ridge_beta: f64,
2532    backend: &B,
2533    resident: Option<&SaeResidentReducedSchur>,
2534    gpu_matvec: Option<&GpuSchurMatvec>,
2535    iters: usize,
2536    seed: u64,
2537) -> Option<f64> {
2538    let k = sys.k;
2539    if k == 0 {
2540        return None;
2541    }
2542    // Deterministic Rademacher start (same stream discipline as the surrogate
2543    // probes): a ±1 vector never lands orthogonal to the top eigenspace.
2544    let mut v = Array1::<f64>::zeros(k);
2545    {
2546        let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
2547        let mut bits: u64 = 0;
2548        let mut remaining: u32 = 0;
2549        for value in v.iter_mut() {
2550            if remaining == 0 {
2551                bits = gam_linalg::utils::splitmix64(&mut state);
2552                remaining = 64;
2553            }
2554            *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
2555            bits >>= 1;
2556            remaining -= 1;
2557        }
2558    }
2559    let inv_norm0 = v.dot(&v).sqrt().recip();
2560    if !inv_norm0.is_finite() {
2561        return None;
2562    }
2563    v.mapv_inplace(|x| x * inv_norm0);
2564    // One resident operator reused across every power-iteration apply — device
2565    // seam threaded so the bracket estimate rides the SAME resident `S·v` the
2566    // ladder/probes use.
2567    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2568        .with_gpu_matvec(gpu_matvec);
2569    let apply = |x: &Array1<f64>| -> Array1<f64> { op.apply_owned(x) };
2570    for _ in 0..iters.max(1) {
2571        let sv = apply(&v);
2572        let norm = sv.dot(&sv).sqrt();
2573        if !(norm.is_finite() && norm > 0.0) {
2574            break;
2575        }
2576        v = sv / norm;
2577    }
2578    // Rayleigh quotient on the converged iterate (v stays unit).
2579    let sv = apply(&v);
2580    let lambda = v.dot(&sv);
2581    (lambda.is_finite() && lambda > 0.0).then_some(lambda)
2582}
2583
2584/// A measured direction of negative curvature of the reduced Schur complement,
2585/// carried into the FULL arrow coordinates.
2586///
2587/// `curvature` is the Rayleigh quotient `vᵀSv` re-measured with one extra
2588/// `S·v` apply, not the Ritz value the eigensolver reported — the Ritz value
2589/// is an estimate from a Krylov space, and a certificate of indefiniteness must
2590/// be an evaluation of the operator itself. `border` is the unit mode `v` in
2591/// the reduced (border) coordinates and `eliminated` is its exact lift
2592/// `L(v)` through the arrow elimination, so `(eliminated, border)` is a
2593/// displacement of the full system whose curvature is exactly `curvature`.
2594#[derive(Debug, Clone)]
2595pub struct ReducedSchurNegativeCurvature {
2596    /// `vᵀSv < 0`, measured by an apply rather than reported by the eigensolver.
2597    pub curvature: f64,
2598    /// The algebraically smallest Ritz value the shifted solve certified.
2599    pub ritz_eigenvalue: f64,
2600    /// The shift `σ ≥ λ_max` the spectral fold used.
2601    pub shift: f64,
2602    /// The unit mode in reduced/border coordinates.
2603    pub border: Array1<f64>,
2604    /// `L(v)`: the same mode in the eliminated blocks' coordinates.
2605    pub eliminated: Array1<f64>,
2606}
2607
2608/// The reduced Schur's algebraically most-negative eigenpair, matrix-free, and
2609/// the full-space displacement it lifts to — `None` when the operator resolves
2610/// no negative direction.
2611///
2612/// # Why a shift rather than plain Lanczos
2613///
2614/// [`gam_linalg::lanczos::symmetric_extreme_lanczos_eigenpairs`] certifies
2615/// extreme-MAGNITUDE eigenpairs. At a saddle of a penalized fit `λ_max` is the
2616/// data curvature and `λ_min` is a small negative number, so the largest
2617/// magnitude is the wrong end and the mode that matters is invisible to it.
2618/// Running the same solver on `σI − S` fixes that exactly: the spectrum folds
2619/// to `σ − λ_j ≥ 0`, its largest element is `σ − λ_min`, and largest-magnitude
2620/// is now the end we want. The fold is an exact similarity on the eigenvectors
2621/// — it changes which eigenvalue is extreme and nothing else — and `σ` is the
2622/// `λ_max` the surrogate's spectral bracket already estimates
2623/// ([`reduced_schur_lambda_max`]), so no new spectral information is needed.
2624///
2625/// # Why this is a statement about the ITERATE
2626///
2627/// The lift `L` satisfies `[L(v); v]ᵀ H [L(v); v] = vᵀ S v` exactly (see
2628/// `arrow_lift_border_direction`). So a negative `curvature` here is not a
2629/// property of the reduced surrogate that might vanish in the full problem: it
2630/// is negative curvature of the fit's own objective at this point, and a fit
2631/// reporting convergence there has converged to something that is not a local
2632/// minimum.
2633pub fn reduced_schur_negative_curvature<B: BatchedBlockSolver + Sync>(
2634    sys: &ArrowSchurSystem,
2635    htt_factors: &ArrowFactorSlab,
2636    ridge_beta: f64,
2637    backend: &B,
2638    resident: Option<&SaeResidentReducedSchur>,
2639    gpu_matvec: Option<&GpuSchurMatvec>,
2640    lambda_max: f64,
2641    max_steps: usize,
2642    seed: u64,
2643) -> Option<ReducedSchurNegativeCurvature> {
2644    let k = sys.k;
2645    if k == 0 || !(lambda_max.is_finite() && lambda_max > 0.0) {
2646        return None;
2647    }
2648    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2649        .with_gpu_matvec(gpu_matvec);
2650    // Deterministic Rademacher start, the same stream discipline the surrogate
2651    // probes and `reduced_schur_lambda_max` use: reproducible across runs and
2652    // never orthogonal to the sought eigenspace by construction.
2653    let mut start = vec![0.0_f64; k];
2654    {
2655        let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
2656        let mut bits: u64 = 0;
2657        let mut remaining: u32 = 0;
2658        for value in start.iter_mut() {
2659            if remaining == 0 {
2660                bits = gam_linalg::utils::splitmix64(&mut state);
2661                remaining = 64;
2662            }
2663            *value = if bits & 1 == 1 { 1.0 } else { -1.0 };
2664            bits >>= 1;
2665            remaining -= 1;
2666        }
2667    }
2668    // `σ` strictly above `λ_max` so the folded operator is positive semidefinite
2669    // even when the power-iteration estimate sits a rounding below the true top.
2670    let shift = lambda_max * (1.0 + 8.0 * f64::EPSILON.sqrt());
2671    let options = gam_linalg::lanczos::SymmetricExtremeLanczosOptions {
2672        target_rank: 1,
2673        max_steps: max_steps.clamp(1, k),
2674        check_every: 4,
2675        relative_residual_tol: f64::EPSILON.sqrt(),
2676        breakdown_tol: 0.0,
2677    };
2678    let mut work = Array1::<f64>::zeros(k);
2679    let pairs = gam_linalg::lanczos::symmetric_extreme_lanczos_eigenpairs(
2680        k,
2681        &start,
2682        options,
2683        |x: &[f64], out: &mut [f64]| {
2684            let xv = Array1::from_iter(x.iter().copied());
2685            op.apply_into(&xv, &mut work);
2686            for (slot, (&xi, &sv)) in out.iter_mut().zip(x.iter().zip(work.iter())) {
2687                *slot = shift * xi - sv;
2688            }
2689            Ok(())
2690        },
2691    )
2692    .ok()?;
2693    // Largest folded eigenvalue ⇒ smallest eigenvalue of `S`.
2694    let (best, &folded) = pairs
2695        .eigenvalues
2696        .iter()
2697        .enumerate()
2698        .max_by(|a, b| a.1.total_cmp(b.1))?;
2699    let ritz_eigenvalue = shift - folded;
2700    let mode = pairs.eigenvectors.column(best).to_owned();
2701    let norm = mode.dot(&mode).sqrt();
2702    if !(norm.is_finite() && norm > 0.0) {
2703        return None;
2704    }
2705    let border = mode / norm;
2706    // The certificate: an APPLY of the operator, not the eigensolver's estimate.
2707    let curvature = border.dot(&op.apply_owned(&border));
2708    if !(curvature.is_finite() && curvature < 0.0) {
2709        return None;
2710    }
2711    let eliminated = arrow_lift_border_direction(sys, htt_factors, border.view(), backend);
2712    if eliminated.iter().any(|value| !value.is_finite()) {
2713        return None;
2714    }
2715    Some(ReducedSchurNegativeCurvature {
2716        curvature,
2717        ritz_eigenvalue,
2718        shift,
2719        border,
2720        eliminated,
2721    })
2722}
2723
2724/// Matrix-free reduced-Schur log-determinant `log|S|` via the #2080 fixed
2725/// rational surrogate ([`RationalLogdetPlan`]) on the exact `schur_matvec`
2726/// apply — the desync-safe companion to `slq_reduced_schur_log_det`. **The
2727/// dense `k×k` `S` is NEVER formed.**
2728///
2729/// Returns the built plan and its evaluation so the caller can (a) read
2730/// `eval.estimate` = the surrogate value `L̃ ≈ log|S|` (with `eval.std_err` the
2731/// honest Hutchinson error bar), and (b) later contract the SAME shifted-solve
2732/// bundle against any per-ρ-coordinate Schur-derivative operator `∂S` via
2733/// `rational_reduced_schur_directional`. Because both the value and that
2734/// derivative are the exact value / gradient of the ONE deterministic function
2735/// `L̃(ρ)` (fixed probes, fixed quadrature), the outer optimiser descends a
2736/// function whose gradient is its own — the objective↔gradient desync class the
2737/// bare SLQ value re-opened (a stochastic value paired with the analytic exact
2738/// gradient) is closed by construction, not by tolerance tuning.
2739///
2740/// The spectral bracket is estimated matrix-free: `λ_max` by power iteration
2741/// ([`reduced_schur_lambda_max`]), `λ_min` from the deflation-floor convention
2742/// `SPECTRAL_DEFLATION_REL_FLOOR·λ_max` (the operative lower bound of the
2743/// unit-deflated spectrum). Deterministic for a fixed
2744/// `(sys, htt_factors, ρ_β, resident, num_probes, seed, rel_tol, power_iters,
2745/// cg_rel_tol, cg_max_iters)`.
2746///
2747/// `None` when `k == 0`, the bracket estimate is degenerate, the plan cannot be
2748/// built, or a shifted CG solve breaks down on a non-finite operator.
2749pub fn rational_reduced_schur_log_det<B: BatchedBlockSolver + Sync>(
2750    sys: &ArrowSchurSystem,
2751    htt_factors: &ArrowFactorSlab,
2752    ridge_beta: f64,
2753    backend: &B,
2754    resident: Option<&SaeResidentReducedSchur>,
2755    gpu_matvec: Option<&GpuSchurMatvec>,
2756    num_probes: usize,
2757    seed: u64,
2758    rel_tol: f64,
2759    power_iters: usize,
2760    cg_rel_tol: f64,
2761    cg_max_iters: usize,
2762) -> Option<(RationalLogdetPlan, RationalLogdetEval)> {
2763    let k = sys.k;
2764    if k == 0 {
2765        return None;
2766    }
2767    let lambda_max = reduced_schur_lambda_max(
2768        sys,
2769        htt_factors,
2770        ridge_beta,
2771        backend,
2772        resident,
2773        gpu_matvec,
2774        power_iters,
2775        seed,
2776    )?;
2777    // λ_min from the deflation floor: after unit-deflation the operative spectrum
2778    // is bounded below by `SPECTRAL_DEFLATION_REL_FLOOR·λ_max` (or 1.0), so this
2779    // is a sound lower bracket for the quadrature window sizing. The window is
2780    // padded two decades below `λ_min` inside `RationalLogdetPlan::build`, so a
2781    // conservative (too-small) floor only widens the resolved range, never biases
2782    // the estimate.
2783    let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
2784    let plan = RationalLogdetPlan::build(k, num_probes, seed, lambda_min, lambda_max, rel_tol)?;
2785    // One resident operator; the plan's shift ladder reuses it across every
2786    // shifted solve. The probes fan across rayon workers (in `evaluate`), and
2787    // `schur_matvec`'s own row parallelism is guarded off inside a worker, so
2788    // there is no nested oversubscription.
2789    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2790        .with_gpu_matvec(gpu_matvec);
2791    let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2792    // #2576: the exact diag(S) is reachable from `resident` and measured NOT to
2793    // reduce iterations — see the refutation note at the surrogate-core call site.
2794    let precond = reduced_schur_shifted_preconditioner(sys, ridge_beta);
2795    let eval = plan.evaluate_family_preconditioned(&matvec, &precond, cg_rel_tol, cg_max_iters)?;
2796    Some((plan, eval))
2797}
2798
2799/// Build the FROZEN #2080 surrogate plan for one outer solve, with the Hutch++
2800/// deflation rank DERIVED from a pilot evaluation — the build-once companion to
2801/// per-ρ [`RationalLogdetPlan::evaluate`]. Returns the plan (probes +
2802/// quadrature + frozen Hutch++ `Q`) together with the certified evaluation that
2803/// selected its rank. The entry evaluation is the first criterion value and
2804/// derivative payload: discarding it and immediately evaluating the same plan,
2805/// operator, and preconditioner would repeat the whole shifted-PCG ladder. Later
2806/// ρ values evaluate the frozen plan normally, so rank derivation remains a
2807/// once-per-outer-solve cost.
2808///
2809/// Derived rank (the #2080 lead ruling): a rank-0 pilot fixes the log-det scale,
2810/// the target bar is `deflation_target_std_err_rel · (|log|S|_pilot| + 1)` — one
2811/// order under the smallest tolerance the criterion feeds (the caller passes
2812/// `0.1 · STALL_REL_TOL`; `log|S|` is the criterion's dominant term at wide `k`
2813/// so `|log|S||+1` is the right objective scale to `O(1)` and the `0.1` margin
2814/// absorbs the loss/Occam remainder). The peel rank grows on a doubling schedule
2815/// until the Hutchinson error bar clears the target. `deflation_max_rank` is a
2816/// resource-admission ceiling, not permission to return an under-certified
2817/// estimate: exhausting it before the bar clears returns `None` and the caller
2818/// surfaces a typed evidence failure. `deflation_max_rank == 0` explicitly
2819/// requests the bare-Hutchinson plan; a pilot already under target also returns
2820/// it. Deterministic for fixed inputs (`Q` and probes are seed-derived). The
2821/// returned plan's `Q` is FROZEN, so
2822/// `RationalLogdetPlan::directional_derivative` on its evaluations is the exact
2823/// surrogate gradient.
2824pub struct DerivedRationalLogdetPlan {
2825    /// Frozen statistical plan selected at the entry operator.
2826    pub plan: RationalLogdetPlan,
2827    /// Certified value and shifted solves already computed while selecting the
2828    /// plan, consumed as the entry value and derivative payload.
2829    pub entry_evaluation: RationalLogdetEval,
2830}
2831
2832pub fn rational_reduced_schur_plan_derived<B: BatchedBlockSolver + Sync>(
2833    sys: &ArrowSchurSystem,
2834    htt_factors: &ArrowFactorSlab,
2835    ridge_beta: f64,
2836    backend: &B,
2837    resident: Option<&SaeResidentReducedSchur>,
2838    gpu_matvec: Option<&GpuSchurMatvec>,
2839    num_probes: usize,
2840    seed: u64,
2841    rel_tol: f64,
2842    power_iters: usize,
2843    cg_rel_tol: f64,
2844    cg_max_iters: usize,
2845    deflation_max_rank: usize,
2846    deflation_subspace_iters: usize,
2847    deflation_target_std_err_rel: f64,
2848) -> Result<DerivedRationalLogdetPlan, String> {
2849    let k = sys.k;
2850    if k == 0
2851        || !(cg_rel_tol.is_finite() && cg_rel_tol > 0.0 && cg_rel_tol < 1.0)
2852        || !(deflation_target_std_err_rel.is_finite() && deflation_target_std_err_rel >= 0.0)
2853    {
2854        return Err(format!(
2855            "inadmissible surrogate request: reduced Schur dim {k}, cg_rel_tol {cg_rel_tol:.3e} \
2856             (needs 0 < tol < 1), deflation target {deflation_target_std_err_rel:.3e} (needs \
2857             finite and non-negative)"
2858        ));
2859    }
2860    let lambda_max = reduced_schur_lambda_max(
2861        sys,
2862        htt_factors,
2863        ridge_beta,
2864        backend,
2865        resident,
2866        gpu_matvec,
2867        power_iters,
2868        seed,
2869    )
2870    .ok_or_else(|| {
2871        format!(
2872            "spectral bracket unavailable: the power iteration produced no finite λ_max for \
2873             reduced Schur dim {k} in {power_iters} iterations"
2874        )
2875    })?;
2876    let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
2877    let base_plan = RationalLogdetPlan::build(
2878        k, num_probes, seed, lambda_min, lambda_max, rel_tol,
2879    )
2880    .ok_or_else(|| {
2881        format!(
2882            "quadrature plan unbuildable on bracket [{lambda_min:.6e}, {lambda_max:.6e}] at \
2883             rel_tol {rel_tol:.3e} with {num_probes} probes (reduced Schur dim {k})"
2884        )
2885    })?;
2886    // One resident operator across the pilot, every deflation re-solve, and the
2887    // subspace-iteration `with_two_sided_deflation` applies — the whole rank-derivation
2888    // ladder (the two-sided deflation: block-power on S + inverse subspace
2889    // iteration on S⁻¹) reuses the same staged residency / device `S·v`.
2890    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
2891        .with_gpu_matvec(gpu_matvec);
2892    let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
2893    // The SAME shared-block diagonal every evaluation of this plan will use, so
2894    // the rank-derivation ladder is not measuring a differently-conditioned
2895    // iteration from the one production runs. (#2576: the exact diag(S) was
2896    // measured here and does not help — see the surrogate-core call site.)
2897    let precond = reduced_schur_shifted_preconditioner(sys, ridge_beta);
2898    // Rank-0 pilot: fixes the |log|S|| scale and is the answer outright when no
2899    // deflation is requested or the bare bar already clears the target.
2900    // `log|S|` exists only for a positive-definite `S`, and every shifted solve
2901    // below is a conjugate-gradient recurrence that assumes it. Nothing checked
2902    // that assumption: the bracket's LOWER end is not measured, it is set to a
2903    // fixed fraction of the estimated `lambda_max`, so an operator whose
2904    // spectrum reaches below zero was planned for as if it did not, and the
2905    // first thing to notice was a CG breakdown tens of iterations in, reported
2906    // as "no finite solution". Measured on gam#2731: `pᵀ(A+σI)p = -1.50e10` at
2907    // seed shift `4.2e-15`, on a fit that had already converged.
2908    //
2909    // A quadratic form is a ONE-SIDED certificate: `vᵀ(S + t_lo·I)v <= 0` proves
2910    // the operator is indefinite on this bracket, while a positive value over
2911    // finitely many probes proves nothing. So this refuses when it fires and is
2912    // silent otherwise — the breakdown path still catches what it misses, and
2913    // now names itself. The probes are the plan's own, so this costs one extra
2914    // operator application each and introduces no new randomness.
2915    let seed_shift = base_plan
2916        .nodes
2917        .iter()
2918        .map(|(t, _)| *t)
2919        .filter(|t| t.is_finite())
2920        .fold(f64::INFINITY, f64::min);
2921    if seed_shift.is_finite() {
2922        for (index, probe) in base_plan.probes.iter().enumerate() {
2923            let norm_sq = probe.dot(probe);
2924            if !(norm_sq > 0.0) {
2925                continue;
2926            }
2927            let mut shifted = matvec(probe.view());
2928            shifted.scaled_add(seed_shift, probe);
2929            let form = probe.dot(&shifted);
2930            if !(form.is_finite() && form > 0.0) {
2931                // #2731 — a probe proves indefiniteness but names no direction,
2932                // and a direction is the only thing an escape can use. The
2933                // spectrum's own most-negative mode is a shifted Lanczos away
2934                // (the shift is the `λ_max` this plan already estimated), and
2935                // the arrow elimination lifts it into a full-space
2936                // displacement, so the refusal reports what descends rather
2937                // than only that something does.
2938                let escape = reduced_schur_negative_curvature(
2939                    sys,
2940                    htt_factors,
2941                    ridge_beta,
2942                    backend,
2943                    resident,
2944                    gpu_matvec,
2945                    lambda_max,
2946                    power_iters,
2947                    seed,
2948                )
2949                .map(|found| {
2950                    format!(
2951                        " The spectrum's most-negative direction is vᵀSv = {:.6e} (Ritz {:.6e}                          under the fold σ = {:.6e}); the arrow elimination lifts it to a                          full-space displacement of {} eliminated coordinates, whose curvature                          is that same number by the Schur identity — so the descent direction                          is available, not merely implied.",
2952                        found.curvature,
2953                        found.ritz_eigenvalue,
2954                        found.shift,
2955                        found.eliminated.len(),
2956                    )
2957                })
2958                .unwrap_or_else(|| {
2959                    " The shifted Lanczos did not certify a negative eigenpair within its                      step budget, so the probe above is the whole of the evidence."
2960                        .to_string()
2961                });
2962                return Err(format!(
2963                    "the reduced Schur is not positive definite on this bracket, so log|S| is \
2964                     not defined at this iterate: probe {index} gives \
2965                     vᵀ(S + {seed_shift:.6e}·I)v = {form:.6e} with ‖v‖² = {norm_sq:.6e} \
2966                     (reduced Schur dim {k}, bracket [{lambda_min:.6e}, {lambda_max:.6e}] whose \
2967                     lower end is SPECTRAL_DEFLATION_REL_FLOOR × λ_max, not a measured \
2968                     eigenvalue). A converged fit reaching here has converged to a point with \
2969                     negative curvature in the reduced Schur, which is a statement about the \
2970                     iterate, not about the surrogate.{escape}"
2971                ));
2972            }
2973        }
2974    }
2975    let pilot = base_plan
2976        .evaluate_family_preconditioned(&matvec, &precond, cg_rel_tol, cg_max_iters)
2977        .ok_or_else(|| {
2978            format!(
2979                "rank-0 pilot solve broke down: the shifted-CG family did not return a finite \
2980                 solution on the bracket [{lambda_min:.6e}, {lambda_max:.6e}] at cg_rel_tol \
2981                 {cg_rel_tol:.3e}, cg_max_iters {cg_max_iters} (reduced Schur dim {k}). The \
2982                 seed system's own budget is min(cg_max_iters, dim) = {} iterations. The \
2983                 one-sided definiteness probe above did not fire, so this is either an \
2984                 indefiniteness those probes missed or a genuine loss of accuracy; the \
2985                 `[rational-logdet] shifted-CG seed breakdown` line says which.",
2986                cg_max_iters.min(k.max(1))
2987            )
2988        })?;
2989    if deflation_max_rank == 0 {
2990        return Ok(DerivedRationalLogdetPlan {
2991            plan: base_plan,
2992            entry_evaluation: pilot,
2993        });
2994    }
2995    let target = deflation_target_std_err_rel * (pilot.estimate.abs() + 1.0);
2996    if pilot.std_err <= target {
2997        return Ok(DerivedRationalLogdetPlan {
2998            plan: base_plan,
2999            entry_evaluation: pilot,
3000        });
3001    }
3002    let pilot_std_err = pilot.std_err;
3003    // Grow from the smallest nonzero peel rank (doubling ⇒ log-many re-solves)
3004    // until the bar clears. The caller's cap is a resource ceiling; reaching it
3005    // with an over-target bar refuses the surrogate rather than silently
3006    // weakening the requested statistical-accuracy contract.
3007    let cap = deflation_max_rank.min(k);
3008    let mut rank = 1usize;
3009    // Basis iteration only steers Q for variance reduction. Derive its looser
3010    // true-residual tolerance from the evaluation solve's tolerance instead of
3011    // carrying an unrelated fixed knob: √tol is strictly looser while still
3012    // converging as the bottom-tail builder now requires.
3013    let basis_cg_rel_tol = cg_rel_tol.sqrt();
3014    loop {
3015        let r = rank.min(cap);
3016        // Split the peel budget across BOTH spectral tails at equal total rank:
3017        // the Hutchinson bar rides on ‖offdiag(P log(S/c) P)‖_F, whose mass sits
3018        // symmetrically on the λ_max AND λ_min tails (|log(λ/c)| peaks equally at
3019        // both ends of the bracket since c is its geometric midpoint), so top-only
3020        // deflation stalls at ~½ the removable variance
3021        // (`two_sided_deflation_drops_wide_kappa_std_err_below_two_percent`).
3022        // The bottom-tail basis comes from inverse iteration — CG on the UNSHIFTED
3023        // operator at full κ — so it gets its own LOOSE budget, not the
3024        // evaluation-grade `cg_rel_tol`: an approximate bottom `Q` only relaxes
3025        // the variance reduction, never biases the value (the split is exact for
3026        // any orthonormal `Q`), while an evaluation-grade solve there would burn
3027        // √κ-scale iterations per basis column for no accuracy in return.
3028        let plan = base_plan.clone().with_two_sided_deflation_preconditioned(
3029            &matvec,
3030            &precond,
3031            r.div_ceil(2),
3032            r / 2,
3033            deflation_subspace_iters,
3034            seed,
3035            (basis_cg_rel_tol, cg_max_iters),
3036        )
3037        .ok_or_else(|| {
3038            format!(
3039                "two-sided deflation basis unbuildable at rank {r} (top {}, bottom {}) after \
3040                 {deflation_subspace_iters} subspace iterations on reduced Schur dim {k}",
3041                r.div_ceil(2),
3042                r / 2
3043            )
3044        })?;
3045        let eval = plan
3046            .evaluate_family_preconditioned(&matvec, &precond, cg_rel_tol, cg_max_iters)
3047            .ok_or_else(|| {
3048                format!(
3049                    "deflated solve broke down at rank {r}: the shifted-CG family did not return \
3050                     a finite solution at cg_rel_tol {cg_rel_tol:.3e} (reduced Schur dim {k})"
3051                )
3052            })?;
3053        if eval.std_err <= target {
3054            return Ok(DerivedRationalLogdetPlan {
3055                plan,
3056                entry_evaluation: eval,
3057            });
3058        }
3059        if r >= cap {
3060            // The resource ceiling, reached with an over-target bar. This is a
3061            // deliberate refusal rather than a silent weakening of the accuracy
3062            // contract — but a refusal that names only its dimension cannot be
3063            // acted on, and this one aborts a fit that has already converged.
3064            // Every number the caller needs to decide whether to raise the cap,
3065            // relax the target, or take the estimate as it stands is here.
3066            return Err(format!(
3067                "deflation reached its rank ceiling {cap} (requested {deflation_max_rank}, \
3068                 reduced Schur dim {k}) with the Hutchinson bar still over target: std_err \
3069                 {:.6e} against target {target:.6e} (= {deflation_target_std_err_rel:.3e} × \
3070                 (|estimate| + 1)), estimate {:.6e}; the rank-0 pilot's bar was \
3071                 {pilot_std_err:.6e}, so deflation removed {:.1}% of the pilot variance and \
3072                 needed {:.1}%",
3073                eval.std_err,
3074                eval.estimate,
3075                100.0 * (1.0 - eval.std_err / pilot_std_err.max(f64::MIN_POSITIVE)),
3076                100.0 * (1.0 - target / pilot_std_err.max(f64::MIN_POSITIVE)),
3077            ));
3078        }
3079        rank = rank.saturating_mul(2);
3080    }
3081}
3082
3083/// One tier of the evidence lane's preconditioner study: what the SAME frozen
3084/// surrogate cost, and produced, under one preconditioner.
3085#[derive(Debug, Clone, Copy, PartialEq)]
3086pub struct ReducedSchurLogdetPrecondRow {
3087    pub preconditioner: ReducedSchurCgPreconditioner,
3088    /// The surrogate value `L̃ ≈ log|S|`.
3089    pub log_det: f64,
3090    /// The surrogate's own Hutchinson error bar on that value.
3091    pub std_err: f64,
3092    /// Total shifted-CG iterations across the whole probe × node ladder.
3093    pub cg_iterations: usize,
3094}
3095
3096/// Evidence-lane preconditioner study: evaluate ONE frozen rational
3097/// log-determinant plan on one reduced Schur, once per preconditioner tier, and
3098/// report what each cost.
3099///
3100/// This is the evidence-side companion to
3101/// `arrow_precond_ladder_iteration_study`, which does the same for the Newton
3102/// PCG ladder. It exists because the two questions are different: the Newton
3103/// ladder asks which preconditioner solves a STEP fastest, while this asks what
3104/// the log-determinant's shifted-solve ladder costs — and before #2576 that
3105/// second question had no answer at all, because nothing measured it and the
3106/// solves were unpreconditioned.
3107///
3108/// Both tiers evaluate the SAME plan (same probes, same quadrature nodes, same
3109/// deflation basis) against the SAME operator, so their `log_det` values must
3110/// agree to solve accuracy: the preconditioner steers the iteration and cannot
3111/// move the functional. That agreement is the study's built-in self-check —
3112/// a tier that changed the value would be a bug in the preconditioner, not a
3113/// measurement.
3114///
3115/// `None` when the spectral bracket or the plan cannot be built, or a shifted
3116/// solve breaks down.
3117pub fn reduced_schur_logdet_preconditioner_study<B: BatchedBlockSolver + Sync>(
3118    sys: &ArrowSchurSystem,
3119    htt_factors: &ArrowFactorSlab,
3120    ridge_beta: f64,
3121    backend: &B,
3122    num_probes: usize,
3123    seed: u64,
3124    rel_tol: f64,
3125    power_iters: usize,
3126    cg_rel_tol: f64,
3127    cg_max_iters: usize,
3128) -> Option<Vec<ReducedSchurLogdetPrecondRow>> {
3129    if sys.k == 0 {
3130        return None;
3131    }
3132    let lambda_max = reduced_schur_lambda_max(
3133        sys,
3134        htt_factors,
3135        ridge_beta,
3136        backend,
3137        None,
3138        None,
3139        power_iters,
3140        seed,
3141    )?;
3142    let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
3143    let plan =
3144        RationalLogdetPlan::build(sys.k, num_probes, seed, lambda_min, lambda_max, rel_tol)?;
3145    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, None);
3146    let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
3147    let shared_block = reduced_schur_shifted_preconditioner(sys, ridge_beta);
3148    let mut tiers = vec![
3149        (
3150            ReducedSchurCgPreconditioner::Identity,
3151            ShiftedDiagonalPreconditioner::identity(),
3152        ),
3153        (
3154            ReducedSchurCgPreconditioner::SharedBlockDiagonal,
3155            shared_block,
3156        ),
3157    ];
3158    // The operator is the SAME across tiers (built above with no residency); the
3159    // residency here serves only the exact-diagonal tier's preconditioner build,
3160    // so the study still compares every tier against ONE iteration.
3161    //
3162    // Emitted only when a residency exists. Without one
3163    // `reduced_schur_shifted_preconditioner_resident` returns the shared-block
3164    // diagonal itself, and a tier that is a verbatim duplicate of the row above
3165    // it would report a second measurement of the same thing under a name that
3166    // claims otherwise.
3167    if let Some(resident) = SaeResidentReducedSchur::build(sys, htt_factors, backend) {
3168        tiers.push((
3169            ReducedSchurCgPreconditioner::SchurDiagonal,
3170            reduced_schur_shifted_preconditioner_resident(sys, ridge_beta, Some(&resident)),
3171        ));
3172    }
3173    let mut out = Vec::with_capacity(tiers.len());
3174    for (kind, preconditioner) in tiers {
3175        let eval = plan.evaluate_preconditioned(&matvec, &preconditioner, cg_rel_tol, cg_max_iters)?;
3176        out.push(ReducedSchurLogdetPrecondRow {
3177            preconditioner: kind,
3178            log_det: eval.estimate,
3179            std_err: eval.std_err,
3180            cg_iterations: eval.cg_iterations,
3181        });
3182    }
3183    Some(out)
3184}
3185
3186/// What one quadrature node of the shifted-solve ladder cost, and what the
3187/// operator looked like there.
3188#[derive(Debug, Clone)]
3189pub struct ShiftLadderNodeProfile {
3190    /// Position of this node in the ladder's DESCENDING walk (0 = largest shift,
3191    /// solved first and cold; the rest are warm-started from their predecessor).
3192    pub ladder_position: usize,
3193    /// The shift `t_ℓ`.
3194    pub shift: f64,
3195    /// The quadrature weight `w_ℓ`.
3196    pub weight: f64,
3197    /// Iterations summed over every right-hand side solved at this node.
3198    pub iterations: usize,
3199    /// Largest single-solve iteration count at this node.
3200    pub max_solve_iterations: usize,
3201    /// Ritz condition estimate `θ_max/θ_min` of the PRECONDITIONED shifted
3202    /// operator, read off the CG coefficients of this node's longest solve. `None`
3203    /// when that solve converged before resolving two Ritz values.
3204    pub krylov_condition: Option<f64>,
3205}
3206
3207/// The complete work profile of one rational log-determinant evaluation, node by
3208/// node, plus the residual history of its single most expensive solve.
3209///
3210/// This is the #2576 discriminator. The issue's headline evidence — "loosening
3211/// the CG tolerance from 1e-8 to 1e-4 changes nothing" — is equally consistent
3212/// with a solve stagnating at its iteration cap and a solve converging so fast
3213/// that four decades of tolerance cost a handful of iterations, and those need
3214/// opposite repairs. The two are told apart by the residual CURVE (geometric
3215/// decay versus a flat line) and by the Ritz spectrum the same solve hands over
3216/// for free. Neither existed before: the evaluation reported one summed
3217/// iteration count and nothing else.
3218#[derive(Debug, Clone)]
3219pub struct ShiftLadderProfile {
3220    /// One row per quadrature node, in ladder (descending-shift) order.
3221    pub nodes: Vec<ShiftLadderNodeProfile>,
3222    /// The surrogate value this evaluation produced.
3223    pub log_det: f64,
3224    /// Its Hutchinson error bar.
3225    pub std_err: f64,
3226    /// Iterations over the whole ladder — the quantity the existing
3227    /// [`reduced_schur_logdet_preconditioner_study`] reports as a single number.
3228    pub total_iterations: usize,
3229    /// Full residual and coefficient history of the ladder's most expensive
3230    /// single solve. This one is WARM-STARTED from the node above it, so its
3231    /// curve begins wherever the previous shift's solution left it.
3232    pub hardest_solve: ShiftedPcgTrace,
3233    /// One COLD solve at the ladder's smallest shift, from a zero start, on the
3234    /// first probe.
3235    ///
3236    /// This is the honest price of the family: the smallest shift is the
3237    /// worst-conditioned member, and a Krylov space built from the right-hand
3238    /// side alone — no warm start — is what any evaluator that serves all shifts
3239    /// from ONE space must pay. It is also the trace whose residual curve is
3240    /// interpretable, since it starts at `‖r‖/‖b‖ = 1` rather than wherever the
3241    /// previous node's solution happened to land.
3242    pub cold_seed_solve: ShiftedPcgTrace,
3243    /// The same cold seed solve with NO diagonal, i.e. on the raw operator.
3244    ///
3245    /// A shifted family shares its Krylov space only when nothing shift-dependent
3246    /// is applied to it, and this module's diagonal is `1/(d + t)` — shift
3247    /// dependent by construction. So the two cold traces price the two ways to
3248    /// serve the family from one space: rescale the operator by its diagonal ONCE
3249    /// (and carry `Σ ln d_g` in the value), or keep the operator and pay the raw
3250    /// conditioning. Which is cheaper is a measurement, not an argument.
3251    pub cold_seed_solve_undiagonalized: ShiftedPcgTrace,
3252    /// `|vᵀSw − wᵀSv| / (‖Sv‖·‖w‖)` on a deterministic probe pair. CG is only
3253    /// valid on a symmetric operator, so a non-negligible value here means no
3254    /// preconditioner can help and the algorithm itself is wrong for the problem.
3255    pub symmetry_defect: f64,
3256    /// The `[λ_min, λ_max]` bracket the plan was sized from. `λ_min` is the
3257    /// deflation-floor convention `SPECTRAL_DEFLATION_REL_FLOOR·λ_max`, i.e. an
3258    /// ASSUMED lower bound, not a measurement — comparing it against
3259    /// `hardest_solve`'s smallest Ritz value is how one sees whether the
3260    /// quadrature window is sized for a spectrum the operator does not have.
3261    pub bracket: (f64, f64),
3262}
3263
3264impl ShiftLadderProfile {
3265    /// Iterations of the single hardest solve, against the whole ladder's total.
3266    ///
3267    /// A shifted family `(S + t_ℓ I)` spans ONE Krylov space for every `t_ℓ`, so
3268    /// a multi-shift Krylov evaluator would pay the hardest solve and get the
3269    /// rest as vector updates. This ratio is exactly what such a change could
3270    /// win, and it is a measurement rather than an argument.
3271    #[must_use]
3272    pub fn ladder_concentration(&self) -> f64 {
3273        let hardest = self.hardest_solve.iterations().max(1) as f64;
3274        self.total_iterations as f64 / hardest
3275    }
3276
3277    /// The applies ONE right-hand side may cost, given the operator's own
3278    /// conditioning and the plan's own node count:
3279    ///
3280    /// ```text
3281    /// ½·√κ·ln(2/rel_tol)  +  node_count
3282    /// ```
3283    ///
3284    /// — the textbook CG bound for a single solve at the conditioning `κ` the
3285    /// cold seed measured, plus one certification apply per node. Nothing here is
3286    /// chosen: `κ` is read off the seed's Ritz values and the node count is the
3287    /// plan's, so a better-conditioned operator or a coarser quadrature moves the
3288    /// budget on its own.
3289    ///
3290    /// `κ` is the UNDIAGONALIZED seed's, because that is the space a family
3291    /// evaluator can actually share: the diagonal here is `1/(diag(S) + t)` and
3292    /// anything shift-dependent destroys the shift invariance the one-space
3293    /// argument rests on. The diagonal remains available to the single-shift
3294    /// repair path, where one fixed `t` makes it a preconditioner again.
3295    ///
3296    /// `None` when the cold seed resolved no spectrum.
3297    #[must_use]
3298    pub fn one_krylov_space_apply_budget(&self) -> Option<f64> {
3299        Some(
3300            self.cold_seed_solve_undiagonalized
3301                .conditioning_iteration_bound()?
3302                + self.nodes.len() as f64,
3303        )
3304    }
3305}
3306
3307/// Profile one evaluation of the evidence lane's frozen rational
3308/// log-determinant plan: what every quadrature node cost, and what the operator
3309/// looked like at the node that cost the most.
3310///
3311/// Builds the plan, operator and preconditioner exactly as
3312/// [`rational_reduced_schur_log_det`] does — same bracket, same probes, same
3313/// nodes, same shared-block diagonal — and then evaluates it through a recording
3314/// shifted solver. `RationalLogdetPlan::evaluate_with_shifted_solver` is the seam
3315/// that makes this possible without a second copy of the ladder: the statistical
3316/// functional is untouched and only the numerical inverse is instrumented, so
3317/// the `log_det` this reports is the one production computes.
3318///
3319/// `None` on the same conditions as [`rational_reduced_schur_log_det`].
3320pub fn reduced_schur_logdet_shift_ladder_profile<B: BatchedBlockSolver + Sync>(
3321    sys: &ArrowSchurSystem,
3322    htt_factors: &ArrowFactorSlab,
3323    ridge_beta: f64,
3324    backend: &B,
3325    resident: Option<&SaeResidentReducedSchur>,
3326    num_probes: usize,
3327    seed: u64,
3328    rel_tol: f64,
3329    power_iters: usize,
3330    cg_rel_tol: f64,
3331    cg_max_iters: usize,
3332) -> Option<ShiftLadderProfile> {
3333    let k = sys.k;
3334    if k == 0 {
3335        return None;
3336    }
3337    let lambda_max = reduced_schur_lambda_max(
3338        sys,
3339        htt_factors,
3340        ridge_beta,
3341        backend,
3342        resident,
3343        None,
3344        power_iters,
3345        seed,
3346    )?;
3347    let lambda_min = (SPECTRAL_DEFLATION_REL_FLOOR * lambda_max).max(f64::MIN_POSITIVE);
3348    let plan = RationalLogdetPlan::build(k, num_probes, seed, lambda_min, lambda_max, rel_tol)?;
3349    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident);
3350    let matvec = |v: ArrayView1<f64>| -> Array1<f64> { op.apply(v) };
3351    let precond = reduced_schur_shifted_preconditioner(sys, ridge_beta);
3352
3353    // (b) in the #2576 fault taxonomy: CG on a non-symmetric operator cannot be
3354    // rescued by any preconditioner. Two deterministic probes, no RNG plumbing:
3355    // an alternating-sign vector and a linear ramp are not related by any
3356    // symmetry of an arrow system, so `vᵀSw = wᵀSv` here is a real test.
3357    let mut v = Array1::<f64>::zeros(k);
3358    let mut w = Array1::<f64>::zeros(k);
3359    for index in 0..k {
3360        v[index] = if index % 2 == 0 { 1.0 } else { -1.0 };
3361        w[index] = (index as f64 + 1.0) / (k as f64);
3362    }
3363    let sv = matvec(v.view());
3364    let sw = matvec(w.view());
3365    let scale = (sv.dot(&sv).sqrt() * w.dot(&w).sqrt()).max(f64::MIN_POSITIVE);
3366    let symmetry_defect = (w.dot(&sv) - v.dot(&sw)).abs() / scale;
3367
3368    // `(ladder_position, shift, iterations-per-solve, trace of the longest solve)`
3369    // accumulated by the recording solver. The ladder walks nodes in descending
3370    // shift order and every solve at one node happens before the next node's, so
3371    // the recorded order IS the ladder position order.
3372    type LadderRecord = (f64, Vec<usize>, ShiftedPcgTrace);
3373    let recorded: std::sync::Mutex<Vec<LadderRecord>> = std::sync::Mutex::new(Vec::new());
3374    let solve = |shift: f64, rhs: &Array1<f64>, warm: &Array1<f64>| {
3375        let (outcome, trace) =
3376            shifted_pcg_traced(&matvec, &precond, shift, rhs, warm, cg_rel_tol, cg_max_iters);
3377        let iterations = trace.iterations();
3378        let mut log = recorded.lock().ok()?;
3379        match log.last_mut() {
3380            Some(entry) if entry.0 == shift => {
3381                entry.1.push(iterations);
3382                if iterations > entry.2.iterations() {
3383                    entry.2 = trace;
3384                }
3385            }
3386            _ => log.push((shift, vec![iterations], trace)),
3387        }
3388        drop(log);
3389        outcome
3390    };
3391    let eval = plan.evaluate_with_shifted_solver(&solve)?;
3392    let log = recorded.into_inner().ok()?;
3393
3394    // The cold seed: the ladder's SMALLEST shift, first probe, zero start. Every
3395    // solve above was warm-started from the node before it, so none of them
3396    // prices what a single Krylov space costs from scratch — which is exactly
3397    // the quantity a one-space evaluator would pay, and the only trace whose
3398    // residual curve starts at 1 and is therefore readable as a convergence
3399    // history.
3400    let seed_shift = plan
3401        .nodes
3402        .iter()
3403        .map(|(t, _)| *t)
3404        .fold(f64::INFINITY, f64::min);
3405    let cold_start = Array1::<f64>::zeros(k);
3406    let (_, cold_seed_solve) = shifted_pcg_traced(
3407        &matvec,
3408        &precond,
3409        seed_shift,
3410        plan.probes.first()?,
3411        &cold_start,
3412        cg_rel_tol,
3413        cg_max_iters,
3414    );
3415    let (_, cold_seed_solve_undiagonalized) = shifted_pcg_traced(
3416        &matvec,
3417        &ShiftedDiagonalPreconditioner::identity(),
3418        seed_shift,
3419        plan.probes.first()?,
3420        &cold_start,
3421        cg_rel_tol,
3422        cg_max_iters,
3423    );
3424
3425    let weight_of = |shift: f64| -> f64 {
3426        plan.nodes
3427            .iter()
3428            .find(|(t, _)| *t == shift)
3429            .map(|(_, w)| *w)
3430            .unwrap_or(f64::NAN)
3431    };
3432    let mut hardest = ShiftedPcgTrace::default();
3433    let mut nodes = Vec::with_capacity(log.len());
3434    let mut total_iterations = 0usize;
3435    for (ladder_position, (shift, per_solve, trace)) in log.into_iter().enumerate() {
3436        let iterations: usize = per_solve.iter().sum();
3437        total_iterations += iterations;
3438        if trace.iterations() > hardest.iterations() {
3439            hardest = trace.clone();
3440        }
3441        nodes.push(ShiftLadderNodeProfile {
3442            ladder_position,
3443            shift,
3444            weight: weight_of(shift),
3445            iterations,
3446            max_solve_iterations: per_solve.iter().copied().max().unwrap_or(0),
3447            krylov_condition: trace.krylov_condition_estimate(),
3448        });
3449    }
3450    Some(ShiftLadderProfile {
3451        nodes,
3452        log_det: eval.estimate,
3453        std_err: eval.std_err,
3454        total_iterations,
3455        hardest_solve: hardest,
3456        cold_seed_solve,
3457        cold_seed_solve_undiagonalized,
3458        symmetry_defect,
3459        bracket: (lambda_min, lambda_max),
3460    })
3461}
3462
3463/// Convergence certificate for one matrix-free reduced-Schur CG solve.
3464///
3465/// The evidence lane's `S⁻¹`-apply used to return its iterate with no way to
3466/// tell a converged solve from one truncated at `max_iters`: a stagnating CG
3467/// handed back an arbitrarily-wrong `S⁻¹b`, and every downstream trace /
3468/// log-determinant estimate inherited that error SILENTLY (#2576 — a 4096-cap
3469/// truncation invisible behind six minutes of no log output). The solve now
3470/// carries what it achieved so consumers can refuse, escalate, or report
3471/// instead of re-deriving it from nothing.
3472#[derive(Debug, Clone, Copy, PartialEq)]
3473pub struct ReducedSchurCgReport {
3474    /// CG iterations actually taken.
3475    pub iterations: usize,
3476    /// Iteration cap the solve ran under.
3477    pub max_iterations: usize,
3478    /// `‖b − S y‖ / ‖b‖` at the returned iterate.
3479    pub relative_residual: f64,
3480    /// Relative-residual target the solve was asked for.
3481    pub tolerance: f64,
3482    /// Which preconditioner steered the iteration.
3483    pub preconditioner: ReducedSchurCgPreconditioner,
3484}
3485
3486impl ReducedSchurCgReport {
3487    /// True iff the returned iterate met the requested relative-residual bound.
3488    /// A `false` here means the iterate is a TRUNCATION, not a solve.
3489    pub fn converged(&self) -> bool {
3490        self.relative_residual <= self.tolerance
3491    }
3492
3493    /// Merge two certificates into the weaker of the pair, so a bundle of
3494    /// solves reports its LEAST converged member rather than its best.
3495    pub fn weaker(self, other: Self) -> Self {
3496        let self_slack = self.relative_residual / self.tolerance.max(f64::MIN_POSITIVE);
3497        let other_slack = other.relative_residual / other.tolerance.max(f64::MIN_POSITIVE);
3498        if other_slack > self_slack { other } else { self }
3499    }
3500}
3501
3502/// Which preconditioner a reduced-Schur CG solve ran with.
3503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3504pub enum ReducedSchurCgPreconditioner {
3505    /// No usable diagonal was available; the iteration ran on the raw operator.
3506    Identity,
3507    /// `diag(H_ββ + ridge)` — the shared block's own diagonal, read straight
3508    /// off the assembled system at zero build cost.
3509    SharedBlockDiagonal,
3510    /// The EXACT reduced-Schur diagonal
3511    /// `diag(H_ββ) + ridge − Σ_i diag(H_βt^(i)(H_tt^(i))⁻¹H_tβ^(i))`, built from
3512    /// the SAE resident factors. See `resident_schur_elimination_diagonal` for
3513    /// why this is affordable here when the generic column probe is not
3514    /// (#2576).
3515    SchurDiagonal,
3516}
3517
3518/// Diagonal preconditioner for the matrix-free reduced-Schur CG.
3519///
3520/// `S = (H_ββ + ρ_β I) − Σ_i H_βt^(i)(H_tt^(i))⁻¹H_tβ^(i)` is SPD, and its
3521/// diagonal spans whatever range the shared block's diagonal spans. On the
3522/// overcomplete SAE border that range is the atom FIRING-COUNT distribution:
3523/// `H_ββ`'s per-atom diagonal accumulates `Σ_{i ∋ k} φ_i,b²` over the rows on
3524/// atom `k`'s support, so a dictionary whose atoms fire in 3 rows and 3,000
3525/// rows carries three orders of magnitude of diagonal spread. Unpreconditioned
3526/// CG's convergence rate is governed by `√κ(S)`, so that spread alone stalls
3527/// it — which is exactly the #2576 stagnation (16 probes × 3 groups × the full
3528/// 4096-iteration cap, tolerance-insensitive because the tolerance was never
3529/// the binding constraint).
3530///
3531/// Against a GENERIC cross-block operator the exact reduced-Schur diagonal needs
3532/// the point-elimination quotient `Σ_i (H_tβ^(i)e_a)ᵀ(H_tt^(i))⁻¹(H_tβ^(i)e_a)`
3533/// per column — the `O(n·K)` probe build the Newton-side scalar Jacobi pays,
3534/// which at the massive-K border costs orders of magnitude more than the solve
3535/// it would precondition. The SHARED-BLOCK diagonal is already assembled
3536/// (`hbb_diag` / `penalty_op`), so it is free, and it carries the whole
3537/// firing-count spread. It is an upper bound on the true diagonal (the
3538/// eliminated term is PSD), hence strictly positive whenever the assembled
3539/// diagonal is, and it needs no factorization.
3540///
3541/// On the SAE support lane that generic argument does NOT bind —
3542/// [`resident_schur_elimination_diagonal`] takes the exact diagonal for less
3543/// than the cost of one matvec. **It was measured and it does not help**: 5189
3544/// shifted-CG iterations against this shared block's 5138, all tiers agreeing on
3545/// `log|S|` to 10 significant figures. The reason is structural, not a tuning
3546/// accident — `diag(H_ββ)` and the eliminated term are sums over the SAME rows
3547/// with the SAME `φ²` weights, so the firing-count spread appears in both and
3548/// cancels, leaving a near-uniform rescaling that CG is invariant to. This
3549/// shared block is therefore the RIGHT preconditioner, not a cheap stand-in for
3550/// one. See
3551/// `exact_schur_diagonal_is_a_near_uniform_rescaling_of_the_shared_block_2576`,
3552/// which guards the cancellation and will fail if it ever stops holding.
3553///
3554/// This is a preconditioner, not a change of operator: PCG converges to the
3555/// same `S⁻¹b` as CG, only faster, so every downstream criterion value is
3556/// unchanged up to the residual tolerance both must meet.
3557struct ReducedSchurDiagonalPreconditioner {
3558    inverse_diagonal: Option<Array1<f64>>,
3559}
3560
3561/// The shared-block diagonal `diag(H_ββ) + ρ_β` of the reduced Schur, as the
3562/// preconditioner the SHIFTED rational-surrogate solves take.
3563///
3564/// Same diagonal, same justification as
3565/// [`ReducedSchurDiagonalPreconditioner`] — but the surrogate solves
3566/// `(S + t_ℓ I)` rather than `S`, and `diag(S + t I) = diag(S) + t`, so one
3567/// diagonal serves the entire shift ladder with the shift added per solve.
3568/// This is where the log-determinant lane's iterations actually go: `m` probes
3569/// times the quadrature's node count, every one an unshifted-to-tiny-shift CG
3570/// on the same wide-diagonal border (#2576).
3571///
3572/// Device seam: this costs no transfers even when the `S·v` apply is running on
3573/// a GPU. The shifted CG already materializes its residual host-side (the
3574/// matvec seam hands back an owned `Array1`), so the preconditioner is one
3575/// elementwise `O(k)` pass over a vector that was already there. It is also
3576/// reduction-free, hence bit-identical run to run regardless of thread count —
3577/// the property the criterion's reproducibility contract needs.
3578pub(crate) fn reduced_schur_shifted_preconditioner(
3579    sys: &ArrowSchurSystem,
3580    ridge_beta: f64,
3581) -> ShiftedDiagonalPreconditioner {
3582    match ReducedSchurDiagonalPreconditioner::shared_block_diagonal(sys, ridge_beta) {
3583        Some(diagonal) => ShiftedDiagonalPreconditioner::from_operator_diagonal(&diagonal),
3584        None => ShiftedDiagonalPreconditioner::identity(),
3585    }
3586}
3587
3588/// The EXACT diagonal of the eliminated term
3589/// `Σ_i H_βt^(i)(H_tt^(i)+ρ_t I)⁻¹H_tβ^(i)`, read off the staged SAE residency.
3590///
3591/// # Why this is affordable when the generic column probe is not (#2576)
3592///
3593/// The generic route materializes `H_tβ^(i)` column by column against `K` basis
3594/// vectors, which is what makes an exact diagonal cost `O(n·K)` and is why
3595/// [`ReducedSchurDiagonalPreconditioner`] settles for the shared block alone.
3596/// The SAE cross-block is not generic. It factors as `H_tβ^(i) = L_i P_i` with
3597/// `P_i` the support projector `(P_i)[c, base_s + c] = φ_s` and `L_i` the row's
3598/// `di × p` local Jacobian — exactly the decomposition
3599/// [`SaeResidentReducedSchur::row_into`] already applies, `S_i = P_iᵀ L_iᵀ Y_i P_i`
3600/// with `Y_i = (H_tt^(i)+ρ_t I)⁻¹L_i` ALREADY STAGED. So
3601///
3602/// ```text
3603/// diag(S_i)[base_s + c] = φ_s² · G_i[c, c],   G_i[c, c] = Σ_r L_i[r, c]·Y_i[r, c]
3604/// ```
3605///
3606/// — a column dot of two resident slabs plus a sparse scatter. The cost is
3607/// `di·p + support_i·p` per row against the matvec's `2·support_i·p + 2·di·p`:
3608/// this build is CHEAPER THAN A SINGLE MATVEC, and it is amortized over the
3609/// whole shift ladder (the quadrature's node count times every probe, each of
3610/// which runs to thousands of iterations). No factorization is added — `Y_i` is
3611/// the solve the residency already paid for.
3612///
3613/// The result is the exact diagonal, not an approximation or a bound.
3614///
3615/// Determinism: rows are accumulated in increasing index order, serially. This
3616/// is the fixed-order accumulation the #1211 exact-no-move contract wants, and
3617/// at one-matvec cost per plan build there is nothing to gain from fanning it
3618/// out.
3619///
3620/// `None` when the residency does not describe this system (degenerate `p`,
3621/// slab length mismatch, or a support base that would run off the border), in
3622/// which case the caller keeps the shared-block diagonal it already had.
3623pub(crate) fn resident_schur_elimination_diagonal(
3624    resident: &SaeResidentReducedSchur,
3625    k: usize,
3626) -> Option<Array1<f64>> {
3627    let p = resident.p;
3628    if p == 0 || k == 0 || resident.rows.len() != resident.a_phi.len() {
3629        return None;
3630    }
3631    let mut elimination = Array1::<f64>::zeros(k);
3632    let out = elimination.as_slice_mut()?;
3633    let mut g_diag = vec![0.0_f64; p];
3634    // Per-row `(base, Σφ)` with equal bases COMBINED BEFORE squaring: the
3635    // projector's coefficient on column `base + c` is the sum of every support
3636    // entry carrying that base, and it is that sum which gets squared. Summing
3637    // `φ²` instead would price a different projector than the matvec applies.
3638    let mut combined: Vec<(usize, f64)> = Vec::new();
3639    for (row, factor) in resident.rows.iter().enumerate() {
3640        let di = factor.di;
3641        let support = &resident.a_phi[row];
3642        if di == 0 || support.is_empty() {
3643            continue;
3644        }
3645        let l_i = &resident.local_jac[row];
3646        if l_i.len() != di * p || factor.y.len() != di * p {
3647            return None;
3648        }
3649        // G_i[c, c] = Σ_r L_i[r, c] · Y_i[r, c]  (the diagonal of L_iᵀ Y_i,
3650        // never the dense p×p product).
3651        for value in g_diag.iter_mut() {
3652            *value = 0.0;
3653        }
3654        for r in 0..di {
3655            let l_row = &l_i[r * p..r * p + p];
3656            let y_row = &factor.y[r * p..r * p + p];
3657            for ((value, &l), &y) in g_diag.iter_mut().zip(l_row).zip(y_row) {
3658                *value += l * y;
3659            }
3660        }
3661        combined.clear();
3662        for &(base, phi) in support.iter() {
3663            if phi == 0.0 {
3664                continue;
3665            }
3666            if base + p > k {
3667                return None;
3668            }
3669            match combined.iter_mut().find(|(seen, _)| *seen == base) {
3670                Some(entry) => entry.1 += phi,
3671                None => combined.push((base, phi)),
3672            }
3673        }
3674        for &(base, phi) in combined.iter() {
3675            let scale = phi * phi;
3676            for (value, &g) in out[base..base + p].iter_mut().zip(g_diag.iter()) {
3677                *value += scale * g;
3678            }
3679        }
3680    }
3681    Some(elimination)
3682}
3683
3684/// The shifted-ladder preconditioner built from the EXACT reduced-Schur diagonal
3685/// when an SAE residency is in hand, falling back to
3686/// [`reduced_schur_shifted_preconditioner`]'s shared-block diagonal otherwise.
3687///
3688/// **Not on any production path, deliberately.** #2576's standing thesis was
3689/// that preconditioning a reduced Schur with only its penalty block discards the
3690/// structure that makes it a Schur complement. That thesis is measurably wrong
3691/// on this lane: this exact diagonal costs 5189 shifted-CG iterations against
3692/// the shared block's 5138 (identity: 29236), because the two differ by very
3693/// nearly a uniform rescaling. It is retained as the instrument that establishes
3694/// that — the third tier of
3695/// [`reduced_schur_logdet_preconditioner_study`] — so the refutation can be
3696/// re-measured rather than re-argued.
3697///
3698/// Safeguard, per entry: `S` is SPD so its true diagonal is positive, but the
3699/// subtraction is a cancellation and a column whose curvature is almost entirely
3700/// eliminated can round to zero or below. Such an entry keeps the shared-block
3701/// value it would have had before this function existed. That is never worse
3702/// than the status quo — a preconditioner needs only to be SPD, and a per-entry
3703/// fallback keeps the whole diagonal rather than discarding it over one column.
3704pub(crate) fn reduced_schur_shifted_preconditioner_resident(
3705    sys: &ArrowSchurSystem,
3706    ridge_beta: f64,
3707    resident: Option<&SaeResidentReducedSchur>,
3708) -> ShiftedDiagonalPreconditioner {
3709    let Some(mut diagonal) =
3710        ReducedSchurDiagonalPreconditioner::shared_block_diagonal(sys, ridge_beta)
3711    else {
3712        return ShiftedDiagonalPreconditioner::identity();
3713    };
3714    let elimination = resident.and_then(|resident| {
3715        resident_schur_elimination_diagonal(resident, sys.k)
3716            .filter(|elimination| elimination.len() == diagonal.len())
3717    });
3718    if let Some(elimination) = elimination {
3719        for (value, &eliminated) in diagonal.iter_mut().zip(elimination.iter()) {
3720            let exact = *value - eliminated;
3721            if exact.is_finite() && exact > 0.0 {
3722                *value = exact;
3723            }
3724        }
3725    }
3726    ShiftedDiagonalPreconditioner::from_operator_diagonal(&diagonal)
3727}
3728
3729impl ReducedSchurDiagonalPreconditioner {
3730    /// `diag(H_ββ) + ρ_β`, or `None` when the assembled system carries no
3731    /// strictly positive finite diagonal to scale by.
3732    fn shared_block_diagonal(sys: &ArrowSchurSystem, ridge_beta: f64) -> Option<Array1<f64>> {
3733        if sys.k == 0 {
3734            return None;
3735        }
3736        let mut diag = sys.shared_block_diagonal();
3737        for value in diag.iter_mut() {
3738            *value += ridge_beta;
3739            if !(value.is_finite() && *value > 0.0) {
3740                return None;
3741            }
3742        }
3743        Some(diag)
3744    }
3745
3746    /// A shared block that assembled no diagonal (or a non-positive /
3747    /// non-finite entry, which the eliminated PSD term can only make worse) has
3748    /// nothing to scale by: fall back to the identity rather than fabricating a
3749    /// scale. `S` is still SPD, so plain CG remains correct — just slower,
3750    /// exactly as before this preconditioner existed.
3751    fn build(sys: &ArrowSchurSystem, ridge_beta: f64) -> Self {
3752        Self {
3753            inverse_diagonal: Self::shared_block_diagonal(sys, ridge_beta)
3754                .map(|diagonal| diagonal.mapv(|value| 1.0 / value)),
3755        }
3756    }
3757
3758    fn kind(&self) -> ReducedSchurCgPreconditioner {
3759        match self.inverse_diagonal {
3760            Some(_) => ReducedSchurCgPreconditioner::SharedBlockDiagonal,
3761            None => ReducedSchurCgPreconditioner::Identity,
3762        }
3763    }
3764
3765    fn apply(&self, residual: &Array1<f64>) -> Array1<f64> {
3766        match &self.inverse_diagonal {
3767            Some(inverse) => residual * inverse,
3768            None => residual.clone(),
3769        }
3770    }
3771}
3772
3773/// Preconditioned CG solve `S y = b` on the SPD reduced Schur through the
3774/// matrix-free [`schur_matvec`] apply (the `t = 0`, unshifted companion to the
3775/// surrogate's shifted solves), warm-started from `y0`. Yields `y = S⁻¹ b` —
3776/// the operator every `tr(S⁻¹·M)` gradient / adjoint channel contracts against
3777/// at massive K — together with the [`ReducedSchurCgReport`] certifying what
3778/// residual it actually reached.
3779///
3780/// `None` on a non-finite breakdown (SPD `S` ⇒ that signals a caller bug or a
3781/// non-finite operator, both of which must surface rather than be swallowed).
3782/// Running out of iterations is NOT a breakdown: the iterate is returned with
3783/// `converged() == false` so the caller decides.
3784fn reduced_schur_cg_solve<B: BatchedBlockSolver + Sync>(
3785    sys: &ArrowSchurSystem,
3786    htt_factors: &ArrowFactorSlab,
3787    ridge_beta: f64,
3788    backend: &B,
3789    resident: Option<&SaeResidentReducedSchur>,
3790    gpu_matvec: Option<&GpuSchurMatvec>,
3791    b: &Array1<f64>,
3792    y0: &Array1<f64>,
3793    cg_rel_tol: f64,
3794    cg_max_iters: usize,
3795) -> Option<(Array1<f64>, ReducedSchurCgReport)> {
3796    // One resident operator reused across every CG apply of this solve — device
3797    // seam threaded so the inverse-subspace S⁻¹·probe solves ride the resident op.
3798    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
3799        .with_gpu_matvec(gpu_matvec);
3800    let apply = |v: &Array1<f64>| -> Array1<f64> { op.apply_owned(v) };
3801    let precond = ReducedSchurDiagonalPreconditioner::build(sys, ridge_beta);
3802    let quotient = sys.beta_gauge_quotient.as_ref();
3803    // The preconditioned direction must stay in the quotient complement, or the
3804    // iteration re-injects the pinned gauge orbit the projected operator has no
3805    // curvature along. Project inside the preconditioner apply, not after it.
3806    let precondition = |residual: &Array1<f64>| -> Array1<f64> {
3807        let z = precond.apply(residual);
3808        match quotient {
3809            Some(quotient) => quotient.project_complement(z.view()),
3810            None => z,
3811        }
3812    };
3813    let b = match quotient {
3814        Some(quotient) => quotient.project_complement(b.view()),
3815        None => b.clone(),
3816    };
3817    let mut y = match quotient {
3818        Some(quotient) => quotient.project_complement(y0.view()),
3819        None => y0.clone(),
3820    };
3821    let mut r = &b - &apply(&y);
3822    let b_norm = b.dot(&b).sqrt().max(f64::MIN_POSITIVE);
3823    // One matvec buffer reused across every CG iteration. `apply_owned` builds a
3824    // fresh `Array1::zeros(k)` per call, which at this scale is an ~11 MB
3825    // mmap/munmap pair with first-touch faults and a TLB shootdown EVERY
3826    // iteration -- and this solve runs to its iteration cap on the LAML path.
3827    // Reuse is the contract `schur_matvec` already documents and enforces: it
3828    // accumulates, so it clears `out` itself, which also makes the `zeros()`
3829    // inside `apply_owned` a second redundant zeroing of a buffer about to be
3830    // discarded.
3831    let mut ap = Array1::<f64>::zeros(b.len());
3832    let mut z = precondition(&r);
3833    let mut p = z.clone();
3834    let mut rs = r.dot(&z);
3835    let mut residual_norm_sq = r.dot(&r);
3836    if !(rs.is_finite() && residual_norm_sq.is_finite()) {
3837        return None;
3838    }
3839    let tol = cg_rel_tol * b_norm;
3840    let mut iters = 0usize;
3841    while residual_norm_sq.sqrt() > tol && iters < cg_max_iters {
3842        op.apply_into(&p, &mut ap);
3843        let denom = p.dot(&ap);
3844        if !(denom.is_finite() && denom > 0.0) {
3845            return None;
3846        }
3847        // `rs = rᵀM⁻¹r` is zero only when `r` is, and a zero residual exits
3848        // through the loop condition above (`tol > 0` always). Reaching here
3849        // with `rs == 0` therefore means round-off has destroyed the
3850        // SPD-by-construction preconditioned inner product, and the direction
3851        // update below would be a division by zero rather than a descent step.
3852        if rs == 0.0 {
3853            return None;
3854        }
3855        let alpha = rs / denom;
3856        y.scaled_add(alpha, &p);
3857        r.scaled_add(-alpha, &ap);
3858        residual_norm_sq = r.dot(&r);
3859        z = precondition(&r);
3860        let rs_new = r.dot(&z);
3861        if !(rs_new.is_finite() && residual_norm_sq.is_finite()) {
3862            return None;
3863        }
3864        // In place: `&z + &(&p * c)` allocates two more full-length temporaries
3865        // per iteration for the same arithmetic.
3866        p *= rs_new / rs;
3867        p += &z;
3868        rs = rs_new;
3869        iters += 1;
3870    }
3871    let report = ReducedSchurCgReport {
3872        iterations: iters,
3873        max_iterations: cg_max_iters,
3874        relative_residual: residual_norm_sq.sqrt() / b_norm,
3875        tolerance: cg_rel_tol,
3876        preconditioner: precond.kind(),
3877    };
3878    let solved = match quotient {
3879        Some(quotient) => quotient.project_complement(y.view()),
3880        None => y,
3881    };
3882    Some((solved, report))
3883}
3884
3885/// Matrix-free single-rhs reduced-Schur solve `S⁻¹ rhs` (`t = 0`) via CG on
3886/// `schur_matvec`, warm-started from `warm` (or cold). The base primitive for
3887/// the selected-inverse gradient channels whose `S⁻¹` argument is NOT the fixed
3888/// probe family but a per-call probe-derived vector (e.g. `(H⁻¹)_tt`'s
3889/// `H_βt(H_tt)⁻¹z` term in the ARD latent-block diagonal, and the per-row
3890/// `(H⁻¹)_tβ` blocks the θ-adjoint / assignment-strength traces contract) — those
3891/// cannot reuse the `(probes, S⁻¹·probes)` bundle, so they solve `S⁻¹` on demand
3892/// through this. `None` on a CG breakdown (SPD `S` forbids it, so it signals a
3893/// non-finite operator or caller bug).
3894pub fn reduced_schur_inverse_apply<B: BatchedBlockSolver + Sync>(
3895    sys: &ArrowSchurSystem,
3896    htt_factors: &ArrowFactorSlab,
3897    ridge_beta: f64,
3898    backend: &B,
3899    resident: Option<&SaeResidentReducedSchur>,
3900    gpu_matvec: Option<&GpuSchurMatvec>,
3901    rhs: &Array1<f64>,
3902    warm: Option<&Array1<f64>>,
3903    cg_rel_tol: f64,
3904    cg_max_iters: usize,
3905) -> Option<(Array1<f64>, ReducedSchurCgReport)> {
3906    let zero = Array1::<f64>::zeros(sys.k);
3907    let y0 = warm.unwrap_or(&zero);
3908    reduced_schur_cg_solve(
3909        sys,
3910        htt_factors,
3911        ridge_beta,
3912        backend,
3913        resident,
3914        gpu_matvec,
3915        rhs,
3916        y0,
3917        cg_rel_tol,
3918        cg_max_iters,
3919    )
3920}
3921
3922fn matrix_free_cache_factor_slab(cache: &ArrowFactorCache) -> &ArrowFactorSlab {
3923    match &cache.htt_factors_undamped {
3924        ArrowUndampedFactors::SameAsDamped => &cache.htt_factors,
3925        ArrowUndampedFactors::Owned(factors) => factors,
3926    }
3927}
3928
3929fn validate_matrix_free_arrow_pair(
3930    sys: &ArrowSchurSystem,
3931    cache: &ArrowFactorCache,
3932    operation: &str,
3933) -> Result<(), ArrowSchurError> {
3934    if cache.ridge_t != 0.0 || cache.ridge_beta != 0.0 || !cache.schur_factor_is_undamped {
3935        return Err(ArrowSchurError::SchurFactorFailed {
3936            reason: format!(
3937                "{operation} requires an undamped evidence cache; got ridge_t={}, \
3938                 ridge_beta={}, schur_factor_is_undamped={}",
3939                cache.ridge_t, cache.ridge_beta, cache.schur_factor_is_undamped
3940            ),
3941        });
3942    }
3943    if sys.k != cache.k
3944        || sys.rows.len() != cache.n_rows()
3945        || sys.row_dims.as_ref() != cache.row_dims.as_ref()
3946        || sys.row_offsets.as_ref() != cache.row_offsets.as_ref()
3947    {
3948        return Err(ArrowSchurError::SchurFactorFailed {
3949            reason: format!(
3950                "{operation} system/cache layout mismatch: system (rows={}, k={}, offsets={:?}) \
3951                 vs cache (rows={}, k={}, offsets={:?})",
3952                sys.rows.len(),
3953                sys.k,
3954                sys.row_offsets,
3955                cache.n_rows(),
3956                cache.k,
3957                cache.row_offsets,
3958            ),
3959        });
3960    }
3961    if sys.row_hessian_fingerprint != cache.row_hessian_fingerprint
3962        || sys.manifold_mode_fingerprint != cache.manifold_mode_fingerprint
3963    {
3964        return Err(ArrowSchurError::SchurFactorFailed {
3965            reason: format!(
3966                "{operation} refuses a stale matrix-free system/cache pair \
3967                 (row fingerprint {} vs {}, manifold fingerprint {} vs {})",
3968                sys.row_hessian_fingerprint,
3969                cache.row_hessian_fingerprint,
3970                sys.manifold_mode_fingerprint,
3971                cache.manifold_mode_fingerprint,
3972            ),
3973        });
3974    }
3975    if !sys.cross_row_penalties.is_empty() {
3976        return Err(ArrowSchurError::SchurFactorFailed {
3977            reason: format!(
3978                "{operation} supports the row-block bordered arrow only; cross-row latent \
3979                 curvature requires its own matrix-free inverse carrier"
3980            ),
3981        });
3982    }
3983    if !cache.htbeta_available() && cache.k > 0 {
3984        return Err(ArrowSchurError::SchurFactorFailed {
3985            reason: format!("{operation} requires the cached H_tbeta operator"),
3986        });
3987    }
3988    Ok(())
3989}
3990
3991fn cholesky_factor_operator_apply(
3992    factor: ArrayView2<'_, f64>,
3993    vector: ArrayView1<'_, f64>,
3994) -> Array1<f64> {
3995    let n = factor.nrows();
3996    let mut transposed = Array1::<f64>::zeros(n);
3997    for col in 0..n {
3998        let mut value = 0.0_f64;
3999        for row in col..n {
4000            value += factor[[row, col]] * vector[row];
4001        }
4002        transposed[col] = value;
4003    }
4004    let mut out = Array1::<f64>::zeros(n);
4005    for row in 0..n {
4006        let mut value = 0.0_f64;
4007        for col in 0..=row {
4008            value += factor[[row, col]] * transposed[col];
4009        }
4010        out[row] = value;
4011    }
4012    out
4013}
4014
4015/// Apply the undamped full bordered-arrow evidence operator without forming its
4016/// dense reduced Schur complement.
4017///
4018/// The cache supplies the authoritative conditioned row factors and `H_tbeta`
4019/// operator. The system supplies the matrix-free shared block. Rather than read
4020/// raw `H_betabeta` directly, this reconstructs it from
4021/// `S + H_betat A^-1 H_tbeta`, where `S` is applied through the same quotient-
4022/// aware reduced operator used by the matrix-free log-determinant. Value,
4023/// selected-inverse traces, and this IFT operator therefore describe one `B`.
4024pub fn matrix_free_arrow_operator_apply(
4025    sys: &ArrowSchurSystem,
4026    cache: &ArrowFactorCache,
4027    vector_t: ArrayView1<'_, f64>,
4028    vector_beta: ArrayView1<'_, f64>,
4029) -> Result<(Array1<f64>, Array1<f64>), ArrowSchurError> {
4030    validate_matrix_free_arrow_pair(sys, cache, "matrix_free_arrow_operator_apply")?;
4031    if vector_t.len() != cache.delta_t_len() || vector_beta.len() != cache.k {
4032        return Err(ArrowSchurError::SchurFactorFailed {
4033            reason: format!(
4034                "matrix_free_arrow_operator_apply vector shapes (t={}, beta={}) != ({}, {})",
4035                vector_t.len(),
4036                vector_beta.len(),
4037                cache.delta_t_len(),
4038                cache.k,
4039            ),
4040        });
4041    }
4042
4043    let factors = matrix_free_cache_factor_slab(cache);
4044    let backend = CpuBatchedBlockSolver;
4045    let reduced = ReducedSchurOperator::new(sys, factors, 0.0, &backend, None);
4046    let mut out_beta = reduced.apply(vector_beta);
4047    let mut out_t = Array1::<f64>::zeros(cache.delta_t_len());
4048    for row in 0..cache.n_rows() {
4049        let dim = cache.row_dims[row];
4050        let start = cache.row_offsets[row];
4051        let row_vector = vector_t.slice(ndarray::s![start..start + dim]);
4052        let factor = cache.undamped_factor(row);
4053        let row_applied = cholesky_factor_operator_apply(factor, row_vector);
4054        for axis in 0..dim {
4055            out_t[start + axis] = row_applied[axis];
4056        }
4057
4058        if cache.k == 0 {
4059            continue;
4060        }
4061        let mut cross = Array1::<f64>::zeros(dim);
4062        if !cache.apply_htbeta_row(row, vector_beta, &mut cross) {
4063            return Err(ArrowSchurError::SchurFactorFailed {
4064                reason: format!("matrix_free_arrow_operator_apply H_tbeta row {row} apply failed"),
4065            });
4066        }
4067        for axis in 0..dim {
4068            out_t[start + axis] += cross[axis];
4069        }
4070        if !cache.apply_htbeta_row_transpose(row, row_vector, &mut out_beta, None) {
4071            return Err(ArrowSchurError::SchurFactorFailed {
4072                reason: format!("matrix_free_arrow_operator_apply H_betat row {row} apply failed"),
4073            });
4074        }
4075
4076        // `out_beta` already contains `S * vector_beta`; add the eliminated
4077        // `H_betat A^-1 H_tbeta * vector_beta` term to recover H_betabeta.
4078        let solved_cross = cholesky_solve_vector(factor, cross.view());
4079        if !cache.apply_htbeta_row_transpose(row, solved_cross.view(), &mut out_beta, None) {
4080            return Err(ArrowSchurError::SchurFactorFailed {
4081                reason: format!(
4082                    "matrix_free_arrow_operator_apply Schur reconstruction row {row} failed"
4083                ),
4084            });
4085        }
4086    }
4087    Ok((out_t, out_beta))
4088}
4089
4090/// Solve the undamped full bordered-arrow evidence system for an arbitrary RHS
4091/// using the matrix-free reduced-Schur CG primitive and exact row backsolves.
4092///
4093/// This is the matrix-free sibling of `ArrowFactorCache::full_inverse_apply`.
4094/// It never materializes `S` or `S^-1`; the beta solve uses the same
4095/// quotient-aware `S` operator as the rational log-determinant, then the latent
4096/// block is recovered by standard arrow back-substitution.
4097///
4098/// The returned [`ReducedSchurCgReport`] certifies the inner CG's achieved
4099/// relative residual. It is NOT decoration: the border solve is iterative and
4100/// may truncate, so an `H⁻¹b` whose report says `converged() == false` is an
4101/// approximation of unbounded error, and any consumer forming a criterion,
4102/// trace, or gradient from it must say so rather than pass it on silently
4103/// (#2576).
4104pub fn matrix_free_arrow_inverse_apply(
4105    sys: &ArrowSchurSystem,
4106    cache: &ArrowFactorCache,
4107    rhs_t: ArrayView1<'_, f64>,
4108    rhs_beta: ArrayView1<'_, f64>,
4109    cg_rel_tol: f64,
4110    cg_max_iters: usize,
4111) -> Result<(Array1<f64>, Array1<f64>, ReducedSchurCgReport), ArrowSchurError> {
4112    validate_matrix_free_arrow_pair(sys, cache, "matrix_free_arrow_inverse_apply")?;
4113    if rhs_t.len() != cache.delta_t_len() || rhs_beta.len() != cache.k {
4114        return Err(ArrowSchurError::SchurFactorFailed {
4115            reason: format!(
4116                "matrix_free_arrow_inverse_apply rhs shapes (t={}, beta={}) != ({}, {})",
4117                rhs_t.len(),
4118                rhs_beta.len(),
4119                cache.delta_t_len(),
4120                cache.k,
4121            ),
4122        });
4123    }
4124    if !(cg_rel_tol.is_finite() && cg_rel_tol > 0.0) || cg_max_iters == 0 {
4125        return Err(ArrowSchurError::PcgFailed {
4126            reason: format!(
4127                "matrix_free_arrow_inverse_apply requires positive finite CG tolerance and \
4128                 iteration count; got rel_tol={cg_rel_tol}, max_iters={cg_max_iters}"
4129            ),
4130        });
4131    }
4132
4133    let factors = matrix_free_cache_factor_slab(cache);
4134    let backend = CpuBatchedBlockSolver;
4135    let mut latent_forward = Array1::<f64>::zeros(cache.delta_t_len());
4136    let mut eliminated = Array1::<f64>::zeros(cache.k);
4137    for row in 0..cache.n_rows() {
4138        let dim = cache.row_dims[row];
4139        let start = cache.row_offsets[row];
4140        let solved = cholesky_solve_vector(
4141            cache.undamped_factor(row),
4142            rhs_t.slice(ndarray::s![start..start + dim]),
4143        );
4144        for axis in 0..dim {
4145            latent_forward[start + axis] = solved[axis];
4146        }
4147        if cache.k > 0
4148            && !cache.apply_htbeta_row_transpose(row, solved.view(), &mut eliminated, None)
4149        {
4150            return Err(ArrowSchurError::SchurFactorFailed {
4151                reason: format!("matrix_free_arrow_inverse_apply H_betat row {row} apply failed"),
4152            });
4153        }
4154    }
4155    // The transpose helper accumulates the eliminated term positively.
4156    let mut reduced_rhs = rhs_beta.to_owned();
4157    reduced_rhs -= &eliminated;
4158
4159    let (solved_beta, report) = if cache.k == 0 {
4160        (
4161            Array1::<f64>::zeros(0),
4162            ReducedSchurCgReport {
4163                iterations: 0,
4164                max_iterations: cg_max_iters,
4165                relative_residual: 0.0,
4166                tolerance: cg_rel_tol,
4167                preconditioner: ReducedSchurCgPreconditioner::Identity,
4168            },
4169        )
4170    } else {
4171        reduced_schur_inverse_apply(
4172            sys,
4173            factors,
4174            0.0,
4175            &backend,
4176            None,
4177            None,
4178            &reduced_rhs,
4179            None,
4180            cg_rel_tol,
4181            cg_max_iters,
4182        )
4183        .ok_or_else(|| ArrowSchurError::PcgFailed {
4184            reason: format!(
4185                "matrix_free_arrow_inverse_apply reduced-Schur solve failed \
4186                 (dim={}, rel_tol={cg_rel_tol}, max_iters={cg_max_iters})",
4187                cache.k
4188            ),
4189        })?
4190    };
4191
4192    let mut solved_t = latent_forward;
4193    for row in 0..cache.n_rows() {
4194        let dim = cache.row_dims[row];
4195        let start = cache.row_offsets[row];
4196        if cache.k == 0 {
4197            continue;
4198        }
4199        let mut cross = Array1::<f64>::zeros(dim);
4200        if !cache.apply_htbeta_row(row, solved_beta.view(), &mut cross) {
4201            return Err(ArrowSchurError::SchurFactorFailed {
4202                reason: format!("matrix_free_arrow_inverse_apply H_tbeta row {row} apply failed"),
4203            });
4204        }
4205        let correction = cholesky_solve_vector(cache.undamped_factor(row), cross.view());
4206        for axis in 0..dim {
4207            solved_t[start + axis] -= correction[axis];
4208        }
4209    }
4210    Ok((solved_t, solved_beta, report))
4211}
4212
4213/// The `S⁻¹ v_j` bundle for a fixed probe set: solves `S y_j = v_j` (`t = 0`) on
4214/// the matrix-free reduced Schur for each probe `v_j`, warm-started per-probe
4215/// from `warm` when supplied (e.g. the surrogate's smallest-shift solves, which
4216/// already sit close to `S⁻¹ v_j`). Computed ONCE per outer solve and reused
4217/// across every `tr(S⁻¹·M)` channel, so the whole massive-K ρ-gradient +
4218/// θ-adjoint rides on one probe family — one functional, desync closed.
4219///
4220/// `probes` are the surrogate plan's Rademacher probes (`RationalLogdetPlan::
4221/// probes`); pass the SAME set the value used so the trace estimates are
4222/// consistent with it. `None` on any CG breakdown.
4223///
4224/// The returned [`ReducedSchurCgReport`] is the bundle's WEAKEST member — a
4225/// bundle is only as certified as its least-converged solve, and every trace
4226/// estimated from it averages over all of them.
4227pub fn reduced_schur_inverse_probe_solves<B: BatchedBlockSolver + Sync>(
4228    sys: &ArrowSchurSystem,
4229    htt_factors: &ArrowFactorSlab,
4230    ridge_beta: f64,
4231    backend: &B,
4232    resident: Option<&SaeResidentReducedSchur>,
4233    gpu_matvec: Option<&GpuSchurMatvec>,
4234    probes: &[Array1<f64>],
4235    warm: Option<&[Array1<f64>]>,
4236    cg_rel_tol: f64,
4237    cg_max_iters: usize,
4238) -> Option<(Vec<Array1<f64>>, ReducedSchurCgReport)> {
4239    let k = sys.k;
4240    let zero = Array1::<f64>::zeros(k);
4241    let mut out = Vec::with_capacity(probes.len());
4242    let mut weakest: Option<ReducedSchurCgReport> = None;
4243    for (j, v) in probes.iter().enumerate() {
4244        let y0 = warm.and_then(|w| w.get(j)).unwrap_or(&zero);
4245        let (y, report) = reduced_schur_cg_solve(
4246            sys,
4247            htt_factors,
4248            ridge_beta,
4249            backend,
4250            resident,
4251            gpu_matvec,
4252            v,
4253            y0,
4254            cg_rel_tol,
4255            cg_max_iters,
4256        )?;
4257        weakest = Some(match weakest {
4258            Some(previous) => previous.weaker(report),
4259            None => report,
4260        });
4261        out.push(y);
4262    }
4263    weakest.map(|report| (out, report))
4264}
4265
4266/// Hutchinson estimate `tr(S⁻¹ M) ≈ (1/m) Σ_j (S⁻¹ v_j)ᵀ (M v_j)` for the reduced
4267/// Schur `S` and a SYMMETRIC channel operator `M` supplied by its matvec
4268/// `m_matvec(v) = M·v`. `sinv_probes[j] = S⁻¹ v_j` is the bundle from
4269/// [`reduced_schur_inverse_probe_solves`] and `probes` the matching probe set.
4270///
4271/// The general umbrella (#2080): every dense-`S⁻¹` consumer in the SAE outer
4272/// gradient — the per-row selected-inverse deflation corrections
4273/// (`M = Σ_i G_iᵀ C_i G_i`), the direct β–β contractions (`M = ∂H_ββ` channel),
4274/// and the θ-adjoint — is ultimately a `tr(S⁻¹·M)` with `M·v` computable
4275/// row-locally without forming `M`. Estimating them all from the SAME
4276/// `(probes, S⁻¹ v_j)` pair keeps the value, ρ-gradient, and θ-adjoint one
4277/// functional. Unbiased for the ±1 Rademacher probes (`E[vᵀ S⁻¹ M v] =
4278/// tr(S⁻¹ M)`). `None` on a length mismatch or a non-finite accumulation.
4279pub fn hutchinson_reduced_schur_inverse_trace(
4280    probes: &[Array1<f64>],
4281    sinv_probes: &[Array1<f64>],
4282    m_matvec: &(impl Fn(ArrayView1<f64>) -> Array1<f64> + Sync),
4283) -> Option<f64> {
4284    let m = probes.len();
4285    if m == 0 || sinv_probes.len() != m {
4286        return None;
4287    }
4288    let mut acc = 0.0_f64;
4289    for (v, y) in probes.iter().zip(sinv_probes) {
4290        let mv = m_matvec(v.view());
4291        acc += y.dot(&mv);
4292    }
4293    acc /= m as f64;
4294    acc.is_finite().then_some(acc)
4295}
4296
4297/// Accumulate one row's reduced-Schur point-elimination contribution
4298/// `H_βt^(i) (H_tt^(i))⁻¹ H_tβ^(i) x` (length `K`) into `acc`.
4299///
4300/// `local` is caller-owned `≥ sys.d`-length scratch (reused across rows to keep
4301/// the hot loop allocation-free); only `..di` is touched. `acc` is **added to**,
4302/// never cleared, so the caller controls whether contributions sum into a chunk
4303/// partial (parallel path) or a per-row buffer (sequential path).
4304#[inline]
4305pub(crate) fn schur_matvec_row_into<B: BatchedBlockSolver>(
4306    sys: &ArrowSchurSystem,
4307    htt_factors: &ArrowFactorSlab,
4308    x: &Array1<f64>,
4309    backend: &B,
4310    i: usize,
4311    local: &mut Array1<f64>,
4312    acc: &mut Array1<f64>,
4313) {
4314    let row = &sys.rows[i];
4315    let di = sys.row_dims[i];
4316    // H_tβ^(i) · x → local[..di], routed through sys.htbeta_matvec
4317    // when the dense block is absent.
4318    let mut local_i = local.slice_mut(ndarray::s![..di]).to_owned();
4319    local_i.fill(0.0);
4320    sys_htbeta_apply_row(sys, i, row, x.view(), &mut local_i);
4321    let solved = backend.solve_block_vector(htt_factors.factor(i), local_i.view());
4322    // H_βt^(i) · solved accumulates into acc (length k).  Routed through
4323    // sys.htbeta_matvec when needed.
4324    sys_htbeta_accumulate_transpose(sys, i, row, solved.view(), acc);
4325}
4326
4327/// One per-term block factor for the block-Jacobi Schur preconditioner.
4328///
4329/// Carries either a dense Cholesky factor (for PD blocks ≤ 256 columns) or
4330/// the scalar inverses for that block's diagonal as a fallback.
4331#[derive(Clone)]
4332pub(crate) enum BlockFactor {
4333    /// Cholesky L stored column-major via faer. `range` identifies the
4334    /// columns in the full K-vector this block covers.
4335    Chol {
4336        factor: FaerLlt<f64>,
4337        range: Range<usize>,
4338    },
4339    /// Scalar fallback: per-element `1/s_aa` for each column in `range`.
4340    Scalar {
4341        inv: Array1<f64>,
4342        range: Range<usize>,
4343    },
4344}
4345
4346impl std::fmt::Debug for BlockFactor {
4347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4348        match self {
4349            BlockFactor::Chol { range, .. } => {
4350                write!(f, "BlockFactor::Chol {{ range: {:?} }}", range)
4351            }
4352            BlockFactor::Scalar { inv, range } => {
4353                write!(
4354                    f,
4355                    "BlockFactor::Scalar {{ inv.len: {}, range: {:?} }}",
4356                    inv.len(),
4357                    range
4358                )
4359            }
4360        }
4361    }
4362}
4363
4364/// Block-Jacobi Schur preconditioner for BA's inexact reduced-system PCG.
4365///
4366/// When [`ArrowSchurSystem::block_offsets`] is populated (via
4367/// [`ArrowSchurSystem::set_block_offsets`]) and the largest block has ≤ 256
4368/// columns, builds one small dense Schur block per term, factors it with
4369/// Cholesky (faer LLT), and applies the preconditioner as per-block
4370/// triangular solves.  Non-PD blocks fall back to scalar diagonal inversion
4371/// for that block only.  When `block_offsets` is empty or the largest block
4372/// exceeds 256 columns the preconditioner reduces to pure scalar-diagonal
4373/// Jacobi (pre-#283 behaviour), so callers that have not called
4374/// `set_block_offsets` are unaffected.
4375///
4376/// The `block_offsets` plumbing is compatible with issue #287 (custom
4377/// `ParameterBlockSpec` families): those callers supply ranges derived from
4378/// their own block layout.
4379#[derive(Debug, Clone)]
4380pub struct JacobiPreconditioner {
4381    pub(crate) blocks: Vec<BlockFactor>,
4382}
4383
4384/// Maximum block size for which we attempt dense block-Jacobi factorization.
4385pub(crate) const BLOCK_JACOBI_MAX_BLOCK: usize = 256;
4386
4387/// Positive-definiteness floor on a Schur-complement Jacobi diagonal entry.
4388/// A diagonal at or below this value (or non-finite) signals a non-PD reduced
4389/// system: the preconditioner cannot invert it, so the PCG solve fails loudly
4390/// and demands operator regularization rather than returning a garbage scale.
4391pub(crate) const JACOBI_DIAGONAL_PD_FLOOR: f64 = 1e-18;
4392
4393impl JacobiPreconditioner {
4394    /// Build the block-Jacobi (or scalar fallback) preconditioner from the
4395    /// Arrow-Schur system without materializing the full dense Schur
4396    /// complement.
4397    ///
4398    /// When `sys.block_offsets` is non-empty and `max(block_size) ≤ 256`,
4399    /// each block gets a dense `b×b` Schur sub-matrix formed, factored, and
4400    /// stored.  Otherwise every column gets its own scalar entry.
4401    pub(crate) fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
4402        sys: &ArrowSchurSystem,
4403        htt_factors: &ArrowFactorSlab,
4404        ridge_beta: f64,
4405        backend: &B,
4406        resident: Option<&SaeResidentReducedSchur>,
4407    ) -> Result<Self, ArrowSchurError> {
4408        let use_block = !sys.block_offsets.is_empty()
4409            && sys
4410                .block_offsets
4411                .iter()
4412                .map(|r| r.end.saturating_sub(r.start))
4413                .max()
4414                .unwrap_or(0)
4415                <= BLOCK_JACOBI_MAX_BLOCK;
4416        if use_block {
4417            if let Some(res) = resident {
4418                Self::build_block_jacobi_resident(sys, ridge_beta, res)
4419            } else {
4420                Self::build_block_jacobi(sys, htt_factors, ridge_beta, backend)
4421            }
4422        } else if let Some(res) = resident {
4423            // #1017 — SAE residency scalar Jacobi. The generic scalar build
4424            // probes `H_tβ^(i) e_a` and re-solves `(H_tt^(i))⁻¹` once for EVERY
4425            // (row, β-column) pair: `O(n·K)` triangular solves and `O(n·K·p)`
4426            // operator-probe work per Newton step, with `K = K_atoms·p` in the
4427            // tens of thousands at LLM shapes. The reduced-Schur diagonal is the
4428            // same quotient the resident `(L_i, Y_i)` factors already carry, so
4429            // read the diagonal straight off them in one support-sparse pass —
4430            // no probe, no per-column solve.
4431            Self::build_scalar_jacobi_resident(sys, ridge_beta, res)
4432        } else {
4433            Self::build_scalar_jacobi(sys, htt_factors, ridge_beta, backend)
4434        }
4435    }
4436
4437    /// Build scalar-diagonal Jacobi: one `BlockFactor::Scalar` of length 1
4438    /// per column.  Matches pre-#283 semantics.
4439    ///
4440    /// When `sys.htbeta_matvec` is set and per-row `htbeta` slabs are absent,
4441    /// each column is probed via the matvec (one call per column per row).
4442    pub(crate) fn build_scalar_jacobi<B: BatchedBlockSolver + Sync>(
4443        sys: &ArrowSchurSystem,
4444        htt_factors: &ArrowFactorSlab,
4445        ridge_beta: f64,
4446        backend: &B,
4447    ) -> Result<Self, ArrowSchurError> {
4448        let k = sys.k;
4449        // Extract diagonal of H_ββ via penalty_diagonal_add (#296):
4450        // no Arc-clone; falls back to hbb_diag or hbb[[a,a]] inline.
4451        let mut diag = Array1::<f64>::zeros(k);
4452        {
4453            let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
4454            sys.penalty_diagonal_add(diag_slice);
4455        }
4456        for a in 0..k {
4457            diag[a] += ridge_beta;
4458        }
4459        // Per-row body: subtract this row's `Σ_a (H_tβ^(i)e_a)ᵀ(H_tt^(i))⁻¹
4460        // (H_tβ^(i)e_a)` contribution into a caller-provided length-`K` diagonal
4461        // accumulator (`-=`). For each column `a`, probe the cross-block (or read
4462        // the dense slab) and compute the scalar point-elimination quotient. The
4463        // `O(K)` solves per row are the build's whole cost; the row contributions
4464        // are independent length-`K` vectors, so a worker sums a chunk into a
4465        // private `diag_part` and the caller folds the partials back in chunk
4466        // order — bit-identical run-to-run (the #1017 preconditioner gate).
4467        let row_into = |i: usize, row: &ArrowRowBlock, diag_part: &mut Array1<f64>| {
4468            let di = sys.row_dims[i];
4469            // Dense-slab fast path (#1017): when the per-row cross-block is a
4470            // materialized `di × k` slab (no matrix-free operator), the entire
4471            // reduced-Schur diagonal contribution for this row is
4472            // `Σ_c H_tβ[c,a] · ((H_tt)⁻¹ H_tβ)[c,a]`. The generic loop below
4473            // re-solved `(H_tt)⁻¹` once PER COLUMN — `O(k)` block solves + `O(k)`
4474            // allocations per row, i.e. `O(n·k)` tiny solves per Newton step
4475            // (the dominant fixed per-solve cost at the SAE wide-border shape,
4476            // k in the tens of thousands). Solve all `k` columns in ONE batched
4477            // block solve instead, then take the column dots. Reassociates the
4478            // diagonal within the documented #1211 preconditioner margin (same as
4479            // the resident no-probe path), and the preconditioner only steers the
4480            // PCG iterate, which still terminates at the PCG tolerance.
4481            if sys.htbeta_matvec.is_none() && row.htbeta.dim() == (di, k) {
4482                let solved = backend.solve_block_matrix(htt_factors.factor(i), row.htbeta.view());
4483                for a in 0..k {
4484                    let mut acc = 0.0;
4485                    for c in 0..di {
4486                        acc += row.htbeta[[c, a]] * solved[[c, a]];
4487                    }
4488                    diag_part[a] -= acc;
4489                }
4490                return;
4491            }
4492            // Matrix-free path: probe column a. `e_a` stays all-zero between
4493            // columns — set the single active entry and reset it after the probe,
4494            // so we never pay the `O(k)` `e_a.fill(0.0)` per column (that fill was
4495            // `O(n·k²)`). `sys_htbeta_apply_row` zeroes `col_i` internally.
4496            let mut col_i = Array1::<f64>::zeros(di);
4497            let mut e_a = Array1::<f64>::zeros(k);
4498            for a in 0..k {
4499                e_a[a] = 1.0;
4500                sys_htbeta_apply_row(sys, i, row, e_a.view(), &mut col_i);
4501                e_a[a] = 0.0;
4502                let solved = backend.solve_block_vector(htt_factors.factor(i), col_i.view());
4503                let mut acc = 0.0;
4504                for c in 0..di {
4505                    acc += col_i[c] * solved[c];
4506                }
4507                diag_part[a] -= acc;
4508            }
4509        };
4510        let n = sys.rows.len();
4511        let parallel =
4512            n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
4513        if parallel {
4514            use rayon::prelude::*;
4515            const CHUNK: usize = 64;
4516            let partials: Vec<Array1<f64>> = (0..n)
4517                .into_par_iter()
4518                .chunks(CHUNK)
4519                .map(|idxs| {
4520                    let mut diag_part = Array1::<f64>::zeros(k);
4521                    for i in idxs {
4522                        row_into(i, &sys.rows[i], &mut diag_part);
4523                    }
4524                    diag_part
4525                })
4526                .collect();
4527            // Deterministic ordered reduction: fold chunk partials left-to-right.
4528            for part in &partials {
4529                for a in 0..k {
4530                    diag[a] += part[a];
4531                }
4532            }
4533        } else {
4534            for (i, row) in sys.rows.iter().enumerate() {
4535                row_into(i, row, &mut diag);
4536            }
4537        }
4538        let mut blocks = Vec::with_capacity(k);
4539        for a in 0..k {
4540            let v = diag[a];
4541            if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
4542                return Err(ArrowSchurError::PcgFailed {
4543                    reason: format!(
4544                        "invalid Schur Jacobi diagonal at index {a}: {v}; \
4545                         operator regularization is required"
4546                    ),
4547                });
4548            }
4549            blocks.push(BlockFactor::Scalar {
4550                inv: Array1::from_elem(1, 1.0 / v),
4551                range: a..a + 1,
4552            });
4553        }
4554        Ok(Self { blocks })
4555    }
4556
4557    /// Build scalar-diagonal Jacobi from the pre-staged SAE residency factors
4558    /// `(L_i, Y_i)` (#1017).
4559    ///
4560    /// The generic [`Self::build_scalar_jacobi`] forms each reduced-Schur
4561    /// diagonal entry `S_aa = H_ββ,aa + ρ − Σ_i (H_tβ^(i) e_a)ᵀ(H_tt^(i))⁻¹(H_tβ^(i) e_a)`
4562    /// by probing the cross-block operator with the unit vector `e_a` and
4563    /// re-solving `(H_tt^(i))⁻¹` for every `(row, column)` pair — `O(n·K)`
4564    /// triangular solves per Newton step. For the SAE Kronecker cross-block the
4565    /// `a`-th column lives on exactly one active support entry: `a = beta_base + j`
4566    /// for some `(beta_base, φ) ∈ a_phi[i]` and output channel `j ∈ 0..p`, with
4567    /// `H_tβ^(i) e_a = φ · L_i[:, j]`. The point-elimination quotient is then
4568    ///
4569    /// ```text
4570    /// (H_tβ^(i) e_a)ᵀ (H_tt^(i))⁻¹ (H_tβ^(i) e_a)
4571    ///     = φ² · L_i[:, j]ᵀ (H_tt^(i))⁻¹ L_i[:, j]
4572    ///     = φ² · (L_i[:, j] · Y_i[:, j]),          Y_i := (H_tt^(i))⁻¹ L_i.
4573    /// ```
4574    ///
4575    /// so the whole diagonal is accumulated in ONE support-sparse pass over the
4576    /// resident factors — no probe, no per-column solve, the staged `Y_i` reused
4577    /// from the matvec residency. The result is the SAME quotient the generic
4578    /// path computes (up to float reassociation of the row sum), so the PCG
4579    /// preconditioner is unchanged up to that f64 margin. Since the preconditioner
4580    /// only steers the iterate (which still terminates at the PCG tolerance), the
4581    /// criterion ranking is stable except for candidates within that margin,
4582    /// where the near-tie winner can flip — not an exact no-move guarantee (#1211).
4583    pub(crate) fn build_scalar_jacobi_resident(
4584        sys: &ArrowSchurSystem,
4585        ridge_beta: f64,
4586        resident: &SaeResidentReducedSchur,
4587    ) -> Result<Self, ArrowSchurError> {
4588        let k = sys.k;
4589        let p = resident.p;
4590        let n = resident.rows.len();
4591        // Seed with diag(H_ββ) + ridge — same penalty source the generic path
4592        // reads, so the only difference is how the point-elimination term is
4593        // gathered.
4594        let mut diag = Array1::<f64>::zeros(k);
4595        {
4596            let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
4597            sys.penalty_diagonal_add(diag_slice);
4598        }
4599        for a in 0..k {
4600            diag[a] += ridge_beta;
4601        }
4602        // Per-row point-elimination diagonal: for each active support entry
4603        // `(beta_base, φ)` and channel `j`, subtract `φ² · L_i[:, j]·Y_i[:, j]`
4604        // into `diag[beta_base + j]`. `L_i`/`Y_i` are row-major `di × p`, so the
4605        // `j`-th column dot is `Σ_r L_i[r·p + j]·Y_i[r·p + j]`.
4606        //
4607        // The accumulation is into a SHARED `diag` (rows scatter into overlapping
4608        // `beta_base + j` columns), so — like the generic `build_scalar_jacobi`
4609        // and the `schur_matvec` row loop (#1017) — parallelism uses worker-private
4610        // length-`K` partials folded back in chunk order: each chunk is a
4611        // contiguous ascending row range and rows within it stay ascending, so the
4612        // chunk-ordered fold reproduces the serial `row = 0..n` subtraction order
4613        // bit-for-bit run-to-run (the #1017 determinism gate). Run-to-run
4614        // bit-identity does not extend to bit-identity with the in-place serial
4615        // accumulation, so the preconditioner — and any criterion ranking it
4616        // steers — is stable only up to the chunk-reassociation margin; a near-tie
4617        // winner inside that margin can flip (#1211).
4618        // This build runs once per inexact-PCG solve = O(inner-Newton-iters)
4619        // per fit; at the SAE LLM shape (thousands of rows, wide border `k`) the
4620        // per-row support sweep is the build's whole cost and was on one core.
4621        // The per-channel column dot `col_dot[j] = Σ_r L_i[r·p+j]·Y_i[r·p+j]`
4622        // (the diagonal of `G_i = L_iᵀ(H_tt)⁻¹L_i`) depends ONLY on the row `i`,
4623        // not on the support entry `(beta_base, φ)`. The previous loop recomputed
4624        // it once per support entry — a row with `m` active atoms paid `m·p`
4625        // column dots over `di`. Hoist it: compute the `p` column dots once per
4626        // row into reusable `col_dot` scratch, then each support entry is a pure
4627        // scatter `diag[beta_base+j] -= φ²·col_dot[j]`. Bit-for-bit identical:
4628        // each `col_dot[j]` is the same `r`-ascending sum, and `φ²·col_dot[j]`
4629        // yields identical bits whether `col_dot[j]` was just computed or cached.
4630        let row_into = |row: usize, diag_part: &mut [f64], col_dot: &mut [f64]| {
4631            let rf = &resident.rows[row];
4632            let di = rf.di;
4633            if di == 0 {
4634                return;
4635            }
4636            let support = &resident.a_phi[row];
4637            if support.is_empty() {
4638                return;
4639            }
4640            // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte
4641            // the former per-row `rf.l` copy.
4642            let l_i = &resident.local_jac[row];
4643            for (j, slot) in col_dot.iter_mut().enumerate().take(p) {
4644                let mut acc = 0.0_f64;
4645                for r in 0..di {
4646                    let idx = r * p + j;
4647                    acc += l_i[idx] * rf.y[idx];
4648                }
4649                *slot = acc;
4650            }
4651            for &(beta_base, phi) in support {
4652                if phi == 0.0 {
4653                    continue;
4654                }
4655                let phi2 = phi * phi;
4656                for j in 0..p {
4657                    diag_part[beta_base + j] -= phi2 * col_dot[j];
4658                }
4659            }
4660        };
4661        let parallel =
4662            n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
4663        if parallel {
4664            use rayon::prelude::*;
4665            const CHUNK: usize = 64;
4666            let partials: Vec<Array1<f64>> = (0..n)
4667                .into_par_iter()
4668                .chunks(CHUNK)
4669                .map(|idxs| {
4670                    let mut diag_part = Array1::<f64>::zeros(k);
4671                    let mut col_dot = vec![0.0_f64; p];
4672                    let slice = diag_part
4673                        .as_slice_mut()
4674                        .expect("diag_part must be contiguous");
4675                    for i in idxs {
4676                        row_into(i, slice, &mut col_dot);
4677                    }
4678                    diag_part
4679                })
4680                .collect();
4681            // Deterministic ordered reduction: fold chunk partials left-to-right
4682            // (each partial already holds the per-row terms subtracted, so add
4683            // them into `diag` in chunk order to mirror the serial subtraction).
4684            for part in &partials {
4685                for a in 0..k {
4686                    diag[a] += part[a];
4687                }
4688            }
4689        } else {
4690            let diag_slice = diag.as_slice_mut().expect("diag must be contiguous");
4691            let mut col_dot = vec![0.0_f64; p];
4692            for row in 0..n {
4693                row_into(row, diag_slice, &mut col_dot);
4694            }
4695        }
4696        let mut blocks = Vec::with_capacity(k);
4697        for a in 0..k {
4698            let v = diag[a];
4699            if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
4700                return Err(ArrowSchurError::PcgFailed {
4701                    reason: format!(
4702                        "invalid SAE-resident Schur Jacobi diagonal at index {a}: {v}; \
4703                         operator regularization is required"
4704                    ),
4705                });
4706            }
4707            blocks.push(BlockFactor::Scalar {
4708                inv: Array1::from_elem(1, 1.0 / v),
4709                range: a..a + 1,
4710            });
4711        }
4712        Ok(Self { blocks })
4713    }
4714
4715    /// Build block-Jacobi from the pre-staged SAE residency factors `(L_i, Y_i)`.
4716    ///
4717    /// This is the block analogue of [`Self::build_scalar_jacobi_resident`].
4718    /// When SAE block offsets are small enough to select BetaBlockJacobi (for
4719    /// example per-atom decoder blocks with `basis_size·p <= 256`), the generic
4720    /// block builder materializes every row's dense `(d_i × K)` `H_tβ` by probing
4721    /// the matrix-free operator, then re-solves `(H_tt)⁻¹` for each block column.
4722    /// The resident factors already carry `G_i = L_iᵀ(H_tt)⁻¹L_i`, so each block
4723    /// is assembled by scattering only the active support pairs inside that block:
4724    ///
4725    /// ```text
4726    /// S_block -= Σ_i Σ_(s,t in block support) φ_s φ_t · G_i[channel_s, channel_t]
4727    /// ```
4728    ///
4729    /// It computes the same block-diagonal restriction as the generic path, but
4730    /// avoids the full-row `H_tβ` materialization and per-column triangular solves.
4731    pub(crate) fn build_block_jacobi_resident(
4732        sys: &ArrowSchurSystem,
4733        ridge_beta: f64,
4734        resident: &SaeResidentReducedSchur,
4735    ) -> Result<Self, ArrowSchurError> {
4736        let block_offsets = &sys.block_offsets;
4737        let p = resident.p;
4738        let mut schur_blocks: Vec<Array2<f64>> = Vec::with_capacity(block_offsets.len());
4739        for (block_idx, range) in block_offsets.iter().enumerate() {
4740            let b = range.end - range.start;
4741            let mut schur_block = Array2::<f64>::zeros((b, b));
4742            sys.penalty_block_add(
4743                BetaBlockId(block_idx),
4744                block_offsets.as_ref(),
4745                &mut schur_block,
4746            );
4747            for bi in 0..b {
4748                schur_block[[bi, bi]] += ridge_beta;
4749            }
4750            schur_blocks.push(schur_block);
4751        }
4752
4753        let row_into = |row: usize, blocks: &mut [Array2<f64>]| {
4754            let rf = &resident.rows[row];
4755            let di = rf.di;
4756            if di == 0 {
4757                return;
4758            }
4759            let support = &resident.a_phi[row];
4760            if support.is_empty() {
4761                return;
4762            }
4763            // `L_i` is the shared `local_jac[row]` slab (#1033) — byte-for-byte
4764            // the former per-row `rf.l` copy.
4765            let l_i = &resident.local_jac[row];
4766            for (block_idx, range) in block_offsets.iter().enumerate() {
4767                let block = &mut blocks[block_idx];
4768                for &(base_left, phi_left) in support {
4769                    if phi_left == 0.0 {
4770                        continue;
4771                    }
4772                    let left_start = base_left.max(range.start);
4773                    let left_end = (base_left + p).min(range.end);
4774                    if left_start >= left_end {
4775                        continue;
4776                    }
4777                    for &(base_right, phi_right) in support {
4778                        if phi_right == 0.0 {
4779                            continue;
4780                        }
4781                        let right_start = base_right.max(range.start);
4782                        let right_end = (base_right + p).min(range.end);
4783                        if right_start >= right_end {
4784                            continue;
4785                        }
4786                        let phi = phi_left * phi_right;
4787                        for gi in left_start..left_end {
4788                            let li = gi - range.start;
4789                            let ch_i = gi - base_left;
4790                            for gj in right_start..right_end {
4791                                let lj = gj - range.start;
4792                                let ch_j = gj - base_right;
4793                                let mut gij = 0.0_f64;
4794                                for r in 0..di {
4795                                    gij += l_i[r * p + ch_i] * rf.y[r * p + ch_j];
4796                                }
4797                                block[[li, lj]] -= phi * gij;
4798                            }
4799                        }
4800                    }
4801                }
4802            }
4803        };
4804
4805        let n = resident.rows.len();
4806        let parallel =
4807            n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
4808        if parallel {
4809            use rayon::prelude::*;
4810            const CHUNK: usize = 64;
4811            let n_blocks = block_offsets.len();
4812            let block_dims: Vec<usize> = block_offsets.iter().map(|r| r.end - r.start).collect();
4813            let partials: Vec<Vec<Array2<f64>>> = (0..n)
4814                .into_par_iter()
4815                .chunks(CHUNK)
4816                .map(|idxs| {
4817                    let mut local: Vec<Array2<f64>> = block_dims
4818                        .iter()
4819                        .map(|&b| Array2::<f64>::zeros((b, b)))
4820                        .collect();
4821                    for i in idxs {
4822                        row_into(i, &mut local);
4823                    }
4824                    local
4825                })
4826                .collect();
4827            for local in &partials {
4828                for bidx in 0..n_blocks {
4829                    schur_blocks[bidx] += &local[bidx];
4830                }
4831            }
4832        } else {
4833            for row in 0..n {
4834                row_into(row, &mut schur_blocks);
4835            }
4836        }
4837
4838        let mut blocks = Vec::with_capacity(block_offsets.len());
4839        for (block_idx, range) in block_offsets.iter().enumerate() {
4840            let b = range.end - range.start;
4841            let schur_block = &schur_blocks[block_idx];
4842            let factor_opt = {
4843                use faer::Side;
4844                let view = FaerArrayView::new(schur_block);
4845                FaerLlt::new(view.as_ref(), Side::Lower).ok()
4846            };
4847            if let Some(llt) = factor_opt {
4848                blocks.push(BlockFactor::Chol {
4849                    factor: llt,
4850                    range: range.clone(),
4851                });
4852            } else {
4853                let mut inv = Array1::<f64>::zeros(b);
4854                for bi in 0..b {
4855                    let v = schur_block[[bi, bi]];
4856                    if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
4857                        return Err(ArrowSchurError::PcgFailed {
4858                            reason: format!(
4859                                "SAE-resident block Jacobi scalar fallback: non-PD diagonal at \
4860                                 global index {}: {v}; regularization required",
4861                                range.start + bi
4862                            ),
4863                        });
4864                    }
4865                    inv[bi] = 1.0 / v;
4866                }
4867                blocks.push(BlockFactor::Scalar {
4868                    inv,
4869                    range: range.clone(),
4870                });
4871            }
4872        }
4873        Ok(Self { blocks })
4874    }
4875
4876    /// Build term-block Jacobi: one dense `b×b` Schur block per term in
4877    /// `sys.block_offsets`.
4878    pub(crate) fn build_block_jacobi<B: BatchedBlockSolver + Sync>(
4879        sys: &ArrowSchurSystem,
4880        htt_factors: &ArrowFactorSlab,
4881        ridge_beta: f64,
4882        backend: &B,
4883    ) -> Result<Self, ArrowSchurError> {
4884        let block_offsets = &sys.block_offsets;
4885
4886        // Initialise every b×b Schur sub-block from H_ββ + ridge·I via
4887        // penalty_block_add (#296): routes to penalty_op or falls back to
4888        // hbb / hbb_diag inline without Arc-clone per loop iteration. These are
4889        // the block-diagonal restrictions of the reduced Schur complement; the
4890        // per-row cross-block contributions are accumulated in the row sweep
4891        // below.
4892        let mut schur_blocks: Vec<Array2<f64>> = Vec::with_capacity(block_offsets.len());
4893        for (block_idx, range) in block_offsets.iter().enumerate() {
4894            let b = range.end - range.start;
4895            let mut schur_block = Array2::<f64>::zeros((b, b));
4896            sys.penalty_block_add(
4897                BetaBlockId(block_idx),
4898                block_offsets.as_ref(),
4899                &mut schur_block,
4900            );
4901            for bi in 0..b {
4902                schur_block[[bi, bi]] += ridge_beta;
4903            }
4904            schur_blocks.push(schur_block);
4905        }
4906
4907        // Subtract Schur contributions:
4908        // S_kk -= H_βt_k^(i) (H_tt^(i))^{-1} H_tβ_k^(i)
4909        //
4910        // Materialize each row's (d_i × K) cross-block ONCE and scatter its
4911        // contribution into every block-diagonal sub-block — mirroring the
4912        // row-outer structure of `build_dense_schur_direct`. The previous
4913        // block-outer form re-materialized every row for each β-block
4914        // (O(n_blocks · n · K) probes); for the matrix-free softmax cross-block
4915        // each materialize is itself O(K²), so that nesting made the
4916        // preconditioner build quadratically more expensive than the direct
4917        // dense Schur it preconditions. sys_htbeta_materialize_row handles the
4918        // Kronecker / htbeta_matvec path transparently.
4919        // Per-row body: materialize the row's `(d_i × K)` cross-block once and
4920        // subtract its `H_βt_k^(i)(H_tt^(i))⁻¹H_tβ_k^(i)` contribution into EACH
4921        // block-diagonal sub-block. Writes INTO a caller-provided `blocks`
4922        // accumulator (`-=`) so a rayon worker can subtract a chunk's rows into
4923        // a worker-private zero-seeded `Vec<Array2>` and the caller folds the
4924        // chunk partials back in chunk order — bit-identical run-to-run
4925        // regardless of thread scheduling (the #1017 verification gate). This
4926        // is deterministic and within the chunk-reassociation margin of serial,
4927        // so the preconditioner, hence the criterion ranking, is stable except
4928        // for near-tie candidates inside that f64 margin — not an exact no-move
4929        // guarantee (#1211).
4930        let row_into = |i: usize,
4931                        row: &ArrowRowBlock,
4932                        blocks: &mut [Array2<f64>]|
4933         -> Result<(), ArrowSchurError> {
4934            let di = sys.row_dims[i];
4935            let htbeta_full = sys_htbeta_materialize_row(sys, i, row)?;
4936            for (block_idx, range) in block_offsets.iter().enumerate() {
4937                let b = range.end - range.start;
4938                let mut solved_cols = Array2::<f64>::zeros((di, b));
4939                for bj in 0..b {
4940                    let gj = range.start + bj;
4941                    let rhs = htbeta_full.column(gj).to_owned();
4942                    let solved = backend.solve_block_vector(htt_factors.factor(i), rhs.view());
4943                    for c in 0..di {
4944                        solved_cols[[c, bj]] = solved[c];
4945                    }
4946                }
4947                let schur_block = &mut blocks[block_idx];
4948                for bi in 0..b {
4949                    let gi = range.start + bi;
4950                    for bj in 0..b {
4951                        let mut acc = 0.0;
4952                        for c in 0..di {
4953                            acc += htbeta_full[[c, gi]] * solved_cols[[c, bj]];
4954                        }
4955                        schur_block[[bi, bj]] -= acc;
4956                    }
4957                }
4958            }
4959            Ok(())
4960        };
4961        // Each row materializes an `O(K²)` cross-block (Kronecker) plus `Σ_k b_k`
4962        // triangular solves — the preconditioner build's whole per-row cost at
4963        // the SAE LLM shape (#1017), and the rows are independent. Fan over fixed
4964        // row chunks above the threshold, staying serial for the handful-of-rows
4965        // non-SAE callers and inside a rayon worker (topology-race nesting guard)
4966        // — the same gate `schur_matvec` uses.
4967        let n = sys.rows.len();
4968        let parallel =
4969            n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
4970        if parallel {
4971            use rayon::prelude::*;
4972            const CHUNK: usize = 64;
4973            let n_blocks = block_offsets.len();
4974            let block_dims: Vec<usize> = block_offsets.iter().map(|r| r.end - r.start).collect();
4975            let partials: Vec<Vec<Array2<f64>>> = (0..n)
4976                .into_par_iter()
4977                .chunks(CHUNK)
4978                .map(|idxs| {
4979                    let mut local: Vec<Array2<f64>> = block_dims
4980                        .iter()
4981                        .map(|&b| Array2::<f64>::zeros((b, b)))
4982                        .collect();
4983                    for i in idxs {
4984                        row_into(i, &sys.rows[i], &mut local)?;
4985                    }
4986                    Ok::<_, ArrowSchurError>(local)
4987                })
4988                .collect::<Result<Vec<_>, _>>()?;
4989            // Deterministic ordered reduction: fold chunk partials left-to-right.
4990            for local in &partials {
4991                for bidx in 0..n_blocks {
4992                    schur_blocks[bidx] += &local[bidx];
4993                }
4994            }
4995        } else {
4996            for (i, row) in sys.rows.iter().enumerate() {
4997                row_into(i, row, &mut schur_blocks)?;
4998            }
4999        }
5000
5001        // Factor each accumulated block: LLT, with scalar-diagonal fallback for
5002        // a block that comes out non-PD at this ridge.
5003        let mut blocks = Vec::with_capacity(block_offsets.len());
5004        for (block_idx, range) in block_offsets.iter().enumerate() {
5005            let b = range.end - range.start;
5006            let schur_block = &schur_blocks[block_idx];
5007            let factor_opt = {
5008                use faer::Side;
5009                let view = FaerArrayView::new(schur_block);
5010                FaerLlt::new(view.as_ref(), Side::Lower).ok()
5011            };
5012            if let Some(llt) = factor_opt {
5013                blocks.push(BlockFactor::Chol {
5014                    factor: llt,
5015                    range: range.clone(),
5016                });
5017            } else {
5018                // Non-PD block: fall back to scalar diagonal for this block.
5019                let mut inv = Array1::<f64>::zeros(b);
5020                for bi in 0..b {
5021                    let v = schur_block[[bi, bi]];
5022                    if !v.is_finite() || v <= JACOBI_DIAGONAL_PD_FLOOR {
5023                        return Err(ArrowSchurError::PcgFailed {
5024                            reason: format!(
5025                                "block Jacobi scalar fallback: non-PD diagonal at \
5026                                 global index {}: {v}; regularization required",
5027                                range.start + bi
5028                            ),
5029                        });
5030                    }
5031                    inv[bi] = 1.0 / v;
5032                }
5033                blocks.push(BlockFactor::Scalar {
5034                    inv,
5035                    range: range.clone(),
5036                });
5037            }
5038        }
5039        Ok(Self { blocks })
5040    }
5041
5042    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5043        let mut out = Array1::<f64>::zeros(r.len());
5044        for block in &self.blocks {
5045            match block {
5046                BlockFactor::Scalar { inv, range } => {
5047                    for (local, gi) in range.clone().enumerate() {
5048                        out[gi] = inv[local] * r[gi];
5049                    }
5050                }
5051                BlockFactor::Chol { factor, range } => {
5052                    let b = range.end - range.start;
5053                    let mut rhs = Array1::<f64>::zeros(b);
5054                    for (local, gi) in range.clone().enumerate() {
5055                        rhs[local] = r[gi];
5056                    }
5057                    use faer::linalg::solvers::Solve;
5058                    let stride = rhs.strides()[0];
5059                    let len = rhs.len();
5060                    // SAFETY: rhs is a uniquely-borrowed contiguous Array1
5061                    // with positive stride (standard layout).
5062                    let rhs_mat =
5063                        unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
5064                    let solved = factor.solve(rhs_mat);
5065                    for (local, gi) in range.clone().enumerate() {
5066                        out[gi] = solved[(local, 0)];
5067                    }
5068                }
5069            }
5070        }
5071        out
5072    }
5073}
5074
5075// ---------------------------------------------------------------------------
5076// Preconditioner ladder: SchurPreconditionerKind, ClusterJacobi,
5077// AdditiveSchwarz  (issue #299)
5078// ---------------------------------------------------------------------------
5079
5080/// Which Schur preconditioner to use in the inexact-PCG path.
5081///
5082/// Ladder ordered by cost / effectiveness:
5083/// - `Diagonal`: scalar Jacobi (pre-#283 behaviour).
5084/// - `BetaBlockJacobi`: block-Jacobi per `block_offsets` term (#287).
5085/// - `ClusterJacobi`: one dense block per beta-graph connected component.
5086/// - `AdditiveSchwarz { overlap }`: component + `overlap`-hop expansion,
5087///   overlapping columns averaged by partition-of-unity weights (full dense
5088///   local-inverse apply per subdomain).
5089/// - `DiagAssembledSchwarz { overlap }`: the cheap Schwarz variant (#299) —
5090///   same overlapping decomposition, but each subdomain contributes only the
5091///   diagonal of its local inverse `(A_k⁻¹)_ii`, assembled additively with
5092///   partition-of-unity weights into a single `O(K)`-apply diagonal.
5093/// - `BlockIncompleteCholesky`: level-0 incomplete Cholesky (#299). Within each
5094///   connected component of the β-coupling graph the dense reduced-Schur block
5095///   `S[C,C]` is assembled once, its structural-nonzero pattern is taken as the
5096///   level-0 fill pattern, and a no-fill incomplete Cholesky `S ≈ L̃ L̃ᵀ` is
5097///   formed keeping ONLY that pattern (Saad, *Iterative Methods*, IC(0)). Apply
5098///   is a sparse triangular forward/back solve over `nnz(S[C,C])`, so for a
5099///   large component with internal sparsity it is far cheaper to build and apply
5100///   than `ClusterJacobi`'s full dense Cholesky (which fills the whole `b×b`
5101///   factor) while retaining the inter-block coupling that ClusterJacobi keeps
5102///   but the diagonal/Schwarz tiers discard. A non-PD incomplete pivot degrades
5103///   that component to the scalar reciprocal diagonal.
5104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5105pub enum SchurPreconditionerKind {
5106    Diagonal,
5107    BetaBlockJacobi,
5108    ClusterJacobi,
5109    /// Cluster-Jacobi whose blocks come from the bounded co-visibility PARTITION
5110    /// (`BetaCouplingGraph::covisibility_cluster_partition`) rather than the
5111    /// connected-component partition. At real over-complete widths the co-firing
5112    /// graph is a single giant component, so plain `ClusterJacobi` exceeds the
5113    /// size cap and degrades to scalar Jacobi; this tier splits that component
5114    /// into bounded strongly-co-firing clusters so the dense per-cluster factor
5115    /// conditions the cross-atom coupling scalar Jacobi cannot see.
5116    CoVisibilityClusterJacobi,
5117    AdditiveSchwarz {
5118        overlap: usize,
5119    },
5120    DiagAssembledSchwarz {
5121        overlap: usize,
5122    },
5123    BlockIncompleteCholesky,
5124}
5125
5126/// Escalate beyond BetaBlockJacobi only when K exceeds this value and PCG
5127/// exhausted `max_iterations`.
5128pub(crate) const PRECOND_ESCALATE_K_THRESHOLD: usize = 100;
5129
5130/// #1026 matrix-free Schur curvature-floor (the unbounded-PCG analogue of the
5131/// dense `spectral_pd_floored_schur`). On `pᵀSp ≤ 0` in the unbounded SAE inner
5132/// PCG, the operator ridge is lifted by the minimal amount that restores
5133/// positive curvature along the offending direction, plus this fractional
5134/// margin (so the next CG iterate sits strictly inside the positive cone, not on
5135/// the `0` knife-edge).
5136pub(crate) const SCHUR_CURVATURE_FLOOR_MARGIN: f64 = 1.0e-2;
5137/// Lower bound on the curvature-floor ridge bump, relative to the rhs scale, so
5138/// a `pᵀSp` that rounds to exactly `0` still gets a strictly positive bump.
5139pub(crate) const SCHUR_CURVATURE_FLOOR_REL_FLOOR: f64 = 1.0e-12;
5140/// Ceiling on the accumulated curvature-floor ridge, relative to the rhs scale.
5141/// Beyond this the operator is treated as un-conditionable by a minimal floor
5142/// and the recoverable failure is handed to the outer LM loop (which re-forms
5143/// the whole system at a heavier ridge). Generous so that a large collapsed
5144/// over-subtraction `(H_tβ)²/H_tt` is still reachable.
5145pub(crate) const SCHUR_CURVATURE_FLOOR_REL_CEILING: f64 = 1.0e12;
5146/// Multiplicative growth for the DIAGONAL-refusal ridge escalation (no
5147/// `(curvature, ‖p‖²)` deficit is available there), matching the per-row
5148/// `factor_one_row_result` `RIDGE_GROWTH_FACTOR`.
5149pub(crate) const SCHUR_CURVATURE_FLOOR_DIAG_GROWTH: f64 = 10.0;
5150/// Max curvature-floor ridge-lift attempts before deferring to the outer LM
5151/// loop. The diagonal-refusal path grows ×10 per attempt, so this bounds the
5152/// reachable ridge at `rhs_scale · 10^(attempts)` — ample for any realistic
5153/// over-subtraction while still bounded.
5154pub(crate) const SCHUR_CURVATURE_FLOOR_MAX_ATTEMPTS: usize = 24;
5155
5156/// Cholesky or scalar factor for one cluster of the beta-coefficient graph.
5157#[derive(Clone)]
5158pub(crate) enum ClusterFactor {
5159    Chol {
5160        cols: Vec<usize>,
5161        factor: FaerLlt<f64>,
5162    },
5163    Scalar {
5164        cols: Vec<usize>,
5165        inv: Vec<f64>,
5166    },
5167}
5168
5169impl std::fmt::Debug for ClusterFactor {
5170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5171        match self {
5172            ClusterFactor::Chol { cols, .. } => {
5173                write!(f, "ClusterFactor::Chol {{ cols.len: {} }}", cols.len())
5174            }
5175            ClusterFactor::Scalar { cols, inv } => write!(
5176                f,
5177                "ClusterFactor::Scalar {{ cols.len: {}, inv.len: {} }}",
5178                cols.len(),
5179                inv.len()
5180            ),
5181        }
5182    }
5183}
5184
5185/// Maximum columns per cluster before scalar fallback.
5186pub(crate) const CLUSTER_JACOBI_MAX_CLUSTER: usize = 512;
5187
5188/// Host-memory budget for ONE cluster's dense reduced-Schur Cholesky factor
5189/// (the `b×b` f64 `L` the cluster-Jacobi preconditioner stores and applies).
5190///
5191/// The co-visibility cluster partition caps a cluster's total column count `b`
5192/// at the largest value whose factor fits this budget, `b_max = ⌊√(budget/8)⌋`
5193/// (`8b²` bytes for an `f64` `b×b` factor). This DERIVES the cluster-size cap
5194/// from the factor's memory footprint rather than asserting a bare number:
5195/// beyond `b_max` the dense factor's `O(b²)` apply also throttles the CG
5196/// iteration budget, so the cap is the point past which a single dense block
5197/// stops being the right preconditioner and the partition must split instead.
5198/// 2 MiB ⇒ `b_max = 512`, pinned equal to [`CLUSTER_JACOBI_MAX_CLUSTER`] by
5199/// [`tests::covisibility_cap_is_derived_from_factor_budget`] so the co-visibility
5200/// partition and the legacy scalar-fallback ceiling agree by construction.
5201pub(crate) const CLUSTER_SCHUR_FACTOR_BYTES_BUDGET: u128 = 2 * 1024 * 1024;
5202
5203/// Derived co-visibility cluster-size cap (columns): the largest `b` whose dense
5204/// `b×b` f64 Cholesky factor fits [`CLUSTER_SCHUR_FACTOR_BYTES_BUDGET`]. See that
5205/// constant for the memory justification. Never below 1.
5206pub(crate) fn covisibility_cluster_max_cols() -> usize {
5207    let b = ((CLUSTER_SCHUR_FACTOR_BYTES_BUDGET / 8) as f64)
5208        .sqrt()
5209        .floor() as usize;
5210    b.max(1)
5211}
5212
5213/// Maximum columns in a single connected component for which the IC(0)
5214/// preconditioner assembles the dense `S[C,C]` to derive its sparsity pattern.
5215/// IC(0) is cheap to APPLY at any size, but the pattern is read from the dense
5216/// assembly, which is `O(b²)` memory; beyond this the component falls back to
5217/// the scalar reciprocal diagonal (the same ceiling concern as
5218/// `CLUSTER_JACOBI_MAX_CLUSTER`, lifted because the IC(0) FACTOR is sparse).
5219pub(crate) const IC0_MAX_COMPONENT: usize = 4096;
5220
5221/// Relative threshold below which an assembled `S[i,j]` is treated as a
5222/// structural zero when deriving the IC(0) level-0 pattern. Scaled by
5223/// `sqrt(|S_ii|·|S_jj|)` so it is invariant to column scaling; this prunes
5224/// entries that are pure FMA round-off (a genuinely decoupled `(i,j)` pair
5225/// assembles to ~0) so they do not enter the kept fill pattern.
5226pub(crate) const IC0_PATTERN_REL_DROP: f64 = 1.0e-13;
5227
5228/// Assemble the dense `b×b` reduced-Schur block for the column set `cols`:
5229/// `S[cols, cols] = H_ββ[cols, cols] + ridge·I − Σ_i H_tβ[cols]ᵀ (H_tt^i)⁻¹ H_tβ[cols]`.
5230///
5231/// Shared by `ClusterJacobiPreconditioner::build_from_column_groups` (which
5232/// Cholesky-factors the returned block) and `DiagAssembledSchwarzPreconditioner`
5233/// (which inverts each subdomain block and keeps only its diagonal). The result
5234/// is the LOWER triangle filled by the row reduction; callers that need the full
5235/// symmetric block must `symmetrize_upper_from_lower`.
5236///
5237/// The per-row Schur contribution is fanned over fixed 64-row chunks above
5238/// `SCHUR_MATVEC_PARALLEL_ROW_MIN` and folded left-to-right so the assembly is
5239/// bit-identical to the serial path (and run-to-run deterministic), exactly as
5240/// in `build_block_jacobi` (#1017).
5241pub(crate) fn assemble_local_schur_block<B: BatchedBlockSolver + Sync>(
5242    sys: &ArrowSchurSystem,
5243    htt_factors: &ArrowFactorSlab,
5244    ridge_beta: f64,
5245    backend: &B,
5246    cols: &[usize],
5247) -> Array2<f64> {
5248    let b = cols.len();
5249    let mut s_block = Array2::<f64>::zeros((b, b));
5250    // Initialise from H_ββ via penalty_subblock_add (#296): routes through
5251    // penalty_op or falls back to hbb / hbb_diag inline.
5252    sys.penalty_subblock_add(cols, &mut s_block);
5253    for bi in 0..b {
5254        s_block[[bi, bi]] += ridge_beta;
5255    }
5256    let cluster_row_into = |row_idx: usize, row: &ArrowRowBlock, acc: &mut Array2<f64>| {
5257        // Materialize the b needed cross-block columns through the ROUTED
5258        // `H_tβ` convention (`sys_htbeta_apply_row`: matrix-free operator plus
5259        // any dense supplement) at the row's OWN width `di` — never a raw
5260        // `row.htbeta` read at the global `sys.d`: matvec-backed rows carry
5261        // absent/zero-sized slabs by contract (a raw read is wrong or panics),
5262        // and per-row widths vary.
5263        let di = sys.row_dims[row_idx];
5264        let mut e_g = Array1::<f64>::zeros(sys.k);
5265        let mut col_i = Array1::<f64>::zeros(di);
5266        let mut cols_mat = Array2::<f64>::zeros((di, b));
5267        let mut solved_cols = Array2::<f64>::zeros((di, b));
5268        for bj in 0..b {
5269            let gj = cols[bj];
5270            e_g[gj] = 1.0;
5271            sys_htbeta_apply_row(sys, row_idx, row, e_g.view(), &mut col_i);
5272            e_g[gj] = 0.0;
5273            let solved = backend.solve_block_vector(htt_factors.factor(row_idx), col_i.view());
5274            for c in 0..di {
5275                cols_mat[[c, bj]] = col_i[c];
5276                solved_cols[[c, bj]] = solved[c];
5277            }
5278        }
5279        for bi in 0..b {
5280            for bj in 0..b {
5281                let mut dot = 0.0;
5282                for c in 0..di {
5283                    dot += cols_mat[[c, bi]] * solved_cols[[c, bj]];
5284                }
5285                acc[[bi, bj]] -= dot;
5286            }
5287        }
5288    };
5289    let n = sys.rows.len();
5290    let parallel = n >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
5291    if parallel {
5292        use rayon::prelude::*;
5293        const CHUNK: usize = 64;
5294        let partials: Vec<Array2<f64>> = (0..n)
5295            .into_par_iter()
5296            .chunks(CHUNK)
5297            .map(|idxs| {
5298                let mut local = Array2::<f64>::zeros((b, b));
5299                for i in idxs {
5300                    cluster_row_into(i, &sys.rows[i], &mut local);
5301                }
5302                local
5303            })
5304            .collect();
5305        for local in &partials {
5306            s_block += local;
5307        }
5308    } else {
5309        for (row_idx, row) in sys.rows.iter().enumerate() {
5310            cluster_row_into(row_idx, row, &mut s_block);
5311        }
5312    }
5313    s_block
5314}
5315
5316/// Column groups for the bounded co-visibility cluster preconditioner.
5317///
5318/// Builds the weighted co-firing graph over `sys.block_offsets` and returns the
5319/// column sets of its bounded co-visibility partition
5320/// (`BetaCouplingGraph::covisibility_cluster_partition`), each capped at
5321/// [`covisibility_cluster_max_cols`] columns. With no registered block offsets
5322/// there is no block structure to cluster, so the whole `0..k` border is one
5323/// group (identical to the component-partition builders' `block_offsets`-empty
5324/// case). Each group's columns are sorted ascending.
5325pub(crate) fn covisibility_column_groups(sys: &ArrowSchurSystem) -> Vec<Vec<usize>> {
5326    if sys.block_offsets.is_empty() {
5327        return vec![(0..sys.k).collect()];
5328    }
5329    let graph = BetaCouplingGraph::build_from_system(sys);
5330    graph
5331        .covisibility_cluster_partition(&sys.block_offsets, covisibility_cluster_max_cols())
5332        .iter()
5333        .map(|blocks| {
5334            let mut cols: Vec<usize> = blocks
5335                .iter()
5336                .flat_map(|&b| sys.block_offsets[b].clone())
5337                .collect();
5338            cols.sort_unstable();
5339            cols
5340        })
5341        .collect()
5342}
5343
5344/// Dense Schur block per connected component of the beta-coupling graph.
5345///
5346/// Nodes = beta blocks (`block_offsets`); edges = rows where two blocks
5347/// co-occur with nonzero `H_t_beta` entries. One Cholesky factor per
5348/// connected component; applied as a triangular solve.
5349#[derive(Debug, Clone)]
5350pub struct ClusterJacobiPreconditioner {
5351    pub(crate) clusters: Vec<ClusterFactor>,
5352}
5353
5354impl ClusterJacobiPreconditioner {
5355    pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
5356        sys: &ArrowSchurSystem,
5357        htt_factors: &ArrowFactorSlab,
5358        ridge_beta: f64,
5359        backend: &B,
5360    ) -> Result<Self, ArrowSchurError> {
5361        if sys.block_offsets.is_empty() {
5362            let cols: Vec<usize> = (0..sys.k).collect();
5363            return Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &[cols]);
5364        }
5365        let graph = BetaCouplingGraph::build_from_system(sys);
5366        let col_groups: Vec<Vec<usize>> = graph
5367            .component_partition()
5368            .iter()
5369            .map(|comp_blocks| {
5370                let mut cols: Vec<usize> = comp_blocks
5371                    .iter()
5372                    .flat_map(|&b| sys.block_offsets[b].clone())
5373                    .collect();
5374                cols.sort_unstable();
5375                cols
5376            })
5377            .collect();
5378        Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
5379    }
5380
5381    /// Cluster-Jacobi from the bounded CO-VISIBILITY partition (Kushal & Agarwal,
5382    /// CVPR 2012) — the default above the size cap.
5383    ///
5384    /// [`Self::from_arrow_schur`] groups β-blocks by CONNECTED COMPONENT of the
5385    /// co-firing graph. At real over-complete SAE widths that graph is a single
5386    /// giant component (transitive co-firing), so the lone component's column
5387    /// count exceeds [`CLUSTER_JACOBI_MAX_CLUSTER`] and
5388    /// [`Self::build_from_column_groups`] degrades the whole tier to the scalar
5389    /// reciprocal diagonal — the scaling ceiling (cross-atom coupling through
5390    /// co-activating atoms with overlapping ambient subspaces is dropped, and PCG
5391    /// iteration counts blow up). This builder instead partitions the co-firing
5392    /// graph into clusters bounded by [`covisibility_cluster_max_cols`], keeping
5393    /// the strongest co-firing edges inside a cluster, so each cluster's dense
5394    /// Cholesky conditions the strong cross-atom coupling the scalar diagonal
5395    /// misses while staying inside the per-factor memory budget.
5396    ///
5397    /// With no registered `block_offsets` (or a graph that fits the cap in one
5398    /// piece) the partition is a single group and this coincides with
5399    /// [`Self::from_arrow_schur`]. Because the preconditioner only steers the CG
5400    /// iterate over the SAME reduced operator, the solve converges to the SAME
5401    /// reduced-system solution regardless of the partition — REML-neutral.
5402    pub(crate) fn from_arrow_schur_covisibility<B: BatchedBlockSolver + Sync>(
5403        sys: &ArrowSchurSystem,
5404        htt_factors: &ArrowFactorSlab,
5405        ridge_beta: f64,
5406        backend: &B,
5407    ) -> Result<Self, ArrowSchurError> {
5408        let col_groups = covisibility_column_groups(sys);
5409        Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
5410    }
5411
5412    pub(crate) fn build_from_column_groups<B: BatchedBlockSolver + Sync>(
5413        sys: &ArrowSchurSystem,
5414        htt_factors: &ArrowFactorSlab,
5415        ridge_beta: f64,
5416        backend: &B,
5417        col_groups: &[Vec<usize>],
5418    ) -> Result<Self, ArrowSchurError> {
5419        let mut clusters = Vec::with_capacity(col_groups.len());
5420        for cols in col_groups {
5421            let b = cols.len();
5422            if b == 0 {
5423                continue;
5424            }
5425            if b > CLUSTER_JACOBI_MAX_CLUSTER {
5426                let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5427                clusters.push(ClusterFactor::Scalar {
5428                    cols: cols.clone(),
5429                    inv,
5430                });
5431                continue;
5432            }
5433            let mut s_block =
5434                assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
5435            symmetrize_upper_from_lower(&mut s_block);
5436            let factor_opt = {
5437                use faer::Side;
5438                let view = FaerArrayView::new(&s_block);
5439                FaerLlt::new(view.as_ref(), Side::Lower).ok()
5440            };
5441            if let Some(llt) = factor_opt {
5442                clusters.push(ClusterFactor::Chol {
5443                    cols: cols.clone(),
5444                    factor: llt,
5445                });
5446            } else {
5447                let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5448                clusters.push(ClusterFactor::Scalar {
5449                    cols: cols.clone(),
5450                    inv,
5451                });
5452            }
5453        }
5454        Ok(Self { clusters })
5455    }
5456
5457    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5458        let mut out = Array1::<f64>::zeros(r.len());
5459        for cluster in &self.clusters {
5460            apply_cluster(cluster, r, &mut out, &ClusterApplyMode::Overwrite);
5461        }
5462        out
5463    }
5464}
5465
5466/// Additive Schwarz: base components expanded by `overlap` graph-hops;
5467/// overlapping columns averaged by partition-of-unity weights.
5468#[derive(Debug, Clone)]
5469pub struct AdditiveSchwarzPreconditioner {
5470    pub(crate) clusters: Vec<ClusterFactor>,
5471    pub(crate) weights: Vec<f64>,
5472}
5473
5474impl AdditiveSchwarzPreconditioner {
5475    pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
5476        sys: &ArrowSchurSystem,
5477        htt_factors: &ArrowFactorSlab,
5478        ridge_beta: f64,
5479        backend: &B,
5480        overlap: usize,
5481    ) -> Result<Self, ArrowSchurError> {
5482        if sys.block_offsets.is_empty() {
5483            let cols: Vec<usize> = (0..sys.k).collect();
5484            let inner = ClusterJacobiPreconditioner::build_from_column_groups(
5485                sys,
5486                htt_factors,
5487                ridge_beta,
5488                backend,
5489                &[cols],
5490            )?;
5491            return Ok(Self {
5492                clusters: inner.clusters,
5493                weights: vec![1.0f64; sys.k],
5494            });
5495        }
5496        let graph = BetaCouplingGraph::build_from_system(sys);
5497        let col_groups: Vec<Vec<usize>> = graph
5498            .component_partition()
5499            .iter()
5500            .map(|seed| {
5501                let mut current = seed.clone();
5502                for _ in 0..overlap {
5503                    current = graph.expand_one_hop(&current);
5504                }
5505                let mut cols: Vec<usize> = current
5506                    .iter()
5507                    .flat_map(|&b| sys.block_offsets[b].clone())
5508                    .collect();
5509                cols.sort_unstable();
5510                cols.dedup();
5511                cols
5512            })
5513            .collect();
5514        let mut counts = vec![0u32; sys.k];
5515        for cols in &col_groups {
5516            for &gi in cols {
5517                counts[gi] += 1;
5518            }
5519        }
5520        let weights: Vec<f64> = counts
5521            .iter()
5522            .map(|&c| if c == 0 { 1.0 } else { 1.0 / c as f64 })
5523            .collect();
5524        let inner = ClusterJacobiPreconditioner::build_from_column_groups(
5525            sys,
5526            htt_factors,
5527            ridge_beta,
5528            backend,
5529            &col_groups,
5530        )?;
5531        Ok(Self {
5532            clusters: inner.clusters,
5533            weights,
5534        })
5535    }
5536
5537    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5538        let mut out = Array1::<f64>::zeros(r.len());
5539        for cluster in &self.clusters {
5540            apply_cluster(
5541                cluster,
5542                r,
5543                &mut out,
5544                &ClusterApplyMode::Accumulate {
5545                    weights: &self.weights,
5546                },
5547            );
5548        }
5549        out
5550    }
5551}
5552
5553/// Diagonal-assembled additive Schwarz (#299).
5554///
5555/// The cheap Schwarz variant the domain-decomposition literature recommends as
5556/// the default for sparse-coupling β-graphs: instead of storing and applying a
5557/// dense Cholesky factor per overlapping subdomain (as
5558/// [`AdditiveSchwarzPreconditioner`] does), it inverts each overlapping
5559/// subdomain Schur block ONCE at build time and keeps only the **diagonal of the
5560/// local inverse** `(A_k⁻¹)_ii`. Those per-subdomain diagonal contributions are
5561/// then assembled additively across overlapping subdomains with partition-of-
5562/// unity weights into a single global diagonal `m`, applied as `out[i] = m[i]·r[i]`.
5563///
5564/// This is strictly richer than scalar Jacobi (`1/S_ii`): the local inverse
5565/// diagonal `(A_k⁻¹)_ii` folds in the off-diagonal coupling WITHIN the subdomain,
5566/// so a strongly-coupled column gets a smaller (better-damped) effective scale
5567/// than its bare reciprocal diagonal would give — while the apply stays `O(K)`
5568/// (one multiply per column), unlike the `O(Σ b_k²)` triangular solves of dense
5569/// Schwarz. For `overlap = 0` and one column per subdomain it reduces exactly to
5570/// scalar Jacobi.
5571#[derive(Debug, Clone)]
5572pub struct DiagAssembledSchwarzPreconditioner {
5573    /// Global per-column multiplier `m[i]`; `out[i] = m[i] · r[i]`.
5574    pub(crate) inv_diag: Vec<f64>,
5575}
5576
5577impl DiagAssembledSchwarzPreconditioner {
5578    pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
5579        sys: &ArrowSchurSystem,
5580        htt_factors: &ArrowFactorSlab,
5581        ridge_beta: f64,
5582        backend: &B,
5583        overlap: usize,
5584    ) -> Result<Self, ArrowSchurError> {
5585        // Build the overlapping subdomain column groups exactly like
5586        // AdditiveSchwarz (component partition + `overlap` graph-hop expansion),
5587        // so the two Schwarz variants decompose the β space identically and
5588        // differ only in how each subdomain's local inverse is applied.
5589        let col_groups: Vec<Vec<usize>> = if sys.block_offsets.is_empty() {
5590            vec![(0..sys.k).collect()]
5591        } else {
5592            let graph = BetaCouplingGraph::build_from_system(sys);
5593            graph
5594                .component_partition()
5595                .iter()
5596                .map(|seed| {
5597                    let mut current = seed.clone();
5598                    for _ in 0..overlap {
5599                        current = graph.expand_one_hop(&current);
5600                    }
5601                    let mut cols: Vec<usize> = current
5602                        .iter()
5603                        .flat_map(|&b| sys.block_offsets[b].clone())
5604                        .collect();
5605                    cols.sort_unstable();
5606                    cols.dedup();
5607                    cols
5608                })
5609                .collect()
5610        };
5611        Self::build_from_column_groups(sys, htt_factors, ridge_beta, backend, &col_groups)
5612    }
5613
5614    pub(crate) fn build_from_column_groups<B: BatchedBlockSolver + Sync>(
5615        sys: &ArrowSchurSystem,
5616        htt_factors: &ArrowFactorSlab,
5617        ridge_beta: f64,
5618        backend: &B,
5619        col_groups: &[Vec<usize>],
5620    ) -> Result<Self, ArrowSchurError> {
5621        // Partition-of-unity weights: a column shared by `c` subdomains gets each
5622        // of its `c` diagonal contributions scaled by `1/c`, so the assembled
5623        // diagonal is a convex combination (and reduces to a single contribution
5624        // for non-overlapping columns).
5625        let mut counts = vec![0u32; sys.k];
5626        for cols in col_groups {
5627            for &gi in cols {
5628                counts[gi] += 1;
5629            }
5630        }
5631        let mut accum = vec![0.0f64; sys.k];
5632        for cols in col_groups {
5633            let b = cols.len();
5634            if b == 0 {
5635                continue;
5636            }
5637            // For large subdomains, the dense inverse is too costly; fall back to
5638            // the global scalar Schur diagonal inverse `1/S_ii` for those columns
5639            // (the diag-assembled variant then coincides with scalar Jacobi over
5640            // that subdomain, which is exactly the intended cheap degradation).
5641            if b > CLUSTER_JACOBI_MAX_CLUSTER {
5642                let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5643                for (local, &gi) in cols.iter().enumerate() {
5644                    let w = if counts[gi] == 0 {
5645                        1.0
5646                    } else {
5647                        1.0 / counts[gi] as f64
5648                    };
5649                    accum[gi] += w * inv[local];
5650                }
5651                continue;
5652            }
5653            let mut s_block =
5654                assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
5655            symmetrize_upper_from_lower(&mut s_block);
5656            // Diagonal of the local inverse `(A_k⁻¹)_ii`, obtained by solving
5657            // `A_k X = I` through the same faer Cholesky used elsewhere; on a
5658            // non-PD local block, degrade to the scalar reciprocal diagonal.
5659            let local_inv_diag = match local_inverse_diagonal(&s_block) {
5660                Some(diag) => diag,
5661                None => {
5662                    let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5663                    inv
5664                }
5665            };
5666            for (local, &gi) in cols.iter().enumerate() {
5667                let w = if counts[gi] == 0 {
5668                    1.0
5669                } else {
5670                    1.0 / counts[gi] as f64
5671                };
5672                accum[gi] += w * local_inv_diag[local];
5673            }
5674        }
5675        // A column never covered by any subdomain (only possible for `k` columns
5676        // with no block_offsets coverage) keeps a neutral unit scale.
5677        for (gi, &c) in counts.iter().enumerate() {
5678            if c == 0 {
5679                accum[gi] = 1.0;
5680            }
5681        }
5682        for (gi, m) in accum.iter().enumerate() {
5683            if !m.is_finite() || *m <= 0.0 {
5684                return Err(ArrowSchurError::PcgFailed {
5685                    reason: format!(
5686                        "diag-assembled Schwarz: non-positive assembled diagonal at index {gi}: {m}"
5687                    ),
5688                });
5689            }
5690        }
5691        Ok(Self { inv_diag: accum })
5692    }
5693
5694    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5695        let mut out = Array1::<f64>::zeros(r.len());
5696        for (gi, &m) in self.inv_diag.iter().enumerate() {
5697            out[gi] = m * r[gi];
5698        }
5699        out
5700    }
5701}
5702
5703/// Diagonal of `A⁻¹` for a small dense SPD block `A`, via the same faer
5704/// Cholesky used by the cluster/Schwarz factors. Returns `None` if `A` is not
5705/// positive-definite (caller degrades to the scalar reciprocal diagonal).
5706pub(crate) fn local_inverse_diagonal(a: &Array2<f64>) -> Option<Vec<f64>> {
5707    let b = a.nrows();
5708    let llt = {
5709        use faer::Side;
5710        let view = FaerArrayView::new(a);
5711        FaerLlt::new(view.as_ref(), Side::Lower).ok()?
5712    };
5713    use faer::linalg::solvers::Solve;
5714    let mut diag = Vec::with_capacity(b);
5715    for col in 0..b {
5716        // Solve `A x = e_col`; the `col`-th entry of `x` is `(A⁻¹)_{col,col}`.
5717        let mut rhs = Array1::<f64>::zeros(b);
5718        rhs[col] = 1.0;
5719        let stride = rhs.strides()[0];
5720        let len = rhs.len();
5721        // SAFETY: `rhs` is a uniquely-borrowed contiguous `Array1<f64>` of `len`
5722        // elements with positive row stride; a single column never dereferences
5723        // the column stride, so `0` is sound.
5724        let rhs_mat = unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
5725        let solved = llt.solve(rhs_mat);
5726        diag.push(solved[(col, 0)]);
5727    }
5728    Some(diag)
5729}
5730
5731/// How a cluster factor's contribution is written into the output vector.
5732///
5733/// `Overwrite` assigns `out[gi] = value` (non-overlapping clusters, each global
5734/// column touched by exactly one cluster). `Accumulate` adds the partition-of-unity
5735/// weighted contribution `out[gi] += weights[gi] * value` (overlapping Schwarz
5736/// clusters, where a column may belong to several clusters).
5737pub(crate) enum ClusterApplyMode<'w> {
5738    Overwrite,
5739    Accumulate { weights: &'w [f64] },
5740}
5741
5742impl ClusterApplyMode<'_> {
5743    #[inline]
5744    pub(crate) fn write(&self, out: &mut Array1<f64>, gi: usize, value: f64) {
5745        match self {
5746            ClusterApplyMode::Overwrite => out[gi] = value,
5747            ClusterApplyMode::Accumulate { weights } => out[gi] += weights[gi] * value,
5748        }
5749    }
5750}
5751
5752/// Apply a single cluster factor to the residual `r`, writing into `out`
5753/// according to `mode` (overwrite for non-overlapping clusters, weighted
5754/// accumulate for overlapping Schwarz clusters).
5755pub(crate) fn apply_cluster(
5756    cluster: &ClusterFactor,
5757    r: &Array1<f64>,
5758    out: &mut Array1<f64>,
5759    mode: &ClusterApplyMode<'_>,
5760) {
5761    match cluster {
5762        ClusterFactor::Scalar { cols, inv } => {
5763            for (local, &gi) in cols.iter().enumerate() {
5764                mode.write(out, gi, inv[local] * r[gi]);
5765            }
5766        }
5767        ClusterFactor::Chol { cols, factor } => {
5768            let b = cols.len();
5769            let mut rhs = Array1::<f64>::zeros(b);
5770            for (local, &gi) in cols.iter().enumerate() {
5771                rhs[local] = r[gi];
5772            }
5773            use faer::linalg::solvers::Solve;
5774            let stride = rhs.strides()[0];
5775            let len = rhs.len();
5776            // SAFETY: rhs is uniquely-borrowed contiguous Array1 with positive stride.
5777            let rhs_mat = unsafe { faer::MatRef::from_raw_parts(rhs.as_ptr(), len, 1, stride, 0) };
5778            let solved = factor.solve(rhs_mat);
5779            for (local, &gi) in cols.iter().enumerate() {
5780                mode.write(out, gi, solved[(local, 0)]);
5781            }
5782        }
5783    }
5784}
5785
5786/// One connected-component factor of the block IC(0) preconditioner.
5787///
5788/// `IncompleteChol` holds a sparse lower-triangular `L̃` in column-compressed
5789/// form over the component's local indices: `col_ptr[j]..col_ptr[j+1]` indexes
5790/// into `(row_idx, val)` for column `j` (rows `>= j`, diagonal first). `cols`
5791/// maps a local index back to its global β column. `Scalar` is the non-PD /
5792/// oversized degradation, identical in meaning to [`ClusterFactor::Scalar`].
5793#[derive(Clone)]
5794pub(crate) enum Ic0Factor {
5795    IncompleteChol {
5796        cols: Vec<usize>,
5797        col_ptr: Vec<usize>,
5798        row_idx: Vec<usize>,
5799        val: Vec<f64>,
5800    },
5801    Scalar {
5802        cols: Vec<usize>,
5803        inv: Vec<f64>,
5804    },
5805}
5806
5807impl std::fmt::Debug for Ic0Factor {
5808    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5809        match self {
5810            Ic0Factor::IncompleteChol { cols, val, .. } => write!(
5811                f,
5812                "Ic0Factor::IncompleteChol {{ cols.len: {}, nnz: {} }}",
5813                cols.len(),
5814                val.len()
5815            ),
5816            Ic0Factor::Scalar { cols, .. } => {
5817                write!(f, "Ic0Factor::Scalar {{ cols.len: {} }}", cols.len())
5818            }
5819        }
5820    }
5821}
5822
5823/// Level-0 incomplete-Cholesky Schur preconditioner (#299).
5824///
5825/// One sparse incomplete-Cholesky factor per connected component of the
5826/// β-coupling graph. Within a component the dense `S[C,C]` is assembled, its
5827/// structural-nonzero pattern `P = { (i,j) : |S_ij| > drop·sqrt(S_ii S_jj) }`
5828/// is taken as the level-0 fill set, and the no-fill incomplete Cholesky
5829/// `S ≈ L̃ L̃ᵀ` is formed keeping only `P` (drop any update landing outside it).
5830/// See [`SchurPreconditionerKind::BlockIncompleteCholesky`].
5831#[derive(Debug, Clone)]
5832pub struct BlockIncompleteCholeskyPreconditioner {
5833    pub(crate) components: Vec<Ic0Factor>,
5834}
5835
5836impl BlockIncompleteCholeskyPreconditioner {
5837    pub fn from_arrow_schur<B: BatchedBlockSolver + Sync>(
5838        sys: &ArrowSchurSystem,
5839        htt_factors: &ArrowFactorSlab,
5840        ridge_beta: f64,
5841        backend: &B,
5842    ) -> Result<Self, ArrowSchurError> {
5843        // Column grouping mirrors ClusterJacobi: one group per connected
5844        // component of the β-coupling graph (whole-K single group when no
5845        // block_offsets are registered), so IC(0) preconditions exactly the
5846        // coupling ClusterJacobi keeps, but with a sparse (no-fill) factor.
5847        let col_groups: Vec<Vec<usize>> = if sys.block_offsets.is_empty() {
5848            vec![(0..sys.k).collect()]
5849        } else {
5850            let graph = BetaCouplingGraph::build_from_system(sys);
5851            graph
5852                .component_partition()
5853                .iter()
5854                .map(|comp| {
5855                    let mut cols: Vec<usize> = comp
5856                        .iter()
5857                        .flat_map(|&blk| sys.block_offsets[blk].clone())
5858                        .collect();
5859                    cols.sort_unstable();
5860                    cols.dedup();
5861                    cols
5862                })
5863                .collect()
5864        };
5865
5866        let mut components = Vec::with_capacity(col_groups.len());
5867        for cols in &col_groups {
5868            let b = cols.len();
5869            if b == 0 {
5870                continue;
5871            }
5872            if b > IC0_MAX_COMPONENT {
5873                let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5874                components.push(Ic0Factor::Scalar {
5875                    cols: cols.clone(),
5876                    inv,
5877                });
5878                continue;
5879            }
5880            let mut s_block =
5881                assemble_local_schur_block(sys, htt_factors, ridge_beta, backend, cols);
5882            symmetrize_upper_from_lower(&mut s_block);
5883            match incomplete_cholesky_level0(&s_block) {
5884                Some((col_ptr, row_idx, val)) => components.push(Ic0Factor::IncompleteChol {
5885                    cols: cols.clone(),
5886                    col_ptr,
5887                    row_idx,
5888                    val,
5889                }),
5890                None => {
5891                    // Non-PD incomplete pivot: degrade this component to the
5892                    // scalar reciprocal diagonal (mirrors the ClusterJacobi
5893                    // non-PD fallback), which is always applicable for a
5894                    // PD-floored Schur diagonal.
5895                    let inv = build_schur_scalar_inv(sys, htt_factors, ridge_beta, backend, cols)?;
5896                    components.push(Ic0Factor::Scalar {
5897                        cols: cols.clone(),
5898                        inv,
5899                    });
5900                }
5901            }
5902        }
5903        Ok(Self { components })
5904    }
5905
5906    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
5907        let mut out = Array1::<f64>::zeros(r.len());
5908        for comp in &self.components {
5909            match comp {
5910                Ic0Factor::Scalar { cols, inv } => {
5911                    for (local, &gi) in cols.iter().enumerate() {
5912                        out[gi] = inv[local] * r[gi];
5913                    }
5914                }
5915                Ic0Factor::IncompleteChol {
5916                    cols,
5917                    col_ptr,
5918                    row_idx,
5919                    val,
5920                } => {
5921                    let b = cols.len();
5922                    // Gather the local residual, solve `L̃ L̃ᵀ z = r_local` by a
5923                    // sparse forward solve (`L̃ y = r`) then a sparse back solve
5924                    // (`L̃ᵀ z = y`), then scatter `z` back to global columns.
5925                    let mut z = vec![0.0f64; b];
5926                    for (local, &gi) in cols.iter().enumerate() {
5927                        z[local] = r[gi];
5928                    }
5929                    // Forward solve `L̃ y = r` (overwrite z with y). Column-major
5930                    // CSC: row_idx[col_ptr[j]] == j (diagonal stored first).
5931                    for j in 0..b {
5932                        let dstart = col_ptr[j];
5933                        let diag = val[dstart];
5934                        z[j] /= diag;
5935                        let yj = z[j];
5936                        for k in (dstart + 1)..col_ptr[j + 1] {
5937                            z[row_idx[k]] -= val[k] * yj;
5938                        }
5939                    }
5940                    // Back solve `L̃ᵀ z = y` (overwrite z). Walk columns in
5941                    // reverse; the below-diagonal entries of column j are the
5942                    // off-diagonal entries of row j of L̃ᵀ.
5943                    for j in (0..b).rev() {
5944                        let dstart = col_ptr[j];
5945                        let mut acc = z[j];
5946                        for k in (dstart + 1)..col_ptr[j + 1] {
5947                            acc -= val[k] * z[row_idx[k]];
5948                        }
5949                        z[j] = acc / val[dstart];
5950                    }
5951                    for (local, &gi) in cols.iter().enumerate() {
5952                        out[gi] = z[local];
5953                    }
5954                }
5955            }
5956        }
5957        out
5958    }
5959}
5960
5961/// Level-0 incomplete Cholesky of a dense SPD-ish block `a` (`b×b`, symmetric).
5962///
5963/// Returns the lower factor `L̃` in column-compressed (CSC) form
5964/// `(col_ptr, row_idx, val)` where each column lists its diagonal entry FIRST
5965/// followed by the strictly-below-diagonal entries, in increasing row order.
5966/// The kept pattern is the level-0 set `P` = structural nonzeros of `a` (a
5967/// relative drop threshold prunes round-off). IC(0) computes the standard
5968/// Cholesky recurrence but DROPS any value at a position outside `P`, so the
5969/// factor has exactly `nnz(tril(P))` entries — no fill. Returns `None` on a
5970/// non-positive pivot (caller degrades to scalar diagonal).
5971///
5972/// Reference: Y. Saad, *Iterative Methods for Sparse Linear Systems*, 2nd ed.,
5973/// §10.3.2 (IC(0)). This is the left-looking, pattern-restricted variant.
5974pub(crate) fn incomplete_cholesky_level0(
5975    a: &Array2<f64>,
5976) -> Option<(Vec<usize>, Vec<usize>, Vec<f64>)> {
5977    let b = a.nrows();
5978    assert_eq!(a.ncols(), b, "incomplete Cholesky needs a square block");
5979
5980    // ---- derive the level-0 lower-triangular pattern from `a` --------------
5981    // Per column j, the kept below-or-on-diagonal rows i>=j with a structurally
5982    // nonzero a[i,j]. The diagonal is always kept.
5983    let mut col_ptr = vec![0usize; b + 1];
5984    let mut row_idx: Vec<usize> = Vec::new();
5985    // value buffer, parallel to row_idx, initialised from tril(a) on the pattern
5986    let mut val: Vec<f64> = Vec::new();
5987    // For O(1) "is (i,j) in pattern + where" lookups during the recurrence, keep
5988    // a per-column map from global row -> position in that column's value slice.
5989    let mut col_pos: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(b);
5990    for j in 0..b {
5991        let ajj = a[[j, j]];
5992        let scale_j = ajj.abs().max(0.0).sqrt();
5993        let mut map = std::collections::HashMap::new();
5994        // diagonal first
5995        map.insert(j, val.len());
5996        row_idx.push(j);
5997        val.push(ajj);
5998        for i in (j + 1)..b {
5999            let aij = a[[i, j]];
6000            let scale_i = a[[i, i]].abs().sqrt();
6001            let thresh = IC0_PATTERN_REL_DROP * scale_i * scale_j;
6002            if aij.abs() > thresh {
6003                map.insert(i, val.len());
6004                row_idx.push(i);
6005                val.push(aij);
6006            }
6007        }
6008        col_pos.push(map);
6009        col_ptr[j + 1] = val.len();
6010    }
6011
6012    // ---- IC(0) recurrence, left-looking over columns -----------------------
6013    // For column j: subtract the contributions of all prior columns k<j that
6014    // have BOTH a nonzero at row j (so they touch the diagonal/the column) — the
6015    // multiplier L[j,k] — and a nonzero at the rows i of column j's pattern.
6016    // Any update whose target (i,j) is OUTSIDE the kept pattern is dropped.
6017    for j in 0..b {
6018        // Diagonal: a[j,j] - Σ_{k<j} L[j,k]². Each prior column k<j contributes
6019        // its row-j entry L[j,k] (looked up by row, so the column index is not
6020        // needed); columns without a row-j entry contribute nothing.
6021        let dpos = col_ptr[j];
6022        let mut diag = val[dpos];
6023        for mapk in &col_pos[..j] {
6024            if let Some(&pjk) = mapk.get(&j) {
6025                let ljk = val[pjk];
6026                diag -= ljk * ljk;
6027            }
6028        }
6029        if !diag.is_finite() || diag <= JACOBI_DIAGONAL_PD_FLOOR {
6030            return None;
6031        }
6032        let ljj = diag.sqrt();
6033        val[dpos] = ljj;
6034        // Below-diagonal of column j: L[i,j] = (a[i,j] - Σ_{k<j} L[i,k] L[j,k]) / L[j,j]
6035        for p in (dpos + 1)..col_ptr[j + 1] {
6036            let i = row_idx[p];
6037            let mut s = val[p];
6038            for mapk in &col_pos[..j] {
6039                if let (Some(&pik), Some(&pjk)) = (mapk.get(&i), mapk.get(&j)) {
6040                    s -= val[pik] * val[pjk];
6041                }
6042            }
6043            val[p] = s / ljj;
6044        }
6045    }
6046    Some((col_ptr, row_idx, val))
6047}
6048
6049/// One row of the #299 preconditioner-ladder iteration study: the converged
6050/// PCG iteration count and stop reason for a single preconditioner tier.
6051#[derive(Debug, Clone, Copy)]
6052pub struct PrecondLadderRow {
6053    /// PCG iterations to convergence (or to the `MaxIter` cutoff).
6054    pub iterations: usize,
6055    /// Whether the PCG converged (vs hit `MaxIter` / negative curvature).
6056    pub converged: bool,
6057    /// Final relative residual reported by the PCG.
6058    pub final_relative_residual: f64,
6059}
6060
6061/// Build scalar diagonal inverses for a set of global column indices.
6062///
6063/// Used when a cluster is non-PD or exceeds `CLUSTER_JACOBI_MAX_CLUSTER`.
6064pub(crate) fn build_schur_scalar_inv<B: BatchedBlockSolver>(
6065    sys: &ArrowSchurSystem,
6066    htt_factors: &ArrowFactorSlab,
6067    ridge_beta: f64,
6068    backend: &B,
6069    cols: &[usize],
6070) -> Result<Vec<f64>, ArrowSchurError> {
6071    let mut result = Vec::with_capacity(cols.len());
6072    // Extract the penalty diagonal for all K columns once, then index per-column.
6073    let mut full_diag = Array1::<f64>::zeros(sys.k);
6074    {
6075        let diag_slice = full_diag.as_slice_mut().expect("full_diag contiguous");
6076        sys.penalty_diagonal_add(diag_slice);
6077    }
6078    // Probe each needed column through the ROUTED `H_tβ` convention at each
6079    // row's own width (see `assemble_local_schur_block` for why a raw
6080    // `row.htbeta` read at the global `sys.d` is wrong here).
6081    let mut e_g = Array1::<f64>::zeros(sys.k);
6082    for &gi in cols {
6083        let mut s = full_diag[gi] + ridge_beta;
6084        e_g[gi] = 1.0;
6085        for (row_idx, row) in sys.rows.iter().enumerate() {
6086            let di = sys.row_dims[row_idx];
6087            let mut col_vec = Array1::<f64>::zeros(di);
6088            sys_htbeta_apply_row(sys, row_idx, row, e_g.view(), &mut col_vec);
6089            let solved = backend.solve_block_vector(htt_factors.factor(row_idx), col_vec.view());
6090            let mut acc = 0.0;
6091            for c in 0..di {
6092                acc += col_vec[c] * solved[c];
6093            }
6094            s -= acc;
6095        }
6096        e_g[gi] = 0.0;
6097        if !s.is_finite() || s <= JACOBI_DIAGONAL_PD_FLOOR {
6098            return Err(ArrowSchurError::PcgFailed {
6099                reason: format!(
6100                    "cluster Schur scalar fallback: non-PD diagonal at index {gi}: {s}"
6101                ),
6102            });
6103        }
6104        result.push(1.0 / s);
6105    }
6106    Ok(result)
6107}
6108
6109/// Inexact PCG with automatic preconditioner-ladder escalation.
6110///
6111/// Starts with `JacobiPreconditioner` (Diagonal or BetaBlockJacobi).
6112/// If PCG hits `MaxIter` and `k > PRECOND_ESCALATE_K_THRESHOLD`,
6113/// escalates to `ClusterJacobi`; if still `MaxIter`, escalates to
6114/// `AdditiveSchwarz { overlap: 1 }`.
6115pub(crate) fn steihaug_pcg_auto<B: BatchedBlockSolver + Sync>(
6116    sys: &ArrowSchurSystem,
6117    htt_factors: &ArrowFactorSlab,
6118    ridge_beta: f64,
6119    rhs: &Array1<f64>,
6120    pcg: &ArrowPcgOptions,
6121    trust: &ArrowTrustRegionOptions,
6122    backend: &B,
6123    gpu_matvec: Option<&GpuSchurMatvec>,
6124    metric_weights: Option<&MetricWeights>,
6125    curvature_floor: Option<f64>,
6126) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
6127    // #1017 CPU residency: stage the per-row reduced-Schur factors `(L_i, Y_i)`
6128    // (NOT the dense `p×p` block — `di ≪ p`, so the factored form is `O(n·di·p)`
6129    // memory and `2·support_i·p + 2·di·p` flops/row including the sparse
6130    // gather/scatter over the active support) once, up
6131    // front, when the SAE structure is installed and the matvec runs on host
6132    // (CPU). The GPU matvec carries its own residency, so skip when it is engaged.
6133    // The same staged operator is reused across the whole preconditioner ladder
6134    // (Jacobi → ClusterJacobi → AdditiveSchwarz) — built once, not per tier.
6135    let resident = if gpu_matvec.is_none() {
6136        SaeResidentReducedSchur::build(sys, htt_factors, backend)
6137    } else {
6138        None
6139    };
6140    // #2228 — a β-gauge-quotiented system has a reduced Schur that is singular
6141    // along the gauge orbit, and every preconditioner in the ladder below
6142    // (block-Jacobi, cluster, Schwarz, IC(0)) is formed from the UN-pinned
6143    // operator, so it would misprice — or refuse as non-PD — that orbit
6144    // direction. The matvec now applies the Faddeev–Popov pin `P S P + Q Qᵀ`,
6145    // which is SPD and well-conditioned on the identifiable complement (the gauge
6146    // dimension is tiny — one direction per circle/torus phase), so an identity
6147    // preconditioner converges without a bespoke pinned diagonal. Route straight
6148    // through it and skip the diagonal ladder, whose preconditioners assume the
6149    // un-pinned Schur; the `None`-quotient path below is byte-identical.
6150    if sys.beta_gauge_quotient.is_some() {
6151        let identity = IdentityPreconditioner;
6152        let (step, diag) = run_pcg_with_preconditioner(
6153            sys,
6154            htt_factors,
6155            ridge_beta,
6156            rhs,
6157            |r| identity.apply(r),
6158            pcg,
6159            trust,
6160            backend,
6161            gpu_matvec,
6162            metric_weights,
6163            resident.as_ref(),
6164        )?;
6165        // Mirror the non-gauge contract: below the escalation threshold a MaxIter
6166        // stop is accepted (the ladder returns it as `Ok`); above it the ladder
6167        // would escalate the preconditioner, but the cluster/Schwarz/IC(0) tiers
6168        // assume the un-pinned Schur and cannot precondition the gauge pin, so
6169        // surface a recoverable failure and let the outer LM loop escalate the
6170        // ridge instead (a bespoke pinned-diagonal preconditioner is the follow-up).
6171        if diag.stopping_reason == PcgStopReason::MaxIter
6172            && sys.k > PRECOND_ESCALATE_K_THRESHOLD
6173        {
6174            return Err(ArrowSchurError::PcgFailed {
6175                reason: format!(
6176                    "gauge-pinned Schur PCG (identity preconditioner) exhausted its \
6177                     iteration budget without converging; final relative residual = {:e}",
6178                    diag.final_relative_residual
6179                ),
6180            });
6181        }
6182        return Ok((step, diag));
6183    }
6184    // #1026 — curvature-floor retry on the Jacobi tier. The unbounded SAE inner
6185    // PCG (trust radius = ∞) fails on `pᵀSp ≤ 0` when the reduced Schur is
6186    // indefinite (K≥4 co-collapse: a near-singular per-row `H_tt` over-subtracts
6187    // `S`). Instead of letting that failure propagate to the outer LM loop —
6188    // which inflates `ridge_β` over EVERY β direction and makes the inner Newton
6189    // crawl — floor the OPERATOR by the minimal ridge `δ = |pᵀSp|/‖p‖² · (1+ε)`
6190    // that restores positive curvature along the offending direction, rebuild the
6191    // Jacobi preconditioner at the lifted ridge, and retry. This is the
6192    // matrix-free analogue of the dense `spectral_pd_floored_schur`: the healthy
6193    // β subspace (where curvature is already positive) is essentially untouched
6194    // by a tiny `δ`, while the collapsed direction gets exactly the stiffness it
6195    // needs to make a real descent step. A PD reduced Schur never hits `pᵀSp ≤ 0`,
6196    // so this loop is a strict no-op there (bit-for-bit unchanged). Bounded by a
6197    // small attempt cap and a relative ridge ceiling; on exhaustion the original
6198    // recoverable failure still reaches the outer LM loop.
6199    let mut effective_ridge = ridge_beta;
6200    let mut x0_diag0: Option<(Array1<f64>, ArrowPcgDiagnostics)> = None;
6201    let mut last_curvature_err: Option<ArrowSchurError> = None;
6202    let rhs_scale = metric_norm(rhs.view(), metric_weights).max(1.0);
6203    let ridge_ceiling = ridge_beta.max(SCHUR_CURVATURE_FLOOR_REL_CEILING * rhs_scale);
6204    for _attempt in 0..=SCHUR_CURVATURE_FLOOR_MAX_ATTEMPTS {
6205        // The Jacobi preconditioner build itself refuses a non-PD Schur diagonal
6206        // (`PcgFailed: invalid Schur Jacobi diagonal`) — the SAME co-collapse
6207        // signature reached BEFORE the CG loop, since `S_ii = H_ββ,ii − Σ …` goes
6208        // negative. Treat that build failure as a curvature deficit too: when the
6209        // floor is enabled, lift the ridge and retry; otherwise propagate.
6210        let jacobi = match JacobiPreconditioner::from_arrow_schur(
6211            sys,
6212            htt_factors,
6213            effective_ridge,
6214            backend,
6215            resident.as_ref(),
6216        ) {
6217            Ok(jacobi) => jacobi,
6218            Err(err @ ArrowSchurError::PcgFailed { .. }) => {
6219                if curvature_floor.is_none() {
6220                    return Err(err);
6221                }
6222                // A diagonal refusal carries no `(curvature, ‖p‖²)` deficit, and
6223                // the over-subtraction magnitude `Σ H_tβᵀ(H_tt)⁻¹H_tβ` is
6224                // unbounded relative to `rhs_scale`, so a small additive bump
6225                // would crawl. Escalate the ridge MULTIPLICATIVELY (×10, matching
6226                // the per-row `factor_one_row_result` RIDGE_GROWTH_FACTOR), seeded
6227                // at `rhs_scale`, so even a large deficit (the collapsed
6228                // `(H_tβ)²/H_tt` over-subtraction) is reached in a handful of
6229                // attempts. The ceiling + attempt cap still bound it; on
6230                // exhaustion the recoverable failure reaches the outer LM loop.
6231                // Jump straight to a meaningful scale on the FIRST refusal rather
6232                // than crawling ×10 from a tiny `ridge_beta`: each rebuild is a full
6233                // block-Jacobi factorization (the massive-K preconditioner hotspot),
6234                // and a large collapsed deficit (`Σ H_tβᵀ(H_tt)⁻¹H_tβ` over-subtraction,
6235                // O(1)-scale) otherwise costs ~log10(deficit / ridge_beta) rebuilds.
6236                // Seeding the first bump at `rhs_scale` covers it in one or two, then
6237                // escalates multiplicatively; the ceiling + attempt cap still bound it.
6238                let next = if effective_ridge > 0.0 {
6239                    (effective_ridge * SCHUR_CURVATURE_FLOOR_DIAG_GROWTH).max(rhs_scale)
6240                } else {
6241                    rhs_scale
6242                };
6243                last_curvature_err = Some(err);
6244                if !next.is_finite() || next > ridge_ceiling {
6245                    break;
6246                }
6247                effective_ridge = next;
6248                continue;
6249            }
6250            Err(other) => return Err(other),
6251        };
6252        match run_pcg_with_preconditioner(
6253            sys,
6254            htt_factors,
6255            effective_ridge,
6256            rhs,
6257            |r| jacobi.apply(r),
6258            pcg,
6259            trust,
6260            backend,
6261            gpu_matvec,
6262            metric_weights,
6263            resident.as_ref(),
6264        ) {
6265            Ok(result) => {
6266                x0_diag0 = Some(result);
6267                break;
6268            }
6269            Err(ArrowSchurError::UnboundedNegativeCurvature {
6270                curvature,
6271                direction_norm_sq,
6272            }) => {
6273                // Only floor when the caller opted in (SAE solve path); otherwise
6274                // propagate the raw negative-curvature signal so BA / non-SAE
6275                // unbounded solves keep their existing failure contract.
6276                let Some(relative_floor) = curvature_floor else {
6277                    return Err(ArrowSchurError::UnboundedNegativeCurvature {
6278                        curvature,
6279                        direction_norm_sq,
6280                    });
6281                };
6282                // Minimal ridge to make `pᵀ(S+δI)p = |curvature| + δ·‖p‖² > 0`,
6283                // with a margin so the next CG iterate has strictly positive
6284                // curvature rather than sitting on the `0` knife-edge.
6285                let deficit = if direction_norm_sq > 0.0 {
6286                    curvature.abs() / direction_norm_sq
6287                } else {
6288                    0.0
6289                };
6290                let bump = (deficit * (1.0 + SCHUR_CURVATURE_FLOOR_MARGIN))
6291                    .max(relative_floor.max(SCHUR_CURVATURE_FLOOR_REL_FLOOR) * rhs_scale);
6292                let next = (effective_ridge + bump).max(effective_ridge * 2.0);
6293                last_curvature_err = Some(ArrowSchurError::UnboundedNegativeCurvature {
6294                    curvature,
6295                    direction_norm_sq,
6296                });
6297                if !next.is_finite() || next > ridge_ceiling {
6298                    break;
6299                }
6300                effective_ridge = next;
6301            }
6302            Err(other) => return Err(other),
6303        }
6304    }
6305    let (x0, diag0) = match x0_diag0 {
6306        Some(result) => result,
6307        None => {
6308            // The curvature floor could not condition the operator within the
6309            // ceiling; hand the recoverable failure to the outer LM loop, which
6310            // re-forms the system at a heavier ridge.
6311            return Err(last_curvature_err.unwrap_or(ArrowSchurError::PcgFailed {
6312                reason: "unbounded Schur PCG negative curvature unresolved by curvature floor"
6313                    .to_string(),
6314            }));
6315        }
6316    };
6317    if sys.k <= PRECOND_ESCALATE_K_THRESHOLD || diag0.stopping_reason != PcgStopReason::MaxIter {
6318        return Ok((x0, diag0));
6319    }
6320    // Escalation tiers reuse the curvature-floored `effective_ridge` so the
6321    // operator they precondition is the SAME (PD-floored) one the Jacobi tier
6322    // settled on; a still-negative-curvature signal here is handed to the outer
6323    // LM loop (it only arises if the floored Jacobi tier merely ran out of
6324    // iterations yet a coarser preconditioner still finds an indefinite
6325    // direction — rare; the LM loop re-forms at a heavier ridge).
6326    // Default cluster tier: the bounded CO-VISIBILITY partition, not the
6327    // connected-component partition. At the SAE widths this ladder targets the
6328    // co-firing graph is one giant component, so the component partition exceeds
6329    // the size cap and `from_arrow_schur` degrades to scalar Jacobi (the ceiling
6330    // this tier exists to lift). `from_arrow_schur_covisibility` splits that
6331    // component into bounded strongly-co-firing clusters whose dense factors
6332    // condition the cross-atom coupling scalar Jacobi drops. The component
6333    // partition stays selectable via `from_arrow_schur` (used by the ladder
6334    // study and its regression gates). Both precondition the SAME operator, so
6335    // the converged step — and the REML optimum — is unchanged.
6336    let cluster = ClusterJacobiPreconditioner::from_arrow_schur_covisibility(
6337        sys,
6338        htt_factors,
6339        effective_ridge,
6340        backend,
6341    )?;
6342    let (x1, diag1) = run_pcg_with_preconditioner(
6343        sys,
6344        htt_factors,
6345        effective_ridge,
6346        rhs,
6347        |r| cluster.apply(r),
6348        pcg,
6349        trust,
6350        backend,
6351        gpu_matvec,
6352        metric_weights,
6353        resident.as_ref(),
6354    )?;
6355    if diag1.stopping_reason != PcgStopReason::MaxIter {
6356        return Ok((x1, diag1));
6357    }
6358    let schwarz = AdditiveSchwarzPreconditioner::from_arrow_schur(
6359        sys,
6360        htt_factors,
6361        effective_ridge,
6362        backend,
6363        1,
6364    )?;
6365    let (x2, diag2) = run_pcg_with_preconditioner(
6366        sys,
6367        htt_factors,
6368        effective_ridge,
6369        rhs,
6370        |r| schwarz.apply(r),
6371        pcg,
6372        trust,
6373        backend,
6374        gpu_matvec,
6375        metric_weights,
6376        resident.as_ref(),
6377    )?;
6378    if diag2.stopping_reason != PcgStopReason::MaxIter {
6379        return Ok((x2, diag2));
6380    }
6381    // Final tier — diagonal-assembled additive Schwarz (#299), the cheap-apply
6382    // Schwarz variant. When the dense-block AdditiveSchwarz still ran out of
6383    // iterations its O(Σ b_k²) apply may have throttled the iteration budget on
6384    // a wide subdomain; the diag-assembled variant keeps Schwarz's overlapping
6385    // local-inverse conditioning but applies in O(K), so it can take more CG
6386    // iterations within the same wall budget. Same overlap (1) and same
6387    // curvature-floored ridge as the dense-block tier.
6388    let diag_schwarz = DiagAssembledSchwarzPreconditioner::from_arrow_schur(
6389        sys,
6390        htt_factors,
6391        effective_ridge,
6392        backend,
6393        1,
6394    )?;
6395    let (x3, diag3) = run_pcg_with_preconditioner(
6396        sys,
6397        htt_factors,
6398        effective_ridge,
6399        rhs,
6400        |r| diag_schwarz.apply(r),
6401        pcg,
6402        trust,
6403        backend,
6404        gpu_matvec,
6405        metric_weights,
6406        resident.as_ref(),
6407    )?;
6408    if diag3.stopping_reason != PcgStopReason::MaxIter {
6409        return Ok((x3, diag3));
6410    }
6411    // Richest tier — level-0 incomplete Cholesky (#299). ClusterJacobi keeps the
6412    // full DENSE Cholesky of each component (so on a single large connected
6413    // component it fills the whole `b×b` factor and its `O(b²)` apply throttles
6414    // the CG iteration budget), while the diagonal/Schwarz tiers drop most
6415    // inter-block coupling. IC(0) keeps the component's full structural coupling
6416    // but only the level-0 (no-fill) pattern, so its sparse triangular apply is
6417    // `O(nnz(S[C,C]))` — it can take more CG iterations within the same wall
6418    // budget AND conditions the off-diagonal coupling the cheap tiers discard.
6419    // Last in the ladder so it is only paid when every cheaper tier stalled.
6420    let ic0 = BlockIncompleteCholeskyPreconditioner::from_arrow_schur(
6421        sys,
6422        htt_factors,
6423        effective_ridge,
6424        backend,
6425    )?;
6426    let (x4, diag4) = run_pcg_with_preconditioner(
6427        sys,
6428        htt_factors,
6429        effective_ridge,
6430        rhs,
6431        |r| ic0.apply(r),
6432        pcg,
6433        trust,
6434        backend,
6435        gpu_matvec,
6436        metric_weights,
6437        resident.as_ref(),
6438    )?;
6439    // All five preconditioner tiers (Jacobi -> ClusterJacobi -> AdditiveSchwarz
6440    // -> DiagAssembledSchwarz -> BlockIncompleteCholesky) exhausted their
6441    // iteration budget without driving the residual below tolerance. Returning a
6442    // truncated iterate as `Ok` would feed an arbitrarily-large-residual step
6443    // into the Newton driver, where the PCG diagnostics are discarded. Surface a
6444    // recoverable failure instead so `solve_with_lm_escalation_inner` escalates
6445    // the proximal ridge: better conditioning is precisely what a stalled PCG on
6446    // an ill-conditioned reduced system needs.
6447    if diag4.stopping_reason == PcgStopReason::MaxIter {
6448        return Err(ArrowSchurError::PcgFailed {
6449            reason: format!(
6450                "Schur PCG exhausted all preconditioner tiers (Jacobi, ClusterJacobi, \
6451                 AdditiveSchwarz, DiagAssembledSchwarz, BlockIncompleteCholesky) at MaxIter; \
6452                 final relative residual = {:e}",
6453                diag4.final_relative_residual
6454            ),
6455        });
6456    }
6457    Ok((x4, diag4))
6458}
6459
6460/// Run Steihaug-CG with a generic preconditioner closure.
6461/// Routes matvec through GPU when `gpu_matvec` is set.
6462pub(crate) fn run_pcg_with_preconditioner<ApplyPrec, B: BatchedBlockSolver + Sync>(
6463    sys: &ArrowSchurSystem,
6464    htt_factors: &ArrowFactorSlab,
6465    ridge_beta: f64,
6466    rhs: &Array1<f64>,
6467    apply_prec: ApplyPrec,
6468    pcg: &ArrowPcgOptions,
6469    trust: &ArrowTrustRegionOptions,
6470    backend: &B,
6471    gpu_matvec: Option<&GpuSchurMatvec>,
6472    metric_weights: Option<&MetricWeights>,
6473    resident: Option<&SaeResidentReducedSchur>,
6474) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError>
6475where
6476    ApplyPrec: FnMut(&Array1<f64>) -> Array1<f64>,
6477{
6478    let max_iters = pcg.max_iterations.min(trust.max_iterations);
6479    let tol = pcg
6480        .relative_tolerance
6481        .max(trust.steihaug_relative_tolerance);
6482    // #2228 — route the fit-step matvec through `ReducedSchurOperator`, which
6483    // applies the Faddeev–Popov pin `v ↦ P S P v + Q Qᵀ v` when the system carries
6484    // a β-gauge quotient and is byte-for-byte the bare `gpu_matvec` / `schur_matvec`
6485    // apply when it does not. This gauge-fixes the wide-`p` InexactPCG Newton step
6486    // exactly like the dense Direct/SqrtBA modes while leaving the `None`-quotient
6487    // lane (every non-SAE-fit caller) unchanged.
6488    let op = ReducedSchurOperator::new(sys, htt_factors, ridge_beta, backend, resident)
6489        .with_gpu_matvec(gpu_matvec);
6490    steihaug_cg(
6491        rhs,
6492        |p, out| op.apply_into(p, out),
6493        apply_prec,
6494        max_iters,
6495        tol,
6496        trust.radius,
6497        metric_weights,
6498    )
6499}
6500
6501#[derive(Debug, Clone, Copy)]
6502pub(crate) struct IdentityPreconditioner;
6503
6504impl IdentityPreconditioner {
6505    pub(crate) fn apply(&self, r: &Array1<f64>) -> Array1<f64> {
6506        r.clone()
6507    }
6508}
6509
6510pub(crate) fn steihaug_dense_system(
6511    schur: &Array2<f64>,
6512    rhs: &Array1<f64>,
6513    preconditioner: &IdentityPreconditioner,
6514    pcg: &ArrowPcgOptions,
6515    trust: &ArrowTrustRegionOptions,
6516    metric_weights: Option<&MetricWeights>,
6517) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
6518    steihaug_cg(
6519        rhs,
6520        |p, out| dense_matvec(schur, p, out),
6521        |r| preconditioner.apply(r),
6522        pcg.max_iterations,
6523        pcg.relative_tolerance,
6524        trust.radius,
6525        metric_weights,
6526    )
6527}
6528
6529pub(crate) fn steihaug_cg<MatVec, ApplyPrec>(
6530    rhs: &Array1<f64>,
6531    mut matvec: MatVec,
6532    mut apply_preconditioner: ApplyPrec,
6533    max_iterations: usize,
6534    relative_tolerance: f64,
6535    trust_radius: f64,
6536    metric_weights: Option<&MetricWeights>,
6537) -> Result<(Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError>
6538where
6539    MatVec: FnMut(&Array1<f64>, &mut Array1<f64>),
6540    ApplyPrec: FnMut(&Array1<f64>) -> Array1<f64>,
6541{
6542    let n = rhs.len();
6543    if let Some(weights) = metric_weights {
6544        assert_eq!(
6545            weights.len(),
6546            n,
6547            "Steihaug-CG metric weight length must match solve dimension"
6548        );
6549    }
6550    let radius = if trust_radius.is_finite() && trust_radius > 0.0 {
6551        trust_radius
6552    } else {
6553        f64::INFINITY
6554    };
6555    let rhs_norm = metric_norm(rhs.view(), metric_weights);
6556    if rhs_norm == 0.0 {
6557        return Ok((Array1::<f64>::zeros(n), ArrowPcgDiagnostics::default()));
6558    }
6559    let tol = (relative_tolerance.max(0.0) * rhs_norm).max(PCG_ABSOLUTE_TOLERANCE_FLOOR);
6560    let mut x = Array1::<f64>::zeros(n);
6561    let mut r = rhs.clone();
6562    let mut z = apply_preconditioner(&r);
6563    let mut diag = ArrowPcgDiagnostics {
6564        precond_apply_calls: 1,
6565        ..ArrowPcgDiagnostics::default()
6566    };
6567    let mut p = z.clone();
6568    let mut rz = metric_dot(&r, &z, metric_weights);
6569    if rz <= 0.0 || !rz.is_finite() {
6570        if radius.is_finite() {
6571            diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
6572            diag.stopping_reason = PcgStopReason::TrustRegion;
6573            return Ok((step_to_trust_boundary(&x, &r, radius, metric_weights), diag));
6574        }
6575        // Unbounded (radius = ∞) non-positive preconditioned residual: the
6576        // reduced Schur is indefinite at the very first direction. Surface the
6577        // typed curvature-floor signal so `steihaug_pcg_auto` floors the
6578        // operator minimally and retries, instead of failing into a global
6579        // `ridge_β` ramp. `rz = rᵀM⁻¹r` is a preconditioner-metric curvature;
6580        // report it with the residual norm² as the direction scale.
6581        return Err(ArrowSchurError::UnboundedNegativeCurvature {
6582            curvature: rz,
6583            direction_norm_sq: metric_dot(&r, &r, metric_weights),
6584        });
6585    }
6586    if metric_norm(r.view(), metric_weights) <= tol {
6587        diag.final_relative_residual = 0.0;
6588        diag.stopping_reason = PcgStopReason::Converged;
6589        return Ok((x, diag));
6590    }
6591    let mut ap = Array1::<f64>::zeros(n);
6592    // Reused candidate scratch — avoid per-iteration clone of x.
6593    let mut candidate = Array1::<f64>::zeros(n);
6594    for _ in 0..max_iterations {
6595        matvec(&p, &mut ap);
6596        diag.matvec_calls += 1;
6597        diag.iterations += 1;
6598        let pap = metric_dot(&p, &ap, metric_weights);
6599        if pap <= 0.0 || !pap.is_finite() {
6600            if radius.is_finite() {
6601                diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
6602                diag.stopping_reason = PcgStopReason::TrustRegion;
6603                return Ok((step_to_trust_boundary(&x, &p, radius, metric_weights), diag));
6604            }
6605            // Unbounded negative curvature `pᵀSp ≤ 0`: the reduced Schur is
6606            // indefinite along `p` (the #1026 co-collapse direction). Surface
6607            // the typed signal carrying `pᵀSp` and `‖p‖²` so the caller floors
6608            // the operator by the minimal ridge `δ = |pᵀSp|/‖p‖²` (which makes
6609            // `pᵀ(S+δI)p = 0⁺`) plus a margin, and retries.
6610            return Err(ArrowSchurError::UnboundedNegativeCurvature {
6611                curvature: pap,
6612                direction_norm_sq: metric_dot(&p, &p, metric_weights),
6613            });
6614        }
6615        let alpha = rz / pap;
6616        for i in 0..n {
6617            candidate[i] = x[i] + alpha * p[i];
6618        }
6619        if radius.is_finite() && metric_norm(candidate.view(), metric_weights) >= radius {
6620            diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
6621            diag.stopping_reason = PcgStopReason::TrustRegion;
6622            return Ok((step_to_trust_boundary(&x, &p, radius, metric_weights), diag));
6623        }
6624        x.assign(&candidate);
6625        for i in 0..n {
6626            r[i] -= alpha * ap[i];
6627        }
6628        if metric_norm(r.view(), metric_weights) <= tol {
6629            diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
6630            diag.stopping_reason = PcgStopReason::Converged;
6631            return Ok((x, diag));
6632        }
6633        z = apply_preconditioner(&r);
6634        diag.precond_apply_calls += 1;
6635        let rz_next = metric_dot(&r, &z, metric_weights);
6636        if rz_next <= 0.0 || !rz_next.is_finite() {
6637            return Err(ArrowSchurError::PcgFailed {
6638                reason: "non-positive or non-finite PCG residual".to_string(),
6639            });
6640        }
6641        let beta = rz_next / rz;
6642        for i in 0..n {
6643            p[i] = z[i] + beta * p[i];
6644        }
6645        rz = rz_next;
6646    }
6647    diag.final_relative_residual = metric_norm(r.view(), metric_weights) / rhs_norm;
6648    diag.stopping_reason = PcgStopReason::MaxIter;
6649    Ok((x, diag))
6650}
6651
6652pub(crate) fn step_to_trust_boundary(
6653    x: &Array1<f64>,
6654    p: &Array1<f64>,
6655    radius: f64,
6656    metric_weights: Option<&MetricWeights>,
6657) -> Array1<f64> {
6658    let pp = metric_dot(p, p, metric_weights);
6659    if pp == 0.0 {
6660        return x.clone();
6661    }
6662    let xp = metric_dot(x, p, metric_weights);
6663    let xx = metric_dot(x, x, metric_weights);
6664    let disc = (xp * xp + pp * (radius * radius - xx)).max(0.0);
6665    let tau = (-xp + disc.sqrt()) / pp;
6666    let mut out = x.clone();
6667    for i in 0..out.len() {
6668        out[i] += tau * p[i];
6669    }
6670    out
6671}
6672
6673pub(crate) fn dense_matvec(a: &Array2<f64>, x: &Array1<f64>, out: &mut Array1<f64>) {
6674    let n = a.nrows();
6675    for i in 0..n {
6676        let mut acc = 0.0;
6677        for j in 0..n {
6678            acc += a[[i, j]] * x[j];
6679        }
6680        out[i] = acc;
6681    }
6682}
6683
6684pub(crate) fn dot(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
6685    let mut acc = 0.0;
6686    for i in 0..a.len() {
6687        acc += a[i] * b[i];
6688    }
6689    acc
6690}
6691
6692pub(crate) fn metric_dot(
6693    a: &Array1<f64>,
6694    b: &Array1<f64>,
6695    metric_weights: Option<&MetricWeights>,
6696) -> f64 {
6697    assert_eq!(a.len(), b.len());
6698    match metric_weights {
6699        Some(weights) => {
6700            assert_eq!(weights.len(), a.len());
6701            let mut acc = 0.0;
6702            for i in 0..a.len() {
6703                acc += weights[i] * a[i] * b[i];
6704            }
6705            acc
6706        }
6707        None => dot(a, b),
6708    }
6709}
6710
6711pub(crate) fn metric_norm(v: ArrayView1<'_, f64>, metric_weights: Option<&MetricWeights>) -> f64 {
6712    let mut acc = 0.0;
6713    match metric_weights {
6714        Some(weights) => {
6715            assert_eq!(weights.len(), v.len());
6716            for i in 0..v.len() {
6717                acc += weights[i] * v[i] * v[i];
6718            }
6719        }
6720        None => {
6721            for x in v.iter() {
6722                acc += x * x;
6723            }
6724        }
6725    }
6726    acc.sqrt()
6727}
6728
6729pub(crate) fn symmetrize_upper_from_lower(a: &mut Array2<f64>) {
6730    let n = a.nrows().min(a.ncols());
6731    for i in 0..n {
6732        for j in 0..i {
6733            let v = 0.5 * (a[[i, j]] + a[[j, i]]);
6734            a[[i, j]] = v;
6735            a[[j, i]] = v;
6736        }
6737    }
6738}
6739
6740/// Errors raised by [`ArrowSchurSystem::solve`].
6741#[derive(Debug, Clone)]
6742pub enum ArrowSchurError {
6743    /// A per-row `H_tt^(i)` block was not positive-definite at the
6744    /// supplied ridge. Indicates an under-regularized latent block —
6745    /// typically a gauge-free fit without an identifiability penalty.
6746    PerRowFactorFailed { row: usize, reason: String },
6747    /// A per-row `H_tt^(i)` block factored, but the Cholesky factor failed
6748    /// the safe-inversion guard for the Schur reduction. This can be either
6749    /// an excessive diagonal-ratio condition-number estimate or a numerically
6750    /// tiny pivot relative to the row block scale. Cholesky technically
6751    /// succeeded, but the inverse used in
6752    /// `S = H_ββ − Σ_i H_tβ^(i)ᵀ (H_tt^(i))⁻¹ H_tβ^(i)` is contaminated
6753    /// by spectral terms on the order of `κ_i`; functionally
6754    /// equivalent to a PSD-fail for Schur stability. The LM outer
6755    /// wrapper escalates `ridge_t` identically to `PerRowFactorFailed`.
6756    PerRowFactorIllConditioned { row: usize, kappa_estimate: f64 },
6757    /// The Schur complement was not positive-definite. Indicates a
6758    /// near-collinear decoder or a degenerate weighting; the LM outer
6759    /// wrapper should escalate `ridge_beta` and retry.
6760    SchurFactorFailed { reason: String },
6761    /// The BA inexact-step PCG solve failed before producing a usable
6762    /// Steihaug trust-region step.
6763    PcgFailed { reason: String },
6764    /// The UNBOUNDED (trust-radius = ∞) Schur PCG encountered negative
6765    /// curvature `pᵀSp ≤ 0` (or a non-positive preconditioned residual): the
6766    /// reduced Schur is indefinite, the #1026 K≥4 co-collapse signature where
6767    /// a near-singular per-row `H_tt` over-subtracts `S`. With no trust radius
6768    /// there is no boundary to step to, so CG cannot proceed. `curvature` is
6769    /// the offending `pᵀSp` and `direction_norm_sq` the `‖p‖²` of the
6770    /// negative-curvature direction; the caller floors the operator with the
6771    /// minimal ridge `δ = (|curvature|/‖p‖² )·(1+ε)` that restores positive
6772    /// curvature along `p` and retries (matrix-free analogue of the dense
6773    /// `spectral_pd_floored_schur`), rather than blindly inflating `ridge_β`.
6774    UnboundedNegativeCurvature {
6775        curvature: f64,
6776        direction_norm_sq: f64,
6777    },
6778    /// Adaptive proximal damping could not produce an Armijo-accepted
6779    /// nonlinear step.
6780    AdaptiveCorrectionFailed { reason: String },
6781}
6782
6783impl ArrowSchurError {
6784
6785    /// Whether this refusal is a Schur complement that is merely not positive
6786    /// definite — a RELOCATABLE trial point rather than a defect.
6787    ///
6788    /// The distinction is the caller's next move: an indefinite complement means
6789    /// the point is in an indefinite basin adjacent to a PD optimum, so the trial
6790    /// can be refused and the search steered, whereas a non-finite or non-square
6791    /// operator is a defect no relocation fixes. gam-sae's outer ρ-search is
6792    /// exactly that caller — it reads an indefinite complement as `+∞` and steers
6793    /// ρ back into the PD region (#1782).
6794    ///
6795    /// ⚠ #2598 — this predicate exists because that caller was recovering the
6796    /// same verdict by matching TWO substrings of [`Display`]'s output
6797    /// (`"Schur complement Cholesky failed"` and `"not positive definite"`) on a
6798    /// `String`-typed spine. The information was already a type here and was
6799    /// being rendered to prose and reconstructed, across a crate boundary:
6800    /// rewording either message below would have silently reclassified every
6801    /// recoverable Schur refusal as a fatal defect with nothing failing. The
6802    /// conjunct is preserved exactly — the discriminant carries the first
6803    /// substring and `reason` carries the second — so a `SchurFactorFailed`
6804    /// whose reason is a non-finite entry, a non-square operator or an
6805    /// unavailable device still reports `false` and stays fatal.
6806    ///
6807    /// [`Display`]: std::fmt::Display
6808    pub fn is_non_pd_schur_complement(&self) -> bool {
6809        matches!(
6810            self,
6811            ArrowSchurError::SchurFactorFailed { reason }
6812                if reason.contains("not positive definite")
6813        )
6814    }
6815
6816    /// [`Self::is_non_pd_schur_complement`], read off a message that has already
6817    /// been rendered — the same verdict for a caller that no longer holds the
6818    /// value.
6819    ///
6820    /// #2598 — gam-sae's ρ-probe classifier is one such caller: by the time a
6821    /// refusal reaches `ProbeRefusalKind::classify` the spine has flattened it
6822    /// to a `String`, and it was recovering this verdict by matching two
6823    /// literals of the [`Display`] impl below — in another crate. That made
6824    /// **rewording either message here a silent reclassification of every
6825    /// recoverable Schur refusal as a fatal defect**, with nothing failing.
6826    ///
6827    /// The wording knowledge now lives beside the wording. The discriminant
6828    /// phrase and the reason phrase are the same conjunct the value-level
6829    /// predicate above tests, and
6830    /// `rendered_verdict_matches_the_value_verdict_for_every_variant_2598`
6831    /// pins the two to each other for every variant, so a reword must move all
6832    /// three together in this one file or fail here.
6833    ///
6834    /// `contains` rather than equality because callers wrap the rendered text
6835    /// in their own context before it arrives.
6836    ///
6837    /// [`Display`]: std::fmt::Display
6838    pub fn rendered_is_non_pd_schur_complement(rendered: &str) -> bool {
6839        rendered.contains("Schur complement Cholesky failed")
6840            && rendered.contains("not positive definite")
6841    }
6842
6843    /// #2515 — the phrase every RESOLVED-INDEFINITE evidence refusal carries,
6844    /// and the only place it is written.
6845    ///
6846    /// The two producers are the reduced-Schur and per-row conditioning under
6847    /// [`ArrowEvidencePolicy::UnitDeflationRefusingIndefinite`]. Their consumer
6848    /// is in another crate (`gam-sae` maps this to the same typed
6849    /// `IndefiniteObservedInformation` verdict the dense exact-`A` route
6850    /// returns), which is exactly the arrangement #2598 caught drifting: a
6851    /// reworded message in this crate silently reclassified every recoverable
6852    /// refusal as a fatal defect. So the wording lives beside its reader, both
6853    /// producers interpolate it, and [`Self::rendered_is_indefinite_evidence`]
6854    /// matches the same function.
6855    pub fn indefinite_evidence_marker() -> &'static str {
6856        "evidence operator carries RESOLVED NEGATIVE curvature"
6857    }
6858
6859    /// Whether a rendered refusal is the [`Self::indefinite_evidence_marker`]
6860    /// class. `contains` rather than equality because callers wrap the rendered
6861    /// text in their own context before it arrives.
6862    pub fn rendered_is_indefinite_evidence(rendered: &str) -> bool {
6863        rendered.contains(Self::indefinite_evidence_marker())
6864    }
6865}
6866
6867impl std::fmt::Display for ArrowSchurError {
6868    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6869        match self {
6870            ArrowSchurError::PerRowFactorFailed { row, reason } => write!(
6871                f,
6872                "arrow-Schur: per-row H_tt^({row}) Cholesky failed: {reason}"
6873            ),
6874            ArrowSchurError::PerRowFactorIllConditioned {
6875                row,
6876                kappa_estimate,
6877            } => write!(
6878                f,
6879                "arrow-Schur: per-row H_tt^({row}) Cholesky succeeded but failed \
6880                 the safe-inversion guard (kappa_estimate={kappa_estimate:e}); \
6881                 Schur reduction would be numerically contaminated"
6882            ),
6883            ArrowSchurError::SchurFactorFailed { reason } => {
6884                write!(f, "arrow-Schur: Schur complement Cholesky failed: {reason}")
6885            }
6886            ArrowSchurError::PcgFailed { reason } => {
6887                write!(f, "arrow-Schur: Schur PCG failed: {reason}")
6888            }
6889            ArrowSchurError::UnboundedNegativeCurvature {
6890                curvature,
6891                direction_norm_sq,
6892            } => write!(
6893                f,
6894                "arrow-Schur: unbounded Schur PCG hit negative curvature pᵀSp={curvature:e} \
6895                 (‖p‖²={direction_norm_sq:e}); reduced Schur is indefinite (co-collapse), \
6896                 retry with a curvature-floor ridge"
6897            ),
6898            ArrowSchurError::AdaptiveCorrectionFailed { reason } => {
6899                write!(
6900                    f,
6901                    "arrow-Schur: adaptive proximal correction failed: {reason}"
6902                )
6903            }
6904        }
6905    }
6906}
6907
6908impl std::error::Error for ArrowSchurError {}
6909
6910// ---------------------------------------------------------------------------
6911// Cholesky helpers (kept local to avoid a new public-API dependency on the
6912// linalg crate. The systems here are tiny per-row (d × d, d ∈ {1..16}) and
6913// modest at the Schur level (K × K, K ∈ {basis size}). For production SAE
6914// scales the Schur factor should switch to faer; this module's `cholesky_lower`
6915// is the obvious replacement site.)
6916// ---------------------------------------------------------------------------
6917
6918pub(crate) fn cholesky_lower(a: &Array2<f64>) -> Result<Array2<f64>, String> {
6919    let n = a.nrows();
6920    if a.ncols() != n {
6921        return Err(format!("cholesky_lower: non-square {}×{}", n, a.ncols()));
6922    }
6923    if let Some((idx, _)) = a.iter().enumerate().find(|(_, v)| !v.is_finite()) {
6924        return Err(format!(
6925            "cholesky_lower: non-finite entry at linear index {idx}"
6926        ));
6927    }
6928
6929    // CPU factorization seam (#1017): device routing happens explicitly in the
6930    // arrow-Schur solve before reaching this reference/fallback primitive. At
6931    // the SAE border width the reduced Schur is a
6932    // dense `k×k` (k≈2k–4k) whose scalar triple-loop factorization is O(k³/3)
6933    // and neither blocked nor SIMD-vectorized — the dominant per-Newton-step
6934    // cost on a CPU-only host. faer's blocked LLT computes the SAME `A = L Lᵀ`
6935    // (to O(κ·ε), the slack the reduced solve/log-det already tolerate) an order
6936    // of magnitude faster. Restrict it to `k ≥ FAER_CHOLESKY_MIN` so the many
6937    // tiny per-row `d×d` blocks (d≤~8, factorization.rs) and the small dense
6938    // test fixtures keep the exact scalar loop — bit-for-bit their historical
6939    // factor — where faer's setup overhead would not pay off anyway. If faer
6940    // declines (a non-PD blocked pivot) fall through to the scalar loop so the
6941    // PD/non-PD verdict and its typed error stay exactly the historical ones
6942    // (`factor_dense_reduced_schur`'s spectral-floor fallback keys only on Ok vs
6943    // Err, so the boundary behavior is unchanged).
6944    const FAER_CHOLESKY_MIN: usize = 128;
6945    if n >= FAER_CHOLESKY_MIN {
6946        let view = gam_linalg::faer_ndarray::FaerArrayView::new(a);
6947        if let Ok(llt) = gam_linalg::faer_ndarray::FaerLlt::new(view.as_ref(), faer::Side::Lower) {
6948            let l_faer = llt.L();
6949            let mut l = Array2::<f64>::zeros((n, n));
6950            for i in 0..n {
6951                for j in 0..=i {
6952                    l[[i, j]] = l_faer[(i, j)];
6953                }
6954            }
6955            return Ok(l);
6956        }
6957    }
6958
6959    let mut l = Array2::<f64>::zeros((n, n));
6960    for i in 0..n {
6961        for j in 0..=i {
6962            let mut sum = a[[i, j]];
6963            for kk in 0..j {
6964                sum -= l[[i, kk]] * l[[j, kk]];
6965            }
6966            if i == j {
6967                if !sum.is_finite() || sum <= 0.0 {
6968                    return Err(format!(
6969                        "non-PD pivot {sum} at index {i} (matrix is not positive definite)"
6970                    ));
6971                }
6972                l[[i, j]] = sum.sqrt();
6973            } else {
6974                l[[i, j]] = sum / l[[j, j]];
6975            }
6976        }
6977    }
6978    Ok(l)
6979}