1pub 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
15pub trait Sampler: Send + Sync {
23 fn prepare(&mut self, _space: &SearchSpace) {}
28
29 fn sample(
31 &mut self,
32 space: &SearchSpace,
33 trial_index: usize,
34 ) -> Result<Option<HashMap<String, serde_json::Value>>>;
35
36 fn n_trials(&self) -> Option<usize>;
38
39 fn record_result(&mut self, _params: &HashMap<String, serde_json::Value>, _value: f64) {}
43}
44
45pub struct GridSampler {
55 points_per_dim: usize,
56 dim_values: Option<Vec<(String, Vec<serde_json::Value>)>>,
58 total: Option<usize>,
60}
61
62impl GridSampler {
63 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 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 } 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 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 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
170pub struct RandomSampler {
176 n_trials: usize,
177 seed: u64,
178}
179
180impl RandomSampler {
181 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); 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 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
242fn 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 linspace(low, high, n, Scale::Log)
265 .into_iter()
266 .rev()
267 .collect()
268 }
269 }
270}
271
272pub 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
288pub fn pseudo_random(state: u64) -> f64 {
290 let h = splitmix64(state);
291 (h >> 11) as f64 / (1u64 << 53) as f64
292}
293
294pub 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
302pub 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 #[test]
334 fn grid_sampler_generates_all_combinations() {
335 let mut sampler = GridSampler::new(3);
336 let space = sample_space();
337
338 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 for t in &trials {
351 assert!(t.contains_key("lr"));
352 assert!(t.contains_key("kernel"));
353 }
354
355 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 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 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(¶ms, 0.9);
427 }
428 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()); assert!(result.unwrap().is_empty());
444 assert!(sampler.sample(&space, 1).unwrap().is_none());
445 }
446
447 #[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 assert_ne!(p1["lr"], p2["lr"]);
518 }
519
520 #[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 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}