Skip to main content

gam_terms/analytic_penalties/
penalty_trait.rs

1use super::*;
2
3pub(crate) const MIN_CONDITIONAL_PRECISION: f64 = 1.0e-12;
4pub(crate) use gam_problem::{LOG_STRENGTH_MAX, LOG_STRENGTH_MIN, checked_exp_log_strength};
5
6// ---------------------------------------------------------------------------
7// Common trait
8// ---------------------------------------------------------------------------
9
10/// Whether a penalty's target is a slice of `β` (decoder coefficients), a
11/// slice of extension coordinates (per-observation latent field, e.g.
12/// `LatentCoordValues`),
13/// or a slice of `ρ` (a hyperparameter sub-block — rare, used by hyperpriors
14/// that we don't yet ship analytically).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum PenaltyTier {
17    Beta,
18    Psi,
19    Rho,
20}
21
22/// Reference for the column / coordinate range a penalty operates over.
23///
24/// Mirrors `BlockwisePenalty::col_range` for the β tier and is the natural
25/// per-observation flat index for the extension-coordinate tier (matching the
26/// `LatentCoordValues` row-major flat layout: `n * d + a`).
27#[derive(Debug, Clone)]
28pub struct PsiSlice {
29    /// Inclusive-start, exclusive-end flat range into the underlying ext-coordinate vector.
30    pub range: std::ops::Range<usize>,
31    /// For latent-coordinate slices: the latent dimensionality, used to
32    /// reshape the flat slice into per-row `(n_obs, d)` blocks.
33    pub latent_dim: Option<usize>,
34}
35
36impl PsiSlice {
37    #[must_use]
38    pub fn full(len: usize, latent_dim: Option<usize>) -> Self {
39        Self {
40            range: 0..len,
41            latent_dim,
42        }
43    }
44
45    pub fn len(&self) -> usize {
46        self.range.len()
47    }
48
49    pub fn is_empty(&self) -> bool {
50        self.range.is_empty()
51    }
52}
53
54/// Resolve the exact learnable strength `base_weight · exp(rho)` in log space.
55///
56/// The effective log-strength `ln|base_weight| + rho` must lie in the closed
57/// [`LOG_STRENGTH_MIN`, `LOG_STRENGTH_MAX`] domain. Values outside that domain
58/// are rejected instead of saturated: a plateau would make the evaluated
59/// value constant while analytic `rho` derivatives remain nonzero. Computing
60/// the product as `sign(base_weight) · exp(ln|base_weight| + rho)` also avoids
61/// an overflowing intermediate `exp(rho)` when a very small base permits a
62/// large legal coordinate.
63pub fn resolve_learnable_weight(base_weight: f64, rho: f64) -> Result<f64, String> {
64    if base_weight == 0.0 {
65        return Err(
66            "a multiplicatively learnable weight requires a nonzero base; zero would make its rho coordinate structurally dead"
67                .to_string(),
68        );
69    }
70    if !(base_weight.is_finite() && rho.is_finite()) {
71        return Err(format!(
72            "learnable weight requires finite base and coordinate; got base_weight={base_weight}, rho={rho}"
73        ));
74    }
75    let log_base = base_weight.abs().ln();
76    let (lower, upper) = (LOG_STRENGTH_MIN - log_base, LOG_STRENGTH_MAX - log_base);
77    if !(lower..=upper).contains(&rho) {
78        return Err(format!(
79            "learnable coordinate must be in [{lower}, {upper}] so its effective log strength is in [{LOG_STRENGTH_MIN}, {LOG_STRENGTH_MAX}]; got {rho}"
80        ));
81    }
82    // Map the two emitted faces back to their mathematical effective values
83    // exactly. This is not saturation: values beyond either face were refused
84    // above. It only removes one subtraction/addition roundoff at a legal face.
85    let log_strength = if rho == lower {
86        LOG_STRENGTH_MIN
87    } else if rho == upper {
88        LOG_STRENGTH_MAX
89    } else {
90        log_base + rho
91    };
92    Ok(checked_exp_log_strength(log_strength)
93        .map_err(|error| error.to_string())?
94        .copysign(base_weight))
95}
96pub fn learnable_weight_coordinate_domain(base_weight: f64) -> Result<Option<(f64, f64)>, String> {
97    if base_weight == 0.0 {
98        return Ok(None);
99    }
100    if !base_weight.is_finite() {
101        return Err(format!(
102            "learnable weight domain requires a finite base; got {base_weight}"
103        ));
104    }
105    let log_base = base_weight.abs().ln();
106    Ok(Some((
107        LOG_STRENGTH_MIN - log_base,
108        LOG_STRENGTH_MAX - log_base,
109    )))
110}
111
112/// Exact strength for trait methods whose owning evaluation seam has already
113/// called `AnalyticPenalty::validate_rho`. Keeping this preconditioned helper
114/// private prevents an unchecked public plateau/error path.
115pub(crate) fn validated_learnable_weight(base_weight: f64, rho: f64) -> f64 {
116    resolve_learnable_weight(base_weight, rho)
117        .expect("analytic-penalty rho must be validated before strength evaluation")
118}
119
120pub(crate) fn validated_exp_log_strength(log_strength: f64) -> f64 {
121    checked_exp_log_strength(log_strength)
122        .expect("analytic-penalty rho must be validated before precision evaluation")
123}
124
125/// Scalar annealing schedule for analytic penalty weights.
126///
127/// This is the penalty-weight analogue of [`crate::terms::sae::manifold::GumbelTemperatureSchedule`]:
128/// it starts with a weak analytic regularizer and ramps toward the target
129/// weight during REML outer iterations. This follows the standard annealed
130/// regularization pattern in deep learning, where optimization first finds
131/// good fits before stronger structure constrains the solution. It also
132/// addresses the general observation that hand-picked analytic weights
133/// materially affect outcomes — fixed tight auxiliary scales can outperform
134/// learned weights on one dataset and underperform on another. A schedule
135/// side-steps that brittle initial choice by ramping the constraint.
136#[derive(Debug, Clone)]
137pub struct ScalarWeightSchedule {
138    pub w_start: f64,
139    pub w_end: f64,
140    pub kind: ScheduleKind,
141    pub iter_count: usize,
142}
143
144impl ScalarWeightSchedule {
145    #[must_use = "build error must be handled"]
146    pub fn new(w_start: f64, w_end: f64, kind: ScheduleKind) -> Result<Self, String> {
147        let schedule = Self {
148            w_start,
149            w_end,
150            kind,
151            iter_count: 0,
152        };
153        schedule.validate()?;
154        Ok(schedule)
155    }
156
157    pub fn validate(&self) -> Result<(), String> {
158        if !(self.w_start.is_finite() && self.w_start >= 0.0) {
159            return Err(format!(
160                "ScalarWeightSchedule: w_start must be finite and non-negative; got {}",
161                self.w_start
162            ));
163        }
164        if !(self.w_end.is_finite() && self.w_end >= 0.0) {
165            return Err(format!(
166                "ScalarWeightSchedule: w_end must be finite and non-negative; got {}",
167                self.w_end
168            ));
169        }
170        match &self.kind {
171            ScheduleKind::Geometric { rate } => {
172                if !(rate.is_finite() && *rate > 0.0 && *rate < 1.0) {
173                    return Err(format!(
174                        "ScalarWeightSchedule::Geometric: rate must be in (0, 1); got {rate}"
175                    ));
176                }
177            }
178            ScheduleKind::Linear { steps } => {
179                if *steps == 0 {
180                    return Err("ScalarWeightSchedule::Linear: steps must be positive".into());
181                }
182            }
183            ScheduleKind::ReciprocalIter => {}
184        }
185        Ok(())
186    }
187
188    pub fn current_weight(&self, iter: usize) -> f64 {
189        let delta = self.w_end - self.w_start;
190        let raw = match &self.kind {
191            ScheduleKind::Geometric { rate } => self.w_end - delta * rate.powf(iter as f64),
192            ScheduleKind::Linear { steps } => {
193                if iter >= *steps {
194                    self.w_end
195                } else {
196                    let frac = iter as f64 / *steps as f64;
197                    self.w_start + frac * delta
198                }
199            }
200            ScheduleKind::ReciprocalIter => self.w_end - delta / (1.0 + iter as f64),
201        };
202        raw.clamp(self.w_start.min(self.w_end), self.w_start.max(self.w_end))
203    }
204
205    pub fn step(&mut self) -> f64 {
206        let weight = self.current_weight(self.iter_count);
207        self.iter_count += 1;
208        weight
209    }
210}
211
212/// Uniform interface implemented by every analytic penalty in this module.
213///
214/// `target` is the relevant slice of the β or extension-coordinate vector, viewed as
215/// a flat `ArrayView1`. The owning REML driver is responsible for slicing the
216/// global parameter vector before calling, and for routing the returned
217/// gradient back into the correct global indices.
218pub trait AnalyticPenalty: Send + Sync {
219    /// Tier the target lives in (β or ext-coord).
220    fn tier(&self) -> PenaltyTier;
221
222    /// Validate the penalty-local outer-rho vector before any value or
223    /// derivative method consumes it. Implementations with a multiplicative
224    /// base weight override this to validate `ln|base| + rho`; the default is
225    /// the unit-base log-strength domain.
226    fn validate_rho(&self, rho: ArrayView1<'_, f64>) -> Result<(), String> {
227        if rho.len() != self.rho_count() {
228            return Err(format!(
229                "analytic penalty `{}` rho length {} != declared {}",
230                self.name(),
231                rho.len(),
232                self.rho_count()
233            ));
234        }
235        for (axis, &value) in rho.iter().enumerate() {
236            checked_exp_log_strength(value).map_err(|error| {
237                format!(
238                    "analytic penalty `{}` rho axis {axis}: {error}",
239                    self.name()
240                )
241            })?;
242        }
243        Ok(())
244    }
245
246    /// Per-local-coordinate legal intervals. The generic optimizer intersects
247    /// these with its configured box before evaluating a penalty. Ordinary
248    /// non-log coordinates may return infinite endpoints to denote an
249    /// unbounded face; evaluation still requires every supplied coordinate to
250    /// be finite.
251    fn rho_coordinate_domains(&self) -> Result<Vec<(f64, f64)>, String> {
252        Ok(vec![(LOG_STRENGTH_MIN, LOG_STRENGTH_MAX); self.rho_count()])
253    }
254
255    /// Scalar penalty contribution `P(target; ρ)`. The strength factor
256    /// `exp(ρ)` (or whatever parameterization the penalty uses) is folded in.
257    fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64;
258
259    /// Gradient `∂P/∂target`, same length as `target`.
260    fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64>;
261
262    /// Diagonal of the Hessian `diag(∂²P/∂target²)` when the Hessian is
263    /// block-diagonal. Returns `None` for penalties whose Hessian is dense
264    /// (Isometry); those implement [`Self::hvp`] instead. The default
265    /// signals "no closed-form diagonal" by returning `None` for any
266    /// non-empty target — concrete penalties either override with their
267    /// own analytic diagonal or rely on the matrix-free `hvp` path.
268    fn hessian_diag(
269        &self,
270        target: ArrayView1<'_, f64>,
271        rho: ArrayView1<'_, f64>,
272    ) -> Option<Array1<f64>> {
273        assert!(
274            rho.iter().all(|value| value.is_finite()),
275            "analytic-penalty rho must be finite"
276        );
277        if target.is_empty() {
278            Some(Array1::zeros(0))
279        } else {
280            None
281        }
282    }
283
284    /// Hessian-vector product `H v = (∂²P/∂target²) v`, in closed form.
285    ///
286    /// The default covers every penalty whose Hessian is diagonal: it reads the
287    /// analytic [`Self::hessian_diag`] and forms `diag ⊙ v`. Penalties with a
288    /// dense (non-diagonal) Hessian — e.g. `IsometryPenalty`,
289    /// `SheafConsistencyPenalty`, the orthogonality / nuclear-norm family —
290    /// return `None` from `hessian_diag` and supply their own analytic `hvp`
291    /// override (Laplacian/Gram-vector products). There is no finite-difference
292    /// path: a penalty that reaches the default without a closed-form diagonal
293    /// is a programming error and panics rather than silently differencing its
294    /// own gradient (SPEC: finite differences are never used outside tests).
295    fn hvp(
296        &self,
297        target: ArrayView1<'_, f64>,
298        rho: ArrayView1<'_, f64>,
299        v: ArrayView1<'_, f64>,
300    ) -> Array1<f64> {
301        let diag = self.hessian_diag(target, rho).unwrap_or_else(|| {
302            // SAFETY: programming-error invariant, never a runtime/data condition.
303            // A penalty whose Hessian is non-diagonal MUST override `hvp` with its
304            // closed-form Hessian-vector product; reaching this default means the
305            // impl is missing that override. SPEC forbids a finite-difference
306            // fallback outside tests, so there is no recoverable path — failing
307            // loud here is the contract.
308            panic!(
309                "AnalyticPenalty::hvp default reached for `{}`, whose Hessian is \
310                 not diagonal (hessian_diag returned None). Such a penalty must \
311                 override `hvp` with its closed-form Hessian-vector product; the \
312                 default never finite-differences.",
313                self.name()
314            )
315        });
316        assert_eq!(diag.len(), v.len(), "hvp dimension mismatch");
317        let mut out = Array1::<f64>::zeros(v.len());
318        for i in 0..v.len() {
319            out[i] = diag[i] * v[i];
320        }
321        out
322    }
323
324    /// Diagonal of a **PSD majorizer** of the Hessian — the positive
325    /// re-weighted-ℓ₂ / MM surrogate `diag(B(target; ρ))` with
326    /// `B ⪰ ∂²P/∂target²` everywhere and `B ⪰ 0`. This is a *different*
327    /// operator from [`Self::hessian_diag`]: for nonconvex penalties (log
328    /// sparsity, smooth-threshold) the exact Hessian is indefinite, but the inner
329    /// Newton / PIRLS solve and the log-det / preconditioner pipeline require
330    /// a PSD curvature block. For convex penalties the majorizer coincides
331    /// with the exact Hessian, so the default simply delegates to
332    /// [`Self::hessian_diag`]; nonconvex penalties override.
333    fn psd_majorizer_diag(
334        &self,
335        target: ArrayView1<'_, f64>,
336        rho: ArrayView1<'_, f64>,
337    ) -> Option<Array1<f64>> {
338        self.hessian_diag(target, rho)
339    }
340
341    /// Matrix-vector product against the **PSD majorizer** `B(target; ρ) v`
342    /// (see [`Self::psd_majorizer_diag`]). For convex penalties this is the
343    /// exact Hessian-vector product, so the default delegates to
344    /// [`Self::hvp`]; nonconvex penalties override to return their PSD
345    /// surrogate instead of the indefinite true Hessian.
346    fn psd_majorizer_hvp(
347        &self,
348        target: ArrayView1<'_, f64>,
349        rho: ArrayView1<'_, f64>,
350        v: ArrayView1<'_, f64>,
351    ) -> Array1<f64> {
352        if let Some(diag) = self.psd_majorizer_diag(target, rho) {
353            assert_eq!(diag.len(), v.len(), "psd_majorizer_hvp dimension mismatch");
354            let mut out = Array1::<f64>::zeros(v.len());
355            for i in 0..v.len() {
356                out[i] = diag[i] * v[i];
357            }
358            return out;
359        }
360        self.hvp(target, rho, v)
361    }
362
363    /// Gradient of the penalty value w.r.t. each owned ρ-axis. Length equals
364    /// [`Self::rho_count`].
365    fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64>;
366
367    /// Number of REML-selectable hyperparameter axes this penalty contributes
368    /// to the outer ρ vector.
369    fn rho_count(&self) -> usize;
370
371    /// Human-readable identifier for diagnostics / logging.
372    fn name(&self) -> &str;
373
374    /// Update any attached scalar weight schedule at the given REML outer
375    /// iteration. Penalties without schedules keep their stored weight.
376    fn apply_schedule(&mut self, iter: usize) {
377        // REML outer loops are bounded well below 1,000,000; a value beyond
378        // that cap signals counter corruption rather than a legitimate
379        // iteration count, so refuse to silently accept it.
380        assert!(
381            iter < 1_000_000,
382            "apply_schedule received implausible outer iteration {iter}",
383        );
384    }
385}
386
387pub(crate) fn advance_scalar_weight(
388    weight: &mut f64,
389    schedule: &mut Option<ScalarWeightSchedule>,
390    iter: usize,
391) {
392    if let Some(schedule) = schedule.as_mut() {
393        *weight = schedule.current_weight(iter);
394        schedule.iter_count = iter + 1;
395    }
396}
397
398/// Emit the standard scalar-weight-schedule builder for a penalty struct whose
399/// scalar weight lives in `$field` and whose schedule lives in
400/// `weight_schedule: Option<ScalarWeightSchedule>`. The builder seeds the
401/// current weight from the schedule and stores the schedule. Invoke inside the
402/// struct's inherent `impl … {}` block.
403macro_rules! impl_with_weight_schedule {
404    ($field:ident) => {
405        /// Attach a scalar weight schedule, seeding the current weight from
406        /// the schedule's stored iteration counter.
407        #[must_use]
408        pub fn with_weight_schedule(mut self, schedule: ScalarWeightSchedule) -> Self {
409            self.$field = schedule.current_weight(schedule.iter_count);
410            self.weight_schedule = Some(schedule);
411            self
412        }
413    };
414}
415
416/// Emit the standard [`AnalyticPenalty::apply_schedule`] override for a penalty
417/// whose scalar weight lives in `$field`. Invoke inside the `impl
418/// AnalyticPenalty for …` block.
419macro_rules! impl_scalar_apply_schedule {
420    ($field:ident) => {
421        fn apply_schedule(&mut self, iter: usize) {
422            advance_scalar_weight(&mut self.$field, &mut self.weight_schedule, iter);
423        }
424    };
425}
426
427/// Emit the standard learnable-scalar-weight [`AnalyticPenalty::grad_rho`] for a
428/// penalty whose single owned ρ-axis is the (optionally learnable) log-weight at
429/// `self.rho_index`, gated by `self.learnable_weight`. Invoke inside the `impl
430/// AnalyticPenalty for …` block.
431macro_rules! impl_learnable_weight_grad_rho {
432    () => {
433        fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
434            if !self.learnable_weight {
435                return Array1::<f64>::zeros(0);
436            }
437            let mut out = Array1::<f64>::zeros(1);
438            out[self.rho_index] = self.value(target, rho);
439            out
440        }
441    };
442}
443
444/// Emit the standard learnable-scalar-weight [`AnalyticPenalty::rho_count`]:
445/// one ρ-axis when the weight is learnable, none otherwise. Invoke inside the
446/// `impl AnalyticPenalty for …` block.
447macro_rules! impl_learnable_weight_rho_count {
448    () => {
449        fn rho_count(&self) -> usize {
450            usize::from(self.learnable_weight)
451        }
452    };
453}
454
455macro_rules! impl_learnable_weight_domain {
456    ($field:ident) => {
457        fn validate_rho(&self, rho: ArrayView1<'_, f64>) -> Result<(), String> {
458            if rho.len() != self.rho_count() {
459                return Err(format!(
460                    "analytic penalty `{}` rho length {} != declared {}",
461                    self.name(),
462                    rho.len(),
463                    self.rho_count()
464                ));
465            }
466            if self.learnable_weight {
467                resolve_learnable_weight(self.$field, rho[self.rho_index]).map_err(|error| {
468                    format!("analytic penalty `{}`: {error}", self.name())
469                })?;
470            }
471            Ok(())
472        }
473
474        fn rho_coordinate_domains(&self) -> Result<Vec<(f64, f64)>, String> {
475            if !self.learnable_weight {
476                return Ok(Vec::new());
477            }
478            let domain = learnable_weight_coordinate_domain(self.$field)?.ok_or_else(|| {
479                format!(
480                    "analytic penalty `{}` cannot expose a learnable coordinate with zero base weight",
481                    self.name()
482                )
483            })?;
484            Ok(vec![domain])
485        }
486    };
487}