Skip to main content

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}