datasynth_eval/calibration/
knob.rs1use std::fmt;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
23pub enum KnobValue {
24 F64(f64),
26 Usize(usize),
28}
29
30impl KnobValue {
31 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 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#[derive(Debug, Clone, Copy, PartialEq)]
57pub enum KnobBounds {
58 F64Range { min: f64, max: f64 },
60 UsizeRange { min: usize, max: usize },
62}
63
64impl KnobBounds {
65 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 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#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum KnobClipResult {
90 InRange,
92 ClippedLow,
94 ClippedHigh,
96 TypeMismatch,
100}
101
102#[derive(Debug, Clone)]
104pub struct CalibrationKnob {
105 pub path: String,
110 pub current: KnobValue,
112 pub bounds: KnobBounds,
114 pub max_step: f64,
119}
120
121impl CalibrationKnob {
122 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 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 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 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 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 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 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 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 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 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 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 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}