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    /// 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}