Skip to main content

gam_problem/
schedule.rs

1/// Decay law for deterministic Gumbel/concrete assignment temperature.
2#[derive(Debug, Clone)]
3pub enum ScheduleKind {
4    Geometric { rate: f64 },
5    Linear { steps: usize },
6    ReciprocalIter,
7}
8
9impl ScheduleKind {
10    /// Geometric decay rate annealing `tau_start` → `tau_min` over `steps`
11    /// steps: `rate = (tau_min / tau_start)^(1/steps)`.
12    ///
13    /// This is the single source for the endpoints-and-steps geometric spec, so
14    /// callers that only know the `(tau_start, tau_min, steps)` triple (e.g. the
15    /// Python `SparsityConfig.gumbel_schedule`) hand the spec over verbatim and
16    /// let the schedule derive the rate rather than duplicating the arithmetic.
17    #[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/// Outer-state temperature annealing for SAE assignment relaxations.
25///
26/// Annealing drives the continuous concrete/softmax assignment toward the
27/// discrete argmax or IBP active-set solution while PIRLS solves smooth
28/// positive-temperature subproblems. In the zero-floor limit, softmax becomes
29/// argmax and the IBP-MAP sigmoid active set becomes exact; a positive
30/// `tau_min` optimizes the corresponding near-discrete MAP problem.
31#[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                if !(rate.is_finite() && rate > 0.0 && rate < 1.0) {
74                    return Err(format!(
75                        "GumbelTemperatureSchedule::Geometric: rate must be in (0, 1); got {rate}"
76                    ));
77                }
78            }
79            ScheduleKind::Linear { steps } => {
80                if steps == 0 {
81                    return Err("GumbelTemperatureSchedule::Linear: steps must be positive".into());
82                }
83            }
84            ScheduleKind::ReciprocalIter => {}
85        }
86        Ok(())
87    }
88
89    pub fn current_tau(&self, iter: usize) -> f64 {
90        let raw = match self.decay {
91            ScheduleKind::Geometric { rate } => self.tau_start * rate.powf(iter as f64),
92            ScheduleKind::Linear { steps } => {
93                if iter >= steps {
94                    self.tau_min
95                } else {
96                    let frac = iter as f64 / steps as f64;
97                    self.tau_start + frac * (self.tau_min - self.tau_start)
98                }
99            }
100            ScheduleKind::ReciprocalIter => self.tau_start / (1.0 + iter as f64),
101        };
102        raw.max(self.tau_min)
103    }
104
105    pub fn step(&mut self) -> f64 {
106        let tau = self.current_tau(self.iter_count);
107        self.iter_count += 1;
108        tau
109    }
110}
111
112#[derive(Debug, Clone, PartialEq)]
113pub enum SearchStrategy {
114    Fixed,
115    ExponentialSweep { values: Vec<f64> },
116}
117
118impl SearchStrategy {
119    #[must_use]
120    pub fn is_fixed(&self) -> bool {
121        matches!(self, Self::Fixed)
122    }
123
124    #[must_use]
125    pub fn sweep_values(&self) -> Option<&[f64]> {
126        match self {
127            Self::Fixed => None,
128            Self::ExponentialSweep { values } => Some(values),
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn geometric(rate: f64) -> GumbelTemperatureSchedule {
138        GumbelTemperatureSchedule::new(1.0, 0.01, ScheduleKind::Geometric { rate }).unwrap()
139    }
140
141    // ── GumbelTemperatureSchedule validation ──────────────────────────────────
142
143    #[test]
144    fn new_ok_for_valid_geometric() {
145        assert!(
146            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.9 }).is_ok()
147        );
148    }
149
150    #[test]
151    fn new_err_for_non_positive_tau_start() {
152        assert!(GumbelTemperatureSchedule::new(0.0, 0.1, ScheduleKind::ReciprocalIter).is_err());
153        assert!(
154            GumbelTemperatureSchedule::new(f64::NAN, 0.1, ScheduleKind::ReciprocalIter).is_err()
155        );
156    }
157
158    #[test]
159    fn new_err_for_tau_min_exceeds_tau_start() {
160        assert!(
161            GumbelTemperatureSchedule::new(0.5, 1.0, ScheduleKind::Geometric { rate: 0.9 })
162                .is_err()
163        );
164    }
165
166    #[test]
167    fn new_err_for_geometric_rate_out_of_range() {
168        assert!(
169            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 1.0 })
170                .is_err()
171        );
172        assert!(
173            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.0 })
174                .is_err()
175        );
176    }
177
178    #[test]
179    fn new_err_for_linear_zero_steps() {
180        assert!(
181            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Linear { steps: 0 }).is_err()
182        );
183    }
184
185    // ── current_tau: Geometric ────────────────────────────────────────────────
186
187    #[test]
188    fn geometric_iter_zero_returns_tau_start() {
189        let s = geometric(0.5);
190        assert!((s.current_tau(0) - 1.0).abs() < 1e-14);
191    }
192
193    #[test]
194    fn geometric_decays_by_rate_each_step() {
195        let s = geometric(0.5);
196        // iter 2: 1.0 * 0.5^2 = 0.25
197        assert!((s.current_tau(2) - 0.25).abs() < 1e-12);
198    }
199
200    #[test]
201    fn geometric_clamps_at_tau_min() {
202        let s = GumbelTemperatureSchedule::new(1.0, 0.5, ScheduleKind::Geometric { rate: 0.1 })
203            .unwrap();
204        // 1.0 * 0.1^5 = 1e-5 < tau_min=0.5 → clamped
205        assert!((s.current_tau(5) - 0.5).abs() < 1e-14);
206    }
207
208    // ── current_tau: Linear ───────────────────────────────────────────────────
209
210    #[test]
211    fn linear_iter_zero_returns_tau_start() {
212        let s =
213            GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
214        assert!((s.current_tau(0) - 2.0).abs() < 1e-14);
215    }
216
217    #[test]
218    fn linear_at_steps_returns_tau_min() {
219        let s =
220            GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
221        assert!((s.current_tau(10) - 0.5).abs() < 1e-14);
222    }
223
224    // ── current_tau: ReciprocalIter ───────────────────────────────────────────
225
226    #[test]
227    fn reciprocal_iter_zero_returns_tau_start() {
228        let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
229        assert!((s.current_tau(0) - 4.0).abs() < 1e-14);
230    }
231
232    #[test]
233    fn reciprocal_iter_one_halves_tau_start() {
234        let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
235        assert!((s.current_tau(1) - 2.0).abs() < 1e-14);
236    }
237
238    // ── step() increments iter_count ──────────────────────────────────────────
239
240    #[test]
241    fn step_increments_iter_count() {
242        let mut s = geometric(0.5);
243        assert_eq!(s.iter_count, 0);
244        s.step();
245        assert_eq!(s.iter_count, 1);
246        s.step();
247        assert_eq!(s.iter_count, 2);
248    }
249
250    // ── SearchStrategy ────────────────────────────────────────────────────────
251
252    #[test]
253    fn fixed_is_fixed_and_has_no_sweep_values() {
254        let s = SearchStrategy::Fixed;
255        assert!(s.is_fixed());
256        assert!(s.sweep_values().is_none());
257    }
258
259    #[test]
260    fn exponential_sweep_is_not_fixed_and_returns_values() {
261        let s = SearchStrategy::ExponentialSweep {
262            values: vec![1.0, 2.0, 3.0],
263        };
264        assert!(!s.is_fixed());
265        assert_eq!(s.sweep_values().unwrap(), &[1.0, 2.0, 3.0]);
266    }
267}