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#[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    // ── GumbelTemperatureSchedule validation ──────────────────────────────────
156
157    #[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    /// A `tau_min == tau_start` endpoint pair asks for a schedule with nothing
193    /// to anneal towards. `geometric_rate_from_steps` derives exactly
194    /// `rate = 1.0` for that pair (for any `steps`), and the constructor must
195    /// accept it — this is the "no annealing needed" degenerate case, not the
196    /// out-of-range rate the previous test guards against (there, `tau_min <
197    /// tau_start` so `rate == 1.0` would mean the schedule never reaches its
198    /// floor and is rightly rejected).
199    #[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    /// Same endpoint pair via an explicit `rate: 1.0` (not routed through the
214    /// derivation helper) must be accepted identically — the two are the same
215    /// mathematical schedule and validation cannot (and should not) tell them
216    /// apart.
217    #[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    // ── current_tau: Geometric ────────────────────────────────────────────────
232
233    #[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        // iter 2: 1.0 * 0.5^2 = 0.25
243        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        // 1.0 * 0.1^5 = 1e-5 < tau_min=0.5 → clamped
251        assert!((s.current_tau(5) - 0.5).abs() < 1e-14);
252    }
253
254    // ── current_tau: Linear ───────────────────────────────────────────────────
255
256    #[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    // ── current_tau: ReciprocalIter ───────────────────────────────────────────
271
272    #[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    // ── step() increments iter_count ──────────────────────────────────────────
285
286    #[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    // ── SearchStrategy ────────────────────────────────────────────────────────
297
298    #[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}