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}