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