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