Skip to main content

ipfrs_tensorlogic/
hyperparameter_tuner.rs

1//! Hyperparameter Tuner — Bayesian optimization and random/grid search.
2//!
3//! Provides [`HyperparameterTuner`] supporting:
4//! - **Random Search** — sample random configurations with xorshift64 PRNG
5//! - **Grid Search** — enumerate all discrete/categorical/continuous combinations
6//! - **Bayesian Optimization** — Gaussian process surrogate with UCB acquisition
7//!
8//! # Example
9//!
10//! ```
11//! use ipfrs_tensorlogic::{
12//!     HyperparameterTuner, TunerConfig, HpSpec, HpType, TuningStrategy,
13//! };
14//!
15//! let spec = HpSpec {
16//!     name: "lr".to_string(),
17//!     hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
18//!     log_scale: true,
19//! };
20//! let config = TunerConfig {
21//!     specs: vec![spec],
22//!     maximize: false,
23//!     seed: 42,
24//! };
25//! let mut tuner = HyperparameterTuner::new(config);
26//! let mut rng = 42u64;
27//! let results = tuner.run_random_search(5, |cfg| {
28//!     // Simulated scorer: extract lr, return a dummy loss
29//!     use ipfrs_tensorlogic::HpValue;
30//!     if let Some(HpValue::Float(lr)) = cfg.get("lr") {
31//!         lr.abs()
32//!     } else {
33//!         f64::MAX
34//!     }
35//! }, &mut rng);
36//! assert_eq!(results.len(), 5);
37//! ```
38
39use std::collections::HashMap;
40use std::fmt;
41
42// ---------------------------------------------------------------------------
43// xorshift64 PRNG
44// ---------------------------------------------------------------------------
45
46#[inline]
47fn xorshift64(state: &mut u64) -> u64 {
48    *state ^= *state << 13;
49    *state ^= *state >> 7;
50    *state ^= *state << 17;
51    *state
52}
53
54/// Generate a float in [0.0, 1.0).
55#[inline]
56fn rng_f64(state: &mut u64) -> f64 {
57    // Use upper 53 bits for f64 mantissa precision.
58    let bits = xorshift64(state) >> 11;
59    bits as f64 / (1u64 << 53) as f64
60}
61
62/// Generate an integer in [lo, hi] (inclusive).
63#[inline]
64fn rng_i64_range(state: &mut u64, lo: i64, hi: i64) -> i64 {
65    if lo >= hi {
66        return lo;
67    }
68    let span = (hi - lo + 1) as u64;
69    lo + (xorshift64(state) % span) as i64
70}
71
72/// Generate a usize in [lo, hi).
73#[inline]
74fn rng_usize_range(state: &mut u64, lo: usize, hi: usize) -> usize {
75    if lo >= hi {
76        return lo;
77    }
78    lo + (xorshift64(state) as usize % (hi - lo))
79}
80
81// ---------------------------------------------------------------------------
82// Error types
83// ---------------------------------------------------------------------------
84
85/// Errors that can arise during hyperparameter tuning.
86#[derive(Debug, Clone, PartialEq)]
87pub enum HpTunerError {
88    /// No hyperparameter specs have been added.
89    NoSpecs,
90    /// The history is empty (no trials have been recorded).
91    NoHistory,
92    /// A specification is malformed (e.g. inverted bounds, empty categories).
93    InvalidSpec(String),
94}
95
96impl fmt::Display for HpTunerError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            HpTunerError::NoSpecs => write!(f, "no hyperparameter specs defined"),
100            HpTunerError::NoHistory => write!(f, "no trial history available"),
101            HpTunerError::InvalidSpec(msg) => write!(f, "invalid spec: {}", msg),
102        }
103    }
104}
105
106impl std::error::Error for HpTunerError {}
107
108// ---------------------------------------------------------------------------
109// HpType
110// ---------------------------------------------------------------------------
111
112/// The domain type of a single hyperparameter.
113#[derive(Debug, Clone, PartialEq)]
114pub enum HpType {
115    /// Real-valued parameter in `[lo, hi]`.
116    Continuous { lo: f64, hi: f64 },
117    /// Integer-valued parameter in `[lo, hi]` (inclusive).
118    Discrete { lo: i64, hi: i64 },
119    /// One of a fixed set of string choices.
120    Categorical { choices: Vec<String> },
121}
122
123// ---------------------------------------------------------------------------
124// HpSpec
125// ---------------------------------------------------------------------------
126
127/// Specification for a single hyperparameter.
128#[derive(Debug, Clone)]
129pub struct HpSpec {
130    /// Name of the hyperparameter (used as the key in [`HpConfig`]).
131    pub name: String,
132    /// Domain type.
133    pub hp_type: HpType,
134    /// If `true` and the type is `Continuous`, sample in log space
135    /// (transform lo/hi with `ln`, sample uniformly, then `exp`).
136    pub log_scale: bool,
137}
138
139impl HpSpec {
140    /// Validate the spec, returning an error description on failure.
141    pub fn validate(&self) -> Result<(), HpTunerError> {
142        match &self.hp_type {
143            HpType::Continuous { lo, hi } => {
144                if lo >= hi {
145                    return Err(HpTunerError::InvalidSpec(format!(
146                        "Continuous spec '{}': lo ({}) must be < hi ({})",
147                        self.name, lo, hi
148                    )));
149                }
150                if self.log_scale && *lo <= 0.0 {
151                    return Err(HpTunerError::InvalidSpec(format!(
152                        "Continuous spec '{}': log_scale requires lo > 0, got {}",
153                        self.name, lo
154                    )));
155                }
156            }
157            HpType::Discrete { lo, hi } => {
158                if lo > hi {
159                    return Err(HpTunerError::InvalidSpec(format!(
160                        "Discrete spec '{}': lo ({}) must be <= hi ({})",
161                        self.name, lo, hi
162                    )));
163                }
164            }
165            HpType::Categorical { choices } => {
166                if choices.is_empty() {
167                    return Err(HpTunerError::InvalidSpec(format!(
168                        "Categorical spec '{}': choices must not be empty",
169                        self.name
170                    )));
171                }
172            }
173        }
174        Ok(())
175    }
176}
177
178// ---------------------------------------------------------------------------
179// HpValue
180// ---------------------------------------------------------------------------
181
182/// The value of a single hyperparameter in a concrete configuration.
183#[derive(Debug, Clone, PartialEq)]
184pub enum HpValue {
185    /// Real-valued (Continuous or Continuous log-scale).
186    Float(f64),
187    /// Integer-valued (Discrete).
188    Int(i64),
189    /// One of a categorical set.
190    Choice(String),
191}
192
193impl fmt::Display for HpValue {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            HpValue::Float(v) => write!(f, "{:.6e}", v),
197            HpValue::Int(v) => write!(f, "{}", v),
198            HpValue::Choice(s) => write!(f, "{}", s),
199        }
200    }
201}
202
203// ---------------------------------------------------------------------------
204// HpConfig
205// ---------------------------------------------------------------------------
206
207/// A complete hyperparameter configuration (map from name to value).
208#[derive(Debug, Clone, Default)]
209pub struct HpConfig(pub HashMap<String, HpValue>);
210
211impl HpConfig {
212    /// Create an empty config.
213    pub fn new() -> Self {
214        HpConfig(HashMap::new())
215    }
216
217    /// Get the value for a parameter by name.
218    pub fn get(&self, name: &str) -> Option<&HpValue> {
219        self.0.get(name)
220    }
221
222    /// Insert a name→value pair.
223    pub fn insert(&mut self, name: String, value: HpValue) {
224        self.0.insert(name, value);
225    }
226
227    /// Number of parameters in this config.
228    pub fn len(&self) -> usize {
229        self.0.len()
230    }
231
232    /// Returns true if the config has no parameters.
233    pub fn is_empty(&self) -> bool {
234        self.0.is_empty()
235    }
236
237    /// Encode continuous parameters as a vector for distance calculations.
238    /// Discrete values are cast to f64; categorical values are ignored.
239    pub fn continuous_vec(&self, specs: &[HpSpec]) -> Vec<f64> {
240        let mut result = Vec::with_capacity(specs.len());
241        for spec in specs {
242            match self.0.get(&spec.name) {
243                Some(HpValue::Float(v)) => result.push(*v),
244                Some(HpValue::Int(v)) => result.push(*v as f64),
245                _ => {}
246            }
247        }
248        result
249    }
250}
251
252// ---------------------------------------------------------------------------
253// TuningResult
254// ---------------------------------------------------------------------------
255
256/// The result of a single hyperparameter tuning trial.
257#[derive(Debug, Clone)]
258pub struct TuningResult {
259    /// Unique sequential trial identifier (starts at 0).
260    pub trial_id: u64,
261    /// The hyperparameter configuration that was evaluated.
262    pub config: HpConfig,
263    /// The score returned by the objective function (higher = better when `maximize = true`).
264    pub score: f64,
265    /// Caller-supplied timestamp (e.g. seconds since epoch, or step number).
266    pub timestamp: u64,
267}
268
269// ---------------------------------------------------------------------------
270// TuningStrategy
271// ---------------------------------------------------------------------------
272
273/// How the tuner explores the hyperparameter space.
274#[derive(Debug, Clone)]
275pub enum TuningStrategy {
276    /// Draw `n_trials` random configurations independently.
277    RandomSearch { n_trials: u32 },
278    /// Enumerate every combination.  Continuous parameters get 5 evenly-spaced values.
279    GridSearch,
280    /// Gaussian process surrogate with UCB acquisition.
281    BayesianOptimization {
282        n_trials: u32,
283        /// Number of random trials used to warm-start the surrogate.
284        n_initial: u32,
285        /// Weight on the standard-deviation term in the UCB formula (exploration factor).
286        exploration_weight: f64,
287    },
288}
289
290// ---------------------------------------------------------------------------
291// TunerConfig
292// ---------------------------------------------------------------------------
293
294/// Configuration for [`HyperparameterTuner`].
295#[derive(Debug, Clone)]
296pub struct TunerConfig {
297    /// Hyperparameter specifications.
298    pub specs: Vec<HpSpec>,
299    /// If `true`, higher score = better.  If `false`, lower score = better.
300    pub maximize: bool,
301    /// Seed for the xorshift64 PRNG (initialised once; individual calls receive
302    /// a mutable reference so callers can control reproducibility).
303    pub seed: u64,
304}
305
306// ---------------------------------------------------------------------------
307// TunerStats
308// ---------------------------------------------------------------------------
309
310/// Aggregate statistics over all recorded trials.
311#[derive(Debug, Clone, PartialEq)]
312pub struct TunerStats {
313    pub total_trials: usize,
314    pub best_score: f64,
315    pub worst_score: f64,
316    pub avg_score: f64,
317    /// Fraction of trials that strictly improved on the running best.
318    pub improvement_rate: f64,
319}
320
321// ---------------------------------------------------------------------------
322// HyperparameterTuner
323// ---------------------------------------------------------------------------
324
325/// Hyperparameter tuner supporting random search, grid search, and simplified
326/// Bayesian optimization with UCB acquisition.
327#[derive(Debug)]
328pub struct HyperparameterTuner {
329    pub config: TunerConfig,
330    pub history: Vec<TuningResult>,
331    pub next_trial_id: u64,
332}
333
334impl HyperparameterTuner {
335    /// Create a new tuner with the given configuration.
336    pub fn new(config: TunerConfig) -> Self {
337        HyperparameterTuner {
338            config,
339            history: Vec::new(),
340            next_trial_id: 0,
341        }
342    }
343
344    /// Add a hyperparameter specification to the tuner (builder-style).
345    pub fn add_spec(&mut self, spec: HpSpec) -> &mut Self {
346        self.config.specs.push(spec);
347        self
348    }
349
350    // -----------------------------------------------------------------------
351    // Sampling helpers
352    // -----------------------------------------------------------------------
353
354    /// Sample a single value for one spec using the xorshift64 PRNG.
355    pub fn sample_value(spec: &HpSpec, rng: &mut u64) -> HpValue {
356        match &spec.hp_type {
357            HpType::Continuous { lo, hi } => {
358                if spec.log_scale {
359                    let log_lo = lo.ln();
360                    let log_hi = hi.ln();
361                    let log_val = log_lo + rng_f64(rng) * (log_hi - log_lo);
362                    HpValue::Float(log_val.exp())
363                } else {
364                    HpValue::Float(lo + rng_f64(rng) * (hi - lo))
365                }
366            }
367            HpType::Discrete { lo, hi } => HpValue::Int(rng_i64_range(rng, *lo, *hi)),
368            HpType::Categorical { choices } => {
369                let idx = rng_usize_range(rng, 0, choices.len());
370                HpValue::Choice(choices[idx].clone())
371            }
372        }
373    }
374
375    /// Sample a complete configuration (one value per spec).
376    pub fn sample_config(&self, rng: &mut u64) -> HpConfig {
377        let mut cfg = HpConfig::new();
378        for spec in &self.config.specs {
379            let val = Self::sample_value(spec, rng);
380            cfg.insert(spec.name.clone(), val);
381        }
382        cfg
383    }
384
385    // -----------------------------------------------------------------------
386    // Recording
387    // -----------------------------------------------------------------------
388
389    /// Record an evaluation result.  Returns the assigned trial_id.
390    pub fn record_result(&mut self, config: HpConfig, score: f64, now: u64) -> u64 {
391        let id = self.next_trial_id;
392        self.history.push(TuningResult {
393            trial_id: id,
394            config,
395            score,
396            timestamp: now,
397        });
398        self.next_trial_id += 1;
399        id
400    }
401
402    // -----------------------------------------------------------------------
403    // Best result
404    // -----------------------------------------------------------------------
405
406    /// Return a reference to the best trial in history, or `None` if empty.
407    pub fn best_config(&self) -> Option<&TuningResult> {
408        if self.history.is_empty() {
409            return None;
410        }
411        if self.config.maximize {
412            self.history.iter().max_by(|a, b| {
413                a.score
414                    .partial_cmp(&b.score)
415                    .unwrap_or(std::cmp::Ordering::Equal)
416            })
417        } else {
418            self.history.iter().min_by(|a, b| {
419                a.score
420                    .partial_cmp(&b.score)
421                    .unwrap_or(std::cmp::Ordering::Equal)
422            })
423        }
424    }
425
426    // -----------------------------------------------------------------------
427    // UCB-based suggest_next (used by Bayesian strategy)
428    // -----------------------------------------------------------------------
429
430    /// Evaluate the GP-UCB acquisition for a candidate config given the
431    /// current history.  Returns `(mean, std_dev, ucb)`.
432    fn ucb_for_candidate(&self, candidate: &HpConfig, exploration_weight: f64) -> (f64, f64, f64) {
433        if self.history.is_empty() {
434            return (0.0, 1.0, exploration_weight);
435        }
436
437        // Compute Euclidean distances from candidate to each history point.
438        let specs = &self.config.specs;
439        let cand_vec = candidate.continuous_vec(specs);
440
441        // Collect (distance, score) pairs.
442        let mut weighted_scores: Vec<(f64, f64)> = self
443            .history
444            .iter()
445            .map(|r| {
446                let hist_vec = r.config.continuous_vec(specs);
447                let dist = euclidean_dist(&cand_vec, &hist_vec);
448                (dist, r.score)
449            })
450            .collect();
451
452        // Sort by distance so nearest neighbors come first.
453        weighted_scores.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
454
455        // Use up to k=5 nearest neighbors.
456        let k = weighted_scores.len().min(5);
457        let neighbors = &weighted_scores[..k];
458
459        let scores: Vec<f64> = neighbors.iter().map(|(_, s)| *s).collect();
460        let mean = scores.iter().sum::<f64>() / scores.len() as f64;
461
462        let variance = scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / scores.len() as f64;
463        let std_dev = variance.sqrt();
464
465        let epsilon = 1e-8;
466        let ucb = mean + exploration_weight * std_dev + epsilon;
467        (mean, std_dev, ucb)
468    }
469
470    /// Suggest the next configuration to evaluate.
471    ///
472    /// - For `RandomSearch` / no strategy context: sample a random config.
473    /// - For Bayesian: draw 10 random candidates, evaluate UCB for each,
474    ///   and return the one with the highest UCB.
475    pub fn suggest_next(&self, rng: &mut u64) -> HpConfig {
476        // We always have access to history; choose UCB-guided selection if we
477        // have prior results and a meaningful number of specs, otherwise random.
478        if self.history.is_empty() || self.config.specs.is_empty() {
479            return self.sample_config(rng);
480        }
481
482        // Default exploration weight when called outside a BayesianOptimization run.
483        let exploration_weight = 1.0_f64;
484
485        let n_candidates = 10_usize;
486        let mut best_cfg = self.sample_config(rng);
487        let (_, _, mut best_ucb) = self.ucb_for_candidate(&best_cfg, exploration_weight);
488
489        for _ in 1..n_candidates {
490            let candidate = self.sample_config(rng);
491            let (_, _, ucb) = self.ucb_for_candidate(&candidate, exploration_weight);
492            if ucb > best_ucb {
493                best_ucb = ucb;
494                best_cfg = candidate;
495            }
496        }
497        best_cfg
498    }
499
500    // -----------------------------------------------------------------------
501    // Random search
502    // -----------------------------------------------------------------------
503
504    /// Run `n_trials` random evaluations using the provided scorer, record all
505    /// results, and return them sorted by score (best first).
506    pub fn run_random_search(
507        &mut self,
508        n_trials: u32,
509        mut scorer: impl FnMut(&HpConfig) -> f64,
510        rng: &mut u64,
511    ) -> Vec<TuningResult> {
512        for _ in 0..n_trials {
513            let cfg = self.sample_config(rng);
514            let score = scorer(&cfg);
515            self.record_result(cfg, score, 0);
516        }
517        let mut results = self.history.clone();
518        sort_results(&mut results, self.config.maximize);
519        results
520    }
521
522    // -----------------------------------------------------------------------
523    // Grid search
524    // -----------------------------------------------------------------------
525
526    /// Enumerate all grid configurations.
527    ///
528    /// - `Continuous` → 5 evenly-spaced values across `[lo, hi]`
529    /// - `Discrete`   → every integer in `[lo, hi]`
530    /// - `Categorical` → every choice
531    pub fn grid_configs(&self) -> Vec<HpConfig> {
532        if self.config.specs.is_empty() {
533            return Vec::new();
534        }
535
536        // Build a list of candidate values per spec.
537        let value_lists: Vec<Vec<HpValue>> =
538            self.config.specs.iter().map(spec_grid_values).collect();
539
540        // Compute Cartesian product iteratively.
541        let mut configs: Vec<HpConfig> = vec![HpConfig::new()];
542        for (spec, values) in self.config.specs.iter().zip(value_lists.iter()) {
543            let mut next_configs: Vec<HpConfig> = Vec::new();
544            for existing in &configs {
545                for val in values {
546                    let mut new_cfg = existing.clone();
547                    new_cfg.insert(spec.name.clone(), val.clone());
548                    next_configs.push(new_cfg);
549                }
550            }
551            configs = next_configs;
552        }
553        configs
554    }
555
556    /// Enumerate all grid configurations, evaluate each with `scorer`, record
557    /// results, and return them sorted best-first.
558    pub fn run_grid_search(
559        &mut self,
560        mut scorer: impl FnMut(&HpConfig) -> f64,
561    ) -> Vec<TuningResult> {
562        let configs = self.grid_configs();
563        for cfg in configs {
564            let score = scorer(&cfg);
565            self.record_result(cfg, score, 0);
566        }
567        let mut results = self.history.clone();
568        sort_results(&mut results, self.config.maximize);
569        results
570    }
571
572    // -----------------------------------------------------------------------
573    // Bayesian optimization
574    // -----------------------------------------------------------------------
575
576    /// Run Bayesian (UCB) optimization.  Draws `n_initial` random configs to
577    /// warm-start, then proposes `n_trials - n_initial` UCB-guided candidates.
578    pub fn run_bayesian(
579        &mut self,
580        n_trials: u32,
581        n_initial: u32,
582        exploration_weight: f64,
583        mut scorer: impl FnMut(&HpConfig) -> f64,
584        rng: &mut u64,
585    ) -> Vec<TuningResult> {
586        let initial = n_initial.min(n_trials);
587
588        // Warm-start phase.
589        for _ in 0..initial {
590            let cfg = self.sample_config(rng);
591            let score = scorer(&cfg);
592            self.record_result(cfg, score, 0);
593        }
594
595        // Bayesian exploitation/exploration phase.
596        for _ in initial..n_trials {
597            let cfg = self.suggest_next_bayesian(rng, exploration_weight);
598            let score = scorer(&cfg);
599            self.record_result(cfg, score, 0);
600        }
601
602        let mut results = self.history.clone();
603        sort_results(&mut results, self.config.maximize);
604        results
605    }
606
607    /// Suggest the next config using UCB with a given exploration weight.
608    fn suggest_next_bayesian(&self, rng: &mut u64, exploration_weight: f64) -> HpConfig {
609        if self.history.is_empty() || self.config.specs.is_empty() {
610            return self.sample_config(rng);
611        }
612
613        let n_candidates = 10_usize;
614        let mut best_cfg = self.sample_config(rng);
615        let (_, _, mut best_ucb) = self.ucb_for_candidate(&best_cfg, exploration_weight);
616
617        for _ in 1..n_candidates {
618            let candidate = self.sample_config(rng);
619            let (_, _, ucb) = self.ucb_for_candidate(&candidate, exploration_weight);
620            if self.config.maximize {
621                if ucb > best_ucb {
622                    best_ucb = ucb;
623                    best_cfg = candidate;
624                }
625            } else {
626                // For minimization, prefer low mean − exploration * std.
627                let (mean, std_dev, _) = self.ucb_for_candidate(&candidate, exploration_weight);
628                let lcb = mean - exploration_weight * std_dev;
629                let (best_mean, best_std, _) =
630                    self.ucb_for_candidate(&best_cfg, exploration_weight);
631                let best_lcb = best_mean - exploration_weight * best_std;
632                if lcb < best_lcb {
633                    best_ucb = ucb;
634                    best_cfg = candidate;
635                }
636            }
637        }
638        best_cfg
639    }
640
641    // -----------------------------------------------------------------------
642    // Importance scores
643    // -----------------------------------------------------------------------
644
645    /// Compute a simple variance-of-scores importance score for each parameter.
646    ///
647    /// For each parameter, group history trials into equal-width buckets across
648    /// the parameter's range and compute the variance of per-bucket mean scores.
649    /// Returns `0.0` for any parameter if there are fewer than 2 trials.
650    pub fn importance_scores(&self) -> HashMap<String, f64> {
651        let mut result = HashMap::new();
652        if self.history.len() < 2 {
653            for spec in &self.config.specs {
654                result.insert(spec.name.clone(), 0.0);
655            }
656            return result;
657        }
658
659        for spec in &self.config.specs {
660            let importance = compute_importance(spec, &self.history);
661            result.insert(spec.name.clone(), importance);
662        }
663        result
664    }
665
666    // -----------------------------------------------------------------------
667    // Stats
668    // -----------------------------------------------------------------------
669
670    /// Aggregate statistics over all recorded trials.
671    pub fn stats(&self) -> TunerStats {
672        if self.history.is_empty() {
673            return TunerStats {
674                total_trials: 0,
675                best_score: 0.0,
676                worst_score: 0.0,
677                avg_score: 0.0,
678                improvement_rate: 0.0,
679            };
680        }
681
682        let scores: Vec<f64> = self.history.iter().map(|r| r.score).collect();
683        let best_score = if self.config.maximize {
684            scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
685        } else {
686            scores.iter().cloned().fold(f64::INFINITY, f64::min)
687        };
688        let worst_score = if self.config.maximize {
689            scores.iter().cloned().fold(f64::INFINITY, f64::min)
690        } else {
691            scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
692        };
693        let avg_score = scores.iter().sum::<f64>() / scores.len() as f64;
694
695        let improvement_rate = compute_improvement_rate(&scores, self.config.maximize);
696
697        TunerStats {
698            total_trials: self.history.len(),
699            best_score,
700            worst_score,
701            avg_score,
702            improvement_rate,
703        }
704    }
705}
706
707// ---------------------------------------------------------------------------
708// Free helper functions
709// ---------------------------------------------------------------------------
710
711/// Euclidean distance between two vectors (handles unequal lengths via zip).
712fn euclidean_dist(a: &[f64], b: &[f64]) -> f64 {
713    a.iter()
714        .zip(b.iter())
715        .map(|(x, y)| (x - y).powi(2))
716        .sum::<f64>()
717        .sqrt()
718}
719
720/// Sort results best-first (descending if maximize, ascending if minimize).
721fn sort_results(results: &mut [TuningResult], maximize: bool) {
722    if maximize {
723        results.sort_by(|a, b| {
724            b.score
725                .partial_cmp(&a.score)
726                .unwrap_or(std::cmp::Ordering::Equal)
727        });
728    } else {
729        results.sort_by(|a, b| {
730            a.score
731                .partial_cmp(&b.score)
732                .unwrap_or(std::cmp::Ordering::Equal)
733        });
734    }
735}
736
737/// Build the grid of candidate values for a single spec.
738fn spec_grid_values(spec: &HpSpec) -> Vec<HpValue> {
739    const N_CONTINUOUS: usize = 5;
740    match &spec.hp_type {
741        HpType::Continuous { lo, hi } => (0..N_CONTINUOUS)
742            .map(|i| {
743                let t = i as f64 / (N_CONTINUOUS - 1) as f64;
744                if spec.log_scale && *lo > 0.0 {
745                    let log_lo = lo.ln();
746                    let log_hi = hi.ln();
747                    HpValue::Float((log_lo + t * (log_hi - log_lo)).exp())
748                } else {
749                    HpValue::Float(lo + t * (hi - lo))
750                }
751            })
752            .collect(),
753        HpType::Discrete { lo, hi } => (*lo..=*hi).map(HpValue::Int).collect(),
754        HpType::Categorical { choices } => {
755            choices.iter().map(|c| HpValue::Choice(c.clone())).collect()
756        }
757    }
758}
759
760/// Compute the variance-of-bucket-means importance score for a single parameter.
761fn compute_importance(spec: &HpSpec, history: &[TuningResult]) -> f64 {
762    const N_BUCKETS: usize = 5;
763
764    // Collect (bucket_index, score) pairs.
765    let mut bucket_scores: Vec<Vec<f64>> = vec![Vec::new(); N_BUCKETS];
766
767    for result in history {
768        let bucket_idx = match result.config.get(&spec.name) {
769            Some(HpValue::Float(v)) => {
770                // Bucket by value range.
771                if let HpType::Continuous { lo, hi } = &spec.hp_type {
772                    let range = hi - lo;
773                    if range <= 0.0 {
774                        0
775                    } else {
776                        let normalized = (v - lo) / range;
777                        let idx = (normalized * N_BUCKETS as f64) as usize;
778                        idx.min(N_BUCKETS - 1)
779                    }
780                } else {
781                    0
782                }
783            }
784            Some(HpValue::Int(v)) => {
785                if let HpType::Discrete { lo, hi } = &spec.hp_type {
786                    let range = (hi - lo) as f64;
787                    if range <= 0.0 {
788                        0
789                    } else {
790                        let normalized = (v - lo) as f64 / range;
791                        let idx = (normalized * N_BUCKETS as f64) as usize;
792                        idx.min(N_BUCKETS - 1)
793                    }
794                } else {
795                    0
796                }
797            }
798            Some(HpValue::Choice(c)) => {
799                if let HpType::Categorical { choices } = &spec.hp_type {
800                    choices.iter().position(|ch| ch == c).unwrap_or(0) % N_BUCKETS
801                } else {
802                    0
803                }
804            }
805            None => continue,
806        };
807        bucket_scores[bucket_idx].push(result.score);
808    }
809
810    // Compute per-bucket means (skip empty buckets).
811    let means: Vec<f64> = bucket_scores
812        .iter()
813        .filter(|b| !b.is_empty())
814        .map(|b| b.iter().sum::<f64>() / b.len() as f64)
815        .collect();
816
817    if means.len() < 2 {
818        return 0.0;
819    }
820
821    // Variance of bucket means.
822    let mean_of_means = means.iter().sum::<f64>() / means.len() as f64;
823    means
824        .iter()
825        .map(|m| (m - mean_of_means).powi(2))
826        .sum::<f64>()
827        / means.len() as f64
828}
829
830/// Compute the fraction of trials that strictly improved the running best.
831fn compute_improvement_rate(scores: &[f64], maximize: bool) -> f64 {
832    if scores.is_empty() {
833        return 0.0;
834    }
835    let mut improvements = 0usize;
836    let mut running_best = scores[0];
837    // First trial is never counted as an improvement over nothing.
838    for &s in scores.iter().skip(1) {
839        let improved = if maximize {
840            s > running_best
841        } else {
842            s < running_best
843        };
844        if improved {
845            improvements += 1;
846            running_best = s;
847        }
848    }
849    improvements as f64 / scores.len() as f64
850}
851
852// ---------------------------------------------------------------------------
853// Tests
854// ---------------------------------------------------------------------------
855
856#[cfg(test)]
857mod tests {
858    use crate::hyperparameter_tuner::{
859        compute_improvement_rate, euclidean_dist, rng_f64, rng_i64_range, rng_usize_range,
860        sort_results, spec_grid_values, xorshift64, HpConfig, HpSpec, HpTunerError, HpType,
861        HpValue, HyperparameterTuner, TunerConfig, TuningResult,
862    };
863
864    // ------------------------------------------------------------------
865    // PRNG tests
866    // ------------------------------------------------------------------
867
868    #[test]
869    fn test_xorshift64_non_zero() {
870        let mut state = 1u64;
871        let v = xorshift64(&mut state);
872        assert_ne!(v, 0);
873    }
874
875    #[test]
876    fn test_xorshift64_different_values() {
877        let mut state = 12345u64;
878        let a = xorshift64(&mut state);
879        let b = xorshift64(&mut state);
880        assert_ne!(a, b);
881    }
882
883    #[test]
884    fn test_rng_f64_in_range() {
885        let mut state = 99u64;
886        for _ in 0..1000 {
887            let v = rng_f64(&mut state);
888            assert!((0.0..1.0).contains(&v), "out of [0,1): {}", v);
889        }
890    }
891
892    #[test]
893    fn test_rng_i64_range_bounds() {
894        let mut state = 7u64;
895        for _ in 0..500 {
896            let v = rng_i64_range(&mut state, 3, 7);
897            assert!((3..=7).contains(&v), "out of [3,7]: {}", v);
898        }
899    }
900
901    #[test]
902    fn test_rng_i64_range_equal_bounds() {
903        let mut state = 1u64;
904        assert_eq!(rng_i64_range(&mut state, 5, 5), 5);
905    }
906
907    #[test]
908    fn test_rng_usize_range_bounds() {
909        let mut state = 42u64;
910        for _ in 0..500 {
911            let v = rng_usize_range(&mut state, 0, 4);
912            assert!(v < 4, "out of [0,4): {}", v);
913        }
914    }
915
916    // ------------------------------------------------------------------
917    // HpSpec validation
918    // ------------------------------------------------------------------
919
920    #[test]
921    fn test_spec_validate_continuous_ok() {
922        let spec = HpSpec {
923            name: "lr".into(),
924            hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
925            log_scale: false,
926        };
927        assert!(spec.validate().is_ok());
928    }
929
930    #[test]
931    fn test_spec_validate_continuous_inverted_bounds() {
932        let spec = HpSpec {
933            name: "lr".into(),
934            hp_type: HpType::Continuous { lo: 1.0, hi: 0.0 },
935            log_scale: false,
936        };
937        assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
938    }
939
940    #[test]
941    fn test_spec_validate_log_scale_nonpositive_lo() {
942        let spec = HpSpec {
943            name: "lr".into(),
944            hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
945            log_scale: true,
946        };
947        assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
948    }
949
950    #[test]
951    fn test_spec_validate_discrete_ok() {
952        let spec = HpSpec {
953            name: "layers".into(),
954            hp_type: HpType::Discrete { lo: 1, hi: 5 },
955            log_scale: false,
956        };
957        assert!(spec.validate().is_ok());
958    }
959
960    #[test]
961    fn test_spec_validate_discrete_inverted() {
962        let spec = HpSpec {
963            name: "layers".into(),
964            hp_type: HpType::Discrete { lo: 5, hi: 1 },
965            log_scale: false,
966        };
967        assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
968    }
969
970    #[test]
971    fn test_spec_validate_categorical_ok() {
972        let spec = HpSpec {
973            name: "optim".into(),
974            hp_type: HpType::Categorical {
975                choices: vec!["adam".into(), "sgd".into()],
976            },
977            log_scale: false,
978        };
979        assert!(spec.validate().is_ok());
980    }
981
982    #[test]
983    fn test_spec_validate_categorical_empty() {
984        let spec = HpSpec {
985            name: "optim".into(),
986            hp_type: HpType::Categorical { choices: vec![] },
987            log_scale: false,
988        };
989        assert!(matches!(spec.validate(), Err(HpTunerError::InvalidSpec(_))));
990    }
991
992    // ------------------------------------------------------------------
993    // HpConfig
994    // ------------------------------------------------------------------
995
996    #[test]
997    fn test_hp_config_insert_and_get() {
998        let mut cfg = HpConfig::new();
999        cfg.insert("lr".into(), HpValue::Float(0.01));
1000        assert_eq!(cfg.get("lr"), Some(&HpValue::Float(0.01)));
1001        assert_eq!(cfg.get("missing"), None);
1002    }
1003
1004    #[test]
1005    fn test_hp_config_len_is_empty() {
1006        let cfg = HpConfig::new();
1007        assert!(cfg.is_empty());
1008        assert_eq!(cfg.len(), 0);
1009        let mut cfg2 = HpConfig::new();
1010        cfg2.insert("x".into(), HpValue::Int(1));
1011        assert!(!cfg2.is_empty());
1012        assert_eq!(cfg2.len(), 1);
1013    }
1014
1015    // ------------------------------------------------------------------
1016    // sample_value
1017    // ------------------------------------------------------------------
1018
1019    #[test]
1020    fn test_sample_continuous_in_range() {
1021        let spec = HpSpec {
1022            name: "lr".into(),
1023            hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1024            log_scale: false,
1025        };
1026        let mut rng = 1234u64;
1027        for _ in 0..100 {
1028            if let HpValue::Float(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1029                assert!((0.0..=1.0).contains(&v), "out of range: {}", v);
1030            } else {
1031                panic!("expected Float");
1032            }
1033        }
1034    }
1035
1036    #[test]
1037    fn test_sample_continuous_log_scale() {
1038        let spec = HpSpec {
1039            name: "lr".into(),
1040            hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1041            log_scale: true,
1042        };
1043        let mut rng = 77u64;
1044        for _ in 0..200 {
1045            if let HpValue::Float(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1046                assert!(
1047                    (1e-4..=1e-1 + 1e-10).contains(&v),
1048                    "log sample out of range: {}",
1049                    v
1050                );
1051            } else {
1052                panic!("expected Float");
1053            }
1054        }
1055    }
1056
1057    #[test]
1058    fn test_sample_discrete_in_range() {
1059        let spec = HpSpec {
1060            name: "layers".into(),
1061            hp_type: HpType::Discrete { lo: 2, hi: 8 },
1062            log_scale: false,
1063        };
1064        let mut rng = 55u64;
1065        for _ in 0..200 {
1066            if let HpValue::Int(v) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1067                assert!((2..=8).contains(&v), "discrete out of range: {}", v);
1068            } else {
1069                panic!("expected Int");
1070            }
1071        }
1072    }
1073
1074    #[test]
1075    fn test_sample_categorical() {
1076        let choices = vec!["adam".to_string(), "sgd".to_string(), "rmsprop".to_string()];
1077        let spec = HpSpec {
1078            name: "opt".into(),
1079            hp_type: HpType::Categorical {
1080                choices: choices.clone(),
1081            },
1082            log_scale: false,
1083        };
1084        let mut rng = 11u64;
1085        for _ in 0..300 {
1086            if let HpValue::Choice(s) = HyperparameterTuner::sample_value(&spec, &mut rng) {
1087                assert!(choices.contains(&s), "unexpected choice: {}", s);
1088            } else {
1089                panic!("expected Choice");
1090            }
1091        }
1092    }
1093
1094    // ------------------------------------------------------------------
1095    // sample_config
1096    // ------------------------------------------------------------------
1097
1098    #[test]
1099    fn test_sample_config_keys_match_specs() {
1100        let config = TunerConfig {
1101            specs: vec![
1102                HpSpec {
1103                    name: "lr".into(),
1104                    hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1105                    log_scale: false,
1106                },
1107                HpSpec {
1108                    name: "layers".into(),
1109                    hp_type: HpType::Discrete { lo: 1, hi: 5 },
1110                    log_scale: false,
1111                },
1112                HpSpec {
1113                    name: "opt".into(),
1114                    hp_type: HpType::Categorical {
1115                        choices: vec!["adam".into()],
1116                    },
1117                    log_scale: false,
1118                },
1119            ],
1120            maximize: true,
1121            seed: 42,
1122        };
1123        let tuner = HyperparameterTuner::new(config);
1124        let mut rng = 42u64;
1125        let cfg = tuner.sample_config(&mut rng);
1126        assert!(cfg.get("lr").is_some());
1127        assert!(cfg.get("layers").is_some());
1128        assert!(cfg.get("opt").is_some());
1129    }
1130
1131    // ------------------------------------------------------------------
1132    // record_result / best_config
1133    // ------------------------------------------------------------------
1134
1135    #[test]
1136    fn test_record_and_best_maximize() {
1137        let config = TunerConfig {
1138            specs: vec![],
1139            maximize: true,
1140            seed: 0,
1141        };
1142        let mut tuner = HyperparameterTuner::new(config);
1143        tuner.record_result(HpConfig::new(), 0.5, 0);
1144        tuner.record_result(HpConfig::new(), 0.9, 1);
1145        tuner.record_result(HpConfig::new(), 0.2, 2);
1146        let best = tuner.best_config().expect("best must exist");
1147        assert!((best.score - 0.9).abs() < 1e-10);
1148    }
1149
1150    #[test]
1151    fn test_record_and_best_minimize() {
1152        let config = TunerConfig {
1153            specs: vec![],
1154            maximize: false,
1155            seed: 0,
1156        };
1157        let mut tuner = HyperparameterTuner::new(config);
1158        tuner.record_result(HpConfig::new(), 0.5, 0);
1159        tuner.record_result(HpConfig::new(), 0.1, 1);
1160        tuner.record_result(HpConfig::new(), 0.8, 2);
1161        let best = tuner.best_config().expect("best must exist");
1162        assert!((best.score - 0.1).abs() < 1e-10);
1163    }
1164
1165    #[test]
1166    fn test_best_config_empty_returns_none() {
1167        let config = TunerConfig {
1168            specs: vec![],
1169            maximize: true,
1170            seed: 0,
1171        };
1172        let tuner = HyperparameterTuner::new(config);
1173        assert!(tuner.best_config().is_none());
1174    }
1175
1176    #[test]
1177    fn test_trial_id_sequential() {
1178        let config = TunerConfig {
1179            specs: vec![],
1180            maximize: true,
1181            seed: 0,
1182        };
1183        let mut tuner = HyperparameterTuner::new(config);
1184        let id0 = tuner.record_result(HpConfig::new(), 1.0, 0);
1185        let id1 = tuner.record_result(HpConfig::new(), 2.0, 0);
1186        assert_eq!(id0, 0);
1187        assert_eq!(id1, 1);
1188    }
1189
1190    // ------------------------------------------------------------------
1191    // grid_configs
1192    // ------------------------------------------------------------------
1193
1194    #[test]
1195    fn test_grid_configs_continuous_gives_5_values() {
1196        let spec = HpSpec {
1197            name: "lr".into(),
1198            hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1199            log_scale: false,
1200        };
1201        let vals = spec_grid_values(&spec);
1202        assert_eq!(vals.len(), 5);
1203        // Check endpoints.
1204        if let HpValue::Float(lo) = &vals[0] {
1205            assert!((*lo - 0.0).abs() < 1e-10);
1206        }
1207        if let HpValue::Float(hi) = &vals[4] {
1208            assert!((*hi - 1.0).abs() < 1e-10);
1209        }
1210    }
1211
1212    #[test]
1213    fn test_grid_configs_discrete() {
1214        let spec = HpSpec {
1215            name: "n".into(),
1216            hp_type: HpType::Discrete { lo: 1, hi: 4 },
1217            log_scale: false,
1218        };
1219        let vals = spec_grid_values(&spec);
1220        assert_eq!(vals.len(), 4);
1221        assert_eq!(vals[0], HpValue::Int(1));
1222        assert_eq!(vals[3], HpValue::Int(4));
1223    }
1224
1225    #[test]
1226    fn test_grid_configs_categorical() {
1227        let spec = HpSpec {
1228            name: "opt".into(),
1229            hp_type: HpType::Categorical {
1230                choices: vec!["a".into(), "b".into(), "c".into()],
1231            },
1232            log_scale: false,
1233        };
1234        let vals = spec_grid_values(&spec);
1235        assert_eq!(vals.len(), 3);
1236        assert_eq!(vals[1], HpValue::Choice("b".into()));
1237    }
1238
1239    #[test]
1240    fn test_grid_configs_cartesian_product() {
1241        let config = TunerConfig {
1242            specs: vec![
1243                HpSpec {
1244                    name: "a".into(),
1245                    hp_type: HpType::Discrete { lo: 0, hi: 1 },
1246                    log_scale: false,
1247                },
1248                HpSpec {
1249                    name: "b".into(),
1250                    hp_type: HpType::Categorical {
1251                        choices: vec!["x".into(), "y".into()],
1252                    },
1253                    log_scale: false,
1254                },
1255            ],
1256            maximize: true,
1257            seed: 0,
1258        };
1259        let tuner = HyperparameterTuner::new(config);
1260        let cfgs = tuner.grid_configs();
1261        // 2 discrete * 2 categorical = 4 configs
1262        assert_eq!(cfgs.len(), 4);
1263    }
1264
1265    #[test]
1266    fn test_grid_configs_empty_specs() {
1267        let config = TunerConfig {
1268            specs: vec![],
1269            maximize: true,
1270            seed: 0,
1271        };
1272        let tuner = HyperparameterTuner::new(config);
1273        assert!(tuner.grid_configs().is_empty());
1274    }
1275
1276    // ------------------------------------------------------------------
1277    // run_random_search
1278    // ------------------------------------------------------------------
1279
1280    #[test]
1281    fn test_run_random_search_count() {
1282        let config = TunerConfig {
1283            specs: vec![HpSpec {
1284                name: "x".into(),
1285                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1286                log_scale: false,
1287            }],
1288            maximize: true,
1289            seed: 1,
1290        };
1291        let mut tuner = HyperparameterTuner::new(config);
1292        let mut rng = 1u64;
1293        let results = tuner.run_random_search(10, |_| 0.5, &mut rng);
1294        assert_eq!(results.len(), 10);
1295    }
1296
1297    #[test]
1298    fn test_run_random_search_sorted_maximize() {
1299        let config = TunerConfig {
1300            specs: vec![HpSpec {
1301                name: "x".into(),
1302                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1303                log_scale: false,
1304            }],
1305            maximize: true,
1306            seed: 42,
1307        };
1308        let mut tuner = HyperparameterTuner::new(config);
1309        let mut rng = 42u64;
1310        let mut counter = 0.0f64;
1311        let results = tuner.run_random_search(
1312            5,
1313            |_| {
1314                counter += 1.0;
1315                counter
1316            },
1317            &mut rng,
1318        );
1319        // Should be sorted descending.
1320        for w in results.windows(2) {
1321            assert!(
1322                w[0].score >= w[1].score,
1323                "not sorted: {} < {}",
1324                w[0].score,
1325                w[1].score
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn test_run_random_search_sorted_minimize() {
1332        let config = TunerConfig {
1333            specs: vec![HpSpec {
1334                name: "x".into(),
1335                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1336                log_scale: false,
1337            }],
1338            maximize: false,
1339            seed: 7,
1340        };
1341        let mut tuner = HyperparameterTuner::new(config);
1342        let mut rng = 7u64;
1343        let mut counter = 5.0f64;
1344        let results = tuner.run_random_search(
1345            5,
1346            |_| {
1347                counter -= 1.0;
1348                counter
1349            },
1350            &mut rng,
1351        );
1352        for w in results.windows(2) {
1353            assert!(w[0].score <= w[1].score, "not sorted ascending");
1354        }
1355    }
1356
1357    // ------------------------------------------------------------------
1358    // run_grid_search
1359    // ------------------------------------------------------------------
1360
1361    #[test]
1362    fn test_run_grid_search_all_evaluated() {
1363        let config = TunerConfig {
1364            specs: vec![HpSpec {
1365                name: "a".into(),
1366                hp_type: HpType::Discrete { lo: 1, hi: 3 },
1367                log_scale: false,
1368            }],
1369            maximize: true,
1370            seed: 0,
1371        };
1372        let mut tuner = HyperparameterTuner::new(config);
1373        let results = tuner.run_grid_search(|cfg| {
1374            if let Some(HpValue::Int(v)) = cfg.get("a") {
1375                *v as f64
1376            } else {
1377                0.0
1378            }
1379        });
1380        // Discrete 1..=3 → 3 values.
1381        assert_eq!(results.len(), 3);
1382        // Sorted descending (maximize).
1383        assert_eq!(results[0].score, 3.0);
1384    }
1385
1386    // ------------------------------------------------------------------
1387    // importance_scores
1388    // ------------------------------------------------------------------
1389
1390    #[test]
1391    fn test_importance_scores_no_history() {
1392        let config = TunerConfig {
1393            specs: vec![HpSpec {
1394                name: "x".into(),
1395                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1396                log_scale: false,
1397            }],
1398            maximize: true,
1399            seed: 0,
1400        };
1401        let tuner = HyperparameterTuner::new(config);
1402        let scores = tuner.importance_scores();
1403        assert_eq!(scores.get("x"), Some(&0.0));
1404    }
1405
1406    #[test]
1407    fn test_importance_scores_returns_all_specs() {
1408        let config = TunerConfig {
1409            specs: vec![
1410                HpSpec {
1411                    name: "lr".into(),
1412                    hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1413                    log_scale: false,
1414                },
1415                HpSpec {
1416                    name: "layers".into(),
1417                    hp_type: HpType::Discrete { lo: 1, hi: 5 },
1418                    log_scale: false,
1419                },
1420            ],
1421            maximize: true,
1422            seed: 0,
1423        };
1424        let mut tuner = HyperparameterTuner::new(config);
1425        // Add enough history.
1426        for i in 0..10 {
1427            let mut cfg = HpConfig::new();
1428            cfg.insert("lr".into(), HpValue::Float(i as f64 * 0.1));
1429            cfg.insert("layers".into(), HpValue::Int(i % 5 + 1));
1430            tuner.record_result(cfg, i as f64, 0);
1431        }
1432        let scores = tuner.importance_scores();
1433        assert!(scores.contains_key("lr"));
1434        assert!(scores.contains_key("layers"));
1435    }
1436
1437    // ------------------------------------------------------------------
1438    // stats
1439    // ------------------------------------------------------------------
1440
1441    #[test]
1442    fn test_stats_empty() {
1443        let config = TunerConfig {
1444            specs: vec![],
1445            maximize: true,
1446            seed: 0,
1447        };
1448        let tuner = HyperparameterTuner::new(config);
1449        let s = tuner.stats();
1450        assert_eq!(s.total_trials, 0);
1451        assert_eq!(s.improvement_rate, 0.0);
1452    }
1453
1454    #[test]
1455    fn test_stats_correct_values() {
1456        let config = TunerConfig {
1457            specs: vec![],
1458            maximize: true,
1459            seed: 0,
1460        };
1461        let mut tuner = HyperparameterTuner::new(config);
1462        tuner.record_result(HpConfig::new(), 1.0, 0);
1463        tuner.record_result(HpConfig::new(), 3.0, 0);
1464        tuner.record_result(HpConfig::new(), 2.0, 0);
1465        let s = tuner.stats();
1466        assert_eq!(s.total_trials, 3);
1467        assert!((s.best_score - 3.0).abs() < 1e-10);
1468        assert!((s.worst_score - 1.0).abs() < 1e-10);
1469        assert!((s.avg_score - 2.0).abs() < 1e-10);
1470        // Improvement rate: trial 1 (3.0 > 1.0 → improves) = 1/3.
1471        assert!((s.improvement_rate - 1.0 / 3.0).abs() < 1e-10);
1472    }
1473
1474    #[test]
1475    fn test_stats_minimize() {
1476        let config = TunerConfig {
1477            specs: vec![],
1478            maximize: false,
1479            seed: 0,
1480        };
1481        let mut tuner = HyperparameterTuner::new(config);
1482        tuner.record_result(HpConfig::new(), 10.0, 0);
1483        tuner.record_result(HpConfig::new(), 5.0, 0);
1484        tuner.record_result(HpConfig::new(), 7.0, 0);
1485        let s = tuner.stats();
1486        assert!((s.best_score - 5.0).abs() < 1e-10);
1487        assert!((s.worst_score - 10.0).abs() < 1e-10);
1488    }
1489
1490    // ------------------------------------------------------------------
1491    // suggest_next
1492    // ------------------------------------------------------------------
1493
1494    #[test]
1495    fn test_suggest_next_returns_config_with_all_specs() {
1496        let config = TunerConfig {
1497            specs: vec![HpSpec {
1498                name: "lr".into(),
1499                hp_type: HpType::Continuous { lo: 1e-4, hi: 1.0 },
1500                log_scale: false,
1501            }],
1502            maximize: true,
1503            seed: 1,
1504        };
1505        let mut tuner = HyperparameterTuner::new(config);
1506        let mut rng = 1u64;
1507        // Add some history first.
1508        for i in 0..5 {
1509            let mut cfg = HpConfig::new();
1510            cfg.insert("lr".into(), HpValue::Float(0.1 * i as f64));
1511            tuner.record_result(cfg, i as f64, 0);
1512        }
1513        let next = tuner.suggest_next(&mut rng);
1514        assert!(next.get("lr").is_some());
1515    }
1516
1517    #[test]
1518    fn test_suggest_next_no_history_still_works() {
1519        let config = TunerConfig {
1520            specs: vec![HpSpec {
1521                name: "lr".into(),
1522                hp_type: HpType::Continuous { lo: 0.01, hi: 0.1 },
1523                log_scale: false,
1524            }],
1525            maximize: true,
1526            seed: 5,
1527        };
1528        let tuner = HyperparameterTuner::new(config);
1529        let mut rng = 5u64;
1530        let next = tuner.suggest_next(&mut rng);
1531        assert!(next.get("lr").is_some());
1532    }
1533
1534    // ------------------------------------------------------------------
1535    // add_spec builder
1536    // ------------------------------------------------------------------
1537
1538    #[test]
1539    fn test_add_spec_builder() {
1540        let config = TunerConfig {
1541            specs: vec![],
1542            maximize: true,
1543            seed: 0,
1544        };
1545        let mut tuner = HyperparameterTuner::new(config);
1546        tuner.add_spec(HpSpec {
1547            name: "lr".into(),
1548            hp_type: HpType::Continuous { lo: 1e-4, hi: 1.0 },
1549            log_scale: true,
1550        });
1551        assert_eq!(tuner.config.specs.len(), 1);
1552    }
1553
1554    // ------------------------------------------------------------------
1555    // Bayesian optimization
1556    // ------------------------------------------------------------------
1557
1558    #[test]
1559    fn test_bayesian_optimization_count() {
1560        let config = TunerConfig {
1561            specs: vec![HpSpec {
1562                name: "x".into(),
1563                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1564                log_scale: false,
1565            }],
1566            maximize: false,
1567            seed: 3,
1568        };
1569        let mut tuner = HyperparameterTuner::new(config);
1570        let mut rng = 3u64;
1571        let results = tuner.run_bayesian(
1572            10,
1573            3,
1574            1.0,
1575            |cfg| {
1576                if let Some(HpValue::Float(x)) = cfg.get("x") {
1577                    (*x - 0.3).powi(2)
1578                } else {
1579                    1.0
1580                }
1581            },
1582            &mut rng,
1583        );
1584        assert_eq!(results.len(), 10);
1585    }
1586
1587    #[test]
1588    fn test_bayesian_optimization_sorted() {
1589        let config = TunerConfig {
1590            specs: vec![HpSpec {
1591                name: "x".into(),
1592                hp_type: HpType::Continuous { lo: 0.0, hi: 1.0 },
1593                log_scale: false,
1594            }],
1595            maximize: true,
1596            seed: 17,
1597        };
1598        let mut tuner = HyperparameterTuner::new(config);
1599        let mut rng = 17u64;
1600        let results = tuner.run_bayesian(
1601            8,
1602            3,
1603            1.5,
1604            |cfg| {
1605                if let Some(HpValue::Float(x)) = cfg.get("x") {
1606                    *x
1607                } else {
1608                    0.0
1609                }
1610            },
1611            &mut rng,
1612        );
1613        for w in results.windows(2) {
1614            assert!(w[0].score >= w[1].score, "Bayesian results not sorted");
1615        }
1616    }
1617
1618    // ------------------------------------------------------------------
1619    // Euclidean distance helper
1620    // ------------------------------------------------------------------
1621
1622    #[test]
1623    fn test_euclidean_dist() {
1624        let a = vec![0.0, 0.0];
1625        let b = vec![3.0, 4.0];
1626        assert!((euclidean_dist(&a, &b) - 5.0).abs() < 1e-10);
1627    }
1628
1629    #[test]
1630    fn test_euclidean_dist_same_point() {
1631        let a = vec![1.0, 2.0, 3.0];
1632        assert!((euclidean_dist(&a, &a) - 0.0).abs() < 1e-10);
1633    }
1634
1635    // ------------------------------------------------------------------
1636    // sort_results helper
1637    // ------------------------------------------------------------------
1638
1639    #[test]
1640    fn test_sort_results_maximize() {
1641        let mut results = vec![
1642            TuningResult {
1643                trial_id: 0,
1644                config: HpConfig::new(),
1645                score: 0.2,
1646                timestamp: 0,
1647            },
1648            TuningResult {
1649                trial_id: 1,
1650                config: HpConfig::new(),
1651                score: 0.8,
1652                timestamp: 0,
1653            },
1654            TuningResult {
1655                trial_id: 2,
1656                config: HpConfig::new(),
1657                score: 0.5,
1658                timestamp: 0,
1659            },
1660        ];
1661        sort_results(&mut results, true);
1662        assert!((results[0].score - 0.8).abs() < 1e-10);
1663        assert!((results[2].score - 0.2).abs() < 1e-10);
1664    }
1665
1666    #[test]
1667    fn test_sort_results_minimize() {
1668        let mut results = vec![
1669            TuningResult {
1670                trial_id: 0,
1671                config: HpConfig::new(),
1672                score: 0.8,
1673                timestamp: 0,
1674            },
1675            TuningResult {
1676                trial_id: 1,
1677                config: HpConfig::new(),
1678                score: 0.2,
1679                timestamp: 0,
1680            },
1681        ];
1682        sort_results(&mut results, false);
1683        assert!((results[0].score - 0.2).abs() < 1e-10);
1684    }
1685
1686    // ------------------------------------------------------------------
1687    // compute_improvement_rate
1688    // ------------------------------------------------------------------
1689
1690    #[test]
1691    fn test_improvement_rate_monotone_increase_maximize() {
1692        let scores = vec![1.0, 2.0, 3.0, 4.0];
1693        // Improvements at indices 1, 2, 3 → 3 improvements / 4 total = 0.75
1694        let rate = compute_improvement_rate(&scores, true);
1695        assert!((rate - 0.75).abs() < 1e-10, "expected 0.75, got {}", rate);
1696    }
1697
1698    #[test]
1699    fn test_improvement_rate_no_improvement() {
1700        let scores = vec![5.0, 4.0, 3.0]; // descending, maximize → no improvement
1701        let rate = compute_improvement_rate(&scores, true);
1702        assert_eq!(rate, 0.0);
1703    }
1704
1705    #[test]
1706    fn test_improvement_rate_minimize() {
1707        let scores = vec![10.0, 8.0, 6.0]; // decreasing, minimize → improvements at 1,2 → 2/3
1708        let rate = compute_improvement_rate(&scores, false);
1709        assert!((rate - 2.0 / 3.0).abs() < 1e-10);
1710    }
1711
1712    // ------------------------------------------------------------------
1713    // HpTunerError display
1714    // ------------------------------------------------------------------
1715
1716    #[test]
1717    fn test_error_display_no_specs() {
1718        let e = HpTunerError::NoSpecs;
1719        assert!(!format!("{}", e).is_empty());
1720    }
1721
1722    #[test]
1723    fn test_error_display_invalid_spec() {
1724        let e = HpTunerError::InvalidSpec("bad range".into());
1725        assert!(format!("{}", e).contains("bad range"));
1726    }
1727
1728    // ------------------------------------------------------------------
1729    // HpValue display
1730    // ------------------------------------------------------------------
1731
1732    #[test]
1733    fn test_hp_value_display() {
1734        assert!(!format!("{}", HpValue::Float(0.001)).is_empty());
1735        assert!(!format!("{}", HpValue::Int(42)).is_empty());
1736        assert!(!format!("{}", HpValue::Choice("relu".into())).is_empty());
1737    }
1738
1739    // ------------------------------------------------------------------
1740    // Grid search log scale
1741    // ------------------------------------------------------------------
1742
1743    #[test]
1744    fn test_grid_continuous_log_scale_endpoints() {
1745        let spec = HpSpec {
1746            name: "lr".into(),
1747            hp_type: HpType::Continuous { lo: 1e-4, hi: 1e-1 },
1748            log_scale: true,
1749        };
1750        let vals = spec_grid_values(&spec);
1751        assert_eq!(vals.len(), 5);
1752        if let HpValue::Float(lo_val) = &vals[0] {
1753            assert!(
1754                (lo_val - 1e-4).abs() < 1e-10,
1755                "log-scale lo wrong: {}",
1756                lo_val
1757            );
1758        }
1759        if let HpValue::Float(hi_val) = &vals[4] {
1760            assert!(
1761                (hi_val - 1e-1).abs() < 1e-10,
1762                "log-scale hi wrong: {}",
1763                hi_val
1764            );
1765        }
1766    }
1767
1768    // ------------------------------------------------------------------
1769    // Mixed spec types in sample_config
1770    // ------------------------------------------------------------------
1771
1772    #[test]
1773    fn test_sample_config_all_spec_types() {
1774        let config = TunerConfig {
1775            specs: vec![
1776                HpSpec {
1777                    name: "lr".into(),
1778                    hp_type: HpType::Continuous { lo: 1e-4, hi: 0.1 },
1779                    log_scale: true,
1780                },
1781                HpSpec {
1782                    name: "n".into(),
1783                    hp_type: HpType::Discrete { lo: 2, hi: 10 },
1784                    log_scale: false,
1785                },
1786                HpSpec {
1787                    name: "act".into(),
1788                    hp_type: HpType::Categorical {
1789                        choices: vec!["relu".into(), "tanh".into()],
1790                    },
1791                    log_scale: false,
1792                },
1793            ],
1794            maximize: true,
1795            seed: 99,
1796        };
1797        let tuner = HyperparameterTuner::new(config);
1798        let mut rng = 99u64;
1799        let cfg = tuner.sample_config(&mut rng);
1800        assert!(matches!(cfg.get("lr"), Some(HpValue::Float(_))));
1801        assert!(matches!(cfg.get("n"), Some(HpValue::Int(_))));
1802        assert!(matches!(cfg.get("act"), Some(HpValue::Choice(_))));
1803    }
1804}