1use std::collections::BTreeMap;
23
24use super::knob::{CalibrationKnob, KnobValue};
25use super::loop_runner::{ProposedPatch, Proposer, StepOutcome, StepReport};
26
27#[derive(Debug, Clone, Default)]
29struct KnobState {
30 last_direction: i32,
33 last_improved: bool,
36}
37
38pub struct GreedyKnobProposer {
45 state: BTreeMap<String, KnobState>,
47 next_knob: usize,
49 fails_per_knob: BTreeMap<String, usize>,
53}
54
55impl Default for GreedyKnobProposer {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl GreedyKnobProposer {
62 pub fn new() -> Self {
63 Self {
64 state: BTreeMap::new(),
65 next_knob: 0,
66 fails_per_knob: BTreeMap::new(),
67 }
68 }
69
70 const MAX_FAILS: usize = 2;
73}
74
75impl Proposer for GreedyKnobProposer {
76 fn propose(
77 &mut self,
78 knobs: &[CalibrationKnob],
79 _current_loss: (f64, f64),
80 history: &[StepReport],
81 ) -> Option<ProposedPatch> {
82 if knobs.is_empty() {
83 return None;
84 }
85
86 if let Some(last) = history.last() {
88 if let Some(patch) = &last.proposed_patch {
89 let path = knobs[patch.knob_index].path.clone();
90 let improved = matches!(last.outcome, StepOutcome::Improved);
91 let entry = self.state.entry(path.clone()).or_default();
92 entry.last_improved = improved;
93 if !improved {
94 *self.fails_per_knob.entry(path).or_default() += 1;
95 }
96 }
97 }
98
99 for offset in 0..knobs.len() {
102 let idx = (self.next_knob + offset) % knobs.len();
103 let path = &knobs[idx].path;
104 let fails = self.fails_per_knob.get(path).copied().unwrap_or(0);
105 if fails >= Self::MAX_FAILS {
106 continue;
107 }
108
109 let entry = self.state.entry(path.clone()).or_default();
110 let dir = match (entry.last_direction, entry.last_improved) {
115 (0, _) => 1,
116 (d, true) => d,
117 (d, false) => -d,
118 };
119 entry.last_direction = dir;
120 entry.last_improved = false; let cur = knobs[idx].current.as_f64();
123 let step = knobs[idx].max_step * dir as f64;
124 let proposed_f = cur + step;
125
126 let proposed = match knobs[idx].current {
128 KnobValue::F64(_) => KnobValue::F64(proposed_f),
129 KnobValue::Usize(_) => KnobValue::Usize(proposed_f.round().max(0.0) as usize),
130 };
131
132 self.next_knob = (idx + 1) % knobs.len();
133 return Some(ProposedPatch {
134 knob_index: idx,
135 proposed_value: proposed,
136 rationale: format!(
137 "greedy: knob `{}` direction {dir} step {step}",
138 knobs[idx].path
139 ),
140 });
141 }
142
143 None
145 }
146}
147
148pub struct RoundRobinProposer {
152 pub next_knob: usize,
153}
154
155impl RoundRobinProposer {
156 pub fn new() -> Self {
157 Self { next_knob: 0 }
158 }
159}
160
161impl Default for RoundRobinProposer {
162 fn default() -> Self {
163 Self::new()
164 }
165}
166
167impl Proposer for RoundRobinProposer {
168 fn propose(
169 &mut self,
170 knobs: &[CalibrationKnob],
171 _current_loss: (f64, f64),
172 _history: &[StepReport],
173 ) -> Option<ProposedPatch> {
174 if knobs.is_empty() {
175 return None;
176 }
177 let idx = self.next_knob % knobs.len();
178 self.next_knob = (self.next_knob + 1) % knobs.len();
179 let cur = knobs[idx].current.as_f64();
180 let proposed = match knobs[idx].current {
181 KnobValue::F64(_) => KnobValue::F64(cur + knobs[idx].max_step),
182 KnobValue::Usize(_) => {
183 KnobValue::Usize((cur + knobs[idx].max_step).round().max(0.0) as usize)
184 }
185 };
186 Some(ProposedPatch {
187 knob_index: idx,
188 proposed_value: proposed,
189 rationale: format!("round-robin step on `{}`", knobs[idx].path),
190 })
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::calibration::loop_runner::{ProposedPatch, StepReport};
198 use std::collections::BTreeMap;
199
200 fn knobs() -> Vec<CalibrationKnob> {
201 vec![
202 CalibrationKnob::new_f64("k.a", 0.05, 0.0, 1.0, 0.01),
203 CalibrationKnob::new_f64("k.b", 0.10, 0.0, 1.0, 0.02),
204 ]
205 }
206
207 fn step_with_outcome(
208 iter: usize,
209 knob_index: usize,
210 proposed_value: KnobValue,
211 outcome: StepOutcome,
212 ) -> StepReport {
213 StepReport {
214 iter,
215 loss_before_mean: 1.0,
216 loss_before_std: 0.0,
217 proposed_patch: Some(ProposedPatch {
218 knob_index,
219 proposed_value,
220 rationale: "test".into(),
221 }),
222 loss_after_mean: Some(1.0),
223 loss_after_std: Some(0.0),
224 knob_values: BTreeMap::new(),
225 outcome,
226 }
227 }
228
229 #[test]
230 fn round_robin_proposer_cycles_through_knobs() {
231 let mut prop = RoundRobinProposer::new();
232 let ks = knobs();
233 let p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
234 assert_eq!(p1.knob_index, 0);
235 let p2 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
236 assert_eq!(p2.knob_index, 1);
237 let p3 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
238 assert_eq!(p3.knob_index, 0);
239 }
240
241 #[test]
242 fn round_robin_proposer_steps_by_max_step() {
243 let mut prop = RoundRobinProposer::new();
244 let ks = knobs();
245 let p = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
246 assert!(
248 (p.proposed_value.as_f64() - 0.06).abs() < 1e-12,
249 "expected 0.06, got {}",
250 p.proposed_value
251 );
252 }
253
254 #[test]
255 fn round_robin_proposer_empty_knobs_returns_none() {
256 let mut prop = RoundRobinProposer::new();
257 assert!(prop.propose(&[], (0.0, 0.0), &[]).is_none());
258 }
259
260 #[test]
261 fn greedy_proposer_first_call_picks_positive_direction() {
262 let mut prop = GreedyKnobProposer::new();
263 let ks = knobs();
264 let p = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
265 assert_eq!(p.knob_index, 0);
267 assert!((p.proposed_value.as_f64() - 0.06).abs() < 1e-12);
268 }
269
270 #[test]
271 fn greedy_proposer_continues_same_direction_after_improvement() {
272 let mut prop = GreedyKnobProposer::new();
273 let ks = knobs();
274
275 let _p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
277 let h1 = vec![step_with_outcome(
279 0,
280 0,
281 KnobValue::F64(0.06),
282 StepOutcome::Improved,
283 )];
284
285 let p2 = prop.propose(&ks, (0.0, 0.0), &h1).unwrap();
288 assert_eq!(p2.knob_index, 1);
289
290 let h2 = vec![
292 step_with_outcome(0, 0, KnobValue::F64(0.06), StepOutcome::Improved),
293 step_with_outcome(1, 1, KnobValue::F64(0.12), StepOutcome::Improved),
294 ];
295
296 let p3 = prop.propose(&ks, (0.0, 0.0), &h2).unwrap();
298 assert_eq!(p3.knob_index, 0);
299 assert!((p3.proposed_value.as_f64() - 0.06).abs() < 1e-12);
303 }
304
305 #[test]
306 fn greedy_proposer_flips_direction_after_failure() {
307 let mut prop = GreedyKnobProposer::new();
308 let ks = knobs();
309
310 let _p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
312 let h1 = vec![step_with_outcome(
314 0,
315 0,
316 KnobValue::F64(0.06),
317 StepOutcome::Reverted,
318 )];
319
320 let _p2 = prop.propose(&ks, (0.0, 0.0), &h1).unwrap();
322 let h2 = vec![
323 step_with_outcome(0, 0, KnobValue::F64(0.06), StepOutcome::Reverted),
324 step_with_outcome(1, 1, KnobValue::F64(0.12), StepOutcome::Improved),
325 ];
326
327 let p3 = prop.propose(&ks, (0.0, 0.0), &h2).unwrap();
329 assert_eq!(p3.knob_index, 0);
330 assert!(
332 (p3.proposed_value.as_f64() - 0.04).abs() < 1e-12,
333 "expected 0.04 (flipped direction), got {}",
334 p3.proposed_value
335 );
336 }
337
338 #[test]
339 fn greedy_proposer_exhausts_after_both_directions_fail() {
340 let mut prop = GreedyKnobProposer::new();
341 let ks = vec![CalibrationKnob::new_f64("k", 0.05, 0.0, 1.0, 0.01)];
343
344 let _p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
346 let h1 = vec![step_with_outcome(
347 0,
348 0,
349 KnobValue::F64(0.06),
350 StepOutcome::Reverted,
351 )];
352 let _p2 = prop.propose(&ks, (0.0, 0.0), &h1).unwrap();
354 let h2 = vec![
355 step_with_outcome(0, 0, KnobValue::F64(0.06), StepOutcome::Reverted),
356 step_with_outcome(1, 0, KnobValue::F64(0.04), StepOutcome::Reverted),
357 ];
358
359 let p3 = prop.propose(&ks, (0.0, 0.0), &h2);
361 assert!(
362 p3.is_none(),
363 "proposer should exhaust after both directions fail; got {:?}",
364 p3.map(|p| p.proposed_value)
365 );
366 }
367}