Skip to main content

gam_terms/analytic_penalties/
sparsity.rs

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