Skip to main content

qualia_core_db/inference/lab/
search.rs

1//! Search and optimization engine: Sobol quasi-random initial design,
2//! a k-NN surrogate with Expected Improvement acquisition (Bayesian-style),
3//! and a Track-and-Stop multi-armed bandit for A/B testing.
4//!
5//! The ask-and-tell API:
6//! 1. Create a `SearchEngine` with a `ConfigurationSpace` and budget.
7//! 2. Call `ask()` to get the next configuration to try.
8//! 3. Run the experiment and call `tell(result)` with the outcome.
9//! 4. Repeat until budget exhausted. Call `best()` for the Pareto-optimal result.
10
11use std::collections::HashMap;
12
13use super::config_space::{Configuration, ConfigurationSpace};
14use super::experiment::ExperimentResult;
15
16// ── Sobol quasi-random sequence ───────────────────────────────────────────────
17
18/// A Sobol quasi-random sequence generator for uniform coverage of the [0,1]^d
19/// unit hypercube. Uses the Joe & Kuo direction numbers (compact implementation).
20pub struct SobolSequence {
21    dim: usize,
22    index: u64,
23    /// Direction numbers (bit-packed). For dim ≤ 20 this is sufficient.
24    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    /// Generate the next point in [0,1]^d.
38    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    /// Reset the sequence to the beginning.
56    pub fn reset(&mut self) {
57        self.index = 0;
58    }
59}
60
61/// Precomputed Sobol direction numbers for up to 20 dimensions.
62/// These are the first 20 Joe & Kuo primitive polynomials + direction numbers.
63fn sobol_direction_numbers(dim: usize) -> Vec<u64> {
64    // Each dimension d has direction numbers v[d][j] for j = 0..63.
65    // We store them as a flat array: direction[d * 64 + j].
66    // For compactness, we use the standard initialization for the first 20 dims.
67    // Source: Joe & Kuo (2008), "Constructing Sobol sequences with better two-dimensional projections"
68    let mut dir = vec![0u64; dim * 64];
69
70    // Initialize dimension 0: v[0][j] = 1 (the identity sequence).
71    if dim > 0 {
72        for j in 0..64 {
73            dir[j] = 1u64 << (63 - j);
74        }
75    }
76
77    // For dimensions 1..dim-1, use primitive polynomials and initial direction numbers.
78    // (p, m, initial v values) for each dimension.
79    let seeds: &[(u32, u32, &[u32])] = &[
80        (1, 1, &[1]),                        // d=1
81        (3, 2, &[1, 3]),                     // d=2
82        (1, 3, &[1, 3, 5]),                  // d=3
83        (5, 3, &[1, 1, 7]),                  // d=4
84        (9, 4, &[1, 3, 7, 13]),              // d=5
85        (9, 4, &[1, 1, 5, 11]),              // d=6
86        (21, 5, &[1, 1, 7, 13, 23]),         // d=7
87        (29, 5, &[1, 3, 3, 9, 17]),          // d=8
88        (5, 5, &[1, 3, 5, 11, 25]),          // d=9
89        (7, 5, &[1, 1, 1, 3, 15]),           // d=10
90        (15, 5, &[1, 1, 5, 11, 25]),         // d=11
91        (17, 5, &[1, 3, 1, 9, 21]),          // d=12
92        (65, 6, &[1, 1, 5, 11, 25, 53]),     // d=13
93        (21, 6, &[1, 3, 1, 7, 19, 37]),      // d=14
94        (15, 6, &[1, 1, 3, 11, 25, 45]),     // d=15
95        (735, 6, &[1, 1, 1, 3, 13, 29]),     // d=16
96        (165, 6, &[1, 1, 5, 7, 21, 43]),     // d=17
97        (15, 6, &[1, 3, 5, 11, 25, 45]),     // d=18
98        (511, 7, &[1, 1, 1, 3, 13, 29, 61]), // d=19
99    ];
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        // Set initial direction numbers.
107        for j in 0..m {
108            let v = (init[j] as u64) << (63 - j);
109            dir[base + j] = v;
110        }
111
112        // Extend using the recurrence: v[j] = v[j-m] XOR (v[j-m] * 2^m)
113        // with the primitive polynomial bits.
114        for j in m..64 {
115            let mut v = dir[base + j - m];
116            v >>= m;
117            // Apply polynomial: for each bit set in poly, XOR with shifted v.
118            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
135// ── k-NN surrogate model ──────────────────────────────────────────────────────
136
137/// A simple k-nearest-neighbor surrogate model for the objective function.
138/// Predicts the objective at a new point as the distance-weighted average of
139/// the k nearest observed points. This is a lightweight alternative to a full
140/// Random Forest (SMAC3) or Gaussian Process, suitable for small budgets.
141pub struct KnnSurrogate {
142    /// Observed normalized inputs.
143    points: Vec<Vec<f64>>,
144    /// Observed objective values (higher is better).
145    values: Vec<f64>,
146    /// Number of neighbors to consider.
147    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    /// Add an observation.
160    pub fn add(&mut self, x: Vec<f64>, y: f64) {
161        self.points.push(x);
162        self.values.push(y);
163    }
164
165    /// Number of observations.
166    pub fn len(&self) -> usize {
167        self.points.len()
168    }
169
170    /// Predict the objective at a new point.
171    /// Returns (mean, std) — the std is used for exploration in EI.
172    pub fn predict(&self, x: &[f64]) -> (f64, f64) {
173        if self.points.is_empty() {
174            return (0.0, 1.0);
175        }
176
177        // Compute distances to all observed points.
178        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        // Sort by distance ascending.
189        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; // inverse distance weighting
197            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        // Standard deviation of the k nearest values (exploration signal).
209        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
229// ── Expected Improvement acquisition ──────────────────────────────────────────
230
231/// Expected Improvement (EI) acquisition function.
232/// EI(x) = (mu - f_best) * Phi(Z) + sigma * phi(Z)
233/// where Z = (mu - f_best) / sigma.
234/// Higher EI = more promising to try.
235pub fn expected_improvement(mu: f64, sigma: f64, f_best: f64) -> f64 {
236    if sigma < 1e-12 {
237        // No uncertainty: EI = max(0, mu - f_best).
238        return (mu - f_best).max(0.0);
239    }
240    let z = (mu - f_best) / sigma;
241    // Normal CDF and PDF approximations.
242    let phi_z = normal_pdf(z);
243    let cdf_z = normal_cdf(z);
244    (mu - f_best) * cdf_z + sigma * phi_z
245}
246
247/// Standard normal PDF.
248fn 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
253/// Standard normal CDF (Abramowitz & Stegun approximation).
254fn normal_cdf(x: f64) -> f64 {
255    // Use the error function approximation.
256    0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
257}
258
259/// Error function approximation (Abramowitz & Stegun 7.1.26).
260fn 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
274// ── Search engine (ask-and-tell API) ──────────────────────────────────────────
275
276/// The search engine: combines Sobol initial design with k-NN + EI Bayesian optimization.
277pub struct SearchEngine {
278    space: ConfigurationSpace,
279    sobol: SobolSequence,
280    surrogate: KnnSurrogate,
281    /// Number of initial Sobol points before switching to EI.
282    initial_design: usize,
283    /// Observed results: (normalized input, objective value, config hash).
284    observations: Vec<(Vec<f64>, f64, u64)>,
285    /// Best objective value seen so far.
286    best_objective: f64,
287    /// Budget (max number of trials).
288    budget: usize,
289    /// Set of already-tried config hashes (dedup).
290    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    /// Number of trials so far.
310    pub fn trials(&self) -> usize {
311        self.observations.len()
312    }
313
314    /// Best objective value seen.
315    pub fn best_value(&self) -> f64 {
316        self.best_objective
317    }
318
319    /// Ask for the next configuration to try.
320    /// Returns `None` if the budget is exhausted.
321    pub fn ask(&mut self) -> Option<Configuration> {
322        if self.observations.len() >= self.budget {
323            return None;
324        }
325
326        // Phase 1: Sobol initial design.
327        if self.observations.len() < self.initial_design {
328            let t = self.sobol.next();
329            let cfg = self.space.build_from_normalized(&t);
330            // Skip if already tried (rare but possible with rounding).
331            let h = cfg.hash();
332            if self.tried.contains_key(&h) {
333                return self.ask();
334            }
335            return Some(cfg);
336        }
337
338        // Phase 2: EI-guided search.
339        // Generate a batch of random candidates and pick the one with highest EI.
340        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 all candidates were already tried, fall back to a random one.
360        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    /// Tell the engine the result of an experiment.
376    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        // Objective: combine throughput and quality (higher is better).
384        // This is a scalarization for the surrogate; the Pareto frontier uses all 6 dims.
385        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    /// Directly add an observation to the surrogate (for resuming from a log).
397    /// The normalized vector and config hash must be pre-computed.
398    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    /// Get the best configuration found so far (by scalar objective).
411    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
419/// Scalar objective for the surrogate model: throughput × quality (higher is better).
420/// Failed experiments get -infinity.
421fn 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
430// ── Track-and-Stop bandit for A/B testing ─────────────────────────────────────
431
432/// A multi-armed bandit using the Track-and-Stop algorithm for best-arm identification.
433/// Given a small set of pre-selected configurations, it adaptively allocates samples
434/// to identify the best one with statistical rigor.
435pub struct TrackAndStopBandit {
436    /// Arm configurations (pre-selected).
437    arms: Vec<Configuration>,
438    /// Observed rewards per arm.
439    rewards: Vec<Vec<f64>>,
440    /// Total samples per arm.
441    counts: Vec<usize>,
442    /// Whether the bandit has declared a winner.
443    stopped: bool,
444    /// The winning arm index (if stopped).
445    winner: Option<usize>,
446    /// Number of samples per arm in the initial round.
447    initial_samples: usize,
448    /// GLR threshold for stopping (Chernoff rule).
449    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, // Conservative threshold.
463        }
464    }
465
466    /// Number of arms.
467    pub fn n_arms(&self) -> usize {
468        self.arms.len()
469    }
470
471    /// Whether the bandit has stopped (declared a winner).
472    pub fn is_stopped(&self) -> bool {
473        self.stopped
474    }
475
476    /// The winning arm index (if stopped).
477    pub fn winner(&self) -> Option<usize> {
478        self.winner
479    }
480
481    /// Get the configuration for an arm.
482    pub fn arm_config(&self, i: usize) -> Option<&Configuration> {
483        self.arms.get(i)
484    }
485
486    /// Which arm to sample next. Returns `None` if stopped.
487    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        // Phase 1: ensure each arm has at least `initial_samples` samples.
497        for i in 0..n {
498            if self.counts[i] < self.initial_samples {
499                return Some(i);
500            }
501        }
502
503        // Phase 2: Track-and-Stop — sample the arm with the highest GLR statistic.
504        // The GLR (Generalized Likelihood Ratio) test identifies the arm most likely
505        // to be the best. We sample the current best arm more often (tracking).
506        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        // Find the current best arm.
518        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        // Check stopping condition: GLR test.
526        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; // Signal to stop — check_winner will be called.
531            }
532        }
533
534        // Tracking: sample the best arm with probability proportional to its lead.
535        // Simple D-tracking: sample the best arm 50% of the time, round-robin the rest.
536        let round = total_samples % n;
537        if round == best % n {
538            Some(best)
539        } else {
540            Some(round)
541        }
542    }
543
544    /// Report a reward for an arm.
545    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        // Check if we should stop.
553        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    /// Compute the GLR (Generalized Likelihood Ratio) statistic.
579    /// Higher GLR = more confidence that the best arm is truly the best.
580    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; // No clear winner.
598        }
599        // GLR ≈ sum_i n_i * (best_mean - mean_i)^2 / (2 * sigma^2)
600        // With sigma^2 estimated from the rewards.
601        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    /// Get the mean reward for each arm.
628    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        // Check that points are in [0, 1].
655        for p in &points {
656            for &v in p {
657                assert!(v >= 0.0 && v <= 1.0);
658            }
659        }
660        // Check that the mean is roughly 0.5 (uniform).
661        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        // Should be closer to 10 than 20.
674        assert!(mean < 15.0);
675
676        let (mean, _) = model.predict(&[0.9, 0.9]);
677        // Should be closer to 20 than 10.
678        assert!(mean > 15.0);
679    }
680
681    #[test]
682    fn expected_improvement_zero_sigma() {
683        // When sigma=0, EI = max(0, mu - f_best).
684        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        // With uncertainty, EI should be positive even if mu < f_best.
691        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        // Should produce configurations.
703        let cfg = engine.ask();
704        assert!(cfg.is_some());
705
706        // After telling results, it should continue producing.
707        for _ in 0..5 {
708            if let Some(c) = engine.ask() {
709                // Simulate a result (we can't run a real experiment in unit tests).
710                // Just check that ask/tell cycle works.
711                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        // Simulate: arm 0 has mean reward 10, arm 1 has mean reward 5.
728        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        // The bandit should identify arm 0 as the winner.
743        if let Some(w) = bandit.winner() {
744            assert_eq!(w, 0);
745        }
746    }
747}