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}