Skip to main content

somatize_runtime/sampler/
mod.rs

1//! Hyperparameter samplers for optimization studies.
2//!
3//! - [`GridSampler`] — exhaustive cartesian product, lazy index-based
4//! - [`RandomSampler`] — uniform sampling with deterministic seeds
5//! - [`BayesianSampler`] — TPE (Tree-Parzen Estimator) with explore/exploit
6
7pub mod bayesian;
8
9pub use bayesian::BayesianSampler;
10
11use somatize_core::error::Result;
12use somatize_core::search::{Scale, SearchDimension, SearchSpace};
13use std::collections::HashMap;
14
15/// A sampler produces hyperparameter configurations from a search space.
16///
17/// The contract is ask/tell: the runner calls [`prepare`](Self::prepare)
18/// once before the loop, [`sample`](Self::sample) to ask for the next
19/// configuration, and [`record_result`](Self::record_result) to tell
20/// the sampler each completed trial's objective value — the feedback
21/// that model-based samplers (TPE, future BO backends) require.
22pub trait Sampler: Send + Sync {
23    /// Called once before the trial loop with the resolved search
24    /// space. Lets samplers precompute state — e.g. [`GridSampler`]
25    /// resolves its dimension grid here so `n_trials` is correct
26    /// before the first sample.
27    fn prepare(&mut self, _space: &SearchSpace) {}
28
29    /// Sample the next set of parameters. Returns None when exhausted.
30    fn sample(
31        &mut self,
32        space: &SearchSpace,
33        trial_index: usize,
34    ) -> Result<Option<HashMap<String, serde_json::Value>>>;
35
36    /// Total number of trials this sampler will produce (if known).
37    fn n_trials(&self) -> Option<usize>;
38
39    /// Feedback for a completed trial. `value` is normalized so that
40    /// higher is always better (the runner negates for `Minimize`).
41    /// Default: no-op (stateless samplers ignore feedback).
42    fn record_result(&mut self, _params: &HashMap<String, serde_json::Value>, _value: f64) {}
43}
44
45// ──────────────────────────────────────────────
46// Grid Sampler
47// ──────────────────────────────────────────────
48
49/// Exhaustive grid search over all combinations.
50///
51/// Uses lazy index-based generation: instead of building the full cartesian
52/// product in memory, it computes the parameter set for a given trial index
53/// on the fly. Safe for large search spaces.
54pub struct GridSampler {
55    points_per_dim: usize,
56    /// Cached per-dimension discrete values (computed once, not the full grid).
57    dim_values: Option<Vec<(String, Vec<serde_json::Value>)>>,
58    /// Total number of combinations.
59    total: Option<usize>,
60}
61
62impl GridSampler {
63    /// A grid with `points_per_dim` values per continuous dimension
64    /// (categorical dimensions contribute every choice).
65    pub fn new(points_per_dim: usize) -> Self {
66        Self {
67            points_per_dim,
68            dim_values: None,
69            total: None,
70        }
71    }
72
73    /// Compute discrete values for each dimension (once).
74    fn ensure_dims(&mut self, space: &SearchSpace) {
75        if self.dim_values.is_some() {
76            return;
77        }
78        let dims: Vec<(String, Vec<serde_json::Value>)> = space
79            .active_dimensions()
80            .iter()
81            .map(|dim| {
82                let name = dim.name().to_string();
83                let values = self.discretize(dim);
84                (name, values)
85            })
86            .collect();
87
88        let total = if dims.is_empty() {
89            1 // one combo with empty params
90        } else {
91            dims.iter().map(|(_, v)| v.len()).product()
92        };
93
94        self.dim_values = Some(dims);
95        self.total = Some(total);
96    }
97
98    /// Convert a flat trial index into a multi-dimensional index
99    /// and look up the parameter values. O(n_dims) per call.
100    fn sample_at(&self, trial_index: usize) -> Option<HashMap<String, serde_json::Value>> {
101        let dims = self.dim_values.as_ref()?;
102        let total = self.total?;
103
104        if trial_index >= total {
105            return None;
106        }
107
108        if dims.is_empty() {
109            return Some(HashMap::new());
110        }
111
112        let mut params = HashMap::new();
113        let mut remaining = trial_index;
114
115        // Decompose flat index into per-dimension indices
116        // like converting a number to mixed-radix representation
117        for (name, values) in dims.iter().rev() {
118            let dim_size = values.len();
119            let dim_idx = remaining % dim_size;
120            remaining /= dim_size;
121            params.insert(name.clone(), values[dim_idx].clone());
122        }
123
124        Some(params)
125    }
126
127    fn discretize(&self, dim: &SearchDimension) -> Vec<serde_json::Value> {
128        match dim {
129            SearchDimension::Float {
130                low, high, scale, ..
131            } => linspace(*low, *high, self.points_per_dim, *scale)
132                .into_iter()
133                .map(|v| serde_json::json!(v))
134                .collect(),
135            SearchDimension::Int {
136                low, high, scale, ..
137            } => {
138                let n = self.points_per_dim.min((*high - *low + 1) as usize);
139                linspace(*low as f64, *high as f64, n, *scale)
140                    .into_iter()
141                    .map(|v| serde_json::json!(v.round() as i64))
142                    .collect()
143            }
144            SearchDimension::Categorical { choices, .. } => choices.clone(),
145            SearchDimension::Conditional { dimension, .. } => self.discretize(dimension),
146            _ => vec![serde_json::Value::Null],
147        }
148    }
149}
150
151impl Sampler for GridSampler {
152    fn prepare(&mut self, space: &SearchSpace) {
153        self.ensure_dims(space);
154    }
155
156    fn sample(
157        &mut self,
158        space: &SearchSpace,
159        trial_index: usize,
160    ) -> Result<Option<HashMap<String, serde_json::Value>>> {
161        self.ensure_dims(space);
162        Ok(self.sample_at(trial_index))
163    }
164
165    fn n_trials(&self) -> Option<usize> {
166        self.total
167    }
168}
169
170// ──────────────────────────────────────────────
171// Random Sampler
172// ──────────────────────────────────────────────
173
174/// Random search: sample uniformly from each dimension.
175pub struct RandomSampler {
176    n_trials: usize,
177    seed: u64,
178}
179
180impl RandomSampler {
181    /// A random sampler producing `n_trials` configurations. `None` seed
182    /// defaults to 42, keeping runs reproducible.
183    pub fn new(n_trials: usize, seed: Option<u64>) -> Self {
184        Self {
185            n_trials,
186            seed: seed.unwrap_or(42),
187        }
188    }
189
190    fn sample_dim(&self, dim: &SearchDimension, rng_state: u64) -> serde_json::Value {
191        let t = pseudo_random(rng_state); // [0.0, 1.0)
192        match dim {
193            SearchDimension::Float {
194                low, high, scale, ..
195            } => {
196                let val = sample_float(*low, *high, *scale, t);
197                serde_json::json!(val)
198            }
199            SearchDimension::Int { low, high, .. } => {
200                let range = (*high - *low + 1) as f64;
201                let val = *low + (t * range).floor() as i64;
202                let val = val.min(*high);
203                serde_json::json!(val)
204            }
205            SearchDimension::Categorical { choices, .. } => {
206                let idx = (t * choices.len() as f64).floor() as usize;
207                let idx = idx.min(choices.len() - 1);
208                choices[idx].clone()
209            }
210            SearchDimension::Conditional { dimension, .. } => self.sample_dim(dimension, rng_state),
211            _ => serde_json::Value::Null,
212        }
213    }
214}
215
216impl Sampler for RandomSampler {
217    fn sample(
218        &mut self,
219        space: &SearchSpace,
220        trial_index: usize,
221    ) -> Result<Option<HashMap<String, serde_json::Value>>> {
222        if trial_index >= self.n_trials {
223            return Ok(None);
224        }
225
226        let mut params = HashMap::new();
227        for (i, dim) in space.active_dimensions().iter().enumerate() {
228            // Different rng state per dimension per trial
229            let rng_state = hash_u64(self.seed, trial_index as u64, i as u64);
230            let value = self.sample_dim(dim, rng_state);
231            params.insert(dim.name().to_string(), value);
232        }
233
234        Ok(Some(params))
235    }
236
237    fn n_trials(&self) -> Option<usize> {
238        Some(self.n_trials)
239    }
240}
241
242// ──────────────────────────────────────────────
243// Helpers
244// ──────────────────────────────────────────────
245
246/// Generate evenly spaced values in a range, respecting scale.
247fn linspace(low: f64, high: f64, n: usize, scale: Scale) -> Vec<f64> {
248    if n <= 1 {
249        return vec![(low + high) / 2.0];
250    }
251    match scale {
252        Scale::Linear => (0..n)
253            .map(|i| low + (high - low) * (i as f64 / (n - 1) as f64))
254            .collect(),
255        Scale::Log => {
256            let log_low = low.max(1e-12).ln();
257            let log_high = high.max(1e-12).ln();
258            (0..n)
259                .map(|i| (log_low + (log_high - log_low) * (i as f64 / (n - 1) as f64)).exp())
260                .collect()
261        }
262        Scale::ReverseLog => {
263            // Reverse: denser at high end
264            linspace(low, high, n, Scale::Log)
265                .into_iter()
266                .rev()
267                .collect()
268        }
269    }
270}
271
272/// Sample a float from [low, high] given t in [0, 1), respecting scale.
273pub fn sample_float(low: f64, high: f64, scale: Scale, t: f64) -> f64 {
274    match scale {
275        Scale::Linear => low + (high - low) * t,
276        Scale::Log => {
277            let log_low = low.max(1e-12).ln();
278            let log_high = high.max(1e-12).ln();
279            (log_low + (log_high - log_low) * t).exp()
280        }
281        Scale::ReverseLog => {
282            let val = sample_float(low, high, Scale::Log, 1.0 - t);
283            low + high - val
284        }
285    }
286}
287
288/// Simple deterministic pseudo-random (public for use by BayesianSampler): hash-based, returns [0.0, 1.0).
289pub fn pseudo_random(state: u64) -> f64 {
290    let h = splitmix64(state);
291    (h >> 11) as f64 / (1u64 << 53) as f64
292}
293
294/// Simple hash combiner for generating unique RNG states.
295pub fn hash_u64(seed: u64, a: u64, b: u64) -> u64 {
296    splitmix64(
297        seed.wrapping_add(a.wrapping_mul(6364136223846793005))
298            .wrapping_add(b),
299    )
300}
301
302/// SplitMix64 hash function.
303pub fn splitmix64(mut x: u64) -> u64 {
304    x = x.wrapping_add(0x9e3779b97f4a7c15);
305    x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
306    x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
307    x ^ (x >> 31)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use serde_json::json;
314
315    fn sample_space() -> SearchSpace {
316        let mut space = SearchSpace::new();
317        space.add(SearchDimension::Float {
318            name: "lr".into(),
319            low: 0.001,
320            high: 0.1,
321            scale: Scale::Log,
322            default: None,
323        });
324        space.add(SearchDimension::Categorical {
325            name: "kernel".into(),
326            choices: vec![json!("rbf"), json!("linear"), json!("poly")],
327        });
328        space
329    }
330
331    // ── Grid tests ──
332
333    #[test]
334    fn grid_sampler_generates_all_combinations() {
335        let mut sampler = GridSampler::new(3);
336        let space = sample_space();
337
338        // 3 points for lr * 3 choices for kernel = 9 combinations
339        let mut trials = Vec::new();
340        for i in 0.. {
341            match sampler.sample(&space, i).unwrap() {
342                Some(params) => trials.push(params),
343                None => break,
344            }
345        }
346
347        assert_eq!(trials.len(), 9);
348
349        // All should have both params
350        for t in &trials {
351            assert!(t.contains_key("lr"));
352            assert!(t.contains_key("kernel"));
353        }
354
355        // All kernels should appear
356        let kernels: Vec<&serde_json::Value> = trials.iter().map(|t| &t["kernel"]).collect();
357        assert!(kernels.contains(&&json!("rbf")));
358        assert!(kernels.contains(&&json!("linear")));
359        assert!(kernels.contains(&&json!("poly")));
360    }
361
362    #[test]
363    fn grid_sampler_respects_log_scale() {
364        let mut space = SearchSpace::new();
365        space.add(SearchDimension::Float {
366            name: "lr".into(),
367            low: 0.001,
368            high: 1.0,
369            scale: Scale::Log,
370            default: None,
371        });
372
373        let mut sampler = GridSampler::new(3);
374        let t0 = sampler.sample(&space, 0).unwrap().unwrap();
375        let t1 = sampler.sample(&space, 1).unwrap().unwrap();
376        let t2 = sampler.sample(&space, 2).unwrap().unwrap();
377
378        let v0 = t0["lr"].as_f64().unwrap();
379        let v1 = t1["lr"].as_f64().unwrap();
380        let v2 = t2["lr"].as_f64().unwrap();
381
382        // Log scale: gap between v0-v1 should be smaller than v1-v2
383        assert!(v0 < v1 && v1 < v2);
384        assert!((v1 - v0) < (v2 - v1));
385    }
386
387    #[test]
388    fn grid_sampler_int_dimension() {
389        let mut space = SearchSpace::new();
390        space.add(SearchDimension::Int {
391            name: "n".into(),
392            low: 1,
393            high: 5,
394            scale: Scale::Linear,
395        });
396
397        let mut sampler = GridSampler::new(5);
398        let mut values = Vec::new();
399        for i in 0.. {
400            match sampler.sample(&space, i).unwrap() {
401                Some(p) => values.push(p["n"].as_i64().unwrap()),
402                None => break,
403            }
404        }
405        assert_eq!(values, vec![1, 2, 3, 4, 5]);
406    }
407
408    #[test]
409    fn grid_prepare_resolves_total_before_first_sample() {
410        // The whole point of Sampler::prepare — without it, grid
411        // studies reported total_trials = 0 in StudyStarted.
412        let mut sampler = GridSampler::new(3);
413        assert_eq!(sampler.n_trials(), None, "unknown before prepare");
414        sampler.prepare(&sample_space());
415        assert_eq!(sampler.n_trials(), Some(9), "3 lr points × 3 kernels");
416    }
417
418    #[test]
419    fn record_result_is_a_noop_for_stateless_samplers() {
420        let space = sample_space();
421        let mut with_feedback = RandomSampler::new(5, Some(42));
422        let mut without = RandomSampler::new(5, Some(42));
423
424        for i in 0..3 {
425            let params = with_feedback.sample(&space, i).unwrap().unwrap();
426            with_feedback.record_result(&params, 0.9);
427        }
428        // Same sequence regardless of feedback.
429        for i in 3..5 {
430            assert_eq!(
431                with_feedback.sample(&space, i).unwrap(),
432                without.sample(&space, i).unwrap()
433            );
434        }
435    }
436
437    #[test]
438    fn grid_empty_space() {
439        let mut sampler = GridSampler::new(3);
440        let space = SearchSpace::new();
441        let result = sampler.sample(&space, 0).unwrap();
442        assert!(result.is_some()); // one combo with empty params
443        assert!(result.unwrap().is_empty());
444        assert!(sampler.sample(&space, 1).unwrap().is_none());
445    }
446
447    // ── Random tests ──
448
449    #[test]
450    fn random_sampler_generates_n_trials() {
451        let mut sampler = RandomSampler::new(10, Some(42));
452        let space = sample_space();
453
454        let mut trials = Vec::new();
455        for i in 0..20 {
456            match sampler.sample(&space, i).unwrap() {
457                Some(params) => trials.push(params),
458                None => break,
459            }
460        }
461
462        assert_eq!(trials.len(), 10);
463    }
464
465    #[test]
466    fn random_sampler_respects_bounds() {
467        let mut space = SearchSpace::new();
468        space.add(SearchDimension::Float {
469            name: "x".into(),
470            low: 0.0,
471            high: 1.0,
472            scale: Scale::Linear,
473            default: None,
474        });
475        space.add(SearchDimension::Int {
476            name: "n".into(),
477            low: 5,
478            high: 10,
479            scale: Scale::Linear,
480        });
481
482        let mut sampler = RandomSampler::new(100, Some(123));
483
484        for i in 0..100 {
485            let params = sampler.sample(&space, i).unwrap().unwrap();
486            let x = params["x"].as_f64().unwrap();
487            let n = params["n"].as_i64().unwrap();
488            assert!((0.0..=1.0).contains(&x), "x={x} out of bounds");
489            assert!((5..=10).contains(&n), "n={n} out of bounds");
490        }
491    }
492
493    #[test]
494    fn random_sampler_deterministic_with_seed() {
495        let space = sample_space();
496
497        let mut s1 = RandomSampler::new(5, Some(42));
498        let mut s2 = RandomSampler::new(5, Some(42));
499
500        for i in 0..5 {
501            let p1 = s1.sample(&space, i).unwrap().unwrap();
502            let p2 = s2.sample(&space, i).unwrap().unwrap();
503            assert_eq!(p1, p2);
504        }
505    }
506
507    #[test]
508    fn random_sampler_different_seeds_differ() {
509        let space = sample_space();
510
511        let mut s1 = RandomSampler::new(5, Some(42));
512        let mut s2 = RandomSampler::new(5, Some(99));
513
514        let p1 = s1.sample(&space, 0).unwrap().unwrap();
515        let p2 = s2.sample(&space, 0).unwrap().unwrap();
516        // Very unlikely to be equal with different seeds
517        assert_ne!(p1["lr"], p2["lr"]);
518    }
519
520    // ── Linspace tests ──
521
522    #[test]
523    fn linspace_linear() {
524        let vals = linspace(0.0, 10.0, 5, Scale::Linear);
525        assert_eq!(vals, vec![0.0, 2.5, 5.0, 7.5, 10.0]);
526    }
527
528    #[test]
529    fn linspace_single_point() {
530        let vals = linspace(0.0, 10.0, 1, Scale::Linear);
531        assert_eq!(vals, vec![5.0]);
532    }
533
534    #[test]
535    fn linspace_log_denser_at_low_end() {
536        let vals = linspace(0.001, 1.0, 5, Scale::Log);
537        // Log scale: gaps should increase
538        let gaps: Vec<f64> = vals.windows(2).map(|w| w[1] - w[0]).collect();
539        for i in 1..gaps.len() {
540            assert!(gaps[i] > gaps[i - 1], "gap[{i}] should be > gap[{}]", i - 1);
541        }
542    }
543}