Skip to main content

gam_problem/
seeding.rs

1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2pub enum SeedRiskProfile {
3    Gaussian,
4    /// Gaussian location-scale keeps Gaussian's lowest-REML keep-best policy,
5    /// but its non-profiled log-scale predictor has the same capped-screening
6    /// over-smoothing risk as multi-parameter likelihoods.
7    GaussianLocationScale,
8    GeneralizedLinear,
9    Survival,
10}
11
12impl SeedRiskProfile {
13    #[inline]
14    pub const fn anchor_rho_shift(self) -> f64 {
15        match self {
16            Self::Gaussian | Self::GaussianLocationScale => 0.0,
17            Self::GeneralizedLinear => 1.0,
18            Self::Survival => 2.0,
19        }
20    }
21
22    #[inline]
23    pub const fn baseline_centers(self) -> &'static [f64] {
24        match self {
25            Self::Gaussian | Self::GaussianLocationScale => &[0.0, -3.0, 3.0, -6.0, 6.0],
26            Self::GeneralizedLinear => &[0.0, 2.0, 4.0, -2.0],
27            Self::Survival => &[0.0, 2.0, 4.0, 6.0],
28        }
29    }
30
31    #[inline]
32    pub const fn global_shifts(self) -> &'static [f64] {
33        match self {
34            Self::Gaussian | Self::GaussianLocationScale => &[-2.0, 2.0, -4.0, 4.0],
35            Self::GeneralizedLinear => &[0.0, 2.0, 4.0, -1.0, -2.0, -4.0],
36            Self::Survival => &[0.0, 2.0, 4.0, 6.0, -2.0, -4.0],
37        }
38    }
39
40    #[inline]
41    pub const fn exploratory_amplitude(self) -> f64 {
42        match self {
43            Self::Gaussian | Self::GaussianLocationScale => 2.0,
44            Self::GeneralizedLinear => 2.5,
45            Self::Survival => 3.0,
46        }
47    }
48
49    #[inline]
50    pub const fn promotes_interior_seed_extremes(self) -> bool {
51        // Plain Gaussian REML's profiled-scale basin does NOT exhibit the
52        // capped-screening over-smoothing bias the other profiles do, so it does
53        // not need the flexible slot-0 promotion for its own sake. But a
54        // weak-signal Gaussian fit on an over-rich spatial basis has the
55        // OPPOSITE failure: REML descends from the heuristic anchor into the
56        // flexible (low-λ) basin and over-fits (#1074 quakes: edf≈104 vs mgcv≈15,
57        // held-out R²≈0.02), because the heavily-penalized basin is a separate
58        // attractor never seeded/solved. Promoting the heaviest INTERIOR seed to
59        // the second full-budget slot (paired with the Gaussian over-smoothing
60        // probe and seed_budget≥2 in `external_reml_seed_config`) lets the
61        // multi-start SEE that basin; Gaussian's lowest-cost keep-best
62        // (`uses_lowest_cost_keep_best`) then adopts it only when it scores a
63        // strictly lower REML, so this can never worsen a flexible fit.
64        matches!(
65            self,
66            Self::Gaussian | Self::GaussianLocationScale | Self::GeneralizedLinear | Self::Survival
67        )
68    }
69
70    #[inline]
71    pub const fn uses_parsimonious_keep_best(self) -> bool {
72        matches!(self, Self::GeneralizedLinear | Self::Survival)
73    }
74
75    #[inline]
76    pub const fn uses_lowest_cost_keep_best(self) -> bool {
77        matches!(self, Self::Gaussian | Self::GaussianLocationScale)
78    }
79}
80
81#[derive(Clone, Copy, Debug)]
82pub struct SeedConfig {
83    pub bounds: (f64, f64),
84    pub max_seeds: usize,
85    /// Nominal number of seed starts to run in heuristic order.
86    ///
87    /// A rejected or nonstationary start does not consume the optimizer's
88    /// authority to return a fit: when no candidate has certified, the runner
89    /// may continue through the remaining finite `max_seeds` lattice until one
90    /// certifies or the lattice is exhausted. The generated lattice remains the
91    /// absolute work bound.
92    pub seed_budget: usize,
93    /// Initial inner-iteration cap used while ranking candidate seeds.
94    pub screen_max_inner_iterations: usize,
95    pub risk_profile: SeedRiskProfile,
96    /// Number of trailing dimensions that are auxiliary parameters rather than
97    /// log-smoothing parameters.
98    pub num_auxiliary_trailing: usize,
99    /// Optional absolute over-smoothing probe on every smoothing dimension.
100    pub over_smoothing_probe_rho: Option<f64>,
101}
102
103impl Default for SeedConfig {
104    fn default() -> Self {
105        Self {
106            bounds: (-12.0, 12.0),
107            max_seeds: 12,
108            seed_budget: 2,
109            screen_max_inner_iterations: 3,
110            risk_profile: SeedRiskProfile::GeneralizedLinear,
111            num_auxiliary_trailing: 0,
112            over_smoothing_probe_rho: None,
113        }
114    }
115}
116
117/// A validated, finite, ordered ρ (log-λ) seed interval `[lo, hi]`.
118///
119/// Every seed clamp in the outer-optimizer prepass and the candidate lattice
120/// derives a trial ρ and pins it into a single uniform box assembled from two
121/// *independently-owned* constants — the outer ρ lower wall
122/// (`options.rho_lower_bound`) and an over-smoothing ceiling (`RHO_BOUND` or an
123/// effective-df crossing). When those constants drift apart the interval inverts
124/// (`lo > hi`): the #2370 disease, where an edf-ceiling that used to equal
125/// `-rho_lower_bound` was moved by #2356 and the emitted upper bound dropped
126/// below the lower one. The historical response — *silently swapping* the pair
127/// (`normalize_seed_bounds`) — does not make the fit correct; it makes the
128/// optimizer solve a *different, silently substituted* box and return a model as
129/// if nothing were wrong. That is strictly worse than the panic it replaced: a
130/// panic is loud, a silently-wrong λ-box is not.
131///
132/// This type makes the inverted state unrepresentable. It is constructed only
133/// through [`OrderedRhoBounds::new`], which refuses an inverted or non-finite
134/// interval with the same typed `EstimationError::InvalidInput` the outer
135/// entry (`run_outer_uncertified`) now enforces (#2379 / #2370). Every downstream
136/// clamp then operates on an interval that is ordered *by construction*, so
137/// `f64::clamp`'s `min <= max` precondition can never be violated.
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub struct OrderedRhoBounds {
140    lo: f64,
141    hi: f64,
142}
143
144impl OrderedRhoBounds {
145    /// Validate and wrap a `[lo, hi]` ρ interval. Refuses (rather than silently
146    /// reorders) an inverted (`lo > hi`) or non-finite interval, naming both
147    /// endpoints. `lo == hi` is a valid degenerate single-point box.
148    pub fn new(lo: f64, hi: f64) -> Result<Self, crate::estimation_error::EstimationError> {
149        if !lo.is_finite() || !hi.is_finite() || lo > hi {
150            return Err(crate::estimation_error::EstimationError::InvalidInput(
151                format!(
152                    "seed ρ-box is inverted or non-finite: lower={lo}, upper={hi}; an \
153                 inverted box means the ρ lower wall and the over-smoothing ceiling \
154                 have drifted apart (cf. #2370) — refusing rather than silently \
155                 reordering the interval (#2379)"
156                ),
157            ));
158        }
159        Ok(Self { lo, hi })
160    }
161
162    /// The (validated) lower endpoint.
163    #[inline]
164    pub fn lower(self) -> f64 {
165        self.lo
166    }
167
168    /// The (validated) upper endpoint.
169    #[inline]
170    pub fn upper(self) -> f64 {
171        self.hi
172    }
173
174    /// Clamp `value` into `[lo, hi]`. Infallible: the interval is ordered by
175    /// construction, so `f64::clamp`'s `min <= max` precondition always holds.
176    #[inline]
177    pub fn clamp(self, value: f64) -> f64 {
178        value.clamp(self.lo, self.hi)
179    }
180
181    /// Raise the upper endpoint to at least `floor`, preserving orderedness.
182    ///
183    /// The criterion-ranked prepass widens its over-smoothing bound to the full
184    /// range the outer optimizer can reach (`RHO_BOUND`) so a genuinely large λ
185    /// seed is not clipped to the seed band. This only ever *raises* `hi`, so the
186    /// interval stays valid by construction. A non-finite `floor` is ignored to
187    /// preserve the finiteness invariant (callers pass the finite `RHO_BOUND`).
188    #[inline]
189    pub fn with_upper_at_least(self, floor: f64) -> Self {
190        if floor.is_finite() && floor > self.hi {
191            Self {
192                lo: self.lo,
193                hi: floor,
194            }
195        } else {
196            self
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    // ── anchor_rho_shift ───────────────────────────────────────────────────────
206
207    #[test]
208    fn anchor_rho_shift_gaussian_is_zero() {
209        assert_eq!(SeedRiskProfile::Gaussian.anchor_rho_shift(), 0.0);
210        assert_eq!(
211            SeedRiskProfile::GaussianLocationScale.anchor_rho_shift(),
212            0.0
213        );
214    }
215
216    #[test]
217    fn anchor_rho_shift_generalized_linear_is_one() {
218        assert_eq!(SeedRiskProfile::GeneralizedLinear.anchor_rho_shift(), 1.0);
219    }
220
221    #[test]
222    fn anchor_rho_shift_survival_is_two() {
223        assert_eq!(SeedRiskProfile::Survival.anchor_rho_shift(), 2.0);
224    }
225
226    // ── promotes_interior_seed_extremes ───────────────────────────────────────
227
228    #[test]
229    fn promotes_interior_extremes_for_all_profiles() {
230        // #1074: plain Gaussian was originally excluded (its profiled-scale REML
231        // basin has no capped-screening over-smoothing bias), but a weak-signal
232        // Gaussian fit on an over-rich basis has the OPPOSITE failure — it
233        // descends into the flexible (low-λ) basin and over-fits. Promoting the
234        // heaviest interior seed to the second full-budget slot (paired with the
235        // over-smoothing probe + `seed_budget ≥ 2`) lets the multi-start SEE the
236        // heavily-penalized basin; Gaussian's lowest-cost keep-best then adopts
237        // it only when it scores a strictly lower REML, so this can never worsen
238        // a flexible fit. Every risk profile now promotes the interior extremes.
239        assert!(SeedRiskProfile::Gaussian.promotes_interior_seed_extremes());
240        assert!(SeedRiskProfile::GaussianLocationScale.promotes_interior_seed_extremes());
241        assert!(SeedRiskProfile::GeneralizedLinear.promotes_interior_seed_extremes());
242        assert!(SeedRiskProfile::Survival.promotes_interior_seed_extremes());
243    }
244
245    // ── keep-best policy flags ────────────────────────────────────────────────
246
247    #[test]
248    fn parsimonious_keep_best_only_for_glm_and_survival() {
249        assert!(!SeedRiskProfile::Gaussian.uses_parsimonious_keep_best());
250        assert!(!SeedRiskProfile::GaussianLocationScale.uses_parsimonious_keep_best());
251        assert!(SeedRiskProfile::GeneralizedLinear.uses_parsimonious_keep_best());
252        assert!(SeedRiskProfile::Survival.uses_parsimonious_keep_best());
253    }
254
255    #[test]
256    fn lowest_cost_keep_best_only_for_gaussian_variants() {
257        assert!(SeedRiskProfile::Gaussian.uses_lowest_cost_keep_best());
258        assert!(SeedRiskProfile::GaussianLocationScale.uses_lowest_cost_keep_best());
259        assert!(!SeedRiskProfile::GeneralizedLinear.uses_lowest_cost_keep_best());
260        assert!(!SeedRiskProfile::Survival.uses_lowest_cost_keep_best());
261    }
262
263    // ── OrderedRhoBounds (#2379) ──────────────────────────────────────────────
264    // The validated-interval type that REPLACES the silent swap on every seed
265    // clamp: an inverted box must be a typed refusal, never a reordered interval.
266
267    #[test]
268    fn ordered_rho_bounds_accepts_ordered_interval() {
269        let b = OrderedRhoBounds::new(-12.0, 12.0).expect("ordered interval is valid");
270        assert_eq!(b.lower(), -12.0);
271        assert_eq!(b.upper(), 12.0);
272    }
273
274    #[test]
275    fn ordered_rho_bounds_accepts_degenerate_point_interval() {
276        // lo == hi is a valid single-point box (matches opt::Bounds, which uses
277        // `lower > upper` as the inversion test).
278        let b = OrderedRhoBounds::new(2.0, 2.0).expect("point interval is valid");
279        assert_eq!(b.clamp(5.0), 2.0);
280        assert_eq!(b.clamp(-5.0), 2.0);
281    }
282
283    #[test]
284    fn ordered_rho_bounds_refuses_inverted_interval_with_typed_error() {
285        // This is the #2379 contract: an inverted seed-bound pair reaching the
286        // seed path is a typed refusal, NOT a silently reordered box. The exact
287        // scenario from #2370 — lower = -10 (the ρ lower wall) above an
288        // independently-derived edf ceiling of -11.855.
289        let err = OrderedRhoBounds::new(-10.0, -11.855)
290            .expect_err("an inverted box must be refused, not swapped");
291        match err {
292            crate::estimation_error::EstimationError::InvalidInput(msg) => {
293                // Both endpoints are named, so a drift is diagnosable from the error.
294                assert!(msg.contains("-10"), "error names the lower bound: {msg}");
295                assert!(
296                    msg.contains("-11.855"),
297                    "error names the upper bound: {msg}"
298                );
299                assert!(
300                    msg.contains("invert"),
301                    "error explains the inversion: {msg}"
302                );
303            }
304            other => panic!("expected InvalidInput, got {other:?}"),
305        }
306    }
307
308    #[test]
309    fn ordered_rho_bounds_refuses_non_finite_interval() {
310        assert!(OrderedRhoBounds::new(f64::NAN, 12.0).is_err());
311        assert!(OrderedRhoBounds::new(-12.0, f64::INFINITY).is_err());
312        assert!(OrderedRhoBounds::new(f64::NEG_INFINITY, 12.0).is_err());
313    }
314
315    #[test]
316    fn ordered_rho_bounds_clamp_respects_both_ends() {
317        let b = OrderedRhoBounds::new(-3.0, 5.0).unwrap();
318        assert_eq!(b.clamp(1.0), 1.0);
319        assert_eq!(b.clamp(-10.0), -3.0);
320        assert_eq!(b.clamp(100.0), 5.0);
321    }
322
323    #[test]
324    fn ordered_rho_bounds_with_upper_only_raises_and_stays_ordered() {
325        let b = OrderedRhoBounds::new(-12.0, 8.0).unwrap();
326        // Widening to a larger ceiling raises the upper endpoint.
327        let widened = b.with_upper_at_least(30.0);
328        assert_eq!(widened.lower(), -12.0);
329        assert_eq!(widened.upper(), 30.0);
330        // Widening to a floor already below `hi` is a no-op (never lowers `hi`).
331        let unchanged = b.with_upper_at_least(2.0);
332        assert_eq!(unchanged.upper(), 8.0);
333        // A non-finite floor is ignored so the finiteness invariant is preserved.
334        let finite = b.with_upper_at_least(f64::INFINITY);
335        assert!(finite.upper().is_finite());
336        assert_eq!(finite.upper(), 8.0);
337    }
338}