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/// Maximum number of retry attempts before a generator gives up on a rejection-sampling
24/// loop (the space is considered effectively exhausted). Shared by [`Random`] and
25/// [`Neighborhood`], both of which resample until an unvisited variation is found.
26///
27/// [`Random`]: crate::Random
28/// [`Neighborhood`]: crate::Neighborhood
29pub(crate) const MAX_RETRIES: usize = 1000;
30
31/// A strategy for generating parameter variations one at a time.
32///
33/// Each call to [`VariationGenerator::next`] must produce a variation that changes
34/// exactly one parameter from the baseline. The caller ([`ExperimentEngine`]) is
35/// responsible for tracking visited variations and passing them back via `visited`.
36///
37/// Implementations hold mutable state (position cursor, RNG seed) and must be
38/// both `Send` and `Sync` so that [`ExperimentEngine`] can be used with
39/// `tokio::spawn`. The engine loop accesses the generator exclusively via `&mut self`,
40/// so no concurrent access occurs in practice.
41///
42/// # Implementing a Custom Generator
43///
44/// ```rust
45/// use std::collections::HashSet;
46/// use zeph_experiments::{ConfigSnapshot, ParameterKind, Variation, VariationValue, VariationGenerator};
47///
48/// /// Always suggests temperature = 0.5, then exhausts.
49/// struct FixedSuggestion;
50///
51/// impl VariationGenerator for FixedSuggestion {
52///     fn next(&mut self, _baseline: &ConfigSnapshot, visited: &HashSet<Variation>) -> Option<Variation> {
53///         let v = Variation {
54///             parameter: ParameterKind::Temperature,
55///             value: VariationValue::from(0.5_f64),
56///         };
57///         if visited.contains(&v) { None } else { Some(v) }
58///     }
59///
60///     fn name(&self) -> &'static str { "fixed" }
61/// }
62///
63/// fn main() {
64///     let mut variation_gen = FixedSuggestion;
65///     let baseline = ConfigSnapshot::default();
66///     let mut visited = HashSet::new();
67///     let first = variation_gen.next(&baseline, &visited).unwrap();
68///     visited.insert(first);
69///     assert!(variation_gen.next(&baseline, &visited).is_none());
70/// }
71/// ```
72///
73/// [`ExperimentEngine`]: crate::ExperimentEngine
74pub trait VariationGenerator: Send + Sync {
75    /// Produce the next untested variation, or `None` if the space is exhausted.
76    ///
77    /// - `baseline` — the current best-known configuration snapshot (updated on acceptance).
78    /// - `visited` — all variations already tested in this session; must not be returned again.
79    fn next(
80        &mut self,
81        baseline: &ConfigSnapshot,
82        visited: &HashSet<Variation>,
83    ) -> Option<Variation>;
84
85    /// Strategy name used in log messages and experiment reports.
86    fn name(&self) -> &'static str;
87}
88
89#[cfg(test)]
90mod tests {
91    use super::super::types::{ParameterKind, VariationValue};
92    use super::*;
93    use ordered_float::OrderedFloat;
94
95    struct AlwaysOne;
96
97    impl VariationGenerator for AlwaysOne {
98        fn next(
99            &mut self,
100            _baseline: &ConfigSnapshot,
101            visited: &HashSet<Variation>,
102        ) -> Option<Variation> {
103            let v = Variation {
104                parameter: ParameterKind::Temperature,
105                value: VariationValue::Float(OrderedFloat(1.0)),
106            };
107            if visited.contains(&v) { None } else { Some(v) }
108        }
109
110        fn name(&self) -> &'static str {
111            "always_one"
112        }
113    }
114
115    #[test]
116    fn generator_returns_variation_when_not_visited() {
117        let mut generator = AlwaysOne;
118        let baseline = ConfigSnapshot::default();
119        let visited = HashSet::new();
120        let v = generator.next(&baseline, &visited);
121        assert!(v.is_some());
122        assert_eq!(v.unwrap().parameter, ParameterKind::Temperature);
123    }
124
125    #[test]
126    fn generator_returns_none_when_visited() {
127        let mut generator = AlwaysOne;
128        let baseline = ConfigSnapshot::default();
129        let mut visited = HashSet::new();
130        visited.insert(Variation {
131            parameter: ParameterKind::Temperature,
132            value: VariationValue::Float(OrderedFloat(1.0)),
133        });
134        assert!(generator.next(&baseline, &visited).is_none());
135    }
136
137    #[test]
138    fn generator_name_is_static_str() {
139        let generator = AlwaysOne;
140        assert_eq!(generator.name(), "always_one");
141    }
142
143    #[test]
144    fn generator_is_send() {
145        fn assert_send<T: Send>() {}
146        assert_send::<AlwaysOne>();
147    }
148}