1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
/// Decay law for deterministic Gumbel/concrete assignment temperature.
#[derive(Debug, Clone)]
pub enum ScheduleKind {
Geometric { rate: f64 },
Linear { steps: usize },
ReciprocalIter,
}
impl ScheduleKind {
/// Geometric decay rate annealing `tau_start` → `tau_min` over `steps`
/// steps: `rate = (tau_min / tau_start)^(1/steps)`.
///
/// This is the single source for the endpoints-and-steps geometric spec, so
/// callers that only know the `(tau_start, tau_min, steps)` triple (e.g. the
/// Python `sae_manifold_fit(schedule=...)` callers hand the spec over verbatim and
/// let the schedule derive the rate rather than duplicating the arithmetic.
#[must_use]
pub fn geometric_rate_from_steps(tau_start: f64, tau_min: f64, steps: usize) -> f64 {
let steps = steps.max(1);
(tau_min / tau_start).powf(1.0 / steps as f64)
}
}
/// Outer-state temperature annealing for SAE assignment relaxations.
///
/// Annealing drives the continuous concrete/softmax assignment toward the
/// discrete argmax or IBP active-set solution while PIRLS solves smooth
/// positive-temperature subproblems. In the zero-floor limit, softmax becomes
/// argmax and the IBP-MAP sigmoid active set becomes exact; a positive
/// `tau_min` optimizes the corresponding near-discrete MAP problem.
#[derive(Debug, Clone)]
pub struct GumbelTemperatureSchedule {
pub tau_start: f64,
pub tau_min: f64,
pub decay: ScheduleKind,
pub iter_count: usize,
}
impl GumbelTemperatureSchedule {
#[must_use = "build error must be handled"]
pub fn new(tau_start: f64, tau_min: f64, decay: ScheduleKind) -> Result<Self, String> {
let sched = Self {
tau_start,
tau_min,
decay,
iter_count: 0,
};
sched.validate()?;
Ok(sched)
}
pub fn validate(&self) -> Result<(), String> {
if !(self.tau_start.is_finite() && self.tau_start > 0.0) {
return Err(format!(
"GumbelTemperatureSchedule: tau_start must be finite and positive; got {}",
self.tau_start
));
}
if !(self.tau_min.is_finite() && self.tau_min > 0.0) {
return Err(format!(
"GumbelTemperatureSchedule: tau_min must be finite and positive; got {}",
self.tau_min
));
}
if self.tau_min > self.tau_start {
return Err(format!(
"GumbelTemperatureSchedule: tau_min ({}) cannot exceed tau_start ({})",
self.tau_min, self.tau_start
));
}
match self.decay {
ScheduleKind::Geometric { rate } => {
// `rate == 1.0` (no decay) is only legitimate when there is
// nothing to anneal towards, i.e. `tau_min == tau_start` (the
// `tau_min > tau_start` case was already rejected above, so
// this is exactly the equality case). `geometric_rate_from_steps`
// derives exactly `rate = 1.0` for that endpoint pair — for any
// `steps` — so rejecting it here made the derived-rate helper
// disagree with the constructor it exists to feed for a
// perfectly legal "no annealing needed" schedule. Genuine
// annealing (`tau_min < tau_start`) still requires a strictly
// decaying rate.
let no_decay_needed = self.tau_min >= self.tau_start;
let rate_ok = rate.is_finite()
&& rate > 0.0
&& (rate < 1.0 || (no_decay_needed && rate == 1.0));
if !rate_ok {
return Err(format!(
"GumbelTemperatureSchedule::Geometric: rate must be in (0, 1); got {rate}"
));
}
}
ScheduleKind::Linear { steps } => {
if steps == 0 {
return Err("GumbelTemperatureSchedule::Linear: steps must be positive".into());
}
}
ScheduleKind::ReciprocalIter => {}
}
Ok(())
}
pub fn current_tau(&self, iter: usize) -> f64 {
let raw = match self.decay {
ScheduleKind::Geometric { rate } => self.tau_start * rate.powf(iter as f64),
ScheduleKind::Linear { steps } => {
if iter >= steps {
self.tau_min
} else {
let frac = iter as f64 / steps as f64;
self.tau_start + frac * (self.tau_min - self.tau_start)
}
}
ScheduleKind::ReciprocalIter => self.tau_start / (1.0 + iter as f64),
};
raw.max(self.tau_min)
}
pub fn step(&mut self) -> f64 {
let tau = self.current_tau(self.iter_count);
self.iter_count += 1;
tau
}
}
#[cfg(test)]
mod tests {
use super::*;
fn geometric(rate: f64) -> GumbelTemperatureSchedule {
GumbelTemperatureSchedule::new(1.0, 0.01, ScheduleKind::Geometric { rate }).unwrap()
}
// ── GumbelTemperatureSchedule validation ──────────────────────────────────
#[test]
fn new_ok_for_valid_geometric() {
assert!(
GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.9 }).is_ok()
);
}
#[test]
fn new_err_for_non_positive_tau_start() {
assert!(GumbelTemperatureSchedule::new(0.0, 0.1, ScheduleKind::ReciprocalIter).is_err());
assert!(
GumbelTemperatureSchedule::new(f64::NAN, 0.1, ScheduleKind::ReciprocalIter).is_err()
);
}
#[test]
fn new_err_for_tau_min_exceeds_tau_start() {
assert!(
GumbelTemperatureSchedule::new(0.5, 1.0, ScheduleKind::Geometric { rate: 0.9 })
.is_err()
);
}
#[test]
fn new_err_for_geometric_rate_out_of_range() {
assert!(
GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 1.0 })
.is_err()
);
assert!(
GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Geometric { rate: 0.0 })
.is_err()
);
}
/// A `tau_min == tau_start` endpoint pair asks for a schedule with nothing
/// to anneal towards. `geometric_rate_from_steps` derives exactly
/// `rate = 1.0` for that pair (for any `steps`), and the constructor must
/// accept it — this is the "no annealing needed" degenerate case, not the
/// out-of-range rate the previous test guards against (there, `tau_min <
/// tau_start` so `rate == 1.0` would mean the schedule never reaches its
/// floor and is rightly rejected).
#[test]
fn new_ok_for_geometric_rate_one_when_no_decay_needed() {
let rate = ScheduleKind::geometric_rate_from_steps(0.5, 0.5, 10);
assert!((rate - 1.0).abs() < 1e-15, "rate {rate}");
let s = GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate })
.expect("tau_min == tau_start must accept the derived rate == 1.0 geometric decay");
for iter in [0usize, 1, 10, 1000] {
assert!(
(s.current_tau(iter) - 0.5).abs() < 1e-15,
"tau at iter {iter} should stay pinned at 0.5"
);
}
}
/// Same endpoint pair via an explicit `rate: 1.0` (not routed through the
/// derivation helper) must be accepted identically — the two are the same
/// mathematical schedule and validation cannot (and should not) tell them
/// apart.
#[test]
fn new_ok_for_explicit_geometric_rate_one_when_no_decay_needed() {
assert!(
GumbelTemperatureSchedule::new(0.5, 0.5, ScheduleKind::Geometric { rate: 1.0 }).is_ok()
);
}
#[test]
fn new_err_for_linear_zero_steps() {
assert!(
GumbelTemperatureSchedule::new(1.0, 0.1, ScheduleKind::Linear { steps: 0 }).is_err()
);
}
// ── current_tau: Geometric ────────────────────────────────────────────────
#[test]
fn geometric_iter_zero_returns_tau_start() {
let s = geometric(0.5);
assert!((s.current_tau(0) - 1.0).abs() < 1e-14);
}
#[test]
fn geometric_decays_by_rate_each_step() {
let s = geometric(0.5);
// iter 2: 1.0 * 0.5^2 = 0.25
assert!((s.current_tau(2) - 0.25).abs() < 1e-12);
}
#[test]
fn geometric_clamps_at_tau_min() {
let s = GumbelTemperatureSchedule::new(1.0, 0.5, ScheduleKind::Geometric { rate: 0.1 })
.unwrap();
// 1.0 * 0.1^5 = 1e-5 < tau_min=0.5 → clamped
assert!((s.current_tau(5) - 0.5).abs() < 1e-14);
}
// ── current_tau: Linear ───────────────────────────────────────────────────
#[test]
fn linear_iter_zero_returns_tau_start() {
let s =
GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
assert!((s.current_tau(0) - 2.0).abs() < 1e-14);
}
#[test]
fn linear_at_steps_returns_tau_min() {
let s =
GumbelTemperatureSchedule::new(2.0, 0.5, ScheduleKind::Linear { steps: 10 }).unwrap();
assert!((s.current_tau(10) - 0.5).abs() < 1e-14);
}
// ── current_tau: ReciprocalIter ───────────────────────────────────────────
#[test]
fn reciprocal_iter_zero_returns_tau_start() {
let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
assert!((s.current_tau(0) - 4.0).abs() < 1e-14);
}
#[test]
fn reciprocal_iter_one_halves_tau_start() {
let s = GumbelTemperatureSchedule::new(4.0, 0.1, ScheduleKind::ReciprocalIter).unwrap();
assert!((s.current_tau(1) - 2.0).abs() < 1e-14);
}
// ── step() increments iter_count ──────────────────────────────────────────
#[test]
fn step_increments_iter_count() {
let mut s = geometric(0.5);
assert_eq!(s.iter_count, 0);
s.step();
assert_eq!(s.iter_count, 1);
s.step();
assert_eq!(s.iter_count, 2);
}
}