Skip to main content

datasynth_eval/calibration/
knob.rs

1//! C3 Piece 1 — calibration knob (parameter space element).
2//!
3//! Each [`CalibrationKnob`] names one tunable engine parameter, its
4//! current value, its allowable range, and the maximum step size
5//! the calibration loop is permitted to propose per iteration.
6//! Knobs are typed via [`KnobValue`] — initial cut covers `f64`
7//! (rates, ratios) and `usize` (pool sizes); discrete-string knobs
8//! are deferred.
9//!
10//! Values round-trip through YAML config patches as strings so a
11//! `--patch fraud.fraud_rate=0.03` flag works the same way the
12//! existing `AutoTuner::ConfigPatch.suggested_value` shape does.
13
14use std::fmt;
15
16use serde::{Deserialize, Serialize};
17
18/// One value a knob can hold.
19///
20/// Strings round-trip via YAML; numbers are parsed/formatted when
21/// the caller writes a config patch.
22#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub enum KnobValue {
24    /// f64 — e.g. `fraud.fraud_rate = 0.02`.
25    F64(f64),
26    /// usize — e.g. `concentration.trading_partner_pool.target_size = 12`.
27    Usize(usize),
28}
29
30impl KnobValue {
31    /// YAML-ready string form: float as `"0.02"`, int as `"12"`.
32    pub fn to_yaml_string(&self) -> String {
33        match self {
34            Self::F64(v) => format!("{v}"),
35            Self::Usize(v) => v.to_string(),
36        }
37    }
38
39    /// Numeric magnitude (for comparing two values or computing a
40    /// step size). Both variants project to `f64`.
41    pub fn as_f64(&self) -> f64 {
42        match self {
43            Self::F64(v) => *v,
44            Self::Usize(v) => *v as f64,
45        }
46    }
47}
48
49impl fmt::Display for KnobValue {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(&self.to_yaml_string())
52    }
53}
54
55/// Allowable range for a knob's value.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum KnobBounds {
58    /// `f64 ∈ [min, max]`.
59    F64Range { min: f64, max: f64 },
60    /// `usize ∈ [min, max]`.
61    UsizeRange { min: usize, max: usize },
62}
63
64impl KnobBounds {
65    /// Range width — `max - min`. Used as a default step-size scale.
66    pub fn width(&self) -> f64 {
67        match self {
68            Self::F64Range { min, max } => max - min,
69            Self::UsizeRange { min, max } => (max - min) as f64,
70        }
71    }
72
73    /// Check whether the bounds variant matches a [`KnobValue`].
74    /// Returns false on type mismatch (e.g. F64Range for a Usize
75    /// value) — a constructor + setter should never produce such a
76    /// pair, but the loop's safety rail tests for it defensively.
77    pub fn matches_value(&self, value: &KnobValue) -> bool {
78        matches!(
79            (self, value),
80            (Self::F64Range { .. }, KnobValue::F64(_))
81                | (Self::UsizeRange { .. }, KnobValue::Usize(_))
82        )
83    }
84}
85
86/// Outcome of [`CalibrationKnob::clip`]: whether the proposed value
87/// was inside bounds, or was clamped to a bound.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum KnobClipResult {
90    /// Proposed value was inside bounds; final value == proposed.
91    InRange,
92    /// Proposed value was below the lower bound; clamped to `min`.
93    ClippedLow,
94    /// Proposed value was above the upper bound; clamped to `max`.
95    ClippedHigh,
96    /// Type mismatch between bounds and proposed value — final
97    /// value == current (no change). Should never occur in
98    /// well-constructed knobs; the loop logs this as a bug.
99    TypeMismatch,
100}
101
102/// One tunable engine parameter.
103#[derive(Debug, Clone)]
104pub struct CalibrationKnob {
105    /// Config-tree path the knob writes (e.g. `"fraud.fraud_rate"`).
106    /// Must match the path the orchestrator's config-patcher
107    /// understands — same format as
108    /// [`crate::enhancement::ConfigPatch::path`].
109    pub path: String,
110    /// Current value.
111    pub current: KnobValue,
112    /// Allowable bounds.
113    pub bounds: KnobBounds,
114    /// Maximum step size (numeric magnitude) the loop may propose
115    /// per iteration. Patches with `|Δ| > max_step` are clipped to
116    /// `±max_step`. Use this to prevent the optimizer from making
117    /// a giant jump on a single noisy gradient.
118    pub max_step: f64,
119}
120
121impl CalibrationKnob {
122    /// Convenience constructor for an f64 knob.
123    pub fn new_f64(
124        path: impl Into<String>,
125        current: f64,
126        min: f64,
127        max: f64,
128        max_step: f64,
129    ) -> Self {
130        Self {
131            path: path.into(),
132            current: KnobValue::F64(current),
133            bounds: KnobBounds::F64Range { min, max },
134            max_step,
135        }
136    }
137
138    /// Convenience constructor for a usize knob.
139    pub fn new_usize(
140        path: impl Into<String>,
141        current: usize,
142        min: usize,
143        max: usize,
144        max_step: f64,
145    ) -> Self {
146        Self {
147            path: path.into(),
148            current: KnobValue::Usize(current),
149            bounds: KnobBounds::UsizeRange { min, max },
150            max_step,
151        }
152    }
153
154    /// Clip `proposed` to the knob's bounds AND step budget.
155    /// Returns the final value + a [`KnobClipResult`] describing
156    /// whether any clipping happened. Does NOT mutate `self`.
157    pub fn clip(&self, proposed: KnobValue) -> (KnobValue, KnobClipResult) {
158        if !self.bounds.matches_value(&proposed) {
159            return (self.current, KnobClipResult::TypeMismatch);
160        }
161
162        let cur_f = self.current.as_f64();
163        let prop_f = proposed.as_f64();
164
165        // Step-size clip.
166        let delta = prop_f - cur_f;
167        let stepped_f = if delta.abs() > self.max_step {
168            cur_f + delta.signum() * self.max_step
169        } else {
170            prop_f
171        };
172
173        // Bounds clip.
174        let (clipped_f, bound_result) = match self.bounds {
175            KnobBounds::F64Range { min, max } => {
176                if stepped_f < min {
177                    (min, KnobClipResult::ClippedLow)
178                } else if stepped_f > max {
179                    (max, KnobClipResult::ClippedHigh)
180                } else {
181                    (stepped_f, KnobClipResult::InRange)
182                }
183            }
184            KnobBounds::UsizeRange { min, max } => {
185                // Round to nearest non-negative integer first.
186                let i = stepped_f.round().max(0.0) as usize;
187                if i < min {
188                    (min as f64, KnobClipResult::ClippedLow)
189                } else if i > max {
190                    (max as f64, KnobClipResult::ClippedHigh)
191                } else {
192                    (i as f64, KnobClipResult::InRange)
193                }
194            }
195        };
196
197        let final_v = match proposed {
198            KnobValue::F64(_) => KnobValue::F64(clipped_f),
199            KnobValue::Usize(_) => KnobValue::Usize(clipped_f as usize),
200        };
201        (final_v, bound_result)
202    }
203
204    /// Apply a clipped value to the knob (mutates `self.current`).
205    /// Returns the same [`KnobClipResult`] [`Self::clip`] returns.
206    pub fn apply(&mut self, proposed: KnobValue) -> KnobClipResult {
207        let (final_v, result) = self.clip(proposed);
208        self.current = final_v;
209        result
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn f64_clip_in_range_is_noop() {
219        let mut k = CalibrationKnob::new_f64("fraud.fraud_rate", 0.02, 0.0, 0.1, 0.01);
220        let r = k.apply(KnobValue::F64(0.025));
221        assert_eq!(r, KnobClipResult::InRange);
222        assert_eq!(k.current, KnobValue::F64(0.025));
223    }
224
225    #[test]
226    fn f64_clip_low_clamps_to_min() {
227        let mut k = CalibrationKnob::new_f64("fraud.fraud_rate", 0.02, 0.01, 0.1, 1.0);
228        // Step budget is wide so the step doesn't clip first;
229        // the bounds clip dominates.
230        let r = k.apply(KnobValue::F64(0.001));
231        assert_eq!(r, KnobClipResult::ClippedLow);
232        assert_eq!(k.current, KnobValue::F64(0.01));
233    }
234
235    #[test]
236    fn f64_clip_high_clamps_to_max() {
237        let mut k = CalibrationKnob::new_f64("fraud.fraud_rate", 0.02, 0.0, 0.05, 1.0);
238        let r = k.apply(KnobValue::F64(0.1));
239        assert_eq!(r, KnobClipResult::ClippedHigh);
240        assert_eq!(k.current, KnobValue::F64(0.05));
241    }
242
243    #[test]
244    fn f64_step_size_clamps_large_jumps() {
245        // Bounds wide, step tight: a jump from 0.02 → 0.05 (Δ=0.03)
246        // exceeds max_step=0.01, so the value lands at 0.02+0.01.
247        let mut k = CalibrationKnob::new_f64("fraud.fraud_rate", 0.02, 0.0, 0.1, 0.01);
248        let r = k.apply(KnobValue::F64(0.05));
249        assert_eq!(r, KnobClipResult::InRange);
250        assert!(
251            (k.current.as_f64() - 0.03).abs() < 1e-12,
252            "value should land at 0.02 + 0.01 step = 0.03, got {}",
253            k.current
254        );
255    }
256
257    #[test]
258    fn f64_step_size_clamps_negative_jumps() {
259        let mut k = CalibrationKnob::new_f64("rate", 0.10, 0.0, 1.0, 0.01);
260        // Δ = -0.08, step budget 0.01 → final = 0.10 - 0.01 = 0.09.
261        let r = k.apply(KnobValue::F64(0.02));
262        assert_eq!(r, KnobClipResult::InRange);
263        assert!((k.current.as_f64() - 0.09).abs() < 1e-12);
264    }
265
266    #[test]
267    fn usize_clip_rounds_and_clamps() {
268        let mut k = CalibrationKnob::new_usize("pool.target_size", 12, 5, 20, 4.0);
269        // Δ = 5, exceeds step budget 4, so step lands at 16.
270        let r = k.apply(KnobValue::Usize(17));
271        assert_eq!(r, KnobClipResult::InRange);
272        assert_eq!(k.current, KnobValue::Usize(16));
273    }
274
275    #[test]
276    fn usize_clip_low_clamps_to_min() {
277        let mut k = CalibrationKnob::new_usize("pool.target_size", 12, 5, 20, 100.0);
278        let r = k.apply(KnobValue::Usize(2));
279        assert_eq!(r, KnobClipResult::ClippedLow);
280        assert_eq!(k.current, KnobValue::Usize(5));
281    }
282
283    #[test]
284    fn type_mismatch_is_noop() {
285        let mut k = CalibrationKnob::new_f64("fraud.fraud_rate", 0.02, 0.0, 0.1, 0.01);
286        let r = k.apply(KnobValue::Usize(12));
287        assert_eq!(r, KnobClipResult::TypeMismatch);
288        // Knob unchanged.
289        assert_eq!(k.current, KnobValue::F64(0.02));
290    }
291
292    #[test]
293    fn yaml_string_round_trips() {
294        assert_eq!(KnobValue::F64(0.025).to_yaml_string(), "0.025");
295        assert_eq!(KnobValue::Usize(12).to_yaml_string(), "12");
296    }
297
298    #[test]
299    fn bounds_width_is_max_minus_min() {
300        assert!((KnobBounds::F64Range { min: 0.0, max: 0.1 }.width() - 0.1).abs() < 1e-12);
301        assert_eq!(KnobBounds::UsizeRange { min: 5, max: 20 }.width(), 15.0);
302    }
303}