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