Skip to main content

datasynth_eval/calibration/
proposer.rs

1//! C3 Piece 2.5 — stock proposer implementations.
2//!
3//! Two ship in this piece:
4//!
5//! - [`GreedyKnobProposer`] — cycles through knobs and remembers
6//!   the direction (positive or negative Δ) that most recently
7//!   improved the loss for each knob. Re-proposes the same
8//!   direction until it stops working, then flips. Self-contained
9//!   coordinate-descent baseline that needs no external eval
10//!   surface beyond the loop's own [`StepReport`] history.
11//! - [`RoundRobinProposer`] — simpler still: always proposes
12//!   `current ± max_step` alternating per call. Useful as a sanity
13//!   baseline + as the proposer used in the C3 Piece 2 mock
14//!   evaluator tests.
15//!
16//! An AutoTuner-backed proposer that consults
17//! [`crate::enhancement::AutoTuner`] is a follow-up — that path
18//! requires bridging the BF-report objective stream to the
19//! AutoTuner's `ComprehensiveEvaluation` shape, which is more
20//! work than fits this commit.
21
22use std::collections::BTreeMap;
23
24use super::knob::{CalibrationKnob, KnobValue};
25use super::loop_runner::{ProposedPatch, Proposer, StepOutcome, StepReport};
26
27/// Per-knob bookkeeping for [`GreedyKnobProposer`].
28#[derive(Debug, Clone, Default)]
29struct KnobState {
30    /// `+1` or `-1` — the direction of the last proposed step.
31    /// `0` means "no direction tried yet".
32    last_direction: i32,
33    /// True if the last step in that direction was credited as
34    /// Improved by the loop; signals the proposer to keep going.
35    last_improved: bool,
36}
37
38/// Coordinate-descent proposer: pick the next knob round-robin,
39/// propose its current ± max_step in the direction that last
40/// improved (default +). After a step that didn't improve, flip
41/// the direction for that knob. Returns `None` only when every
42/// knob has tried both directions without improvement, which
43/// makes the loop stop with `ProposerExhausted`.
44pub struct GreedyKnobProposer {
45    /// Per-knob state.
46    state: BTreeMap<String, KnobState>,
47    /// Round-robin cursor — next knob index to try.
48    next_knob: usize,
49    /// Each knob's count of consecutive failed directions. When
50    /// this hits 2 (both ± tried, neither worked), the knob is
51    /// marked exhausted.
52    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    /// Per-knob exhaustion threshold. `fails ≥ MAX_FAILS` →
71    /// proposer skips this knob entirely.
72    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        // Update state from the most recent step (if any).
87        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        // Round-robin sweep starting at next_knob, looking for a
100        // knob that hasn't exhausted both directions yet.
101        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            // Decide direction:
111            //   - First time: try +1.
112            //   - Last improved: keep same direction.
113            //   - Last failed: flip direction.
114            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; // Will be updated next call.
121
122            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            // Convert proposed value to the same KnobValue variant.
127            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        // All knobs exhausted.
144        None
145    }
146}
147
148/// Stateless proposer that just steps the next knob in round-robin
149/// order by `+max_step` each time. Mostly useful as a smoke test
150/// fixture (or for the mock-evaluator unit tests).
151pub 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        // k.a starts at 0.05 + 0.01 max_step = 0.06.
247        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        // First time on k.a → +max_step → 0.05 + 0.01 = 0.06.
266        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        // First call: propose +max_step on knob 0.
276        let _p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
277        // Simulate a history where that step Improved.
278        let h1 = vec![step_with_outcome(
279            0,
280            0,
281            KnobValue::F64(0.06),
282            StepOutcome::Improved,
283        )];
284
285        // Second call: should propose on knob 1 (round-robin), then
286        // when we come back to knob 0 it should still go +.
287        let p2 = prop.propose(&ks, (0.0, 0.0), &h1).unwrap();
288        assert_eq!(p2.knob_index, 1);
289
290        // Tell the proposer knob 1 also improved.
291        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        // Third call: back to knob 0, direction should still be +.
297        let p3 = prop.propose(&ks, (0.0, 0.0), &h2).unwrap();
298        assert_eq!(p3.knob_index, 0);
299        // Knob 0 is still at 0.05 in `ks` (we didn't mutate the
300        // input slice in this test). Direction should be +1, so
301        // proposed = 0.05 + 0.01.
302        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        // First call on knob 0.
311        let _p1 = prop.propose(&ks, (0.0, 0.0), &[]).unwrap();
312        // Simulate that step being Reverted (no improvement).
313        let h1 = vec![step_with_outcome(
314            0,
315            0,
316            KnobValue::F64(0.06),
317            StepOutcome::Reverted,
318        )];
319
320        // Round-robin advances to knob 1 first.
321        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        // Back to knob 0: direction should now be -1.
328        let p3 = prop.propose(&ks, (0.0, 0.0), &h2).unwrap();
329        assert_eq!(p3.knob_index, 0);
330        // Proposed should now be 0.05 - 0.01 = 0.04.
331        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        // Single-knob set so we can deterministically test exhaustion.
342        let ks = vec![CalibrationKnob::new_f64("k", 0.05, 0.0, 1.0, 0.01)];
343
344        // Step 1: + direction, Reverted.
345        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        // Step 2: - direction, Reverted.
353        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        // Step 3: both directions failed → proposer should give up.
360        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}