Skip to main content

gam_terms/analytic_penalties/
orthogonality.rs

1use super::*;
2use ndarray::s;
3
4// ---------------------------------------------------------------------------
5// Block-orthogonality penalty
6// ---------------------------------------------------------------------------
7
8/// Between-block-only orthogonality on a row-major matrix-valued latent
9/// block.
10///
11/// Lives on the extension-coordinate tier. Penalizes the squared Frobenius
12/// norm of the between-block Gram matrices, where `T` is the row-major
13/// `n_eff × latent_dim` view of the target slice and `groups` partitions
14/// the latent axes into disjoint subsets:
15///
16/// ```text
17///   P(T) = ½ · w · Σ_{g < h} ‖ T[:, group_g]^T T[:, group_h] ‖²_F
18/// ```
19///
20/// Within-block structure is unconstrained: this penalty only pushes different
21/// groups into mutually orthogonal subspaces. In the SAE objective it is the
22/// block-level separability / gauge term for latent decompositions where known
23/// or supervised coordinates should not leak into free coordinates.
24///
25/// Typical use: gauge-fixing a latent decomposition where one block has been
26/// supervised (e.g. anchored to known coordinates) and a free block needs to
27/// inhabit the orthogonal complement of that supervision. Pair with per-block
28/// ARD or sparsity when you also want within-block axis selection.
29///
30/// Gotchas:
31///
32/// * `groups` must be a true partition of all latent axes: every axis appears
33///   exactly once, and at least two groups are required.
34/// * The Hessian is dense across rows and axes even though an exact diagonal is
35///   available for diagnostics/preconditioning. Use the HVP for the full
36///   Newton curvature.
37#[derive(Debug, Clone)]
38pub struct BlockOrthogonalityPenalty {
39    pub target: PsiSlice,
40    pub groups: Vec<Vec<usize>>,
41    /// Base strength. If `learnable_weight` is true, the resolved strength is
42    /// `weight * exp(rho[rho_index])`; otherwise it is fixed at `weight`.
43    pub weight: f64,
44    /// Number of rows in the row-major matrix-valued latent block.
45    pub n_eff: usize,
46    pub learnable_weight: bool,
47    pub rho_index: usize,
48    pub weight_schedule: Option<ScalarWeightSchedule>,
49}
50
51impl BlockOrthogonalityPenalty {
52    #[must_use = "build error must be handled"]
53    pub fn new(
54        target: PsiSlice,
55        groups: Vec<Vec<usize>>,
56        weight: f64,
57        n_eff: usize,
58        learnable_weight: bool,
59    ) -> Result<Self, String> {
60        if target.is_empty() {
61            return Err("BlockOrthogonalityPenalty::new requires a non-empty target".to_string());
62        }
63        if !(weight.is_finite() && weight > 0.0) {
64            return Err(format!(
65                "BlockOrthogonalityPenalty::new requires finite weight > 0, got {weight}"
66            ));
67        }
68        if n_eff == 0 {
69            return Err("BlockOrthogonalityPenalty::new requires n_eff > 0".to_string());
70        }
71        if !target.len().is_multiple_of(n_eff) {
72            return Err(format!(
73                "BlockOrthogonalityPenalty::new target length {} is not divisible by n_eff {}",
74                target.len(),
75                n_eff
76            ));
77        }
78        let latent_dim = target.len() / n_eff;
79        if let Some(expected_dim) = target.latent_dim {
80            let expected = n_eff.checked_mul(expected_dim).ok_or_else(|| {
81                "BlockOrthogonalityPenalty::new target shape overflows usize".to_string()
82            })?;
83            if expected != target.len() {
84                return Err(format!(
85                    "BlockOrthogonalityPenalty::new target length {} does not match n_eff {} × latent_dim {}",
86                    target.len(),
87                    n_eff,
88                    expected_dim
89                ));
90            }
91        }
92        if groups.len() < 2 {
93            return Err("BlockOrthogonalityPenalty::new requires at least two groups".to_string());
94        }
95        let mut seen = vec![false; latent_dim];
96        for (group_idx, group) in groups.iter().enumerate() {
97            if group.is_empty() {
98                return Err(format!(
99                    "BlockOrthogonalityPenalty::new groups[{group_idx}] must not be empty"
100                ));
101            }
102            for &axis in group {
103                if axis >= latent_dim {
104                    return Err(format!(
105                        "BlockOrthogonalityPenalty::new groups[{group_idx}] axis {axis} exceeds latent_dim {latent_dim}"
106                    ));
107                }
108                if seen[axis] {
109                    return Err(format!(
110                        "BlockOrthogonalityPenalty::new axis {axis} appears in more than one group"
111                    ));
112                }
113                seen[axis] = true;
114            }
115        }
116        for (axis, present) in seen.iter().copied().enumerate() {
117            if !present {
118                return Err(format!(
119                    "BlockOrthogonalityPenalty::new groups must partition latent axes; missing axis {axis}"
120                ));
121            }
122        }
123        Ok(Self {
124            target,
125            groups,
126            weight,
127            n_eff,
128            learnable_weight,
129            rho_index: 0,
130            weight_schedule: None,
131        })
132    }
133
134    impl_with_weight_schedule!(weight);
135
136    fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
137        if self.learnable_weight {
138            validated_learnable_weight(self.weight, rho[self.rho_index])
139        } else {
140            self.weight
141        }
142    }
143
144    fn latent_dim(&self, target_len: usize) -> Option<usize> {
145        if self.n_eff == 0 || !target_len.is_multiple_of(self.n_eff) {
146            assert_eq!(
147                target_len % self.n_eff.max(1),
148                0,
149                "target length must be divisible by n_eff"
150            );
151            return None;
152        }
153        Some(target_len / self.n_eff)
154    }
155
156    fn target_matrix<'a>(&self, target: ArrayView1<'a, f64>) -> Option<ArrayView2<'a, f64>> {
157        let d = self.latent_dim(target.len())?;
158        target.into_shape_with_order((self.n_eff, d)).ok()
159    }
160
161    fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
162        let n_obs = m.nrows();
163        let d = m.ncols();
164        let mut out = Array1::<f64>::zeros(n_obs * d);
165        for n in 0..n_obs {
166            for a in 0..d {
167                out[n * d + a] = m[[n, a]];
168            }
169        }
170        out
171    }
172
173    fn cross_gram(t: ArrayView2<'_, f64>, left: &[usize], right: &[usize]) -> Array2<f64> {
174        let mut out = Array2::<f64>::zeros((left.len(), right.len()));
175        for (li, &a) in left.iter().enumerate() {
176            for (ri, &b) in right.iter().enumerate() {
177                let mut s = 0.0;
178                for n in 0..t.nrows() {
179                    s += t[[n, a]] * t[[n, b]];
180                }
181                out[[li, ri]] = s;
182            }
183        }
184        out
185    }
186
187    /// `out[li, ri] = Σ_n a[n, left[li]] · b[n, right[ri]]` — two-argument
188    /// cross-gram used to assemble the directional derivative of `C_{gh}` in
189    /// direction `v`:  `∂_v C_{gh}[gi, hi] = Σ_n {v[n, axes_g[gi]] · t[n, axes_h[hi]] + t[n, axes_g[gi]] · v[n, axes_h[hi]]}`.
190    /// The `cross_gram` helper (single-input self-product) was previously
191    /// (mis)used for both terms, but `cross_gram(v, h, g) + cross_gram(t, h, g)`
192    /// is the unrelated quantity `(v⊗v) + (t⊗t)`, not `(v⊗t) + (t⊗v)`.
193    fn mixed_cross_gram(
194        a: ArrayView2<'_, f64>,
195        b: ArrayView2<'_, f64>,
196        left: &[usize],
197        right: &[usize],
198    ) -> Array2<f64> {
199        assert_eq!(a.nrows(), b.nrows(), "mixed_cross_gram row mismatch");
200        let mut out = Array2::<f64>::zeros((left.len(), right.len()));
201        for (li, &al) in left.iter().enumerate() {
202            for (ri, &br) in right.iter().enumerate() {
203                let mut s = 0.0;
204                for n in 0..a.nrows() {
205                    s += a[[n, al]] * b[[n, br]];
206                }
207                out[[li, ri]] = s;
208            }
209        }
210        out
211    }
212
213    fn add_right_times_cross(
214        out: &mut Array2<f64>,
215        right: ArrayView2<'_, f64>,
216        left_axes: &[usize],
217        right_axes: &[usize],
218        cross_right_left: ArrayView2<'_, f64>,
219        factor: f64,
220    ) {
221        assert_eq!(cross_right_left.dim(), (right_axes.len(), left_axes.len()));
222        for n in 0..out.nrows() {
223            for (li, &left_axis) in left_axes.iter().enumerate() {
224                let mut s = 0.0;
225                for (ri, &right_axis) in right_axes.iter().enumerate() {
226                    s += right[[n, right_axis]] * cross_right_left[[ri, li]];
227                }
228                out[[n, left_axis]] += factor * s;
229            }
230        }
231    }
232
233    fn hvp_with_precomputed_cross(
234        &self,
235        t: ArrayView2<'_, f64>,
236        cross: &[Vec<Option<Array2<f64>>>],
237        v: ArrayView2<'_, f64>,
238        weight: f64,
239    ) -> Array2<f64> {
240        assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
241        if v.dim() != t.dim() {
242            return Array2::<f64>::zeros(t.dim());
243        }
244        let mut out = Array2::<f64>::zeros(t.dim());
245        for g in 0..self.groups.len() {
246            let group_g = &self.groups[g];
247            for h in 0..self.groups.len() {
248                if g == h {
249                    continue;
250                }
251                let group_h = &self.groups[h];
252                let c_hg = cross[h][g]
253                    .as_ref()
254                    .expect("between-block cross Gram must be precomputed");
255                // Linear contribution: w · Σ_b C_{g,h}[i,b] · v[n, axes_h[b]] —
256                // the C-direct piece of d/dv (∂P/∂t).
257                Self::add_right_times_cross(&mut out, v, group_g, group_h, c_hg.view(), weight);
258
259                // Directional derivative of C_{hg} in direction v:
260                //   ∂_v C_{hg}[hi, gi] = Σ_n {v[n, axes_h[hi]] · t[n, axes_g[gi]]
261                //                            + t[n, axes_h[hi]] · v[n, axes_g[gi]]}
262                // = MixedCross(v, t, h, g) + MixedCross(t, v, h, g).
263                // The earlier formulation used `cross_gram(v, h, g) +
264                // cross_gram(t, h, g)`, which is `(v⊗v) + (t⊗t)` — quadratic in v
265                // (resp. independent of v) and unrelated to the JVP. The bug made
266                // the Hessian non-symmetric (it added a fixed `(t⊗t)`-driven term
267                // to every column), violated the gradient/Hessian consistency
268                // check that REML's spectral solve relies on, and the sibling
269                // `OrthogonalityPenalty::hvp_with_precomputed_m` already uses the
270                // correct `v_c · t_b + t_c · v_b` mixed pattern.
271                let dv_h_g = Self::mixed_cross_gram(v, t, group_h, group_g);
272                let tv_h_g = Self::mixed_cross_gram(t, v, group_h, group_g);
273                let mut d_c_hg = dv_h_g;
274                d_c_hg += &tv_h_g;
275                Self::add_right_times_cross(&mut out, t, group_g, group_h, d_c_hg.view(), weight);
276            }
277        }
278        out
279    }
280
281    fn precompute_cross(&self, t: ArrayView2<'_, f64>) -> Vec<Vec<Option<Array2<f64>>>> {
282        let mut cross = vec![vec![None; self.groups.len()]; self.groups.len()];
283        for g in 0..self.groups.len() {
284            for h in 0..self.groups.len() {
285                if g != h {
286                    cross[g][h] = Some(Self::cross_gram(t, &self.groups[g], &self.groups[h]));
287                }
288            }
289        }
290        cross
291    }
292
293    /// Materialize the between-block orthogonality Hessian for small-block
294    /// spectral paths.
295    pub fn as_dense(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array2<f64> {
296        let n = target.len();
297        let Some(t) = self.target_matrix(target) else {
298            return Array2::<f64>::zeros((n, n));
299        };
300        let cross = self.precompute_cross(t.view());
301        let weight = self.resolved_weight(rho);
302        let mut dense = Array2::<f64>::zeros((n, n));
303        let mut e = Array1::<f64>::zeros(n);
304        for j in 0..n {
305            e[j] = 1.0;
306            let Some(e_mat) = self.target_matrix(e.view()) else {
307                return Array2::<f64>::zeros((n, n));
308            };
309            let col = self.hvp_with_precomputed_cross(t.view(), &cross, e_mat, weight);
310            for i in 0..n {
311                dense[[i, j]] = col[[i / t.ncols(), i % t.ncols()]];
312            }
313            e[j] = 0.0;
314        }
315        dense
316    }
317}
318
319impl AnalyticPenalty for BlockOrthogonalityPenalty {
320    fn tier(&self) -> PenaltyTier {
321        PenaltyTier::Psi
322    }
323
324    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
325        let Some(t) = self.target_matrix(target) else {
326            return 0.0;
327        };
328        let mut acc = 0.0;
329        for g in 0..self.groups.len() {
330            for h in (g + 1)..self.groups.len() {
331                let c = Self::cross_gram(t.view(), &self.groups[g], &self.groups[h]);
332                for &v in c.iter() {
333                    acc += v * v;
334                }
335            }
336        }
337        // Each unordered pair appears exactly once because h starts at g+1.
338        // Thus this is (w/2) sum_{g<h} ||T_g^T T_h||_F^2.  Do not rewrite it
339        // as (w/2) sum_{g!=h}: that ordered-pair sum contains every term twice
340        // and therefore denotes a different public weight convention.
341        0.5 * self.resolved_weight(rho) * acc
342    }
343
344    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
345        let Some(t) = self.target_matrix(target) else {
346            return Array1::<f64>::zeros(target.len());
347        };
348        let cross = self.precompute_cross(t.view());
349        let weight = self.resolved_weight(rho);
350        let mut grad = Array2::<f64>::zeros(t.dim());
351        // Differentiating one unordered-pair term contributes once to each of
352        // its two blocks.  Iterating ordered (g,h), g != h, below assembles
353        // those two block derivatives; it does not double the scalar weight.
354        for g in 0..self.groups.len() {
355            for h in 0..self.groups.len() {
356                if g == h {
357                    continue;
358                }
359                let c_hg = cross[h][g]
360                    .as_ref()
361                    .expect("between-block cross Gram must be precomputed");
362                Self::add_right_times_cross(
363                    &mut grad,
364                    t.view(),
365                    &self.groups[g],
366                    &self.groups[h],
367                    c_hg.view(),
368                    weight,
369                );
370            }
371        }
372        Self::flatten_matrix(&grad)
373    }
374
375    fn hvp(
376        &self,
377        target: ArrayView1<'_, f64>,
378        rho: ArrayView1<'_, f64>,
379        v: ArrayView1<'_, f64>,
380    ) -> Array1<f64> {
381        assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
382        if target.len() != v.len() {
383            return Array1::<f64>::zeros(target.len());
384        }
385        let Some(t) = self.target_matrix(target) else {
386            return Array1::<f64>::zeros(target.len());
387        };
388        let Some(v_mat) = self.target_matrix(v) else {
389            return Array1::<f64>::zeros(target.len());
390        };
391        let cross = self.precompute_cross(t.view());
392        let hv = self.hvp_with_precomputed_cross(
393            t.view(),
394            &cross,
395            v_mat.view(),
396            self.resolved_weight(rho),
397        );
398        Self::flatten_matrix(&hv)
399    }
400
401    fn hessian_diag(
402        &self,
403        target: ArrayView1<'_, f64>,
404        rho: ArrayView1<'_, f64>,
405    ) -> Option<Array1<f64>> {
406        let t = self.target_matrix(target)?;
407        let n_obs = t.nrows();
408        let d = t.ncols();
409        let weight = self.resolved_weight(rho);
410        let mut group_of = vec![usize::MAX; d];
411        for (gi, group) in self.groups.iter().enumerate() {
412            for &axis in group {
413                group_of[axis] = gi;
414            }
415        }
416        let mut out = Array1::<f64>::zeros(n_obs * d);
417        for n in 0..n_obs {
418            let mut row_sq = 0.0_f64;
419            let mut group_sq = vec![0.0_f64; self.groups.len()];
420            for b in 0..d {
421                let v = t[[n, b]];
422                let v2 = v * v;
423                row_sq += v2;
424                group_sq[group_of[b]] += v2;
425            }
426            for a in 0..d {
427                let g = group_of[a];
428                out[n * d + a] = weight * (row_sq - group_sq[g]);
429            }
430        }
431        Some(out)
432    }
433
434    impl_learnable_weight_grad_rho!();
435
436    impl_learnable_weight_rho_count!();
437    impl_learnable_weight_domain!(weight);
438
439    fn name(&self) -> &str {
440        "block_orthogonality"
441    }
442
443    impl_scalar_apply_schedule!(weight);
444}
445
446// ---------------------------------------------------------------------------
447// Decoder column-space incoherence penalty
448// ---------------------------------------------------------------------------
449
450/// Cross-atom decoder column-space incoherence, restricted to co-activating
451/// atom pairs (issue #671).
452///
453/// Lives on the β tier and targets the flat SAE decoder coefficient block. The
454/// β layout concatenates the per-atom decoder blocks in atom order: atom `k`
455/// owns `M_k · p_out` coefficients, stored as
456/// `β[off_k + a·p_out + o]` for basis row `a` and output feature `o`.
457/// The stored block is `B_k ∈ ℝ^{M_k × p_out}` with rows `B_k[a, :]`
458/// representing decoder directions in output space.
459///
460/// The penalty is the co-activation-masked cross-column-space overlap
461///
462/// ```text
463///   P = ½ · w · Σ_{j<k} W[j,k] · ‖B_j B_k^T‖²_F,
464///   W[j,k] = ½ · (coactivation[j,k] + coactivation[k,j]).
465/// ```
466///
467/// `coactivation[j,k]` is the mean over observations of
468/// `gate[n,j] · gate[n,k]`; pairs that never co-fire (`W[j,k] = 0`) contribute
469/// nothing. In the SAE objective this is the separability lever: atoms that
470/// are active on the same examples are discouraged from spanning the same
471/// decoder output directions, while unrelated atoms are not pushed apart just
472/// because they both exist in the dictionary.
473///
474/// The Hessian used here is the Gauss-Newton (positive-semidefinite) curvature
475/// of the Frobenius objective in `C`, dropping the indefinite second-order term
476/// in `C`. This keeps the β-tier Newton / PIRLS curvature block PSD, matching
477/// the other quadratic-on-Gram penalties.
478///
479/// Gotchas:
480///
481/// * `block_sizes` are decoder basis-row counts `M_k`, not output widths;
482///   every atom shares the same `p_out`. Stored decoder blocks are
483///   `(M_k, p_out)`, so `B_j B_k^T` is the cross-Gram of decoder directions in
484///   output space and remains well-defined for heterogeneous `M_k`.
485/// * The descriptor path builds a placeholder penalty; live SAE wiring replaces
486///   the co-activation matrix with the current mean gate products.
487/// * Offsets are interpreted against the vector passed to this penalty. In the
488///   SAE decoder-incoherence path the registered target slice is zero-based;
489///   callers using an already sliced target view must keep that convention.
490#[derive(Debug, Clone)]
491pub struct DecoderIncoherencePenalty {
492    pub target: PsiSlice,
493    /// Per-atom decoder basis-function counts `M_k`. The atom blocks are laid
494    /// out contiguously in β order; `Σ_k M_k·p_out == target.len()`.
495    pub block_sizes: Vec<usize>,
496    /// Output / feature dimension `p_out` (decoder column count, shared by all
497    /// atoms).
498    pub p_out: usize,
499    /// Atom count `K`. The operator only stores the SPARSE list of penalized
500    /// atom pairs (`pairs`), not the dense `K×K` co-activation matrix — at
501    /// `K = 32768` that dense matrix is 8 GiB. Every consumer of this operator
502    /// already skipped pairs whose symmetrized weight is `0`, so storing only
503    /// the nonzero pairs is exactly equivalent to the dense matrix while being
504    /// linear in the number of co-active / near-collinear pairs (#1026).
505    pub k_atoms: usize,
506    /// Sparse penalized atom pairs `(j, k, w)` with `j < k` and the symmetrized
507    /// weight `w = ½·(W[j,k] + W[k,j]) > 0` (this is exactly the value the old
508    /// `pair_weight(j, k)` returned). Pairs with `w == 0` are omitted; the dense
509    /// operator skipped them, so results are byte-identical.
510    pub pairs: Vec<(usize, usize, f64)>,
511    /// Base strength. If `learnable_weight` is true the resolved strength is
512    /// `weight·exp(rho[rho_index])`; otherwise it is fixed at `weight`.
513    pub weight: f64,
514    pub learnable_weight: bool,
515    pub rho_index: usize,
516    pub weight_schedule: Option<ScalarWeightSchedule>,
517}
518
519struct PreparedCoherencePair {
520    left: std::ops::Range<usize>,
521    right: std::ops::Range<usize>,
522    coefficient: f64,
523    geometry: normalized_gram::NormalizedCrossGram,
524}
525
526/// Immutable geometry of the live normalized decoder-coherence prior. One
527/// preparation supplies the omitted signed curvature, its exact diagonal and
528/// the full prior's bilinear theta derivative, including the live normalizers.
529pub struct PreparedDecoderIncoherence {
530    dimension: usize,
531    pairs: Vec<PreparedCoherencePair>,
532}
533
534impl PreparedDecoderIncoherence {
535    fn pair_direction(pair: &PreparedCoherencePair, direction: &[f64]) -> Array1<f64> {
536        Array1::from_iter(
537            direction[pair.left.clone()]
538                .iter()
539                .chain(direction[pair.right.clone()].iter())
540                .copied(),
541        )
542    }
543
544    fn scatter(pair: &PreparedCoherencePair, local: ArrayView1<'_, f64>, out: &mut [f64]) {
545        for (i, destination) in pair.left.clone().chain(pair.right.clone()).enumerate() {
546            out[destination] += pair.coefficient * local[i];
547        }
548    }
549
550    pub fn remainder_action_add(&self, direction: &[f64], out: &mut [f64]) {
551        assert_eq!(direction.len(), self.dimension);
552        assert_eq!(out.len(), self.dimension);
553        for pair in &self.pairs {
554            // Dense block requests need only incident edges; do not rebuild
555            // every pair's matrix products for a direction supported on one atom.
556            if pair
557                .left
558                .clone()
559                .chain(pair.right.clone())
560                .all(|i| direction[i] == 0.0)
561            {
562                continue;
563            }
564            let local = Self::pair_direction(pair, direction);
565            let delta = pair.geometry.hessian_action(local.view())
566                - pair.geometry.gauss_newton_action(local.view());
567            Self::scatter(pair, delta.view(), out);
568        }
569    }
570
571    pub fn remainder_diagonal_add(&self, out: &mut [f64]) {
572        assert_eq!(out.len(), self.dimension);
573        for pair in &self.pairs {
574            let delta = pair.geometry.diagonal() - pair.geometry.gauss_newton_diagonal();
575            Self::scatter(pair, delta.view(), out);
576        }
577    }
578
579    pub fn theta_bilinear_add(&self, exact: bool, left: &[f64], right: &[f64], out: &mut [f64]) {
580        assert_eq!(left.len(), self.dimension);
581        assert_eq!(right.len(), self.dimension);
582        assert_eq!(out.len(), self.dimension);
583        for pair in &self.pairs {
584            let l = Self::pair_direction(pair, left);
585            let r = Self::pair_direction(pair, right);
586            let local = if exact {
587                pair.geometry.third_bilinear(l.view(), r.view())
588            } else {
589                pair.geometry
590                    .gauss_newton_bilinear_gradient(l.view(), r.view())
591            };
592            Self::scatter(pair, local.view(), out);
593        }
594    }
595}
596
597impl DecoderIncoherencePenalty {
598    pub fn prepare_curvature(
599        &self,
600        target: ArrayView1<'_, f64>,
601        rho: ArrayView1<'_, f64>,
602    ) -> PreparedDecoderIncoherence {
603        assert_eq!(target.len(), self.target.len());
604        let offsets = self.block_offsets();
605        let weight = self.resolved_weight(rho);
606        let pairs = self
607            .pairs
608            .iter()
609            .filter_map(|&(j, k, pair_weight)| {
610                if pair_weight == 0.0 || weight == 0.0 {
611                    return None;
612                }
613                let left = offsets[j]..offsets[j] + self.block_sizes[j] * self.p_out;
614                let right = offsets[k]..offsets[k] + self.block_sizes[k] * self.p_out;
615                let left_matrix = target
616                    .slice(s![left.clone()])
617                    .into_shape_with_order((self.block_sizes[j], self.p_out))
618                    .expect("validated left block span matches its decoder shape");
619                let right_matrix = target
620                    .slice(s![right.clone()])
621                    .into_shape_with_order((self.block_sizes[k], self.p_out))
622                    .expect("validated right block span matches its decoder shape");
623                let geometry = normalized_gram::NormalizedCrossGram::new(
624                    left_matrix,
625                    right_matrix,
626                    normalized_gram::GramNormalization::DecoderNorm,
627                )?;
628                Some(PreparedCoherencePair {
629                    left,
630                    right,
631                    coefficient: 0.5 * weight * pair_weight,
632                    geometry,
633                })
634            })
635            .collect();
636        PreparedDecoderIncoherence {
637            dimension: target.len(),
638            pairs,
639        }
640    }
641
642    #[must_use = "build error must be handled"]
643    pub fn new(
644        target: PsiSlice,
645        block_sizes: Vec<usize>,
646        p_out: usize,
647        coactivation: Array2<f64>,
648        weight: f64,
649        learnable_weight: bool,
650    ) -> Result<Self, String> {
651        if target.is_empty() {
652            return Err("DecoderIncoherencePenalty::new requires a non-empty target".to_string());
653        }
654        if !(weight.is_finite() && weight > 0.0) {
655            return Err(format!(
656                "DecoderIncoherencePenalty::new requires finite weight > 0, got {weight}"
657            ));
658        }
659        if p_out == 0 {
660            return Err("DecoderIncoherencePenalty::new requires p_out > 0".to_string());
661        }
662        if block_sizes.len() < 2 {
663            return Err(
664                "DecoderIncoherencePenalty::new requires at least two atom blocks".to_string(),
665            );
666        }
667        let k = block_sizes.len();
668        if coactivation.dim() != (k, k) {
669            return Err(format!(
670                "DecoderIncoherencePenalty::new requires (K, K)=({k}, {k}) coactivation; got {:?}",
671                coactivation.dim()
672            ));
673        }
674        if !coactivation
675            .iter()
676            .all(|value| value.is_finite() && *value >= 0.0)
677        {
678            return Err(
679                "DecoderIncoherencePenalty::new requires finite non-negative coactivation entries"
680                    .to_string(),
681            );
682        }
683        let mut total = 0usize;
684        for (atom_idx, &m) in block_sizes.iter().enumerate() {
685            if m == 0 {
686                return Err(format!(
687                    "DecoderIncoherencePenalty::new block_sizes[{atom_idx}] must be > 0"
688                ));
689            }
690            let span = m.checked_mul(p_out).ok_or_else(|| {
691                "DecoderIncoherencePenalty::new block span overflows usize".to_string()
692            })?;
693            total = total.checked_add(span).ok_or_else(|| {
694                "DecoderIncoherencePenalty::new total span overflows usize".to_string()
695            })?;
696        }
697        if total != target.len() {
698            return Err(format!(
699                "DecoderIncoherencePenalty::new Σ_k M_k·p_out = {total} does not match target length {}",
700                target.len()
701            ));
702        }
703        // Sparsify: store only the upper-triangular pairs whose symmetrized
704        // weight `½·(W[j,k]+W[k,j])` is nonzero. The dense operator skipped every
705        // pair with a zero symmetrized weight, so the sparse list reproduces it
706        // bit-for-bit while never materializing the dense `K×K` matrix downstream.
707        let mut pairs = Vec::new();
708        for j in 0..k {
709            for kk in (j + 1)..k {
710                let w = 0.5 * (coactivation[[j, kk]] + coactivation[[kk, j]]);
711                if w != 0.0 {
712                    pairs.push((j, kk, w));
713                }
714            }
715        }
716        Ok(Self {
717            target,
718            block_sizes,
719            p_out,
720            k_atoms: k,
721            pairs,
722            weight,
723            learnable_weight,
724            rho_index: 0,
725            weight_schedule: None,
726        })
727    }
728
729    /// Sparse-pair constructor used by the SAE live wiring (#1026): build the
730    /// operator directly from a list of penalized atom pairs `(j, k, w)` with
731    /// `j < k` and the symmetrized per-pair weight `w` (exactly the value the old
732    /// dense `pair_weight(j, k)` returned), avoiding any dense `K×K` allocation.
733    /// `w == 0` pairs and out-of-range indices are dropped / rejected. This is
734    /// equivalent to [`Self::new`] fed the dense symmetric matrix with the same
735    /// nonzero entries.
736    #[must_use = "build error must be handled"]
737    pub fn new_sparse(
738        target: PsiSlice,
739        block_sizes: Vec<usize>,
740        p_out: usize,
741        pairs: Vec<(usize, usize, f64)>,
742        weight: f64,
743        learnable_weight: bool,
744    ) -> Result<Self, String> {
745        if target.is_empty() {
746            return Err(
747                "DecoderIncoherencePenalty::new_sparse requires a non-empty target".to_string(),
748            );
749        }
750        if !(weight.is_finite() && weight > 0.0) {
751            return Err(format!(
752                "DecoderIncoherencePenalty::new_sparse requires finite weight > 0, got {weight}"
753            ));
754        }
755        if p_out == 0 {
756            return Err("DecoderIncoherencePenalty::new_sparse requires p_out > 0".to_string());
757        }
758        if block_sizes.len() < 2 {
759            return Err(
760                "DecoderIncoherencePenalty::new_sparse requires at least two atom blocks"
761                    .to_string(),
762            );
763        }
764        let k = block_sizes.len();
765        let mut total = 0usize;
766        for (atom_idx, &m) in block_sizes.iter().enumerate() {
767            if m == 0 {
768                return Err(format!(
769                    "DecoderIncoherencePenalty::new_sparse block_sizes[{atom_idx}] must be > 0"
770                ));
771            }
772            let span = m.checked_mul(p_out).ok_or_else(|| {
773                "DecoderIncoherencePenalty::new_sparse block span overflows usize".to_string()
774            })?;
775            total = total.checked_add(span).ok_or_else(|| {
776                "DecoderIncoherencePenalty::new_sparse total span overflows usize".to_string()
777            })?;
778        }
779        if total != target.len() {
780            return Err(format!(
781                "DecoderIncoherencePenalty::new_sparse Σ_k M_k·p_out = {total} does not match target length {}",
782                target.len()
783            ));
784        }
785        let mut clean = Vec::with_capacity(pairs.len());
786        for (j, kk, w) in pairs {
787            if j >= k || kk >= k {
788                return Err(format!(
789                    "DecoderIncoherencePenalty::new_sparse pair ({j}, {kk}) out of range K={k}"
790                ));
791            }
792            if j >= kk {
793                return Err(format!(
794                    "DecoderIncoherencePenalty::new_sparse requires j < k for each pair, got ({j}, {kk})"
795                ));
796            }
797            if !(w.is_finite() && w >= 0.0) {
798                return Err(format!(
799                    "DecoderIncoherencePenalty::new_sparse requires finite non-negative pair weight, got {w}"
800                ));
801            }
802            if w != 0.0 {
803                clean.push((j, kk, w));
804            }
805        }
806        Ok(Self {
807            target,
808            block_sizes,
809            p_out,
810            k_atoms: k,
811            pairs: clean,
812            weight,
813            learnable_weight,
814            rho_index: 0,
815            weight_schedule: None,
816        })
817    }
818
819    impl_with_weight_schedule!(weight);
820
821    fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
822        if self.learnable_weight {
823            validated_learnable_weight(self.weight, rho[self.rho_index])
824        } else {
825            self.weight
826        }
827    }
828
829    /// Flat-β offset of atom `k`'s decoder block within the vector passed to
830    /// this penalty. SAE decoder-incoherence wiring registers a zero-based
831    /// target slice, so `target.range.start` is normally zero here.
832    fn block_offsets(&self) -> Vec<usize> {
833        let mut out = Vec::with_capacity(self.block_sizes.len());
834        let mut cursor = self.target.range.start;
835        for &m in &self.block_sizes {
836            out.push(cursor);
837            cursor += m * self.p_out;
838        }
839        out
840    }
841
842    /// Cross-Gram `C[a, b] = Σ_o B_j[a, o]·B_k[b, o]`, shape `(M_j, M_k)`.
843    fn cross_gram(
844        target: ArrayView1<'_, f64>,
845        off_j: usize,
846        m_j: usize,
847        off_k: usize,
848        m_k: usize,
849        p_out: usize,
850    ) -> Array2<f64> {
851        let mut out = Array2::<f64>::zeros((m_j, m_k));
852        for a in 0..m_j {
853            for b in 0..m_k {
854                let mut s = 0.0;
855                for o in 0..p_out {
856                    s += target[off_j + a * p_out + o] * target[off_k + b * p_out + o];
857                }
858                out[[a, b]] = s;
859            }
860        }
861        out
862    }
863
864    /// Squared Frobenius norm `‖B_x‖²_F = Σ_{a,o} B_x[a,o]²` of atom `x`'s decoder
865    /// block. #2343: the decoder-incoherence penalty is normalized by the LIVE
866    /// `‖B_j‖²_F·‖B_k‖²_F` so the penalized coherence is homogeneous degree-0 in
867    /// each decoder radius (radial derivative ≡ 0 by Euler) — the normalizer is
868    /// differentiated analytically in `grad_target`, never frozen.
869    fn block_norm_sq(target: ArrayView1<'_, f64>, off: usize, m: usize, p_out: usize) -> f64 {
870        let mut s = 0.0;
871        for i in 0..(m * p_out) {
872            let v = target[off + i];
873            s += v * v;
874        }
875        s
876    }
877
878    /// Shared kernel for the two curvature operators. Accumulates, per penalized
879    /// atom pair `(j, k)`, the Gauss-Newton term `W·Σ_b dC[a,b]·B_k[b,o]` (and
880    /// its `_k` transpose) always, and the residual term `W·Σ_b C[a,b]·V_k[b,o]`
881    /// (and `_k` transpose) only when `include_residual`. With the residual the
882    /// result is the exact `∂²P·v` ([`AnalyticPenalty::hvp`]); without it the
883    /// result is the PSD Gauss-Newton surrogate
884    /// ([`AnalyticPenalty::psd_majorizer_hvp`]).
885    fn hvp_impl(
886        &self,
887        target: ArrayView1<'_, f64>,
888        rho: ArrayView1<'_, f64>,
889        v: ArrayView1<'_, f64>,
890        include_residual: bool,
891    ) -> Array1<f64> {
892        let mut out = Array1::<f64>::zeros(target.len());
893        if target.len() != self.target.len() {
894            return out;
895        }
896        let offsets = self.block_offsets();
897        let weight = self.resolved_weight(rho);
898        let p_out = self.p_out;
899        for &(j, k, w_sym) in &self.pairs {
900            {
901                let w_pair = w_sym * weight;
902                if w_pair == 0.0 {
903                    continue;
904                }
905                let off_j = offsets[j];
906                let off_k = offsets[k];
907                let m_j = self.block_sizes[j];
908                let m_k = self.block_sizes[k];
909                // #2343 — curvature of the degree-0 normalized penalty
910                // `½·w·E/(N_j·N_k)`, whose gradient is `κ·(G_j − (E/N_j)B_j)` with
911                // `κ = w_pair/(N_j·N_k)`, `G_j[a,o]=Σ_b C[a,b]B_k[b,o]`, `E=‖C‖²_F`.
912                let nj = Self::block_norm_sq(target, off_j, m_j, p_out);
913                let nk = Self::block_norm_sq(target, off_k, m_k, p_out);
914                if !(nj > 0.0 && nk > 0.0) {
915                    continue;
916                }
917                let kappa = w_pair / (nj * nk);
918                // Directional cross-Gram derivative dC[a,b] = Σ_o (Vj·Bk + Bj·Vk).
919                let mut d_c = Array2::<f64>::zeros((m_j, m_k));
920                for a in 0..m_j {
921                    for b in 0..m_k {
922                        let mut s = 0.0;
923                        for o in 0..p_out {
924                            s += v[off_j + a * p_out + o] * target[off_k + b * p_out + o]
925                                + target[off_j + a * p_out + o] * v[off_k + b * p_out + o];
926                        }
927                        d_c[[a, b]] = s;
928                    }
929                }
930                if !include_residual {
931                    // PSD Gauss-Newton majorizer: the same directional-Gram Gram as
932                    // before, scaled by the LIVE κ (not the frozen weight). Dropping
933                    // the residual + normalizer-second-derivative terms keeps it PSD
934                    // (`κ·JᵀJ`, κ>0); see `accumulate_psd_majorizer_dense` for the
935                    // solver-contract note (#2343).
936                    for a in 0..m_j {
937                        for o in 0..p_out {
938                            let mut s = 0.0;
939                            for b in 0..m_k {
940                                s += d_c[[a, b]] * target[off_k + b * p_out + o];
941                            }
942                            out[off_j + a * p_out + o] += kappa * s;
943                        }
944                    }
945                    for b in 0..m_k {
946                        for o in 0..p_out {
947                            let mut s = 0.0;
948                            for a in 0..m_j {
949                                s += d_c[[a, b]] * target[off_j + a * p_out + o];
950                            }
951                            out[off_k + b * p_out + o] += kappa * s;
952                        }
953                    }
954                    continue;
955                }
956                // EXACT ∂²P·v of the quotient penalty (differentiates the analytic
957                // normalizer — no frozen-cache shortcut, #2343). With α=2⟨Bj,Vj⟩/Nj,
958                // β=2⟨Bk,Vk⟩/Nk, DE=2Σ C·dC:
959                //   (Hv)_j = κ[ DG_j − (α+β)G_j − (DE/Nj)Bj + (E/Nj)(2α+β)Bj − (E/Nj)Vj ]
960                // (and j↔k, with 2α+β → 2β+α). DG_j=Σ_b(dC·Bk + C·Vk).
961                let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, p_out);
962                let mut e = 0.0;
963                let mut d_e = 0.0;
964                for a in 0..m_j {
965                    for b in 0..m_k {
966                        e += c[[a, b]] * c[[a, b]];
967                        d_e += 2.0 * c[[a, b]] * d_c[[a, b]];
968                    }
969                }
970                let mut bjvj = 0.0;
971                for i in 0..(m_j * p_out) {
972                    bjvj += target[off_j + i] * v[off_j + i];
973                }
974                let mut bkvk = 0.0;
975                for i in 0..(m_k * p_out) {
976                    bkvk += target[off_k + i] * v[off_k + i];
977                }
978                let alpha = 2.0 * bjvj / nj;
979                let beta = 2.0 * bkvk / nk;
980                let e_o_nj = e / nj;
981                let e_o_nk = e / nk;
982                let de_o_nj = d_e / nj;
983                let de_o_nk = d_e / nk;
984                for a in 0..m_j {
985                    for o in 0..p_out {
986                        let mut g_j = 0.0;
987                        let mut dg_j = 0.0;
988                        for b in 0..m_k {
989                            g_j += c[[a, b]] * target[off_k + b * p_out + o];
990                            dg_j += d_c[[a, b]] * target[off_k + b * p_out + o]
991                                + c[[a, b]] * v[off_k + b * p_out + o];
992                        }
993                        let bj = target[off_j + a * p_out + o];
994                        let vj = v[off_j + a * p_out + o];
995                        let hv = dg_j - (alpha + beta) * g_j - de_o_nj * bj
996                            + e_o_nj * (2.0 * alpha + beta) * bj
997                            - e_o_nj * vj;
998                        out[off_j + a * p_out + o] += kappa * hv;
999                    }
1000                }
1001                for b in 0..m_k {
1002                    for o in 0..p_out {
1003                        let mut g_k = 0.0;
1004                        let mut dg_k = 0.0;
1005                        for a in 0..m_j {
1006                            g_k += c[[a, b]] * target[off_j + a * p_out + o];
1007                            dg_k += d_c[[a, b]] * target[off_j + a * p_out + o]
1008                                + c[[a, b]] * v[off_j + a * p_out + o];
1009                        }
1010                        let bk = target[off_k + b * p_out + o];
1011                        let vk = v[off_k + b * p_out + o];
1012                        let hv = dg_k - (alpha + beta) * g_k - de_o_nk * bk
1013                            + e_o_nk * (2.0 * beta + alpha) * bk
1014                            - e_o_nk * vk;
1015                        out[off_k + b * p_out + o] += kappa * hv;
1016                    }
1017                }
1018            }
1019        }
1020        out
1021    }
1022
1023    /// Scatter the Gauss-Newton (PSD majorizer) curvature DIRECTLY into a dense
1024    /// `β × β` block, accumulating `scale · H_GN` onto `hbb`.
1025    ///
1026    /// This produces exactly the operator [`AnalyticPenalty::psd_majorizer_hvp`]
1027    /// applies (the `include_residual = false` branch of `Self::hvp_impl`), but
1028    /// assembled block-by-block over the penalized atom pairs instead of
1029    /// reconstructed column-by-column from `β` unit-probe HVPs. Since `H_GN` is
1030    /// pair-local — it couples only the `(j, k)` pairs in `self.pairs`, each within
1031    /// their `(M·p)` decoder blocks — reading off the four output loops of
1032    /// `hvp_impl` at a unit probe gives, per pair `(j, k)` with
1033    /// `w = w_sym · λ · scale` and `G_x = B_xᵀ B_x` (the `p × p` decoder output
1034    /// Gram of atom `x`):
1035    ///   * j-block diagonal  `H[(j,a,o),(j,a,o')] += w · G_k[o,o']`
1036    ///   * k-block diagonal  `H[(k,b,o),(k,b,o')] += w · G_j[o,o']`
1037    ///   * off-diagonal      `H[(j,a,o₁),(k,b,o₂)] += w · B_j[a,o₂] · B_k[b,o₁]`
1038    ///     and its symmetric transpose into the `(k, j)` block.
1039    ///
1040    /// Cost is `O(Σ_pairs (M_j·M_k + M_j + M_k)·p²)`, versus the probe loop's
1041    /// `O(β · Σ_pairs M_j·M_k·p)`: once `β = K·M·p` and the collinearity gate
1042    /// admits `O(K)` co-active pairs, the probe loop spends `O(K²)` time
1043    /// rebuilding a matrix this assembles in `O(K)` (#1026).
1044    /// The Gauss–Newton (PSD majorizer) curvature in CARRIER form: one entry per
1045    /// cross-Gram element `C[a,b]` of each penalized pair, as
1046    /// `(κ, (start_j, B_k[b,·]), (start_k, B_j[a,·]))` with the starts in this
1047    /// penalty's own flat-β coordinates.
1048    ///
1049    /// `H_GN = Σ_pairs κ·JᵀJ` with `J = ∂vec(C)/∂vec(B)`, and `C[a,b] = Σ_o
1050    /// B_j[a,o]B_k[b,o]` depends on exactly row `a` of `B_j` and row `b` of
1051    /// `B_k`, so `∂C[a,b]/∂B` is two `p`-long runs and `JᵀJ = Σ_{a,b} v_{ab}
1052    /// v_{ab}ᵀ`. This is therefore the SAME operator as the
1053    /// `include_residual = false` branch of `Self::hvp_impl` and as
1054    /// [`Self::accumulate_psd_majorizer_dense`] — stated in the form a
1055    /// matrix-free solver can install without materializing `(ΣM_kp)²`.
1056    ///
1057    /// #2828: the SAE's un-framed matrix-free assembly lane had no way to carry
1058    /// this curvature and was dropping it while keeping the gradient.
1059    #[must_use]
1060    pub fn psd_majorizer_carriers(
1061        &self,
1062        target: ArrayView1<'_, f64>,
1063        rho: ArrayView1<'_, f64>,
1064        scale: f64,
1065    ) -> Vec<(f64, (usize, Vec<f64>), (usize, Vec<f64>))> {
1066        let mut out = Vec::new();
1067        if target.len() != self.target.len() {
1068            return out;
1069        }
1070        let offsets = self.block_offsets();
1071        let weight = self.resolved_weight(rho);
1072        let p = self.p_out;
1073        for &(j, k, w_sym) in &self.pairs {
1074            if j == k {
1075                continue;
1076            }
1077            let off_j = offsets[j];
1078            let off_k = offsets[k];
1079            let m_j = self.block_sizes[j];
1080            let m_k = self.block_sizes[k];
1081            if m_j == 0 || m_k == 0 {
1082                continue;
1083            }
1084            let nj = Self::block_norm_sq(target, off_j, m_j, p);
1085            let nk = Self::block_norm_sq(target, off_k, m_k, p);
1086            if !(nj > 0.0 && nk > 0.0) {
1087                continue;
1088            }
1089            let kappa = w_sym * weight * scale / (nj * nk);
1090            if kappa == 0.0 {
1091                continue;
1092            }
1093            for a in 0..m_j {
1094                for b in 0..m_k {
1095                    let run_j: Vec<f64> = (0..p).map(|o| target[off_k + b * p + o]).collect();
1096                    let run_k: Vec<f64> = (0..p).map(|o| target[off_j + a * p + o]).collect();
1097                    out.push((kappa, (off_j + a * p, run_j), (off_k + b * p, run_k)));
1098                }
1099            }
1100        }
1101        out
1102    }
1103
1104    pub fn accumulate_psd_majorizer_dense(
1105        &self,
1106        target: ArrayView1<'_, f64>,
1107        rho: ArrayView1<'_, f64>,
1108        scale: f64,
1109        hbb: &mut Array2<f64>,
1110    ) {
1111        if target.len() != self.target.len() {
1112            return;
1113        }
1114        let offsets = self.block_offsets();
1115        let weight = self.resolved_weight(rho);
1116        let p = self.p_out;
1117        for &(j, k, w_sym) in &self.pairs {
1118            let off_j = offsets[j];
1119            let off_k = offsets[k];
1120            let m_j = self.block_sizes[j];
1121            let m_k = self.block_sizes[k];
1122            // #2343 — LIVE per-pair coefficient κ = w·scale/(N_j·N_k). The
1123            // Gauss-Newton scatter below is `κ·JᵀJ` (J = ∂C/∂B), which is PSD for
1124            // any κ>0 — the solver's only hard requirement of this majorizer (a
1125            // PSD curvature stand-in for the nonconvex penalty; the inner Newton
1126            // never assumes it DOMINATES the exact Hessian, only that it is PSD,
1127            // so dropping the quotient's indefinite normalizer-second-derivative
1128            // terms — largest exactly in the collapse regime — is contract-safe).
1129            let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
1130            let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
1131            if !(nj > 0.0 && nk > 0.0) {
1132                continue;
1133            }
1134            let w = w_sym * weight * scale / (nj * nk);
1135            if w == 0.0 {
1136                continue;
1137            }
1138            // Per-pair output Grams G_j = B_jᵀB_j and G_k = B_kᵀB_k (p × p), which
1139            // drive the within-block diagonal curvature of the partner atom.
1140            let mut g_j = vec![0.0_f64; p * p];
1141            let mut g_k = vec![0.0_f64; p * p];
1142            for o in 0..p {
1143                for o2 in 0..p {
1144                    let mut sj = 0.0;
1145                    for a in 0..m_j {
1146                        sj += target[off_j + a * p + o] * target[off_j + a * p + o2];
1147                    }
1148                    g_j[o * p + o2] = sj;
1149                    let mut sk = 0.0;
1150                    for b in 0..m_k {
1151                        sk += target[off_k + b * p + o] * target[off_k + b * p + o2];
1152                    }
1153                    g_k[o * p + o2] = sk;
1154                }
1155            }
1156            // j-block diagonal: H[(j,a,o),(j,a,o')] += w · G_k[o,o'].
1157            for a in 0..m_j {
1158                let base = off_j + a * p;
1159                for o in 0..p {
1160                    for o2 in 0..p {
1161                        hbb[[base + o, base + o2]] += w * g_k[o * p + o2];
1162                    }
1163                }
1164            }
1165            // k-block diagonal: H[(k,b,o),(k,b,o')] += w · G_j[o,o'].
1166            for b in 0..m_k {
1167                let base = off_k + b * p;
1168                for o in 0..p {
1169                    for o2 in 0..p {
1170                        hbb[[base + o, base + o2]] += w * g_j[o * p + o2];
1171                    }
1172                }
1173            }
1174            // Off-diagonal coupling: H[(j,a,o₁),(k,b,o₂)] += w · B_j[a,o₂]·B_k[b,o₁],
1175            // and the symmetric transpose into the (k, j) block.
1176            for a in 0..m_j {
1177                for b in 0..m_k {
1178                    for o1 in 0..p {
1179                        let row_j = off_j + a * p + o1;
1180                        let bk_b_o1 = target[off_k + b * p + o1];
1181                        for o2 in 0..p {
1182                            let col_k = off_k + b * p + o2;
1183                            let contrib = w * target[off_j + a * p + o2] * bk_b_o1;
1184                            hbb[[row_j, col_k]] += contrib;
1185                            hbb[[col_k, row_j]] += contrib;
1186                        }
1187                    }
1188                }
1189            }
1190        }
1191    }
1192}
1193
1194impl AnalyticPenalty for DecoderIncoherencePenalty {
1195    fn tier(&self) -> PenaltyTier {
1196        PenaltyTier::Beta
1197    }
1198
1199    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1200        if target.len() != self.target.len() {
1201            return 0.0;
1202        }
1203        let offsets = self.block_offsets();
1204        let mut acc = 0.0;
1205        for &(j, k, w_pair) in &self.pairs {
1206            {
1207                if w_pair == 0.0 {
1208                    continue;
1209                }
1210                // #2343 — degree-0 normalized coherence `‖C‖²_F/(N_j·N_k)`. When an
1211                // atom's radius is exactly zero it has no direction to be coherent
1212                // with, so the normalized penalty is undefined there and abstains
1213                // (the interior amplitude barrier owns that collapse point); every
1214                // reachable atom carries positive energy.
1215                let nj = Self::block_norm_sq(target, offsets[j], self.block_sizes[j], self.p_out);
1216                let nk = Self::block_norm_sq(target, offsets[k], self.block_sizes[k], self.p_out);
1217                if !(nj > 0.0 && nk > 0.0) {
1218                    continue;
1219                }
1220                let c = Self::cross_gram(
1221                    target,
1222                    offsets[j],
1223                    self.block_sizes[j],
1224                    offsets[k],
1225                    self.block_sizes[k],
1226                    self.p_out,
1227                );
1228                let mut frob_sq = 0.0;
1229                for &value in c.iter() {
1230                    frob_sq += value * value;
1231                }
1232                acc += w_pair * frob_sq / (nj * nk);
1233            }
1234        }
1235        0.5 * self.resolved_weight(rho) * acc
1236    }
1237
1238    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1239        let mut grad = Array1::<f64>::zeros(target.len());
1240        if target.len() != self.target.len() {
1241            return grad;
1242        }
1243        let offsets = self.block_offsets();
1244        let weight = self.resolved_weight(rho);
1245        for &(j, k, w_sym) in &self.pairs {
1246            {
1247                let w_pair = w_sym * weight;
1248                if w_pair == 0.0 {
1249                    continue;
1250                }
1251                let off_j = offsets[j];
1252                let off_k = offsets[k];
1253                let m_j = self.block_sizes[j];
1254                let m_k = self.block_sizes[k];
1255                let nj = Self::block_norm_sq(target, off_j, m_j, self.p_out);
1256                let nk = Self::block_norm_sq(target, off_k, m_k, self.p_out);
1257                if !(nj > 0.0 && nk > 0.0) {
1258                    continue;
1259                }
1260                let c = Self::cross_gram(target, off_j, m_j, off_k, m_k, self.p_out);
1261                let mut e = 0.0;
1262                for &value in c.iter() {
1263                    e += value * value;
1264                }
1265                // #2343 — degree-0 quotient gradient of `½·w·E/(N_j·N_k)`:
1266                //   ∂P/∂B_j[a,o] = (w/(N_j N_k))·( Σ_b C[a,b]·B_k[b,o] − (E/N_j)·B_j[a,o] ).
1267                // The `−(E/N_j)·B_j` term is the radial projection; by Euler's theorem
1268                // (P homogeneous degree-0 in B_j) Σ_{a,o} B_j·∂P/∂B_j = w/(N_j N_k)·(E−E) = 0,
1269                // i.e. the repulsion exerts NO radial (amplitude) force — it prices
1270                // decoder DIRECTION only, and the interior amplitude barrier prices
1271                // amplitude. This is what kills the #2343 inward collapse force.
1272                let inv_j = w_pair / (nj * nk);
1273                for a in 0..m_j {
1274                    for o in 0..self.p_out {
1275                        let mut s = 0.0;
1276                        for b in 0..m_k {
1277                            s += c[[a, b]] * target[off_k + b * self.p_out + o];
1278                        }
1279                        let radial = (e / nj) * target[off_j + a * self.p_out + o];
1280                        grad[off_j + a * self.p_out + o] += inv_j * (s - radial);
1281                    }
1282                }
1283                for b in 0..m_k {
1284                    for o in 0..self.p_out {
1285                        let mut s = 0.0;
1286                        for a in 0..m_j {
1287                            s += c[[a, b]] * target[off_j + a * self.p_out + o];
1288                        }
1289                        let radial = (e / nk) * target[off_k + b * self.p_out + o];
1290                        grad[off_k + b * self.p_out + o] += inv_j * (s - radial);
1291                    }
1292                }
1293            }
1294        }
1295        grad
1296    }
1297
1298    /// Exact Hessian-vector product `H v = (∂²P/∂target²) v`.
1299    ///
1300    /// `P = ½ w Σ_{j<k} w_{jk} ‖C_{jk}‖²_F` is biquadratic (quartic) in the
1301    /// decoder blocks, so the second derivative of the nonlinear-least-squares
1302    /// objective carries **two** pieces along a direction `V` (per pair, with
1303    /// `W = w·w_{jk}`):
1304    ///
1305    /// ```text
1306    ///   (H v)_j[a,o] = W [ Σ_b dC[a,b]·B_k[b,o]   +   Σ_b C[a,b]·V_k[b,o] ]
1307    /// ```
1308    ///
1309    /// the Gauss-Newton term `Σ dC·B` and the residual term `Σ C·V`, with
1310    /// `dC[a,b] = Σ_o (V_j[a,o]·B_k[b,o] + B_j[a,o]·V_k[b,o])` (and the symmetric
1311    /// `_k` block). The residual term is what makes the exact Hessian indefinite;
1312    /// the GN-only surrogate lives in [`Self::psd_majorizer_hvp`].
1313    fn hvp(
1314        &self,
1315        target: ArrayView1<'_, f64>,
1316        rho: ArrayView1<'_, f64>,
1317        v: ArrayView1<'_, f64>,
1318    ) -> Array1<f64> {
1319        assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1320        self.hvp_impl(target, rho, v, /* include_residual = */ true)
1321    }
1322
1323    /// PSD majorizer-vector product `B_GN(target; ρ) v` for the **nonconvex**
1324    /// decoder-incoherence penalty.
1325    ///
1326    /// Dropping the indefinite residual term `W·Σ C·V` from the exact
1327    /// [`Self::hvp`] leaves the Gauss-Newton block `W·Jᵀ(J v)` with
1328    /// `J = ∂vec(C)/∂vec(B)`. That block is PSD by construction — a sum of
1329    /// `W ≥ 0` (`weight > 0`, `coactivation ≥ 0`) times rank-structured Gram
1330    /// products `JᵀJ` — and coincides with the exact Hessian as the cross-Gram
1331    /// `C → 0`. The inner Newton / PIRLS curvature block must stay
1332    /// positive-definite, so the GN block is the correct operator here, mirroring
1333    /// the other nonconvex penalties (sparsity, smooth-threshold, isometry) that override
1334    /// the majorizer rather than hand back the indefinite true Hessian.
1335    fn psd_majorizer_hvp(
1336        &self,
1337        target: ArrayView1<'_, f64>,
1338        rho: ArrayView1<'_, f64>,
1339        v: ArrayView1<'_, f64>,
1340    ) -> Array1<f64> {
1341        assert_eq!(
1342            target.len(),
1343            v.len(),
1344            "psd_majorizer_hvp dimension mismatch"
1345        );
1346        self.hvp_impl(target, rho, v, /* include_residual = */ false)
1347    }
1348
1349    // `hessian_diag` is intentionally left at the trait default (returns `None`
1350    // for a non-empty target): the Hessian of the cross-Gram Frobenius objective
1351    // is dense, not diagonal, so curvature is supplied via the closed-form
1352    // `hvp` / `psd_majorizer_hvp` path above.
1353
1354    impl_learnable_weight_grad_rho!();
1355
1356    impl_learnable_weight_rho_count!();
1357    impl_learnable_weight_domain!(weight);
1358
1359    fn name(&self) -> &str {
1360        "decoder_incoherence"
1361    }
1362
1363    impl_scalar_apply_schedule!(weight);
1364}
1365
1366// ---------------------------------------------------------------------------
1367// Orthogonality penalty
1368// ---------------------------------------------------------------------------
1369
1370/// Gauge-fixing penalty for latent-coordinate axes.
1371///
1372/// ARD alone is rotation-invariant — pair with Orthogonality to identify
1373/// intrinsic dim. This penalty locks a canonical orthonormal basis first;
1374/// ARD can then shrink axes after the rotation gauge has been identified.
1375#[derive(Debug, Clone)]
1376pub struct OrthogonalityPenalty {
1377    pub target: PsiSlice,
1378    pub latent_dim: usize,
1379    /// Base strength. If `learnable_weight` is true, the resolved strength is
1380    /// `weight * exp(rho[rho_index])`; otherwise it is fixed at `weight`.
1381    pub weight: f64,
1382    /// Effective observation count used to keep the Frobenius contribution on
1383    /// the same scale as per-axis latent priors.
1384    pub n_eff: usize,
1385    pub learnable_weight: bool,
1386    pub rho_index: usize,
1387    pub weight_schedule: Option<ScalarWeightSchedule>,
1388}
1389
1390impl OrthogonalityPenalty {
1391    #[must_use = "build error must be handled"]
1392    pub fn new(
1393        target: PsiSlice,
1394        latent_dim: usize,
1395        weight: f64,
1396        n_eff: usize,
1397        learnable_weight: bool,
1398    ) -> Result<Self, String> {
1399        if latent_dim == 0 {
1400            return Err("OrthogonalityPenalty::new requires latent_dim > 0".to_string());
1401        }
1402        if !target.len().is_multiple_of(latent_dim) {
1403            return Err(format!(
1404                "OrthogonalityPenalty::new target length {} is not divisible by latent_dim {}",
1405                target.len(),
1406                latent_dim
1407            ));
1408        }
1409        let n_obs = target.len() / latent_dim;
1410        if n_obs < latent_dim {
1411            return Err(format!(
1412                "OrthogonalityPenalty::new requires n_obs >= latent_dim for a feasible \
1413                 Stiefel target, got n_obs {n_obs} and latent_dim {latent_dim}"
1414            ));
1415        }
1416        if !(weight.is_finite() && weight > 0.0) {
1417            return Err(format!(
1418                "OrthogonalityPenalty::new requires finite weight > 0, got {weight}"
1419            ));
1420        }
1421        if n_eff == 0 {
1422            return Err("OrthogonalityPenalty::new requires n_eff > 0".to_string());
1423        }
1424        if n_eff != n_obs {
1425            return Err(format!(
1426                "OrthogonalityPenalty::new requires n_eff to match target rows, got \
1427                 n_eff {n_eff} and target rows {n_obs}"
1428            ));
1429        }
1430        Ok(Self {
1431            target,
1432            latent_dim,
1433            weight,
1434            n_eff,
1435            learnable_weight,
1436            rho_index: 0,
1437            weight_schedule: None,
1438        })
1439    }
1440
1441    impl_with_weight_schedule!(weight);
1442
1443    fn resolved_weight(&self, rho: ArrayView1<'_, f64>) -> f64 {
1444        if self.learnable_weight {
1445            validated_learnable_weight(self.weight, rho[self.rho_index])
1446        } else {
1447            self.weight
1448        }
1449    }
1450
1451    pub(crate) fn scale(&self, rho: ArrayView1<'_, f64>) -> f64 {
1452        self.resolved_weight(rho) / self.n_eff as f64
1453    }
1454
1455    pub(crate) fn target_matrix<'a>(
1456        &self,
1457        target: ArrayView1<'a, f64>,
1458    ) -> Option<ArrayView2<'a, f64>> {
1459        let d = self.latent_dim;
1460        if !target.len().is_multiple_of(d) {
1461            assert_eq!(
1462                target.len() % d,
1463                0,
1464                "target length must be divisible by latent_dim"
1465            );
1466            return None;
1467        }
1468        let n_obs = target.len() / d;
1469        target.into_shape_with_order((n_obs, d)).ok()
1470    }
1471
1472    pub(crate) fn gram_minus_identity(t: ArrayView2<'_, f64>) -> Array2<f64> {
1473        let n_obs = t.nrows();
1474        let d = t.ncols();
1475        let mut gram = Array2::<f64>::zeros((d, d));
1476        for a in 0..d {
1477            for b in 0..d {
1478                let mut s = 0.0;
1479                for n in 0..n_obs {
1480                    s += t[[n, a]] * t[[n, b]];
1481                }
1482                gram[[a, b]] = s;
1483            }
1484            gram[[a, a]] -= 1.0;
1485        }
1486        gram
1487    }
1488
1489    fn flatten_matrix(m: &Array2<f64>) -> Array1<f64> {
1490        let n_obs = m.nrows();
1491        let d = m.ncols();
1492        let mut out = Array1::<f64>::zeros(n_obs * d);
1493        for n in 0..n_obs {
1494            for a in 0..d {
1495                out[n * d + a] = m[[n, a]];
1496            }
1497        }
1498        out
1499    }
1500
1501    pub(crate) fn hvp_with_precomputed_m(
1502        &self,
1503        t: ArrayView2<'_, f64>,
1504        m: ArrayView2<'_, f64>,
1505        v: ArrayView2<'_, f64>,
1506        scale: f64,
1507    ) -> Array2<f64> {
1508        let n_obs = t.nrows();
1509        let d = t.ncols();
1510        assert_eq!(v.dim(), t.dim(), "hvp matrix dimension mismatch");
1511        assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1512        if v.dim() != t.dim() {
1513            return Array2::<f64>::zeros((n_obs, d));
1514        }
1515
1516        let mut vt_t_plus_tt_v = Array2::<f64>::zeros((d, d));
1517        for c in 0..d {
1518            for b in 0..d {
1519                let mut s = 0.0;
1520                for n in 0..n_obs {
1521                    s += v[[n, c]] * t[[n, b]] + t[[n, c]] * v[[n, b]];
1522                }
1523                vt_t_plus_tt_v[[c, b]] = s;
1524            }
1525        }
1526
1527        let mut out = Array2::<f64>::zeros((n_obs, d));
1528        for n in 0..n_obs {
1529            for b in 0..d {
1530                let mut va = 0.0;
1531                let mut tb = 0.0;
1532                for c in 0..d {
1533                    va += v[[n, c]] * m[[c, b]];
1534                    tb += t[[n, c]] * vt_t_plus_tt_v[[c, b]];
1535                }
1536                out[[n, b]] = 2.0 * scale * (va + tb);
1537            }
1538        }
1539        out
1540    }
1541
1542    pub(crate) fn as_dense_with_precomputed_m(
1543        &self,
1544        t: ArrayView2<'_, f64>,
1545        m: ArrayView2<'_, f64>,
1546        scale: f64,
1547    ) -> Array2<f64> {
1548        let n_obs = t.nrows();
1549        let d = t.ncols();
1550        assert_eq!(m.dim(), (d, d), "precomputed gram dimension mismatch");
1551        if m.dim() != (d, d) {
1552            return Array2::<f64>::zeros((n_obs * d, n_obs * d));
1553        }
1554
1555        let mut dense = Array2::<f64>::zeros((n_obs * d, n_obs * d));
1556        let factor = 2.0 * scale;
1557        for row1 in 0..n_obs {
1558            for row2 in 0..n_obs {
1559                let mut row_dot = 0.0;
1560                for axis in 0..d {
1561                    row_dot += t[[row1, axis]] * t[[row2, axis]];
1562                }
1563                for col1 in 0..d {
1564                    let i = row1 * d + col1;
1565                    for col2 in 0..d {
1566                        let j = row2 * d + col2;
1567                        let mut entry = t[[row1, col2]] * t[[row2, col1]];
1568                        if row1 == row2 {
1569                            entry += m[[col2, col1]];
1570                        }
1571                        if col1 == col2 {
1572                            entry += row_dot;
1573                        }
1574                        dense[[i, j]] = factor * entry;
1575                    }
1576                }
1577            }
1578        }
1579        dense
1580    }
1581}
1582
1583impl AnalyticPenalty for OrthogonalityPenalty {
1584    fn tier(&self) -> PenaltyTier {
1585        PenaltyTier::Psi
1586    }
1587
1588    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1589        let Some(t) = self.target_matrix(target) else {
1590            return 0.0;
1591        };
1592        let gram = Self::gram_minus_identity(t.view());
1593        let mut acc = 0.0;
1594        for &v in gram.iter() {
1595            acc += v * v;
1596        }
1597        0.5 * self.scale(rho) * acc
1598    }
1599
1600    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1601        // Matrix-calculus core:
1602        //   d/dT ½·scale·||TᵀT - I||²_F = 2·scale·T·(TᵀT - I),
1603        // because TᵀT - I is symmetric.
1604        let Some(t) = self.target_matrix(target) else {
1605            return Array1::<f64>::zeros(target.len());
1606        };
1607        let gram = Self::gram_minus_identity(t.view());
1608        let n_obs = t.nrows();
1609        let d = t.ncols();
1610        let factor = 2.0 * self.scale(rho);
1611        let mut grad = Array2::<f64>::zeros((n_obs, d));
1612        for n in 0..n_obs {
1613            for a in 0..d {
1614                let mut s = 0.0;
1615                for b in 0..d {
1616                    s += t[[n, b]] * gram[[b, a]];
1617                }
1618                grad[[n, a]] = factor * s;
1619            }
1620        }
1621        Self::flatten_matrix(&grad)
1622    }
1623
1624    fn hvp(
1625        &self,
1626        target: ArrayView1<'_, f64>,
1627        rho: ArrayView1<'_, f64>,
1628        v: ArrayView1<'_, f64>,
1629    ) -> Array1<f64> {
1630        assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1631        if target.len() != v.len() {
1632            return Array1::<f64>::zeros(target.len());
1633        }
1634        let Some(t) = self.target_matrix(target) else {
1635            return Array1::<f64>::zeros(target.len());
1636        };
1637        let Some(v_mat) = self.target_matrix(v) else {
1638            return Array1::<f64>::zeros(target.len());
1639        };
1640        let m = Self::gram_minus_identity(t.view());
1641        let hv = self.hvp_with_precomputed_m(t.view(), m.view(), v_mat.view(), self.scale(rho));
1642        Self::flatten_matrix(&hv)
1643    }
1644
1645    impl_learnable_weight_grad_rho!();
1646
1647    impl_learnable_weight_rho_count!();
1648    impl_learnable_weight_domain!(weight);
1649
1650    fn name(&self) -> &str {
1651        "orthogonality"
1652    }
1653
1654    impl_scalar_apply_schedule!(weight);
1655}