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}