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}