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}