1#[derive(Debug, Clone)]
3pub enum ScheduleKind {
4 Geometric { rate: f64 },
5 Linear { steps: usize },
6 ReciprocalIter,
7}
8
9impl ScheduleKind {
10 #[must_use]
18 pub fn geometric_rate_from_steps(tau_start: f64, tau_min: f64, steps: usize) -> f64 {
19 let steps = steps.max(1);
20 (tau_min / tau_start).powf(1.0 / steps as f64)
21 }
22}
23
24#[derive(Debug, Clone)]
32pub struct GumbelTemperatureSchedule {
33 pub tau_start: f64,
34 pub tau_min: f64,
35 pub decay: ScheduleKind,
36 pub iter_count: usize,
37}
38
39impl GumbelTemperatureSchedule {
40 #[must_use = "build error must be handled"]
41 pub fn new(tau_start: f64, tau_min: f64, decay: ScheduleKind) -> Result<Self, String> {
42 let sched = Self {
43 tau_start,
44 tau_min,
45 decay,
46 iter_count: 0,
47 };
48 sched.validate()?;
49 Ok(sched)
50 }
51
52 pub fn validate(&self) -> Result<(), String> {
53 if !(self.tau_start.is_finite() && self.tau_start > 0.0) {
54 return Err(format!(
55 "GumbelTemperatureSchedule: tau_start must be finite and positive; got {}",
56 self.tau_start
57 ));
58 }
59 if !(self.tau_min.is_finite() && self.tau_min > 0.0) {
60 return Err(format!(
61 "GumbelTemperatureSchedule: tau_min must be finite and positive; got {}",
62 self.tau_min
63 ));
64 }
65 if self.tau_min > self.tau_start {
66 return Err(format!(
67 "GumbelTemperatureSchedule: tau_min ({}) cannot exceed tau_start ({})",
68 self.tau_min, self.tau_start
69 ));
70 }
71 match self.decay {
72 ScheduleKind::Geometric { rate } => {
73 let no_decay_needed = self.tau_min >= self.tau_start;
84 let rate_ok = rate.is_finite()
85 && rate > 0.0
86 && (rate < 1.0 || (no_decay_needed && rate == 1.0));
87 if !rate_ok {
88 return Err(format!(
89 "GumbelTemperatureSchedule::Geometric: rate must be in (0, 1); got {rate}"
90 ));
91 }
92 }
93 ScheduleKind::Linear { steps } => {
94 if steps == 0 {
95 return Err("GumbelTemperatureSchedule::Linear: steps must be positive".into());
96 }
97 }
98 ScheduleKind::ReciprocalIter => {}
99 }
100 Ok(())
101 }
102
103 pub fn current_tau(&self, iter: usize) -> f64 {
104 let raw = match self.decay {
105 ScheduleKind::Geometric { rate } => self.tau_start * rate.powf(iter as f64),
106 ScheduleKind::Linear { steps } => {
107 if iter >= steps {
108 self.tau_min
109 } else {
110 let frac = iter as f64 / steps as f64;
111 self.tau_start + frac * (self.tau_min - self.tau_start)
112 }
113 }
114 ScheduleKind::ReciprocalIter => self.tau_start / (1.0 + iter as f64),
115 };
116 raw.max(self.tau_min)
117 }
118
119 pub fn step(&mut self) -> f64 {
120 let tau = self.current_tau(self.iter_count);
121 self.iter_count += 1;
122 tau
123 }
124}
125
126#[derive(Debug, Clone, PartialEq)]
127pub enum SearchStrategy {
128 Fixed,
129 ExponentialSweep { values: Vec<f64> },
130}
131
132impl SearchStrategy {
133 #[must_use]
134 pub fn is_fixed(&self) -> bool {
135 matches!(self, Self::Fixed)
136 }
137
138 #[must_use]
139 pub fn sweep_values(&self) -> Option<&[f64]> {
140 match self {
141 Self::Fixed => None,
142 Self::ExponentialSweep { values } => Some(values),
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn geometric(rate: f64) -> GumbelTemperatureSchedule {
152 GumbelTemperatureSchedule::new(1.0, 0.01, ScheduleKind::Geometric { rate }).unwrap()
153 }
154
155 #[test]
158 fn new_ok_for_valid_geometric() {
159 assert!(
160 GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.9 }).is_ok()
161 );
162 }
163
164 #[test]
165 fn new_err_for_non_positive_tau_start() {
166 assert!(GumbelTemperatureSchedule::new(0.0, 0.1, ScheduleKind::ReciprocalIter).is_err());
167 assert!(
168 GumbelTemperatureSchedule::new(f64::NAN, 0.1, ScheduleKind::ReciprocalIter).is_err()
169 );
170 }
171
172 #[test]
173 fn new_err_for_tau_min_exceeds_tau_start() {
174 assert!(
175 GumbelTemperatureSchedule::new(0.5, 1.0, ScheduleKind::Geometric { rate: 0.9 })
176 .is_err()
177 );
178 }
179
180 #[test]
181 fn new_err_for_geometric_rate_out_of_range() {
182 assert!(
183 GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 1.0 })
184 .is_err()
185 );
186 assert!(
187 GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.0 })
188 .is_err()
189 );
190 }
191
192 #[test]
200 fn new_ok_for_geometric_rate_one_when_no_decay_needed() {
201 let rate = ScheduleKind::geometric_rate_from_steps(0.5, 0.5, 10);
202 assert!((rate - 1.0).abs() < 1e-15, "rate {rate}");
203 let s = GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate })
204 .expect("tau_min == tau_start must accept the derived rate == 1.0 geometric decay");
205 for iter in [0usize, 1, 10, 1000] {
206 assert!(
207 (s.current_tau(iter) - 0.5).abs() < 1e-15,
208 "tau at iter {iter} should stay pinned at 0.5"
209 );
210 }
211 }
212
213 #[test]
218 fn new_ok_for_explicit_geometric_rate_one_when_no_decay_needed() {
219 assert!(
220 GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate: 1.0 }).is_ok()
221 );
222 }
223
224 #[test]
225 fn new_err_for_linear_zero_steps() {
226 assert!(
227 GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Linear { steps: 0 }).is_err()
228 );
229 }
230
231 #[test]
234 fn geometric_iter_zero_returns_tau_start() {
235 let s = geometric(0.5);
236 assert!((s.current_tau(0) - 1.0).abs() < 1e-14);
237 }
238
239 #[test]
240 fn geometric_decays_by_rate_each_step() {
241 let s = geometric(0.5);
242 assert!((s.current_tau(2) - 0.25).abs() < 1e-12);
244 }
245
246 #[test]
247 fn geometric_clamps_at_tau_min() {
248 let s = GumbelTemperatureSchedule::new(1.0, 0.5, ScheduleKind::Geometric { rate: 0.1 })
249 .unwrap();
250 assert!((s.current_tau(5) - 0.5).abs() < 1e-14);
252 }
253
254 #[test]
257 fn linear_iter_zero_returns_tau_start() {
258 let s =
259 GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
260 assert!((s.current_tau(0) - 2.0).abs() < 1e-14);
261 }
262
263 #[test]
264 fn linear_at_steps_returns_tau_min() {
265 let s =
266 GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
267 assert!((s.current_tau(10) - 0.5).abs() < 1e-14);
268 }
269
270 #[test]
273 fn reciprocal_iter_zero_returns_tau_start() {
274 let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
275 assert!((s.current_tau(0) - 4.0).abs() < 1e-14);
276 }
277
278 #[test]
279 fn reciprocal_iter_one_halves_tau_start() {
280 let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
281 assert!((s.current_tau(1) - 2.0).abs() < 1e-14);
282 }
283
284 #[test]
287 fn step_increments_iter_count() {
288 let mut s = geometric(0.5);
289 assert_eq!(s.iter_count, 0);
290 s.step();
291 assert_eq!(s.iter_count, 1);
292 s.step();
293 assert_eq!(s.iter_count, 2);
294 }
295
296 #[test]
299 fn fixed_is_fixed_and_has_no_sweep_values() {
300 let s = SearchStrategy::Fixed;
301 assert!(s.is_fixed());
302 assert!(s.sweep_values().is_none());
303 }
304
305 #[test]
306 fn exponential_sweep_is_not_fixed_and_returns_values() {
307 let s = SearchStrategy::ExponentialSweep {
308 values: vec![1.0, 2.0, 3.0],
309 };
310 assert!(!s.is_fixed());
311 assert_eq!(s.sweep_values().unwrap(), &[1.0, 2.0, 3.0]);
312 }
313}