Skip to main content

zeph_experiments/
search_space.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Search space definition for parameter variation experiments.
5
6use serde::{Deserialize, Serialize};
7
8use super::types::ParameterKind;
9
10/// A continuous or discrete range for a single tunable parameter.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParameterRange {
13    pub kind: ParameterKind,
14    pub min: f64,
15    pub max: f64,
16    /// Discrete step size. `None` means continuous (deduplication is effectively disabled).
17    pub step: Option<f64>,
18    pub default: f64,
19}
20
21impl ParameterRange {
22    /// Number of discrete steps in this range, or `None` if step is not set or is non-positive.
23    #[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    /// Clamp `value` to `[min, max]`.
34    #[must_use]
35    pub fn clamp(&self, value: f64) -> f64 {
36        value.clamp(self.min, self.max)
37    }
38
39    /// Return `true` if `value` is within `[min, max]`.
40    #[must_use]
41    pub fn contains(&self, value: f64) -> bool {
42        (self.min..=self.max).contains(&value)
43    }
44
45    /// Quantize `value` to the nearest grid step anchored at `min`.
46    ///
47    /// Formula: `min + ((value - min) / step).round() * step`, then clamped to `[min, max]`.
48    /// Anchoring at `min` ensures grid points align to `{min, min+step, min+2*step, ...}`.
49    #[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    /// Validate that this range is internally consistent.
61    ///
62    /// Returns `false` if `min > max`, any value is non-finite, or `step` is non-positive.
63    #[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/// The set of parameter ranges that define the experiment search space.
74#[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    /// Find the range for a given `ParameterKind`, if present.
126    #[must_use]
127    pub fn range_for(&self, kind: ParameterKind) -> Option<&ParameterRange> {
128        self.parameters.iter().find(|r| r.kind == kind)
129    }
130
131    /// Validate all parameter ranges in this search space.
132    ///
133    /// Returns `false` if any range has `min > max`, non-finite values, or non-positive step.
134    #[must_use]
135    pub fn is_valid(&self) -> bool {
136        self.parameters.iter().all(ParameterRange::is_valid)
137    }
138
139    /// Total number of grid points across all parameters that have a step.
140    ///
141    /// This is the number of distinct variations a `GridStep` strategy will generate.
142    #[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)); // 0.0, 0.5, 1.0
165    }
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        // 0.73 should snap to 0.7
253        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        // Large value quantizes to nearest step, then clamped
279        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        // 0.1 * 7 accumulates to 0.7000000000000001 via addition, quantize must fix this
293        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        // Temperature: 11, TopP: 19, TopK: 20, Freq: 21, Pres: 21 = 92
312        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        // TopK: min=1.0, step=5.0 => grid should be {1, 6, 11, 16, ...}
340        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        // 6.0 should stay at 6.0, not be shifted to 5.0
348        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        // 3.0 is between 1.0 and 6.0; rounds to nearest => 1.0
354        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        // step <= 0 guard: quantize falls back to returning the value as-is
361        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}