Skip to main content

zeph_experiments/
random.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Uniform random sampling strategy for parameter variation.
5//!
6//! [`Random`] selects a parameter uniformly at random on each call, then samples
7//! its value uniformly from `[min, max]`, quantizing to the nearest step. It
8//! provides broad coverage without systematic ordering, which can be useful when
9//! the search space is large and a full [`GridStep`] sweep is too expensive.
10//!
11//! [`GridStep`]: crate::GridStep
12
13use std::collections::HashSet;
14use std::sync::Mutex;
15
16use ordered_float::OrderedFloat;
17use rand::RngExt as _;
18use rand::SeedableRng as _;
19use rand::rngs::SmallRng;
20
21use super::generator::VariationGenerator;
22use super::search_space::SearchSpace;
23use super::snapshot::ConfigSnapshot;
24use super::types::{Variation, VariationValue};
25
26/// Maximum number of retry attempts before giving up (space is considered exhausted).
27const MAX_RETRIES: usize = 1000;
28
29/// Uniform random sampling within parameter bounds.
30///
31/// At each call, a parameter is chosen uniformly at random, then a value is
32/// sampled uniformly from its `[min, max]` range and quantized to the nearest
33/// step (if configured). The sample is rejected if it was already visited.
34/// Returns `None` after 1000 consecutive rejections (the space is considered
35/// effectively exhausted for this seed).
36///
37/// The internal RNG is wrapped in a [`Mutex`] so that `Random` implements [`Sync`],
38/// which is required by [`VariationGenerator`] to allow [`ExperimentEngine`] to be
39/// used in an async context. The experiment loop is sequential, so the mutex is
40/// never contended.
41///
42/// # Examples
43///
44/// ```rust
45/// use std::collections::HashSet;
46/// use zeph_experiments::{ConfigSnapshot, Random, SearchSpace, VariationGenerator};
47///
48/// let mut generator = Random::new(SearchSpace::default(), 42);
49/// let baseline = ConfigSnapshot::default();
50/// let visited = HashSet::new();
51///
52/// // Two generators with the same seed produce the same first variation.
53/// let mut gen2 = Random::new(SearchSpace::default(), 42);
54/// let v1 = generator.next(&baseline, &visited);
55/// let v2 = gen2.next(&baseline, &visited);
56/// assert_eq!(v1, v2);
57/// ```
58///
59/// [`ExperimentEngine`]: crate::ExperimentEngine
60pub struct Random {
61    search_space: SearchSpace,
62    rng: Mutex<SmallRng>,
63}
64
65impl Random {
66    /// Create a new [`Random`] generator with a deterministic seed.
67    ///
68    /// Generators with the same `seed` and `search_space` will produce identical
69    /// variation sequences, making experiments reproducible.
70    ///
71    /// # Examples
72    ///
73    /// ```rust
74    /// use zeph_experiments::{Random, SearchSpace, VariationGenerator};
75    ///
76    /// let generator = Random::new(SearchSpace::default(), 1234);
77    /// assert_eq!(generator.name(), "random");
78    /// ```
79    #[must_use]
80    pub fn new(search_space: SearchSpace, seed: u64) -> Self {
81        Self {
82            search_space,
83            rng: Mutex::new(SmallRng::seed_from_u64(seed)),
84        }
85    }
86}
87
88impl VariationGenerator for Random {
89    fn next(
90        &mut self,
91        _baseline: &ConfigSnapshot,
92        visited: &HashSet<Variation>,
93    ) -> Option<Variation> {
94        if self.search_space.parameters.is_empty() {
95            return None;
96        }
97        let mut rng = self.rng.lock().expect("rng mutex poisoned");
98        for _ in 0..MAX_RETRIES {
99            let idx = rng.random_range(0..self.search_space.parameters.len());
100            let range = &self.search_space.parameters[idx];
101            let raw: f64 = rng.random_range(range.min..=range.max);
102            let value = range.quantize(raw);
103            let variation = Variation {
104                parameter: range.kind,
105                value: VariationValue::Float(OrderedFloat(value)),
106            };
107            if !visited.contains(&variation) {
108                return Some(variation);
109            }
110        }
111        None
112    }
113
114    fn name(&self) -> &'static str {
115        "random"
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    #![allow(clippy::manual_range_contains)]
122
123    use std::collections::HashSet;
124
125    use super::super::search_space::ParameterRange;
126    use super::super::types::ParameterKind;
127    use super::*;
128
129    #[test]
130    fn random_produces_values_in_range() {
131        let space = SearchSpace {
132            parameters: vec![ParameterRange {
133                kind: ParameterKind::Temperature,
134                min: 0.0,
135                max: 1.0,
136                step: Some(0.1),
137                default: 0.5,
138            }],
139        };
140        let mut generator = Random::new(space, 42);
141        let baseline = ConfigSnapshot::default();
142        let visited = HashSet::new();
143        for _ in 0..20 {
144            if let Some(v) = generator.next(&baseline, &visited) {
145                let val = v.value.as_f64();
146                assert!((0.0..=1.0).contains(&val), "out of range: {val}");
147            }
148        }
149    }
150
151    #[test]
152    fn random_skips_visited() {
153        let space = SearchSpace {
154            parameters: vec![ParameterRange {
155                kind: ParameterKind::Temperature,
156                min: 0.5,
157                max: 0.5,
158                step: Some(0.1),
159                default: 0.5,
160            }],
161        };
162        let mut generator = Random::new(space, 0);
163        let baseline = ConfigSnapshot::default();
164        let mut visited = HashSet::new();
165        visited.insert(Variation {
166            parameter: ParameterKind::Temperature,
167            value: VariationValue::Float(OrderedFloat(0.5)),
168        });
169        // Only one point in space (min==max==0.5), so after visiting it, must return None.
170        let result = generator.next(&baseline, &visited);
171        assert!(
172            result.is_none(),
173            "expected None when only option is already visited"
174        );
175    }
176
177    #[test]
178    fn random_empty_space_returns_none() {
179        let mut generator = Random::new(SearchSpace { parameters: vec![] }, 0);
180        let baseline = ConfigSnapshot::default();
181        let visited = HashSet::new();
182        assert!(generator.next(&baseline, &visited).is_none());
183    }
184
185    #[test]
186    fn random_is_deterministic_with_same_seed() {
187        let space = SearchSpace::default();
188        let baseline = ConfigSnapshot::default();
189        let visited = HashSet::new();
190        let mut gen1 = Random::new(space.clone(), 123);
191        let mut gen2 = Random::new(space, 123);
192        let v1 = gen1.next(&baseline, &visited);
193        let v2 = gen2.next(&baseline, &visited);
194        assert_eq!(v1, v2, "same seed must produce same first variation");
195    }
196
197    #[test]
198    fn random_quantizes_sampled_values() {
199        let space = SearchSpace {
200            parameters: vec![ParameterRange {
201                kind: ParameterKind::TopP,
202                min: 0.1,
203                max: 1.0,
204                step: Some(0.05),
205                default: 0.9,
206            }],
207        };
208        let mut generator = Random::new(space, 7);
209        let baseline = ConfigSnapshot::default();
210        let visited = HashSet::new();
211        for _ in 0..30 {
212            if let Some(v) = generator.next(&baseline, &visited) {
213                let val = v.value.as_f64();
214                // Quantized values must be on the 0.05-step grid anchored at min=0.1:
215                // i.e. (val - 0.1) / 0.05 must be an integer.
216                let steps = (val - 0.1) / 0.05;
217                assert!(
218                    (steps - steps.round()).abs() < 1e-10,
219                    "value {val} is not on the 0.05-step grid anchored at 0.1"
220                );
221            }
222        }
223    }
224
225    #[test]
226    fn random_name() {
227        let generator = Random::new(SearchSpace::default(), 0);
228        assert_eq!(generator.name(), "random");
229    }
230
231    #[test]
232    fn random_is_sync() {
233        fn assert_sync<T: Sync>() {}
234        assert_sync::<Random>();
235    }
236}