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