1use 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
19const DEFAULT_MAX_EVALUATIONS: usize = 256;
21
22const MAX_TUNING_HISTORY: usize = 1000;
24
25const MIN_STEP_FRACTION: f64 = 1e-4;
31
32pub const DEFAULT_TUNING_SEED: u64 = 0x0071_7250_5f74_756e;
39
40const TOURNAMENT_SIZE: usize = 2;
42
43const CROSSOVER_ALPHA: f64 = 0.5;
47
48const MUTATION_PROBABILITY: f64 = 0.2;
50
51const MUTATION_SCALE: f64 = 0.1;
53
54#[derive(Debug, Clone)]
56pub struct TuningRecord<A: Float> {
57 pub parameters: HashMap<String, A>,
59 pub performance: A,
61 pub resource_usage: A,
63 pub timestamp: u64,
65}
66
67#[derive(Debug, Clone)]
69pub enum TuningStrategy {
70 GridSearch {
72 resolution: usize,
74 },
75 Greedy {
77 step_fraction: f64,
79 max_rounds: usize,
81 },
82 BayesianOptimization {
84 num_samples: usize,
86 },
87 GeneticAlgorithm {
89 population_size: usize,
91 generations: usize,
93 },
94 ReinforcementLearning {
96 exploration_rate: f64,
98 },
99}
100
101#[derive(Debug, Clone)]
103pub struct TunableParameter<A: Float> {
104 pub name: String,
106 pub minimum: A,
108 pub maximum: A,
110}
111
112impl<A: Float> TunableParameter<A> {
113 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 pub fn span(&self) -> A {
144 self.maximum - self.minimum
145 }
146
147 pub fn midpoint(&self) -> A {
150 self.minimum + self.span() / (A::one() + A::one())
151 }
152
153 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#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct TuningObservation<A: Float> {
168 pub performance: A,
172 pub resource_usage: A,
176}
177
178#[derive(Debug, Clone)]
180pub struct TuningOutcome<A: Float> {
181 pub best_parameters: HashMap<String, A>,
183 pub best_performance: A,
185 pub evaluations: usize,
187 pub target_reached: bool,
189}
190
191#[derive(Debug)]
193pub struct AdaptiveTuner<A: Float> {
194 performance_target: A,
196 strategy: TuningStrategy,
198 parameters: Vec<TunableParameter<A>>,
201 current_params: HashMap<String, A>,
203 tuning_history: Vec<TuningRecord<A>>,
205 best_performance: Option<A>,
207 max_evaluations: usize,
209 total_evaluations: usize,
212 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 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 pub fn random_seed(&self) -> u64 {
247 self.random_seed
248 }
249
250 pub fn set_random_seed(&mut self, seed: u64) {
255 self.random_seed = seed;
256 }
257
258 pub fn performance_target(&self) -> A {
260 self.performance_target
261 }
262
263 pub fn set_performance_target(&mut self, target: A) {
265 self.performance_target = target;
266 }
267
268 pub fn strategy(&self) -> &TuningStrategy {
270 &self.strategy
271 }
272
273 pub fn set_strategy(&mut self, strategy: TuningStrategy) {
275 self.strategy = strategy;
276 }
277
278 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 pub fn parameters(&self) -> &[TunableParameter<A>] {
292 &self.parameters
293 }
294
295 pub fn current_params(&self) -> &HashMap<String, A> {
297 &self.current_params
298 }
299
300 pub fn best_performance(&self) -> Option<A> {
302 self.best_performance
303 }
304
305 pub fn tuning_history(&self) -> &[TuningRecord<A>] {
308 &self.tuning_history
309 }
310
311 pub fn total_evaluations(&self) -> usize {
313 self.total_evaluations
314 }
315
316 pub fn max_evaluations(&self) -> usize {
318 self.max_evaluations
319 }
320
321 pub fn set_max_evaluations(&mut self, max_evaluations: usize) {
323 self.max_evaluations = max_evaluations.max(1);
324 }
325
326 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 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 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 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 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 ¶meters {
499 let current = incumbent
500 .get(¶meter.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 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 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 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(¶meter.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(¶meters, 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 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(¶meters, 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 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(¶meter.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 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 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
750fn 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
762fn 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
774fn 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
787fn 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
805fn unix_timestamp_secs() -> u64 {
808 SystemTime::now()
809 .duration_since(UNIX_EPOCH)
810 .map(|elapsed| elapsed.as_secs())
811 .unwrap_or(0)
812}
813
814pub 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 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 #[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 #[test]
893 fn grid_search_sweeps_the_configured_grid() {
894 let mut tuner = tuner_with_space();
895 tuner.set_performance_target(1e9); 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 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 #[test]
913 fn the_search_stops_once_the_target_is_reached() {
914 let mut tuner = tuner_with_space();
915 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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}