zeph_experiments/generator.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`VariationGenerator`] trait for parameter variation strategies.
5//!
6//! Implement this trait to plug a custom search strategy into [`ExperimentEngine`].
7//! Three built-in implementations are provided:
8//!
9//! - [`GridStep`] — systematic sweep through all discrete grid points.
10//! - [`Random`] — uniform random sampling within parameter bounds.
11//! - [`Neighborhood`] — perturbation around the current best configuration.
12//!
13//! [`ExperimentEngine`]: crate::ExperimentEngine
14//! [`GridStep`]: crate::GridStep
15//! [`Random`]: crate::Random
16//! [`Neighborhood`]: crate::Neighborhood
17
18use std::collections::HashSet;
19
20use super::snapshot::ConfigSnapshot;
21use super::types::Variation;
22
23/// A strategy for generating parameter variations one at a time.
24///
25/// Each call to [`VariationGenerator::next`] must produce a variation that changes
26/// exactly one parameter from the baseline. The caller ([`ExperimentEngine`]) is
27/// responsible for tracking visited variations and passing them back via `visited`.
28///
29/// Implementations hold mutable state (position cursor, RNG seed) and must be
30/// both `Send` and `Sync` so that [`ExperimentEngine`] can be used with
31/// `tokio::spawn`. The engine loop accesses the generator exclusively via `&mut self`,
32/// so no concurrent access occurs in practice.
33///
34/// # Implementing a Custom Generator
35///
36/// ```rust
37/// use std::collections::HashSet;
38/// use zeph_experiments::{ConfigSnapshot, ParameterKind, Variation, VariationValue, VariationGenerator};
39///
40/// /// Always suggests temperature = 0.5, then exhausts.
41/// struct FixedSuggestion;
42///
43/// impl VariationGenerator for FixedSuggestion {
44/// fn next(&mut self, _baseline: &ConfigSnapshot, visited: &HashSet<Variation>) -> Option<Variation> {
45/// let v = Variation {
46/// parameter: ParameterKind::Temperature,
47/// value: VariationValue::from(0.5_f64),
48/// };
49/// if visited.contains(&v) { None } else { Some(v) }
50/// }
51///
52/// fn name(&self) -> &'static str { "fixed" }
53/// }
54///
55/// fn main() {
56/// let mut variation_gen = FixedSuggestion;
57/// let baseline = ConfigSnapshot::default();
58/// let mut visited = HashSet::new();
59/// let first = variation_gen.next(&baseline, &visited).unwrap();
60/// visited.insert(first);
61/// assert!(variation_gen.next(&baseline, &visited).is_none());
62/// }
63/// ```
64///
65/// [`ExperimentEngine`]: crate::ExperimentEngine
66pub trait VariationGenerator: Send + Sync {
67 /// Produce the next untested variation, or `None` if the space is exhausted.
68 ///
69 /// - `baseline` — the current best-known configuration snapshot (updated on acceptance).
70 /// - `visited` — all variations already tested in this session; must not be returned again.
71 fn next(
72 &mut self,
73 baseline: &ConfigSnapshot,
74 visited: &HashSet<Variation>,
75 ) -> Option<Variation>;
76
77 /// Strategy name used in log messages and experiment reports.
78 fn name(&self) -> &'static str;
79}
80
81#[cfg(test)]
82mod tests {
83 use super::super::types::{ParameterKind, VariationValue};
84 use super::*;
85 use ordered_float::OrderedFloat;
86
87 struct AlwaysOne;
88
89 impl VariationGenerator for AlwaysOne {
90 fn next(
91 &mut self,
92 _baseline: &ConfigSnapshot,
93 visited: &HashSet<Variation>,
94 ) -> Option<Variation> {
95 let v = Variation {
96 parameter: ParameterKind::Temperature,
97 value: VariationValue::Float(OrderedFloat(1.0)),
98 };
99 if visited.contains(&v) { None } else { Some(v) }
100 }
101
102 fn name(&self) -> &'static str {
103 "always_one"
104 }
105 }
106
107 #[test]
108 fn generator_returns_variation_when_not_visited() {
109 let mut generator = AlwaysOne;
110 let baseline = ConfigSnapshot::default();
111 let visited = HashSet::new();
112 let v = generator.next(&baseline, &visited);
113 assert!(v.is_some());
114 assert_eq!(v.unwrap().parameter, ParameterKind::Temperature);
115 }
116
117 #[test]
118 fn generator_returns_none_when_visited() {
119 let mut generator = AlwaysOne;
120 let baseline = ConfigSnapshot::default();
121 let mut visited = HashSet::new();
122 visited.insert(Variation {
123 parameter: ParameterKind::Temperature,
124 value: VariationValue::Float(OrderedFloat(1.0)),
125 });
126 assert!(generator.next(&baseline, &visited).is_none());
127 }
128
129 #[test]
130 fn generator_name_is_static_str() {
131 let generator = AlwaysOne;
132 assert_eq!(generator.name(), "always_one");
133 }
134
135 #[test]
136 fn generator_is_send() {
137 fn assert_send<T: Send>() {}
138 assert_send::<AlwaysOne>();
139 }
140}