Skip to main content

gam_terms/analytic_penalties/
sparsity.rs

1use super::*;
2
3/// Exact floating-point continuation of `log(p) + 1` on the support of a
4/// representable softmax row. An underflowed probability is exactly zero in the
5/// value path, so its entropy contribution and all local derivatives are zero;
6/// using the same branch everywhere keeps value/gradient/Hessian consistent.
7#[inline]
8fn entropy_log_plus_one(p: f64) -> f64 {
9    if p > 0.0 { p.ln() + 1.0 } else { 0.0 }
10}
11
12// ---------------------------------------------------------------------------
13// Sparsity penalty
14// ---------------------------------------------------------------------------
15
16/// Sparsifier kernel.
17///
18/// * `SmoothedL1 { eps }` — `Σ_i sqrt(x_i² + ε²)`. The smoothing scale `ε`
19///   may be REML-selected (`eps_rho_index = Some(_)`), in which case the
20///   shrink rate `ε → 0` is governed by the marginal likelihood (Occam keeps
21///   `ε` large when the data don't demand sharpness).
22/// * `Hoyer` — `(√n · ‖x‖_1 − ‖x‖_2) / (√n − 1)`. Scale-invariant; encourages
23///   absolute sparsity even when the global scale of `x` drifts.
24/// * `Log { delta }` — `Σ_i log(1 + x_i² / δ²)`. Strongly concave; aggressive
25///   sparsifier suitable for active-set / iterative-reweighted paths.
26#[derive(Debug, Clone, Copy)]
27pub enum SparsityKind {
28    SmoothedL1 { eps: f64 },
29    Hoyer,
30    Log { delta: f64 },
31}
32
33/// Sparsity penalty on a slice of β (SAE codes) or ext-coords (soft atom assignments).
34///
35/// The smoothed-L¹ default `Σ_i sqrt(x_i² + ε²)` is the simplest analytic
36/// option. Its gradient is `x_i / sqrt(x_i² + ε²)` (a smooth sign function),
37/// and its Hessian is diagonal with entries `ε² / (x_i² + ε²)^{3/2}` — so
38/// `hvp` is cheap and the inner Newton step inherits a benign block-diagonal
39/// regularizer.
40///
41/// When to use: any time a parameter block carries a "this should be sparse"
42/// prior — SAE atom codes (β slice), soft-routing weights on a latent
43/// ext-coordinate slice. For SAE codes specifically, smoothed-L¹ with REML-selected `ε`
44/// gives the principled relaxation of the L¹ objective without giving up
45/// differentiability.
46#[derive(Debug, Clone)]
47pub struct SparsityPenalty {
48    pub target_tier: PenaltyTier,
49    pub kind: SparsityKind,
50    pub weight: f64,
51    pub weight_schedule: Option<ScalarWeightSchedule>,
52    /// Index of `log strength` inside this penalty's local ρ view.
53    pub strength_rho_index: usize,
54    /// If `Some`, the index of `log ε` (or `log δ`) inside this penalty's
55    /// local ρ view. If `None`, `ε` / `δ` is held fixed at the value baked
56    /// into [`SparsityKind`].
57    pub eps_rho_index: Option<usize>,
58}
59
60/// Entropy sparsity over row-wise softmax assignment logits.
61///
62/// This is the SAE-manifold soft-assignment penalty. The target is a flat
63/// row-major `(N, K)` logit matrix. Assignments are
64/// `a_i = softmax(logits_i / temperature)`, and the penalty is
65///
66/// ```text
67///   lambda_sparse * sum_i H(a_i)
68///   H(a_i) = -sum_k a_ik log a_ik
69/// ```
70///
71/// Minimizing entropy drives each row toward a small active support while the
72/// softmax keeps `a_ik >= 0` and `sum_k a_ik = 1`. The exact Hessian is dense
73/// in each row and can be indefinite because entropy is concave in assignment
74/// space, so callers must use the HVP rather than a diagonal Hessian shortcut.
75#[derive(Debug, Clone)]
76pub struct SoftmaxAssignmentSparsityPenalty {
77    pub k_atoms: usize,
78    pub temperature: f64,
79    pub weight: f64,
80    pub weight_schedule: Option<ScalarWeightSchedule>,
81    /// #991 design-honesty per-row weights `w_i` (mean-1). When present, row `i`'s
82    /// prior contribution is scaled by `w_i` in EVERY aggregate channel — value,
83    /// `grad_target`, `hessian_diag`, `hvp`, `psd_majorizer_diag`, `grad_rho`.
84    /// Because each of those is linear in the per-row penalty strength, scaling
85    /// the strength by `w_i` scales all channels by the same `w_i` and cannot
86    /// desync them (the value/gradient FD oracle gates this). The per-row *block*
87    /// helpers (`row_dense_hessian` / `row_psd_majorizer` / their logit
88    /// derivatives / `psd_majorizer_abs_row_sums`) take an explicit `scale` and a
89    /// single row, so their callers apply `scale·w_i` instead. `None` ⇒ every
90    /// weight is `1`, bit-for-bit the unweighted path.
91    pub row_weights: Option<std::sync::Arc<[f64]>>,
92}
93
94impl SoftmaxAssignmentSparsityPenalty {
95    #[must_use]
96    pub fn new(k_atoms: usize, temperature: f64) -> Self {
97        assert!(k_atoms > 0);
98        assert!(temperature > 0.0);
99        Self {
100            k_atoms,
101            temperature,
102            weight: 1.0,
103            weight_schedule: None,
104            row_weights: None,
105        }
106    }
107
108    /// Install #991 design-honesty per-row weights (see [`Self::row_weights`]).
109    /// A uniform / absent design is passed as `None` so the unweighted arithmetic
110    /// stays bit-for-bit; a present slice must have one finite weight per row.
111    #[must_use]
112    pub fn with_row_weights(mut self, weights: Option<&[f64]>) -> Self {
113        self.row_weights = weights.map(|w| std::sync::Arc::from(w.to_vec()));
114        self
115    }
116
117    /// Per-row strength multiplier `w_i` (defaults to `1.0` when no design weights
118    /// are installed). Callers of the per-row *block* helpers fold this into the
119    /// `scale` they pass so those channels carry the identical weighting.
120    #[must_use]
121    pub fn row_weight(&self, row: usize) -> f64 {
122        self.row_weights.as_ref().map_or(1.0, |w| w[row])
123    }
124
125    impl_with_weight_schedule!(weight);
126
127    fn softmax_row(&self, row: &[f64]) -> Vec<f64> {
128        let inv_tau = 1.0 / self.temperature;
129        let mut max_logit = f64::NEG_INFINITY;
130        for (idx, &v) in row.iter().enumerate() {
131            assert!(
132                v.is_finite(),
133                "SoftmaxAssignmentSparsityPenalty: non-finite logit at atom {idx}: {v}"
134            );
135            max_logit = max_logit.max(v);
136        }
137        let mut out = vec![0.0; self.k_atoms];
138        let mut sum = 0.0;
139        for i in 0..self.k_atoms {
140            let v = ((row[i] - max_logit) * inv_tau).exp();
141            out[i] = v;
142            sum += v;
143        }
144        assert!(
145            sum.is_finite() && sum > 0.0,
146            "SoftmaxAssignmentSparsityPenalty: non-finite softmax normalizer"
147        );
148        for v in out.iter_mut() {
149            *v /= sum;
150        }
151        out
152    }
153
154    /// Absolute row sums of the exact per-row dense entropy Hessian, used as a
155    /// Gershgorin / diagonal-dominance PSD majorizer.
156    ///
157    /// The exact per-row Hessian wrt logits (symmetric, dense) is
158    ///
159    /// ```text
160    ///   H_kj = (λ/τ²)·a_k·[ δ_kj·(m − L_k − 1) + a_j·(L_k + L_j + 1 − 2m) ],
161    ///   L_k = ln a_k + 1,   m = Σ_j a_j L_j,
162    /// ```
163    ///
164    /// whose diagonal coincides with [`AnalyticPenalty::hessian_diag`]. Entropy
165    /// is concave in assignment space, so this block is indefinite (negative on
166    /// near-uniform rows). Setting `D_kk = Σ_j |H_kj|` makes `D − H` symmetric
167    /// with nonnegative diagonal and diagonally dominant
168    /// (`D_kk − H_kk = |H_kk| − H_kk + Σ_{j≠k}|H_kj| ≥ Σ_{j≠k}|(D−H)_kj|`),
169    /// hence PSD: `D ⪰ H` and `D ⪰ 0` both hold. `D` is a genuine PSD diagonal
170    /// operator that dominates the dense Hessian's quadratic form — unlike the
171    /// raw indefinite diagonal, which is neither PSD nor a faithful stand-in for
172    /// the dense operator.
173    pub fn psd_majorizer_abs_row_sums(&self, row: &[f64], scale: f64) -> Vec<f64> {
174        let a = self.softmax_row(row);
175        let k = self.k_atoms;
176        let l: Vec<f64> = (0..k).map(|i| entropy_log_plus_one(a[i])).collect();
177        let m: f64 = (0..k).map(|i| a[i] * l[i]).sum();
178        let mut d = vec![0.0_f64; k];
179        for kk in 0..k {
180            // Diagonal entry H_kk.
181            let h_kk = scale * a[kk] * ((m - l[kk] - 1.0) + a[kk] * (2.0 * l[kk] + 1.0 - 2.0 * m));
182            let mut acc = h_kk.abs();
183            // Off-diagonal entries H_kj, j ≠ k.
184            for jj in 0..k {
185                if jj == kk {
186                    continue;
187                }
188                let h_kj = scale * a[kk] * a[jj] * (l[kk] + l[jj] + 1.0 - 2.0 * m);
189                acc += h_kj.abs();
190            }
191            d[kk] = acc;
192        }
193        d
194    }
195
196    /// Exact per-row dense softmax-entropy Hessian wrt the row's logits (#1038),
197    /// scaled by `scale = λ/τ²`. Returns the symmetric `K×K` block
198    ///
199    /// ```text
200    ///   H_kj = scale·a_k·[ δ_kj·(m − L_k − 1) + a_j·(L_k + L_j + 1 − 2m) ],
201    ///   L_k = ln a_k + 1,   m = Σ_r a_r L_r,
202    /// ```
203    ///
204    /// whose diagonal coincides with [`AnalyticPenalty::hessian_diag`] and whose
205    /// quadratic form coincides with [`AnalyticPenalty::hvp`]. This is the dense
206    /// block the Arrow-Schur row factor stores so the criterion's `log|H|` and
207    /// the #1006 θ-adjoint differentiate the SAME operator (not just its
208    /// diagonal). The entropy block alone is gauge-null (`H·𝟙 = 0`, softmax
209    /// shift-invariance); callers must add it to the gauge-breaking data-fit
210    /// row block before factoring — never factor it in isolation.
211    #[must_use]
212    pub fn row_dense_hessian(&self, row_logits: &[f64], scale: f64) -> Array2<f64> {
213        let k = self.k_atoms;
214        let a = self.softmax_row(row_logits);
215        let l: Vec<f64> = (0..k).map(|i| entropy_log_plus_one(a[i])).collect();
216        let m: f64 = (0..k).map(|i| a[i] * l[i]).sum();
217        let mut h = Array2::<f64>::zeros((k, k));
218        for kk in 0..k {
219            for jj in 0..k {
220                let indicator = if kk == jj { 1.0 } else { 0.0 };
221                h[[kk, jj]] = scale
222                    * a[kk]
223                    * (indicator * (m - l[kk] - 1.0) + a[jj] * (l[kk] + l[jj] + 1.0 - 2.0 * m));
224            }
225        }
226        h
227    }
228
229    /// Derivative of the exact per-row dense entropy Hessian
230    /// [`Self::row_dense_hessian`] with respect to a single row logit `z_w`,
231    /// scaled by `scale = λ/τ²`. Returns the symmetric `K×K` block
232    /// `∂H_kj/∂z_w`, the third-derivative tensor slice the #1006 θ-adjoint
233    /// contracts against the row's selected inverse. Built from the SAME
234    /// `(a, L, m)` as [`Self::row_dense_hessian`] (`∂a_r/∂z_w = a_r(δ_rw − a_w)/τ`),
235    /// so value, logdet and adjoint stay on one branch.
236    #[must_use]
237    pub fn row_dense_hessian_logit_derivative(
238        &self,
239        row_logits: &[f64],
240        scale: f64,
241        w: usize,
242    ) -> Array2<f64> {
243        let k = self.k_atoms;
244        let inv_tau = 1.0 / self.temperature;
245        let a = self.softmax_row(row_logits);
246        let l: Vec<f64> = (0..k).map(|i| entropy_log_plus_one(a[i])).collect();
247        let m: f64 = (0..k).map(|i| a[i] * l[i]).sum();
248        // ∂a_r/∂z_w = a_r (δ_rw − a_w)/τ ; ∂L_r/∂z_w = (∂a_r/∂z_w)/a_r.
249        let da: Vec<f64> = (0..k)
250            .map(|r| a[r] * (if r == w { 1.0 } else { 0.0 } - a[w]) * inv_tau)
251            .collect();
252        let dl: Vec<f64> = (0..k)
253            .map(|r| if a[r] > 0.0 { da[r] / a[r] } else { 0.0 })
254            .collect();
255        let dm: f64 = (0..k).map(|r| da[r] * l[r] + a[r] * dl[r]).sum();
256        let mut dh = Array2::<f64>::zeros((k, k));
257        for kk in 0..k {
258            for jj in 0..k {
259                let indicator = if kk == jj { 1.0 } else { 0.0 };
260                // bracket = δ_kj(m − L_k − 1) + a_j(L_k + L_j + 1 − 2m).
261                let bracket =
262                    indicator * (m - l[kk] - 1.0) + a[jj] * (l[kk] + l[jj] + 1.0 - 2.0 * m);
263                let dbracket = indicator * (dm - dl[kk])
264                    + da[jj] * (l[kk] + l[jj] + 1.0 - 2.0 * m)
265                    + a[jj] * (dl[kk] + dl[jj] - 2.0 * dm);
266                dh[[kk, jj]] = scale * (da[kk] * bracket + a[kk] * dbracket);
267            }
268        }
269        dh
270    }
271
272    /// Per-row **Gershgorin diagonal majorizer** `D` of the exact softmax-entropy
273    /// Hessian [`Self::row_dense_hessian`], scaled by `scale = λ/τ²`. Returns the
274    /// `K×K` diagonal block `diag(D_0, …, D_{K−1})` with
275    /// `D_kk = Σ_j |H_kj|` (#1419).
276    ///
277    /// Unlike the Fisher metric [`Self::row_fisher_metric`] — which is PSD but
278    /// does NOT satisfy `G ⪰ H_entropy` (counterexample `a=(0.95,0.05)`,
279    /// `λ=τ=1`: `G₁₁=0.0475 < H₁₁=0.0784`) — this `D` is a genuine Loewner
280    /// majorizer: it is diagonally dominant over `H` (`D_kk − H_kk =
281    /// |H_kk|−H_kk + Σ_{j≠k}|H_kj| ≥ Σ_{j≠k}|(D−H)_kj|`), so `D − H ⪰ 0`, and
282    /// every `D_kk ≥ 0`, so `D ⪰ 0`. It therefore both keeps the assembled
283    /// evidence block PD (the property the entropy block needs so the
284    /// Faddeev–Popov deflation never fires) AND actually majorizes the entropy
285    /// curvature, which the Fisher surrogate did not. The criterion's `log|H|`,
286    /// its θ-adjoint [`Self::row_psd_majorizer_logit_derivative`], and the
287    /// assembled Hessian all differentiate this SAME operator `D`, keeping value
288    /// and adjoint on one exact branch.
289    #[must_use]
290    pub fn row_psd_majorizer(&self, row_logits: &[f64], scale: f64) -> Array2<f64> {
291        let k = self.k_atoms;
292        let d = self.psd_majorizer_abs_row_sums(row_logits, scale);
293        let mut out = Array2::<f64>::zeros((k, k));
294        for kk in 0..k {
295            out[[kk, kk]] = d[kk];
296        }
297        out
298    }
299
300    /// Derivative of the per-row Gershgorin majorizer [`Self::row_psd_majorizer`]
301    /// with respect to a single row logit `z_w`, scaled by `scale = λ/τ²`.
302    /// Returns the `K×K` diagonal block `diag(∂D_0/∂z_w, …)` with
303    /// `∂D_kk/∂z_w = Σ_j sign(H_kj)·(∂H_kj/∂z_w)` (#1419), where `H` is the exact
304    /// entropy Hessian [`Self::row_dense_hessian`] and `∂H_kj/∂z_w` is
305    /// [`Self::row_dense_hessian_logit_derivative`]. `sign(0)=0` (a zero entry
306    /// contributes no first-order change to its own magnitude). Built from the
307    /// SAME `(a, L, m)` derivative convention as the dense Hessian derivative, so
308    /// the θ-adjoint differentiates the SAME `D` the assembly added.
309    #[must_use]
310    pub fn row_psd_majorizer_logit_derivative(
311        &self,
312        row_logits: &[f64],
313        scale: f64,
314        w: usize,
315    ) -> Array2<f64> {
316        let k = self.k_atoms;
317        let h = self.row_dense_hessian(row_logits, scale);
318        let dh = self.row_dense_hessian_logit_derivative(row_logits, scale, w);
319        let mut out = Array2::<f64>::zeros((k, k));
320        for kk in 0..k {
321            let mut acc = 0.0_f64;
322            for jj in 0..k {
323                let s = h[[kk, jj]].signum();
324                if h[[kk, jj]] != 0.0 {
325                    acc += s * dh[[kk, jj]];
326                }
327            }
328            out[[kk, kk]] = acc;
329        }
330        out
331    }
332
333    /// Per-row softmax **Fisher-information metric** `G = scale·(diag(a) − a aᵀ)`
334    /// over the row's logits, with `a = softmax(row_logits)` and
335    /// `scale = λ/τ²` (#1190). Returns the symmetric `K×K` block
336    ///
337    /// ```text
338    ///   G_kj = scale·a_k·(δ_kj − a_j).
339    /// ```
340    ///
341    /// `G` is a covariance/Gram matrix, hence exactly PSD and smooth in the
342    /// logits. It is the Fisher-information metric of the row softmax, NOT a
343    /// curvature majorizer of the entropy Hessian: `G − H_entropy` can be
344    /// indefinite (#1419: `K=2`, `a=(0.95,0.05)`, `λ=τ=1` gives `G₁₁=0.0475 <
345    /// H₁₁=0.0784`, so `G ⋡ H`). The genuine Loewner majorizer the assembled
346    /// evidence block now uses is [`Self::row_psd_majorizer`]
347    /// (`D_kk = Σ_j|H_kj|`, which DOES satisfy `D ⪰ H` and `D ⪰ 0`); this
348    /// Fisher metric is retained only as a smooth PSD conditioning reference and
349    /// its derivative [`Self::row_fisher_metric_logit_derivative`], and must not
350    /// be presented or used as a curvature majorizer.
351    #[must_use]
352    pub fn row_fisher_metric(&self, row_logits: &[f64], scale: f64) -> Array2<f64> {
353        let k = self.k_atoms;
354        let a = self.softmax_row(row_logits);
355        let mut g = Array2::<f64>::zeros((k, k));
356        for kk in 0..k {
357            for jj in 0..k {
358                let indicator = if kk == jj { 1.0 } else { 0.0 };
359                g[[kk, jj]] = scale * a[kk] * (indicator - a[jj]);
360            }
361        }
362        g
363    }
364
365    /// Derivative of the per-row softmax Fisher metric
366    /// [`Self::row_fisher_metric`] with respect to a single row logit `z_w`,
367    /// scaled by `scale = λ/τ²` (#1190). Returns the symmetric `K×K` block
368    /// `∂G_kj/∂z_w`, the third-derivative tensor slice the θ-adjoint contracts
369    /// against the row's selected inverse so the adjoint differentiates the SAME
370    /// PSD `G = scale·(diag(a) − a aᵀ)` the assembly added (value/adjoint on one
371    /// branch, no deflation needed). Built from the SAME softmax derivative
372    /// convention as [`Self::row_dense_hessian_logit_derivative`]
373    /// (`∂a_r/∂z_w = a_r(δ_rw − a_w)/τ`). For `G_kj = scale·a_k(δ_kj − a_j)`,
374    /// the product rule gives
375    /// `∂G_kj/∂z_w = scale·[ (∂a_k/∂z_w)(δ_kj − a_j) − a_k(∂a_j/∂z_w) ]`.
376    #[must_use]
377    pub fn row_fisher_metric_logit_derivative(
378        &self,
379        row_logits: &[f64],
380        scale: f64,
381        w: usize,
382    ) -> Array2<f64> {
383        let k = self.k_atoms;
384        let inv_tau = 1.0 / self.temperature;
385        let a = self.softmax_row(row_logits);
386        // ∂a_r/∂z_w = a_r (δ_rw − a_w)/τ — identical convention to the entropy
387        // Hessian derivative above.
388        let da: Vec<f64> = (0..k)
389            .map(|r| a[r] * (if r == w { 1.0 } else { 0.0 } - a[w]) * inv_tau)
390            .collect();
391        let mut dg = Array2::<f64>::zeros((k, k));
392        for kk in 0..k {
393            for jj in 0..k {
394                let indicator = if kk == jj { 1.0 } else { 0.0 };
395                dg[[kk, jj]] = scale * (da[kk] * (indicator - a[jj]) - a[kk] * da[jj]);
396            }
397        }
398        dg
399    }
400}
401
402impl AnalyticPenalty for SoftmaxAssignmentSparsityPenalty {
403    fn tier(&self) -> PenaltyTier {
404        PenaltyTier::Psi
405    }
406
407    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
408        let lambda = resolve_learnable_weight(self.weight, rho[0]);
409        let n = target.len() / self.k_atoms;
410        let values: Vec<f64> = target.iter().copied().collect();
411        let mut acc = 0.0;
412        for row in 0..n {
413            let start = row * self.k_atoms;
414            let a = self.softmax_row(&values[start..start + self.k_atoms]);
415            let w_row = self.row_weight(row);
416            for v in a {
417                if v > 0.0 {
418                    acc += -w_row * v * v.ln();
419                }
420            }
421        }
422        lambda * acc
423    }
424
425    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
426        let lambda = resolve_learnable_weight(self.weight, rho[0]);
427        let n = target.len() / self.k_atoms;
428        let values: Vec<f64> = target.iter().copied().collect();
429        let mut out = Array1::<f64>::zeros(target.len());
430        let inv_tau = 1.0 / self.temperature;
431        for row in 0..n {
432            let start = row * self.k_atoms;
433            let a = self.softmax_row(&values[start..start + self.k_atoms]);
434            let w_row = self.row_weight(row);
435            let mut d_h_da = vec![0.0; self.k_atoms];
436            let mut mean = 0.0;
437            for k in 0..self.k_atoms {
438                d_h_da[k] = -lambda * entropy_log_plus_one(a[k]);
439                mean += a[k] * d_h_da[k];
440            }
441            for k in 0..self.k_atoms {
442                out[start + k] = w_row * a[k] * (d_h_da[k] - mean) * inv_tau;
443            }
444        }
445        out
446    }
447
448    fn hessian_diag(
449        &self,
450        target: ArrayView1<'_, f64>,
451        rho: ArrayView1<'_, f64>,
452    ) -> Option<Array1<f64>> {
453        assert_eq!(rho.len(), 1, "softmax entropy expects one rho parameter");
454        assert!(
455            rho.iter().all(|value| value.is_finite()),
456            "softmax entropy rho must be finite"
457        );
458        assert_eq!(
459            target.len() % self.k_atoms,
460            0,
461            "softmax entropy target length must be divisible by k_atoms"
462        );
463        // Closed-form diagonal of the softmax-entropy Hessian wrt logits.
464        // Derived by probing the row-dense HVP with the unit vector e_k:
465        // for a row with softmax weights a_k and L_k = ln a_k + 1,
466        //   H_kk = (lambda / tau^2) * a_k *
467        //          ((1 - 2 a_k) * (E_a[L] - L_k) + a_k - 1).
468        // This matches `hvp(...) . e_k` analytically (see derivation in the
469        // bug-fix comment on `hvp`) and gives Newton/Arrow-Schur callers a
470        // principled diagonal surrogate without per-row dense factorization.
471        let lambda = resolve_learnable_weight(self.weight, rho[0]);
472        let inv_tau = 1.0 / self.temperature;
473        let scale = lambda * inv_tau * inv_tau;
474        let n = target.len() / self.k_atoms;
475        let values: Vec<f64> = target.iter().copied().collect();
476        let mut out = Array1::<f64>::zeros(target.len());
477        for row in 0..n {
478            let start = row * self.k_atoms;
479            let a = self.softmax_row(&values[start..start + self.k_atoms]);
480            let w_row = self.row_weight(row);
481            let mut mean_log_plus_one = 0.0;
482            for k in 0..self.k_atoms {
483                mean_log_plus_one += a[k] * entropy_log_plus_one(a[k]);
484            }
485            for k in 0..self.k_atoms {
486                let log_plus_one = entropy_log_plus_one(a[k]);
487                let term = (1.0 - 2.0 * a[k]) * (mean_log_plus_one - log_plus_one) + a[k] - 1.0;
488                out[start + k] = w_row * scale * a[k] * term;
489            }
490        }
491        Some(out)
492    }
493
494    fn hvp(
495        &self,
496        target: ArrayView1<'_, f64>,
497        rho: ArrayView1<'_, f64>,
498        v: ArrayView1<'_, f64>,
499    ) -> Array1<f64> {
500        /*
501        Softmax entropy is not coordinate-separable in logits. The old
502        `hessian_diag` returned λ p_k(1-p_k)/τ², which is only the softmax
503        Jacobian diagonal and omits the entropy curvature and all cross-logit
504        terms. For H(p(z)), p'=p*(v-E_p[v])/τ and
505        (log p_k + 1)'=(v_k-E_p[v])/τ. Differentiating
506        g_k=λ p_k(E_p[log p + 1]-(log p_k+1))/τ gives the row-dense product
507        below. `hessian_diag` returns the analytic diagonal extracted from
508        this HVP by setting v = e_k row-by-row.
509        */
510        let lambda = resolve_learnable_weight(self.weight, rho[0]);
511        assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
512        let n = target.len() / self.k_atoms;
513        let values: Vec<f64> = target.iter().copied().collect();
514        let mut out = Array1::<f64>::zeros(target.len());
515        let inv_tau = 1.0 / self.temperature;
516        let scale = lambda * inv_tau * inv_tau;
517        for row in 0..n {
518            let start = row * self.k_atoms;
519            let a = self.softmax_row(&values[start..start + self.k_atoms]);
520            let w_row = self.row_weight(row);
521            let mut mean_log_plus_one = 0.0;
522            let mut mean_v = 0.0;
523            for k in 0..self.k_atoms {
524                mean_log_plus_one += a[k] * entropy_log_plus_one(a[k]);
525                mean_v += a[k] * v[start + k];
526            }
527            let mut mean_centered_v_log_plus_one = 0.0;
528            for k in 0..self.k_atoms {
529                let centered_v = v[start + k] - mean_v;
530                mean_centered_v_log_plus_one += a[k] * centered_v * entropy_log_plus_one(a[k]);
531            }
532            for k in 0..self.k_atoms {
533                let log_plus_one = entropy_log_plus_one(a[k]);
534                let centered_v = v[start + k] - mean_v;
535                out[start + k] = w_row
536                    * scale
537                    * a[k]
538                    * (centered_v * (mean_log_plus_one - log_plus_one - 1.0)
539                        + mean_centered_v_log_plus_one);
540            }
541        }
542        out
543    }
544
545    fn psd_majorizer_diag(
546        &self,
547        target: ArrayView1<'_, f64>,
548        rho: ArrayView1<'_, f64>,
549    ) -> Option<Array1<f64>> {
550        assert_eq!(rho.len(), 1, "softmax entropy expects one rho parameter");
551        assert_eq!(
552            target.len() % self.k_atoms,
553            0,
554            "softmax entropy target length must be divisible by k_atoms"
555        );
556        // Entropy minimization is nonconvex: the exact per-row Hessian is dense
557        // and indefinite, so the convex-only trait default (which returns the
558        // raw indefinite `hessian_diag`) violates the `B ⪰ 0` contract and is a
559        // diagonal masquerading as a dense operator. Replace it with the
560        // Gershgorin / diagonal-dominance majorizer of the dense per-row block
561        // (see `psd_majorizer_abs_row_sums`): a genuine PSD diagonal with
562        // `D ⪰ H` and `D ⪰ 0`. Coordinate-indexed, so the inherited
563        // `psd_majorizer_hvp` applies `D` as a diagonal operator consistently.
564        let lambda = resolve_learnable_weight(self.weight, rho[0]);
565        let inv_tau = 1.0 / self.temperature;
566        let scale = lambda * inv_tau * inv_tau;
567        let n = target.len() / self.k_atoms;
568        let values: Vec<f64> = target.iter().copied().collect();
569        let mut out = Array1::<f64>::zeros(target.len());
570        for row in 0..n {
571            let start = row * self.k_atoms;
572            let w_row = self.row_weight(row);
573            let d = self.psd_majorizer_abs_row_sums(&values[start..start + self.k_atoms], scale);
574            for k in 0..self.k_atoms {
575                out[start + k] = w_row * d[k];
576            }
577        }
578        Some(out)
579    }
580
581    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
582        Array1::from_vec(vec![self.value(target, rho)])
583    }
584
585    fn rho_count(&self) -> usize {
586        1
587    }
588
589    fn name(&self) -> &str {
590        "softmax_assignment_sparsity"
591    }
592
593    impl_scalar_apply_schedule!(weight);
594}
595
596impl SparsityPenalty {
597    #[must_use = "build error must be handled"]
598    pub fn smoothed_l1(target_tier: PenaltyTier, eps: f64) -> Result<Self, String> {
599        if !(eps.is_finite() && eps > 0.0) {
600            return Err(format!(
601                "SparsityPenalty::smoothed_l1 requires eps > 0 \
602                 (Hessian / gradient have a `1/sqrt(x² + eps²)` factor that needs eps > 0 \
603                 for differentiability at x = 0); got eps = {eps}"
604            ));
605        }
606        Ok(Self {
607            target_tier,
608            kind: SparsityKind::SmoothedL1 { eps },
609            weight: 1.0,
610            weight_schedule: None,
611            strength_rho_index: 0,
612            eps_rho_index: None,
613        })
614    }
615
616    #[must_use = "build error must be handled"]
617    pub fn log(target_tier: PenaltyTier, delta: f64) -> Result<Self, String> {
618        if !(delta.is_finite() && delta > 0.0) {
619            return Err(format!(
620                "SparsityPenalty::log requires delta > 0 \
621                 (the log-sparsifier is log(1 + x²/δ²), undefined at δ = 0); \
622                 got delta = {delta}"
623            ));
624        }
625        Ok(Self {
626            target_tier,
627            kind: SparsityKind::Log { delta },
628            weight: 1.0,
629            weight_schedule: None,
630            strength_rho_index: 0,
631            eps_rho_index: None,
632        })
633    }
634
635    /// Hoyer scale-invariant sparsifier. Requires a target of length > 1
636    /// because the normalized form divides by `sqrt(n) - 1`.
637    #[must_use]
638    pub fn hoyer(target_tier: PenaltyTier) -> Self {
639        Self {
640            target_tier,
641            kind: SparsityKind::Hoyer,
642            weight: 1.0,
643            weight_schedule: None,
644            strength_rho_index: 0,
645            eps_rho_index: None,
646        }
647    }
648
649    impl_with_weight_schedule!(weight);
650
651    #[must_use]
652    pub fn with_eps_reml(mut self, eps_rho_index: usize) -> Self {
653        self.eps_rho_index = Some(eps_rho_index);
654        self
655    }
656
657    /// Resolve `(strength, eps_or_delta)` from the current ρ view.
658    fn resolved(&self, rho: ArrayView1<'_, f64>) -> (f64, f64) {
659        let strength = resolve_learnable_weight(self.weight, rho[self.strength_rho_index]);
660        let smoothing = match (self.eps_rho_index, self.kind) {
661            // A learnable smoothing `exp(rho)` underflows to exact `0.0` for
662            // `rho ≲ -745`, which reintroduces a non-differentiable kink and a
663            // `0/0` at `x = 0` in `sqrt(x² + ε²)` / the Log sparsifier. Floor it
664            // at the smallest positive normal so the smoothing stays strictly
665            // positive while still shrinking arbitrarily close to zero.
666            (Some(idx), _) => rho[idx].exp().max(f64::MIN_POSITIVE),
667            (None, SparsityKind::SmoothedL1 { eps }) => eps,
668            (None, SparsityKind::Log { delta }) => delta,
669            (None, SparsityKind::Hoyer) => 0.0,
670        };
671        (strength, smoothing)
672    }
673}
674
675impl AnalyticPenalty for SparsityPenalty {
676    fn tier(&self) -> PenaltyTier {
677        self.target_tier
678    }
679
680    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
681        let (lam, smooth) = self.resolved(rho);
682        match self.kind {
683            SparsityKind::SmoothedL1 { .. } => {
684                let mut acc = 0.0;
685                for &x in target.iter() {
686                    acc += (x * x + smooth * smooth).sqrt();
687                }
688                lam * acc
689            }
690            SparsityKind::Hoyer => {
691                // Normalized anti-sparsity penalty
692                //   P(x) = (||x||_1 / ||x||_2 - 1) / (sqrt(n) - 1)
693                // maps [1, sqrt(n)] -> [0, 1]. A perfectly dense
694                // equal-magnitude vector hits ||x||_1/||x||_2 = sqrt(n),
695                // so P = 1; a 1-sparse vector has ratio 1, so P = 0
696                // (sparse vectors minimize the penalty).
697                let n = target.len() as f64;
698                assert!(n > 1.0, "Hoyer requires n > 1");
699                let l1: f64 = target.iter().map(|x| x.abs()).sum();
700                let l2: f64 = target.iter().map(|x| x * x).sum::<f64>().sqrt();
701                if l2 == 0.0 {
702                    return 0.0;
703                }
704                let h = (l1 / l2 - 1.0) / (n.sqrt() - 1.0);
705                lam * h
706            }
707            SparsityKind::Log { .. } => {
708                let mut acc = 0.0;
709                let d2 = smooth * smooth;
710                for &x in target.iter() {
711                    acc += (1.0 + x * x / d2).ln();
712                }
713                lam * acc
714            }
715        }
716    }
717
718    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
719        let (lam, smooth) = self.resolved(rho);
720        let mut g = Array1::<f64>::zeros(target.len());
721        match self.kind {
722            SparsityKind::SmoothedL1 { .. } => {
723                let eps2 = smooth * smooth;
724                for (i, &x) in target.iter().enumerate() {
725                    g[i] = lam * x / (x * x + eps2).sqrt();
726                }
727            }
728            SparsityKind::Hoyer => {
729                // P(x) = A · (L1/L2 - 1), A = lam / (sqrt(n) - 1).
730                // ∂P/∂x_i = A · (sign(x_i)/L2 - L1 · x_i / L2³).
731                let n = target.len() as f64;
732                assert!(n > 1.0, "Hoyer requires n > 1");
733                let l1: f64 = target.iter().map(|x| x.abs()).sum();
734                let l2: f64 = target.iter().map(|x| x * x).sum::<f64>().sqrt();
735                if l2 == 0.0 {
736                    return g;
737                }
738                let denom = n.sqrt() - 1.0;
739                let a = lam / denom;
740                let inv_l2 = 1.0 / l2;
741                let inv_l2_cubed = inv_l2 * inv_l2 * inv_l2;
742                for (i, &x) in target.iter().enumerate() {
743                    let sgn = if x > 0.0 {
744                        1.0
745                    } else if x < 0.0 {
746                        -1.0
747                    } else {
748                        0.0
749                    };
750                    g[i] = a * (sgn * inv_l2 - l1 * x * inv_l2_cubed);
751                }
752            }
753            SparsityKind::Log { .. } => {
754                let d2 = smooth * smooth;
755                for (i, &x) in target.iter().enumerate() {
756                    g[i] = lam * 2.0 * x / (d2 + x * x);
757                }
758            }
759        }
760        g
761    }
762
763    fn hessian_diag(
764        &self,
765        target: ArrayView1<'_, f64>,
766        rho: ArrayView1<'_, f64>,
767    ) -> Option<Array1<f64>> {
768        let (lam, smooth) = self.resolved(rho);
769        match self.kind {
770            SparsityKind::SmoothedL1 { .. } => {
771                let mut d = Array1::<f64>::zeros(target.len());
772                let eps2 = smooth * smooth;
773                for (i, &x) in target.iter().enumerate() {
774                    let r = (x * x + eps2).sqrt();
775                    d[i] = lam * eps2 / (r * r * r);
776                }
777                Some(d)
778            }
779            SparsityKind::Log { .. } => {
780                let mut d = Array1::<f64>::zeros(target.len());
781                // The EXACT second derivative of λ log(1 + x²/δ²):
782                //   d/dx [ 2λx/(δ²+x²) ] = 2λ(δ² − x²)/(δ² + x²)²,
783                // which is NEGATIVE for |x| > δ — Log is nonconvex. This is
784                // the genuine Hessian diagonal and exactly differentiates
785                // `grad_target`. PSD consumers (Newton block, preconditioner,
786                // `log_det_plus_λI`, FrozenAnalyticPenaltyOp) must instead
787                // route through `psd_majorizer_diag`/`psd_majorizer_hvp`,
788                // which expose the IRLS/MM surrogate `2λ/(δ²+x²)`.
789                let d2 = smooth * smooth;
790                for (i, &x) in target.iter().enumerate() {
791                    let denom = d2 + x * x;
792                    d[i] = lam * 2.0 * (d2 - x * x) / (denom * denom);
793                }
794                Some(d)
795            }
796            // Hoyer's Hessian is DENSE and NOT generally PSD (Hoyer is a
797            // nonconvex sparsifier). We cannot return a meaningful diagonal
798            // that would be safe to use as a preconditioner / Newton block
799            // through the standard `hessian_diag` path, so we return `None`
800            // and force callers through `hvp`. See `hvp` below for the exact
801            // dense-Hessian-vector product.
802            SparsityKind::Hoyer => None,
803        }
804    }
805
806    fn hvp(
807        &self,
808        target: ArrayView1<'_, f64>,
809        rho: ArrayView1<'_, f64>,
810        v: ArrayView1<'_, f64>,
811    ) -> Array1<f64> {
812        // For SmoothedL1/Log/Hoyer we route through the closed-form Hessian.
813        // SmoothedL1 and Log have purely diagonal Hessians and would
814        // ordinarily reach the diagonal branch of the default `hvp`; we
815        // override here to also serve Hoyer (whose Hessian is dense
816        // rank-1-plus-diagonal).
817        let (lam, smooth) = self.resolved(rho);
818        let n_target = target.len();
819        assert_eq!(v.len(), n_target, "hvp dimension mismatch");
820        match self.kind {
821            SparsityKind::SmoothedL1 { .. } => {
822                let mut out = Array1::<f64>::zeros(n_target);
823                let eps2 = smooth * smooth;
824                for (i, &x) in target.iter().enumerate() {
825                    let r = (x * x + eps2).sqrt();
826                    out[i] = lam * eps2 / (r * r * r) * v[i];
827                }
828                out
829            }
830            SparsityKind::Log { .. } => {
831                // EXACT Hessian-vector product: the Log Hessian is diagonal
832                // with entries 2λ(δ²−x²)/(δ²+x²)², so (Hv)_i = h_i v_i. This
833                // is the genuine second derivative (indefinite for |x|>δ).
834                // PSD consumers use `psd_majorizer_hvp` for the IRLS/MM
835                // surrogate 2λ/(δ²+x²) instead.
836                let mut out = Array1::<f64>::zeros(n_target);
837                let d2 = smooth * smooth;
838                for (i, &x) in target.iter().enumerate() {
839                    let denom = d2 + x * x;
840                    out[i] = lam * 2.0 * (d2 - x * x) / (denom * denom) * v[i];
841                }
842                out
843            }
844            SparsityKind::Hoyer => {
845                // P(x) = A · (L1/L2 - 1), A = lam / (sqrt(n) - 1).
846                // H_ij = A · [ -s_i x_j/L2³ - x_i s_j/L2³
847                //              - L1 δ_ij/L2³ + 3 L1 x_i x_j/L2⁵ ]
848                // (Hv)_i = A · [ -s_i (xᵀv)/L2³ - x_i (sᵀv)/L2³
849                //                - L1 v_i/L2³ + 3 L1 x_i (xᵀv)/L2⁵ ]
850                let n = n_target as f64;
851                assert!(n > 1.0, "Hoyer requires n > 1");
852                let l1: f64 = target.iter().map(|x| x.abs()).sum();
853                let l2: f64 = target.iter().map(|x| x * x).sum::<f64>().sqrt();
854                let mut out = Array1::<f64>::zeros(n_target);
855                if l2 == 0.0 {
856                    return out;
857                }
858                let a = lam / (n.sqrt() - 1.0);
859                let inv_l2_cubed = 1.0 / (l2 * l2 * l2);
860                let inv_l2_5 = inv_l2_cubed / (l2 * l2);
861                let mut x_dot_v = 0.0;
862                let mut s_dot_v = 0.0;
863                for i in 0..n_target {
864                    let xi = target[i];
865                    let si = if xi > 0.0 {
866                        1.0
867                    } else if xi < 0.0 {
868                        -1.0
869                    } else {
870                        0.0
871                    };
872                    x_dot_v += xi * v[i];
873                    s_dot_v += si * v[i];
874                }
875                for i in 0..n_target {
876                    let xi = target[i];
877                    let si = if xi > 0.0 {
878                        1.0
879                    } else if xi < 0.0 {
880                        -1.0
881                    } else {
882                        0.0
883                    };
884                    out[i] = a
885                        * (-si * x_dot_v * inv_l2_cubed
886                            - xi * s_dot_v * inv_l2_cubed
887                            - l1 * v[i] * inv_l2_cubed
888                            + 3.0 * l1 * xi * x_dot_v * inv_l2_5);
889                }
890                out
891            }
892        }
893    }
894
895    fn psd_majorizer_diag(
896        &self,
897        target: ArrayView1<'_, f64>,
898        rho: ArrayView1<'_, f64>,
899    ) -> Option<Array1<f64>> {
900        let (lam, smooth) = self.resolved(rho);
901        match self.kind {
902            // SmoothedL1 is convex: the majorizer equals the exact Hessian.
903            SparsityKind::SmoothedL1 { .. } => self.hessian_diag(target, rho),
904            // Log is nonconvex; expose the IRLS/MM re-weighted-ℓ₂ surrogate
905            //   2λ/(δ²+x²) ⪰ 2λ(δ²−x²)/(δ²+x²)²,
906            // strictly positive, agreeing with the exact Hessian at x = 0.
907            SparsityKind::Log { .. } => {
908                let mut d = Array1::<f64>::zeros(target.len());
909                let d2 = smooth * smooth;
910                for (i, &x) in target.iter().enumerate() {
911                    d[i] = lam * 2.0 / (d2 + x * x);
912                }
913                Some(d)
914            }
915            // Hoyer's Hessian is dense; no diagonal majorizer. Callers fall
916            // back to the exact dense `hvp` through `psd_majorizer_hvp`.
917            SparsityKind::Hoyer => None,
918        }
919    }
920
921    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
922        // Strength axis: ∂P/∂ρ_strength = P (chain rule through exp).
923        // ε axis (if owned): ∂P/∂ρ_eps = ε · ∂P/∂ε.
924        let n_rho = self.rho_count();
925        let mut out = Array1::<f64>::zeros(n_rho);
926        let p_val = self.value(target, rho);
927        out[self.strength_rho_index] = p_val;
928        if let Some(eps_idx) = self.eps_rho_index {
929            let (lam, smooth) = self.resolved(rho);
930            let mut dp_deps = 0.0;
931            match self.kind {
932                SparsityKind::SmoothedL1 { .. } => {
933                    for &x in target.iter() {
934                        dp_deps += smooth / (x * x + smooth * smooth).sqrt();
935                    }
936                    dp_deps *= lam;
937                }
938                SparsityKind::Log { .. } => {
939                    // d/dδ log(1 + x²/δ²) = -2 x² / (δ (δ² + x²))
940                    let d2 = smooth * smooth;
941                    for &x in target.iter() {
942                        dp_deps += -2.0 * x * x / (smooth * (d2 + x * x));
943                    }
944                    dp_deps *= lam;
945                }
946                SparsityKind::Hoyer => {}
947            }
948            // Chain through ρ_eps = log(ε)  ⇒  ∂ε/∂ρ_eps = ε.
949            out[eps_idx] = smooth * dp_deps;
950        }
951        out
952    }
953
954    fn rho_count(&self) -> usize {
955        1 + if self.eps_rho_index.is_some() { 1 } else { 0 }
956    }
957
958    fn name(&self) -> &str {
959        "sparsity"
960    }
961
962    impl_scalar_apply_schedule!(weight);
963}
964
965// ---------------------------------------------------------------------------
966// TopK activation penalty
967// ---------------------------------------------------------------------------
968
969#[derive(Debug, Clone)]
970pub struct TopKActivationPenalty {
971    pub target: PsiSlice,
972    pub k: usize,
973    pub latent_dim: usize,
974    pub weight: f64,
975    pub weight_schedule: Option<ScalarWeightSchedule>,
976}
977
978impl TopKActivationPenalty {
979    #[must_use = "build error must be handled"]
980    pub fn new(target: PsiSlice, k: usize, weight: f64) -> Result<Self, String> {
981        let latent_dim = target
982            .latent_dim
983            .ok_or_else(|| "TopKActivationPenalty::new requires target.latent_dim".to_string())?;
984        if latent_dim == 0 {
985            return Err("TopKActivationPenalty::new requires latent_dim > 0".to_string());
986        }
987        if k == 0 || k > latent_dim {
988            return Err(format!(
989                "TopKActivationPenalty::new requires 0 < k <= latent_dim; got k={k}, latent_dim={latent_dim}"
990            ));
991        }
992        if !(weight.is_finite() && weight > 0.0) {
993            return Err(format!(
994                "TopKActivationPenalty::new requires finite weight > 0, got {weight}"
995            ));
996        }
997        Ok(Self {
998            target,
999            k,
1000            latent_dim,
1001            weight,
1002            weight_schedule: None,
1003        })
1004    }
1005
1006    impl_with_weight_schedule!(weight);
1007
1008    fn topk_mask_row(&self, target: ArrayView1<'_, f64>, row: usize, mask: &mut [bool]) {
1009        mask.fill(false);
1010        let d = self.latent_dim;
1011        let base = row * d;
1012        let mut order = (0..d).collect::<Vec<_>>();
1013        order.sort_by(|&a, &b| {
1014            target[base + b]
1015                .abs()
1016                .total_cmp(&target[base + a].abs())
1017                .then_with(|| a.cmp(&b))
1018        });
1019        for &axis in order.iter().take(self.k) {
1020            mask[axis] = true;
1021        }
1022    }
1023}
1024
1025impl AnalyticPenalty for TopKActivationPenalty {
1026    fn tier(&self) -> PenaltyTier {
1027        PenaltyTier::Psi
1028    }
1029
1030    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1031        assert_eq!(rho.len(), 0, "TopKActivationPenalty has no rho parameters");
1032        let d = self.latent_dim;
1033        let n_obs = target.len() / d;
1034        let mut mask = vec![false; d];
1035        let mut acc = 0.0;
1036        for row in 0..n_obs {
1037            self.topk_mask_row(target, row, &mut mask);
1038            let base = row * d;
1039            for axis in 0..d {
1040                if mask[axis] {
1041                    let v = target[base + axis];
1042                    acc += 0.5 * self.weight * v * v;
1043                }
1044            }
1045        }
1046        acc
1047    }
1048
1049    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1050        assert_eq!(rho.len(), 0, "TopKActivationPenalty has no rho parameters");
1051        let d = self.latent_dim;
1052        let n_obs = target.len() / d;
1053        let mut mask = vec![false; d];
1054        let mut grad = Array1::<f64>::zeros(target.len());
1055        for row in 0..n_obs {
1056            self.topk_mask_row(target, row, &mut mask);
1057            let base = row * d;
1058            for axis in 0..d {
1059                if mask[axis] {
1060                    grad[base + axis] = self.weight * target[base + axis];
1061                }
1062            }
1063        }
1064        grad
1065    }
1066
1067    fn hessian_diag(
1068        &self,
1069        target: ArrayView1<'_, f64>,
1070        rho: ArrayView1<'_, f64>,
1071    ) -> Option<Array1<f64>> {
1072        assert_eq!(rho.len(), 0, "TopKActivationPenalty has no rho parameters");
1073        let d = self.latent_dim;
1074        let n_obs = target.len() / d;
1075        let mut mask = vec![false; d];
1076        let mut diag = Array1::<f64>::zeros(target.len());
1077        for row in 0..n_obs {
1078            self.topk_mask_row(target, row, &mut mask);
1079            let base = row * d;
1080            for axis in 0..d {
1081                if mask[axis] {
1082                    diag[base + axis] = self.weight;
1083                }
1084            }
1085        }
1086        Some(diag)
1087    }
1088
1089    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1090        assert_eq!(rho.len(), 0, "TopKActivationPenalty has no rho parameters");
1091        assert_eq!(
1092            target.len() % self.latent_dim,
1093            0,
1094            "TopKActivationPenalty target length must be a multiple of latent_dim"
1095        );
1096        Array1::<f64>::zeros(0)
1097    }
1098
1099    fn rho_count(&self) -> usize {
1100        0
1101    }
1102
1103    fn name(&self) -> &str {
1104        "topk_activation"
1105    }
1106
1107    impl_scalar_apply_schedule!(weight);
1108}
1109
1110// ---------------------------------------------------------------------------
1111// JumpReLU penalty
1112// ---------------------------------------------------------------------------
1113
1114#[derive(Debug, Clone)]
1115pub struct JumpReLUPenalty {
1116    pub target: PsiSlice,
1117    pub latent_dim: usize,
1118    pub thresholds: Array1<f64>,
1119    pub weight: f64,
1120    pub smoothing_eps: f64,
1121    pub weight_schedule: Option<ScalarWeightSchedule>,
1122}
1123
1124impl JumpReLUPenalty {
1125    #[must_use = "build error must be handled"]
1126    pub fn new(
1127        target: PsiSlice,
1128        thresholds: Array1<f64>,
1129        weight: f64,
1130        smoothing_eps: f64,
1131    ) -> Result<Self, String> {
1132        let latent_dim = target
1133            .latent_dim
1134            .ok_or_else(|| "JumpReLUPenalty::new requires target.latent_dim".to_string())?;
1135        if latent_dim == 0 {
1136            return Err("JumpReLUPenalty::new requires latent_dim > 0".to_string());
1137        }
1138        if thresholds.len() != latent_dim {
1139            return Err(format!(
1140                "JumpReLUPenalty::new thresholds length {} does not match latent_dim {latent_dim}",
1141                thresholds.len()
1142            ));
1143        }
1144        for (idx, &tau) in thresholds.iter().enumerate() {
1145            if !(tau.is_finite() && tau > 0.0) {
1146                return Err(format!(
1147                    "JumpReLUPenalty::new thresholds[{idx}] must be finite and > 0, got {tau}"
1148                ));
1149            }
1150        }
1151        if !(weight.is_finite() && weight > 0.0) {
1152            return Err(format!(
1153                "JumpReLUPenalty::new requires finite weight > 0, got {weight}"
1154            ));
1155        }
1156        if !(smoothing_eps.is_finite() && smoothing_eps > 0.0) {
1157            return Err(format!(
1158                "JumpReLUPenalty::new requires finite smoothing_eps > 0, got {smoothing_eps}"
1159            ));
1160        }
1161        Ok(Self {
1162            target,
1163            latent_dim,
1164            thresholds,
1165            weight,
1166            smoothing_eps,
1167            weight_schedule: None,
1168        })
1169    }
1170
1171    impl_with_weight_schedule!(weight);
1172
1173    fn threshold(&self, axis: usize, rho: ArrayView1<'_, f64>) -> f64 {
1174        // A learnable threshold `θ·exp(rho)` overflows to `inf` for large `rho`;
1175        // the downstream gate `σ((l−θ)/τ)` then evaluates `inf·gate = NaN`. Clamp
1176        // the log-magnitude so the threshold stays a finite normal.
1177        resolve_learnable_weight(self.thresholds[axis], rho[axis])
1178    }
1179
1180    pub(crate) fn sigmoid_gate(&self, x: f64) -> f64 {
1181        if x >= 0.0 {
1182            1.0 / (1.0 + (-x).exp())
1183        } else {
1184            let ex = x.exp();
1185            ex / (1.0 + ex)
1186        }
1187    }
1188
1189    fn true_hessian_diag_entry(&self, tau: f64, gate: f64) -> f64 {
1190        self.weight * tau * gate * (1.0 - gate) * (1.0 - 2.0 * gate)
1191            / (self.smoothing_eps * self.smoothing_eps)
1192    }
1193
1194    fn psd_hessian_diag_entry(&self, tau: f64, gate: f64) -> f64 {
1195        // Genuine PSD majorizer of the indefinite exact diagonal Hessian
1196        //   h(g) = λτ·g(1−g)(1−2g)/ε².
1197        // The bare re-weighted-ℓ₂ surrogate λτ·[g(1−g)]²/ε² is ≥ 0 but only
1198        // dominates h in the concave region g > ½. For g < (3−√5)/2 ≈ 0.382 the
1199        // exact curvature is positive and strictly larger, so the square alone
1200        // is NOT an upper bound — the `B ⪰ ∂²P` contract is violated for exactly
1201        // the comfortably-below-threshold (inactive) coordinates JumpReLU is
1202        // meant to suppress, costing the MM step its monotone-decrease guarantee.
1203        //
1204        // Take the elementwise max of that surrogate and the absolute exact
1205        // Hessian |h| = λτ·g(1−g)|1−2g|/ε². Since |h| ≥ h everywhere and ≥ 0, the
1206        // max is a true PSD upper bound; it equals |h| in the wings (tight where
1207        // the bare square failed) and keeps the surrogate's strictly-positive
1208        // floor near the inflection g ≈ ½ (where h ≈ 0) so the curvature block
1209        // never collapses to zero.
1210        let slope = gate * (1.0 - gate);
1211        let reweighted_l2 = slope * slope;
1212        let abs_exact = slope * (1.0 - 2.0 * gate).abs();
1213        self.weight * tau * reweighted_l2.max(abs_exact) / (self.smoothing_eps * self.smoothing_eps)
1214    }
1215}
1216
1217/// JumpReLU activation gate `φ(z) = z · 1[z > τ]` together with the
1218/// straight-through-estimator derivatives of its smooth surrogate
1219/// `φ̃(z) = z · σ((z − τ)/ε)`. The forward value is the hard gate; the backward
1220/// uses the surrogate's gradients so the activation has a usable subgradient in
1221/// the smoothing band `|z − τ| ≲ ε`:
1222///
1223///   g       = σ((z − τ)/ε)
1224///   φ        = z · 1[z > τ]                 (returned value)
1225///   ∂φ̃/∂z   = g + z · g (1 − g) / ε          (`dphi_dz`)
1226///   ∂φ̃/∂τ   = − z · g (1 − g) / ε            (`dphi_dtau`)
1227///
1228/// This is the single Rust source of truth that `gamfit.torch`'s
1229/// `_JumpReLUSTEFn` consumes so the torch activation gate's backward matches the
1230/// smoothed gate exactly instead of re-deriving it in Python.
1231#[must_use]
1232pub fn jumprelu_gate_value_grad(z: f64, tau: f64, smoothing_eps: f64) -> (f64, f64, f64) {
1233    let g = gam_linalg::utils::stable_logistic((z - tau) / smoothing_eps);
1234    let value = if z > tau { z } else { 0.0 };
1235    let slope = z * g * (1.0 - g) / smoothing_eps;
1236    let dphi_dz = g + slope;
1237    let dphi_dtau = -slope;
1238    (value, dphi_dz, dphi_dtau)
1239}
1240
1241impl AnalyticPenalty for JumpReLUPenalty {
1242    fn tier(&self) -> PenaltyTier {
1243        PenaltyTier::Psi
1244    }
1245
1246    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1247        let d = self.latent_dim;
1248        let n_obs = target.len() / d;
1249        let mut acc = 0.0;
1250        for row in 0..n_obs {
1251            let base = row * d;
1252            for axis in 0..d {
1253                let tau = self.threshold(axis, rho);
1254                let gate = self.sigmoid_gate((target[base + axis] - tau) / self.smoothing_eps);
1255                acc += self.weight * tau * gate;
1256            }
1257        }
1258        acc
1259    }
1260
1261    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1262        let d = self.latent_dim;
1263        let n_obs = target.len() / d;
1264        let mut grad = Array1::<f64>::zeros(target.len());
1265        for row in 0..n_obs {
1266            let base = row * d;
1267            for axis in 0..d {
1268                let tau = self.threshold(axis, rho);
1269                let gate = self.sigmoid_gate((target[base + axis] - tau) / self.smoothing_eps);
1270                grad[base + axis] = self.weight * tau * gate * (1.0 - gate) / self.smoothing_eps;
1271            }
1272        }
1273        grad
1274    }
1275
1276    fn hessian_diag(
1277        &self,
1278        target: ArrayView1<'_, f64>,
1279        rho: ArrayView1<'_, f64>,
1280    ) -> Option<Array1<f64>> {
1281        let d = self.latent_dim;
1282        let n_obs = target.len() / d;
1283        let mut diag = Array1::<f64>::zeros(target.len());
1284        for row in 0..n_obs {
1285            let base = row * d;
1286            for axis in 0..d {
1287                let tau = self.threshold(axis, rho);
1288                let gate = self.sigmoid_gate((target[base + axis] - tau) / self.smoothing_eps);
1289                diag[base + axis] = self.true_hessian_diag_entry(tau, gate);
1290            }
1291        }
1292        Some(diag)
1293    }
1294
1295    fn hvp(
1296        &self,
1297        target: ArrayView1<'_, f64>,
1298        rho: ArrayView1<'_, f64>,
1299        v: ArrayView1<'_, f64>,
1300    ) -> Array1<f64> {
1301        assert_eq!(target.len(), v.len(), "hvp dimension mismatch");
1302        let d = self.latent_dim;
1303        let n_obs = target.len() / d;
1304        let mut out = Array1::<f64>::zeros(target.len());
1305        for row in 0..n_obs {
1306            let base = row * d;
1307            for axis in 0..d {
1308                let tau = self.threshold(axis, rho);
1309                let gate = self.sigmoid_gate((target[base + axis] - tau) / self.smoothing_eps);
1310                out[base + axis] = self.true_hessian_diag_entry(tau, gate) * v[base + axis];
1311            }
1312        }
1313        out
1314    }
1315
1316    fn psd_majorizer_diag(
1317        &self,
1318        target: ArrayView1<'_, f64>,
1319        rho: ArrayView1<'_, f64>,
1320    ) -> Option<Array1<f64>> {
1321        // The smoothed JumpReLU surrogate's exact diagonal Hessian
1322        //   λτ·g(1−g)(1−2g)/ε²
1323        // is indefinite (negative once the gate passes the inflection
1324        // g = ½). The Newton / PIRLS pipeline needs a PSD curvature block, so
1325        // expose the PSD upper bound implemented by `psd_hessian_diag_entry`:
1326        // the elementwise max of the re-weighted surrogate and the absolute
1327        // exact curvature.
1328        let d = self.latent_dim;
1329        let n_obs = target.len() / d;
1330        let mut diag = Array1::<f64>::zeros(target.len());
1331        for row in 0..n_obs {
1332            let base = row * d;
1333            for axis in 0..d {
1334                let tau = self.threshold(axis, rho);
1335                let gate = self.sigmoid_gate((target[base + axis] - tau) / self.smoothing_eps);
1336                diag[base + axis] = self.psd_hessian_diag_entry(tau, gate);
1337            }
1338        }
1339        Some(diag)
1340    }
1341
1342    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1343        let d = self.latent_dim;
1344        let n_obs = target.len() / d;
1345        let mut out = Array1::<f64>::zeros(d);
1346        for axis in 0..d {
1347            let tau = self.threshold(axis, rho);
1348            let mut g_tau = 0.0;
1349            for row in 0..n_obs {
1350                let x = target[row * d + axis];
1351                let gate = self.sigmoid_gate((x - tau) / self.smoothing_eps);
1352                g_tau += gate - tau * gate * (1.0 - gate) / self.smoothing_eps;
1353            }
1354            out[axis] = self.weight * tau * g_tau;
1355        }
1356        out
1357    }
1358
1359    fn rho_count(&self) -> usize {
1360        self.latent_dim
1361    }
1362
1363    fn name(&self) -> &str {
1364        "jumprelu"
1365    }
1366
1367    impl_scalar_apply_schedule!(weight);
1368}
1369
1370#[cfg(test)]
1371mod fisher_majorizer_1419_tests {
1372    use super::*;
1373    use approx::assert_abs_diff_eq;
1374    use gam_linalg::faer_ndarray::FaerEigh;
1375    use ndarray::Array2;
1376
1377    /// #1419 — the Fisher information metric `G = scale·(diag(a) − a aᵀ)` is PSD
1378    /// but is NOT a curvature majorizer of the exact softmax-entropy Hessian
1379    /// `H_entropy`: `G − H_entropy` is indefinite. The genuine Gershgorin
1380    /// diagonal operator `D_kk = Σ_j|H_kj|` (now `row_psd_majorizer`) IS a
1381    /// Loewner majorizer: `D − H_entropy ⪰ 0` AND `D ⪰ 0`.
1382    ///
1383    /// Oracle: the exact entropy Hessian is built independently from
1384    /// `row_dense_hessian` (the formula at sparsity.rs:160-193); the smallest
1385    /// eigenvalue of `M − H` is computed by a direct symmetric eigensolve. The
1386    /// stated K=2 counterexample (`a=(0.95,0.05)`, `λ=τ=1`) is pinned numerically
1387    /// against the issue's `H_11 = 0.0783747664` and `G_11 = 0.0475`, and the
1388    /// contrast (Fisher FAILS, Gershgorin PASSES) is asserted in both the full
1389    /// K×K block and the single free direction of the reference-logit chart.
1390    #[test]
1391    fn gershgorin_majorizes_entropy_where_fisher_does_not_1419() {
1392        // K=2, λ=τ=1 ⇒ scale = λ/τ² = 1. Logits that realize a = (0.95, 0.05):
1393        // softmax([z0,z1]) = (0.95,0.05) ⟹ z0 − z1 = ln(0.95/0.05) = ln(19).
1394        let temperature = 1.0_f64;
1395        let scale = 1.0_f64; // λ/τ² with λ=1, τ=1.
1396        let pen = SoftmaxAssignmentSparsityPenalty::new(2, temperature);
1397        let z1 = 0.0_f64;
1398        let z0 = z1 + (0.95_f64 / 0.05_f64).ln();
1399        let row = [z0, z1];
1400
1401        // Confirm the realized softmax weights.
1402        let a = pen.softmax_row(&row);
1403        assert_abs_diff_eq!(a[0], 0.95, epsilon = 1e-12);
1404        assert_abs_diff_eq!(a[1], 0.05, epsilon = 1e-12);
1405
1406        // Independent oracles: exact entropy Hessian, Fisher metric, majorizer.
1407        let h = pen.row_dense_hessian(&row, scale);
1408        let g = pen.row_fisher_metric(&row, scale);
1409        let m = pen.row_psd_majorizer(&row, scale);
1410
1411        // Pin the issue's exact numbers in the sole free direction (index 0):
1412        //   H_11 = 0.0783747664,  G_11 = a0·a1 = 0.0475.
1413        assert_abs_diff_eq!(h[[0, 0]], 0.0783747664, epsilon = 1e-9);
1414        assert_abs_diff_eq!(g[[0, 0]], 0.95 * 0.05, epsilon = 1e-12);
1415
1416        // The genuine majorizer's diagonal is the abs-row-sum D_kk = Σ_j|H_kj|.
1417        for kk in 0..2 {
1418            let row_sum: f64 = (0..2).map(|jj| h[[kk, jj]].abs()).sum();
1419            assert_abs_diff_eq!(m[[kk, kk]], row_sum, epsilon = 1e-12);
1420        }
1421        // M is a nonnegative diagonal (PSD by inspection) — off-diagonals zero.
1422        assert_abs_diff_eq!(m[[0, 1]], 0.0, epsilon = 1e-15);
1423        assert_abs_diff_eq!(m[[1, 0]], 0.0, epsilon = 1e-15);
1424        assert!(m[[0, 0]] >= 0.0 && m[[1, 1]] >= 0.0);
1425
1426        // Reference-logit chart: hold z1 fixed, the only free direction is z0, so
1427        // the reduced 1×1 curvature is the (0,0) entry. Fisher FAILS the Loewner
1428        // bound there (G_11 − H_11 < 0), the Gershgorin majorizer PASSES it.
1429        let fisher_free = g[[0, 0]] - h[[0, 0]];
1430        let major_free = m[[0, 0]] - h[[0, 0]];
1431        assert!(
1432            fisher_free < -1e-3,
1433            "Fisher must FAIL the majorizer bound in the free direction (#1419); \
1434             G_11 − H_11 = {fisher_free}"
1435        );
1436        assert!(
1437            major_free >= -1e-12,
1438            "Gershgorin majorizer must SATISFY the bound in the free direction (#1419); \
1439             D_11 − H_11 = {major_free}"
1440        );
1441
1442        // Full K×K Loewner check via a direct symmetric eigensolve oracle.
1443        // smallest eigenvalue of (M − H) ≥ −tiny ⟹ M ⪰ H; the Fisher case has a
1444        // strictly negative smallest eigenvalue ⟹ G ⋡ H.
1445        let mut m_minus_h = Array2::<f64>::zeros((2, 2));
1446        let mut g_minus_h = Array2::<f64>::zeros((2, 2));
1447        for i in 0..2 {
1448            for j in 0..2 {
1449                m_minus_h[[i, j]] = m[[i, j]] - h[[i, j]];
1450                g_minus_h[[i, j]] = g[[i, j]] - h[[i, j]];
1451            }
1452        }
1453        let (m_evals, _) = m_minus_h.eigh(faer::Side::Lower).expect("eigh(M−H)");
1454        let (g_evals, _) = g_minus_h.eigh(faer::Side::Lower).expect("eigh(G−H)");
1455        let m_min = m_evals.iter().cloned().fold(f64::INFINITY, f64::min);
1456        let g_min = g_evals.iter().cloned().fold(f64::INFINITY, f64::min);
1457        assert!(
1458            m_min >= -1e-12,
1459            "Gershgorin majorizer must be a Loewner majorizer (M − H ⪰ 0, #1419); \
1460             smallest eigenvalue of M−H = {m_min}"
1461        );
1462        assert!(
1463            g_min < -1e-9,
1464            "the OLD Fisher metric must FAIL the Loewner majorizer test (#1419); \
1465             smallest eigenvalue of G−H = {g_min} (expected strictly negative)"
1466        );
1467    }
1468
1469    /// #1419 — the majorizer's θ-derivative `∂D_kk/∂z_w = Σ_j sign(H_kj)∂H_kj/∂z_w`
1470    /// is the exact derivative of the operator the assembly installs, so value and
1471    /// log-det adjoint differentiate the SAME `D`. Oracle: a central finite
1472    /// difference of `row_psd_majorizer` itself (away from any sign change, the
1473    /// abs-row-sum is smooth). FD is permitted ONLY inside this test as an
1474    /// independent check of the closed-form derivative.
1475    #[test]
1476    fn gershgorin_majorizer_logit_derivative_matches_fd_1419() {
1477        let pen = SoftmaxAssignmentSparsityPenalty::new(4, 0.8);
1478        let row = [0.3_f64, -0.6, 0.9, 0.2];
1479        let scale = 1.1_f64 * (1.0 / 0.8_f64) * (1.0 / 0.8_f64);
1480        let eps = 1e-6;
1481        for w in 0..4 {
1482            let dd = pen.row_psd_majorizer_logit_derivative(&row, scale, w);
1483            let mut rp = row;
1484            let mut rm = row;
1485            rp[w] += eps;
1486            rm[w] -= eps;
1487            let mp = pen.row_psd_majorizer(&rp, scale);
1488            let mm = pen.row_psd_majorizer(&rm, scale);
1489            for k in 0..4 {
1490                let fd = (mp[[k, k]] - mm[[k, k]]) / (2.0 * eps);
1491                assert_abs_diff_eq!(dd[[k, k]], fd, epsilon = 1e-6);
1492            }
1493            // The derivative is a pure diagonal (D is diagonal).
1494            for i in 0..4 {
1495                for j in 0..4 {
1496                    if i != j {
1497                        assert_abs_diff_eq!(dd[[i, j]], 0.0, epsilon = 1e-15);
1498                    }
1499                }
1500            }
1501        }
1502    }
1503}
1504
1505#[cfg(test)]
1506mod row_weighted_prior_991_tests {
1507    //! #991 design-honesty per-row weights: row `i`'s softmax-entropy prior must
1508    //! be scaled by `w_i` IDENTICALLY in every channel. Because value, gradient,
1509    //! Hessian diagonal, HVP, and the PSD majorizer are all linear in the per-row
1510    //! penalty strength, scaling the strength by `w_i` scales all of them by the
1511    //! same `w_i` and cannot desync them. These are the CI gate for that
1512    //! invariant (the fit that consumes it cannot be run here).
1513    use super::AnalyticPenalty;
1514    use super::*;
1515    use approx::assert_abs_diff_eq;
1516    use ndarray::{Array1, s};
1517
1518    fn logits(n: usize, k: usize) -> Array1<f64> {
1519        // Deterministic non-uniform logits so every row has genuine entropy
1520        // gradient/curvature (no trivially-degenerate softmax rows).
1521        let mut v = Array1::<f64>::zeros(n * k);
1522        for r in 0..n {
1523            for a in 0..k {
1524                v[r * k + a] =
1525                    0.35 * (r as f64) - 0.6 * (a as f64) + 0.11 * ((r * k + a) as f64).sin();
1526            }
1527        }
1528        v
1529    }
1530
1531    /// The weighted value equals the unweighted per-row entropies recombined with
1532    /// `w_i`, and the mean-1 weighting leaves the total exactly invariant when the
1533    /// weights average to one — the design-honesty contract.
1534    #[test]
1535    fn weighted_value_is_per_row_reweight_of_unweighted() {
1536        let (n, k) = (5usize, 3usize);
1537        let temperature = 0.7_f64;
1538        let rho = Array1::from_vec(vec![0.2_f64]);
1539        let target = logits(n, k);
1540        let base = SoftmaxAssignmentSparsityPenalty::new(k, temperature);
1541        // Per-row entropies via single-row penalties (each a 1-row problem).
1542        let mut per_row = vec![0.0_f64; n];
1543        for r in 0..n {
1544            let row = target.slice(s![r * k..r * k + k]).to_owned();
1545            per_row[r] = base.value(row.view(), rho.view());
1546        }
1547        let unweighted: f64 = per_row.iter().sum();
1548        assert_abs_diff_eq!(
1549            base.value(target.view(), rho.view()),
1550            unweighted,
1551            epsilon = 1e-12
1552        );
1553
1554        let w = vec![1.7_f64, 0.3, 1.1, 0.5, 1.4]; // mean = 1.0 exactly.
1555        let weighted = base.clone().with_row_weights(Some(&w));
1556        let expect: f64 = (0..n).map(|r| w[r] * per_row[r]).sum();
1557        assert_abs_diff_eq!(
1558            weighted.value(target.view(), rho.view()),
1559            expect,
1560            epsilon = 1e-12
1561        );
1562        // Mean-1 weights preserve the total (Σ w_i H_i vs Σ H_i differ only by the
1563        // per-row redistribution, but here we assert the exact reweighted target).
1564        assert_abs_diff_eq!(
1565            weighted.value(target.view(), rho.view()),
1566            (0..n).map(|r| w[r] * per_row[r]).sum::<f64>(),
1567            epsilon = 1e-12
1568        );
1569    }
1570
1571    /// FD ORACLE: `d(value)/d(z_{r,a}) == grad_target[r*K+a]` under NONTRIVIAL
1572    /// per-row weights. This is the value/gradient desync gate — if any channel
1573    /// carried a different weighting than the value, this central difference would
1574    /// diverge from the analytic gradient.
1575    #[test]
1576    fn weighted_value_grad_are_fd_consistent() {
1577        let (n, k) = (4usize, 3usize);
1578        let temperature = 0.9_f64;
1579        let rho = Array1::from_vec(vec![-0.1_f64]);
1580        let target = logits(n, k);
1581        let w = vec![1.9_f64, 0.4, 0.8, 0.9];
1582        let pen = SoftmaxAssignmentSparsityPenalty::new(k, temperature).with_row_weights(Some(&w));
1583        let grad = pen.grad_target(target.view(), rho.view());
1584        let eps = 1e-6;
1585        for idx in 0..n * k {
1586            let mut plus = target.clone();
1587            let mut minus = target.clone();
1588            plus[idx] += eps;
1589            minus[idx] -= eps;
1590            let fd = (pen.value(plus.view(), rho.view()) - pen.value(minus.view(), rho.view()))
1591                / (2.0 * eps);
1592            assert_abs_diff_eq!(grad[idx], fd, epsilon = 1e-7);
1593        }
1594    }
1595
1596    /// Every channel scales by exactly `w_i` on row `i` relative to the unweighted
1597    /// penalty — grad_target, hessian_diag, psd_majorizer_diag, and hvp. Confirms
1598    /// the single strength multiplier reaches all of them identically.
1599    #[test]
1600    fn every_channel_scales_by_w_row_identically() {
1601        let (n, k) = (4usize, 3usize);
1602        let temperature = 0.8_f64;
1603        let rho = Array1::from_vec(vec![0.15_f64]);
1604        let target = logits(n, k);
1605        let v = logits(n, k); // arbitrary HVP direction.
1606        let w = vec![1.6_f64, 0.25, 1.05, 1.1];
1607        let base = SoftmaxAssignmentSparsityPenalty::new(k, temperature);
1608        let wtd = base.clone().with_row_weights(Some(&w));
1609
1610        let g0 = base.grad_target(target.view(), rho.view());
1611        let g1 = wtd.grad_target(target.view(), rho.view());
1612        let d0 = base.hessian_diag(target.view(), rho.view()).unwrap();
1613        let d1 = wtd.hessian_diag(target.view(), rho.view()).unwrap();
1614        let m0 = base.psd_majorizer_diag(target.view(), rho.view()).unwrap();
1615        let m1 = wtd.psd_majorizer_diag(target.view(), rho.view()).unwrap();
1616        let h0 = base.hvp(target.view(), rho.view(), v.view());
1617        let h1 = wtd.hvp(target.view(), rho.view(), v.view());
1618        for r in 0..n {
1619            for a in 0..k {
1620                let i = r * k + a;
1621                assert_abs_diff_eq!(g1[i], w[r] * g0[i], epsilon = 1e-12);
1622                assert_abs_diff_eq!(d1[i], w[r] * d0[i], epsilon = 1e-12);
1623                assert_abs_diff_eq!(m1[i], w[r] * m0[i], epsilon = 1e-12);
1624                assert_abs_diff_eq!(h1[i], w[r] * h0[i], epsilon = 1e-12);
1625            }
1626        }
1627        // grad_rho (softmax) is the value itself, so it too carries the weighting.
1628        let r0 = base.grad_rho(target.view(), rho.view())[0];
1629        let r1 = wtd.grad_rho(target.view(), rho.view())[0];
1630        let expect: f64 = (0..n)
1631            .map(|r| {
1632                let row = target.slice(s![r * k..r * k + k]).to_owned();
1633                w[r] * base.value(row.view(), rho.view())
1634            })
1635            .sum();
1636        assert_abs_diff_eq!(r1, expect, epsilon = 1e-12);
1637        assert!(r0.is_finite());
1638    }
1639
1640    /// `None` weights are byte-for-byte the unweighted path (no silent ×1.0 drift).
1641    #[test]
1642    fn none_weights_are_bit_for_bit_unweighted() {
1643        let (n, k) = (3usize, 4usize);
1644        let rho = Array1::from_vec(vec![0.0_f64]);
1645        let target = logits(n, k);
1646        let base = SoftmaxAssignmentSparsityPenalty::new(k, 1.0);
1647        let none = base.clone().with_row_weights(None);
1648        assert_eq!(
1649            base.value(target.view(), rho.view()).to_bits(),
1650            none.value(target.view(), rho.view()).to_bits()
1651        );
1652        let g0 = base.grad_target(target.view(), rho.view());
1653        let g1 = none.grad_target(target.view(), rho.view());
1654        for i in 0..n * k {
1655            assert_eq!(g0[i].to_bits(), g1[i].to_bits());
1656        }
1657    }
1658}