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