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