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 `sae_manifold_fit(schedule=...)` callers 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                // `rate == 1.0` (no decay) is only legitimate when there is
74                // nothing to anneal towards, i.e. `tau_min == tau_start` (the
75                // `tau_min > tau_start` case was already rejected above, so
76                // this is exactly the equality case). `geometric_rate_from_steps`
77                // derives exactly `rate = 1.0` for that endpoint pair — for any
78                // `steps` — so rejecting it here made the derived-rate helper
79                // disagree with the constructor it exists to feed for a
80                // perfectly legal "no annealing needed" schedule. Genuine
81                // annealing (`tau_min < tau_start`) still requires a strictly
82                // decaying rate.
83                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
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn geometric(rate: f64) -> GumbelTemperatureSchedule {
132        GumbelTemperatureSchedule::new(1.0, 0.01, ScheduleKind::Geometric { rate }).unwrap()
133    }
134
135    // ── GumbelTemperatureSchedule validation ──────────────────────────────────
136
137    #[test]
138    fn new_ok_for_valid_geometric() {
139        assert!(
140            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.9 }).is_ok()
141        );
142    }
143
144    #[test]
145    fn new_err_for_non_positive_tau_start() {
146        assert!(GumbelTemperatureSchedule::new(0.0, 0.1, ScheduleKind::ReciprocalIter).is_err());
147        assert!(
148            GumbelTemperatureSchedule::new(f64::NAN, 0.1, ScheduleKind::ReciprocalIter).is_err()
149        );
150    }
151
152    #[test]
153    fn new_err_for_tau_min_exceeds_tau_start() {
154        assert!(
155            GumbelTemperatureSchedule::new(0.5, 1.0, ScheduleKind::Geometric { rate: 0.9 })
156                .is_err()
157        );
158    }
159
160    #[test]
161    fn new_err_for_geometric_rate_out_of_range() {
162        assert!(
163            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 1.0 })
164                .is_err()
165        );
166        assert!(
167            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.0 })
168                .is_err()
169        );
170    }
171
172    /// A `tau_min == tau_start` endpoint pair asks for a schedule with nothing
173    /// to anneal towards. `geometric_rate_from_steps` derives exactly
174    /// `rate = 1.0` for that pair (for any `steps`), and the constructor must
175    /// accept it — this is the "no annealing needed" degenerate case, not the
176    /// out-of-range rate the previous test guards against (there, `tau_min <
177    /// tau_start` so `rate == 1.0` would mean the schedule never reaches its
178    /// floor and is rightly rejected).
179    #[test]
180    fn new_ok_for_geometric_rate_one_when_no_decay_needed() {
181        let rate = ScheduleKind::geometric_rate_from_steps(0.5, 0.5, 10);
182        assert!((rate - 1.0).abs() < 1e-15, "rate {rate}");
183        let s = GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate })
184            .expect("tau_min == tau_start must accept the derived rate == 1.0 geometric decay");
185        for iter in [0usize, 1, 10, 1000] {
186            assert!(
187                (s.current_tau(iter) - 0.5).abs() < 1e-15,
188                "tau at iter {iter} should stay pinned at 0.5"
189            );
190        }
191    }
192
193    /// Same endpoint pair via an explicit `rate: 1.0` (not routed through the
194    /// derivation helper) must be accepted identically — the two are the same
195    /// mathematical schedule and validation cannot (and should not) tell them
196    /// apart.
197    #[test]
198    fn new_ok_for_explicit_geometric_rate_one_when_no_decay_needed() {
199        assert!(
200            GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate: 1.0 }).is_ok()
201        );
202    }
203
204    #[test]
205    fn new_err_for_linear_zero_steps() {
206        assert!(
207            GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Linear { steps: 0 }).is_err()
208        );
209    }
210
211    // ── current_tau: Geometric ────────────────────────────────────────────────
212
213    #[test]
214    fn geometric_iter_zero_returns_tau_start() {
215        let s = geometric(0.5);
216        assert!((s.current_tau(0) - 1.0).abs() < 1e-14);
217    }
218
219    #[test]
220    fn geometric_decays_by_rate_each_step() {
221        let s = geometric(0.5);
222        // iter 2: 1.0 * 0.5^2 = 0.25
223        assert!((s.current_tau(2) - 0.25).abs() < 1e-12);
224    }
225
226    #[test]
227    fn geometric_clamps_at_tau_min() {
228        let s = GumbelTemperatureSchedule::new(1.0, 0.5, ScheduleKind::Geometric { rate: 0.1 })
229            .unwrap();
230        // 1.0 * 0.1^5 = 1e-5 < tau_min=0.5 → clamped
231        assert!((s.current_tau(5) - 0.5).abs() < 1e-14);
232    }
233
234    // ── current_tau: Linear ───────────────────────────────────────────────────
235
236    #[test]
237    fn linear_iter_zero_returns_tau_start() {
238        let s =
239            GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
240        assert!((s.current_tau(0) - 2.0).abs() < 1e-14);
241    }
242
243    #[test]
244    fn linear_at_steps_returns_tau_min() {
245        let s =
246            GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
247        assert!((s.current_tau(10) - 0.5).abs() < 1e-14);
248    }
249
250    // ── current_tau: ReciprocalIter ───────────────────────────────────────────
251
252    #[test]
253    fn reciprocal_iter_zero_returns_tau_start() {
254        let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
255        assert!((s.current_tau(0) - 4.0).abs() < 1e-14);
256    }
257
258    #[test]
259    fn reciprocal_iter_one_halves_tau_start() {
260        let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
261        assert!((s.current_tau(1) - 2.0).abs() < 1e-14);
262    }
263
264    // ── step() increments iter_count ──────────────────────────────────────────
265
266    #[test]
267    fn step_increments_iter_count() {
268        let mut s = geometric(0.5);
269        assert_eq!(s.iter_count, 0);
270        s.step();
271        assert_eq!(s.iter_count, 1);
272        s.step();
273        assert_eq!(s.iter_count, 2);
274    }
275
276}