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