Skip to main content

gam_terms/analytic_penalties/
orthogonality.rs

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