Skip to main content

optirs_core/hardware_aware/
adaptive_tuner.rs

1// Adaptive tuning search for the hardware-aware optimizer.
2//
3// [`TuningStrategy`] used to be a pure description: the tuner stored a
4// performance target and nothing else, so selecting `GridSearch` tuned exactly
5// as much as selecting nothing. This module supplies the search loop behind the
6// strategy — a real evaluator-driven search over registered parameter ranges
7// that records every measurement in `tuning_history` and leaves the best
8// candidate in `current_params`.
9
10use std::collections::HashMap;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use scirs2_core::numeric::Float;
14use scirs2_core::random::{rngs::StdRng, seeded_rng, CoreRandom};
15
16use crate::error::{OptimError, Result};
17use crate::utils::{scalar_or, total_order, try_f64};
18
19/// Evaluations a single [`AdaptiveTuner::tune`] call performs before giving up.
20const DEFAULT_MAX_EVALUATIONS: usize = 256;
21
22/// Measurements kept in [`AdaptiveTuner::tuning_history`].
23const MAX_TUNING_HISTORY: usize = 1000;
24
25/// Smallest hill-climbing step, as a fraction of a parameter's range.
26///
27/// Greedy search halves its step whenever a round finds no improvement; below
28/// this fraction the candidates it generates are indistinguishable from the
29/// incumbent for any realistic objective, so the search stops.
30const MIN_STEP_FRACTION: f64 = 1e-4;
31
32/// Seed the genetic search uses unless the caller sets another one.
33///
34/// The seed is fixed rather than drawn from the environment so a tuning run is
35/// reproducible: two runs of the same evaluator over the same search space must
36/// produce the same recommendation, or the tuning history cannot be compared
37/// across runs.
38pub const DEFAULT_TUNING_SEED: u64 = 0x0071_7250_5f74_756e;
39
40/// Candidates drawn per tournament in the genetic search.
41const TOURNAMENT_SIZE: usize = 2;
42
43/// BLX-alpha crossover width: offspring are drawn from the interval spanned by
44/// the two parents, widened by this fraction at each end (Eshelman & Schaffer,
45/// "Real-Coded Genetic Algorithms and Interval-Schemata", FOGA 1993).
46const CROSSOVER_ALPHA: f64 = 0.5;
47
48/// Probability that a given coordinate of an offspring is mutated.
49const MUTATION_PROBABILITY: f64 = 0.2;
50
51/// Mutation magnitude, as a fraction of the parameter's range.
52const MUTATION_SCALE: f64 = 0.1;
53
54/// Tuning record for adaptive optimization
55#[derive(Debug, Clone)]
56pub struct TuningRecord<A: Float> {
57    /// Tuning parameters used
58    pub parameters: HashMap<String, A>,
59    /// Performance achieved
60    pub performance: A,
61    /// Resource consumption
62    pub resource_usage: A,
63    /// Timestamp
64    pub timestamp: u64,
65}
66
67/// Tuning strategies
68#[derive(Debug, Clone)]
69pub enum TuningStrategy {
70    /// Grid search over parameter space
71    GridSearch {
72        /// Grid search resolution
73        resolution: usize,
74    },
75    /// Greedy coordinate-wise hill climbing
76    Greedy {
77        /// Initial step, as a fraction of each parameter's range
78        step_fraction: f64,
79        /// Maximum number of sweeps over the parameters
80        max_rounds: usize,
81    },
82    /// Bayesian optimization
83    BayesianOptimization {
84        /// Number of samples
85        num_samples: usize,
86    },
87    /// Genetic algorithm
88    GeneticAlgorithm {
89        /// Population size
90        population_size: usize,
91        /// Number of generations
92        generations: usize,
93    },
94    /// Reinforcement learning based
95    ReinforcementLearning {
96        /// Exploration rate
97        exploration_rate: f64,
98    },
99}
100
101/// A parameter the tuner is allowed to move, and the range it may move it in.
102#[derive(Debug, Clone)]
103pub struct TunableParameter<A: Float> {
104    /// Name the evaluator will see in the candidate map.
105    pub name: String,
106    /// Inclusive lower bound.
107    pub minimum: A,
108    /// Inclusive upper bound.
109    pub maximum: A,
110}
111
112impl<A: Float> TunableParameter<A> {
113    /// Register a parameter over `[minimum, maximum]`.
114    ///
115    /// A reversed or non-finite range is rejected: every search in this module
116    /// interpolates inside the range, so an invalid one would silently produce
117    /// candidates the caller never authorised.
118    pub fn new(name: impl Into<String>, minimum: A, maximum: A) -> Result<Self> {
119        let name = name.into();
120        if name.is_empty() {
121            return Err(OptimError::InvalidConfig(
122                "a tunable parameter needs a non-empty name".to_string(),
123            ));
124        }
125        if !minimum.is_finite() || !maximum.is_finite() {
126            return Err(OptimError::InvalidConfig(format!(
127                "tunable parameter '{name}' has a non-finite bound"
128            )));
129        }
130        if minimum > maximum {
131            return Err(OptimError::InvalidConfig(format!(
132                "tunable parameter '{name}' has minimum > maximum"
133            )));
134        }
135        Ok(Self {
136            name,
137            minimum,
138            maximum,
139        })
140    }
141
142    /// Width of the range.
143    pub fn span(&self) -> A {
144        self.maximum - self.minimum
145    }
146
147    /// Midpoint of the range, used as the starting point when the caller has
148    /// not supplied one.
149    pub fn midpoint(&self) -> A {
150        self.minimum + self.span() / (A::one() + A::one())
151    }
152
153    /// Clamp a value into the range.
154    pub fn clamp(&self, value: A) -> A {
155        if value < self.minimum {
156            self.minimum
157        } else if value > self.maximum {
158            self.maximum
159        } else {
160            value
161        }
162    }
163}
164
165/// What the evaluator reports back for one candidate parameter set.
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct TuningObservation<A: Float> {
168    /// Measured performance. **Higher is better** — the tuner maximizes this
169    /// and compares it against [`AdaptiveTuner::performance_target`], so a
170    /// loss-like metric must be negated by the evaluator.
171    pub performance: A,
172    /// Measured resource consumption for the same candidate, recorded in the
173    /// tuning history so a caller can trade performance against cost after the
174    /// fact.
175    pub resource_usage: A,
176}
177
178/// Result of one [`AdaptiveTuner::tune`] call.
179#[derive(Debug, Clone)]
180pub struct TuningOutcome<A: Float> {
181    /// Best candidate found, also left in [`AdaptiveTuner::current_params`].
182    pub best_parameters: HashMap<String, A>,
183    /// Performance measured for `best_parameters`.
184    pub best_performance: A,
185    /// Number of candidates evaluated during this call.
186    pub evaluations: usize,
187    /// Whether the search stopped because it reached the performance target.
188    pub target_reached: bool,
189}
190
191/// Adaptive tuner for dynamic optimization
192#[derive(Debug)]
193pub struct AdaptiveTuner<A: Float> {
194    /// Performance target
195    performance_target: A,
196    /// Search strategy used by [`AdaptiveTuner::tune`]
197    strategy: TuningStrategy,
198    /// Parameters the search may move, in registration order (which is what
199    /// makes every search in this module deterministic)
200    parameters: Vec<TunableParameter<A>>,
201    /// Best candidate found so far
202    current_params: HashMap<String, A>,
203    /// Every measurement taken, most recent last
204    tuning_history: Vec<TuningRecord<A>>,
205    /// Performance of `current_params`, if anything has been measured
206    best_performance: Option<A>,
207    /// Evaluation budget for a single `tune` call
208    max_evaluations: usize,
209    /// Lifetime evaluation count, kept separately from `tuning_history` because
210    /// the history is bounded
211    total_evaluations: usize,
212    /// Seed for the stochastic searches, so a tuning run is reproducible
213    random_seed: u64,
214}
215
216impl<A: Float + Send + Sync> Default for AdaptiveTuner<A> {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222impl<A: Float + Send + Sync> AdaptiveTuner<A> {
223    /// Create a new adaptive tuner.
224    ///
225    /// The default strategy is greedy hill climbing, which needs no search-space
226    /// budget beyond the parameters themselves; register those with
227    /// [`AdaptiveTuner::add_parameter`] before calling [`AdaptiveTuner::tune`].
228    pub fn new() -> Self {
229        Self {
230            performance_target: crate::utils::scalar_or(100.0, A::zero()),
231            strategy: TuningStrategy::Greedy {
232                step_fraction: 0.25,
233                max_rounds: 8,
234            },
235            parameters: Vec::new(),
236            current_params: HashMap::new(),
237            tuning_history: Vec::new(),
238            best_performance: None,
239            max_evaluations: DEFAULT_MAX_EVALUATIONS,
240            total_evaluations: 0,
241            random_seed: DEFAULT_TUNING_SEED,
242        }
243    }
244
245    /// Seed the stochastic searches draw from.
246    pub fn random_seed(&self) -> u64 {
247        self.random_seed
248    }
249
250    /// Set the seed the stochastic searches draw from.
251    ///
252    /// Two [`AdaptiveTuner::tune`] calls with the same seed, search space and
253    /// evaluator produce the same sequence of candidates.
254    pub fn set_random_seed(&mut self, seed: u64) {
255        self.random_seed = seed;
256    }
257
258    /// Performance the search stops at once reached.
259    pub fn performance_target(&self) -> A {
260        self.performance_target
261    }
262
263    /// Set the performance target.
264    pub fn set_performance_target(&mut self, target: A) {
265        self.performance_target = target;
266    }
267
268    /// Search strategy in use.
269    pub fn strategy(&self) -> &TuningStrategy {
270        &self.strategy
271    }
272
273    /// Select the search strategy.
274    pub fn set_strategy(&mut self, strategy: TuningStrategy) {
275        self.strategy = strategy;
276    }
277
278    /// Register a parameter the search may move.
279    pub fn add_parameter(&mut self, parameter: TunableParameter<A>) -> Result<()> {
280        if self.parameters.iter().any(|p| p.name == parameter.name) {
281            return Err(OptimError::InvalidConfig(format!(
282                "tunable parameter '{}' is already registered",
283                parameter.name
284            )));
285        }
286        self.parameters.push(parameter);
287        Ok(())
288    }
289
290    /// Parameters the search may move.
291    pub fn parameters(&self) -> &[TunableParameter<A>] {
292        &self.parameters
293    }
294
295    /// Best candidate found so far.
296    pub fn current_params(&self) -> &HashMap<String, A> {
297        &self.current_params
298    }
299
300    /// Performance measured for [`AdaptiveTuner::current_params`].
301    pub fn best_performance(&self) -> Option<A> {
302        self.best_performance
303    }
304
305    /// Every measurement taken so far, oldest first, bounded to the most recent
306    /// `MAX_TUNING_HISTORY` entries.
307    pub fn tuning_history(&self) -> &[TuningRecord<A>] {
308        &self.tuning_history
309    }
310
311    /// Total candidates evaluated over the tuner's lifetime.
312    pub fn total_evaluations(&self) -> usize {
313        self.total_evaluations
314    }
315
316    /// Evaluation budget for a single [`AdaptiveTuner::tune`] call.
317    pub fn max_evaluations(&self) -> usize {
318        self.max_evaluations
319    }
320
321    /// Set the evaluation budget for a single [`AdaptiveTuner::tune`] call.
322    pub fn set_max_evaluations(&mut self, max_evaluations: usize) {
323        self.max_evaluations = max_evaluations.max(1);
324    }
325
326    /// Run the search behind the configured [`TuningStrategy`].
327    ///
328    /// `evaluate` measures one candidate parameter set; it is called at most
329    /// [`AdaptiveTuner::max_evaluations`] times. The search maximizes
330    /// [`TuningObservation::performance`] and stops early once a candidate
331    /// reaches [`AdaptiveTuner::performance_target`].
332    ///
333    /// The previous best candidate is used as the starting point for greedy
334    /// search, but the recorded best *performance* is reset first: the objective
335    /// a caller measures now (a different workload, a different platform) is not
336    /// comparable with one measured before, and keeping the old number would
337    /// silently veto every new measurement.
338    pub fn tune<F>(&mut self, mut evaluate: F) -> Result<TuningOutcome<A>>
339    where
340        F: FnMut(&HashMap<String, A>) -> Result<TuningObservation<A>>,
341    {
342        if self.parameters.is_empty() {
343            return Err(OptimError::InvalidConfig(
344                "adaptive tuning needs at least one tunable parameter; register \
345                 one with AdaptiveTuner::add_parameter"
346                    .to_string(),
347            ));
348        }
349
350        self.best_performance = None;
351        let mut budget = self.max_evaluations;
352        let evaluations_before = self.total_evaluations;
353
354        let strategy = self.strategy.clone();
355        let target_reached = match strategy {
356            TuningStrategy::GridSearch { resolution } => {
357                self.tune_grid_search(resolution, &mut budget, &mut evaluate)?
358            }
359            TuningStrategy::Greedy {
360                step_fraction,
361                max_rounds,
362            } => self.tune_greedy(step_fraction, max_rounds, &mut budget, &mut evaluate)?,
363            TuningStrategy::BayesianOptimization { .. } => {
364                return Err(OptimError::UnsupportedOperation(
365                    "TuningStrategy::BayesianOptimization needs a surrogate model \
366                     (a Gaussian process over the tuning history) and an acquisition \
367                     optimizer, neither of which optirs-core provides; use GridSearch \
368                     or Greedy"
369                        .to_string(),
370                ));
371            }
372            TuningStrategy::GeneticAlgorithm {
373                population_size,
374                generations,
375            } => self.tune_genetic(population_size, generations, &mut budget, &mut evaluate)?,
376            TuningStrategy::ReinforcementLearning { .. } => {
377                return Err(OptimError::UnsupportedOperation(
378                    "TuningStrategy::ReinforcementLearning needs an environment model \
379                     and a policy to train against it, neither of which optirs-core \
380                     provides; use GridSearch or Greedy"
381                        .to_string(),
382                ));
383            }
384        };
385
386        match self.best_performance {
387            Some(best_performance) => Ok(TuningOutcome {
388                best_parameters: self.current_params.clone(),
389                best_performance,
390                evaluations: self.total_evaluations - evaluations_before,
391                target_reached,
392            }),
393            None => Err(OptimError::InvalidConfig(
394                "adaptive tuning evaluated no candidate: the evaluation budget is \
395                 exhausted before the first measurement"
396                    .to_string(),
397            )),
398        }
399    }
400
401    /// Exhaustive search over an evenly spaced grid.
402    ///
403    /// Returns whether the target was reached.
404    fn tune_grid_search<F>(
405        &mut self,
406        resolution: usize,
407        budget: &mut usize,
408        evaluate: &mut F,
409    ) -> Result<bool>
410    where
411        F: FnMut(&HashMap<String, A>) -> Result<TuningObservation<A>>,
412    {
413        if resolution == 0 {
414            return Err(OptimError::InvalidConfig(
415                "TuningStrategy::GridSearch needs a resolution of at least 1".to_string(),
416            ));
417        }
418
419        // Reject an oversized grid instead of silently exploring a truncated
420        // prefix of it, which would not be a grid search at all.
421        let mut total: usize = 1;
422        for _ in 0..self.parameters.len() {
423            total = total.checked_mul(resolution).ok_or_else(|| {
424                OptimError::InvalidConfig(format!(
425                    "grid search over {} parameters at resolution {resolution} overflows",
426                    self.parameters.len()
427                ))
428            })?;
429        }
430        if total > *budget {
431            return Err(OptimError::InvalidConfig(format!(
432                "grid search over {} parameters at resolution {resolution} needs \
433                 {total} evaluations but the budget is {budget}; lower the \
434                 resolution or raise it with set_max_evaluations",
435                self.parameters.len()
436            )));
437        }
438
439        let axes: Vec<Vec<A>> = self
440            .parameters
441            .iter()
442            .map(|parameter| grid_axis(parameter, resolution))
443            .collect();
444
445        for index in 0..total {
446            let mut candidate = HashMap::with_capacity(self.parameters.len());
447            let mut remaining = index;
448            for (parameter, axis) in self.parameters.iter().zip(axes.iter()) {
449                let position = remaining % axis.len();
450                remaining /= axis.len();
451                candidate.insert(parameter.name.clone(), axis[position]);
452            }
453
454            let performance = self.evaluate_candidate(&candidate, budget, evaluate)?;
455            match performance {
456                None => return Ok(false),
457                Some(performance) if performance >= self.performance_target => return Ok(true),
458                Some(_) => {}
459            }
460        }
461
462        Ok(false)
463    }
464
465    /// Coordinate-wise hill climbing with a step that halves on a failed sweep.
466    ///
467    /// Returns whether the target was reached.
468    fn tune_greedy<F>(
469        &mut self,
470        step_fraction: f64,
471        max_rounds: usize,
472        budget: &mut usize,
473        evaluate: &mut F,
474    ) -> Result<bool>
475    where
476        F: FnMut(&HashMap<String, A>) -> Result<TuningObservation<A>>,
477    {
478        if !step_fraction.is_finite() || step_fraction <= 0.0 {
479            return Err(OptimError::InvalidConfig(
480                "TuningStrategy::Greedy needs a positive, finite step_fraction".to_string(),
481            ));
482        }
483
484        let mut incumbent = self.starting_point();
485        match self.evaluate_candidate(&incumbent, budget, evaluate)? {
486            None => return Ok(false),
487            Some(performance) if performance >= self.performance_target => return Ok(true),
488            Some(_) => {}
489        }
490
491        // Cloned once so the sweep can call `&mut self` methods while iterating
492        // the search space in its (deterministic) registration order.
493        let parameters = self.parameters.clone();
494        let mut fraction = step_fraction;
495        for _ in 0..max_rounds.max(1) {
496            let mut improved = false;
497
498            for parameter in &parameters {
499                let current = incumbent
500                    .get(&parameter.name)
501                    .copied()
502                    .unwrap_or_else(|| parameter.midpoint());
503                let step = parameter.span() * crate::utils::scalar_or(fraction, A::zero());
504
505                for direction in [A::one(), -A::one()] {
506                    let proposal = parameter.clamp(current + step * direction);
507                    if proposal == current {
508                        continue;
509                    }
510
511                    let mut candidate = incumbent.clone();
512                    candidate.insert(parameter.name.clone(), proposal);
513
514                    let best_before = self.best_performance;
515                    let performance = self.evaluate_candidate(&candidate, budget, evaluate)?;
516                    match performance {
517                        None => return Ok(false),
518                        Some(performance) => {
519                            // `evaluate_candidate` only replaces the recorded
520                            // best on a strict improvement, so comparing against
521                            // the pre-evaluation best is what decides whether the
522                            // incumbent moves.
523                            if best_before.is_none_or(|best| performance > best) {
524                                incumbent = candidate;
525                                improved = true;
526                            }
527                            if performance >= self.performance_target {
528                                return Ok(true);
529                            }
530                        }
531                    }
532                }
533            }
534
535            if !improved {
536                fraction *= 0.5;
537                if fraction < MIN_STEP_FRACTION {
538                    break;
539                }
540            }
541        }
542
543        Ok(false)
544    }
545
546    /// Elitist real-coded genetic search: tournament selection, BLX-alpha
547    /// crossover and bounded uniform mutation, seeded from
548    /// [`AdaptiveTuner::random_seed`] so a run is reproducible.
549    ///
550    /// Unlike greedy search this can leave a local optimum, which is the reason
551    /// to reach for it on a multi-modal objective; unlike grid search its cost
552    /// does not grow exponentially with the number of parameters.
553    ///
554    /// Returns whether the target was reached.
555    fn tune_genetic<F>(
556        &mut self,
557        population_size: usize,
558        generations: usize,
559        budget: &mut usize,
560        evaluate: &mut F,
561    ) -> Result<bool>
562    where
563        F: FnMut(&HashMap<String, A>) -> Result<TuningObservation<A>>,
564    {
565        if population_size < 2 {
566            return Err(OptimError::InvalidConfig(
567                "TuningStrategy::GeneticAlgorithm needs a population of at least 2 \
568                 so selection has something to choose between"
569                    .to_string(),
570            ));
571        }
572        if generations == 0 {
573            return Err(OptimError::InvalidConfig(
574                "TuningStrategy::GeneticAlgorithm needs at least one generation".to_string(),
575            ));
576        }
577
578        let mut rng = seeded_rng(self.random_seed);
579        let parameters = self.parameters.clone();
580
581        // The incumbent seeds individual 0, so a genetic run can only improve on
582        // what an earlier search already found.
583        let mut population: Vec<Vec<A>> = Vec::with_capacity(population_size);
584        let start = self.starting_point();
585        population.push(
586            parameters
587                .iter()
588                .map(|parameter| {
589                    start
590                        .get(&parameter.name)
591                        .copied()
592                        .unwrap_or_else(|| parameter.midpoint())
593                })
594                .collect(),
595        );
596        for _ in 1..population_size {
597            population.push(
598                parameters
599                    .iter()
600                    .map(|parameter| {
601                        let fraction: f64 = rng.gen_range(0.0..1.0);
602                        parameter.clamp(
603                            parameter.minimum + parameter.span() * scalar_or(fraction, A::zero()),
604                        )
605                    })
606                    .collect(),
607            );
608        }
609
610        let mut fitness = Vec::with_capacity(population_size);
611        for individual in &population {
612            let candidate = as_candidate(&parameters, individual);
613            match self.evaluate_candidate(&candidate, budget, evaluate)? {
614                None => return Ok(false),
615                Some(performance) if performance >= self.performance_target => return Ok(true),
616                Some(performance) => fitness.push(performance),
617            }
618        }
619
620        for _ in 0..generations {
621            // Elitism: the best individual survives unchanged, so a generation
622            // can never lose ground.
623            let elite_index = best_index(&fitness);
624            let mut offspring: Vec<Vec<A>> = vec![population[elite_index].clone()];
625
626            while offspring.len() < population_size {
627                let first = tournament(&fitness, &mut rng);
628                let second = tournament(&fitness, &mut rng);
629                let mut child = Vec::with_capacity(parameters.len());
630
631                for (index, parameter) in parameters.iter().enumerate() {
632                    let left = population[first][index];
633                    let right = population[second][index];
634                    let low = if left < right { left } else { right };
635                    let high = if left < right { right } else { left };
636                    let widening = (high - low) * scalar_or(CROSSOVER_ALPHA, A::zero());
637                    let span = (high + widening) - (low - widening);
638                    let fraction: f64 = rng.gen_range(0.0..1.0);
639                    let mut value = (low - widening) + span * scalar_or(fraction, A::zero());
640
641                    if rng.gen_range(0.0..1.0) < MUTATION_PROBABILITY {
642                        let jitter: f64 = rng.gen_range(-MUTATION_SCALE..MUTATION_SCALE);
643                        value = value + parameter.span() * scalar_or(jitter, A::zero());
644                    }
645
646                    child.push(parameter.clamp(value));
647                }
648
649                offspring.push(child);
650            }
651
652            let mut offspring_fitness = Vec::with_capacity(offspring.len());
653            for individual in &offspring {
654                let candidate = as_candidate(&parameters, individual);
655                match self.evaluate_candidate(&candidate, budget, evaluate)? {
656                    None => return Ok(false),
657                    Some(performance) if performance >= self.performance_target => {
658                        return Ok(true);
659                    }
660                    Some(performance) => offspring_fitness.push(performance),
661                }
662            }
663
664            population = offspring;
665            fitness = offspring_fitness;
666        }
667
668        Ok(false)
669    }
670
671    /// Starting candidate for a local search: the best candidate found so far
672    /// when it covers every registered parameter, otherwise the midpoint of each
673    /// range.
674    fn starting_point(&self) -> HashMap<String, A> {
675        let mut start = HashMap::with_capacity(self.parameters.len());
676        for parameter in &self.parameters {
677            let value = match self.current_params.get(&parameter.name) {
678                Some(&value) => parameter.clamp(value),
679                None => parameter.midpoint(),
680            };
681            start.insert(parameter.name.clone(), value);
682        }
683        start
684    }
685
686    /// Measure one candidate, record it, and keep it if it is the best so far.
687    ///
688    /// Returns `None` when the evaluation budget is exhausted, which ends the
689    /// search without failing it.
690    fn evaluate_candidate<F>(
691        &mut self,
692        candidate: &HashMap<String, A>,
693        budget: &mut usize,
694        evaluate: &mut F,
695    ) -> Result<Option<A>>
696    where
697        F: FnMut(&HashMap<String, A>) -> Result<TuningObservation<A>>,
698    {
699        if *budget == 0 {
700            return Ok(None);
701        }
702        *budget -= 1;
703
704        let observation = evaluate(candidate)?;
705        if !observation.performance.is_finite() {
706            return Err(OptimError::InvalidParameter(
707                "the tuning evaluator reported a non-finite performance, which \
708                 cannot be ranked"
709                    .to_string(),
710            ));
711        }
712        self.total_evaluations += 1;
713
714        self.tuning_history.push(TuningRecord {
715            parameters: candidate.clone(),
716            performance: observation.performance,
717            resource_usage: observation.resource_usage,
718            timestamp: unix_timestamp_secs(),
719        });
720        if self.tuning_history.len() > MAX_TUNING_HISTORY {
721            self.tuning_history.remove(0);
722        }
723
724        let improved = match self.best_performance {
725            Some(best) => observation.performance > best,
726            None => true,
727        };
728        if improved {
729            self.best_performance = Some(observation.performance);
730            self.current_params = candidate.clone();
731        }
732
733        Ok(Some(observation.performance))
734    }
735
736    /// Best resource usage recorded among the candidates that met the
737    /// performance target, if any.
738    ///
739    /// Surfaces [`TuningRecord::resource_usage`], which is otherwise only
740    /// reachable by walking the history: the cheapest candidate that still hits
741    /// the target is usually the one a caller wants to deploy.
742    pub fn cheapest_candidate_meeting_target(&self) -> Option<&TuningRecord<A>> {
743        self.tuning_history
744            .iter()
745            .filter(|record| record.performance >= self.performance_target)
746            .min_by(|a, b| total_order(&a.resource_usage, &b.resource_usage))
747    }
748}
749
750/// Pair a genetic individual's coordinates back up with their parameter names.
751fn as_candidate<A: Float>(
752    parameters: &[TunableParameter<A>],
753    individual: &[A],
754) -> HashMap<String, A> {
755    parameters
756        .iter()
757        .zip(individual.iter())
758        .map(|(parameter, &value)| (parameter.name.clone(), value))
759        .collect()
760}
761
762/// Index of the fittest individual. Ties go to the earliest index, so the choice
763/// does not depend on the sort being stable.
764fn best_index<A: Float>(fitness: &[A]) -> usize {
765    let mut best = 0;
766    for (index, value) in fitness.iter().enumerate() {
767        if total_order(value, &fitness[best]) == std::cmp::Ordering::Greater {
768            best = index;
769        }
770    }
771    best
772}
773
774/// Tournament selection: draw [`TOURNAMENT_SIZE`] individuals and return the
775/// fittest one's index.
776fn tournament<A: Float>(fitness: &[A], rng: &mut CoreRandom<StdRng>) -> usize {
777    let mut best = rng.gen_range(0..fitness.len());
778    for _ in 1..TOURNAMENT_SIZE {
779        let challenger = rng.gen_range(0..fitness.len());
780        if total_order(&fitness[challenger], &fitness[best]) == std::cmp::Ordering::Greater {
781            best = challenger;
782        }
783    }
784    best
785}
786
787/// Evenly spaced grid values for one parameter.
788///
789/// A resolution of 1 collapses to the midpoint rather than to a bound, so a
790/// single-point grid probes the middle of the authorised range instead of its
791/// edge.
792fn grid_axis<A: Float>(parameter: &TunableParameter<A>, resolution: usize) -> Vec<A> {
793    if resolution <= 1 {
794        return vec![parameter.midpoint()];
795    }
796    let divisor = crate::utils::scalar_or(resolution - 1, A::one());
797    (0..resolution)
798        .map(|index| {
799            let fraction = crate::utils::scalar_or(index, A::zero()) / divisor;
800            parameter.clamp(parameter.minimum + parameter.span() * fraction)
801        })
802        .collect()
803}
804
805/// Seconds since the Unix epoch, clamped to the epoch on a mis-set clock rather
806/// than panicking a tuning run.
807fn unix_timestamp_secs() -> u64 {
808    SystemTime::now()
809        .duration_since(UNIX_EPOCH)
810        .map(|elapsed| elapsed.as_secs())
811        .unwrap_or(0)
812}
813
814/// Read a tuned parameter back as an `f64`, for callers that need to map it onto
815/// an integer configuration field such as a batch size.
816pub fn tuned_value_as_f64<A: Float>(params: &HashMap<String, A>, name: &str) -> Option<f64> {
817    params.get(name).copied().and_then(|v| try_f64(v).ok())
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    /// A unimodal synthetic objective peaking at `x = 3, y = -1`.
825    fn synthetic_objective(params: &HashMap<String, f64>) -> Result<TuningObservation<f64>> {
826        let x = params.get("x").copied().unwrap_or(0.0);
827        let y = params.get("y").copied().unwrap_or(0.0);
828        let performance = 10.0 - (x - 3.0).powi(2) - (y + 1.0).powi(2);
829        Ok(TuningObservation {
830            performance,
831            resource_usage: x.abs() + y.abs(),
832        })
833    }
834
835    fn tuner_with_space() -> AdaptiveTuner<f64> {
836        let mut tuner = AdaptiveTuner::new();
837        tuner
838            .add_parameter(TunableParameter::new("x", -10.0, 10.0).expect("valid range"))
839            .expect("register x");
840        tuner
841            .add_parameter(TunableParameter::new("y", -10.0, 10.0).expect("valid range"))
842            .expect("register y");
843        tuner
844    }
845
846    /// Greedy hill climbing must actually improve the objective and leave the
847    /// winner in `current_params`.
848    #[test]
849    fn greedy_search_improves_a_synthetic_objective() {
850        let mut tuner = tuner_with_space();
851        tuner.set_performance_target(9.99);
852        tuner.set_strategy(TuningStrategy::Greedy {
853            step_fraction: 0.25,
854            max_rounds: 40,
855        });
856
857        let start = synthetic_objective(&tuner.starting_point()).expect("start");
858        let outcome = tuner.tune(synthetic_objective).expect("tuning must run");
859
860        assert!(
861            outcome.best_performance > start.performance,
862            "greedy search did not improve on the starting point ({} -> {})",
863            start.performance,
864            outcome.best_performance
865        );
866        assert!(outcome.evaluations > 1, "nothing was searched");
867        assert_eq!(tuner.total_evaluations(), outcome.evaluations);
868        assert_eq!(
869            tuner.tuning_history().len(),
870            outcome.evaluations,
871            "every measurement must be recorded"
872        );
873        assert_eq!(
874            tuner.current_params(),
875            &outcome.best_parameters,
876            "current_params must hold the best candidate"
877        );
878
879        let x = tuner.current_params().get("x").copied().expect("x tuned");
880        let y = tuner.current_params().get("y").copied().expect("y tuned");
881        assert!(
882            (x - 3.0).abs() < 1.0,
883            "x = {x} did not approach the optimum"
884        );
885        assert!(
886            (y + 1.0).abs() < 1.0,
887            "y = {y} did not approach the optimum"
888        );
889    }
890
891    /// Grid search must sweep the whole grid and find its best point.
892    #[test]
893    fn grid_search_sweeps_the_configured_grid() {
894        let mut tuner = tuner_with_space();
895        tuner.set_performance_target(1e9); // unreachable: force a full sweep
896        tuner.set_strategy(TuningStrategy::GridSearch { resolution: 11 });
897        tuner.set_max_evaluations(200);
898
899        let outcome = tuner.tune(synthetic_objective).expect("tuning must run");
900
901        assert_eq!(outcome.evaluations, 121, "11 x 11 grid");
902        assert!(!outcome.target_reached);
903        // The grid contains x = 2 and x = 4 but not 3; y = -2 and y = 0 but not
904        // -1. The best grid point is therefore one step off the true optimum.
905        let x = outcome.best_parameters.get("x").copied().expect("x");
906        let y = outcome.best_parameters.get("y").copied().expect("y");
907        assert!((x - 2.0).abs() < 1e-9 || (x - 4.0).abs() < 1e-9, "x = {x}");
908        assert!((y + 2.0).abs() < 1e-9 || y.abs() < 1e-9, "y = {y}");
909    }
910
911    /// Reaching the target must stop the search early.
912    #[test]
913    fn the_search_stops_once_the_target_is_reached() {
914        let mut tuner = tuner_with_space();
915        // The first grid point is the corner (-10, -10), worth -240; a target of
916        // -1000 is therefore met immediately.
917        tuner.set_performance_target(-1000.0);
918        tuner.set_strategy(TuningStrategy::GridSearch { resolution: 5 });
919
920        let outcome = tuner.tune(synthetic_objective).expect("tuning must run");
921        assert!(outcome.target_reached);
922        assert_eq!(outcome.evaluations, 1, "the target was met immediately");
923    }
924
925    /// A grid that does not fit the evaluation budget must be reported, not
926    /// silently truncated to a prefix.
927    #[test]
928    fn an_oversized_grid_is_reported() {
929        let mut tuner = tuner_with_space();
930        tuner.set_strategy(TuningStrategy::GridSearch { resolution: 40 });
931        tuner.set_max_evaluations(100);
932
933        let error = tuner
934            .tune(synthetic_objective)
935            .expect_err("1600 evaluations must not fit a budget of 100");
936        assert!(matches!(error, OptimError::InvalidConfig(_)), "{error:?}");
937    }
938
939    /// Tuning with no registered parameters is a configuration error, not a
940    /// silent no-op.
941    #[test]
942    fn tuning_without_a_search_space_is_reported() {
943        let mut tuner: AdaptiveTuner<f64> = AdaptiveTuner::new();
944        let error = tuner
945            .tune(synthetic_objective)
946            .expect_err("an empty search space must be reported");
947        assert!(matches!(error, OptimError::InvalidConfig(_)), "{error:?}");
948    }
949
950    /// The genetic search must improve the objective and be reproducible.
951    #[test]
952    fn genetic_search_improves_a_synthetic_objective_reproducibly() {
953        let run = || {
954            let mut tuner = tuner_with_space();
955            tuner.set_performance_target(9.999);
956            tuner.set_strategy(TuningStrategy::GeneticAlgorithm {
957                population_size: 12,
958                generations: 20,
959            });
960            tuner.set_max_evaluations(1000);
961            let outcome = tuner.tune(synthetic_objective).expect("tuning must run");
962            (tuner, outcome)
963        };
964
965        let (tuner, outcome) = run();
966        let start = synthetic_objective(&HashMap::from([
967            ("x".to_string(), 0.0),
968            ("y".to_string(), 0.0),
969        ]))
970        .expect("start");
971        assert!(
972            outcome.best_performance > start.performance,
973            "genetic search did not improve on the midpoint ({} -> {})",
974            start.performance,
975            outcome.best_performance
976        );
977        for (name, &value) in &outcome.best_parameters {
978            assert!(
979                (-10.0..=10.0).contains(&value),
980                "{name} = {value} left its authorised range"
981            );
982        }
983
984        let (_, repeat) = run();
985        assert_eq!(
986            outcome.best_parameters.len(),
987            repeat.best_parameters.len(),
988            "the same seed must produce the same search"
989        );
990        assert!(
991            (outcome.best_performance - repeat.best_performance).abs() < 1e-12,
992            "the same seed produced a different result: {} vs {}",
993            outcome.best_performance,
994            repeat.best_performance
995        );
996        assert_eq!(tuner.random_seed(), DEFAULT_TUNING_SEED);
997    }
998
999    /// A degenerate population or generation count is a configuration error.
1000    #[test]
1001    fn a_degenerate_genetic_configuration_is_reported() {
1002        for strategy in [
1003            TuningStrategy::GeneticAlgorithm {
1004                population_size: 1,
1005                generations: 5,
1006            },
1007            TuningStrategy::GeneticAlgorithm {
1008                population_size: 10,
1009                generations: 0,
1010            },
1011        ] {
1012            let mut tuner = tuner_with_space();
1013            tuner.set_strategy(strategy.clone());
1014            let error = tuner
1015                .tune(synthetic_objective)
1016                .expect_err("a degenerate genetic configuration must be reported");
1017            assert!(
1018                matches!(error, OptimError::InvalidConfig(_)),
1019                "{strategy:?}: {error:?}"
1020            );
1021        }
1022    }
1023
1024    /// The strategies with no implementation behind them must say so instead of
1025    /// quietly behaving like a different search.
1026    #[test]
1027    fn unimplemented_strategies_report_what_is_missing() {
1028        for strategy in [
1029            TuningStrategy::BayesianOptimization { num_samples: 10 },
1030            TuningStrategy::ReinforcementLearning {
1031                exploration_rate: 0.1,
1032            },
1033        ] {
1034            let mut tuner = tuner_with_space();
1035            tuner.set_strategy(strategy.clone());
1036            let error = tuner
1037                .tune(synthetic_objective)
1038                .expect_err("an unimplemented strategy must not fabricate success");
1039            assert!(
1040                matches!(error, OptimError::UnsupportedOperation(_)),
1041                "{strategy:?}: {error:?}"
1042            );
1043        }
1044    }
1045
1046    /// Every measurement must carry its resource usage, so the cheapest
1047    /// candidate meeting the target can be recovered.
1048    #[test]
1049    fn resource_usage_is_recorded_with_every_measurement() {
1050        let mut tuner = tuner_with_space();
1051        tuner.set_strategy(TuningStrategy::GridSearch { resolution: 5 });
1052        tuner.set_max_evaluations(100);
1053        // An unreachable target forces the full sweep, so the whole history is
1054        // available to inspect.
1055        tuner.set_performance_target(1e9);
1056        tuner.tune(synthetic_objective).expect("tuning must run");
1057
1058        assert_eq!(tuner.tuning_history().len(), 25);
1059        for record in tuner.tuning_history() {
1060            let expected: f64 = record
1061                .parameters
1062                .values()
1063                .map(|value| value.abs())
1064                .sum::<f64>();
1065            assert!(
1066                (record.resource_usage - expected).abs() < 1e-9,
1067                "resource usage was not recorded from the evaluator"
1068            );
1069            assert!(record.timestamp > 0, "timestamp must be recorded");
1070        }
1071
1072        tuner.set_performance_target(5.0);
1073        let cheapest = tuner
1074            .cheapest_candidate_meeting_target()
1075            .expect("some grid point beats 5.0");
1076        assert!(cheapest.performance >= 5.0);
1077    }
1078
1079    /// A non-finite measurement cannot be ranked and must be reported.
1080    #[test]
1081    fn a_non_finite_measurement_is_reported() {
1082        let mut tuner = tuner_with_space();
1083        let error = tuner
1084            .tune(|_| {
1085                Ok(TuningObservation {
1086                    performance: f64::NAN,
1087                    resource_usage: 0.0,
1088                })
1089            })
1090            .expect_err("NaN performance must be reported");
1091        assert!(
1092            matches!(error, OptimError::InvalidParameter(_)),
1093            "{error:?}"
1094        );
1095    }
1096
1097    /// An invalid search range must be rejected at registration time.
1098    #[test]
1099    fn an_invalid_range_is_rejected() {
1100        assert!(TunableParameter::new("x", 1.0, 0.0).is_err());
1101        assert!(TunableParameter::<f64>::new("", 0.0, 1.0).is_err());
1102        assert!(TunableParameter::new("x", f64::NAN, 1.0).is_err());
1103
1104        let mut tuner = tuner_with_space();
1105        assert!(
1106            tuner
1107                .add_parameter(TunableParameter::new("x", 0.0, 1.0).expect("valid"))
1108                .is_err(),
1109            "a duplicate parameter name must be rejected"
1110        );
1111    }
1112}