1use serde::{Deserialize, Serialize};
7
8use super::types::ParameterKind;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParameterRange {
13 pub kind: ParameterKind,
14 pub min: f64,
15 pub max: f64,
16 pub step: Option<f64>,
18 pub default: f64,
19}
20
21impl ParameterRange {
22 #[must_use]
24 pub fn step_count(&self) -> Option<usize> {
25 let step = self.step?;
26 if step <= 0.0 {
27 return None;
28 }
29 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
30 Some(((self.max - self.min) / step).floor() as usize + 1)
31 }
32
33 #[must_use]
35 pub fn clamp(&self, value: f64) -> f64 {
36 value.clamp(self.min, self.max)
37 }
38
39 #[must_use]
41 pub fn contains(&self, value: f64) -> bool {
42 (self.min..=self.max).contains(&value)
43 }
44
45 #[must_use]
50 pub fn quantize(&self, value: f64) -> f64 {
51 if let Some(step) = self.step
52 && step > 0.0
53 {
54 let quantized = self.min + ((value - self.min) / step).round() * step;
55 return self.clamp((quantized * 100.0).round() / 100.0);
56 }
57 value
58 }
59
60 #[must_use]
64 pub fn is_valid(&self) -> bool {
65 self.min.is_finite()
66 && self.max.is_finite()
67 && self.default.is_finite()
68 && self.min <= self.max
69 && self.step.is_none_or(|s| s.is_finite() && s > 0.0)
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct SearchSpace {
77 pub parameters: Vec<ParameterRange>,
78}
79
80impl Default for SearchSpace {
81 fn default() -> Self {
82 Self {
83 parameters: vec![
84 ParameterRange {
85 kind: ParameterKind::Temperature,
86 min: 0.0,
87 max: 1.0,
88 step: Some(0.1),
89 default: 0.7,
90 },
91 ParameterRange {
92 kind: ParameterKind::TopP,
93 min: 0.1,
94 max: 1.0,
95 step: Some(0.05),
96 default: 0.9,
97 },
98 ParameterRange {
99 kind: ParameterKind::TopK,
100 min: 1.0,
101 max: 100.0,
102 step: Some(5.0),
103 default: 40.0,
104 },
105 ParameterRange {
106 kind: ParameterKind::FrequencyPenalty,
107 min: -2.0,
108 max: 2.0,
109 step: Some(0.2),
110 default: 0.0,
111 },
112 ParameterRange {
113 kind: ParameterKind::PresencePenalty,
114 min: -2.0,
115 max: 2.0,
116 step: Some(0.2),
117 default: 0.0,
118 },
119 ],
120 }
121 }
122}
123
124impl SearchSpace {
125 #[must_use]
127 pub fn range_for(&self, kind: ParameterKind) -> Option<&ParameterRange> {
128 self.parameters.iter().find(|r| r.kind == kind)
129 }
130
131 #[must_use]
135 pub fn is_valid(&self) -> bool {
136 self.parameters.iter().all(ParameterRange::is_valid)
137 }
138
139 #[must_use]
143 pub fn grid_size(&self) -> usize {
144 self.parameters
145 .iter()
146 .filter_map(ParameterRange::step_count)
147 .sum()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn step_count_with_step() {
157 let r = ParameterRange {
158 kind: ParameterKind::Temperature,
159 min: 0.0,
160 max: 1.0,
161 step: Some(0.5),
162 default: 0.5,
163 };
164 assert_eq!(r.step_count(), Some(3)); }
166
167 #[test]
168 fn step_count_no_step() {
169 let r = ParameterRange {
170 kind: ParameterKind::Temperature,
171 min: 0.0,
172 max: 1.0,
173 step: None,
174 default: 0.5,
175 };
176 assert_eq!(r.step_count(), None);
177 }
178
179 #[test]
180 fn step_count_zero_step() {
181 let r = ParameterRange {
182 kind: ParameterKind::Temperature,
183 min: 0.0,
184 max: 1.0,
185 step: Some(0.0),
186 default: 0.5,
187 };
188 assert_eq!(r.step_count(), None);
189 }
190
191 #[test]
192 fn clamp_below_min() {
193 let r = ParameterRange {
194 kind: ParameterKind::TopP,
195 min: 0.1,
196 max: 1.0,
197 step: Some(0.1),
198 default: 0.9,
199 };
200 assert!((r.clamp(-1.0) - 0.1).abs() < f64::EPSILON);
201 }
202
203 #[test]
204 fn clamp_above_max() {
205 let r = ParameterRange {
206 kind: ParameterKind::TopP,
207 min: 0.1,
208 max: 1.0,
209 step: Some(0.1),
210 default: 0.9,
211 };
212 assert!((r.clamp(2.0) - 1.0).abs() < f64::EPSILON);
213 }
214
215 #[test]
216 fn clamp_within_range() {
217 let r = ParameterRange {
218 kind: ParameterKind::Temperature,
219 min: 0.0,
220 max: 2.0,
221 step: Some(0.1),
222 default: 0.7,
223 };
224 assert!((r.clamp(1.0) - 1.0).abs() < f64::EPSILON);
225 }
226
227 #[test]
228 fn contains_within_range() {
229 let r = ParameterRange {
230 kind: ParameterKind::Temperature,
231 min: 0.0,
232 max: 2.0,
233 step: Some(0.1),
234 default: 0.7,
235 };
236 assert!(r.contains(1.0));
237 assert!(r.contains(0.0));
238 assert!(r.contains(2.0));
239 assert!(!r.contains(-0.1));
240 assert!(!r.contains(2.1));
241 }
242
243 #[test]
244 fn quantize_snaps_to_nearest_step() {
245 let r = ParameterRange {
246 kind: ParameterKind::Temperature,
247 min: 0.0,
248 max: 2.0,
249 step: Some(0.1),
250 default: 0.7,
251 };
252 let q = r.quantize(0.73);
254 assert!((q - 0.7).abs() < 1e-10, "expected 0.7, got {q}");
255 }
256
257 #[test]
258 fn quantize_no_step_returns_value_unchanged() {
259 let r = ParameterRange {
260 kind: ParameterKind::Temperature,
261 min: 0.0,
262 max: 2.0,
263 step: None,
264 default: 0.7,
265 };
266 assert!((r.quantize(1.234) - 1.234).abs() < f64::EPSILON);
267 }
268
269 #[test]
270 fn quantize_clamps_result() {
271 let r = ParameterRange {
272 kind: ParameterKind::Temperature,
273 min: 0.0,
274 max: 1.0,
275 step: Some(0.1),
276 default: 0.5,
277 };
278 let q = r.quantize(100.0);
280 assert!(q <= 1.0, "quantize must clamp to max");
281 }
282
283 #[test]
284 fn quantize_avoids_fp_accumulation() {
285 let r = ParameterRange {
286 kind: ParameterKind::Temperature,
287 min: 0.0,
288 max: 2.0,
289 step: Some(0.1),
290 default: 0.7,
291 };
292 let accumulated = 0.1_f64 * 7.0;
294 let q = r.quantize(accumulated);
295 assert!(
296 (q - 0.7).abs() < 1e-10,
297 "expected 0.7, got {q} (accumulated={accumulated})"
298 );
299 }
300
301 #[test]
302 fn default_search_space_has_five_parameters() {
303 let space = SearchSpace::default();
304 assert_eq!(space.parameters.len(), 5);
305 }
306
307 #[test]
308 fn default_grid_size_is_reasonable() {
309 let space = SearchSpace::default();
310 let size = space.grid_size();
311 assert!(size > 0);
313 assert!(size < 200);
314 }
315
316 #[test]
317 fn range_for_finds_temperature() {
318 let space = SearchSpace::default();
319 let range = space.range_for(ParameterKind::Temperature);
320 assert!(range.is_some());
321 assert!((range.unwrap().default - 0.7).abs() < f64::EPSILON);
322 }
323
324 #[test]
325 fn range_for_missing_returns_none() {
326 let space = SearchSpace::default();
327 let range = space.range_for(ParameterKind::RetrievalTopK);
328 assert!(range.is_none());
329 }
330
331 #[test]
332 fn grid_size_empty_space_is_zero() {
333 let space = SearchSpace { parameters: vec![] };
334 assert_eq!(space.grid_size(), 0);
335 }
336
337 #[test]
338 fn quantize_with_nonzero_min_anchors_to_min() {
339 let r = ParameterRange {
341 kind: ParameterKind::TopK,
342 min: 1.0,
343 max: 100.0,
344 step: Some(5.0),
345 default: 40.0,
346 };
347 let q = r.quantize(6.0);
349 assert!(
350 (q - 6.0).abs() < 1e-10,
351 "expected 6.0 (min-anchored grid), got {q}"
352 );
353 let q2 = r.quantize(3.0);
355 assert!((q2 - 1.0).abs() < 1e-10, "expected 1.0, got {q2}");
356 }
357
358 #[test]
359 fn quantize_negative_step_returns_unchanged() {
360 let r = ParameterRange {
362 kind: ParameterKind::Temperature,
363 min: 0.0,
364 max: 2.0,
365 step: Some(-0.1),
366 default: 0.7,
367 };
368 assert!((r.quantize(0.75) - 0.75).abs() < f64::EPSILON);
369 }
370
371 #[test]
372 fn parameter_range_is_valid_for_default() {
373 for r in &SearchSpace::default().parameters {
374 assert!(r.is_valid(), "default range {:?} is invalid", r.kind);
375 }
376 }
377
378 #[test]
379 fn parameter_range_invalid_when_min_gt_max() {
380 let r = ParameterRange {
381 kind: ParameterKind::Temperature,
382 min: 2.0,
383 max: 0.0,
384 step: Some(0.1),
385 default: 1.0,
386 };
387 assert!(!r.is_valid());
388 }
389
390 #[test]
391 fn parameter_range_invalid_when_nonfinite() {
392 let r = ParameterRange {
393 kind: ParameterKind::Temperature,
394 min: f64::NAN,
395 max: 2.0,
396 step: Some(0.1),
397 default: 0.7,
398 };
399 assert!(!r.is_valid());
400 }
401
402 #[test]
403 fn search_space_is_valid_for_default() {
404 assert!(SearchSpace::default().is_valid());
405 }
406
407 #[test]
408 fn search_space_invalid_when_range_inverted() {
409 let space = SearchSpace {
410 parameters: vec![ParameterRange {
411 kind: ParameterKind::Temperature,
412 min: 2.0,
413 max: 0.0,
414 step: Some(0.1),
415 default: 1.0,
416 }],
417 };
418 assert!(!space.is_valid());
419 }
420}