1use std::collections::HashMap;
12
13use super::config_space::{Configuration, ConfigurationSpace};
14use super::experiment::ExperimentResult;
15
16pub struct SobolSequence {
21 dim: usize,
22 index: u64,
23 direction: Vec<u64>,
25}
26
27impl SobolSequence {
28 pub fn new(dim: usize) -> Self {
29 let direction = sobol_direction_numbers(dim);
30 Self {
31 dim,
32 index: 0,
33 direction,
34 }
35 }
36
37 pub fn next(&mut self) -> Vec<f64> {
39 self.index += 1;
40 let mut x = vec![0u64; self.dim];
41 let mut v = self.index;
42 let mut bit = 0usize;
43 while v > 0 {
44 if v & 1 != 0 {
45 for d in 0..self.dim {
46 x[d] ^= self.direction[d * 64 + bit];
47 }
48 }
49 v >>= 1;
50 bit += 1;
51 }
52 x.iter().map(|&v| (v as f64) / (u64::MAX as f64)).collect()
53 }
54
55 pub fn reset(&mut self) {
57 self.index = 0;
58 }
59}
60
61fn sobol_direction_numbers(dim: usize) -> Vec<u64> {
64 let mut dir = vec![0u64; dim * 64];
69
70 if dim > 0 {
72 for j in 0..64 {
73 dir[j] = 1u64 << (63 - j);
74 }
75 }
76
77 let seeds: &[(u32, u32, &[u32])] = &[
80 (1, 1, &[1]), (3, 2, &[1, 3]), (1, 3, &[1, 3, 5]), (5, 3, &[1, 1, 7]), (9, 4, &[1, 3, 7, 13]), (9, 4, &[1, 1, 5, 11]), (21, 5, &[1, 1, 7, 13, 23]), (29, 5, &[1, 3, 3, 9, 17]), (5, 5, &[1, 3, 5, 11, 25]), (7, 5, &[1, 1, 1, 3, 15]), (15, 5, &[1, 1, 5, 11, 25]), (17, 5, &[1, 3, 1, 9, 21]), (65, 6, &[1, 1, 5, 11, 25, 53]), (21, 6, &[1, 3, 1, 7, 19, 37]), (15, 6, &[1, 1, 3, 11, 25, 45]), (735, 6, &[1, 1, 1, 3, 13, 29]), (165, 6, &[1, 1, 5, 7, 21, 43]), (15, 6, &[1, 3, 5, 11, 25, 45]), (511, 7, &[1, 1, 1, 3, 13, 29, 61]), ];
100
101 for d in 1..dim.min(seeds.len() + 1) {
102 let (poly, m, init) = seeds[d - 1];
103 let m = m as usize;
104 let base = d * 64;
105
106 for j in 0..m {
108 let v = (init[j] as u64) << (63 - j);
109 dir[base + j] = v;
110 }
111
112 for j in m..64 {
115 let mut v = dir[base + j - m];
116 v >>= m;
117 let mut p = poly;
119 let mut k = 1u32;
120 while p > 0 {
121 if p & 1 != 0 {
122 let shifted = dir[base + j - k as usize];
123 v ^= shifted >> (m - k as usize);
124 }
125 p >>= 1;
126 k += 1;
127 }
128 dir[base + j] = v;
129 }
130 }
131
132 dir
133}
134
135pub struct KnnSurrogate {
142 points: Vec<Vec<f64>>,
144 values: Vec<f64>,
146 k: usize,
148}
149
150impl KnnSurrogate {
151 pub fn new(k: usize) -> Self {
152 Self {
153 points: Vec::new(),
154 values: Vec::new(),
155 k: k.max(1),
156 }
157 }
158
159 pub fn add(&mut self, x: Vec<f64>, y: f64) {
161 self.points.push(x);
162 self.values.push(y);
163 }
164
165 pub fn len(&self) -> usize {
167 self.points.len()
168 }
169
170 pub fn predict(&self, x: &[f64]) -> (f64, f64) {
173 if self.points.is_empty() {
174 return (0.0, 1.0);
175 }
176
177 let mut dists: Vec<(usize, f64)> = self
179 .points
180 .iter()
181 .enumerate()
182 .map(|(i, p)| {
183 let d = euclidean(x, p);
184 (i, if d < 1e-12 { 1e-12 } else { d })
185 })
186 .collect();
187
188 dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
190
191 let k = self.k.min(dists.len());
192 let mut weighted_sum = 0.0;
193 let mut weight_total = 0.0;
194 let mut vals = Vec::with_capacity(k);
195 for &(i, d) in dists.iter().take(k) {
196 let w = 1.0 / d; weighted_sum += w * self.values[i];
198 weight_total += w;
199 vals.push(self.values[i]);
200 }
201
202 let mean = if weight_total > 0.0 {
203 weighted_sum / weight_total
204 } else {
205 0.0
206 };
207
208 let std = if vals.len() > 1 {
210 let avg = vals.iter().sum::<f64>() / vals.len() as f64;
211 let var = vals.iter().map(|v| (v - avg).powi(2)).sum::<f64>() / vals.len() as f64;
212 var.sqrt()
213 } else {
214 0.5
215 };
216
217 (mean, std)
218 }
219}
220
221fn euclidean(a: &[f64], b: &[f64]) -> f64 {
222 a.iter()
223 .zip(b.iter())
224 .map(|(x, y)| (x - y).powi(2))
225 .sum::<f64>()
226 .sqrt()
227}
228
229pub fn expected_improvement(mu: f64, sigma: f64, f_best: f64) -> f64 {
236 if sigma < 1e-12 {
237 return (mu - f_best).max(0.0);
239 }
240 let z = (mu - f_best) / sigma;
241 let phi_z = normal_pdf(z);
243 let cdf_z = normal_cdf(z);
244 (mu - f_best) * cdf_z + sigma * phi_z
245}
246
247fn normal_pdf(x: f64) -> f64 {
249 let c = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
250 c * (-0.5 * x * x).exp()
251}
252
253fn normal_cdf(x: f64) -> f64 {
255 0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
257}
258
259fn erf(x: f64) -> f64 {
261 let sign = if x < 0.0 { -1.0 } else { 1.0 };
262 let x = x.abs();
263 let a1 = 0.254829592;
264 let a2 = -0.284496736;
265 let a3 = 1.421413741;
266 let a4 = -1.453152027;
267 let a5 = 1.061405429;
268 let p = 0.3275911;
269 let t = 1.0 / (1.0 + p * x);
270 let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
271 sign * y
272}
273
274pub struct SearchEngine {
278 space: ConfigurationSpace,
279 sobol: SobolSequence,
280 surrogate: KnnSurrogate,
281 initial_design: usize,
283 observations: Vec<(Vec<f64>, f64, u64)>,
285 best_objective: f64,
287 budget: usize,
289 tried: HashMap<u64, ()>,
291}
292
293impl SearchEngine {
294 pub fn new(space: ConfigurationSpace, budget: usize) -> Self {
295 let dim = space.dims();
296 let initial = (budget / 5).clamp(5, 20).min(budget);
297 Self {
298 space,
299 sobol: SobolSequence::new(dim),
300 surrogate: KnnSurrogate::new(5),
301 initial_design: initial,
302 observations: Vec::new(),
303 best_objective: f64::NEG_INFINITY,
304 budget,
305 tried: HashMap::new(),
306 }
307 }
308
309 pub fn trials(&self) -> usize {
311 self.observations.len()
312 }
313
314 pub fn best_value(&self) -> f64 {
316 self.best_objective
317 }
318
319 pub fn ask(&mut self) -> Option<Configuration> {
322 if self.observations.len() >= self.budget {
323 return None;
324 }
325
326 if self.observations.len() < self.initial_design {
328 let t = self.sobol.next();
329 let cfg = self.space.build_from_normalized(&t);
330 let h = cfg.hash();
332 if self.tried.contains_key(&h) {
333 return self.ask();
334 }
335 return Some(cfg);
336 }
337
338 let n_candidates = 100;
341 let mut best_ei = f64::NEG_INFINITY;
342 let mut best_cfg = None;
343
344 for _ in 0..n_candidates {
345 let t = self.sobol.next();
346 let cfg = self.space.build_from_normalized(&t);
347 let h = cfg.hash();
348 if self.tried.contains_key(&h) {
349 continue;
350 }
351 let (mu, sigma) = self.surrogate.predict(&t);
352 let ei = expected_improvement(mu, sigma, self.best_objective);
353 if ei > best_ei {
354 best_ei = ei;
355 best_cfg = Some(cfg);
356 }
357 }
358
359 if best_cfg.is_none() {
361 for _ in 0..10 {
362 let t = self.sobol.next();
363 let cfg = self.space.build_from_normalized(&t);
364 let h = cfg.hash();
365 if !self.tried.contains_key(&h) {
366 return Some(cfg);
367 }
368 }
369 return None;
370 }
371
372 best_cfg
373 }
374
375 pub fn tell(&mut self, cfg: &Configuration, result: &ExperimentResult) {
377 let h = cfg.hash();
378 if self.tried.contains_key(&h) {
379 return;
380 }
381 self.tried.insert(h, ());
382
383 let objective = compute_objective(result);
386
387 let normalized = self.space.normalize_config(cfg);
388 self.surrogate.add(normalized.clone(), objective);
389 self.observations.push((normalized, objective, h));
390
391 if objective > self.best_objective {
392 self.best_objective = objective;
393 }
394 }
395
396 pub fn surrogate_add(&mut self, normalized: Vec<f64>, objective: f64, config_hash: u64) {
399 if self.tried.contains_key(&config_hash) {
400 return;
401 }
402 self.tried.insert(config_hash, ());
403 self.surrogate.add(normalized.clone(), objective);
404 self.observations.push((normalized, objective, config_hash));
405 if objective > self.best_objective {
406 self.best_objective = objective;
407 }
408 }
409
410 pub fn best_config(&self) -> Option<u64> {
412 self.observations
413 .iter()
414 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
415 .map(|(_, _, h)| *h)
416 }
417}
418
419fn compute_objective(r: &ExperimentResult) -> f64 {
422 if r.error.is_some() {
423 return f64::NEG_INFINITY;
424 }
425 let tok_s = r.bench.as_ref().map(|b| b.decode_tok_s).unwrap_or(0.0);
426 let quality = r.quality.composite();
427 tok_s * quality
428}
429
430pub struct TrackAndStopBandit {
436 arms: Vec<Configuration>,
438 rewards: Vec<Vec<f64>>,
440 counts: Vec<usize>,
442 stopped: bool,
444 winner: Option<usize>,
446 initial_samples: usize,
448 glr_threshold: f64,
450}
451
452impl TrackAndStopBandit {
453 pub fn new(arms: Vec<Configuration>) -> Self {
454 let n = arms.len();
455 Self {
456 arms,
457 rewards: vec![Vec::new(); n],
458 counts: vec![0; n],
459 stopped: false,
460 winner: None,
461 initial_samples: 3,
462 glr_threshold: 10.0, }
464 }
465
466 pub fn n_arms(&self) -> usize {
468 self.arms.len()
469 }
470
471 pub fn is_stopped(&self) -> bool {
473 self.stopped
474 }
475
476 pub fn winner(&self) -> Option<usize> {
478 self.winner
479 }
480
481 pub fn arm_config(&self, i: usize) -> Option<&Configuration> {
483 self.arms.get(i)
484 }
485
486 pub fn ask(&self) -> Option<usize> {
488 if self.stopped {
489 return None;
490 }
491 let n = self.arms.len();
492 if n == 0 {
493 return None;
494 }
495
496 for i in 0..n {
498 if self.counts[i] < self.initial_samples {
499 return Some(i);
500 }
501 }
502
503 let means: Vec<f64> = (0..n)
507 .map(|i| {
508 let r = &self.rewards[i];
509 if r.is_empty() {
510 0.0
511 } else {
512 r.iter().sum::<f64>() / r.len() as f64
513 }
514 })
515 .collect();
516
517 let best = means
519 .iter()
520 .enumerate()
521 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
522 .map(|(i, _)| i)
523 .unwrap_or(0);
524
525 let total_samples: usize = self.counts.iter().sum();
527 if total_samples > n * self.initial_samples * 2 {
528 let glr = self.compute_glr(&means, best);
529 if glr > self.glr_threshold {
530 return None; }
532 }
533
534 let round = total_samples % n;
537 if round == best % n {
538 Some(best)
539 } else {
540 Some(round)
541 }
542 }
543
544 pub fn tell(&mut self, arm: usize, reward: f64) {
546 if arm >= self.arms.len() {
547 return;
548 }
549 self.rewards[arm].push(reward);
550 self.counts[arm] += 1;
551
552 if self.counts.iter().all(|&c| c >= self.initial_samples) {
554 let means: Vec<f64> = (0..self.arms.len())
555 .map(|i| {
556 let r = &self.rewards[i];
557 if r.is_empty() {
558 0.0
559 } else {
560 r.iter().sum::<f64>() / r.len() as f64
561 }
562 })
563 .collect();
564 let best = means
565 .iter()
566 .enumerate()
567 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
568 .map(|(i, _)| i)
569 .unwrap_or(0);
570 let glr = self.compute_glr(&means, best);
571 if glr > self.glr_threshold {
572 self.stopped = true;
573 self.winner = Some(best);
574 }
575 }
576 }
577
578 fn compute_glr(&self, means: &[f64], best: usize) -> f64 {
581 let n = means.len();
582 if n <= 1 {
583 return f64::INFINITY;
584 }
585 let best_mean = means[best];
586 let mut min_gap = f64::INFINITY;
587 for i in 0..n {
588 if i == best {
589 continue;
590 }
591 let gap = best_mean - means[i];
592 if gap < min_gap {
593 min_gap = gap;
594 }
595 }
596 if min_gap <= 0.0 {
597 return 0.0; }
599 let total_var: f64 = (0..n)
602 .map(|i| {
603 let r = &self.rewards[i];
604 if r.len() < 2 {
605 0.01
606 } else {
607 let m = means[i];
608 let v = r.iter().map(|x| (x - m).powi(2)).sum::<f64>() / r.len() as f64;
609 v
610 }
611 })
612 .sum::<f64>()
613 / n as f64;
614 let sigma2 = total_var.max(0.01);
615
616 let mut glr = 0.0;
617 for i in 0..n {
618 if i == best {
619 continue;
620 }
621 let gap = best_mean - means[i];
622 glr += self.counts[i] as f64 * gap * gap / (2.0 * sigma2);
623 }
624 glr
625 }
626
627 pub fn arm_means(&self) -> Vec<f64> {
629 (0..self.arms.len())
630 .map(|i| {
631 let r = &self.rewards[i];
632 if r.is_empty() {
633 0.0
634 } else {
635 r.iter().sum::<f64>() / r.len() as f64
636 }
637 })
638 .collect()
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::inference::lab::config_space::ParameterDef;
646
647 #[test]
648 fn sobol_generates_uniform() {
649 let mut sobol = SobolSequence::new(3);
650 let mut points = Vec::new();
651 for _ in 0..100 {
652 points.push(sobol.next());
653 }
654 for p in &points {
656 for &v in p {
657 assert!(v >= 0.0 && v <= 1.0);
658 }
659 }
660 let mean: f64 = points.iter().map(|p| p[0]).sum::<f64>() / points.len() as f64;
662 assert!((mean - 0.5).abs() < 0.1);
663 }
664
665 #[test]
666 fn knn_surrogate_predicts() {
667 let mut model = KnnSurrogate::new(3);
668 model.add(vec![0.0, 0.0], 10.0);
669 model.add(vec![1.0, 1.0], 20.0);
670 model.add(vec![0.5, 0.5], 15.0);
671
672 let (mean, _) = model.predict(&[0.1, 0.1]);
673 assert!(mean < 15.0);
675
676 let (mean, _) = model.predict(&[0.9, 0.9]);
677 assert!(mean > 15.0);
679 }
680
681 #[test]
682 fn expected_improvement_zero_sigma() {
683 assert_eq!(expected_improvement(10.0, 0.0, 5.0), 5.0);
685 assert_eq!(expected_improvement(3.0, 0.0, 5.0), 0.0);
686 }
687
688 #[test]
689 fn expected_improvement_positive_with_uncertainty() {
690 let ei = expected_improvement(4.0, 2.0, 5.0);
692 assert!(ei > 0.0);
693 }
694
695 #[test]
696 fn search_engine_ask_tell() {
697 let space = ConfigurationSpace::new("test")
698 .with("x", ParameterDef::Int { lo: 0, hi: 10 })
699 .with("y", ParameterDef::Bool);
700 let mut engine = SearchEngine::new(space, 20);
701
702 let cfg = engine.ask();
704 assert!(cfg.is_some());
705
706 for _ in 0..5 {
708 if let Some(c) = engine.ask() {
709 engine.tried.insert(c.hash(), ());
712 engine.observations.push((vec![0.5, 0.5], 10.0, c.hash()));
713 }
714 }
715 assert!(engine.trials() <= 20);
716 }
717
718 #[test]
719 fn track_and_stop_identifies_best() {
720 let space = ConfigurationSpace::new("test").with("x", ParameterDef::Int { lo: 0, hi: 1 });
721 let arms = vec![
722 space.build_from_normalized(&[0.0]),
723 space.build_from_normalized(&[1.0]),
724 ];
725 let mut bandit = TrackAndStopBandit::new(arms);
726
727 use rand::RngExt;
729 let mut rng = rand::rng();
730 for _ in 0..100 {
731 if let Some(arm) = bandit.ask() {
732 let reward = match arm {
733 0 => 10.0 + rng.random_range(-1.0..1.0),
734 _ => 5.0 + rng.random_range(-1.0..1.0),
735 };
736 bandit.tell(arm, reward);
737 }
738 if bandit.is_stopped() {
739 break;
740 }
741 }
742 if let Some(w) = bandit.winner() {
744 assert_eq!(w, 0);
745 }
746 }
747}