gam_solve/arrow_schur/penalty_ops.rs
1//! Matrix-free penalty-side `H_ββ` operators: the [`BetaPenaltyOp`] trait and
2//! every concrete operator (dense, block, Kronecker, factored-frame, composite,
3//! matvec-diagonal) plus the β-coupling graph used for block-Jacobi clustering.
4
5use super::*;
6
7#[derive(Debug, Clone)]
8pub(crate) struct BetaEdge {
9 pub(crate) a: usize,
10 pub(crate) b: usize,
11}
12
13#[derive(Debug, Clone)]
14pub(crate) struct BetaCouplingGraph {
15 pub(crate) num_blocks: usize,
16 pub(crate) edges: Vec<BetaEdge>,
17 pub(crate) adj_start: Vec<usize>,
18 pub(crate) adj_targets: Vec<usize>,
19 /// Co-visibility WEIGHT parallel to `adj_targets`: `adj_weights[e]` is the
20 /// number of rows in which `node` and `adj_targets[e]` both fire. This is
21 /// the co-visibility count that keys the visibility-based cluster partition
22 /// (Kushal & Agarwal, CVPR 2012): the reduced Schur is, up to its
23 /// block-diagonal penalty, a graph Laplacian over this weighted graph, so a
24 /// strong `adj_weights[e]` marks an off-block coupling a good preconditioner
25 /// block must keep together. Symmetric: the `(a→b)` and `(b→a)` entries carry
26 /// the same weight.
27 pub(crate) adj_weights: Vec<f64>,
28}
29
30impl BetaCouplingGraph {
31 pub(crate) fn build(block_offsets: &[Range<usize>], htbeta_rows: &[Array2<f64>]) -> Self {
32 let num_blocks = block_offsets.len();
33 if num_blocks == 0 {
34 return Self {
35 num_blocks: 0,
36 edges: Vec::new(),
37 adj_start: vec![0],
38 adj_targets: Vec::new(),
39 adj_weights: Vec::new(),
40 };
41 }
42
43 // Accumulate the co-firing MULTIPLICITY per undirected pair: the number
44 // of rows where the two blocks are simultaneously active. The unweighted
45 // edge set (for `component_partition`) is its key set; the counts become
46 // the co-visibility weights that drive the bounded cluster partition.
47 let mut edge_count = std::collections::BTreeMap::<(usize, usize), f64>::new();
48 for row in htbeta_rows {
49 let mut active = Vec::<usize>::new();
50 for (block, range) in block_offsets.iter().enumerate() {
51 if range
52 .clone()
53 .any(|col| (0..row.nrows()).any(|axis| row[[axis, col]] != 0.0))
54 {
55 active.push(block);
56 }
57 }
58 for i in 0..active.len() {
59 for j in (i + 1)..active.len() {
60 let key = (active[i].min(active[j]), active[i].max(active[j]));
61 *edge_count.entry(key).or_insert(0.0) += 1.0;
62 }
63 }
64 }
65
66 let edges: Vec<_> = edge_count.keys().map(|&(a, b)| BetaEdge { a, b }).collect();
67 let weights: Vec<f64> = edge_count.values().copied().collect();
68 let mut degree = vec![0usize; num_blocks];
69 for &BetaEdge { a, b } in &edges {
70 degree[a] += 1;
71 degree[b] += 1;
72 }
73 let mut adj_start = vec![0usize; num_blocks + 1];
74 for block in 0..num_blocks {
75 adj_start[block + 1] = adj_start[block] + degree[block];
76 }
77 let mut adj_targets = vec![0usize; adj_start[num_blocks]];
78 let mut adj_weights = vec![0.0f64; adj_start[num_blocks]];
79 let mut cursor = adj_start[..num_blocks].to_vec();
80 for (&BetaEdge { a, b }, &w) in edges.iter().zip(weights.iter()) {
81 adj_targets[cursor[a]] = b;
82 adj_weights[cursor[a]] = w;
83 cursor[a] += 1;
84 adj_targets[cursor[b]] = a;
85 adj_weights[cursor[b]] = w;
86 cursor[b] += 1;
87 }
88 Self {
89 num_blocks,
90 edges,
91 adj_start,
92 adj_targets,
93 adj_weights,
94 }
95 }
96
97 pub(crate) fn neighbours(&self, node: usize) -> &[usize] {
98 &self.adj_targets[self.adj_start[node]..self.adj_start[node + 1]]
99 }
100
101 /// Co-visibility neighbours of `node` paired with their co-firing weights.
102 pub(crate) fn weighted_neighbours(
103 &self,
104 node: usize,
105 ) -> impl Iterator<Item = (usize, f64)> + '_ {
106 let lo = self.adj_start[node];
107 let hi = self.adj_start[node + 1];
108 self.adj_targets[lo..hi]
109 .iter()
110 .copied()
111 .zip(self.adj_weights[lo..hi].iter().copied())
112 }
113
114 /// Add each unassigned co-visibility neighbour of `block` to `gain`,
115 /// accumulating its co-firing weight toward the growing cluster.
116 fn accumulate_frontier(
117 &self,
118 block: usize,
119 assigned: &[bool],
120 gain: &mut std::collections::HashMap<usize, f64>,
121 ) {
122 for (nbr, w) in self.weighted_neighbours(block) {
123 if !assigned[nbr] {
124 *gain.entry(nbr).or_insert(0.0) += w;
125 }
126 }
127 }
128
129 /// Bounded greedy co-visibility partition of the β-coupling graph (Kushal &
130 /// Agarwal, "Visibility Based Preconditioning for Bundle Adjustment",
131 /// CVPR 2012).
132 ///
133 /// `component_partition` groups by CONNECTED COMPONENT, which at real over-
134 /// complete SAE widths collapses into a single giant component (transitive
135 /// co-firing) — pushing the dense per-cluster factor past the size cap and
136 /// degrading the whole cluster tier to scalar Jacobi, the scaling ceiling.
137 /// This routine instead PARTITIONS the graph into clusters bounded by
138 /// `max_block_cols` (total member columns) that keep the STRONGEST co-firing
139 /// edges inside a cluster: seed at the lowest unassigned block, then
140 /// repeatedly absorb the unassigned neighbour with the greatest accumulated
141 /// co-firing weight to the current cluster, stopping when the next admissible
142 /// block would push the cluster's column count past the cap. A block wider
143 /// than the cap on its own becomes a singleton (the caller scalar-degrades
144 /// it). Every block lands in exactly one cluster.
145 ///
146 /// Deterministic and independent of thread scheduling: the seed order is
147 /// ascending block index and candidate ties break by ascending index, so the
148 /// partition is a pure function of the graph. It is a partition of the SAME
149 /// operator (the preconditioner only steers the PCG iterate), so the solve
150 /// converges to the same reduced-system solution — the change is pure
151 /// numerics on the CG path, REML-neutral.
152 pub(crate) fn covisibility_cluster_partition(
153 &self,
154 block_offsets: &[Range<usize>],
155 max_block_cols: usize,
156 ) -> Vec<Vec<usize>> {
157 let nb = self.num_blocks;
158 let mut clusters: Vec<Vec<usize>> = Vec::new();
159 if nb == 0 {
160 return clusters;
161 }
162 let block_cols = |b: usize| {
163 block_offsets[b]
164 .end
165 .saturating_sub(block_offsets[b].start)
166 .max(1)
167 };
168 let cap = max_block_cols.max(1);
169 let mut assigned = vec![false; nb];
170 for seed in 0..nb {
171 if assigned[seed] {
172 continue;
173 }
174 let mut members = vec![seed];
175 assigned[seed] = true;
176 let mut cols = block_cols(seed);
177 let mut gain = std::collections::HashMap::<usize, f64>::new();
178 self.accumulate_frontier(seed, &assigned, &mut gain);
179 loop {
180 // Pick the admissible frontier block of greatest co-firing weight
181 // to the current cluster; tie-break by ascending index for a
182 // schedule-independent partition. Stale (already-assigned) map
183 // entries are skipped rather than eagerly purged.
184 let mut best: Option<(usize, f64)> = None;
185 for (&cand, &g) in gain.iter() {
186 if assigned[cand] || cols + block_cols(cand) > cap {
187 continue;
188 }
189 match best {
190 None => best = Some((cand, g)),
191 Some((bi, bg)) if g > bg || (g == bg && cand < bi) => {
192 best = Some((cand, g))
193 }
194 _ => {}
195 }
196 }
197 let Some((cand, _)) = best else {
198 break;
199 };
200 assigned[cand] = true;
201 members.push(cand);
202 cols += block_cols(cand);
203 gain.remove(&cand);
204 self.accumulate_frontier(cand, &assigned, &mut gain);
205 }
206 members.sort_unstable();
207 clusters.push(members);
208 }
209 clusters
210 }
211
212 pub(crate) fn component_partition(&self) -> Vec<Vec<usize>> {
213 let mut parent: Vec<usize> = (0..self.num_blocks).collect();
214 let mut rank = vec![0u8; self.num_blocks];
215
216 fn find(parent: &mut [usize], mut x: usize) -> usize {
217 while parent[x] != x {
218 parent[x] = parent[parent[x]];
219 x = parent[x];
220 }
221 x
222 }
223
224 for &BetaEdge { a, b } in &self.edges {
225 let lhs = find(&mut parent, a);
226 let rhs = find(&mut parent, b);
227 if lhs != rhs {
228 if rank[lhs] < rank[rhs] {
229 parent[lhs] = rhs;
230 } else if rank[lhs] > rank[rhs] {
231 parent[rhs] = lhs;
232 } else {
233 parent[rhs] = lhs;
234 rank[lhs] += 1;
235 }
236 }
237 }
238
239 let mut label_map = vec![usize::MAX; self.num_blocks];
240 let mut parts = Vec::<Vec<usize>>::new();
241 for block in 0..self.num_blocks {
242 let root = find(&mut parent, block);
243 let label = if label_map[root] == usize::MAX {
244 label_map[root] = parts.len();
245 parts.push(Vec::new());
246 label_map[root]
247 } else {
248 label_map[root]
249 };
250 parts[label].push(block);
251 }
252 parts
253 }
254
255 pub(crate) fn expand_one_hop(&self, seed: &[usize]) -> Vec<usize> {
256 let mut expanded = seed.to_vec();
257 for &block in seed {
258 expanded.extend_from_slice(self.neighbours(block));
259 }
260 expanded.sort_unstable();
261 expanded.dedup();
262 expanded
263 }
264}
265// ---------------------------------------------------------------------------
266// BetaPenaltyOp — matrix-free penalty-side H_ββ abstraction (#296)
267// ---------------------------------------------------------------------------
268
269/// Identifies one contiguous column block in the shared β vector for
270/// block-Jacobi Schur pre-conditioning (#287).
271///
272/// A `BetaBlockId(i)` refers to the `i`-th range in
273/// [`ArrowSchurSystem::block_offsets`].
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub struct BetaBlockId(pub usize);
276
277/// Matrix-free operator for the penalty side of `H_ββ`.
278///
279/// Callers must satisfy the additive convention: every method **adds** its
280/// contribution to the output buffer (i.e. `y += P x`, not `y = P x`).
281/// This matches the assembly pattern where multiple penalty terms are
282/// accumulated into the same gradient / Hessian buffers.
283pub trait BetaPenaltyOp: Send + Sync {
284 /// Full dimension `K` of the β vector.
285 fn dim(&self) -> usize;
286 /// `y += P x` — penalty Hessian-vector product (length `K`).
287 fn matvec(&self, x: &[f64], y: &mut [f64]);
288 /// Penalty gradient: `out += P β`.
289 fn gradient(&self, beta: &[f64], out: &mut [f64]);
290 /// `diag += diag(P)` — diagonal entries used by Jacobi preconditioner.
291 fn diagonal(&self, diag: &mut [f64]);
292 /// Add the `b×b` dense penalty sub-block for block `id` into `out`
293 /// (row-major, block size `b = offsets[id.0].len()`).
294 /// Used by the block-Jacobi Schur preconditioner (#287).
295 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>);
296 /// Materialize the full `K×K` dense penalty matrix (needed by
297 /// Direct / SqrtBA modes that form the Schur complement explicitly).
298 fn to_dense(&self) -> Array2<f64>;
299 /// Per-row absolute-value sums `out[r] = Σ_c |P[r,c]|`, the row contribution
300 /// to the operator's `∞`-norm. The default folds `to_dense()`, which costs an
301 /// `O(K²)` materialization; structured operators override this to fold their
302 /// compact factors directly so the backward-error certificate's
303 /// `arrow_operator_infinity_norm` never builds a dense `K×K` matrix on the
304 /// SAE LLM-border critical path (#1017). Overrides MUST agree bit-for-bit with
305 /// the `to_dense()` row sums (verified by the cross-check tests).
306 fn row_abs_sums(&self) -> Array1<f64> {
307 let dense = self.to_dense();
308 let k = dense.nrows();
309 let mut out = Array1::<f64>::zeros(k);
310 for r in 0..k {
311 let mut s = 0.0_f64;
312 for c in 0..dense.ncols() {
313 s += dense[[r, c]].abs();
314 }
315 out[r] = s;
316 }
317 out
318 }
319 /// Mix the operator's defining state into `hasher` for cache-validity
320 /// fingerprinting. Must change whenever `matvec` / `to_dense` would change,
321 /// so the factorization / evidence cache (`cache_matches_system`) is
322 /// invalidated when the β-block content changes. Implementations hash their
323 /// own compact defining data (e.g. Kronecker factors, block matrices)
324 /// rather than the full `K×K` dense form, which would defeat the structured
325 /// operator's storage savings.
326 fn fingerprint(&self, hasher: &mut Fingerprinter);
327
328 /// If this operator writes its `matvec` contribution into EXACTLY one
329 /// contiguous output range `[start, end)` and touches no other index,
330 /// return that range; otherwise `None` (the default — opaque / scattered
331 /// output). This lets [`CompositePenaltyOp::matvec`] fan a leading run of
332 /// mutually-disjoint operators across rayon workers, each writing its own
333 /// output sub-slice with no cross-thread aliasing. The per-atom Kronecker
334 /// smooth penalties (the SAE prologue's serial Amdahl ceiling at the K=32k
335 /// manifold border) each cover one atom's β block, so they qualify.
336 fn output_range(&self) -> Option<Range<usize>> {
337 None
338 }
339
340 /// Accumulate `matvec`'s contribution into `y_local`, where `y_local[i]`
341 /// aliases global output index `output_range().start + i` (so
342 /// `y_local.len() == output_range().len()`), reading the FULL-length input
343 /// `x`. ONLY valid when [`Self::output_range`] returns `Some`; the default
344 /// panics because a `None`-range operator has no single contiguous slice to
345 /// write into. Must be BIT-IDENTICAL to the corresponding indices of
346 /// `matvec` (same per-index accumulation order) so the composite's parallel
347 /// prefix reproduces the serial result exactly.
348 fn matvec_local(&self, x: &[f64], y_local: &mut [f64]) {
349 // SAFETY: hard contract guard, not a silent sentinel. A `None`-range
350 // `BetaPenaltyOp` exposes no single contiguous output slice, so the
351 // local matvec is undefined and MUST be routed through `matvec`. This
352 // default exists only to turn that misuse into a loud, immediate
353 // failure at the call site; the input/local extents are surfaced for
354 // triage.
355 panic!(
356 "matvec_local requires output_range() == Some; a None-range \
357 BetaPenaltyOp (input len {}, local output len {}) must be applied \
358 through matvec",
359 x.len(),
360 y_local.len()
361 );
362 }
363}
364
365/// Dense fallback: wraps the existing `K×K` `H_ββ` accumulator.
366pub struct DensePenaltyOp(pub Array2<f64>);
367
368impl BetaPenaltyOp for DensePenaltyOp {
369 fn dim(&self) -> usize {
370 self.0.nrows()
371 }
372
373 fn matvec(&self, x: &[f64], y: &mut [f64]) {
374 let k = self.0.nrows();
375 for a in 0..k {
376 let mut acc = 0.0_f64;
377 for b in 0..k {
378 acc += self.0[[a, b]] * x[b];
379 }
380 y[a] += acc;
381 }
382 }
383
384 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
385 let k = self.0.nrows();
386 for a in 0..k {
387 let mut acc = 0.0_f64;
388 for b in 0..k {
389 acc += self.0[[a, b]] * beta[b];
390 }
391 out[a] += acc;
392 }
393 }
394
395 fn diagonal(&self, diag: &mut [f64]) {
396 let k = self.0.nrows().min(diag.len());
397 for j in 0..k {
398 diag[j] += self.0[[j, j]];
399 }
400 }
401
402 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
403 let range = &offsets[id.0];
404 let b = range.end - range.start;
405 for bi in 0..b {
406 for bj in 0..b {
407 out[[bi, bj]] += self.0[[range.start + bi, range.start + bj]];
408 }
409 }
410 }
411
412 fn to_dense(&self) -> Array2<f64> {
413 self.0.clone()
414 }
415
416 fn fingerprint(&self, hasher: &mut Fingerprinter) {
417 hasher.write_str("dense-penalty-op-v1");
418 hasher.write_f64_array2(&self.0);
419 }
420}
421
422/// Rank-1 PSD penalty operator `scale · v vᵀ` with a SPARSE carrier `v`
423/// (nonzero on only a few atom decoder blocks). Carries the SAE separation
424/// barrier's EXACT self-concordant curvature `∂²P/∂o² · (∂o/∂B)(∂o/∂B)ᵀ` into the
425/// matrix-free / framed penalty operator (#1038), where the per-atom scalar ridge
426/// cannot represent the cross-atom rank-1 coupling: on that path the missing
427/// curvature let the dictionary co-collapse (indefinite reduced Schur → non-PD →
428/// the criterion refines forever). `scale = d2 = ∂²P/∂o² ≥ 0` on the gated
429/// interior ⇒ this rank-1 is PSD, so adding it to any PSD penalty op keeps the
430/// operator PD. The carrier is expressed in whatever coordinate space the operator
431/// runs in (full-`B` on the non-frames path; factored border coords on the framed
432/// path, where the full-`B` `v` is projected `v_c = Φᵀ v` before construction —
433/// still a rank-1 because `Φ` is linear).
434pub struct SparseRankOnePenaltyOp {
435 /// Full β dimension `K` of the coordinate space the carrier lives in.
436 pub k: usize,
437 /// Nonnegative curvature scale `d2 = ∂²P/∂o²`.
438 pub scale: f64,
439 /// Sparse carrier `v`: `(global_index, value)` with every `index < k`.
440 pub carrier: Vec<(usize, f64)>,
441}
442
443impl BetaPenaltyOp for SparseRankOnePenaltyOp {
444 fn dim(&self) -> usize {
445 self.k
446 }
447
448 fn matvec(&self, x: &[f64], y: &mut [f64]) {
449 // y += scale · v (vᵀ x)
450 let mut dot = 0.0_f64;
451 for &(idx, val) in &self.carrier {
452 dot += val * x[idx];
453 }
454 let s = self.scale * dot;
455 for &(idx, val) in &self.carrier {
456 y[idx] += s * val;
457 }
458 }
459
460 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
461 let mut dot = 0.0_f64;
462 for &(idx, val) in &self.carrier {
463 dot += val * beta[idx];
464 }
465 let s = self.scale * dot;
466 for &(idx, val) in &self.carrier {
467 out[idx] += s * val;
468 }
469 }
470
471 fn diagonal(&self, diag: &mut [f64]) {
472 for &(idx, val) in &self.carrier {
473 diag[idx] += self.scale * val * val;
474 }
475 }
476
477 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
478 // Only the intra-block sub-outer-product contributes to this block-Jacobi
479 // sub-block (the cross-block coupling is invisible to the preconditioner,
480 // exactly as for any operator with off-block entries).
481 let range = &offsets[id.0];
482 for &(gi, vi) in &self.carrier {
483 if gi < range.start || gi >= range.end {
484 continue;
485 }
486 let bi = gi - range.start;
487 for &(gj, vj) in &self.carrier {
488 if gj < range.start || gj >= range.end {
489 continue;
490 }
491 out[[bi, gj - range.start]] += self.scale * vi * vj;
492 }
493 }
494 }
495
496 fn to_dense(&self) -> Array2<f64> {
497 let mut out = Array2::<f64>::zeros((self.k, self.k));
498 for &(gi, vi) in &self.carrier {
499 for &(gj, vj) in &self.carrier {
500 out[[gi, gj]] += self.scale * vi * vj;
501 }
502 }
503 out
504 }
505
506 fn fingerprint(&self, hasher: &mut Fingerprinter) {
507 hasher.write_str("sparse-rank-one-penalty-op-v1");
508 hasher.write_usize(self.k);
509 hasher.write_f64(self.scale);
510 hasher.write_usize(self.carrier.len());
511 for &(idx, val) in &self.carrier {
512 hasher.write_usize(idx);
513 hasher.write_f64(val);
514 }
515 }
516
517 fn row_abs_sums(&self) -> Array1<f64> {
518 // Row `gi` of `scale·v vᵀ` is `scale·v_gi·vᵀ`, so its ∞-row sum is
519 // `scale·|v_gi|·Σ_j|v_j|`. Fold the sparse carrier directly — NEVER
520 // materialize the dense `K×K` (the SAE border critical path, #1017).
521 let mut out = Array1::<f64>::zeros(self.k);
522 let abs_sum: f64 = self.carrier.iter().map(|&(_, v)| v.abs()).sum();
523 let s = self.scale * abs_sum;
524 for &(gi, vi) in &self.carrier {
525 out[gi] += s * vi.abs();
526 }
527 out
528 }
529}
530
531/// Block-local penalty operator: applies per-block penalty matrices
532/// (matching `ParameterBlockSpec` boundaries) without materialising a
533/// full `K×K` dense matrix.
534///
535/// Each entry is `(global_offset, local_matrix)` where `global_offset`
536/// is the start of that block in the full β vector.
537pub struct BlockPenaltyOp {
538 /// Full β dimension `K`.
539 pub k: usize,
540 /// `(global_start, local_matrix)` for each atom/block.
541 pub blocks: Vec<(usize, Array2<f64>)>,
542}
543
544impl BetaPenaltyOp for BlockPenaltyOp {
545 fn dim(&self) -> usize {
546 self.k
547 }
548
549 fn matvec(&self, x: &[f64], y: &mut [f64]) {
550 for (off, local) in &self.blocks {
551 let b = local.nrows();
552 for i in 0..b {
553 let gi = off + i;
554 let mut acc = 0.0_f64;
555 for j in 0..b {
556 acc += local[[i, j]] * x[off + j];
557 }
558 y[gi] += acc;
559 }
560 }
561 }
562
563 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
564 for (off, local) in &self.blocks {
565 let b = local.nrows();
566 for i in 0..b {
567 let gi = off + i;
568 let mut acc = 0.0_f64;
569 for j in 0..b {
570 acc += local[[i, j]] * beta[off + j];
571 }
572 out[gi] += acc;
573 }
574 }
575 }
576
577 fn diagonal(&self, diag: &mut [f64]) {
578 for (off, local) in &self.blocks {
579 let b = local.nrows();
580 for j in 0..b {
581 diag[off + j] += local[[j, j]];
582 }
583 }
584 }
585
586 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
587 let range = &offsets[id.0];
588 let b_out = range.end - range.start;
589 for (off, local) in &self.blocks {
590 let b = local.nrows();
591 let block_end = off + b;
592 if block_end <= range.start || *off >= range.end {
593 continue;
594 }
595 for bi in 0..b_out {
596 let gi = range.start + bi;
597 if gi < *off || gi >= block_end {
598 continue;
599 }
600 let li = gi - off;
601 for bj in 0..b_out {
602 let gj = range.start + bj;
603 if gj < *off || gj >= block_end {
604 continue;
605 }
606 let lj = gj - off;
607 out[[bi, bj]] += local[[li, lj]];
608 }
609 }
610 }
611 }
612
613 fn to_dense(&self) -> Array2<f64> {
614 let mut out = Array2::<f64>::zeros((self.k, self.k));
615 for (off, local) in &self.blocks {
616 let b = local.nrows();
617 for i in 0..b {
618 for j in 0..b {
619 out[[off + i, off + j]] += local[[i, j]];
620 }
621 }
622 }
623 out
624 }
625
626 fn fingerprint(&self, hasher: &mut Fingerprinter) {
627 hasher.write_str("block-penalty-op-v1");
628 hasher.write_usize(self.k);
629 hasher.write_usize(self.blocks.len());
630 for (off, local) in &self.blocks {
631 hasher.write_usize(*off);
632 hasher.write_f64_array2(local);
633 }
634 }
635}
636
637/// Kronecker-product penalty: `P = A ⊗ B` applied without materialising
638/// the full `(p_a·p_b)×(p_a·p_b)` matrix.
639pub struct KroneckerPenaltyOp {
640 /// Left factor `A`, shape `(p_a, p_a)`.
641 pub factor_a: Array2<f64>,
642 /// Right factor `B`, shape `(p_b, p_b)`.
643 pub factor_b: Array2<f64>,
644 /// Global offset into the β vector where this block starts.
645 pub global_offset: usize,
646 /// Full β dimension `K`.
647 pub k: usize,
648}
649
650impl BetaPenaltyOp for KroneckerPenaltyOp {
651 fn dim(&self) -> usize {
652 self.k
653 }
654
655 fn matvec(&self, x: &[f64], y: &mut [f64]) {
656 let p_a = self.factor_a.nrows();
657 let p_b = self.factor_b.nrows();
658 let off = self.global_offset;
659 // (A ⊗ B) vec(V) where V is (p_b, p_a) with Fortran/vec ordering.
660 for i_a in 0..p_a {
661 for i_b in 0..p_b {
662 let gi = off + i_a * p_b + i_b;
663 let mut acc = 0.0_f64;
664 for j_a in 0..p_a {
665 let a_ij = self.factor_a[[i_a, j_a]];
666 if a_ij == 0.0 {
667 continue;
668 }
669 for j_b in 0..p_b {
670 acc += a_ij * self.factor_b[[i_b, j_b]] * x[off + j_a * p_b + j_b];
671 }
672 }
673 y[gi] += acc;
674 }
675 }
676 }
677
678 fn output_range(&self) -> Option<Range<usize>> {
679 let off = self.global_offset;
680 Some(off..off + self.factor_a.nrows() * self.factor_b.nrows())
681 }
682
683 fn matvec_local(&self, x: &[f64], y_local: &mut [f64]) {
684 // Byte-for-byte the `matvec` arithmetic with the output written at the
685 // LOCAL index `i_a·p_b + i_b` (== global `gi - off`), so the composite
686 // can apply this block into its own `y[off..off+p_a·p_b]` sub-slice in
687 // parallel. Per-index accumulation order is unchanged ⇒ bit-identical.
688 let p_a = self.factor_a.nrows();
689 let p_b = self.factor_b.nrows();
690 let off = self.global_offset;
691 for i_a in 0..p_a {
692 for i_b in 0..p_b {
693 let li = i_a * p_b + i_b;
694 let mut acc = 0.0_f64;
695 for j_a in 0..p_a {
696 let a_ij = self.factor_a[[i_a, j_a]];
697 if a_ij == 0.0 {
698 continue;
699 }
700 for j_b in 0..p_b {
701 acc += a_ij * self.factor_b[[i_b, j_b]] * x[off + j_a * p_b + j_b];
702 }
703 }
704 y_local[li] += acc;
705 }
706 }
707 }
708
709 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
710 let p_a = self.factor_a.nrows();
711 let p_b = self.factor_b.nrows();
712 let off = self.global_offset;
713 for i_a in 0..p_a {
714 for i_b in 0..p_b {
715 let gi = off + i_a * p_b + i_b;
716 let mut acc = 0.0_f64;
717 for j_a in 0..p_a {
718 let a_ij = self.factor_a[[i_a, j_a]];
719 if a_ij == 0.0 {
720 continue;
721 }
722 for j_b in 0..p_b {
723 acc += a_ij * self.factor_b[[i_b, j_b]] * beta[off + j_a * p_b + j_b];
724 }
725 }
726 out[gi] += acc;
727 }
728 }
729 }
730
731 fn diagonal(&self, diag: &mut [f64]) {
732 let p_a = self.factor_a.nrows();
733 let p_b = self.factor_b.nrows();
734 let off = self.global_offset;
735 for i_a in 0..p_a {
736 for i_b in 0..p_b {
737 diag[off + i_a * p_b + i_b] +=
738 self.factor_a[[i_a, i_a]] * self.factor_b[[i_b, i_b]];
739 }
740 }
741 }
742
743 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
744 let range = &offsets[id.0];
745 let b = range.end - range.start;
746 let p_a = self.factor_a.nrows();
747 let p_b = self.factor_b.nrows();
748 let off = self.global_offset;
749 let block_end = off + p_a * p_b;
750 if block_end <= range.start || off >= range.end {
751 return;
752 }
753 for bi in 0..b {
754 let gi = range.start + bi;
755 if gi < off || gi >= block_end {
756 continue;
757 }
758 let li = gi - off;
759 let i_a = li / p_b;
760 let i_b = li % p_b;
761 for bj in 0..b {
762 let gj = range.start + bj;
763 if gj < off || gj >= block_end {
764 continue;
765 }
766 let lj = gj - off;
767 let j_a = lj / p_b;
768 let j_b = lj % p_b;
769 out[[bi, bj]] += self.factor_a[[i_a, j_a]] * self.factor_b[[i_b, j_b]];
770 }
771 }
772 }
773
774 fn to_dense(&self) -> Array2<f64> {
775 let p_a = self.factor_a.nrows();
776 let p_b = self.factor_b.nrows();
777 let off = self.global_offset;
778 let mut out = Array2::<f64>::zeros((self.k, self.k));
779 for i_a in 0..p_a {
780 for i_b in 0..p_b {
781 let gi = off + i_a * p_b + i_b;
782 for j_a in 0..p_a {
783 let a_ij = self.factor_a[[i_a, j_a]];
784 if a_ij == 0.0 {
785 continue;
786 }
787 for j_b in 0..p_b {
788 let gj = off + j_a * p_b + j_b;
789 out[[gi, gj]] += a_ij * self.factor_b[[i_b, j_b]];
790 }
791 }
792 }
793 }
794 out
795 }
796
797 fn fingerprint(&self, hasher: &mut Fingerprinter) {
798 hasher.write_str("kronecker-penalty-op-v1");
799 hasher.write_usize(self.global_offset);
800 hasher.write_usize(self.k);
801 hasher.write_f64_array2(&self.factor_a);
802 hasher.write_f64_array2(&self.factor_b);
803 }
804}
805
806/// Kronecker-product penalty with an identity right factor:
807/// `P = A ⊗ I_p`.
808///
809/// This is the hot SAE smoothness case. Storing `I_p` as a dense matrix costs
810/// `O(p²)` memory per atom and makes every matvec pay an unnecessary right-factor
811/// loop. This operator stores only the identity dimension and keeps the same
812/// layout as [`KroneckerPenaltyOp`]: local index `i_a * p + i_b`.
813pub struct IdentityRightKroneckerPenaltyOp {
814 /// Left factor `A`, shape `(p_a, p_a)`.
815 pub factor_a: Array2<f64>,
816 /// Identity right-factor dimension `p`.
817 pub p: usize,
818 /// Global offset into the β vector where this block starts.
819 pub global_offset: usize,
820 /// Full β dimension `K`.
821 pub k: usize,
822}
823
824impl BetaPenaltyOp for IdentityRightKroneckerPenaltyOp {
825 fn dim(&self) -> usize {
826 self.k
827 }
828
829 fn matvec(&self, x: &[f64], y: &mut [f64]) {
830 let p_a = self.factor_a.nrows();
831 let p = self.p;
832 let off = self.global_offset;
833 for i_a in 0..p_a {
834 for i_b in 0..p {
835 let gi = off + i_a * p + i_b;
836 let mut acc = 0.0_f64;
837 for j_a in 0..p_a {
838 let a_ij = self.factor_a[[i_a, j_a]];
839 if a_ij == 0.0 {
840 continue;
841 }
842 acc += a_ij * x[off + j_a * p + i_b];
843 }
844 y[gi] += acc;
845 }
846 }
847 }
848
849 fn output_range(&self) -> Option<Range<usize>> {
850 let off = self.global_offset;
851 Some(off..off + self.factor_a.nrows() * self.p)
852 }
853
854 fn matvec_local(&self, x: &[f64], y_local: &mut [f64]) {
855 // Byte-for-byte the `matvec` inner arithmetic, but the output writes to
856 // the LOCAL index `i_a·p + i_b` (== global `gi - off`) so the composite
857 // can hand this operator its own `y[off..off+p_a·p]` sub-slice. The
858 // per-index accumulation order over `j_a` is unchanged, so the result is
859 // bit-identical to `matvec`.
860 let p_a = self.factor_a.nrows();
861 let p = self.p;
862 let off = self.global_offset;
863 for i_a in 0..p_a {
864 for i_b in 0..p {
865 let li = i_a * p + i_b;
866 let mut acc = 0.0_f64;
867 for j_a in 0..p_a {
868 let a_ij = self.factor_a[[i_a, j_a]];
869 if a_ij == 0.0 {
870 continue;
871 }
872 acc += a_ij * x[off + j_a * p + i_b];
873 }
874 y_local[li] += acc;
875 }
876 }
877 }
878
879 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
880 self.matvec(beta, out);
881 }
882
883 fn diagonal(&self, diag: &mut [f64]) {
884 let p_a = self.factor_a.nrows();
885 let p = self.p;
886 let off = self.global_offset;
887 for i_a in 0..p_a {
888 let a_ii = self.factor_a[[i_a, i_a]];
889 for i_b in 0..p {
890 diag[off + i_a * p + i_b] += a_ii;
891 }
892 }
893 }
894
895 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
896 let range = &offsets[id.0];
897 let b = range.end - range.start;
898 let p_a = self.factor_a.nrows();
899 let p = self.p;
900 let off = self.global_offset;
901 let block_end = off + p_a * p;
902 if block_end <= range.start || off >= range.end {
903 return;
904 }
905 for bi in 0..b {
906 let gi = range.start + bi;
907 if gi < off || gi >= block_end {
908 continue;
909 }
910 let li = gi - off;
911 let i_a = li / p;
912 let i_b = li % p;
913 for bj in 0..b {
914 let gj = range.start + bj;
915 if gj < off || gj >= block_end {
916 continue;
917 }
918 let lj = gj - off;
919 let j_a = lj / p;
920 let j_b = lj % p;
921 if i_b == j_b {
922 out[[bi, bj]] += self.factor_a[[i_a, j_a]];
923 }
924 }
925 }
926 }
927
928 fn to_dense(&self) -> Array2<f64> {
929 let p_a = self.factor_a.nrows();
930 let p = self.p;
931 let off = self.global_offset;
932 let mut out = Array2::<f64>::zeros((self.k, self.k));
933 for i_a in 0..p_a {
934 for j_a in 0..p_a {
935 let a_ij = self.factor_a[[i_a, j_a]];
936 if a_ij == 0.0 {
937 continue;
938 }
939 for i_b in 0..p {
940 let gi = off + i_a * p + i_b;
941 let gj = off + j_a * p + i_b;
942 out[[gi, gj]] += a_ij;
943 }
944 }
945 }
946 out
947 }
948
949 fn fingerprint(&self, hasher: &mut Fingerprinter) {
950 hasher.write_str("identity-right-kronecker-penalty-op-v1");
951 hasher.write_usize(self.global_offset);
952 hasher.write_usize(self.k);
953 hasher.write_usize(self.p);
954 hasher.write_f64_array2(&self.factor_a);
955 }
956}
957
958/// One co-occurring atom-pair block of a block-sparse left factor `A`.
959///
960/// `data` is the dense `(m_i × m_j)` coupling between the basis columns of
961/// atom `i` (rows, starting at left-factor offset `row_off`) and atom `j`
962/// (columns, starting at `col_off`). Both offsets are in *left-factor* (`A`)
963/// coordinates, i.e. `μ`-space, not β-space.
964#[derive(Debug, Clone)]
965pub struct SparseGBlock {
966 /// Left-factor (`μ`-space) row offset = `beta_offset[atom_i] / p`.
967 pub row_off: usize,
968 /// Left-factor (`μ`-space) column offset = `beta_offset[atom_j] / p`.
969 pub col_off: usize,
970 /// Dense `(m_i × m_j)` coupling block.
971 pub data: Array2<f64>,
972}
973
974/// Block-sparse Kronecker penalty `P = A ⊗ I_p` where the left factor `A`
975/// (dimension `dim_a × dim_a` in `μ`-space) is stored only on its non-empty
976/// co-occurring atom-pair blocks rather than as a dense `(dim_a × dim_a)`
977/// matrix.
978///
979/// This is the sparse-atom (`K = 100K`) replacement for wrapping the dense
980/// data-fit Gauss-Newton Gram `G` (`m_total × m_total`) in a
981/// [`KroneckerPenaltyOp`]: with per-row active sets of size `k_active ≪ K`,
982/// only the `(atom, atom')` pairs that co-occur in some row contribute a
983/// non-zero `(m_i × m_j)` block, so the storage and every matvec/diagonal
984/// pass cost `O(Σ_pairs m_i m_j · p)` instead of `O((m_total · p)²)`.
985///
986/// The β index of left-factor coordinate `μ` and output channel `oc` is
987/// `μ · p + oc` (the same `μ`-major / `oc`-minor layout the dense
988/// `KroneckerPenaltyOp { factor_b: I_p }` uses), so this op is a drop-in
989/// structured replacement: with the full dense pair set it reproduces the
990/// dense operator exactly.
991pub struct SparseBlockKroneckerPenaltyOp {
992 /// Right-factor identity dimension `p` (number of decoder output channels).
993 pub p: usize,
994 /// Left-factor dimension `dim_a` in `μ`-space (= `m_total`).
995 pub dim_a: usize,
996 /// Full β dimension `K = dim_a · p`.
997 pub k: usize,
998 /// Non-empty `(atom_i, atom_j)` coupling blocks of `A`.
999 pub blocks: Vec<SparseGBlock>,
1000}
1001
1002#[derive(Debug, Clone)]
1003pub struct DeviceSaeSmoothBlock {
1004 pub global_offset: usize,
1005 pub factor_a: Array2<f64>,
1006}
1007
1008/// Frame-factored extension of [`DeviceSaePcgData`] (issue #1017/#1026,
1009/// frames-engaged device PCG). Present only when at least one atom is genuinely
1010/// frame-reduced (`ranks[k] < p`); absent (`None`) on the full-`B` path, where
1011/// the legacy `G ⊗ I_p` channel-identical kernel applies byte-for-byte.
1012///
1013/// On the frames path the β border is the FACTORED coordinate space `C` of width
1014/// `Σ_k M_k·r_k`, the data-fit β-Hessian is `G_{ij} ⊗ W_{ij}` (`W_{ij}=U_iᵀU_j`,
1015/// carried on `frame_blocks`), the smooth penalty is `λ S_k ⊗ I_{r_k}`
1016/// (`smooth_blocks`, reused — width `r_k` instead of `p`), and the per-row
1017/// reduced-Schur cross-block `H_tβ^(i)` is the DENSE `(q_i × border_dim)` slab
1018/// `row_htbeta[i]` (row-major) rather than the full-`B` factored `L_i · J_β`
1019/// gather (so `a_phi`/`local_jac` are unused on this path).
1020#[derive(Debug, Clone)]
1021pub struct DeviceSaeFrameData {
1022 /// Per-atom frame rank `r_k` (factored output width); `r_k == p` for an
1023 /// un-framed atom riding the identity special case.
1024 pub ranks: Vec<usize>,
1025 /// Per-atom basis size `M_k`.
1026 pub basis_sizes: Vec<usize>,
1027 /// Per-atom factored-border offset `off_C[k]` (prefix sum of `M_k·r_k`),
1028 /// length `n_atoms`. Atom `k`'s `C_k` block is `[off_C[k] .. +M_k·r_k)`.
1029 pub border_offsets: Vec<usize>,
1030 /// Co-occurring `(atom_i, atom_j)` data-fit blocks `g ⊗ w` (`w = U_iᵀU_j`).
1031 pub frame_blocks: Vec<FactoredFrameGBlock>,
1032 /// Right-factor width (`r_k`) of each entry of the top-level
1033 /// `DeviceSaePcgData::smooth_blocks`, in the SAME order. On the frames path
1034 /// the smooth penalty is `λ S_k ⊗ I_{r_k}` so the block at
1035 /// `smooth_blocks[i].global_offset` has identity width `smooth_ranks[i]`
1036 /// (which equals `ranks[atom]`), NOT the ambient `p`.
1037 pub smooth_ranks: Vec<usize>,
1038 /// Per-row dense cross-block `H_tβ^(i)` as a row-major `q_i × border_dim`
1039 /// buffer (`q_i = row_dims[i]`). Empty inner `Vec` for a 0-dim row.
1040 pub row_htbeta: Vec<Vec<f64>>,
1041}
1042
1043#[derive(Debug, Clone)]
1044pub struct DeviceSaePcgData {
1045 pub p: usize,
1046 pub beta_dim: usize,
1047 // #1033 large-n: the per-row support `a_phi` and local Jacobians `local_jac`
1048 // are ALSO held by the host matrix-free row operator (`SaeKroneckerRows`) for
1049 // the lifetime of the inner solve. Storing them as `Arc<[…]>` lets the
1050 // assembler hand BOTH consumers the SAME backing allocation instead of a
1051 // second full `O(n·q·p)` clone (`device_rows = (a_phi.clone(), kron_jac.clone())`
1052 // was the dominant always-resident duplication on the CPU non-frames path at
1053 // the LLM shape p≈5120). Indexing/`.len()`/iteration are identical to `Vec`.
1054 pub a_phi: Arc<[Vec<(usize, f64)>]>,
1055 pub local_jac: Arc<[Vec<f64>]>,
1056 pub smooth_blocks: Vec<DeviceSaeSmoothBlock>,
1057 pub sparse_g_blocks: Vec<SparseGBlock>,
1058 /// Frame-factored metadata. `None` ⇒ legacy full-`B` `G ⊗ I_p` path
1059 /// (byte-identical to before this field existed). `Some` ⇒ frames-engaged
1060 /// path: the kernel consumes `frame.frame_blocks`/`smooth_blocks` (now
1061 /// rank-`r_k` wide) and `frame.row_htbeta` instead of the `⊗ I_p` gather.
1062 pub frame: Option<DeviceSaeFrameData>,
1063}
1064
1065impl DeviceSaePcgData {
1066 /// Replace a framed current-iterate payload while retaining the nested host
1067 /// allocations that have stable shapes across nonlinear assemblies.
1068 ///
1069 /// Full-`B` payloads keep their freshly assembled `Arc` slices because those
1070 /// exact allocations are shared with the CPU row operator. Framed payloads
1071 /// have no such aliasing (`a_phi`/`local_jac` are empty), so `Vec::clone_from`
1072 /// can reuse the dominant per-row dense `H_tβ` slabs, frame-Gram blocks, and
1073 /// smooth blocks while replacing every numerical value.
1074 pub(crate) fn replace_reusing_framed_allocations(&mut self, mut data: Self) {
1075 if self.frame.is_none() || data.frame.is_none() {
1076 *self = data;
1077 return;
1078 }
1079 let new_frame = data.frame.take().expect("framed replacement checked above");
1080 let current_frame = self.frame.as_mut().expect("framed receiver checked above");
1081
1082 self.p = data.p;
1083 self.beta_dim = data.beta_dim;
1084 self.a_phi = data.a_phi;
1085 self.local_jac = data.local_jac;
1086 self.smooth_blocks.clone_from(&data.smooth_blocks);
1087 self.sparse_g_blocks.clone_from(&data.sparse_g_blocks);
1088 current_frame.ranks.clone_from(&new_frame.ranks);
1089 current_frame.basis_sizes.clone_from(&new_frame.basis_sizes);
1090 current_frame
1091 .border_offsets
1092 .clone_from(&new_frame.border_offsets);
1093 current_frame
1094 .frame_blocks
1095 .clone_from(&new_frame.frame_blocks);
1096 current_frame
1097 .smooth_ranks
1098 .clone_from(&new_frame.smooth_ranks);
1099 current_frame.row_htbeta.clone_from(&new_frame.row_htbeta);
1100 }
1101
1102 /// Snapshot the per-row active-atom support `a_phi` into a shared `Arc<[…]>`
1103 /// for the CPU residency operator ([`SaeResidentReducedSchur`]). Cloned once
1104 /// per CG-solve build (cost `O(Σ_i m_i)`, dwarfed by the per-row factor solves
1105 /// in the same build), so the resident matvec borrows the index lists without
1106 /// re-cloning them on every CG iteration.
1107 pub(crate) fn a_phi_shared(&self) -> Arc<[Vec<(usize, f64)>]> {
1108 // #1033: `a_phi` is already an `Arc<[…]>`; hand back a refcount bump
1109 // (`O(1)`) rather than re-cloning every `(idx, weight)` pair per CG build.
1110 Arc::clone(&self.a_phi)
1111 }
1112
1113 /// Share the per-row local Jacobians `local_jac` with the CPU residency
1114 /// operator ([`SaeResidentReducedSchur`]) as an `O(1)` refcount bump. The
1115 /// staged row factor used to hold a verbatim row-major copy of each
1116 /// `local_jac[row]`; sharing the slab removes that second full `O(n·di·p)`
1117 /// copy with byte-for-byte identical reads (#1033).
1118 pub(crate) fn local_jac_shared(&self) -> Arc<[Vec<f64>]> {
1119 Arc::clone(&self.local_jac)
1120 }
1121}
1122
1123/// #1017/#2230 residency measurement: the host→device operand bytes that
1124/// `flatten_device_sae_data` / `flatten_device_sae_frame_data` re-upload on EVERY
1125/// matrix-free PCG solve (hence on every LM ridge-ladder escalation trial),
1126/// broken out by category. Lets the a100 job confirm which sub-lane a real fit
1127/// takes — legacy sparse (`⊗ I_p` a_phi/local_jac) vs framed dense (per-row
1128/// `row_htbeta` at the factored border) — and size the transfer a base-resident
1129/// frame would remove. Pure host accounting, no device contact, so it is
1130/// CPU-CI testable without CUDA.
1131#[derive(Clone, Debug, Default)]
1132pub struct SaePcgOperandReport {
1133 /// `true` when the framed dense per-row cross path is active (frame present).
1134 pub framed: bool,
1135 /// Border width `k` (= `beta_dim`).
1136 pub beta_dim: usize,
1137 /// Per-row support `a_phi`: total `(row, weight)` pairs and their bytes.
1138 pub a_phi_pairs: usize,
1139 pub a_phi_bytes: usize,
1140 /// Per-row local Jacobians `local_jac`: total `f64` and their bytes.
1141 pub local_jac_elems: usize,
1142 pub local_jac_bytes: usize,
1143 /// Smooth penalty factors `λ S_k`: bytes.
1144 pub smooth_bytes: usize,
1145 /// Sparse `G` co-occurrence blocks (legacy `⊗ I_p`): bytes.
1146 pub sparse_g_bytes: usize,
1147 /// Framed per-row dense cross `row_htbeta`: bytes (0 on the legacy path). This
1148 /// is the 34MiB-vs-31GiB discriminator the #2230 design report flagged: a full
1149 /// factored-border dense cross balloons here, a legacy fit leaves it at 0.
1150 pub row_htbeta_bytes: usize,
1151 /// Number of rows carrying a non-empty framed `row_htbeta` slab.
1152 pub row_htbeta_rows: usize,
1153 /// Framed `G_{ij} ⊗ W_{ij}` co-occurrence factors: bytes (0 on legacy).
1154 pub frame_blocks_bytes: usize,
1155 /// Sum of the data-category bytes above — the per-solve re-upload total.
1156 pub total_bytes: usize,
1157}
1158
1159impl std::fmt::Display for SaePcgOperandReport {
1160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1161 let mib = |b: usize| b as f64 / (1024.0 * 1024.0);
1162 write!(
1163 f,
1164 "sae-pcg operand upload [{lane}]: total={total:.1}MiB k={k} \
1165 a_phi={pairs}pairs/{aphi:.1}MiB local_jac={lj:.1}MiB smooth={sm:.1}MiB \
1166 sparse_g={sg:.1}MiB row_htbeta={rh:.1}MiB({rhrows}rows) frame_blocks={fb:.1}MiB",
1167 lane = if self.framed {
1168 "framed-dense"
1169 } else {
1170 "legacy-sparse"
1171 },
1172 total = mib(self.total_bytes),
1173 k = self.beta_dim,
1174 pairs = self.a_phi_pairs,
1175 aphi = mib(self.a_phi_bytes),
1176 lj = mib(self.local_jac_bytes),
1177 sm = mib(self.smooth_bytes),
1178 sg = mib(self.sparse_g_bytes),
1179 rh = mib(self.row_htbeta_bytes),
1180 rhrows = self.row_htbeta_rows,
1181 fb = mib(self.frame_blocks_bytes),
1182 )
1183 }
1184}
1185
1186impl DeviceSaePcgData {
1187 /// #1017/#2230 residency measurement — see [`SaePcgOperandReport`]. Sums the
1188 /// resident host operands by category; no device contact.
1189 pub fn operand_byte_report(&self) -> SaePcgOperandReport {
1190 const PAIR: usize = std::mem::size_of::<(usize, f64)>();
1191 const F64: usize = std::mem::size_of::<f64>();
1192
1193 let a_phi_pairs: usize = self.a_phi.iter().map(|r| r.len()).sum();
1194 let local_jac_elems: usize = self.local_jac.iter().map(|r| r.len()).sum();
1195 let smooth_bytes = self
1196 .smooth_blocks
1197 .iter()
1198 .map(|b| b.factor_a.len())
1199 .sum::<usize>()
1200 * F64;
1201 let sparse_g_bytes = self
1202 .sparse_g_blocks
1203 .iter()
1204 .map(|b| b.data.len())
1205 .sum::<usize>()
1206 * F64;
1207
1208 let (framed, row_htbeta_bytes, row_htbeta_rows, frame_blocks_bytes) =
1209 if let Some(frame) = self.frame.as_ref() {
1210 let rh_elems: usize = frame.row_htbeta.iter().map(|r| r.len()).sum();
1211 let rh_rows = frame.row_htbeta.iter().filter(|r| !r.is_empty()).count();
1212 let fb_elems: usize = frame
1213 .frame_blocks
1214 .iter()
1215 .map(|b| b.g.len() + b.w.len())
1216 .sum();
1217 (true, rh_elems * F64, rh_rows, fb_elems * F64)
1218 } else {
1219 (false, 0, 0, 0)
1220 };
1221
1222 let a_phi_bytes = a_phi_pairs * PAIR;
1223 let local_jac_bytes = local_jac_elems * F64;
1224 SaePcgOperandReport {
1225 framed,
1226 beta_dim: self.beta_dim,
1227 a_phi_pairs,
1228 a_phi_bytes,
1229 local_jac_elems,
1230 local_jac_bytes,
1231 smooth_bytes,
1232 sparse_g_bytes,
1233 row_htbeta_bytes,
1234 row_htbeta_rows,
1235 frame_blocks_bytes,
1236 total_bytes: a_phi_bytes
1237 + local_jac_bytes
1238 + smooth_bytes
1239 + sparse_g_bytes
1240 + row_htbeta_bytes
1241 + frame_blocks_bytes,
1242 }
1243 }
1244}
1245
1246impl BetaPenaltyOp for SparseBlockKroneckerPenaltyOp {
1247 fn dim(&self) -> usize {
1248 self.k
1249 }
1250
1251 fn matvec(&self, x: &[f64], y: &mut [f64]) {
1252 let p = self.p;
1253 for blk in &self.blocks {
1254 let (m_i, m_j) = blk.data.dim();
1255 for li in 0..m_i {
1256 let gi_base = (blk.row_off + li) * p;
1257 for lj in 0..m_j {
1258 let a_ij = blk.data[[li, lj]];
1259 if a_ij == 0.0 {
1260 continue;
1261 }
1262 let gj_base = (blk.col_off + lj) * p;
1263 for oc in 0..p {
1264 y[gi_base + oc] += a_ij * x[gj_base + oc];
1265 }
1266 }
1267 }
1268 }
1269 }
1270
1271 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
1272 self.matvec(beta, out);
1273 }
1274
1275 fn diagonal(&self, diag: &mut [f64]) {
1276 let p = self.p;
1277 for blk in &self.blocks {
1278 // Only on-diagonal `A` blocks (row_off == col_off) carry diagonal
1279 // mass; their `(li, li)` entries map to `(row_off+li)·p + oc`.
1280 if blk.row_off != blk.col_off {
1281 continue;
1282 }
1283 let (m_i, m_j) = blk.data.dim();
1284 let m = m_i.min(m_j);
1285 for li in 0..m {
1286 let a_ii = blk.data[[li, li]];
1287 let gi_base = (blk.row_off + li) * p;
1288 for oc in 0..p {
1289 diag[gi_base + oc] += a_ii;
1290 }
1291 }
1292 }
1293 }
1294
1295 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
1296 let range = &offsets[id.0];
1297 let b = range.end - range.start;
1298 let p = self.p;
1299 for blk in &self.blocks {
1300 let (m_i, m_j) = blk.data.dim();
1301 let row_start = blk.row_off * p;
1302 let row_end = (blk.row_off + m_i) * p;
1303 let col_start = blk.col_off * p;
1304 let col_end = (blk.col_off + m_j) * p;
1305 if row_end <= range.start
1306 || row_start >= range.end
1307 || col_end <= range.start
1308 || col_start >= range.end
1309 {
1310 continue;
1311 }
1312 for bi in 0..b {
1313 let gi = range.start + bi;
1314 if gi < row_start || gi >= row_end {
1315 continue;
1316 }
1317 let li = (gi - row_start) / p;
1318 let oc_i = (gi - row_start) % p;
1319 for bj in 0..b {
1320 let gj = range.start + bj;
1321 if gj < col_start || gj >= col_end {
1322 continue;
1323 }
1324 let oc_j = (gj - col_start) % p;
1325 if oc_i != oc_j {
1326 continue;
1327 }
1328 let lj = (gj - col_start) / p;
1329 out[[bi, bj]] += blk.data[[li, lj]];
1330 }
1331 }
1332 }
1333 }
1334
1335 fn to_dense(&self) -> Array2<f64> {
1336 let p = self.p;
1337 let mut out = Array2::<f64>::zeros((self.k, self.k));
1338 for blk in &self.blocks {
1339 let (m_i, m_j) = blk.data.dim();
1340 for li in 0..m_i {
1341 let gi_base = (blk.row_off + li) * p;
1342 for lj in 0..m_j {
1343 let a_ij = blk.data[[li, lj]];
1344 if a_ij == 0.0 {
1345 continue;
1346 }
1347 let gj_base = (blk.col_off + lj) * p;
1348 for oc in 0..p {
1349 out[[gi_base + oc, gj_base + oc]] += a_ij;
1350 }
1351 }
1352 }
1353 }
1354 out
1355 }
1356
1357 fn row_abs_sums(&self) -> Array1<f64> {
1358 // Mirror `to_dense`: entry `(gi_base+oc, gj_base+oc) += a_ij`. Each
1359 // `(li, lj, oc)` lands in a DISTINCT column (`gj_base+oc` is injective in
1360 // `(lj, oc)` for a fixed block, and blocks with the same `row_off` have
1361 // disjoint `col_off`), so the row's `Σ_c|P[r,c]|` is just the sum of
1362 // `|a_ij|` over the contributing `(block, lj)` pairs — no dense matrix.
1363 let p = self.p;
1364 let mut out = Array1::<f64>::zeros(self.k);
1365 for blk in &self.blocks {
1366 let (m_i, m_j) = blk.data.dim();
1367 for li in 0..m_i {
1368 let gi_base = (blk.row_off + li) * p;
1369 let mut row_abs = 0.0_f64;
1370 for lj in 0..m_j {
1371 row_abs += blk.data[[li, lj]].abs();
1372 }
1373 for oc in 0..p {
1374 out[gi_base + oc] += row_abs;
1375 }
1376 }
1377 }
1378 out
1379 }
1380
1381 fn fingerprint(&self, hasher: &mut Fingerprinter) {
1382 hasher.write_str("sparse-block-kronecker-penalty-op-v1");
1383 hasher.write_usize(self.p);
1384 hasher.write_usize(self.dim_a);
1385 hasher.write_usize(self.k);
1386 hasher.write_usize(self.blocks.len());
1387 for blk in &self.blocks {
1388 hasher.write_usize(blk.row_off);
1389 hasher.write_usize(blk.col_off);
1390 hasher.write_f64_array2(&blk.data);
1391 }
1392 }
1393}
1394
1395/// One co-occurring `(atom_i, atom_j)` block of the **frame-factored** data-fit
1396/// Gauss–Newton β-Hessian (issue #972 / #977 T1). Carries the basis-space Gram
1397/// `g` (`m_i × m_j`) AND the per-pair frame output factor `w = U_iᵀ U_j`
1398/// (`r_i × r_j`); the contributed Hessian sub-block is the Kronecker product
1399/// `g ⊗ w`.
1400#[derive(Debug, Clone)]
1401pub struct FactoredFrameGBlock {
1402 /// Atom index of the row factor (selects rank `r_i` and β offset).
1403 pub atom_i: usize,
1404 /// Atom index of the column factor (selects rank `r_j` and β offset).
1405 pub atom_j: usize,
1406 /// Basis-space coupling `G_{ij}` (`m_i × m_j`).
1407 pub g: Array2<f64>,
1408 /// Frame output factor `U_iᵀ U_j` (`r_i × r_j`). For `i == j` with an
1409 /// orthonormal frame this is `I_{r_i}` (the clean within-atom `g ⊗ I_r`
1410 /// collapse); across atoms it is the dense principal-angle cosine matrix
1411 /// between the two frames.
1412 pub w: Array2<f64>,
1413}
1414
1415/// Frame-factored data-fit Gauss–Newton β-Hessian operator (#972 / #977 T1):
1416/// the `Σ_k M_k·r_k` reduced-border analogue of [`SparseBlockKroneckerPenaltyOp`].
1417///
1418/// When every atom's decoder `B_k = C_k U_kᵀ` is profiled onto a Grassmann
1419/// frame `U_k ∈ St(p, r_k)`, the border carries only the shape coefficients
1420/// `C_k` (`M_k · r_k` entries) instead of the full `B_k` (`M_k · p`). The data
1421/// Gram in this reduced space is, for the isotropic likelihood,
1422/// `H[(i,li,a),(j,lj,b)] = G_{ij}[li,lj] · (U_iᵀ U_j)[a,b]` — within an atom the
1423/// orthonormal frame gives `U_iᵀU_i = I_{r_i}` and the block is the clean
1424/// `G ⊗ I_r` collapse; across co-active atoms the frames do not share a basis
1425/// so the output factor is the dense `U_iᵀU_j`.
1426///
1427/// The β layout is `μ`-major / frame-minor with a **variable** per-atom width
1428/// `r_k`: the index of (atom `k`, basis `li`, frame coord `a`) is
1429/// `offset[k] + li·r_k + a`, where `offset` is the prefix sum of `M_k · r_k`.
1430/// With every `r_k = p` and `U_k = I_p` this reproduces
1431/// [`SparseBlockKroneckerPenaltyOp`] exactly (a unit test pins the reduction),
1432/// so it is a strict generalization, not a separate code path.
1433pub struct FactoredFrameKroneckerOp {
1434 /// Per-atom frame rank `r_k` (the factored output width).
1435 pub ranks: Vec<usize>,
1436 /// Per-atom basis size `M_k`.
1437 pub basis_sizes: Vec<usize>,
1438 /// Per-atom β offset (prefix sum of `M_k · r_k`); `offsets[k]` is the start
1439 /// of atom `k`'s `C_k` block, `offsets[n_atoms]` the total dim.
1440 pub offsets: Vec<usize>,
1441 /// Total reduced β dimension `Σ_k M_k · r_k`.
1442 pub dim: usize,
1443 /// Non-empty co-occurring `(atom_i, atom_j)` blocks.
1444 pub blocks: Vec<FactoredFrameGBlock>,
1445}
1446
1447/// Frame output Gram `U_iᵀ U_j` (`r_i × r_j`) between two per-atom output
1448/// frames (each `p × r`). This is the dense principal-angle cosine matrix that
1449/// becomes the `w` factor of a [`FactoredFrameGBlock`]; for `i == j` with an
1450/// orthonormal frame it is `I_{r_i}`. Shared with
1451/// [`gam_terms::sae::manifold`], which builds the same factors when
1452/// profiling decoders onto Grassmann frames.
1453pub fn frame_output_gram(u_i: ArrayView2<f64>, u_j: ArrayView2<f64>) -> Array2<f64> {
1454 let (p_i, r_i) = u_i.dim();
1455 let (p_j, r_j) = u_j.dim();
1456 assert_eq!(
1457 p_i, p_j,
1458 "frame_output_gram: frames live in different ambient dims ({p_i} vs {p_j})"
1459 );
1460 let mut w = Array2::<f64>::zeros((r_i, r_j));
1461 for a in 0..r_i {
1462 for b in 0..r_j {
1463 let mut acc = 0.0;
1464 for c in 0..p_i {
1465 acc += u_i[[c, a]] * u_j[[c, b]];
1466 }
1467 w[[a, b]] = acc;
1468 }
1469 }
1470 w
1471}
1472
1473impl FactoredFrameKroneckerOp {
1474 /// Build from per-atom ranks + basis sizes and the co-occurring blocks.
1475 /// Computes the β offsets (prefix sum of `M_k·r_k`) and validates that each
1476 /// block's `g`/`w` shapes match the atoms' `(M, r)`.
1477 pub fn new(
1478 ranks: Vec<usize>,
1479 basis_sizes: Vec<usize>,
1480 blocks: Vec<FactoredFrameGBlock>,
1481 ) -> Result<Self, String> {
1482 if ranks.len() != basis_sizes.len() {
1483 return Err(format!(
1484 "FactoredFrameKroneckerOp: {} ranks but {} basis sizes",
1485 ranks.len(),
1486 basis_sizes.len()
1487 ));
1488 }
1489 let n_atoms = ranks.len();
1490 let mut offsets = Vec::with_capacity(n_atoms + 1);
1491 let mut acc = 0usize;
1492 for k in 0..n_atoms {
1493 offsets.push(acc);
1494 acc += basis_sizes[k] * ranks[k];
1495 }
1496 offsets.push(acc);
1497 let dim = acc;
1498 for blk in &blocks {
1499 if blk.atom_i >= n_atoms || blk.atom_j >= n_atoms {
1500 return Err(format!(
1501 "FactoredFrameKroneckerOp: block atom indices ({}, {}) out of range (n_atoms = {n_atoms})",
1502 blk.atom_i, blk.atom_j
1503 ));
1504 }
1505 if blk.g.dim() != (basis_sizes[blk.atom_i], basis_sizes[blk.atom_j]) {
1506 return Err(format!(
1507 "FactoredFrameKroneckerOp: block ({}, {}) g has shape {:?} but expected ({}, {})",
1508 blk.atom_i,
1509 blk.atom_j,
1510 blk.g.dim(),
1511 basis_sizes[blk.atom_i],
1512 basis_sizes[blk.atom_j]
1513 ));
1514 }
1515 if blk.w.dim() != (ranks[blk.atom_i], ranks[blk.atom_j]) {
1516 return Err(format!(
1517 "FactoredFrameKroneckerOp: block ({}, {}) w has shape {:?} but expected ({}, {})",
1518 blk.atom_i,
1519 blk.atom_j,
1520 blk.w.dim(),
1521 ranks[blk.atom_i],
1522 ranks[blk.atom_j]
1523 ));
1524 }
1525 }
1526 Ok(Self {
1527 ranks,
1528 basis_sizes,
1529 offsets,
1530 dim,
1531 blocks,
1532 })
1533 }
1534
1535 /// Convenience constructor that builds the operator directly from per-atom
1536 /// output frames + the basis-space Gram block map, computing the per-pair
1537 /// frame factors `W_ij = U_iᵀ U_j` itself.
1538 ///
1539 /// `frames[k]` is either `Some(U_k)` — a `p × r_k` (`r_k ≤ p`) output frame
1540 /// (a Grassmann representative `St(p, r_k)` need not be orthonormal here; the
1541 /// `W` factor carries whatever frame is supplied) — or `None`, meaning atom
1542 /// `k` keeps the full ambient output (`U_k = I_p`, so `r_k = p`). For each
1543 /// non-empty Gram block `(atom_i, atom_j)` the factor `W` is
1544 /// `U_iᵀ U_j` (`r_i × r_j`), with the `None` frame standing in for `I_p`:
1545 /// a framed×unframed cross gives `W = U_iᵀ` (`r_i × p`) and an unframed
1546 /// diagonal gives `W = I_p` — exactly reproducing the `g ⊗ I_p` full-`B`
1547 /// block. The resulting blocks are handed to [`Self::new`], which validates
1548 /// the `(M, r)` shapes and computes the β offsets.
1549 pub fn from_frames_and_blocks(
1550 frames: &[Option<Array2<f64>>],
1551 basis_sizes: &[usize],
1552 p: usize,
1553 g_blocks: &std::collections::BTreeMap<(usize, usize), Array2<f64>>,
1554 ) -> Result<Self, String> {
1555 if frames.len() != basis_sizes.len() {
1556 return Err(format!(
1557 "FactoredFrameKroneckerOp::from_frames_and_blocks: {} frames but {} basis sizes",
1558 frames.len(),
1559 basis_sizes.len()
1560 ));
1561 }
1562 let n_atoms = frames.len();
1563 // Per-atom rank: ncols of a supplied frame, else the ambient dim p.
1564 let mut ranks = Vec::with_capacity(n_atoms);
1565 for (k, frame) in frames.iter().enumerate() {
1566 match frame {
1567 Some(u) => {
1568 let (pr, r) = u.dim();
1569 if pr != p {
1570 return Err(format!(
1571 "FactoredFrameKroneckerOp::from_frames_and_blocks: frame {k} has {pr} rows but ambient dim is {p}"
1572 ));
1573 }
1574 if r > p {
1575 return Err(format!(
1576 "FactoredFrameKroneckerOp::from_frames_and_blocks: frame {k} has rank {r} > ambient dim {p}"
1577 ));
1578 }
1579 ranks.push(r);
1580 }
1581 None => ranks.push(p),
1582 }
1583 }
1584 // Materialize each atom's frame as a `p × r_k` view source: the supplied
1585 // `U_k`, or `I_p` for the unframed atoms.
1586 let identity = Array2::<f64>::eye(p);
1587 let frame_or_ident = |k: usize| -> ArrayView2<f64> {
1588 match &frames[k] {
1589 Some(u) => u.view(),
1590 None => identity.view(),
1591 }
1592 };
1593 let mut blocks = Vec::with_capacity(g_blocks.len());
1594 for (&(atom_i, atom_j), g) in g_blocks {
1595 if atom_i >= n_atoms || atom_j >= n_atoms {
1596 return Err(format!(
1597 "FactoredFrameKroneckerOp::from_frames_and_blocks: block atom indices ({atom_i}, {atom_j}) out of range (n_atoms = {n_atoms})"
1598 ));
1599 }
1600 let w = frame_output_gram(frame_or_ident(atom_i), frame_or_ident(atom_j));
1601 blocks.push(FactoredFrameGBlock {
1602 atom_i,
1603 atom_j,
1604 g: g.clone(),
1605 w,
1606 });
1607 }
1608 Self::new(ranks, basis_sizes.to_vec(), blocks)
1609 }
1610}
1611
1612impl BetaPenaltyOp for FactoredFrameKroneckerOp {
1613 fn dim(&self) -> usize {
1614 self.dim
1615 }
1616
1617 fn matvec(&self, x: &[f64], y: &mut [f64]) {
1618 for blk in &self.blocks {
1619 let r_i = self.ranks[blk.atom_i];
1620 let r_j = self.ranks[blk.atom_j];
1621 let off_i = self.offsets[blk.atom_i];
1622 let off_j = self.offsets[blk.atom_j];
1623 let (m_i, m_j) = blk.g.dim();
1624 for li in 0..m_i {
1625 let yi_base = off_i + li * r_i;
1626 for lj in 0..m_j {
1627 let g = blk.g[[li, lj]];
1628 if g == 0.0 {
1629 continue;
1630 }
1631 let xj_base = off_j + lj * r_j;
1632 // y_block[li, a] += g · Σ_b w[a, b] · x_block[lj, b]
1633 for a in 0..r_i {
1634 let mut acc = 0.0;
1635 for b in 0..r_j {
1636 acc += blk.w[[a, b]] * x[xj_base + b];
1637 }
1638 y[yi_base + a] += g * acc;
1639 }
1640 }
1641 }
1642 }
1643 }
1644
1645 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
1646 self.matvec(beta, out);
1647 }
1648
1649 fn diagonal(&self, diag: &mut [f64]) {
1650 for blk in &self.blocks {
1651 // Only on-diagonal atom blocks carry diagonal mass; the entry at
1652 // (atom k, basis li, coord a) is g[li,li]·w[a,a].
1653 if blk.atom_i != blk.atom_j {
1654 continue;
1655 }
1656 let r = self.ranks[blk.atom_i];
1657 let off = self.offsets[blk.atom_i];
1658 let (m_i, m_j) = blk.g.dim();
1659 let m = m_i.min(m_j);
1660 for li in 0..m {
1661 let gii = blk.g[[li, li]];
1662 let base = off + li * r;
1663 for a in 0..r {
1664 diag[base + a] += gii * blk.w[[a, a]];
1665 }
1666 }
1667 }
1668 }
1669
1670 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
1671 // Dense sub-block over the β index range `offsets[id.0]`. Mirror the
1672 // global (i,a) ↔ (j,b) coupling, keeping only indices inside the range.
1673 let range = &offsets[id.0];
1674 let b_dim = range.end - range.start;
1675 for blk in &self.blocks {
1676 let r_i = self.ranks[blk.atom_i];
1677 let r_j = self.ranks[blk.atom_j];
1678 let off_i = self.offsets[blk.atom_i];
1679 let off_j = self.offsets[blk.atom_j];
1680 let (m_i, m_j) = blk.g.dim();
1681 for li in 0..m_i {
1682 for a in 0..r_i {
1683 let gi = off_i + li * r_i + a;
1684 if gi < range.start || gi >= range.end {
1685 continue;
1686 }
1687 let bi = gi - range.start;
1688 for lj in 0..m_j {
1689 let g = blk.g[[li, lj]];
1690 if g == 0.0 {
1691 continue;
1692 }
1693 for b in 0..r_j {
1694 let gj = off_j + lj * r_j + b;
1695 if gj < range.start || gj >= range.end {
1696 continue;
1697 }
1698 let bj = gj - range.start;
1699 if bi < b_dim && bj < b_dim {
1700 out[[bi, bj]] += g * blk.w[[a, b]];
1701 }
1702 }
1703 }
1704 }
1705 }
1706 }
1707 }
1708
1709 fn to_dense(&self) -> Array2<f64> {
1710 let mut out = Array2::<f64>::zeros((self.dim, self.dim));
1711 for blk in &self.blocks {
1712 let r_i = self.ranks[blk.atom_i];
1713 let r_j = self.ranks[blk.atom_j];
1714 let off_i = self.offsets[blk.atom_i];
1715 let off_j = self.offsets[blk.atom_j];
1716 let (m_i, m_j) = blk.g.dim();
1717 for li in 0..m_i {
1718 for lj in 0..m_j {
1719 let g = blk.g[[li, lj]];
1720 if g == 0.0 {
1721 continue;
1722 }
1723 for a in 0..r_i {
1724 let gi = off_i + li * r_i + a;
1725 for b in 0..r_j {
1726 let gj = off_j + lj * r_j + b;
1727 out[[gi, gj]] += g * blk.w[[a, b]];
1728 }
1729 }
1730 }
1731 }
1732 }
1733 out
1734 }
1735
1736 fn fingerprint(&self, hasher: &mut Fingerprinter) {
1737 hasher.write_str("factored-frame-kronecker-op-v1");
1738 hasher.write_usize(self.dim);
1739 for &r in &self.ranks {
1740 hasher.write_usize(r);
1741 }
1742 for &m in &self.basis_sizes {
1743 hasher.write_usize(m);
1744 }
1745 hasher.write_usize(self.blocks.len());
1746 for blk in &self.blocks {
1747 hasher.write_usize(blk.atom_i);
1748 hasher.write_usize(blk.atom_j);
1749 hasher.write_f64_array2(&blk.g);
1750 hasher.write_f64_array2(&blk.w);
1751 }
1752 }
1753}
1754
1755/// Composite penalty: sum of multiple `BetaPenaltyOp` operators.
1756pub struct CompositePenaltyOp {
1757 /// Full β dimension `K`.
1758 pub k: usize,
1759 /// Component operators, each contributing additively.
1760 pub ops: Vec<Arc<dyn BetaPenaltyOp>>,
1761}
1762
1763impl BetaPenaltyOp for CompositePenaltyOp {
1764 fn dim(&self) -> usize {
1765 self.k
1766 }
1767
1768 fn matvec(&self, x: &[f64], y: &mut [f64]) {
1769 // The reduced-Schur PCG matvec applies this composite ONCE PER CG
1770 // ITERATION as the penalty prologue `y += (H_ββ) x`. At the K=32k
1771 // manifold-SAE border the composite is a leading run of per-atom
1772 // Kronecker smooth penalties (`λ S_k ⊗ I_{r_k}`, one per atom, over
1773 // DISJOINT β blocks) followed by the cross-atom data-fit op and any
1774 // dense analytic tail — and this whole sum ran SERIALLY while the
1775 // point-elimination row term already fanned across all cores, so it was
1776 // the prologue's Amdahl ceiling on the wide border.
1777 //
1778 // Fan the leading run of mutually-disjoint, sorted, contiguous-or-gapped
1779 // output-range operators across rayon workers: each writes ONLY its own
1780 // `y[start..end]` sub-slice (no cross-thread aliasing), then the
1781 // remaining (`None`-range / overlapping) operators run SERIALLY in
1782 // original order. Because every prefix index is touched by exactly one
1783 // prefix operator and all prefix work happens-before the serial tail,
1784 // each output index accumulates in the SAME order as the fully-serial
1785 // loop — the result is BIT-IDENTICAL, not merely deterministic. Stay
1786 // serial when already inside a rayon worker (the topology race / nested
1787 // matvec) to avoid oversubscription — the same guard the row loop uses.
1788 let mut prefix_len = 0usize;
1789 let mut prev_end = 0usize;
1790 if rayon::current_thread_index().is_none() {
1791 for op in &self.ops {
1792 match op.output_range() {
1793 Some(r) if r.start >= prev_end && r.end > r.start && r.end <= y.len() => {
1794 prev_end = r.end;
1795 prefix_len += 1;
1796 }
1797 _ => break,
1798 }
1799 }
1800 }
1801 // Only worth the fan-out when there is real disjoint work: at least two
1802 // blocks and a covered width past the same border threshold the dense
1803 // prologue uses. Otherwise fall through to the plain serial sum.
1804 if prefix_len >= 2 && prev_end >= SCHUR_PROLOGUE_PARALLEL_K_MIN {
1805 use rayon::prelude::*;
1806 // Carve `y` into one mutable sub-slice per prefix operator, skipping
1807 // any gaps between ranges. Sorted, non-overlapping ranges make this
1808 // a single left-to-right walk of `split_at_mut`.
1809 let mut subslices: Vec<&mut [f64]> = Vec::with_capacity(prefix_len);
1810 {
1811 let mut consumed = 0usize;
1812 let mut rest: &mut [f64] = y;
1813 for op in &self.ops[..prefix_len] {
1814 let r = op.output_range().expect("prefix op has an output range");
1815 let (_, after_gap) = rest.split_at_mut(r.start - consumed);
1816 let (block, tail) = after_gap.split_at_mut(r.end - r.start);
1817 subslices.push(block);
1818 rest = tail;
1819 consumed = r.end;
1820 }
1821 }
1822 self.ops[..prefix_len]
1823 .par_iter()
1824 .zip(subslices.par_iter_mut())
1825 .for_each(|(op, y_local)| op.matvec_local(x, y_local));
1826 for op in &self.ops[prefix_len..] {
1827 op.matvec(x, y);
1828 }
1829 } else {
1830 for op in &self.ops {
1831 op.matvec(x, y);
1832 }
1833 }
1834 }
1835
1836 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
1837 for op in &self.ops {
1838 op.gradient(beta, out);
1839 }
1840 }
1841
1842 fn diagonal(&self, diag: &mut [f64]) {
1843 for op in &self.ops {
1844 op.diagonal(diag);
1845 }
1846 }
1847
1848 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
1849 for op in &self.ops {
1850 op.block(id, offsets, out);
1851 }
1852 }
1853
1854 fn to_dense(&self) -> Array2<f64> {
1855 let mut out = Array2::<f64>::zeros((self.k, self.k));
1856 for op in &self.ops {
1857 let dense = op.to_dense();
1858 out += &dense;
1859 }
1860 out
1861 }
1862
1863 fn fingerprint(&self, hasher: &mut Fingerprinter) {
1864 hasher.write_str("composite-penalty-op-v1");
1865 hasher.write_usize(self.k);
1866 hasher.write_usize(self.ops.len());
1867 for op in &self.ops {
1868 op.fingerprint(hasher);
1869 }
1870 }
1871}
1872
1873/// Adapts a closure-based matrix-free `H_ββ` operator (from
1874/// [`ArrowSchurSystem::set_shared_beta_operator`]) to the `BetaPenaltyOp` trait.
1875///
1876/// `diagonal` holds the precomputed `diag(H_ββ)` supplied alongside the matvec;
1877/// `to_dense` falls back to probing all `K` canonical basis vectors.
1878pub struct MatvecDiagPenaltyOp {
1879 pub(crate) k: usize,
1880 pub(crate) matvec: SharedBetaMatvec,
1881 pub(crate) diagonal_vec: Array1<f64>,
1882}
1883
1884impl MatvecDiagPenaltyOp {
1885 pub fn new(k: usize, matvec: SharedBetaMatvec, diagonal_vec: Array1<f64>) -> Self {
1886 assert_eq!(diagonal_vec.len(), k);
1887 Self {
1888 k,
1889 matvec,
1890 diagonal_vec,
1891 }
1892 }
1893}
1894
1895impl BetaPenaltyOp for MatvecDiagPenaltyOp {
1896 fn dim(&self) -> usize {
1897 self.k
1898 }
1899
1900 fn matvec(&self, x: &[f64], y: &mut [f64]) {
1901 let x_arr = Array1::from_iter(x.iter().copied());
1902 let mut out = Array1::<f64>::zeros(self.k);
1903 (self.matvec)(x_arr.view(), &mut out);
1904 for a in 0..self.k {
1905 y[a] += out[a];
1906 }
1907 }
1908
1909 fn gradient(&self, beta: &[f64], out: &mut [f64]) {
1910 let beta_arr = Array1::from_iter(beta.iter().copied());
1911 let mut hb = Array1::<f64>::zeros(self.k);
1912 (self.matvec)(beta_arr.view(), &mut hb);
1913 for a in 0..self.k {
1914 out[a] += hb[a];
1915 }
1916 }
1917
1918 fn diagonal(&self, diag: &mut [f64]) {
1919 for j in 0..self.k.min(diag.len()) {
1920 diag[j] += self.diagonal_vec[j];
1921 }
1922 }
1923
1924 fn block(&self, id: BetaBlockId, offsets: &[Range<usize>], out: &mut Array2<f64>) {
1925 // Probe each basis vector in the block range to extract the sub-block.
1926 let range = &offsets[id.0];
1927 let b = range.end - range.start;
1928 let mut probe = Array1::<f64>::zeros(self.k);
1929 for bj in 0..b {
1930 probe.fill(0.0);
1931 probe[range.start + bj] = 1.0;
1932 let mut col = Array1::<f64>::zeros(self.k);
1933 (self.matvec)(probe.view(), &mut col);
1934 for bi in 0..b {
1935 out[[bi, bj]] += col[range.start + bi];
1936 }
1937 }
1938 }
1939
1940 fn to_dense(&self) -> Array2<f64> {
1941 let k = self.k;
1942 let mut out = Array2::<f64>::zeros((k, k));
1943 let mut probe = Array1::<f64>::zeros(k);
1944 for j in 0..k {
1945 probe.fill(0.0);
1946 probe[j] = 1.0;
1947 let mut col = Array1::<f64>::zeros(k);
1948 (self.matvec)(probe.view(), &mut col);
1949 for i in 0..k {
1950 out[[i, j]] = col[i];
1951 }
1952 }
1953 out
1954 }
1955
1956 fn fingerprint(&self, hasher: &mut Fingerprinter) {
1957 // The matvec closure cannot be hashed by content; the precomputed
1958 // diagonal is the operator's stable defining proxy (it is recomputed
1959 // alongside the matvec each time the operator is installed).
1960 hasher.write_str("matvec-diag-penalty-op-v1");
1961 hasher.write_usize(self.k);
1962 for &value in self.diagonal_vec.iter() {
1963 hasher.write_f64(value);
1964 }
1965 }
1966}