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///
12/// When `step` is `Some`, the parameter is treated as discrete: values are
13/// quantized to the nearest grid point anchored at `min`. When `step` is `None`
14/// the parameter is treated as continuous and generators fall back to an internal
15/// default step count (typically 20 divisions).
16///
17/// # Examples
18///
19/// ```rust
20/// use zeph_experiments::{ParameterRange, ParameterKind};
21///
22/// let range = ParameterRange {
23///     kind: ParameterKind::Temperature,
24///     min: 0.0,
25///     max: 1.0,
26///     step: Some(0.1),
27///     default: 0.7,
28/// };
29///
30/// assert!(range.is_valid());
31/// assert_eq!(range.step_count(), Some(11));
32/// assert!((range.clamp(2.0) - 1.0).abs() < f64::EPSILON);
33/// assert!((range.quantize(0.73) - 0.7).abs() < 1e-10);
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ParameterRange {
37    /// The parameter this range applies to.
38    pub kind: ParameterKind,
39    /// Minimum value (inclusive).
40    pub min: f64,
41    /// Maximum value (inclusive).
42    pub max: f64,
43    /// Discrete step size. `None` means continuous (generators use a default step count).
44    pub step: Option<f64>,
45    /// Default (baseline) value, typically read from the current agent config.
46    pub default: f64,
47}
48
49impl ParameterRange {
50    /// Number of discrete grid points in this range, or `None` if `step` is not set or ≤ 0.
51    ///
52    /// The count is `floor((max - min) / step) + 1`.
53    ///
54    /// # Examples
55    ///
56    /// ```rust
57    /// use zeph_experiments::{ParameterRange, ParameterKind};
58    ///
59    /// let r = ParameterRange { kind: ParameterKind::Temperature, min: 0.0, max: 1.0, step: Some(0.5), default: 0.5 };
60    /// assert_eq!(r.step_count(), Some(3)); // 0.0, 0.5, 1.0
61    ///
62    /// let r_continuous = ParameterRange { step: None, ..r };
63    /// assert_eq!(r_continuous.step_count(), None);
64    /// ```
65    #[must_use]
66    pub fn step_count(&self) -> Option<usize> {
67        let step = self.step?;
68        if step <= 0.0 {
69            return None;
70        }
71        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
72        Some(((self.max - self.min) / step).floor() as usize + 1)
73    }
74
75    /// Clamp `value` to `[min, max]`.
76    ///
77    /// # Examples
78    ///
79    /// ```rust
80    /// use zeph_experiments::{ParameterRange, ParameterKind};
81    ///
82    /// let r = ParameterRange { kind: ParameterKind::TopP, min: 0.1, max: 1.0, step: Some(0.1), default: 0.9 };
83    /// assert!((r.clamp(2.0) - 1.0).abs() < f64::EPSILON);
84    /// assert!((r.clamp(-1.0) - 0.1).abs() < f64::EPSILON);
85    /// ```
86    #[must_use]
87    pub fn clamp(&self, value: f64) -> f64 {
88        value.clamp(self.min, self.max)
89    }
90
91    /// Return `true` if `value` lies within `[min, max]` (inclusive).
92    ///
93    /// # Examples
94    ///
95    /// ```rust
96    /// use zeph_experiments::{ParameterRange, ParameterKind};
97    ///
98    /// let r = ParameterRange { kind: ParameterKind::Temperature, min: 0.0, max: 1.0, step: Some(0.1), default: 0.7 };
99    /// assert!(r.contains(0.5));
100    /// assert!(!r.contains(1.1));
101    /// ```
102    #[must_use]
103    pub fn contains(&self, value: f64) -> bool {
104        (self.min..=self.max).contains(&value)
105    }
106
107    /// Quantize `value` to the nearest grid step anchored at `min`.
108    ///
109    /// Formula: `min + ((value - min) / step).round() * step`, then clamped to `[min, max]`.
110    /// Anchoring at `min` ensures grid points align to `{min, min+step, min+2*step, ...}`.
111    #[must_use]
112    pub fn quantize(&self, value: f64) -> f64 {
113        if let Some(step) = self.step
114            && step > 0.0
115        {
116            let quantized = self.min + ((value - self.min) / step).round() * step;
117            return self.clamp((quantized * 100.0).round() / 100.0);
118        }
119        value
120    }
121
122    /// Return `true` if this range is internally consistent.
123    ///
124    /// Returns `false` if `min > max`, any bound or `default` is non-finite,
125    /// or `step` is present but non-positive or non-finite.
126    ///
127    /// # Examples
128    ///
129    /// ```rust
130    /// use zeph_experiments::{ParameterRange, ParameterKind};
131    ///
132    /// let valid = ParameterRange { kind: ParameterKind::Temperature, min: 0.0, max: 1.0, step: Some(0.1), default: 0.7 };
133    /// assert!(valid.is_valid());
134    ///
135    /// let inverted = ParameterRange { min: 1.0, max: 0.0, ..valid };
136    /// assert!(!inverted.is_valid());
137    /// ```
138    #[must_use]
139    pub fn is_valid(&self) -> bool {
140        self.min.is_finite()
141            && self.max.is_finite()
142            && self.default.is_finite()
143            && self.min <= self.max
144            && self.step.is_none_or(|s| s.is_finite() && s > 0.0)
145    }
146}
147
148/// The set of parameter ranges that define the experiment search space.
149///
150/// The default search space covers five parameters: `temperature`, `top_p`, `top_k`,
151/// `frequency_penalty`, and `presence_penalty`. Custom spaces can be constructed
152/// by providing any subset of [`ParameterRange`] values.
153///
154/// When deserialized from config with `[serde(default)]`, missing fields are filled
155/// from [`Default::default`].
156///
157/// # Examples
158///
159/// ```rust
160/// use zeph_experiments::{SearchSpace, ParameterKind};
161///
162/// let space = SearchSpace::default();
163/// assert!(space.is_valid());
164/// assert!(space.grid_size() > 0);
165/// assert!(space.range_for(ParameterKind::Temperature).is_some());
166/// ```
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default)]
169pub struct SearchSpace {
170    /// The parameter ranges in this search space.
171    pub parameters: Vec<ParameterRange>,
172}
173
174impl Default for SearchSpace {
175    fn default() -> Self {
176        Self {
177            parameters: vec![
178                ParameterRange {
179                    kind: ParameterKind::Temperature,
180                    min: 0.0,
181                    max: 1.0,
182                    step: Some(0.1),
183                    default: 0.7,
184                },
185                ParameterRange {
186                    kind: ParameterKind::TopP,
187                    min: 0.1,
188                    max: 1.0,
189                    step: Some(0.05),
190                    default: 0.9,
191                },
192                ParameterRange {
193                    kind: ParameterKind::TopK,
194                    min: 1.0,
195                    max: 100.0,
196                    step: Some(5.0),
197                    default: 40.0,
198                },
199                ParameterRange {
200                    kind: ParameterKind::FrequencyPenalty,
201                    min: -2.0,
202                    max: 2.0,
203                    step: Some(0.2),
204                    default: 0.0,
205                },
206                ParameterRange {
207                    kind: ParameterKind::PresencePenalty,
208                    min: -2.0,
209                    max: 2.0,
210                    step: Some(0.2),
211                    default: 0.0,
212                },
213            ],
214        }
215    }
216}
217
218impl SearchSpace {
219    /// Find the range for a given [`ParameterKind`], if present.
220    ///
221    /// Returns `None` if the search space does not include the requested kind.
222    ///
223    /// # Examples
224    ///
225    /// ```rust
226    /// use zeph_experiments::{SearchSpace, ParameterKind};
227    ///
228    /// let space = SearchSpace::default();
229    /// let temp = space.range_for(ParameterKind::Temperature).unwrap();
230    /// assert!((temp.default - 0.7).abs() < f64::EPSILON);
231    ///
232    /// // RetrievalTopK is not in the default space
233    /// assert!(space.range_for(ParameterKind::RetrievalTopK).is_none());
234    /// ```
235    #[must_use]
236    pub fn range_for(&self, kind: ParameterKind) -> Option<&ParameterRange> {
237        self.parameters.iter().find(|r| r.kind == kind)
238    }
239
240    /// Return `true` if all parameter ranges in this space are internally consistent.
241    ///
242    /// # Examples
243    ///
244    /// ```rust
245    /// use zeph_experiments::SearchSpace;
246    ///
247    /// assert!(SearchSpace::default().is_valid());
248    /// assert!(SearchSpace { parameters: vec![] }.is_valid()); // empty is valid
249    /// ```
250    #[must_use]
251    pub fn is_valid(&self) -> bool {
252        self.parameters.iter().all(ParameterRange::is_valid)
253    }
254
255    /// Total number of discrete grid points across all parameters that have a step.
256    ///
257    /// This equals the number of distinct variations a [`GridStep`] generator will
258    /// produce before returning `None`. Parameters without a `step` are not counted.
259    ///
260    /// # Examples
261    ///
262    /// ```rust
263    /// use zeph_experiments::SearchSpace;
264    ///
265    /// let size = SearchSpace::default().grid_size();
266    /// assert!(size > 0);
267    ///
268    /// assert_eq!(SearchSpace { parameters: vec![] }.grid_size(), 0);
269    /// ```
270    ///
271    /// [`GridStep`]: crate::GridStep
272    #[must_use]
273    pub fn grid_size(&self) -> usize {
274        self.parameters
275            .iter()
276            .filter_map(ParameterRange::step_count)
277            .sum()
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn step_count_with_step() {
287        let r = ParameterRange {
288            kind: ParameterKind::Temperature,
289            min: 0.0,
290            max: 1.0,
291            step: Some(0.5),
292            default: 0.5,
293        };
294        assert_eq!(r.step_count(), Some(3)); // 0.0, 0.5, 1.0
295    }
296
297    #[test]
298    fn step_count_no_step() {
299        let r = ParameterRange {
300            kind: ParameterKind::Temperature,
301            min: 0.0,
302            max: 1.0,
303            step: None,
304            default: 0.5,
305        };
306        assert_eq!(r.step_count(), None);
307    }
308
309    #[test]
310    fn step_count_zero_step() {
311        let r = ParameterRange {
312            kind: ParameterKind::Temperature,
313            min: 0.0,
314            max: 1.0,
315            step: Some(0.0),
316            default: 0.5,
317        };
318        assert_eq!(r.step_count(), None);
319    }
320
321    #[test]
322    fn clamp_below_min() {
323        let r = ParameterRange {
324            kind: ParameterKind::TopP,
325            min: 0.1,
326            max: 1.0,
327            step: Some(0.1),
328            default: 0.9,
329        };
330        assert!((r.clamp(-1.0) - 0.1).abs() < f64::EPSILON);
331    }
332
333    #[test]
334    fn clamp_above_max() {
335        let r = ParameterRange {
336            kind: ParameterKind::TopP,
337            min: 0.1,
338            max: 1.0,
339            step: Some(0.1),
340            default: 0.9,
341        };
342        assert!((r.clamp(2.0) - 1.0).abs() < f64::EPSILON);
343    }
344
345    #[test]
346    fn clamp_within_range() {
347        let r = ParameterRange {
348            kind: ParameterKind::Temperature,
349            min: 0.0,
350            max: 2.0,
351            step: Some(0.1),
352            default: 0.7,
353        };
354        assert!((r.clamp(1.0) - 1.0).abs() < f64::EPSILON);
355    }
356
357    #[test]
358    fn contains_within_range() {
359        let r = ParameterRange {
360            kind: ParameterKind::Temperature,
361            min: 0.0,
362            max: 2.0,
363            step: Some(0.1),
364            default: 0.7,
365        };
366        assert!(r.contains(1.0));
367        assert!(r.contains(0.0));
368        assert!(r.contains(2.0));
369        assert!(!r.contains(-0.1));
370        assert!(!r.contains(2.1));
371    }
372
373    #[test]
374    fn quantize_snaps_to_nearest_step() {
375        let r = ParameterRange {
376            kind: ParameterKind::Temperature,
377            min: 0.0,
378            max: 2.0,
379            step: Some(0.1),
380            default: 0.7,
381        };
382        // 0.73 should snap to 0.7
383        let q = r.quantize(0.73);
384        assert!((q - 0.7).abs() < 1e-10, "expected 0.7, got {q}");
385    }
386
387    #[test]
388    fn quantize_no_step_returns_value_unchanged() {
389        let r = ParameterRange {
390            kind: ParameterKind::Temperature,
391            min: 0.0,
392            max: 2.0,
393            step: None,
394            default: 0.7,
395        };
396        assert!((r.quantize(1.234) - 1.234).abs() < f64::EPSILON);
397    }
398
399    #[test]
400    fn quantize_clamps_result() {
401        let r = ParameterRange {
402            kind: ParameterKind::Temperature,
403            min: 0.0,
404            max: 1.0,
405            step: Some(0.1),
406            default: 0.5,
407        };
408        // Large value quantizes to nearest step, then clamped
409        let q = r.quantize(100.0);
410        assert!(q <= 1.0, "quantize must clamp to max");
411    }
412
413    #[test]
414    fn quantize_avoids_fp_accumulation() {
415        let r = ParameterRange {
416            kind: ParameterKind::Temperature,
417            min: 0.0,
418            max: 2.0,
419            step: Some(0.1),
420            default: 0.7,
421        };
422        // 0.1 * 7 accumulates to 0.7000000000000001 via addition, quantize must fix this
423        let accumulated = 0.1_f64 * 7.0;
424        let q = r.quantize(accumulated);
425        assert!(
426            (q - 0.7).abs() < 1e-10,
427            "expected 0.7, got {q} (accumulated={accumulated})"
428        );
429    }
430
431    #[test]
432    fn default_search_space_has_five_parameters() {
433        let space = SearchSpace::default();
434        assert_eq!(space.parameters.len(), 5);
435    }
436
437    #[test]
438    fn default_grid_size_is_reasonable() {
439        let space = SearchSpace::default();
440        let size = space.grid_size();
441        // Temperature: 11, TopP: 19, TopK: 20, Freq: 21, Pres: 21 = 92
442        assert!(size > 0);
443        assert!(size < 200);
444    }
445
446    #[test]
447    fn range_for_finds_temperature() {
448        let space = SearchSpace::default();
449        let range = space.range_for(ParameterKind::Temperature);
450        assert!(range.is_some());
451        assert!((range.unwrap().default - 0.7).abs() < f64::EPSILON);
452    }
453
454    #[test]
455    fn range_for_missing_returns_none() {
456        let space = SearchSpace::default();
457        let range = space.range_for(ParameterKind::RetrievalTopK);
458        assert!(range.is_none());
459    }
460
461    #[test]
462    fn grid_size_empty_space_is_zero() {
463        let space = SearchSpace { parameters: vec![] };
464        assert_eq!(space.grid_size(), 0);
465    }
466
467    #[test]
468    fn quantize_with_nonzero_min_anchors_to_min() {
469        // TopK: min=1.0, step=5.0 => grid should be {1, 6, 11, 16, ...}
470        let r = ParameterRange {
471            kind: ParameterKind::TopK,
472            min: 1.0,
473            max: 100.0,
474            step: Some(5.0),
475            default: 40.0,
476        };
477        // 6.0 should stay at 6.0, not be shifted to 5.0
478        let q = r.quantize(6.0);
479        assert!(
480            (q - 6.0).abs() < 1e-10,
481            "expected 6.0 (min-anchored grid), got {q}"
482        );
483        // 3.0 is between 1.0 and 6.0; rounds to nearest => 1.0
484        let q2 = r.quantize(3.0);
485        assert!((q2 - 1.0).abs() < 1e-10, "expected 1.0, got {q2}");
486    }
487
488    #[test]
489    fn quantize_negative_step_returns_unchanged() {
490        // step <= 0 guard: quantize falls back to returning the value as-is
491        let r = ParameterRange {
492            kind: ParameterKind::Temperature,
493            min: 0.0,
494            max: 2.0,
495            step: Some(-0.1),
496            default: 0.7,
497        };
498        assert!((r.quantize(0.75) - 0.75).abs() < f64::EPSILON);
499    }
500
501    #[test]
502    fn parameter_range_is_valid_for_default() {
503        for r in &SearchSpace::default().parameters {
504            assert!(r.is_valid(), "default range {:?} is invalid", r.kind);
505        }
506    }
507
508    #[test]
509    fn parameter_range_invalid_when_min_gt_max() {
510        let r = ParameterRange {
511            kind: ParameterKind::Temperature,
512            min: 2.0,
513            max: 0.0,
514            step: Some(0.1),
515            default: 1.0,
516        };
517        assert!(!r.is_valid());
518    }
519
520    #[test]
521    fn parameter_range_invalid_when_nonfinite() {
522        let r = ParameterRange {
523            kind: ParameterKind::Temperature,
524            min: f64::NAN,
525            max: 2.0,
526            step: Some(0.1),
527            default: 0.7,
528        };
529        assert!(!r.is_valid());
530    }
531
532    #[test]
533    fn search_space_is_valid_for_default() {
534        assert!(SearchSpace::default().is_valid());
535    }
536
537    #[test]
538    fn search_space_invalid_when_range_inverted() {
539        let space = SearchSpace {
540            parameters: vec![ParameterRange {
541                kind: ParameterKind::Temperature,
542                min: 2.0,
543                max: 0.0,
544                step: Some(0.1),
545                default: 1.0,
546            }],
547        };
548        assert!(!space.is_valid());
549    }
550}