Skip to main content

gam_solve/arrow_schur/
system.rs

1//! The bordered arrow-Schur system itself: [`ArrowRowBlock`], the
2//! [`ArrowSchurSystem`] container and its assembly impl, cross-row latent
3//! penalties, the streaming builder, and the per-row factor caches.
4
5use super::*;
6
7/// Per-row block data for the arrow-Schur system.
8///
9/// `htt` holds the `d × d` Gauss–Newton block for row `i` (including any
10/// analytic-penalty contributions on that row); `htbeta` holds the
11/// `d × K` cross-block `H_tβ^(i)`; `gt` is the `d`-length latent
12/// gradient for row `i`.
13#[derive(Debug, Clone)]
14pub struct ArrowRowBlock {
15    /// `H_tt^(i)`, shape `(d, d)`.
16    pub htt: Array2<f64>,
17    /// `H_tβ^(i)`, shape `(d, K)`.
18    pub htbeta: Array2<f64>,
19    /// `g_t^(i)`, shape `(d,)`.
20    pub gt: Array1<f64>,
21}
22
23impl ArrowRowBlock {
24    /// Allocate one BA point-block row: local latent Hessian, point-camera
25    /// cross block, and point gradient.
26    pub fn new(d: usize, k: usize) -> Self {
27        Self::new_with_htbeta_cols(d, k)
28    }
29
30    /// Allocate one BA row whose dense cross-block slab has `htbeta_cols`
31    /// columns. This is used by matrix-free assemblers that keep the shared
32    /// beta tier at one width while dense row supplements live in another
33    /// coordinate system.
34    pub fn new_with_htbeta_cols(d: usize, htbeta_cols: usize) -> Self {
35        Self {
36            htt: Array2::<f64>::zeros((d, d)),
37            htbeta: Array2::<f64>::zeros((d, htbeta_cols)),
38            gt: Array1::<f64>::zeros(d),
39        }
40    }
41}
42
43/// Per-row operands needed to classify the raw exact observed information in
44/// the same majorizer metric and clamp basin on every storage route (#2515).
45///
46/// `delta_*` is `A_raw - B_raw`; retaining it beside the assembled exact-A
47/// system lets the factorization recover `B_raw` without cloning a second full
48/// arrow system. `clamp_diag` is the exactly known positive diagonal `E` whose
49/// restoration distinguishes a bounded prior wrinkle from a genuine saddle.
50#[derive(Debug, Clone)]
51pub struct ExactAClassificationRow {
52    pub delta_tt: Array2<f64>,
53    pub delta_tbeta: Array2<f64>,
54    pub clamp_diag: Array1<f64>,
55}
56
57#[derive(Debug, Clone)]
58pub struct ExactAClassificationGeometry {
59    pub rows: Arc<[ExactAClassificationRow]>,
60    /// Column indices mapping each `delta_tbeta` carrier column into the arrow
61    /// system's shared border.
62    pub border_indices: Arc<[usize]>,
63}
64
65/// Low-rank spectral conditioning discovered from the matrix-free exact-A
66/// Krylov geometry.  Directions are orthonormal Ritz vectors; `shifts[m]`
67/// changes that direction from its raw Ritz curvature to the shared classifier's
68/// unit-null or clamp-basin price.
69#[derive(Debug, Clone)]
70pub struct ExactAReducedRitzConditioning {
71    pub directions: Arc<[Array1<f64>]>,
72    pub shifts: Arc<[f64]>,
73}
74
75/// Bordered (t, β) Newton system with arrow structure.
76///
77/// The β-block is held as a dense `K × K` Hessian `H_ββ` plus a `K`-length
78/// gradient `g_β` for direct BA modes. Large-scale inexact BA callers may
79/// additionally install a matrix-free `H_ββ x` operator and diagonal via
80/// [`ArrowSchurSystem::set_shared_beta_operator`]; the InexactPCG mode then
81/// avoids dense Schur formation/factorization.
82/// The t-block is a `Vec<ArrowRowBlock>` of length `N`.
83///
84/// Construction is the driver's responsibility: the driver
85///
86///   1. evaluates Φ(t) and the radial jet `∂Φ/∂t` (the latter via
87///      [`gam_terms::latent::LatentCoordValues::design_gradient_wrt_t`]);
88///   2. forms the working-weighted Gauss–Newton blocks
89///      `H_tt^(i) += (g_i β)(g_i β)^T`, `H_tβ^(i) += (g_i β) ⊗ Φ_i`,
90///      `H_ββ += Φ^T W Φ + Σ_k λ_k S_k`;
91///   3. calls `ArrowSchurSystem::add_analytic_penalty_contributions` to
92///      fold row-block Psi-tier analytic penalties (`ARDPenalty`,
93///      `SparsityPenalty`) into `H_tt^(i)` and Beta-tier penalties into `H_ββ`;
94///   4. calls [`ArrowSchurSystem::solve`] to obtain `(Δt, Δβ)`.
95pub struct ArrowSchurSystem {
96    /// Per-row latent block (length `N`, each row `d × d` / `d × K` / `d`).
97    pub rows: Vec<ArrowRowBlock>,
98    /// `H_ββ`, shape `(K, K)` for direct BA modes; empty when constructed
99    /// by `ArrowSchurSystem::new_matrix_free_shared` for PCG-only use.
100    pub hbb: Array2<f64>,
101    /// Optional matrix-free `H_ββ x` operator for large BA Schur PCG.
102    ///
103    /// Direct and Square-Root BA modes still require `hbb`; InexactPCG uses
104    /// this operator when present, avoiding dense shared-block storage for
105    /// SAE-manifold scale `K`.
106    pub hbb_matvec: Option<SharedBetaMatvec>,
107    /// Optional row-local matrix-free multiply for `H_tβ^(i) x`.
108    ///
109    /// When present, all inner-Schur paths route through this operator instead
110    /// of indexing the per-row `htbeta` dense slabs: `reduced_rhs_beta`,
111    /// `schur_matvec` (PCG hot loop), back-substitution,
112    /// `JacobiPreconditioner` construction, `build_dense_schur_direct`, and
113    /// `build_dense_schur_sqrt_ba` all call `sys_htbeta_apply_row` or
114    /// `sys_htbeta_materialize_row`.  Factor caches retain the operator for
115    /// IFT/evidence consumers as before.
116    pub htbeta_matvec: Option<RowHtbetaMatvec>,
117    /// Optional row-local matrix-free transpose multiply `out += H_βt^(i) · v`.
118    ///
119    /// The sparse adjoint of [`Self::htbeta_matvec`]. When present, the
120    /// reduced-Schur matvec applies `H_βt^(i)` directly (sparse `scatter`)
121    /// instead of probing the forward operator against `K` basis vectors. This
122    /// is the per-row sparse apply that lifts the `O(K)` column-probe in the
123    /// GPU PCG and streaming Schur paths to `O(m_i · p)` per row. Installed in
124    /// lock-step with `htbeta_matvec` by [`Self::set_row_htbeta_operator`].
125    pub htbeta_transpose_matvec: Option<RowHtbetaTransposeMatvec>,
126    /// Whether `rows[*].htbeta` contains a dense contribution that must be added
127    /// on top of the matrix-free row operator.
128    pub htbeta_dense_supplement: bool,
129    /// Optional diagonal of the matrix-free shared block, used by the
130    /// Schur-Jacobi preconditioner in the Agarwal-style PCG path.
131    pub hbb_diag: Option<Array1<f64>>,
132    /// `g_β`, shape `(K,)`.
133    pub gb: Array1<f64>,
134    /// Maximum per-row latent dimensionality across all rows.
135    ///
136    /// For homogeneous systems (all rows have the same dim) this equals the
137    /// common per-row `d`.  For heterogeneous systems (e.g. sparse SAE rows
138    /// where JumpReLU / TopK / sparsemax active sets vary per observation)
139    /// this is `max_i row_dims[i]`.  Per-row code should use
140    /// `row.htt.nrows()` or `row_dims[i]`; `d` is an upper bound for
141    /// scratch-buffer sizing.
142    pub d: usize,
143    /// Per-row latent dimensionality: `row_dims[i] == rows[i].htt.nrows()`.
144    ///
145    /// For homogeneous systems `row_dims[i] == d` for all `i`.
146    pub row_dims: Arc<[usize]>,
147    /// Flat-buffer row offsets for the `delta_t` vector produced by
148    /// [`Self::solve`] / [`solve_arrow_newton_step_core`].
149    ///
150    /// `row_offsets[i]` is the start index for row `i`'s slice in `delta_t`;
151    /// `row_offsets[n]` is the total `delta_t` length.  For homogeneous
152    /// systems `row_offsets[i] == i * d`.
153    pub row_offsets: Arc<[usize]>,
154    /// β dimensionality `K`.
155    pub k: usize,
156    /// Geometry tag for the row-local latent blocks after optional
157    /// Riemannian projection. Euclidean/no-op geometry uses the sentinel.
158    pub manifold_mode_fingerprint: u64,
159    /// Structural/value tag for row-local Hessian factors and their Schur
160    /// inputs. Stale caches must be rejected when row-dependent Hessian
161    /// penalties or cross-blocks change.
162    pub row_hessian_fingerprint: u64,
163    /// Registry-side tag for row-dependent analytic-penalty Hessian inputs.
164    /// Combined with the materialized row blocks in
165    /// [`Self::current_row_hessian_fingerprint`].
166    pub analytic_row_hessian_fingerprint: u64,
167    /// Term-block column ranges for the block-Jacobi Schur preconditioner.
168    ///
169    /// Each entry `r` means that indices `r.start..r.end` belong to one
170    /// coefficient block (a GAM term or a custom parameter family from
171    /// `ParameterBlockSpec`). When populated via
172    /// [`Self::set_block_offsets`], the Jacobi preconditioner inverts the
173    /// full `b × b` Schur block for each term instead of only its diagonal.
174    ///
175    /// The default (empty slice) causes `JacobiPreconditioner` to fall back
176    /// to pure scalar diagonal inversion, preserving the pre-#283 behaviour.
177    pub block_offsets: Arc<[Range<usize>]>,
178    /// Optional matrix-free penalty-side `H_ββ` operator (#296).
179    ///
180    /// When set, all hot paths (`schur_matvec`, `build_dense_schur_*`,
181    /// `JacobiPreconditioner`, quadratic-form reduction) route through this
182    /// operator instead of the dense `hbb` accumulator, enabling
183    /// `BlockPenaltyOp` / `KroneckerPenaltyOp` to skip the `O(K²)` dense
184    /// materialisation for structured smoothness penalties.
185    ///
186    /// When `None`, those paths fall back to wrapping `hbb` in a transient
187    /// `DensePenaltyOp` — identical observable behaviour, no new allocation
188    /// hot-path cost for callers that have not opted in.
189    pub penalty_op: Option<Arc<dyn BetaPenaltyOp>>,
190    /// Device-uploadable SAE Kronecker data for CUDA-resident reduced PCG.
191    ///
192    /// The generic matrix-free closures remain the authoritative CPU path. This
193    /// descriptor is installed only when SAE assembly has a matching CUDA sparse
194    /// representation for both `H_tβ` and `H_ββ`.
195    pub device_sae_pcg: Option<Arc<DeviceSaePcgData>>,
196    /// Registered Psi-tier analytic penalties whose Hessian couples *distinct*
197    /// latent rows (non-row-block-diagonal), captured by
198    /// `Self::add_analytic_penalty_contributions`.
199    ///
200    /// These penalties (`TotalVariationPenalty`, `SheafConsistencyPenalty`,
201    /// block-orthogonality, …) produce off-row Hessian blocks `∂²P/∂t_i∂t_j`
202    /// (`i ≠ j`) that the arrow elimination — which assumes each `H_tt^(i)` is
203    /// independent of every other row — cannot represent. Their *gradient* is
204    /// still folded into `g_t` exactly like every other Psi penalty; only their
205    /// curvature is held here, applied during the solve as a full-latent
206    /// Hessian-vector product `P_cross · Δt` against the penalty's
207    /// `psd_majorizer_hvp`. When this vector is non-empty,
208    /// `solve_arrow_newton_step_artifacts` auto-selects the matrix-free
209    /// full-system PCG path (arrow block-diagonal inverse as preconditioner)
210    /// instead of the exact one-shot Schur elimination. When empty, the system
211    /// is purely row-block-diagonal and the exact Schur path is unchanged.
212    pub cross_row_penalties: Vec<CrossRowLatentPenalty>,
213    /// Optional row-local gauge directions for evidence-only Faddeev-Popov
214    /// deflation of an otherwise non-PD `H_tt` row block.
215    ///
216    /// These vectors live in each row's actual chart block, so compact SAE rows
217    /// and dense rows share the same factorization path. Ordinary Newton solves
218    /// ignore them; only undamped evidence factors with
219    /// evidence factorization may stiffen a gauge-explained row
220    /// direction.
221    pub row_gauge_deflation: Option<ArrowRowGaugeDeflation>,
222    /// Exact scale-gauge quotient on the reduced shared `beta` border.
223    ///
224    /// SAE installs one normalized radial decoder direction per live atom.
225    /// Evidence paths factor `P S P + Q Q^T` and expose the projected inverse
226    /// `P S_quot^-1 P`; ordinary Newton steps ignore this carrier because their
227    /// joint `(delta B, delta log-amplitude)` trajectory projection is owned by
228    /// the SAE step application.
229    pub beta_gauge_quotient: Option<ArrowBetaGaugeQuotient>,
230    /// CONTENT identity of the matrix-free `H_tβ` row operator, when its
231    /// installer can supply one (#2515).
232    ///
233    /// `row_hessian_fingerprint_for_system` otherwise hashes the operator's
234    /// `Arc` POINTER ADDRESS, which is an identity of the allocation and not of
235    /// the operator: two assemblies of the bit-identical system get different
236    /// fingerprints, and `validate_matrix_free_arrow_pair` then refuses a
237    /// perfectly valid system/cache pair. MEASURED on one state with the
238    /// collapse-prevention gates frozen for both assemblies — every `htt`,
239    /// `htbeta`, `gt`, `hbb` and `gb` block bit-identical (max |Δ| = 0.0) and the
240    /// row fingerprints still `15334315105555998529` vs `6998028885641800537`.
241    ///
242    /// When `Some`, it is hashed INSTEAD of the address, so a rebuild from the
243    /// same state reproduces the same fingerprint and a genuine change to the
244    /// operator's defining data still changes it. `None` keeps the historical
245    /// address behaviour for installers that cannot characterise their closure.
246    pub htbeta_operator_fingerprint: Option<u64>,
247    /// Present only when this system is the raw exact observed information
248    /// `A_raw = B_raw + delta_C`.  Evidence factors consume it to make the same
249    /// majorizer-metric / clamp-basin classification as the dense route.
250    pub exact_a_classification: Option<ExactAClassificationGeometry>,
251    /// Transient matrix-free image of the exact-A classification.  Raw systems
252    /// leave this absent; the rational evidence evaluation installs it only on
253    /// its evaluation-local system clone after lifting the Krylov directions.
254    pub exact_a_reduced_conditioning: Option<ExactAReducedRitzConditioning>,
255}
256
257impl Clone for ArrowSchurSystem {
258    fn clone(&self) -> Self {
259        Self {
260            rows: self.rows.clone(),
261            hbb: self.hbb.clone(),
262            hbb_matvec: self.hbb_matvec.clone(),
263            htbeta_matvec: self.htbeta_matvec.clone(),
264            htbeta_transpose_matvec: self.htbeta_transpose_matvec.clone(),
265            htbeta_dense_supplement: self.htbeta_dense_supplement,
266            hbb_diag: self.hbb_diag.clone(),
267            gb: self.gb.clone(),
268            d: self.d,
269            row_dims: Arc::clone(&self.row_dims),
270            row_offsets: Arc::clone(&self.row_offsets),
271            k: self.k,
272            manifold_mode_fingerprint: self.manifold_mode_fingerprint,
273            row_hessian_fingerprint: self.row_hessian_fingerprint,
274            analytic_row_hessian_fingerprint: self.analytic_row_hessian_fingerprint,
275            block_offsets: Arc::clone(&self.block_offsets),
276            penalty_op: self.penalty_op.clone(),
277            device_sae_pcg: self.device_sae_pcg.clone(),
278            cross_row_penalties: self.cross_row_penalties.clone(),
279            row_gauge_deflation: self.row_gauge_deflation.clone(),
280            beta_gauge_quotient: self.beta_gauge_quotient.clone(),
281            htbeta_operator_fingerprint: self.htbeta_operator_fingerprint,
282            exact_a_classification: self.exact_a_classification.clone(),
283            exact_a_reduced_conditioning: self.exact_a_reduced_conditioning.clone(),
284        }
285    }
286}
287
288/// A captured cross-row Psi-tier analytic penalty: the penalty kind plus the
289/// global-ρ slice (`rho_local`) it was registered with.
290///
291/// Holds an owned copy of the local ρ-axes so the penalty's
292/// [`AnalyticPenaltyKind::psd_majorizer_hvp`] can be evaluated during the
293/// matrix-free full-system solve without re-deriving the ρ layout. The penalty
294/// itself is an `Arc`-backed clone (cheap), so capturing it does not copy the
295/// penalty payload.
296#[derive(Clone)]
297pub struct CrossRowLatentPenalty {
298    /// The non-row-block-diagonal Psi penalty (e.g. `TotalVariationPenalty`).
299    pub penalty: AnalyticPenaltyKind,
300    /// The penalty's local ρ-axes (its slice of the global ρ vector).
301    pub rho_local: Array1<f64>,
302    /// The flat latent vector (`N·d`, row-major) the penalty's curvature was
303    /// linearized at — i.e. the `target_t` passed to
304    /// `ArrowSchurSystem::add_analytic_penalty_contributions`. The Hessian of
305    /// a nonlinear penalty (the smoothed-TV curvature weights `φ''(D t)`,
306    /// etc.) depends on this point, so `psd_majorizer_hvp` must be evaluated
307    /// against it for the Newton operator to be the true Hessian at the
308    /// current iterate.
309    pub target_t: Array1<f64>,
310}
311
312impl ArrowSchurSystem {
313    /// Allocate an empty BA reduced-camera-system instance sized
314    /// `(N point/latent rows × d, K shared decoder parameters)`.
315    pub fn new(n: usize, d: usize, k: usize) -> Self {
316        Self::new_with_hbb(n, d, k, Array2::<f64>::zeros((k, k)))
317    }
318
319    /// Allocate an arrow system using a caller-owned dense shared-block buffer.
320    /// The buffer must already have shape `(k, k)` and is zeroed in place before
321    /// use so callers can recycle it across assemblies without changing
322    /// numerics.
323    pub fn new_with_hbb(n: usize, d: usize, k: usize, hbb: Array2<f64>) -> Self {
324        Self::new_with_hbb_and_htbeta_cols(n, d, k, hbb, k)
325    }
326
327    /// Allocate an arrow system with a caller-owned dense shared-block buffer and
328    /// per-row dense `H_tβ` slabs allocated at `htbeta_cols` columns.
329    pub fn new_with_hbb_and_htbeta_cols(
330        n: usize,
331        d: usize,
332        k: usize,
333        mut hbb: Array2<f64>,
334        htbeta_cols: usize,
335    ) -> Self {
336        assert_eq!(hbb.dim(), (k, k));
337        hbb.fill(0.0);
338        let rows = (0..n)
339            .map(|_| ArrowRowBlock::new_with_htbeta_cols(d, htbeta_cols))
340            .collect();
341        let row_dims: Arc<[usize]> = (0..n).map(|_| d).collect::<Vec<_>>().into();
342        let row_offsets: Arc<[usize]> = (0..=n).map(|i| i * d).collect::<Vec<_>>().into();
343        Self {
344            rows,
345            hbb,
346            hbb_matvec: None,
347            htbeta_matvec: None,
348            htbeta_transpose_matvec: None,
349            htbeta_dense_supplement: false,
350            hbb_diag: None,
351            gb: Array1::<f64>::zeros(k),
352            d,
353            row_dims,
354            row_offsets,
355            k,
356            manifold_mode_fingerprint: EUCLIDEAN_MANIFOLD_MODE_FINGERPRINT,
357            row_hessian_fingerprint: 0,
358            analytic_row_hessian_fingerprint: 0,
359            block_offsets: Arc::from([] as [Range<usize>; 0]),
360            penalty_op: None,
361            device_sae_pcg: None,
362            cross_row_penalties: Vec::new(),
363            row_gauge_deflation: None,
364            beta_gauge_quotient: None,
365            htbeta_operator_fingerprint: None,
366            exact_a_classification: None,
367            exact_a_reduced_conditioning: None,
368        }
369    }
370
371    /// Allocate a heterogeneous-row arrow system with no dense shared `H_ββ`
372    /// block and with row `H_tβ` slabs allocated at `htbeta_cols` columns.
373    pub fn new_with_per_row_dims_empty_hbb_and_htbeta_cols(
374        per_row_dims: Vec<usize>,
375        k: usize,
376        htbeta_cols: usize,
377    ) -> Self {
378        let n = per_row_dims.len();
379        let d = per_row_dims.iter().copied().max().unwrap_or(0);
380        let mut offsets = Vec::with_capacity(n + 1);
381        let mut cursor = 0usize;
382        offsets.push(cursor);
383        for &dim in &per_row_dims {
384            cursor += dim;
385            offsets.push(cursor);
386        }
387        let rows = per_row_dims
388            .iter()
389            .map(|&dim| ArrowRowBlock::new_with_htbeta_cols(dim, htbeta_cols))
390            .collect();
391        Self {
392            rows,
393            hbb: Array2::<f64>::zeros((0, 0)),
394            hbb_matvec: None,
395            htbeta_matvec: None,
396            htbeta_transpose_matvec: None,
397            htbeta_dense_supplement: false,
398            hbb_diag: None,
399            gb: Array1::<f64>::zeros(k),
400            d,
401            row_dims: Arc::from(per_row_dims.into_boxed_slice()),
402            row_offsets: Arc::from(offsets.into_boxed_slice()),
403            k,
404            manifold_mode_fingerprint: EUCLIDEAN_MANIFOLD_MODE_FINGERPRINT,
405            row_hessian_fingerprint: 0,
406            analytic_row_hessian_fingerprint: 0,
407            block_offsets: Arc::from([] as [Range<usize>; 0]),
408            penalty_op: None,
409            device_sae_pcg: None,
410            cross_row_penalties: Vec::new(),
411            row_gauge_deflation: None,
412            beta_gauge_quotient: None,
413            htbeta_operator_fingerprint: None,
414            exact_a_classification: None,
415            exact_a_reduced_conditioning: None,
416        }
417    }
418
419    /// Build a fresh numerical system while reusing caller-owned assembly
420    /// allocations when their shapes still match.
421    ///
422    /// This is deliberately an *allocation* workspace, not a factor cache:
423    /// every entry of `rows`, `hbb`, and `gb` is zeroed before the system is
424    /// returned, and all operator/fingerprint/device fields start empty. A
425    /// nonlinear assembler can therefore refill every state-dependent block at
426    /// the new iterate without paying again for the stable row/shared-buffer
427    /// shapes. Shape changes discard only the incompatible allocation.
428    pub fn new_with_assembly_buffers(
429        per_row_dims: Vec<usize>,
430        k: usize,
431        htbeta_cols: usize,
432        mut hbb: Array2<f64>,
433        mut rows: Vec<ArrowRowBlock>,
434        mut gb: Array1<f64>,
435    ) -> Self {
436        assert!(hbb.dim() == (0, 0) || hbb.dim() == (k, k));
437        hbb.fill(0.0);
438
439        let rows_match = rows.len() == per_row_dims.len()
440            && rows.iter().zip(&per_row_dims).all(|(row, &dim)| {
441                row.htt.dim() == (dim, dim)
442                    && row.htbeta.dim() == (dim, htbeta_cols)
443                    && row.gt.len() == dim
444            });
445        if rows_match {
446            for row in &mut rows {
447                row.htt.fill(0.0);
448                row.htbeta.fill(0.0);
449                row.gt.fill(0.0);
450            }
451        } else {
452            rows = per_row_dims
453                .iter()
454                .map(|&dim| ArrowRowBlock::new_with_htbeta_cols(dim, htbeta_cols))
455                .collect();
456        }
457        if gb.len() == k {
458            gb.fill(0.0);
459        } else {
460            gb = Array1::<f64>::zeros(k);
461        }
462
463        let n = per_row_dims.len();
464        let d = per_row_dims.iter().copied().max().unwrap_or(0);
465        let mut offsets = Vec::with_capacity(n + 1);
466        let mut cursor = 0usize;
467        offsets.push(cursor);
468        for &dim in &per_row_dims {
469            cursor += dim;
470            offsets.push(cursor);
471        }
472        Self {
473            rows,
474            hbb,
475            hbb_matvec: None,
476            htbeta_matvec: None,
477            htbeta_transpose_matvec: None,
478            htbeta_dense_supplement: false,
479            hbb_diag: None,
480            gb,
481            d,
482            row_dims: Arc::from(per_row_dims.into_boxed_slice()),
483            row_offsets: Arc::from(offsets.into_boxed_slice()),
484            k,
485            manifold_mode_fingerprint: EUCLIDEAN_MANIFOLD_MODE_FINGERPRINT,
486            row_hessian_fingerprint: 0,
487            analytic_row_hessian_fingerprint: 0,
488            block_offsets: Arc::from([] as [Range<usize>; 0]),
489            penalty_op: None,
490            device_sae_pcg: None,
491            cross_row_penalties: Vec::new(),
492            row_gauge_deflation: None,
493            beta_gauge_quotient: None,
494            htbeta_operator_fingerprint: None,
495            exact_a_classification: None,
496            exact_a_reduced_conditioning: None,
497        }
498    }
499
500    pub fn set_row_gauge_deflation(&mut self, deflation: ArrowRowGaugeDeflation) {
501        self.row_gauge_deflation = Some(deflation);
502    }
503
504    /// Install the exact evidence quotient for shared-border gauge directions.
505    pub fn set_beta_gauge_quotient(
506        &mut self,
507        quotient: ArrowBetaGaugeQuotient,
508    ) -> Result<(), String> {
509        if quotient.border_dim() != self.k {
510            return Err(format!(
511                "ArrowSchurSystem::set_beta_gauge_quotient: direction width {} != beta border {}",
512                quotient.border_dim(),
513                self.k
514            ));
515        }
516        self.beta_gauge_quotient = Some(quotient);
517        Ok(())
518    }
519
520    /// Number of BA point/latent rows `N`.
521    pub fn n(&self) -> usize {
522        self.rows.len()
523    }
524
525    /// Recompute the row-system fingerprint from the currently materialized
526    /// row blocks, cross-blocks, and shared-block diagonal.
527    pub fn compute_row_hessian_fingerprint(&self) -> u64 {
528        row_hessian_fingerprint_for_system(self)
529    }
530
531    /// Current effective row-system fingerprint, including the materialized
532    /// row blocks and any registry metadata captured while folding analytic
533    /// penalties into the system.
534    pub fn current_row_hessian_fingerprint(&self) -> u64 {
535        combine_row_and_registry_fingerprints(
536            self.compute_row_hessian_fingerprint(),
537            self.analytic_row_hessian_fingerprint,
538        )
539    }
540
541    /// Store the current row-system fingerprint on the system.
542    ///
543    /// This is intentionally explicit and expensive. Cache and evidence callers
544    /// use [`Self::current_row_hessian_fingerprint`] at the point they need the
545    /// value, after assembly has populated the system, instead of hashing each
546    /// intermediate construction/mutation step.
547    pub fn refresh_row_hessian_fingerprint(&mut self) {
548        self.row_hessian_fingerprint = self.current_row_hessian_fingerprint();
549    }
550
551    /// Install a matrix-free shared-block operator for Agarwal-style
552    /// inexact Schur PCG.
553    ///
554    /// `diag` must be the diagonal of the same `H_ββ` operator and is used
555    /// for the Schur-Jacobi preconditioner. This is the BA "large camera
556    /// system" path mapped to large decoder coefficient blocks.
557    pub fn set_shared_beta_operator<F>(&mut self, matvec: F, diag: Array1<f64>)
558    where
559        F: for<'a> Fn(ArrayView1<'a, f64>, &mut Array1<f64>) + Send + Sync + 'static,
560    {
561        assert_eq!(diag.len(), self.k);
562        let matvec_arc: SharedBetaMatvec = Arc::new(matvec);
563        // Mirror the closure into a BetaPenaltyOp so all hot paths (#296)
564        // route through the trait, preserving the existing hbb_matvec +
565        // hbb_diag fields for code that inspects them directly.
566        self.penalty_op = Some(Arc::new(MatvecDiagPenaltyOp::new(
567            self.k,
568            Arc::clone(&matvec_arc),
569            diag.clone(),
570        )));
571        self.hbb_matvec = Some(matvec_arc);
572        self.hbb_diag = Some(diag);
573    }
574
575    /// Mark the dense per-row cross-block slabs as active supplements to the
576    /// installed matrix-free row operator.
577    pub fn activate_dense_htbeta_supplement(&mut self) {
578        self.htbeta_dense_supplement = true;
579    }
580
581    /// Install a matrix-free per-row cross-block operator and its sparse
582    /// adjoint.
583    ///
584    /// `forward` must write `out = H_tβ^(row) x` for `out.len() == d` and
585    /// `x.len() == K`. `transpose` must **add** `H_βt^(row) v` into `out` for
586    /// `out.len() == K` and `v.len() == d` (the sparse `scatter` adjoint).
587    ///
588    /// When installed, the forward operator is used during the Newton solve
589    /// (inside `reduced_rhs_beta`, `schur_matvec`, back-substitution, and
590    /// `JacobiPreconditioner` construction) and afterwards by IFT/evidence
591    /// predictors.  Per-row `htbeta` slabs in `ArrowRowBlock` may be left
592    /// zero-sized when this operator is installed — all inner-Schur paths route
593    /// through the matvec instead of indexing the dense block. The transpose
594    /// operator lets the reduced-Schur matvec apply `H_βt^(row)` directly
595    /// (`O(m_i · p)`) instead of probing `forward` against `K` basis vectors.
596    pub fn set_row_htbeta_operator<F, T>(&mut self, forward: F, transpose: T)
597    where
598        F: for<'a> Fn(usize, ArrayView1<'a, f64>, &mut Array1<f64>) + Send + Sync + 'static,
599        T: for<'a> Fn(usize, ArrayView1<'a, f64>, &mut Array1<f64>) + Send + Sync + 'static,
600    {
601        self.htbeta_matvec = Some(Arc::new(forward));
602        self.htbeta_transpose_matvec = Some(Arc::new(transpose));
603        self.htbeta_operator_fingerprint = None;
604    }
605
606    /// [`Self::set_row_htbeta_operator`] with the operator's CONTENT identity
607    /// (#2515).
608    ///
609    /// `fingerprint` must be a hash of the data that defines the closure — the
610    /// same state must give the same value, and a different operator must give a
611    /// different one. Supplying it makes the row-Hessian fingerprint a function
612    /// of the OPERATOR rather than of its allocation, so a rebuild from an
613    /// unchanged state produces an unchanged fingerprint and the stale-pair guard
614    /// stops refusing valid system/cache pairs. See
615    /// [`ArrowSchurSystem::htbeta_operator_fingerprint`].
616    pub fn set_row_htbeta_operator_with_fingerprint<F, T>(
617        &mut self,
618        forward: F,
619        transpose: T,
620        fingerprint: u64,
621    ) where
622        F: for<'a> Fn(usize, ArrayView1<'a, f64>, &mut Array1<f64>) + Send + Sync + 'static,
623        T: for<'a> Fn(usize, ArrayView1<'a, f64>, &mut Array1<f64>) + Send + Sync + 'static,
624    {
625        self.htbeta_matvec = Some(Arc::new(forward));
626        self.htbeta_transpose_matvec = Some(Arc::new(transpose));
627        self.htbeta_operator_fingerprint = Some(fingerprint);
628    }
629
630    /// Register term-block column ranges for the block-Jacobi Schur preconditioner.
631    ///
632    /// Each `Range<usize>` covers the columns of one GAM term (or custom
633    /// parameter family) in the shared `β` vector. The ranges must be
634    /// non-overlapping, sorted, and their union must cover `0..k`.
635    ///
636    /// Call this after building the system and before [`Self::solve`] /
637    /// [`Self::solve_with_options`] whenever the solver will use
638    /// [`ArrowSolverMode::InexactPCG`]. Absent a call, the preconditioner
639    /// falls back to scalar diagonal Jacobi (the pre-#283 behaviour).
640    ///
641    /// The same plumbing is compatible with #287 (custom `ParameterBlockSpec`
642    /// families): callers from that path simply supply ranges derived from
643    /// their own block layout.
644    pub fn set_block_offsets(&mut self, offsets: Arc<[Range<usize>]>) {
645        self.block_offsets = offsets;
646    }
647
648    /// Install a matrix-free penalty-side `H_ββ` operator (#296).
649    ///
650    /// When set, all hot paths (`schur_matvec`, `build_dense_schur_*`,
651    /// `JacobiPreconditioner`, quadratic-form reduction) route through this
652    /// operator instead of the dense `hbb` accumulator, enabling
653    /// `BlockPenaltyOp` / `KroneckerPenaltyOp` to avoid `O(K²)` allocation
654    /// for structured smoothness penalties.
655    pub fn set_penalty_op(&mut self, op: Arc<dyn BetaPenaltyOp>) {
656        assert_eq!(
657            op.dim(),
658            self.k,
659            "shared beta operator dimension must match the ArrowSchurSystem border"
660        );
661        self.penalty_op = Some(op);
662    }
663
664    /// The authoritative diagonal of the assembled shared `H_ββ` block.
665    ///
666    /// SAE's matrix-free assembly reclaims the dense [`Self::hbb`] workspace
667    /// after installing the equivalent structured operator. Reading `hbb`
668    /// directly after that point therefore returns an empty/zero block even
669    /// though the solve sees nonzero curvature. This accessor follows the same
670    /// operator -> explicit diagonal -> dense dispatch as every Schur solve and
671    /// preconditioner, so diagnostics cannot silently certify a different
672    /// shared block than the optimizer used.
673    pub fn shared_block_diagonal(&self) -> Array1<f64> {
674        let mut diagonal = vec![0.0_f64; self.k];
675        self.penalty_diagonal_add(&mut diagonal);
676        Array1::from_vec(diagonal)
677    }
678
679    pub fn set_device_sae_pcg_data(&mut self, data: DeviceSaePcgData) {
680        self.set_device_sae_pcg_data_reusing(data, None);
681    }
682
683    /// Install an already allocation-resident SAE device descriptor.
684    pub fn set_device_sae_pcg_allocation(&mut self, data: Arc<DeviceSaePcgData>) {
685        assert_eq!(data.beta_dim, self.k);
686        if data.frame.is_none() {
687            assert_eq!(data.a_phi.len(), self.rows.len());
688            assert_eq!(data.local_jac.len(), self.rows.len());
689        }
690        self.device_sae_pcg = Some(data);
691    }
692
693    /// Install current-iterate SAE device operands while retaining the outer
694    /// descriptor allocation from a completed prior assembly when it is
695    /// uniquely owned. Framed payloads also refill their nested row-cross/frame
696    /// vectors through `Vec::clone_from`, retaining matching capacities. `data`
697    /// still replaces every numerical value, so no state-dependent operand or
698    /// factor crosses nonlinear iterates.
699    pub fn set_device_sae_pcg_data_reusing(
700        &mut self,
701        data: DeviceSaePcgData,
702        recycled: Option<Arc<DeviceSaePcgData>>,
703    ) {
704        assert_eq!(data.beta_dim, self.k);
705        // The frames-engaged builder (`build_framed_device_sae_data`) carries the
706        // per-row cross block through `frame.frame_blocks` and intentionally leaves
707        // the full-`B` `a_phi`/`local_jac` slabs EMPTY (#1033). Only the non-framed
708        // full-`B` path populates those per-row slabs, so the length contract
709        // applies only when there is no frame.
710        if data.frame.is_none() {
711            assert_eq!(data.a_phi.len(), self.rows.len());
712            assert_eq!(data.local_jac.len(), self.rows.len());
713        }
714        let allocation = match recycled {
715            Some(mut allocation) => match Arc::get_mut(&mut allocation) {
716                Some(slot) => {
717                    slot.replace_reusing_framed_allocations(data);
718                    allocation
719                }
720                None => Arc::new(data),
721            },
722            None => Arc::new(data),
723        };
724        self.set_device_sae_pcg_allocation(allocation);
725    }
726
727    /// Return the effective penalty operator: the installed `penalty_op` if
728    /// present, otherwise a `DensePenaltyOp` wrapping the current `hbb`.
729    ///
730    /// Note: when `penalty_op` is `None`, this clones `hbb` into a new
731    /// `DensePenaltyOp`. Callers in hot loops should call this once and
732    /// store the result, not call it per-iteration.
733    pub fn effective_penalty_op(&self) -> Arc<dyn BetaPenaltyOp> {
734        match self.penalty_op.as_ref() {
735            Some(op) => Arc::clone(op),
736            None => Arc::new(DensePenaltyOp(self.hbb.clone())),
737        }
738    }
739
740    /// `y += P x` without allocating a new Arc; dispatches to `penalty_op`
741    /// or falls back to `hbb` inline, avoiding the K×K clone hot-path cost.
742    #[inline]
743    pub(crate) fn penalty_matvec_add(&self, x: &[f64], y: &mut [f64]) {
744        if let Some(op) = self.penalty_op.as_ref() {
745            op.matvec(x, y);
746        } else {
747            let k = self.hbb.nrows();
748            // The dense `H_ββ·x` accumulate is the serial `O(k²)` GEMV left
749            // inside the per-CG-iteration cross-row matvec (`arrow_cross_row_matvec`)
750            // and the once-per-Newton-step model reduction: at the SAE wide border
751            // (k≈2048, #1017) it is ≈4M ops/call that pinned one core while the
752            // per-row work fans out. Parallelism is over independent output rows
753            // `a` — each `y[a] += Σ_b hbb[a,b]·x[b]` accumulates in the SAME order
754            // as serial, so the result is bit-identical to serial (not merely
755            // deterministic run-to-run), the #1017 gate. Same `dense_parallel`
756            // guard as `penalty_ridge_prologue_into`: only when not nested in a
757            // rayon worker (the topology race fans candidates) and above the
758            // width floor, so it never oversubscribes and small `k` avoids rayon
759            // overhead on a trivial GEMV.
760            let dense_parallel = self.hbb.dim() == (k, k)
761                && k >= SCHUR_PROLOGUE_PARALLEL_K_MIN
762                && rayon::current_thread_index().is_none();
763            if dense_parallel {
764                use rayon::prelude::*;
765                let hbb = &self.hbb;
766                y.par_iter_mut().enumerate().for_each(|(a, ya)| {
767                    let mut acc = 0.0_f64;
768                    for b in 0..k {
769                        acc += hbb[[a, b]] * x[b];
770                    }
771                    *ya += acc;
772                });
773            } else {
774                for a in 0..k {
775                    let mut acc = 0.0_f64;
776                    for b in 0..k {
777                        acc += self.hbb[[a, b]] * x[b];
778                    }
779                    y[a] += acc;
780                }
781            }
782        }
783    }
784
785    /// Reduced-Schur matvec prologue `y = (P + ridge·I) x` written fresh into a
786    /// zeroed `y` (the caller clears `out` first; this is the first writer).
787    ///
788    /// At the SAE LLM border width (#1017) the dense `H_ββ` fallback is a `k×k`
789    /// GEMV whose `O(k²)` cost (≈4M flops at k=2048) runs once per CG iteration
790    /// and was the serial Amdahl ceiling on the per-row-parallel matvec: while
791    /// the `n`-row point-elimination term fans across all cores, this prologue
792    /// pinned one core and grows as `k²`. The dense GEMV is embarrassingly
793    /// parallel over output rows `a` — each `y[a] = Σ_b hbb[a,b]·x[b] + ridge·x[a]`
794    /// is independent and its inner sum order is identical whether one thread or
795    /// many compute it. Here parallelism is over independent output rows (NOT a
796    /// reassociated reduction), so each `y[a]` accumulates in the SAME order as
797    /// serial — the result is bit-identical to serial, not merely deterministic
798    /// run-to-run (the #1017 determinism gate). On THIS exact-order path the
799    /// criterion ranking is invariant; that no-move guarantee holds because the
800    /// order matches serial, and does NOT generalise to chunk-reassociated
801    /// reductions, where a near-tie winner can flip within the f64 margin
802    /// (#1211). The `penalty_op` path stays serial — it is an opaque operator
803    /// with its own structure (SAE uses the dense `hbb`), and small `k` stays
804    /// serial to avoid rayon overhead on a trivial GEMV.
805    ///
806    /// `parallel` is the caller's top-level / not-nested-in-rayon decision (the
807    /// same guard the row loop uses), so this never oversubscribes inside the
808    /// topology race.
809    pub(crate) fn penalty_ridge_prologue_into(
810        &self,
811        x: &[f64],
812        ridge: f64,
813        y: &mut [f64],
814        parallel: bool,
815    ) {
816        let k = self.hbb.nrows();
817        let dense_parallel = parallel
818            && self.penalty_op.is_none()
819            && self.hbb.dim() == (k, k)
820            && k >= SCHUR_PROLOGUE_PARALLEL_K_MIN;
821        if dense_parallel {
822            use rayon::prelude::*;
823            let hbb = &self.hbb;
824            y.par_iter_mut().enumerate().for_each(|(a, ya)| {
825                let mut acc = 0.0_f64;
826                for b in 0..k {
827                    acc += hbb[[a, b]] * x[b];
828                }
829                *ya = acc + ridge * x[a];
830            });
831        } else {
832            self.penalty_matvec_add(x, y);
833            for a in 0..k {
834                y[a] += ridge * x[a];
835            }
836        }
837    }
838
839    /// `diag += diag(P)` without allocating; dispatches to `penalty_op`
840    /// or falls back to `hbb` diagonal / `hbb_diag` inline.
841    #[inline]
842    pub(crate) fn penalty_diagonal_add(&self, diag: &mut [f64]) {
843        if let Some(op) = self.penalty_op.as_ref() {
844            op.diagonal(diag);
845        } else if let Some(hbb_diag) = self.hbb_diag.as_ref() {
846            let k = hbb_diag.len().min(diag.len());
847            for j in 0..k {
848                diag[j] += hbb_diag[j];
849            }
850        } else {
851            let k = self.hbb.nrows().min(diag.len());
852            for j in 0..k {
853                diag[j] += self.hbb[[j, j]];
854            }
855        }
856    }
857
858    /// Add the `b×b` penalty sub-block for `id` to `out`, routing through
859    /// `penalty_op` or falling back to `hbb` / `hbb_diag` inline.
860    #[inline]
861    pub(crate) fn penalty_block_add(
862        &self,
863        id: BetaBlockId,
864        offsets: &[Range<usize>],
865        out: &mut Array2<f64>,
866    ) {
867        if let Some(op) = self.penalty_op.as_ref() {
868            op.block(id, offsets, out);
869        } else {
870            let range = &offsets[id.0];
871            let b = range.end - range.start;
872            if self.hbb.dim() == (self.k, self.k) {
873                for bi in 0..b {
874                    for bj in 0..b {
875                        out[[bi, bj]] += self.hbb[[range.start + bi, range.start + bj]];
876                    }
877                }
878            } else if let Some(hbb_diag) = self.hbb_diag.as_ref() {
879                for bi in 0..b {
880                    out[[bi, bi]] += hbb_diag[range.start + bi];
881                }
882            }
883        }
884    }
885
886    /// Fill a `b×b` penalty sub-block for a set of arbitrary (possibly
887    /// non-contiguous) global column indices `cols`, routing through
888    /// `penalty_op` or falling back to `hbb` / `hbb_diag` inline.
889    ///
890    /// Used by the cluster-Jacobi preconditioner (#299) which groups columns
891    /// by spectral adjacency rather than contiguous block ranges.
892    #[inline]
893    pub(crate) fn penalty_subblock_add(&self, cols: &[usize], out: &mut Array2<f64>) {
894        let b = cols.len();
895        if let Some(op) = self.penalty_op.as_ref() {
896            // Probe each column basis vector and extract the sub-block entries.
897            let mut probe = Array1::<f64>::zeros(self.k);
898            let mut result = Array1::<f64>::zeros(self.k);
899            for bj in 0..b {
900                probe.fill(0.0);
901                probe[cols[bj]] = 1.0;
902                result.fill(0.0);
903                {
904                    let p_slice = probe.as_slice().expect("probe contiguous");
905                    let r_slice = result.as_slice_mut().expect("result contiguous");
906                    op.matvec(p_slice, r_slice);
907                }
908                for bi in 0..b {
909                    out[[bi, bj]] += result[cols[bi]];
910                }
911            }
912        } else if self.hbb.dim() == (self.k, self.k) {
913            for bi in 0..b {
914                for bj in 0..b {
915                    out[[bi, bj]] += self.hbb[[cols[bi], cols[bj]]];
916                }
917            }
918        } else if let Some(hbb_diag) = self.hbb_diag.as_ref() {
919            for bi in 0..b {
920                out[[bi, bi]] += hbb_diag[cols[bi]];
921            }
922        }
923    }
924
925    /// Convert row-local Euclidean latent blocks to Riemannian tangent blocks.
926    ///
927    /// This is the only arrow-Schur algebra change needed for manifold
928    /// latents: `g_t`, `H_tt`, and each `H_tβ` column are projected to
929    /// `T_{t_i}M`, while the shared β block and Schur structure remain
930    /// untouched. Embedded constrained manifolds carry a pinned normal block
931    /// so the existing ambient Cholesky factorization still works; all RHS
932    /// terms live in the tangent space, so the solved update retracts cleanly.
933    pub fn apply_riemannian_latent_geometry(&mut self, latent: &LatentCoordValues) {
934        let manifold = latent.manifold();
935        self.manifold_mode_fingerprint = manifold_mode_fingerprint(latent);
936        if manifold.is_euclidean() {
937            return;
938        }
939        assert_eq!(latent.n_obs(), self.rows.len());
940        assert_eq!(latent.latent_dim(), self.d);
941        for (i, row) in self.rows.iter_mut().enumerate() {
942            let t_i = ArrayView1::from(latent.row(i));
943            let gt_e = row.gt.clone();
944            let htt_e = row.htt.clone();
945            let htbeta_e = row.htbeta.clone();
946            row.gt = manifold.project_gradient_to_tangent(t_i, gt_e.view());
947            row.htt = manifold.riemannian_hessian_matrix(t_i, gt_e.view(), htt_e.view());
948            row.htbeta = manifold.project_matrix_columns_to_gradient_tangent(
949                t_i,
950                gt_e.view(),
951                htbeta_e.view(),
952            );
953        }
954    }
955
956    /// Apply the aggregate cross-row penalty Hessian `P_cross · v` over the
957    /// full flat latent vector `v` (length `Σ_i row_dims[i]`), accumulating
958    /// into `out`.
959    ///
960    /// `P_cross = Σ_p psd_majorizer_hvp_p(target_t, ·; ρ_p)` summed over every
961    /// captured cross-row penalty. Each penalty's `psd_majorizer_hvp` is its
962    /// exact (PSD) Hessian-vector product over the `N·d` flat latent vector —
963    /// for `TotalVariationPenalty` this is `Dᵀ diag(φ''(D t)) D · v`, the
964    /// graph/forward-difference Laplacian-style coupling that links distinct
965    /// rows. The ρ scaling is already baked into each penalty's resolved
966    /// weight, so no extra factor is applied here.
967    ///
968    /// This is only valid for homogeneous systems (every row of dimension
969    /// `d`), the only shape cross-row latent penalties are defined on; the
970    /// flat-index convention `flat = i·d + j` matches every penalty's
971    /// `latent_dim`/row-major contract.
972    pub(crate) fn apply_cross_row_penalty_hessian(
973        &self,
974        v: ArrayView1<'_, f64>,
975        out: &mut Array1<f64>,
976    ) {
977        for cross in &self.cross_row_penalties {
978            assert_eq!(cross.target_t.len(), v.len());
979            let hv =
980                cross
981                    .penalty
982                    .psd_majorizer_hvp(cross.target_t.view(), cross.rho_local.view(), v);
983            assert_eq!(hv.len(), out.len());
984            for i in 0..out.len() {
985                out[i] += hv[i];
986            }
987        }
988    }
989
990    /// Schur-eliminate the per-row latent block and solve for `(Δt, Δβ, diag)`.
991    ///
992    /// This uses [`ArrowSolveOptions::automatic`]: BA dense RCS for
993    /// `K <= 2000`, and Agarwal-style inexact Schur PCG above that size.
994    /// Call [`ArrowSchurSystem::solve_with_options`] to force Square-Root BA
995    /// or a specific inexact solve policy.
996    ///
997    /// Returns `(delta_t, delta_beta, ArrowPcgDiagnostics)` with `delta_t` flat
998    /// row-major of length `N · d` and `delta_beta` of length `K`. The sign
999    /// convention matches `solve_newton_direction_dense`: the returned
1000    /// increments satisfy the bordered system with RHS `[-g_t; -g_β]`, i.e.
1001    /// they are the *negated* solutions of the standard Newton-direction
1002    /// formulation. `ArrowPcgDiagnostics` is zero-valued for the Direct path and
1003    /// carries live counters (PCG iters, ridge escalations, residual) for
1004    /// InexactPCG.
1005    ///
1006    /// `ridge_t` and `ridge_beta` are nonnegative diagonal regularizers
1007    /// added to the latent and β blocks respectively before factorization
1008    /// — used by the LM damping outer wrapper to recover from near-singular
1009    /// inner steps. Pass `0.0` for both to obtain the unregularized
1010    /// Newton direction.
1011    pub fn solve(
1012        &self,
1013        ridge_t: f64,
1014        ridge_beta: f64,
1015    ) -> Result<(Array1<f64>, Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
1016        let options = ArrowSolveOptions::automatic(self.k);
1017        solve_arrow_newton_step_core(self, ridge_t, ridge_beta, &options)
1018    }
1019
1020    /// Solve with an explicit BA Schur mode, returning `(Δt, Δβ, ArrowPcgDiagnostics)`.
1021    ///
1022    /// [`ArrowSolverMode::Direct`] is the classic dense reduced-camera-system
1023    /// Cholesky path; [`ArrowSolverMode::SqrtBA`] forms the same dense system
1024    /// through Square-Root BA factors; [`ArrowSolverMode::InexactPCG`] runs
1025    /// inexact-step LM on the reduced system with Jacobi-preconditioned
1026    /// Steihaug-CG. `ArrowPcgDiagnostics` is zero-valued for Direct/SqrtBA and
1027    /// carries live counters for InexactPCG (iterations, matvec calls,
1028    /// preconditioner escalations, final relative residual, stopping reason).
1029    pub fn solve_with_options(
1030        &self,
1031        ridge_t: f64,
1032        ridge_beta: f64,
1033        options: &ArrowSolveOptions,
1034    ) -> Result<(Array1<f64>, Array1<f64>, ArrowPcgDiagnostics), ArrowSchurError> {
1035        solve_arrow_newton_step_core(self, ridge_t, ridge_beta, options)
1036    }
1037}
1038
1039/// Chunked Schur assembler that never retains all row cross-blocks.
1040pub struct StreamingArrowSchur {
1041    pub n_rows: usize,
1042    /// Maximum per-row latent dim (upper bound for scratch buffers).
1043    pub d: usize,
1044    /// Per-row latent dims `row_dims[i] == rows[i].htt.nrows()`.
1045    pub row_dims: Arc<[usize]>,
1046    /// Flat-buffer row offsets: `row_offsets[i]` is the start of row `i` in
1047    /// `delta_t`; `row_offsets[n_rows]` is the total `delta_t` length.
1048    pub row_offsets: Arc<[usize]>,
1049    pub k: usize,
1050    pub chunk_size: usize,
1051    pub s_acc: Array2<f64>,
1052    pub(crate) rhs_acc: Array1<f64>,
1053    pub(crate) hbb: Array2<f64>,
1054    pub(crate) gb: Array1<f64>,
1055    pub(crate) row_builder: StreamingArrowRowBuilder,
1056    /// Procedural cross-block operator `H_tβ^(i) x`. When present, the dense
1057    /// per-row `H_tβ` slabs are never materialized: `accumulate_chunk` and
1058    /// `back_substitute` probe this operator column-by-column to apply the
1059    /// cross-block, matching the Kronecker / matrix-free assembly path. When
1060    /// `None` (legacy dense BA callers), the per-row `row.htbeta` slab is used.
1061    pub(crate) htbeta_matvec: Option<RowHtbetaMatvec>,
1062    /// Sparse adjoint of `htbeta_matvec`. When present, `row_htbeta` rebuilds
1063    /// the dense `(d_i × K)` cross-block by probing the transpose with `d_i`
1064    /// basis vectors — `O(d_i · m_i · p)` total, vs the `O(K · m_i · p)` cost
1065    /// of probing the forward operator with `K` basis vectors. Since
1066    /// `d_i ≪ K`, this is the per-row sparse apply that replaces the `O(K)`
1067    /// column-probe in the streaming reduced-Schur accumulation.
1068    pub(crate) htbeta_transpose_matvec: Option<RowHtbetaTransposeMatvec>,
1069    /// Mirror of [`ArrowSchurSystem::htbeta_dense_supplement`]: the per-row
1070    /// `row.htbeta` slab carries a dense contribution that must be ADDED on top
1071    /// of the matrix-free operator, not used instead of it.
1072    ///
1073    /// This flag existed only on the dense system, which is what made #2509's
1074    /// symptom possible: `from_system` zeroed every `row.htbeta` whenever an
1075    /// operator was installed, so a caller that wrote a correction into the slab
1076    /// and set the flag got a streaming lane that silently priced the
1077    /// UNCORRECTED operator — no error, no shape mismatch, just a different
1078    /// number from the dense lane. The dense helpers gate on
1079    /// `htbeta_dense_supplement || htbeta_matvec.is_none()`; the streaming lane
1080    /// had no way to ask the question at all.
1081    pub(crate) htbeta_dense_supplement: bool,
1082    /// Whether streaming rows are being factored for undamped evidence rather
1083    /// than for a Newton step. Defaults to `false` so direct chunk callers keep
1084    /// the full step-accuracy guard.
1085    pub(crate) evidence_factorization: bool,
1086    /// #2515 — whether a RESOLVED negative direction of a streamed evidence row
1087    /// is a typed refusal rather than a unit pin. Set from the same
1088    /// `ArrowEvidencePolicy` as `evidence_factorization`, in the same place, so
1089    /// the two halves of one policy cannot describe different policies.
1090    pub(crate) refuse_resolved_indefinite: bool,
1091    /// SAE manifold evidence-path per-row gauge deflation, copied from the
1092    /// source [`ArrowSchurSystem::row_gauge_deflation`] (#1273/#1377). When
1093    /// present, the streaming per-row factor MUST apply the SAME spectral
1094    /// discovery-and-deflation of an intrinsic-dimension-flat `H_tt^(i)`
1095    /// direction (eigenvalue → +1, ρ-independent `log 1 = 0` evidence) that the
1096    /// dense [`factor_blocks_for_system`] path applies, or the two routes report
1097    /// different log-determinants for the SAME system — the cross-route
1098    /// invariant `streaming_logdet == full_logdet` would break (the #1377
1099    /// regression: #1273 wired the deflation into the dense path only). `None`
1100    /// for every non-evidence caller, which keeps the strict non-PD refusal.
1101    pub(crate) row_gauge_deflation: Option<ArrowRowGaugeDeflation>,
1102}
1103
1104impl std::fmt::Debug for StreamingArrowSchur {
1105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1106        f.debug_struct("StreamingArrowSchur")
1107            .field("n_rows", &self.n_rows)
1108            .field("d", &self.d)
1109            .field("k", &self.k)
1110            .field("chunk_size", &self.chunk_size)
1111            .finish_non_exhaustive()
1112    }
1113}
1114
1115impl StreamingArrowSchur {
1116    #[must_use]
1117    pub fn new(
1118        n_rows: usize,
1119        d: usize,
1120        row_dims: Arc<[usize]>,
1121        row_offsets: Arc<[usize]>,
1122        k: usize,
1123        hbb: Array2<f64>,
1124        gb: Array1<f64>,
1125        row_builder: StreamingArrowRowBuilder,
1126        chunk_size: usize,
1127    ) -> Self {
1128        assert_eq!(hbb.dim(), (k, k));
1129        assert_eq!(gb.len(), k);
1130        Self {
1131            n_rows,
1132            d,
1133            row_dims,
1134            row_offsets,
1135            k,
1136            chunk_size: chunk_size.max(1),
1137            s_acc: Array2::<f64>::zeros((k, k)),
1138            rhs_acc: Array1::<f64>::zeros(k),
1139            hbb,
1140            gb,
1141            row_builder,
1142            htbeta_matvec: None,
1143            htbeta_transpose_matvec: None,
1144            htbeta_dense_supplement: false,
1145            evidence_factorization: false,
1146            refuse_resolved_indefinite: false,
1147            row_gauge_deflation: None,
1148        }
1149    }
1150
1151    #[must_use]
1152    pub fn from_system(sys: &ArrowSchurSystem, chunk_size: usize) -> Self {
1153        // When a Kronecker / matrix-free htbeta_matvec is installed, the dense
1154        // row.htbeta slabs may be zero-sized.  Rather than materialize every
1155        // `(d × K)` slab (the very `(N·K)`-scale buffer the streaming path
1156        // exists to avoid), retain the procedural operator and probe it per row
1157        // inside `accumulate_chunk` / `back_substitute`.  The row builder then
1158        // only carries the small `H_tt` / `g_t` blocks.
1159        let htbeta_matvec = sys.htbeta_matvec.clone();
1160        // ... UNLESS the slab is a SUPPLEMENT rather than a substitute. With
1161        // `htbeta_dense_supplement` set, `row.htbeta` holds a dense correction
1162        // that must be added on top of the operator, so zeroing it here is the
1163        // #2509 defect: the streaming lane then prices the uncorrected operator
1164        // and disagrees with the dense lane by exactly the dropped term.
1165        let rows: Vec<ArrowRowBlock> = if htbeta_matvec.is_some() && !sys.htbeta_dense_supplement {
1166            sys.rows
1167                .iter()
1168                .map(|row| ArrowRowBlock {
1169                    htt: row.htt.clone(),
1170                    htbeta: Array2::<f64>::zeros((0, 0)),
1171                    gt: row.gt.clone(),
1172                })
1173                .collect()
1174        } else {
1175            sys.rows.clone()
1176        };
1177        let rows = Arc::new(rows);
1178        let row_builder: StreamingArrowRowBuilder = Arc::new(move |row| {
1179            rows.get(row)
1180                .cloned()
1181                .ok_or_else(|| ArrowSchurError::SchurFactorFailed {
1182                    reason: format!("streaming row {row} out of bounds"),
1183                })
1184        });
1185        // Materialize the dense β-block from the effective penalty operator so
1186        // the streaming accumulator stays correct when contributions live in a
1187        // structured `BetaPenaltyOp` (e.g. the SAE data-fit Gauss-Newton block,
1188        // represented as `G ⊗ I_p`) rather than the dense `hbb` accumulator.
1189        // When no `penalty_op` is installed this reduces to `hbb.clone()`.
1190        let hbb_dense = sys.effective_penalty_op().to_dense();
1191        let mut streaming = Self::new(
1192            sys.rows.len(),
1193            sys.d,
1194            Arc::clone(&sys.row_dims),
1195            Arc::clone(&sys.row_offsets),
1196            sys.k,
1197            hbb_dense,
1198            sys.gb.clone(),
1199            row_builder,
1200            chunk_size,
1201        );
1202        streaming.htbeta_matvec = htbeta_matvec;
1203        streaming.htbeta_transpose_matvec = sys.htbeta_transpose_matvec.clone();
1204        streaming.htbeta_dense_supplement = sys.htbeta_dense_supplement;
1205        // Carry the SAE evidence-path per-row gauge deflation so the streaming
1206        // per-row factor matches the dense `factor_blocks_for_system` exactly
1207        // (#1377): without it, a row with an intrinsic-dimension-flat `H_tt`
1208        // would deflate on the dense path but be refused / log-det-divergent on
1209        // the streaming path, breaking `streaming_logdet == full_logdet`.
1210        streaming.row_gauge_deflation = sys.row_gauge_deflation.clone();
1211        streaming
1212    }
1213
1214    /// Factor one streaming row's `H_tt^(i)`, applying the SAME per-row gauge /
1215    /// spectral deflation the dense [`factor_blocks_for_system`] path applies
1216    /// when this is the SAE manifold evidence path (an installed
1217    /// `row_gauge_deflation`). For every non-evidence caller this is exactly the
1218    /// generic [`factor_one_row`] (strict non-PD refusal), so PD blocks are
1219    /// bit-for-bit unchanged. Routing both the dense and streaming per-row
1220    /// factors through the identical recovery is what keeps their
1221    /// log-determinants identical (#1273/#1377).
1222    fn factor_row(
1223        &self,
1224        row: &ArrowRowBlock,
1225        ridge_t: f64,
1226        di: usize,
1227        row_idx: usize,
1228    ) -> Result<Array2<f64>, ArrowSchurError> {
1229        match self.row_gauge_deflation.as_ref() {
1230            Some(deflation) => factor_one_row_result(
1231                row,
1232                ridge_t,
1233                di,
1234                row_idx,
1235                self.evidence_factorization,
1236                deflation.row(row_idx),
1237                // Evidence path: opt into spectral discovery of an
1238                // intrinsic-dimension-flat direction even when this row's
1239                // supplied gauge list is empty/non-spanning — matching the
1240                // `allow_spectral_deflation = true` the dense path passes.
1241                true,
1242                self.refuse_resolved_indefinite,
1243                // The streaming system carries no exact-A classification
1244                // geometry; the row is factored as it was before that
1245                // classification existed.
1246                None,
1247            )
1248            .map(|result| result.factor),
1249            None => factor_one_row(row, ridge_t, di, row_idx, self.evidence_factorization),
1250        }
1251    }
1252
1253    /// Build the `(di × k)` cross-block for `row_idx` on demand.
1254    ///
1255    /// When the sparse transpose adjoint is installed, probes it with `di`
1256    /// standard basis vectors — each yields a full `K`-row of `H_βt^(i)`
1257    /// (i.e. a row of the `(di × k)` block) via the sparse scatter, for
1258    /// `O(di · m_i · p)` total, far below the `O(K · m_i · p)` cost of probing
1259    /// the forward operator with `K` basis vectors when `di ≪ K`.
1260    ///
1261    /// When only the forward operator is installed (no adjoint), falls back to
1262    /// the `k`-column forward probe. Otherwise clones the dense `row.htbeta`
1263    /// slab.
1264    ///
1265    /// When [`Self::htbeta_dense_supplement`] is set, the dense slab is a
1266    /// CORRECTION to the operator, not a replacement for it, so the probe result
1267    /// and the slab are summed. Returning only the probe is #2509: the dense
1268    /// lane adds the supplement (`sys.htbeta_dense_supplement || matvec.is_none()`)
1269    /// and the streaming lane did not, so the two priced different operators
1270    /// while every shape and every finiteness check still passed.
1271    pub(crate) fn row_htbeta(&self, row_idx: usize, row: &ArrowRowBlock, di: usize) -> Array2<f64> {
1272        // Add the dense supplement to whatever the operator probe returns. Shape
1273        // is asserted rather than assumed: a mis-sized supplement would
1274        // otherwise broadcast or truncate into a silently wrong cross-block,
1275        // which is the same class of failure this fix exists to remove.
1276        let add_supplement = |mut probe: Array2<f64>| -> Array2<f64> {
1277            if !self.htbeta_dense_supplement || row.htbeta.is_empty() {
1278                return probe;
1279            }
1280            assert_eq!(
1281                row.htbeta.dim(),
1282                probe.dim(),
1283                "row {row_idx}: dense htbeta supplement is {:?} but the operator \
1284                 probe is {:?}; a supplement must be shaped like the block it corrects",
1285                row.htbeta.dim(),
1286                probe.dim(),
1287            );
1288            probe += &row.htbeta;
1289            probe
1290        };
1291
1292        if let Some(op_t) = self.htbeta_transpose_matvec.as_ref() {
1293            // Probe the adjoint: for each latent index c, scatter e_c to obtain
1294            // row c of the (di × k) block.
1295            let mut mat = Array2::<f64>::zeros((di, self.k));
1296            let mut e_c = Array1::<f64>::zeros(di);
1297            let mut beta_row = Array1::<f64>::zeros(self.k);
1298            for c in 0..di {
1299                e_c.fill(0.0);
1300                e_c[c] = 1.0;
1301                beta_row.fill(0.0);
1302                op_t(row_idx, e_c.view(), &mut beta_row);
1303                for a in 0..self.k {
1304                    mat[[c, a]] = beta_row[a];
1305                }
1306            }
1307            return add_supplement(mat);
1308        }
1309        match self.htbeta_matvec.as_ref() {
1310            Some(op) => {
1311                let mut mat = Array2::<f64>::zeros((di, self.k));
1312                let mut e_a = Array1::<f64>::zeros(self.k);
1313                let mut col = Array1::<f64>::zeros(di);
1314                for a in 0..self.k {
1315                    e_a.fill(0.0);
1316                    e_a[a] = 1.0;
1317                    col.fill(0.0);
1318                    op(row_idx, e_a.view(), &mut col);
1319                    for c in 0..di {
1320                        mat[[c, a]] = col[c];
1321                    }
1322                }
1323                add_supplement(mat)
1324            }
1325            None => row.htbeta.clone(),
1326        }
1327    }
1328
1329    /// Reset the dense shared accumulator to `H_ββ + ridge_beta I`.
1330    pub fn reset_accumulator(&mut self, ridge_beta: f64) -> Result<(), ArrowSchurError> {
1331        if self.hbb.dim() != (self.k, self.k) {
1332            return Err(ArrowSchurError::SchurFactorFailed {
1333                reason: "streaming Arrow-Schur requires a dense beta block accumulator".to_string(),
1334            });
1335        }
1336        self.s_acc.assign(&self.hbb);
1337        for j in 0..self.k {
1338            self.s_acc[[j, j]] += ridge_beta;
1339            self.rhs_acc[j] = 0.0;
1340        }
1341        Ok(())
1342    }
1343
1344    /// Accumulate rows `[start, end)` into the reduced RHS and Schur block.
1345    pub fn accumulate_chunk(
1346        &mut self,
1347        start: usize,
1348        end: usize,
1349        ridge_t: f64,
1350        mode: ArrowSolverMode,
1351    ) -> Result<(), ArrowSchurError> {
1352        if start > end || end > self.n_rows {
1353            return Err(ArrowSchurError::SchurFactorFailed {
1354                reason: format!(
1355                    "streaming Arrow-Schur chunk [{start}, {end}) outside 0..{}",
1356                    self.n_rows
1357                ),
1358            });
1359        }
1360        let backend = CpuBatchedBlockSolver;
1361        let k = self.k;
1362        // Per-row factor + two block solves + a `k×k` GEMM subtract is the whole
1363        // assembly cost at the SAE LLM shape (#1017); the rows are independent so
1364        // the reduction fans across cores.
1365        // #2228 determinism: reduce the per-row contributions —
1366        // `+H_βt^(i)(H_tt^(i))⁻¹ g_t^(i)` (length `k`, into the reduced RHS) and
1367        // `−H_βt^(i)(H_tt^(i))⁻¹ H_tβ^(i)` (`k×k`, into the reduced Schur
1368        // complement) — through the length-only pairwise tree. The within-chunk
1369        // association is then bit-identical across thread count AND to the
1370        // sequential fold, removing the #1017/#1211 chunk-reassociation margin
1371        // that let the criterion ranking depend on the driver. The tree
1372        // self-serializes below `BASE_CHUNK` rows (a base block is folded directly
1373        // with no `rayon::join`), so the handful-of-rows callers and nested
1374        // topology-race calls stay single-threaded without a separate branch.
1375        // Each streaming chunk's tree result folds into the seeded running
1376        // `self.{rhs,s}_acc` (which carry `H_ββ + ridge·I`) in chunk order.
1377        let this: &Self = self;
1378        let row_into = |row_idx: usize,
1379                        rhs_part: &mut Array1<f64>,
1380                        s_part: &mut Array2<f64>,
1381                        stack: &mut ChunkSchurStack|
1382         -> Result<(), ArrowSchurError> {
1383            let row = (this.row_builder)(row_idx)?;
1384            let di = row.htt.nrows();
1385            this.validate_row(row_idx, &row)?;
1386            let htbeta = this.row_htbeta(row_idx, &row, di);
1387            let factor = this.factor_row(&row, ridge_t, di, row_idx)?;
1388            let v = backend.solve_block_vector(factor.view(), row.gt.view());
1389            for c in 0..di {
1390                let vc = v[c];
1391                if vc == 0.0 {
1392                    continue;
1393                }
1394                for a in 0..k {
1395                    rhs_part[a] += htbeta[[c, a]] * vc;
1396                }
1397            }
1398            match mode {
1399                // InexactPCG differs from Direct only in how the *reduced* system
1400                // is solved, not how it is assembled, so it shares this Schur
1401                // subtraction.
1402                ArrowSolverMode::Direct | ArrowSolverMode::InexactPCG => {
1403                    let solved = backend.solve_block_matrix(factor.view(), htbeta.view());
1404                    stack.subtract_or_stack(&backend, s_part, &htbeta, &solved);
1405                }
1406                ArrowSolverMode::SqrtBA => {
1407                    let whitened = backend.sqrt_solve_block_matrix(factor.view(), htbeta.view());
1408                    stack.subtract_or_stack(&backend, s_part, &whitened, &whitened);
1409                }
1410            }
1411            Ok(())
1412        };
1413        let contribution = gam_linalg::pairwise_reduce::par_deterministic_try_block_fold(
1414            end - start,
1415            |range: core::ops::Range<usize>| -> Result<(Array1<f64>, Array2<f64>), ArrowSchurError> {
1416                let mut rhs_part = Array1::<f64>::zeros(k);
1417                let mut s_part = Array2::<f64>::zeros((k, k));
1418                // Dense-support rows accumulate into ONE stacked GEMM per base
1419                // block instead of a per-row scalar scatter; sparse rows keep the
1420                // nnz-scaled scatter (see `ChunkSchurStack`).
1421                let mut stack = ChunkSchurStack::new(k);
1422                for local in range {
1423                    row_into(start + local, &mut rhs_part, &mut s_part, &mut stack)?;
1424                }
1425                stack.flush(&mut s_part);
1426                Ok((rhs_part, s_part))
1427            },
1428            |(mut ra, mut sa): (Array1<f64>, Array2<f64>),
1429             (rb, sb): (Array1<f64>, Array2<f64>)|
1430             -> Result<(Array1<f64>, Array2<f64>), ArrowSchurError> {
1431                ra += &rb;
1432                sa += &sb;
1433                Ok((ra, sa))
1434            },
1435        )?;
1436        // `subtract_or_stack`/`flush` already subtracted into each `s_part`, so the
1437        // partials carry the NEGATIVE Schur contribution; add them into the seeded
1438        // running accumulators.
1439        if let Some((rhs_part, s_part)) = contribution {
1440            for a in 0..k {
1441                self.rhs_acc[a] += rhs_part[a];
1442            }
1443            self.s_acc += &s_part;
1444        }
1445        Ok(())
1446    }
1447
1448    /// Compute the exact arrow Hessian log-determinant by accumulating the
1449    /// reduced Schur complement in row chunks, without retaining the full set
1450    /// of per-row Cholesky factors.
1451    ///
1452    /// This is the streaming analogue of [`ArrowFactorCache::arrow_log_det`]:
1453    ///
1454    /// ```text
1455    /// log|H| = Σ_i log|H_tt^(i)| + log|H_ββ - Σ_i H_βt^(i) H_tt^(i)⁻¹ H_tβ^(i)|.
1456    /// ```
1457    ///
1458    /// The same row builder and procedural `H_tβ` callbacks used by the
1459    /// streaming Newton solve are consumed here, so callers can score REML
1460    /// evidence without materialising the full `(N × q × K)` cross block or
1461    /// the full list of row factors.
1462    pub fn reduced_schur_and_log_det_tt(
1463        &mut self,
1464        ridge_t: f64,
1465        ridge_beta: f64,
1466        options: &ArrowSolveOptions,
1467    ) -> Result<(f64, Array2<f64>), ArrowSchurError> {
1468        self.evidence_factorization = options.evidence_policy.factors_undamped_evidence();
1469        self.refuse_resolved_indefinite = options.evidence_policy.refuses_resolved_indefinite();
1470        self.reset_accumulator(ridge_beta)?;
1471        let backend = CpuBatchedBlockSolver;
1472        let mut log_det_tt = 0.0_f64;
1473        for start in (0..self.n_rows).step_by(self.chunk_size) {
1474            let end = (start + self.chunk_size).min(self.n_rows);
1475            for row_idx in start..end {
1476                let row = (self.row_builder)(row_idx)?;
1477                let di = row.htt.nrows();
1478                self.validate_row(row_idx, &row)?;
1479                let htbeta = self.row_htbeta(row_idx, &row, di);
1480                let factor = self.factor_row(&row, ridge_t, di, row_idx)?;
1481                for axis in 0..di {
1482                    log_det_tt += 2.0 * factor[[axis, axis]].ln();
1483                }
1484                match options.mode {
1485                    ArrowSolverMode::Direct | ArrowSolverMode::InexactPCG => {
1486                        let solved = backend.solve_block_matrix(factor.view(), htbeta.view());
1487                        backend.block_gemm_subtract(&mut self.s_acc, &htbeta, &solved);
1488                    }
1489                    ArrowSolverMode::SqrtBA => {
1490                        let whitened =
1491                            backend.sqrt_solve_block_matrix(factor.view(), htbeta.view());
1492                        backend.block_gemm_subtract(&mut self.s_acc, &whitened, &whitened);
1493                    }
1494                }
1495            }
1496        }
1497        symmetrize_upper_from_lower(&mut self.s_acc);
1498        let schur = std::mem::replace(&mut self.s_acc, Array2::<f64>::zeros((self.k, self.k)));
1499        Ok((log_det_tt, schur))
1500    }
1501
1502    pub fn reduced_schur_log_det(
1503        schur: &Array2<f64>,
1504        options: &ArrowSolveOptions,
1505    ) -> Result<f64, ArrowSchurError> {
1506        let schur_factor =
1507            factor_dense_reduced_schur(schur, options.evidence_policy.reduced_schur_policy())?
1508                .factor;
1509        let mut log_det_schur = 0.0_f64;
1510        for axis in 0..schur_factor.nrows() {
1511            log_det_schur += 2.0 * schur_factor[[axis, axis]].ln();
1512        }
1513        Ok(log_det_schur)
1514    }
1515
1516    pub fn solve(
1517        &mut self,
1518        ridge_t: f64,
1519        ridge_beta: f64,
1520        options: &ArrowSolveOptions,
1521    ) -> Result<(Array1<f64>, Array1<f64>, Option<Array2<f64>>), ArrowSchurError> {
1522        // Newton streaming factors always retain the step-accuracy guard.
1523        self.evidence_factorization = false;
1524        self.refuse_resolved_indefinite = false;
1525        self.reset_accumulator(ridge_beta)?;
1526        for start in (0..self.n_rows).step_by(self.chunk_size) {
1527            let end = (start + self.chunk_size).min(self.n_rows);
1528            self.accumulate_chunk(start, end, ridge_t, options.mode)?;
1529        }
1530        for j in 0..self.k {
1531            self.rhs_acc[j] -= self.gb[j];
1532        }
1533        symmetrize_upper_from_lower(&mut self.s_acc);
1534        let trust_metric_weights = None;
1535        let (delta_beta, schur_factor, _diag) =
1536            solve_dense_reduced_system(&self.s_acc, &self.rhs_acc, options, trust_metric_weights)?;
1537        let delta_t = self.back_substitute(ridge_t, delta_beta.view())?;
1538        Ok((delta_t, delta_beta, schur_factor))
1539    }
1540
1541    pub(crate) fn back_substitute(
1542        &self,
1543        ridge_t: f64,
1544        delta_beta: ArrayView1<'_, f64>,
1545    ) -> Result<Array1<f64>, ArrowSchurError> {
1546        let backend = CpuBatchedBlockSolver;
1547        // Total delta_t length = row_offsets[n_rows].
1548        let total_len = self.row_offsets[self.n_rows];
1549        let mut delta_t = Array1::<f64>::zeros(total_len);
1550        // Each row's back-solve `Δt_i = -(H_tt^(i))⁻¹(g_t^(i) + H_tβ^(i)Δβ)`
1551        // writes a DISJOINT segment `delta_t[row_base .. row_base+di]` — no
1552        // cross-row reduction, so this is embarrassingly parallel and the scatter
1553        // is bit-identical regardless of which thread produced each segment (the
1554        // #1017 verification gate). At the SAE LLM shape (`n` in the thousands)
1555        // the per-row factor + solve is the whole cost; below the threshold, or
1556        // when already inside a rayon worker (the topology race fans candidates
1557        // with `run_topology_race_parallel`), stay sequential to avoid
1558        // nested-rayon oversubscription — the same guard `schur_matvec` uses.
1559        let parallel =
1560            self.n_rows >= SCHUR_MATVEC_PARALLEL_ROW_MIN && rayon::current_thread_index().is_none();
1561        if parallel {
1562            use rayon::prelude::*;
1563            const CHUNK: usize = 64;
1564            // Per-row body: factor, form the RHS, solve, return `-(dt_i)`.
1565            let row_solve = |row_idx: usize| -> Result<(usize, Array1<f64>), ArrowSchurError> {
1566                let row = (self.row_builder)(row_idx)?;
1567                let di = row.htt.nrows();
1568                self.validate_row(row_idx, &row)?;
1569                let factor = self.factor_row(&row, ridge_t, di, row_idx)?;
1570                let mut htbeta_delta = Array1::<f64>::zeros(di);
1571                if let Some(op) = self.htbeta_matvec.as_ref() {
1572                    op(row_idx, delta_beta, &mut htbeta_delta);
1573                } else {
1574                    for c in 0..di {
1575                        let mut acc = 0.0_f64;
1576                        for a in 0..self.k {
1577                            acc += row.htbeta[[c, a]] * delta_beta[a];
1578                        }
1579                        htbeta_delta[c] = acc;
1580                    }
1581                }
1582                let mut rhs = Array1::<f64>::zeros(di);
1583                for c in 0..di {
1584                    rhs[c] = row.gt[c] + htbeta_delta[c];
1585                }
1586                let dt_i = backend.solve_block_vector(factor.view(), rhs.view());
1587                let mut neg = Array1::<f64>::zeros(di);
1588                for c in 0..di {
1589                    neg[c] = -dt_i[c];
1590                }
1591                Ok((self.row_offsets[row_idx], neg))
1592            };
1593            // Collect per-row segments under rayon, then scatter into the disjoint
1594            // slices. Errors are surfaced via `collect::<Result<…>>`.
1595            let segments: Vec<(usize, Array1<f64>)> = (0..self.n_rows)
1596                .into_par_iter()
1597                .chunks(CHUNK)
1598                .map(|idxs| {
1599                    idxs.into_iter()
1600                        .map(&row_solve)
1601                        .collect::<Result<Vec<_>, _>>()
1602                })
1603                .collect::<Result<Vec<_>, _>>()?
1604                .into_iter()
1605                .flatten()
1606                .collect();
1607            for (base, seg) in &segments {
1608                for (c, &v) in seg.iter().enumerate() {
1609                    delta_t[base + c] = v;
1610                }
1611            }
1612        } else {
1613            let mut rhs = Array1::<f64>::zeros(self.d);
1614            for start in (0..self.n_rows).step_by(self.chunk_size) {
1615                let end = (start + self.chunk_size).min(self.n_rows);
1616                for row_idx in start..end {
1617                    let row = (self.row_builder)(row_idx)?;
1618                    let di = row.htt.nrows();
1619                    self.validate_row(row_idx, &row)?;
1620                    let factor = self.factor_row(&row, ridge_t, di, row_idx)?;
1621                    // `H_tβ^(i) Δβ`: route through the procedural operator when
1622                    // present (no dense slab), else through the dense slab.
1623                    let mut htbeta_delta = Array1::<f64>::zeros(di);
1624                    if let Some(op) = self.htbeta_matvec.as_ref() {
1625                        op(row_idx, delta_beta, &mut htbeta_delta);
1626                    } else {
1627                        for c in 0..di {
1628                            let mut acc = 0.0_f64;
1629                            for a in 0..self.k {
1630                                acc += row.htbeta[[c, a]] * delta_beta[a];
1631                            }
1632                            htbeta_delta[c] = acc;
1633                        }
1634                    }
1635                    for c in 0..di {
1636                        rhs[c] = row.gt[c] + htbeta_delta[c];
1637                    }
1638                    let dt_i = backend.solve_block_vector(factor.view(), rhs.view());
1639                    let row_base = self.row_offsets[row_idx];
1640                    for c in 0..di {
1641                        delta_t[row_base + c] = -dt_i[c];
1642                    }
1643                }
1644            }
1645        }
1646        Ok(delta_t)
1647    }
1648
1649    pub(crate) fn validate_row(
1650        &self,
1651        row_idx: usize,
1652        row: &ArrowRowBlock,
1653    ) -> Result<(), ArrowSchurError> {
1654        let expected_di = if row_idx < self.row_dims.len() {
1655            self.row_dims[row_idx]
1656        } else {
1657            self.d
1658        };
1659        let actual_di = row.htt.nrows();
1660        if actual_di != expected_di || row.htt.ncols() != expected_di {
1661            return Err(ArrowSchurError::PerRowFactorFailed {
1662                row: row_idx,
1663                reason: format!(
1664                    "streaming row H_tt shape {:?} != ({expected_di}, {expected_di})",
1665                    row.htt.dim(),
1666                ),
1667            });
1668        }
1669        // The dense `H_tβ` slab is only validated when no procedural operator is
1670        // installed; with `htbeta_matvec` the slab is intentionally zero-sized
1671        // and the cross-block is probed in `row_htbeta`.
1672        if self.htbeta_matvec.is_none() && row.htbeta.dim() != (expected_di, self.k) {
1673            return Err(ArrowSchurError::SchurFactorFailed {
1674                reason: format!(
1675                    "streaming row H_tβ shape {:?} != ({expected_di}, {})",
1676                    row.htbeta.dim(),
1677                    self.k
1678                ),
1679            });
1680        }
1681        if row.gt.len() != expected_di {
1682            return Err(ArrowSchurError::PerRowFactorFailed {
1683                row: row_idx,
1684                reason: format!("streaming row g_t length {} != {expected_di}", row.gt.len()),
1685            });
1686        }
1687        Ok::<(), _>(())
1688    }
1689}
1690
1691/// Per-row + Schur Cholesky factor cache produced by
1692/// [`solve_arrow_newton_step_with_options`]. Consumed downstream by the IFT warm-start
1693/// predictor in `crate::persistent_warm_start`: when the outer
1694/// loop perturbs `(β, ρ)` by a small amount, the new Newton step can be
1695/// predicted by re-using these factors against a refreshed RHS, saving
1696/// the dominant `O(N d³ + K³)` factorization cost.
1697#[derive(Clone)]
1698pub struct ArrowFactorSlab {
1699    pub(crate) data: Arc<[f64]>,
1700    pub(crate) offsets: Arc<[usize]>,
1701    pub(crate) dims: Arc<[usize]>,
1702}
1703
1704impl ArrowFactorSlab {
1705    pub fn from_blocks(blocks: Vec<Array2<f64>>) -> Self {
1706        let mut data = Vec::new();
1707        let mut offsets = Vec::with_capacity(blocks.len() + 1);
1708        let mut dims = Vec::with_capacity(blocks.len());
1709        offsets.push(0);
1710        for block in blocks {
1711            let (rows, cols) = block.dim();
1712            assert_eq!(rows, cols, "ArrowFactorSlab stores square row factors");
1713            dims.push(rows);
1714            data.extend(block.iter().copied());
1715            offsets.push(data.len());
1716        }
1717        Self {
1718            data: data.into(),
1719            offsets: offsets.into(),
1720            dims: dims.into(),
1721        }
1722    }
1723
1724    pub fn len(&self) -> usize {
1725        self.dims.len()
1726    }
1727
1728    pub fn is_empty(&self) -> bool {
1729        self.dims.is_empty()
1730    }
1731
1732    pub fn factor(&self, row: usize) -> ArrayView2<'_, f64> {
1733        let dim = self.dims[row];
1734        let range = self.offsets[row]..self.offsets[row + 1];
1735        ArrayView2::from_shape((dim, dim), &self.data[range])
1736            .expect("ArrowFactorSlab row offset/dim invariant violated")
1737    }
1738
1739    pub fn iter(&self) -> impl Iterator<Item = ArrayView2<'_, f64>> + '_ {
1740        (0..self.len()).map(|row| self.factor(row))
1741    }
1742}
1743
1744impl std::fmt::Debug for ArrowFactorSlab {
1745    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1746        f.debug_struct("ArrowFactorSlab")
1747            .field("rows", &self.len())
1748            .field("values", &self.data.len())
1749            .finish()
1750    }
1751}
1752
1753#[derive(Clone)]
1754pub enum ArrowUndampedFactors {
1755    SameAsDamped,
1756    Owned(ArrowFactorSlab),
1757}
1758
1759impl std::fmt::Debug for ArrowUndampedFactors {
1760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1761        match self {
1762            Self::SameAsDamped => f.write_str("SameAsDamped"),
1763            Self::Owned(factors) => f.debug_tuple("Owned").field(&factors.len()).finish(),
1764        }
1765    }
1766}
1767
1768/// Apply `H_tβ^(row) · x` for one row, writing into `out` (length `d`).
1769///
1770/// Sums the installed matrix-free operator, when present, and any correctly
1771/// shaped dense `row.htbeta` slab. This lets structured data-fit rows coexist
1772/// with dense analytic-penalty cross blocks on the same row.
1773pub(crate) fn sys_htbeta_apply_row(
1774    sys: &ArrowSchurSystem,
1775    row_idx: usize,
1776    row: &ArrowRowBlock,
1777    x: ArrayView1<'_, f64>,
1778    out: &mut Array1<f64>,
1779) {
1780    out.fill(0.0);
1781    if let Some(op) = sys.htbeta_matvec.as_ref() {
1782        op(row_idx, x, out);
1783    }
1784    if (sys.htbeta_dense_supplement || sys.htbeta_matvec.is_none())
1785        && row.htbeta.dim() == (out.len(), sys.k)
1786    {
1787        let di = row.htbeta.nrows();
1788        for c in 0..di {
1789            let mut acc = 0.0_f64;
1790            for a in 0..sys.k {
1791                acc += row.htbeta[[c, a]] * x[a];
1792            }
1793            out[c] += acc;
1794        }
1795    }
1796}
1797
1798/// Accumulate `H_βt^(row) · v` into `out` (length `k`).
1799///
1800/// `out[a] += Σ_c H_tβ^(row)[c, a] · v[c]`
1801///
1802/// Sums the installed matrix-free operator, when present, and any correctly
1803/// shaped dense `row.htbeta` slab.
1804pub(crate) fn sys_htbeta_accumulate_transpose(
1805    sys: &ArrowSchurSystem,
1806    row_idx: usize,
1807    row: &ArrowRowBlock,
1808    v: ArrayView1<'_, f64>,
1809    out: &mut Array1<f64>,
1810) {
1811    if let Some(op_t) = sys.htbeta_transpose_matvec.as_ref() {
1812        op_t(row_idx, v, out);
1813    } else if sys.htbeta_matvec.is_some() {
1814        // SAFETY: reaching this arm means a forward matrix-free H_tbeta was
1815        // installed without its declared transpose — an installation-contract
1816        // violation upstream, not a data condition. Skipping the transpose term
1817        // instead would silently price a DIFFERENT operator (#2509's defect
1818        // class), so this must halt.
1819        panic!(
1820            "matrix-free H_tbeta requires its declared sparse transpose; \
1821             install both through ArrowSchurSystem::set_row_htbeta_operator"
1822        );
1823    }
1824    if (sys.htbeta_dense_supplement || sys.htbeta_matvec.is_none())
1825        && row.htbeta.dim() == (v.len(), sys.k)
1826    {
1827        let di = row.htbeta.nrows();
1828        for c in 0..di {
1829            let vc = v[c];
1830            if vc == 0.0 {
1831                continue;
1832            }
1833            for a in 0..sys.k {
1834                out[a] += row.htbeta[[c, a]] * vc;
1835            }
1836        }
1837    }
1838}
1839
1840/// Materialize the dense `(di, k)` cross-block for one row.
1841///
1842/// Materializes the sum of the installed matrix-free operator and any correctly
1843/// shaped dense slab on the row.
1844pub(crate) fn sys_htbeta_materialize_row(
1845    sys: &ArrowSchurSystem,
1846    row_idx: usize,
1847    row: &ArrowRowBlock,
1848) -> Result<Array2<f64>, ArrowSchurError> {
1849    let di = sys.row_dims[row_idx];
1850    let k = sys.k;
1851    let use_dense = sys.htbeta_dense_supplement || sys.htbeta_matvec.is_none();
1852    let mut mat = if use_dense && row.htbeta.dim() == (di, k) {
1853        row.htbeta.clone()
1854    } else {
1855        Array2::<f64>::zeros((di, k))
1856    };
1857    if let Some(op) = sys.htbeta_matvec.as_ref() {
1858        let mut e_a = Array1::<f64>::zeros(k);
1859        let mut col = Array1::<f64>::zeros(di);
1860        for a in 0..k {
1861            e_a.fill(0.0);
1862            e_a[a] = 1.0;
1863            col.fill(0.0);
1864            op(row_idx, e_a.view(), &mut col);
1865            for c in 0..di {
1866                mat[[c, a]] += col[c];
1867            }
1868        }
1869    } else if use_dense && row.htbeta.dim() != (di, k) {
1870        return Err(ArrowSchurError::SchurFactorFailed {
1871            reason: format!(
1872                "row {row_idx}: htbeta shape {:?} != ({di}, {k}) and no htbeta_matvec installed",
1873                row.htbeta.dim()
1874            ),
1875        });
1876    }
1877    Ok(mat)
1878}
1879
1880/// Probe each column of `H_tβ^(row)` by applying the operator to `e_a` and
1881/// dotting the result with `v`.  Accumulates into `out[a]` for all `a in 0..k`.
1882///
1883/// `out[a] += (H_tβ^(row) e_a) · v = H_βt^(row)[a, :] · v`
1884pub(crate) fn htbeta_probe_transpose(
1885    row: usize,
1886    op: &RowHtbetaMatvec,
1887    v: ArrayView1<'_, f64>,
1888    out: &mut Array1<f64>,
1889    d: usize,
1890    k: usize,
1891) {
1892    let mut e_a = Array1::<f64>::zeros(k);
1893    let mut col_a = Array1::<f64>::zeros(d);
1894    for a in 0..k {
1895        e_a.fill(0.0);
1896        e_a[a] = 1.0;
1897        col_a.fill(0.0);
1898        op(row, e_a.view(), &mut col_a);
1899        let mut acc = 0.0_f64;
1900        for c in 0..d {
1901            acc += col_a[c] * v[c];
1902        }
1903        out[a] += acc;
1904    }
1905}
1906
1907#[derive(Clone)]
1908pub enum ArrowHtbetaCache {
1909    Dense {
1910        blocks: Arc<[Array2<f64>]>,
1911        estimated_bytes: usize,
1912    },
1913    Matvec {
1914        op: RowHtbetaMatvec,
1915        transpose_op: RowHtbetaTransposeMatvec,
1916        estimated_bytes: usize,
1917    },
1918    Disabled {
1919        estimated_bytes: usize,
1920    },
1921}
1922
1923impl std::fmt::Debug for ArrowHtbetaCache {
1924    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1925        match self {
1926            Self::Dense {
1927                blocks,
1928                estimated_bytes,
1929            } => f
1930                .debug_struct("Dense")
1931                .field("blocks", &blocks.len())
1932                .field("estimated_bytes", estimated_bytes)
1933                .finish(),
1934            Self::Matvec {
1935                estimated_bytes, ..
1936            } => f
1937                .debug_struct("Matvec")
1938                .field("estimated_bytes", estimated_bytes)
1939                .finish(),
1940            Self::Disabled { estimated_bytes } => f
1941                .debug_struct("Disabled")
1942                .field("estimated_bytes", estimated_bytes)
1943                .finish(),
1944        }
1945    }
1946}
1947
1948impl ArrowHtbetaCache {
1949    pub(crate) fn from_system(sys: &ArrowSchurSystem) -> Result<Self, ArrowSchurError> {
1950        let estimated_bytes = sys
1951            .rows
1952            .len()
1953            .checked_mul(sys.d)
1954            .and_then(|value| value.checked_mul(sys.k))
1955            .and_then(|value| value.checked_mul(std::mem::size_of::<f64>()))
1956            .unwrap_or(usize::MAX);
1957        if let Some(op) = sys.htbeta_matvec.as_ref() {
1958            let transpose_op = sys.htbeta_transpose_matvec.as_ref().ok_or_else(|| {
1959                ArrowSchurError::SchurFactorFailed {
1960                    reason: "matrix-free H_tbeta factor cache requires the sparse transpose \
1961                             installed by ArrowSchurSystem::set_row_htbeta_operator"
1962                        .to_string(),
1963                }
1964            })?;
1965            Ok(Self::Matvec {
1966                op: Arc::clone(op),
1967                transpose_op: Arc::clone(transpose_op),
1968                estimated_bytes,
1969            })
1970        } else if estimated_bytes <= ARROW_FACTOR_CACHE_HTBETA_BUDGET_BYTES {
1971            Ok(Self::Dense {
1972                blocks: sys
1973                    .rows
1974                    .iter()
1975                    .map(|row| row.htbeta.clone())
1976                    .collect::<Vec<_>>()
1977                    .into(),
1978                estimated_bytes,
1979            })
1980        } else {
1981            Ok(Self::Disabled { estimated_bytes })
1982        }
1983    }
1984
1985    pub(crate) fn is_available(&self) -> bool {
1986        !matches!(self, Self::Disabled { .. })
1987    }
1988
1989    pub(crate) fn apply_row(
1990        &self,
1991        row: usize,
1992        delta_beta: ArrayView1<'_, f64>,
1993        out: &mut Array1<f64>,
1994    ) -> bool {
1995        match self {
1996            Self::Dense { blocks, .. } => {
1997                let Some(block) = blocks.get(row) else {
1998                    return false;
1999                };
2000                if block.ncols() != delta_beta.len() || block.nrows() != out.len() {
2001                    return false;
2002                }
2003                for c in 0..block.nrows() {
2004                    let mut acc = 0.0_f64;
2005                    for a in 0..block.ncols() {
2006                        acc += block[[c, a]] * delta_beta[a];
2007                    }
2008                    out[c] = acc;
2009                }
2010                true
2011            }
2012            Self::Matvec { op, .. } => {
2013                op(row, delta_beta, out);
2014                true
2015            }
2016            Self::Disabled { .. } => false,
2017        }
2018    }
2019
2020    /// Apply the transpose: `out[a] += H_βt^(row)[a, c] · v[c]` for all `a`.
2021    ///
2022    /// `v` has length `d`; `out` has length `k`. Accumulates (does NOT zero
2023    /// `out` first) so callers can sum contributions across rows into a shared
2024    /// accumulator.  Returns `false` when the cache is `Disabled` and no
2025    /// `fallback_op` is provided.
2026    pub(crate) fn apply_row_transpose_accumulate(
2027        &self,
2028        row: usize,
2029        v: ArrayView1<'_, f64>,
2030        out: &mut Array1<f64>,
2031        d: usize,
2032        k: usize,
2033        fallback_op: Option<&RowHtbetaMatvec>,
2034    ) -> bool {
2035        match self {
2036            Self::Dense { blocks, .. } => {
2037                let Some(block) = blocks.get(row) else {
2038                    return false;
2039                };
2040                if block.nrows() != v.len() || block.ncols() != out.len() {
2041                    return false;
2042                }
2043                // H_βt^(i) · v: outer-loop c hoists v[c], inner-loop a is
2044                // contiguous in row-major (d, k) layout.
2045                for c in 0..block.nrows() {
2046                    let vc = v[c];
2047                    if vc == 0.0 {
2048                        continue;
2049                    }
2050                    for a in 0..block.ncols() {
2051                        out[a] += block[[c, a]] * vc;
2052                    }
2053                }
2054                true
2055            }
2056            Self::Matvec { transpose_op, .. } => {
2057                transpose_op(row, v, out);
2058                true
2059            }
2060            Self::Disabled { .. } => {
2061                // No cached block.  Use the caller-supplied fallback op if present.
2062                if let Some(op) = fallback_op {
2063                    htbeta_probe_transpose(row, op, v, out, d, k);
2064                    true
2065                } else {
2066                    false
2067                }
2068            }
2069        }
2070    }
2071}
2072
2073/// The authoritative conditioning branch selected for one raw row eigenvalue.
2074///
2075/// This is classifier output, not a state reconstructed from floating-point
2076/// values after the fact. In particular, `cond_evals[m] == 1` does not imply
2077/// unit deflation: an ordinary retained eigenvalue can itself equal one.
2078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2079pub enum RowSpectralConditioning {
2080    /// Preserve the raw positive eigenvalue.
2081    Raw,
2082    /// Preserve the direction, but raise its positive eigenvalue to the
2083    /// numerical factorization floor.
2084    FloorClamped,
2085    /// Remove the raw quotient direction and replace it with unit stiffness.
2086    UnitDeflated,
2087}
2088
2089/// RAW per-row spectral data of a spectrally-conditioned undamped evidence
2090/// `H_tt` block (see [`ArrowFactorCache::deflation_row_spectra`]).
2091///
2092/// `evecs` columns are the RAW symmetric eigenvectors `uₘ` of `H_tt`
2093/// (orthonormal; the deflated directions `vᵢ` are the subset whose eigenvalue
2094/// was pinned). `raw_evals[m]` is the RAW eigenvalue `λₘ` BEFORE the unit-pin /
2095/// floor-clamp. `cond_evals[m]` is the conditioned eigenvalue `λ̃ₘ` the factor
2096/// actually uses. `conditioning[m]` is the authoritative discrete decision:
2097/// raw retention, positive floor clamp, or unit deflation. Together they give
2098/// the Daleckii–Krein divided differences the outer-gradient deflation
2099/// correction needs without inferring branches from coincident numeric values.
2100#[derive(Debug, Clone)]
2101pub struct RowDeflationSpectrum {
2102    pub evecs: Array2<f64>,
2103    pub raw_evals: Array1<f64>,
2104    pub cond_evals: Array1<f64>,
2105    pub conditioning: Arc<[RowSpectralConditioning]>,
2106}
2107
2108/// The classification applied to one raw evidence β-Schur eigendirection.
2109/// Numeric equality between raw and conditioned curvature cannot identify the
2110/// branch: a raw eigenvalue may already equal one, and a clamp-basin price may
2111/// happen to equal its raw curvature.
2112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2113pub enum BetaSchurSpectralConditioning {
2114    Raw,
2115    UnitDeflated,
2116    ClampBasin,
2117}
2118
2119impl BetaSchurSpectralConditioning {
2120    #[must_use]
2121    pub fn is_unit_deflated(self) -> bool {
2122        matches!(self, Self::UnitDeflated)
2123    }
2124}
2125
2126/// Raw and conditioned eigenspectrum of an evidence β-Schur whose classifier
2127/// changed at least one direction. This includes quotient-null unit pins and
2128/// bounded-clamp basin prices: both branches must remain visible so the
2129/// derivative can differentiate the same spectral map as the value (#2515).
2130#[derive(Debug, Clone)]
2131pub struct BetaSchurConditioningSpectrum {
2132    pub evecs: Array2<f64>,
2133    pub raw_evals: Array1<f64>,
2134    pub cond_evals: Array1<f64>,
2135    pub conditioning: Arc<[BetaSchurSpectralConditioning]>,
2136}
2137
2138#[derive(Debug, Clone)]
2139pub struct ArrowFactorCache {
2140    /// Per-row lower-triangular Cholesky factors of `H_tt^(i) + ridge_t·I`.
2141    ///
2142    /// These are the *damped* factors used inside the Newton solve. The IFT
2143    /// predictor must NOT use them — see [`Self::htt_factors_undamped`].
2144    pub htt_factors: ArrowFactorSlab,
2145    /// Per-row lower-triangular Cholesky factors of the UNDAMPED
2146    /// `H_tt^(i)` (no `ridge_t` added).
2147    ///
2148    /// The IFT predictor formula
2149    /// `Δt_i = -(H_tt^(i))⁻¹ · (H_tβ^(i) Δβ + δg_t^(i))` is derived from
2150    /// `∂g_t/∂t = H_tt` at the stationary point, with no LM damping term.
2151    /// Reusing the damped factors would bias the predicted shift toward zero
2152    /// in proportion to `ridge_t`. We pay one extra `O(N d³)` Cholesky per
2153    /// Newton solve — the same complexity class as the Newton solve itself —
2154    /// to make the IFT exact.
2155    pub htt_factors_undamped: ArrowUndampedFactors,
2156    /// Lower-triangular Cholesky factor of the Schur complement when the
2157    /// selected BA mode formed/factored dense RCS. `None` for
2158    /// [`ArrowSolverMode::InexactPCG`], where Agarwal-style inexact LM avoids
2159    /// the dense `K × K` factor.
2160    pub schur_factor: Option<Array2<f64>>,
2161    /// True iff `schur_factor` is the reduced Schur complement built from the
2162    /// undamped evidence row factors (`H_tt`, no LM ridge) and `ridge_beta = 0`.
2163    ///
2164    /// A Newton solve may be damped while the cache still carries an undamped
2165    /// evidence Schur for logdet / selected-inverse consumers. When this is false,
2166    /// consumers must not combine `schur_factor` with [`Self::undamped_factor`]:
2167    /// that would mix two different bordered-arrow operators.
2168    pub schur_factor_is_undamped: bool,
2169    /// Authoritative original-coordinate spectrum and typed classification used
2170    /// when the undamped evidence β-Schur was conditioned. No consumer may
2171    /// reconstruct the branch from numeric eigenvalue equality.
2172    pub beta_schur_conditioning: Option<BetaSchurConditioningSpectrum>,
2173    /// Exact undamped joint-Hessian log-determinant produced by the dense
2174    /// factorization path. REML evidence consumes this directly so the Laplace
2175    /// normalizer cannot miss the log-det even when later cache consumers only
2176    /// need solves/traces.
2177    ///
2178    /// On the matrix-free large-`k` SAE evidence path this is set from the
2179    /// Stochastic Lanczos Quadrature reduced-Schur log-determinant (see
2180    /// [`Self::undamped_arrow_log_det_with_schur`] and
2181    /// [`crate::arrow_schur::slq_logdet`]) so no dense `k × k` Cholesky is ever
2182    /// formed; `arrow_log_det_from_cache` reads THIS field first, before any
2183    /// `schur_factor` diagonal fallback.
2184    pub joint_hessian_log_det: Option<f64>,
2185    /// BA mode used to create this cache.
2186    pub solver_mode: ArrowSolverMode,
2187    /// Ridge values used to build the cached factors (recorded so the
2188    /// warm-start predictor knows whether the cache is still valid for a
2189    /// requested ridge level).
2190    pub ridge_t: f64,
2191    pub ridge_beta: f64,
2192    /// Per-row cross-block access for `H_tβ^(i) x`.
2193    ///
2194    /// Large caches retain a row matvec callback or disable β-coupled IFT
2195    /// prediction instead of cloning every dense `d × K` slab.
2196    pub htbeta: ArrowHtbetaCache,
2197    /// Maximum per-row latent dim (upper bound; matches `sys.d` at creation).
2198    pub d: usize,
2199    /// Per-row latent dims: `row_dims[i]` is the active dim for row `i`.
2200    pub row_dims: Arc<[usize]>,
2201    /// Flat-buffer row offsets for `delta_t` / IFT output vectors.
2202    /// `row_offsets[i]` is the start of row `i`; `row_offsets[n]` is the
2203    /// total length.
2204    pub row_offsets: Arc<[usize]>,
2205    /// β dimensionality `K`.
2206    pub k: usize,
2207    /// Geometry tag for the row-local factors and cross-blocks.
2208    pub manifold_mode_fingerprint: u64,
2209    /// Row-system tag for the cached per-row factors, cross-blocks, and
2210    /// shared-block diagonal used to build the Schur factor.
2211    pub row_hessian_fingerprint: u64,
2212    /// PCG instrumentation from the solve that produced this cache.
2213    ///
2214    /// Zero-valued (default) when the selected mode did not use PCG
2215    /// (i.e. `Direct` or `SqrtBA`).
2216    pub pcg_diagnostics: ArrowPcgDiagnostics,
2217    /// Number of row-local gauge directions stiffened in an undamped evidence
2218    /// factorization.
2219    ///
2220    /// Each direction is stiffened at UNIT stiffness `kappa = 1.0`, so it
2221    /// contributes `log(1) = 0` to the row-block logdet through the returned
2222    /// Cholesky factor: the gauge orbit is a criterion null direction and adds
2223    /// nothing to the Laplace normalizer (the quotient pseudo-determinant
2224    /// convention, cf. `PenaltyPseudologdet`). Zero theta/rho dependence.
2225    pub gauge_deflated_directions: usize,
2226    /// Per-row unit-norm directions `vᵢ` (in each row's `d`-dim latent block
2227    /// coordinates) that an undamped evidence factorization stiffened to UNIT
2228    /// stiffness `λ̃ = 1` (gauge or spectral deflation). Indexed by row; empty
2229    /// for every PD row factored without deflation, and empty overall on the
2230    /// non-deflating solver paths (streaming / cross-row-penalty CG / device).
2231    ///
2232    /// A deflated direction contributes `log(1) = 0` to the row-block log-det
2233    /// and is ρ/θ-INDEPENDENT, so its true contribution to `∂log|H|/∂ρ` is `0`.
2234    /// The analytic outer-gradient traces (`assignment_log_strength_hessian_trace`
2235    /// and `logdet_theta_adjoint`) contract
2236    /// `∂H_raw/∂ρ` (the RAW, pre-deflation block derivative) against the DEFLATED
2237    /// inverse, which assigns `1/λ̃ = 1` to each `vᵢ` and therefore spuriously
2238    /// adds `½ vᵢᵀ (∂H_raw/∂ρ) vᵢ`. Those traces subtract this per-row term
2239    /// (kept-subspace restriction) using these directions; without them the
2240    /// REML outer ρ-gradient is biased by `+Σ_deflated ½ vᵢᵀ ∂H_raw/∂ρ vᵢ`.
2241    pub deflated_row_directions: Arc<[Vec<Array1<f64>>]>,
2242    /// Per-row RAW spectral decomposition of an undamped evidence `H_tt` block
2243    /// that underwent SPECTRAL deflation, surfaced so the outer ρ/θ-gradient
2244    /// traces can apply the EXACT deflation-map (Daleckii–Krein) derivative
2245    /// correction, not just the within-row kept-subspace term.
2246    ///
2247    /// The criterion VALUE re-deflates `H_tt` at every ρ, so its gradient is
2248    /// `tr(H_deflated⁻¹ DΦ[∂H_raw/∂ρ])`, where `Φ` is the spectral pin-to-unit
2249    /// map. By Daleckii–Krein `DΦ[Ȧ] = U (F ∘ UᵀȦU) Uᵀ` with the divided-
2250    /// difference matrix `F_{ml} = (λ̃ₘ − λ̃ₗ)/(λₘ − λₗ)` (raw `λ` in the
2251    /// denominator, conditioned `λ̃` in the numerator). The kept×kept block of
2252    /// `F` is `1` (the kept subspace contracts the raw derivative unchanged), the
2253    /// deflated×deflated block is `0`, and the kept(m)×deflated(i) block is
2254    /// `(λₘ − 1)/(λₘ − λᵢ)` — this last, ROTATION, term is what the per-row
2255    /// kept-subspace correction alone misses; it couples to the β-block through
2256    /// the Schur back-substitution carried in `(H⁻¹)_tt`.
2257    ///
2258    /// `Some(spectrum)` only for spectrally-deflated rows; `None` for PD rows,
2259    /// gauge-only deflation (ρ-independent structural null — within-row term
2260    /// suffices), and every non-SAE-evidence solver path (streaming / device /
2261    /// cross-row CG). Empty overall when no row deflated spectrally.
2262    pub deflation_row_spectra: Arc<[Option<RowDeflationSpectrum>]>,
2263    /// Shared-border scale gauge used by the evidence factor.
2264    ///
2265    /// When present, `schur_factor` factors `P S P + Q Q^T`, and every public
2266    /// inverse primitive projects both its border RHS and result with `P`.  The
2267    /// unit-pinned orbit contributes zero to `arrow_log_det` and zero to every
2268    /// analytic trace, so value and gradient live on the same quotient.
2269    pub beta_gauge_quotient: Option<ArrowBetaGaugeQuotient>,
2270}
2271
2272#[derive(Debug, Clone, Copy, PartialEq)]
2273pub struct ArrowFactorMinPivot {
2274    pub min_row_pivot: Option<f64>,
2275    pub min_schur_pivot: Option<f64>,
2276    pub min_pivot: Option<f64>,
2277}
2278
2279impl ArrowFactorMinPivot {
2280    pub(crate) fn combine(row: Option<f64>, schur: Option<f64>) -> Self {
2281        let min_pivot = match (row, schur) {
2282            (Some(a), Some(b)) => Some(a.min(b)),
2283            (Some(a), None) => Some(a),
2284            (None, Some(b)) => Some(b),
2285            (None, None) => None,
2286        };
2287        Self {
2288            min_row_pivot: row,
2289            min_schur_pivot: schur,
2290            min_pivot,
2291        }
2292    }
2293}
2294
2295pub(crate) fn lower_cholesky_min_pivot(factor: ArrayView2<'_, f64>) -> Option<f64> {
2296    let width = factor.nrows().min(factor.ncols());
2297    let mut out = None;
2298    for idx in 0..width {
2299        let pivot = factor[[idx, idx]] * factor[[idx, idx]];
2300        out = Some(match out {
2301            Some(current) => f64::min(current, pivot),
2302            None => pivot,
2303        });
2304    }
2305    out
2306}
2307
2308pub(crate) fn lower_cholesky_max_pivot(factor: ArrayView2<'_, f64>) -> Option<f64> {
2309    let width = factor.nrows().min(factor.ncols());
2310    let mut out = None;
2311    for idx in 0..width {
2312        let pivot = factor[[idx, idx]] * factor[[idx, idx]];
2313        out = Some(match out {
2314            Some(current) => f64::max(current, pivot),
2315            None => pivot,
2316        });
2317    }
2318    out
2319}
2320
2321/// Smallest cached Cholesky pivot for row blocks and the dense Schur factor.
2322///
2323/// Pivots are returned as squared lower-factor diagonals, matching the Hessian
2324/// scale rather than the Cholesky-factor scale. In inexact PCG mode the dense
2325/// Schur factor is absent, so `min_schur_pivot` is `None`.
2326pub fn arrow_factor_min_pivot(cache: &ArrowFactorCache) -> ArrowFactorMinPivot {
2327    let mut min_row_pivot = None;
2328    for factor in cache.htt_factors.iter() {
2329        if let Some(pivot) = lower_cholesky_min_pivot(factor) {
2330            min_row_pivot = Some(match min_row_pivot {
2331                Some(current) => f64::min(current, pivot),
2332                None => pivot,
2333            });
2334        }
2335    }
2336    let min_schur_pivot = cache
2337        .schur_factor
2338        .as_ref()
2339        .and_then(|factor| lower_cholesky_min_pivot(factor.view()));
2340    ArrowFactorMinPivot::combine(min_row_pivot, min_schur_pivot)
2341}
2342
2343/// Largest cached Cholesky pivot across the row blocks and the dense Schur
2344/// factor (Hessian scale, i.e. squared lower-factor diagonal). This is the
2345/// diagonal magnitude scale a safe-SPD pivot floor is measured against: the
2346/// curvature-homotopy tracker (#1007) compares the min pivot against
2347/// `√eps · max(this, 1)`, the same floor the inner solver's
2348/// `safe_spd_pivot_min` uses. `None` only for an empty cache.
2349pub fn arrow_factor_max_pivot(cache: &ArrowFactorCache) -> Option<f64> {
2350    let mut max_pivot: Option<f64> = None;
2351    for factor in cache.htt_factors.iter() {
2352        if let Some(pivot) = lower_cholesky_max_pivot(factor) {
2353            max_pivot = Some(match max_pivot {
2354                Some(current) => f64::max(current, pivot),
2355                None => pivot,
2356            });
2357        }
2358    }
2359    if let Some(factor) = cache.schur_factor.as_ref()
2360        && let Some(pivot) = lower_cholesky_max_pivot(factor.view())
2361    {
2362        max_pivot = Some(match max_pivot {
2363            Some(current) => f64::max(current, pivot),
2364            None => pivot,
2365        });
2366    }
2367    max_pivot
2368}
2369
2370/// Spectral pseudo-inverse of the cached β-Schur operator `M = L Lᵀ`, deflated
2371/// across the numerically-null curvature directions using the solver's
2372/// canonical rank floor ([`SPECTRAL_DEFLATION_REL_FLOOR`]).
2373///
2374/// `evecs` are the orthonormal eigenvectors of `M` (columns), `inv_evals[i]`
2375/// is `1/λᵢ` for a kept direction and exactly `0.0` for a deflated one, so
2376/// `M⁺ = evecs · diag(inv_evals) · evecsᵀ` is the Moore–Penrose pseudo-inverse
2377/// restricted to the kept subspace. Away from the ρ lower face every eigenvalue
2378/// sits far above the floor, no direction deflates, and `M⁺` equals `M⁻¹` to
2379/// round-off — so the deflated selected inverse reduces to the plain one.
2380struct DeflatedSchurPseudoInverse {
2381    evecs: Array2<f64>,
2382    inv_evals: Array1<f64>,
2383}
2384
2385impl ArrowFactorCache {
2386    pub fn n_rows(&self) -> usize {
2387        self.htt_factors.len()
2388    }
2389
2390    pub fn htbeta_available(&self) -> bool {
2391        self.htbeta.is_available()
2392    }
2393
2394    pub fn undamped_factor(&self, row: usize) -> ArrayView2<'_, f64> {
2395        match &self.htt_factors_undamped {
2396            ArrowUndampedFactors::SameAsDamped => self.htt_factors.factor(row),
2397            ArrowUndampedFactors::Owned(factors) => factors.factor(row),
2398        }
2399    }
2400
2401    pub fn undamped_factor_count(&self) -> usize {
2402        match &self.htt_factors_undamped {
2403            ArrowUndampedFactors::SameAsDamped => self.htt_factors.len(),
2404            ArrowUndampedFactors::Owned(factors) => factors.len(),
2405        }
2406    }
2407
2408    pub fn undamped_factors_iter(&self) -> impl Iterator<Item = ArrayView2<'_, f64>> + '_ {
2409        (0..self.undamped_factor_count()).map(|row| self.undamped_factor(row))
2410    }
2411
2412    pub fn compute_undamped_arrow_log_det(&self) -> Option<f64> {
2413        // When the shared β block is empty (`k == 0`) the joint Hessian is
2414        // exactly the block diagonal of the per-row latent blocks: there is no
2415        // reduced Schur complement to form, so the dense Direct path leaves
2416        // `schur_factor = None` legitimately (not the InexactPCG "never formed
2417        // the dense K×K factor" case, which has `k > 0`). The log-det is then
2418        // the per-row sum with a zero (empty `0×0`) Schur contribution. Without
2419        // this the `schur_factor.as_ref()?` below would return `None` for a
2420        // β-profiled atom (#1132 euclidean K=4) and starve the REML Laplace
2421        // normaliser of the joint Hessian log-det it requires.
2422        let schur = match self.schur_factor.as_ref() {
2423            Some(schur) => Some(schur),
2424            None if self.k == 0 => None,
2425            None => return None,
2426        };
2427        if schur.is_some() && !self.schur_factor_is_undamped {
2428            return None;
2429        }
2430
2431        let mut acc = 0.0_f64;
2432        for l in self.undamped_factors_iter() {
2433            for i in 0..l.nrows() {
2434                let d = l[[i, i]];
2435                if d <= 0.0 || !d.is_finite() {
2436                    return None;
2437                }
2438                acc += 2.0 * d.ln();
2439            }
2440        }
2441        if let Some(schur) = schur {
2442            for i in 0..schur.nrows() {
2443                let d = schur[[i, i]];
2444                if d <= 0.0 || !d.is_finite() {
2445                    return None;
2446                }
2447                acc += 2.0 * d.ln();
2448            }
2449        }
2450        Some(acc)
2451    }
2452
2453    /// Undamped joint log-determinant `log|H| = Σ_i log|H_tt^(i)| + log|S|`
2454    /// using an EXTERNALLY-supplied reduced-Schur term
2455    /// `schur_log_det = log|S|` instead of a dense `schur_factor` diagonal sum.
2456    ///
2457    /// This is the matrix-free large-`k` SAE evidence path: the reduced Schur is
2458    /// never Cholesky-factored, so `schur_factor` is `None` and `log|S|` comes
2459    /// from Stochastic Lanczos Quadrature ([`crate::arrow_schur::slq_logdet`]).
2460    /// The per-row latent-block term is computed exactly as in
2461    /// [`Self::compute_undamped_arrow_log_det`], with the same ridge,
2462    /// positivity, and finiteness guards.
2463    pub fn undamped_arrow_log_det_with_schur(&self, schur_log_det: f64) -> Option<f64> {
2464        if self.ridge_t != 0.0 || self.ridge_beta != 0.0 {
2465            return None;
2466        }
2467        if !schur_log_det.is_finite() {
2468            return None;
2469        }
2470        let mut acc = 0.0_f64;
2471        for l in self.undamped_factors_iter() {
2472            for i in 0..l.nrows() {
2473                let d = l[[i, i]];
2474                if d <= 0.0 || !d.is_finite() {
2475                    return None;
2476                }
2477                acc += 2.0 * d.ln();
2478            }
2479        }
2480        acc += schur_log_det;
2481        Some(acc)
2482    }
2483
2484    /// The total length of `delta_t` / IFT output vectors for this cache.
2485    pub fn delta_t_len(&self) -> usize {
2486        self.row_offsets[self.n_rows()]
2487    }
2488
2489    pub fn apply_htbeta_row(
2490        &self,
2491        row: usize,
2492        delta_beta: ArrayView1<'_, f64>,
2493        out: &mut Array1<f64>,
2494    ) -> bool {
2495        let di = if row < self.row_dims.len() {
2496            self.row_dims[row]
2497        } else {
2498            self.d
2499        };
2500        if out.len() != di || delta_beta.len() != self.k {
2501            return false;
2502        }
2503        self.htbeta.apply_row(row, delta_beta, out)
2504    }
2505
2506    /// Accumulate `out[a] += H_βt^(row)[a, :] · v` for all `a in 0..k`.
2507    ///
2508    /// `v` has length `row_dims[row]`; `out` has length `k`. The caller must
2509    /// zero `out` before the first call if it needs a fresh result.  Returns
2510    /// `false` when the cache is `Disabled` and no `fallback_op` is provided;
2511    /// callers must treat the accumulator as invalid in that case.
2512    pub fn apply_htbeta_row_transpose(
2513        &self,
2514        row: usize,
2515        v: ArrayView1<'_, f64>,
2516        out: &mut Array1<f64>,
2517        fallback_op: Option<&RowHtbetaMatvec>,
2518    ) -> bool {
2519        let di = if row < self.row_dims.len() {
2520            self.row_dims[row]
2521        } else {
2522            self.d
2523        };
2524        if v.len() != di || out.len() != self.k {
2525            return false;
2526        }
2527        self.htbeta
2528            .apply_row_transpose_accumulate(row, v, out, di, self.k, fallback_op)
2529    }
2530
2531    /// Authoritative evidence joint log-determinant for the exact operator this
2532    /// cache exposes to selected-inverse and adjoint consumers.
2533    ///
2534    /// The factorization path computes this once into
2535    /// [`Self::joint_hessian_log_det`]. This accessor deliberately does not
2536    /// reconstruct `Σ_i log|H_tt^(i)| + log|Schur_β|` from loose pieces: a damped
2537    /// Newton cache can otherwise pair undamped row factors with a damped Schur
2538    /// solve and silently describe no live operator. Returning only the stored
2539    /// joint value keeps REML evidence, fixed-state tests, selected inverse, and
2540    /// `logdet_theta_adjoint` on the same factorization branch.
2541    pub fn arrow_log_det(&self) -> Option<f64> {
2542        if self.k > 0 && !self.schur_factor_is_undamped {
2543            return None;
2544        }
2545        self.joint_hessian_log_det
2546            .filter(|log_det| log_det.is_finite())
2547    }
2548
2549    /// Diagonal of the latent (`t`-block) of the *full* bordered-arrow
2550    /// inverse `(H⁻¹)_tt`, in `delta_t` layout (length [`Self::delta_t_len`]).
2551    ///
2552    /// For the bordered arrow Hessian
2553    /// `H = [[A, B], [Bᵀ, H_ββ]]` with `A = H_tt` (block-diagonal per row,
2554    /// `A_i = H_tt^(i)`) and `B = H_tβ`, the standard block-inverse identity
2555    /// gives the `t`-block
2556    /// `(H⁻¹)_tt = A⁻¹ + A⁻¹ B S⁻¹ Bᵀ A⁻¹`, where
2557    /// `S = H_ββ − Bᵀ A⁻¹ B` is the Schur complement on `β`. Because `A` is
2558    /// block-diagonal, the `(i, j)` diagonal entry of `(H⁻¹)_tt` is computed
2559    /// purely from row `i`'s factor and cross-block:
2560    ///
2561    /// ```text
2562    /// a    = A_i⁻¹ e_j                       (chol_solve on the per-row factor)
2563    /// [A_i⁻¹]_{jj} = a[j]
2564    /// w    = B_iᵀ a = H_βt^(i) a             (a K-vector)
2565    /// z    = S⁻¹ w                           (chol_solve on the Schur factor)
2566    /// diag = a[j] + w · z
2567    /// ```
2568    ///
2569    /// The UNDAMPED per-row factors ([`Self::undamped_factor`]) are used so
2570    /// the result is the inverse of the *true* `H_tt`, not the LM-damped
2571    /// `H_tt + ridge_t·I` — same rationale the IFT predictor docstring gives
2572    /// at the top of this struct.
2573    ///
2574    /// # Consuming the diagonal as a per-(atom, axis) trace
2575    ///
2576    /// `(H⁻¹)_tt` is the latent covariance block. The selected-inverse trace
2577    /// for a contiguous group of latent coordinates (e.g. one atom's rows, or
2578    /// one axis across rows) is simply the sum of the returned diagonal entries
2579    /// over those `row_offsets[i] + j` indices — no off-diagonal terms are
2580    /// needed for the trace `tr[(H⁻¹)_tt · D]` against a diagonal selector `D`.
2581    ///
2582    /// # Errors
2583    ///
2584    /// Returns `ArrowSchurError::SchurFactorFailed` when this cache has no
2585    /// dense Schur factor or no usable `H_βt` coupling — i.e. it was produced
2586    /// by an [`ArrowSolverMode::InexactPCG`] solve (no dense `K × K` factor) or
2587    /// by a `Disabled` `htbeta` cache. The selected-inverse block-trace is not
2588    /// yet supported for the matrix-free PCG mode; that branch needs a separate
2589    /// Lanczos/Hutchinson estimator.
2590    pub fn latent_block_inverse_diagonal(&self) -> Result<Array1<f64>, ArrowSchurError> {
2591        let Some(_schur_factor) = self.schur_factor.as_ref() else {
2592            return Err(ArrowSchurError::SchurFactorFailed {
2593                reason: "latent_block_inverse_diagonal requires a dense Schur factor; \
2594                         the InexactPCG mode does not form one"
2595                    .to_string(),
2596            });
2597        };
2598        if !self.schur_factor_is_undamped {
2599            return Err(ArrowSchurError::SchurFactorFailed {
2600                reason: "latent_block_inverse_diagonal refuses a Schur factor that was not \
2601                         built from the undamped evidence row factors"
2602                    .to_string(),
2603            });
2604        }
2605        if !self.htbeta_available() {
2606            return Err(ArrowSchurError::SchurFactorFailed {
2607                reason: "latent_block_inverse_diagonal requires the H_tβ coupling, \
2608                         but this cache's htbeta is Disabled"
2609                    .to_string(),
2610            });
2611        }
2612        let n = self.undamped_factor_count();
2613        let total_len = self.delta_t_len();
2614        let mut out = Array1::<f64>::zeros(total_len);
2615        // Per-row scratch, sized to the max latent dim / K.
2616        let mut e_j = Array1::<f64>::zeros(self.d);
2617        let mut w = Array1::<f64>::zeros(self.k);
2618        for i in 0..n {
2619            let di = self.row_dims[i];
2620            let row_base = self.row_offsets[i];
2621            let factor = self.undamped_factor(i);
2622            for j in 0..di {
2623                // a = A_i⁻¹ e_j.
2624                for c in 0..di {
2625                    e_j[c] = 0.0;
2626                }
2627                e_j[j] = 1.0;
2628                let e_j_slice = e_j.slice(ndarray::s![..di]).to_owned();
2629                let a = cholesky_solve_vector(factor, &e_j_slice);
2630                // w = H_βt^(i) a (a K-vector); accumulator must start zeroed.
2631                w.fill(0.0);
2632                if !self.apply_htbeta_row_transpose(i, a.view(), &mut w, None) {
2633                    return Err(ArrowSchurError::SchurFactorFailed {
2634                        reason: format!(
2635                            "latent_block_inverse_diagonal: H_βt^({i}) apply failed \
2636                             (htbeta cache could not supply row {i})"
2637                        ),
2638                    });
2639                }
2640                // z = S⁻¹ w; correction = w · z.
2641                let z = self.schur_inverse_apply(w.view())?;
2642                let mut corr = 0.0_f64;
2643                for c in 0..self.k {
2644                    corr += w[c] * z[c];
2645                }
2646                out[row_base + j] = a[j] + corr;
2647            }
2648        }
2649        Ok(out)
2650    }
2651
2652    /// Solve the full bordered-arrow system `H·u = w` on the cached factor
2653    /// (#1006): `w` arrives in arrow layout — `w_t` flat per
2654    /// [`Self::delta_t_len`] / `row_offsets`, `w_beta` of length `K` — and the
2655    /// solution comes back in the same layout. Standard block elimination on
2656    /// the SAME factors whose log-determinant the evidence reports:
2657    ///
2658    /// ```text
2659    ///   y_i      = H_tt^(i)⁻¹ · w_t^(i)
2660    ///   r_β      = w_β − Σ_i H_βt^(i) · y_i
2661    ///   u_β      = Schur⁻¹ · r_β
2662    ///   u_t^(i)  = y_i − H_tt^(i)⁻¹ · (H_tβ^(i) · u_β)
2663    /// ```
2664    ///
2665    /// This is the IFT / adjoint back-solve the analytic outer ρ-gradient
2666    /// consumes: `u_j = H⁻¹ (∂g/∂ρ_j)` per outer coordinate and the
2667    /// `H⁻¹`-side of the third-order correction `−½·Γᵀ·H⁻¹·(∂g/∂ρ_j)`.
2668    /// Contract: the cache must be the ridge-0 Direct evidence factor
2669    /// (undamped per-row factors + dense Schur), so the solve is against the
2670    /// criterion's own `H` — never a damped surrogate (that would desync the
2671    /// gradient from the reported evidence).
2672    ///
2673    pub fn full_inverse_apply(
2674        &self,
2675        w_t: ArrayView1<'_, f64>,
2676        w_beta: ArrayView1<'_, f64>,
2677    ) -> Result<(Array1<f64>, Array1<f64>), ArrowSchurError> {
2678        let total_len = self.delta_t_len();
2679        if w_t.len() != total_len || w_beta.len() != self.k {
2680            return Err(ArrowSchurError::SchurFactorFailed {
2681                reason: format!(
2682                    "full_inverse_apply: rhs shapes (w_t={}, w_beta={}) != (delta_t_len={}, K={})",
2683                    w_t.len(),
2684                    w_beta.len(),
2685                    total_len,
2686                    self.k
2687                ),
2688            });
2689        }
2690        let n = self.undamped_factor_count();
2691        // Forward pass: y_i = H_tt^(i)⁻¹ w_t^(i), accumulating the border RHS.
2692        let mut y = Array1::<f64>::zeros(total_len);
2693        let mut r_beta = w_beta.to_owned();
2694        for i in 0..n {
2695            let di = self.row_dims[i];
2696            let base = self.row_offsets[i];
2697            let factor = self.undamped_factor(i);
2698            let w_row = w_t.slice(ndarray::s![base..base + di]).to_owned();
2699            let y_row = cholesky_solve_vector(factor, &w_row);
2700            if self.k > 0 {
2701                // r_β −= H_βt^(i) y_i: accumulate into a scratch then subtract,
2702                // because the helper ACCUMULATES (+=) into its output.
2703                let mut acc = Array1::<f64>::zeros(self.k);
2704                if !self.apply_htbeta_row_transpose(i, y_row.view(), &mut acc, None) {
2705                    return Err(ArrowSchurError::SchurFactorFailed {
2706                        reason: format!(
2707                            "full_inverse_apply: H_βt^({i}) apply failed (htbeta cache \
2708                             could not supply row {i}; htbeta={:?}, di={}, k={})",
2709                            self.htbeta,
2710                            self.row_dims.get(i).copied().unwrap_or(self.d),
2711                            self.k
2712                        ),
2713                    });
2714                }
2715                for c in 0..self.k {
2716                    r_beta[c] -= acc[c];
2717                }
2718            }
2719            for j in 0..di {
2720                y[base + j] = y_row[j];
2721            }
2722        }
2723        // Border solve + back-substitution.
2724        let u_beta = if self.k > 0 {
2725            self.schur_inverse_apply(r_beta.view())?
2726        } else {
2727            Array1::<f64>::zeros(0)
2728        };
2729        let mut u_t = y;
2730        if self.k > 0 {
2731            let mut cross = Array1::<f64>::zeros(self.d);
2732            for i in 0..n {
2733                let di = self.row_dims[i];
2734                let base = self.row_offsets[i];
2735                let mut cross_row = cross.slice_mut(ndarray::s![..di]);
2736                cross_row.fill(0.0);
2737                let mut cross_owned = cross_row.to_owned();
2738                if !self.apply_htbeta_row(i, u_beta.view(), &mut cross_owned) {
2739                    return Err(ArrowSchurError::SchurFactorFailed {
2740                        reason: format!(
2741                            "full_inverse_apply: H_tβ^({i}) apply failed (htbeta cache \
2742                             could not supply row {i})"
2743                        ),
2744                    });
2745                }
2746                let factor = self.undamped_factor(i);
2747                let corr = cholesky_solve_vector(factor, &cross_owned);
2748                for j in 0..di {
2749                    u_t[base + j] -= corr[j];
2750                }
2751            }
2752        }
2753        Ok((u_t, u_beta))
2754    }
2755
2756    /// Apply the β-block of the full inverse, `(H⁻¹)_ββ · rhs = S_β⁻¹ · rhs`,
2757    /// where `S_β` is the Schur complement on β whose Cholesky factor this
2758    /// cache holds in [`Self::schur_factor`].
2759    ///
2760    /// For the bordered arrow Hessian `H = [[A, B], [Bᵀ, H_ββ]]`, the
2761    /// β-block of `H⁻¹` is exactly the inverse of the Schur complement
2762    /// `S_β = H_ββ − Bᵀ A⁻¹ B`. One Cholesky back-substitution per call,
2763    /// reusing the cached factor; `rhs` and the returned vector both have
2764    /// length `K`.
2765    ///
2766    /// This is the general single-solve primitive for the β border. Callers
2767    /// that need a Schur-inverse trace `tr(S_β⁻¹ M)` against a structured
2768    /// penalty `M` (e.g. the SAE λ_smooth Fellner-Schall step, where
2769    /// `M = blockdiag_k(λ_k S_k ⊗ I_p)`) build it as
2770    /// `Σ_col e_colᵀ S_β⁻¹ M e_col` — apply this to each column of `M`
2771    /// (exploiting whatever sparsity `M` has) and read off `result[col]`.
2772    /// Keeping `M`'s layout on the caller side avoids coupling this solver
2773    /// to penalty-op types.
2774    ///
2775    /// # Errors
2776    ///
2777    /// Returns `ArrowSchurError::SchurFactorFailed` when this cache has no
2778    /// dense Schur factor (an [`ArrowSolverMode::InexactPCG`] solve) — the
2779    /// same not-yet-supported branch as [`Self::latent_block_inverse_diagonal`]
2780    /// — or when `rhs.len() != k`.
2781    ///
2782    pub fn schur_inverse_apply(
2783        &self,
2784        rhs: ArrayView1<'_, f64>,
2785    ) -> Result<Array1<f64>, ArrowSchurError> {
2786        let Some(schur_factor) = self.schur_factor.as_ref() else {
2787            return Err(ArrowSchurError::SchurFactorFailed {
2788                reason: "schur_inverse_apply requires a dense Schur factor; \
2789                         the InexactPCG mode does not form one"
2790                    .to_string(),
2791            });
2792        };
2793        if !self.schur_factor_is_undamped {
2794            return Err(ArrowSchurError::SchurFactorFailed {
2795                reason: "schur_inverse_apply refuses a Schur factor that was not built from \
2796                         the undamped evidence row factors"
2797                    .to_string(),
2798            });
2799        }
2800        if rhs.len() != self.k {
2801            return Err(ArrowSchurError::SchurFactorFailed {
2802                reason: format!(
2803                    "schur_inverse_apply: rhs length {} != K {}",
2804                    rhs.len(),
2805                    self.k
2806                ),
2807            });
2808        }
2809        if self.beta_schur_conditioning.is_some() {
2810            let deflated = self.deflated_schur_pseudo_inverse()?;
2811            return Ok(self.apply_deflated_pseudo_inverse(&deflated, rhs));
2812        }
2813        let rhs_owned = match self.beta_gauge_quotient.as_ref() {
2814            Some(quotient) => quotient.project_complement(rhs),
2815            None => rhs.to_owned(),
2816        };
2817        let solved = cholesky_solve_vector(schur_factor, &rhs_owned);
2818        Ok(match self.beta_gauge_quotient.as_ref() {
2819            Some(quotient) => quotient.project_complement(solved.view()),
2820            None => solved,
2821        })
2822    }
2823
2824    /// Dense principal sub-block of the β-block of the full inverse,
2825    /// `(H⁻¹)_ββ[block, block] = S_β⁻¹[block, block]`, shape `(W, W)` with
2826    /// `W = block.len()`.
2827    ///
2828    /// For the bordered arrow Hessian `H = [[A, B], [Bᵀ, H_ββ]]`, the β-block
2829    /// of `H⁻¹` is exactly `S_β⁻¹` (the inverse of the Schur complement whose
2830    /// Cholesky factor this cache holds). This returns the contiguous
2831    /// `block × block` sub-block — e.g. one SAE atom's decoder coefficients via
2832    /// `gam_terms::sae::manifold::SaeManifoldTerm::beta_block_offsets` — by
2833    /// solving `S_β x = e_j` for each `j ∈ block` (reusing the cached factor)
2834    /// and gathering the `block` rows of each solution column. `W`
2835    /// back-substitutions of size `K`; the result is symmetrized to clear
2836    /// back-substitution rounding asymmetry. Up to a dispersion scale `φ`, this
2837    /// block is the joint posterior covariance `Cov(β_block)` of those
2838    /// coefficients with the latent coordinates already marginalized out (that
2839    /// is precisely what Schur-eliminating the per-row `t`-blocks does).
2840    ///
2841    /// Same dense-Schur requirement / error contract as
2842    /// [`Self::schur_inverse_apply`]; additionally errors when `block` runs past
2843    /// `K`.
2844    pub fn schur_inverse_block(
2845        &self,
2846        block: std::ops::Range<usize>,
2847    ) -> Result<Array2<f64>, ArrowSchurError> {
2848        let Some(_schur_factor) = self.schur_factor.as_ref() else {
2849            return Err(ArrowSchurError::SchurFactorFailed {
2850                reason: "schur_inverse_block requires a dense Schur factor; \
2851                         the InexactPCG mode does not form one"
2852                    .to_string(),
2853            });
2854        };
2855        if !self.schur_factor_is_undamped {
2856            return Err(ArrowSchurError::SchurFactorFailed {
2857                reason: "schur_inverse_block refuses a Schur factor that was not built from \
2858                         the undamped evidence row factors"
2859                    .to_string(),
2860            });
2861        }
2862        if block.end > self.k {
2863            return Err(ArrowSchurError::SchurFactorFailed {
2864                reason: format!(
2865                    "schur_inverse_block: block end {} exceeds K {}",
2866                    block.end, self.k
2867                ),
2868            });
2869        }
2870        let w = block.len();
2871        let mut out = Array2::<f64>::zeros((w, w));
2872        let mut e_j = Array1::<f64>::zeros(self.k);
2873        for (jc, j) in block.clone().enumerate() {
2874            e_j.fill(0.0);
2875            e_j[j] = 1.0;
2876            let col = self.schur_inverse_apply(e_j.view())?;
2877            for (ic, i) in block.clone().enumerate() {
2878                out[[ic, jc]] = col[i];
2879            }
2880        }
2881        // S_β⁻¹ is symmetric; symmetrize to clear back-substitution rounding.
2882        for ic in 0..w {
2883            for jc in (ic + 1)..w {
2884                let avg = 0.5 * (out[[ic, jc]] + out[[jc, ic]]);
2885                out[[ic, jc]] = avg;
2886                out[[jc, ic]] = avg;
2887            }
2888        }
2889        Ok(out)
2890    }
2891
2892    /// Precompute the deflated spectral pseudo-inverse ONCE and return a
2893    /// reusable applier — the many-RHS form of
2894    /// `Self::schur_inverse_apply_deflated`. The EDF trace contracts
2895    /// `(H⁻¹)_ββ` against one `λS⊗I` column per basis coefficient (`Σ_k M_k·r_k`
2896    /// columns total); recomputing the `O(K³)` eigendecomposition per column
2897    /// would multiply that cost by the border width for no reason. Each apply
2898    /// through the returned closure is `O(K²)` (two dense mat-vecs through the
2899    /// eigenbasis), identical in complexity to the plain
2900    /// [`Self::schur_inverse_apply`] back-substitution it replaces.
2901    ///
2902    /// Same deflation semantics, contract, and errors as
2903    /// `Self::schur_inverse_apply_deflated`; the closure itself is
2904    /// infallible (rhs length is the caller's loop invariant — a wrong length
2905    /// panics in the underlying gemv shape check rather than dividing by a
2906    /// null pivot).
2907    pub fn schur_deflated_applier(
2908        &self,
2909    ) -> Result<impl Fn(ArrayView1<'_, f64>) -> Array1<f64> + '_, ArrowSchurError> {
2910        let deflated = self.deflated_schur_pseudo_inverse()?;
2911        Ok(move |rhs: ArrayView1<'_, f64>| self.apply_deflated_pseudo_inverse(&deflated, rhs))
2912    }
2913
2914    /// Reconstruct the SPD operator `M = L Lᵀ` this cache inverts (the plain
2915    /// [`Self::schur_inverse_apply`] solves `M x = rhs`; when a β-gauge quotient
2916    /// is installed `M = P S P + Q Qᵀ`), symmetric-eigendecompose it, and deflate
2917    /// every eigen-direction at or below the canonical rank floor
2918    /// `SPECTRAL_DEFLATION_REL_FLOOR · max|λ|` (with the same hysteresis band the
2919    /// per-row spectral deflation uses, so a direction parked at the floor does
2920    /// not flicker across a ρ-walk).
2921    fn deflated_schur_pseudo_inverse(&self) -> Result<DeflatedSchurPseudoInverse, ArrowSchurError> {
2922        let Some(schur_factor) = self.schur_factor.as_ref() else {
2923            return Err(ArrowSchurError::SchurFactorFailed {
2924                reason: "schur_inverse_apply_deflated requires a dense Schur factor; \
2925                         the InexactPCG mode does not form one"
2926                    .to_string(),
2927            });
2928        };
2929        if !self.schur_factor_is_undamped {
2930            return Err(ArrowSchurError::SchurFactorFailed {
2931                reason: "schur_inverse_apply_deflated refuses a Schur factor that was not built \
2932                         from the undamped evidence row factors"
2933                    .to_string(),
2934            });
2935        }
2936        let k = self.k;
2937        if let Some(spectrum) = self.beta_schur_conditioning.as_ref() {
2938            if spectrum.evecs.dim() != (k, k)
2939                || spectrum.raw_evals.len() != k
2940                || spectrum.cond_evals.len() != k
2941                || spectrum.conditioning.len() != k
2942            {
2943                return Err(ArrowSchurError::SchurFactorFailed {
2944                    reason: "cached β-Schur conditioning spectrum has incoherent dimensions"
2945                        .to_string(),
2946                });
2947            }
2948            let mut inv_evals = Array1::<f64>::zeros(k);
2949            for eig_idx in 0..k {
2950                if spectrum.conditioning[eig_idx].is_unit_deflated() {
2951                    continue;
2952                }
2953                let lambda = spectrum.cond_evals[eig_idx];
2954                if !(lambda.is_finite() && lambda > 0.0) {
2955                    return Err(ArrowSchurError::SchurFactorFailed {
2956                        reason: format!(
2957                            "cached β-Schur kept eigenvalue {eig_idx} is not positive finite: {lambda:e}"
2958                        ),
2959                    });
2960                }
2961                inv_evals[eig_idx] = 1.0 / lambda;
2962            }
2963            return Ok(DeflatedSchurPseudoInverse {
2964                evecs: spectrum.evecs.clone(),
2965                inv_evals,
2966            });
2967        }
2968        // Reconstruct `M = L Lᵀ` from the LOWER triangle only (the strict-upper
2969        // entries of the stored factor are not part of the Cholesky factor and
2970        // must not enter the product).
2971        let mut lower = Array2::<f64>::zeros((k, k));
2972        for i in 0..k {
2973            for j in 0..=i {
2974                lower[[i, j]] = schur_factor[[i, j]];
2975            }
2976        }
2977        let m = lower.dot(&lower.t());
2978        let (evals, evecs) =
2979            m.eigh(Side::Lower)
2980                .map_err(|err| ArrowSchurError::SchurFactorFailed {
2981                    reason: format!(
2982                        "schur_inverse_apply_deflated: symmetric eigendecomposition of the \
2983                     reconstructed β-Schur operator failed: {err:?}"
2984                    ),
2985                })?;
2986        let max_abs =
2987            evals.iter().fold(
2988                0.0_f64,
2989                |acc, &v| {
2990                    if v.is_finite() { acc.max(v.abs()) } else { acc }
2991                },
2992            );
2993        if !(max_abs.is_finite() && max_abs > 0.0) {
2994            return Err(ArrowSchurError::SchurFactorFailed {
2995                reason: "schur_inverse_apply_deflated: reconstructed β-Schur operator has no \
2996                         finite positive spectrum"
2997                    .to_string(),
2998            });
2999        }
3000        // No evidence deflation was performed, so every factor eigenvalue is
3001        // part of the value and must remain part of the inverse. Boundary rank
3002        // decisions are made once during evidence factorization and carried in
3003        // `beta_schur_conditioning`; inferring a new mask from the conditioned
3004        // factor would desynchronize the value and its gradient.
3005        let inv_evals = evals.mapv(|lambda| 1.0 / lambda);
3006        Ok(DeflatedSchurPseudoInverse { evecs, inv_evals })
3007    }
3008
3009    /// Apply a precomputed [`DeflatedSchurPseudoInverse`] to one RHS, mirroring
3010    /// the β-gauge-quotient complement projection of the plain
3011    /// [`Self::schur_inverse_apply`]: `P M⁺ P rhs` when a quotient is installed,
3012    /// `M⁺ rhs` otherwise.
3013    fn apply_deflated_pseudo_inverse(
3014        &self,
3015        deflated: &DeflatedSchurPseudoInverse,
3016        rhs: ArrayView1<'_, f64>,
3017    ) -> Array1<f64> {
3018        let rhs_owned = match self.beta_gauge_quotient.as_ref() {
3019            Some(quotient) => quotient.project_complement(rhs),
3020            None => rhs.to_owned(),
3021        };
3022        // M⁺ r = Q · diag(1/λ̃) · Qᵀ r.
3023        let coeffs = deflated.evecs.t().dot(&rhs_owned);
3024        let scaled = &coeffs * &deflated.inv_evals;
3025        let solved = deflated.evecs.dot(&scaled);
3026        match self.beta_gauge_quotient.as_ref() {
3027            Some(quotient) => quotient.project_complement(solved.view()),
3028            None => solved,
3029        }
3030    }
3031}
3032
3033/// Per-chunk stacked Schur subtraction for the parallel assembly fan-out.
3034///
3035/// Dense rows are appended into stacked `(Σd × k)` factors and subtracted with
3036/// ONE sequential SIMD GEMM per chunk (`s_part -= Lᵀ R`) — the CPU mirror of
3037/// the device `tile_schur_partial` stacking — while rows with sparse column
3038/// support keep the nnz-scaled scatter (#1995), which beats a dense GEMM
3039/// there.
3040///
3041/// The crossover is derived, not tuned. The scatter costs
3042/// `Σ_c nnz_l(c)·nnz_r(c)` scalar FMAs against a randomly indexed `k×k`
3043/// accumulator (unvectorizable), while the row's share of the stacked GEMM is
3044/// `d·k²` FMAs at SIMD throughput — ≈8 f64 FMAs per cycle (4-lane vectors,
3045/// dual issue) on both x86-64/AVX2 and aarch64/NEON. The GEMM therefore wins
3046/// once `scatter_flops > d·k²/8`. Pricing this needs one `O(d·k)` support
3047/// count, the same scan the scatter pays to build its active lists, so a
3048/// "scatter" verdict wastes nothing and a "stack" verdict wastes only the
3049/// count.
3050///
3051/// Numerics: the stacked GEMM reassociates the within-chunk row sum relative
3052/// to the per-row scatter — the same reassociation class as the existing
3053/// chunk-partial fold and the device stacking, and deterministic run-to-run
3054/// (`Par::Seq` inside the worker per #1557).
3055struct ChunkSchurStack {
3056    left: Vec<f64>,
3057    right: Vec<f64>,
3058    stacked_rows: usize,
3059    k: usize,
3060}
3061
3062impl ChunkSchurStack {
3063    fn new(k: usize) -> Self {
3064        Self {
3065            left: Vec::new(),
3066            right: Vec::new(),
3067            stacked_rows: 0,
3068            k,
3069        }
3070    }
3071
3072    /// Either scatter this row's Schur contribution immediately (sparse
3073    /// support) or append its factors to the chunk stack (dense support).
3074    fn subtract_or_stack(
3075        &mut self,
3076        backend: &CpuBatchedBlockSolver,
3077        s_part: &mut Array2<f64>,
3078        left: &Array2<f64>,
3079        right: &Array2<f64>,
3080    ) {
3081        let k = self.k;
3082        let d = left.nrows();
3083        // Caller-contract shape checks; real asserts (scanner bans debug_*),
3084        // and trivially cheap next to the k x k scatter below.
3085        assert_eq!(left.ncols(), k, "scatter: left must be (d, k)");
3086        assert_eq!(right.dim(), (d, k), "scatter: right must be (d, k)");
3087        assert_eq!(s_part.dim(), (k, k), "scatter: s_part must be (k, k)");
3088        let mut scatter_flops = 0usize;
3089        for c in 0..d {
3090            let mut nnz_left = 0usize;
3091            let mut nnz_right = 0usize;
3092            for col in 0..k {
3093                nnz_left += usize::from(left[[c, col]] != 0.0);
3094                nnz_right += usize::from(right[[c, col]] != 0.0);
3095            }
3096            scatter_flops += nnz_left * nnz_right;
3097        }
3098        if scatter_flops <= d * k * k / 8 {
3099            backend.block_gemm_subtract(s_part, left, right);
3100            return;
3101        }
3102        for source in [(left, &mut self.left), (right, &mut self.right)] {
3103            let (matrix, stack) = source;
3104            if let Some(values) = matrix.as_slice() {
3105                stack.extend_from_slice(values);
3106            } else {
3107                stack.extend(matrix.iter().copied());
3108            }
3109        }
3110        self.stacked_rows += d;
3111    }
3112
3113    /// Subtract every stacked row in one sequential SIMD GEMM.
3114    fn flush(&mut self, s_part: &mut Array2<f64>) {
3115        if self.stacked_rows == 0 {
3116            return;
3117        }
3118        let shape = (self.stacked_rows, self.k);
3119        let left = ndarray::ArrayView2::from_shape(shape, self.left.as_slice())
3120            .expect("ChunkSchurStack left buffer matches its recorded shape");
3121        let right = ndarray::ArrayView2::from_shape(shape, self.right.as_slice())
3122            .expect("ChunkSchurStack right buffer matches its recorded shape");
3123        let product =
3124            gam_linalg::faer_ndarray::fast_atb_with_parallelism(&left, &right, faer::Par::Seq);
3125        *s_part -= &product;
3126        self.left.clear();
3127        self.right.clear();
3128        self.stacked_rows = 0;
3129    }
3130}