Skip to main content

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}