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}