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}