1use std::time::Instant;
11
12use rand::Rng;
13use rand_distr::{Bernoulli, Distribution, Normal};
14
15use crate::diagnostics::{EvolutionResult, EvolutionStats, GenerationStats, TimingStats};
16use crate::error::EvolutionError;
17use crate::fitness::traits::{Fitness, FitnessValue};
18use crate::genome::bit_string::BitString;
19use crate::genome::bounds::MultiBounds;
20use crate::genome::real_vector::RealVector;
21use crate::genome::traits::{BinaryGenome, EvolutionaryGenome, RealValuedGenome};
22use crate::population::individual::Individual;
23use crate::population::population::Population;
24use crate::termination::{EvolutionState, MaxGenerations, TerminationCriterion};
25
26fn select_top_genomes<G, F>(population: &Population<G, F>, count: usize) -> Vec<&G>
34where
35 G: EvolutionaryGenome,
36 F: FitnessValue,
37{
38 let mut ranked: Vec<&Individual<G, F>> = population.iter().collect();
39 ranked.sort_by(|a, b| {
40 if a.is_better_than(b) {
41 std::cmp::Ordering::Less
42 } else if b.is_better_than(a) {
43 std::cmp::Ordering::Greater
44 } else {
45 std::cmp::Ordering::Equal
46 }
47 });
48 ranked
49 .into_iter()
50 .take(count)
51 .map(|ind| ind.genome())
52 .collect()
53}
54
55#[derive(Clone, Debug)]
63pub struct UMDAConfig {
64 pub population_size: usize,
66 pub selection_ratio: f64,
69 pub min_variance: f64,
71 pub prob_bounds: (f64, f64),
74 pub learning_rate: f64,
78}
79
80impl Default for UMDAConfig {
81 fn default() -> Self {
82 Self {
83 population_size: 100,
84 selection_ratio: 0.5,
85 min_variance: 0.01,
86 prob_bounds: (0.01, 0.99),
87 learning_rate: 1.0,
88 }
89 }
90}
91
92impl UMDAConfig {
93 pub fn validate(&self) -> Result<(), EvolutionError> {
96 if self.population_size == 0 {
97 return Err(EvolutionError::Configuration(
98 "population_size must be >= 1".to_string(),
99 ));
100 }
101 if !(self.selection_ratio > 0.0 && self.selection_ratio <= 1.0) {
102 return Err(EvolutionError::Configuration(format!(
103 "selection_ratio must be in (0.0, 1.0], got {}",
104 self.selection_ratio
105 )));
106 }
107 if !(self.learning_rate > 0.0 && self.learning_rate <= 1.0) {
108 return Err(EvolutionError::Configuration(format!(
109 "learning_rate must be in (0.0, 1.0], got {}",
110 self.learning_rate
111 )));
112 }
113 if self.min_variance < 0.0 {
114 return Err(EvolutionError::Configuration(format!(
115 "min_variance must be >= 0.0, got {}",
116 self.min_variance
117 )));
118 }
119 let (pmin, pmax) = self.prob_bounds;
120 if !(pmin > 0.0 && pmin <= pmax && pmax < 1.0) {
121 return Err(EvolutionError::Configuration(format!(
122 "prob_bounds must satisfy 0.0 < min <= max < 1.0, got ({pmin}, {pmax})"
123 )));
124 }
125 Ok(())
126 }
127}
128
129pub struct UMDABuilder<G, F, Fit, Term>
131where
132 G: EvolutionaryGenome,
133 F: FitnessValue,
134{
135 config: UMDAConfig,
136 bounds: Option<MultiBounds>,
137 fitness: Option<Fit>,
138 termination: Option<Term>,
139 _phantom: std::marker::PhantomData<(G, F)>,
140}
141
142impl<G, F> UMDABuilder<G, F, (), ()>
143where
144 G: EvolutionaryGenome,
145 F: FitnessValue,
146{
147 pub fn new() -> Self {
149 Self {
150 config: UMDAConfig::default(),
151 bounds: None,
152 fitness: None,
153 termination: None,
154 _phantom: std::marker::PhantomData,
155 }
156 }
157}
158
159impl<G, F> Default for UMDABuilder<G, F, (), ()>
160where
161 G: EvolutionaryGenome,
162 F: FitnessValue,
163{
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl<G, F, Fit, Term> UMDABuilder<G, F, Fit, Term>
170where
171 G: EvolutionaryGenome,
172 F: FitnessValue,
173{
174 pub fn population_size(mut self, size: usize) -> Self {
176 self.config.population_size = size;
177 self
178 }
179
180 pub fn selection_ratio(mut self, ratio: f64) -> Self {
182 self.config.selection_ratio = ratio;
183 self
184 }
185
186 pub fn min_variance(mut self, variance: f64) -> Self {
188 self.config.min_variance = variance;
189 self
190 }
191
192 pub fn prob_bounds(mut self, min: f64, max: f64) -> Self {
195 self.config.prob_bounds = (min, max);
196 self
197 }
198
199 pub fn learning_rate(mut self, rate: f64) -> Self {
202 self.config.learning_rate = rate;
203 self
204 }
205
206 pub fn bounds(mut self, bounds: MultiBounds) -> Self {
208 self.bounds = Some(bounds);
209 self
210 }
211
212 pub fn fitness<NewFit>(self, fitness: NewFit) -> UMDABuilder<G, F, NewFit, Term>
214 where
215 NewFit: Fitness<Genome = G, Value = F>,
216 {
217 UMDABuilder {
218 config: self.config,
219 bounds: self.bounds,
220 fitness: Some(fitness),
221 termination: self.termination,
222 _phantom: std::marker::PhantomData,
223 }
224 }
225
226 pub fn termination<NewTerm>(self, termination: NewTerm) -> UMDABuilder<G, F, Fit, NewTerm>
228 where
229 NewTerm: TerminationCriterion<G, F>,
230 {
231 UMDABuilder {
232 config: self.config,
233 bounds: self.bounds,
234 fitness: self.fitness,
235 termination: Some(termination),
236 _phantom: std::marker::PhantomData,
237 }
238 }
239
240 pub fn max_generations(self, max: usize) -> UMDABuilder<G, F, Fit, MaxGenerations> {
242 UMDABuilder {
243 config: self.config,
244 bounds: self.bounds,
245 fitness: self.fitness,
246 termination: Some(MaxGenerations::new(max)),
247 _phantom: std::marker::PhantomData,
248 }
249 }
250}
251
252#[derive(Clone, Debug)]
258pub struct ContinuousUnivariateModel {
259 pub means: Vec<f64>,
261 pub variances: Vec<f64>,
263}
264
265impl ContinuousUnivariateModel {
266 pub const MAX_REJECTION_RETRIES: usize = 100;
268
269 pub fn from_bounds(bounds: &MultiBounds) -> Self {
271 let means: Vec<f64> = bounds.bounds.iter().map(|b| b.center()).collect();
272 let variances: Vec<f64> = bounds
273 .bounds
274 .iter()
275 .map(|b| (b.range() / 4.0).powi(2))
276 .collect();
277 Self { means, variances }
278 }
279
280 pub fn update(&mut self, selected: &[&RealVector], config: &UMDAConfig) {
282 let n = selected.len() as f64;
283 if n == 0.0 {
284 return;
285 }
286
287 for i in 0..self.means.len() {
288 let mean: f64 = selected.iter().map(|g| g.genes()[i]).sum::<f64>() / n;
290
291 let sum_sq: f64 = selected
295 .iter()
296 .map(|g| (g.genes()[i] - mean).powi(2))
297 .sum::<f64>();
298 let variance: f64 = if n > 1.0 { sum_sq / (n - 1.0) } else { 0.0 };
299
300 self.means[i] =
302 config.learning_rate * mean + (1.0 - config.learning_rate) * self.means[i];
303 self.variances[i] = config.learning_rate * variance.max(config.min_variance)
304 + (1.0 - config.learning_rate) * self.variances[i];
305 }
306 }
307
308 pub fn sample<R: Rng>(&self, bounds: &MultiBounds, rng: &mut R) -> RealVector {
317 let genes: Vec<f64> = self
318 .means
319 .iter()
320 .zip(self.variances.iter())
321 .zip(bounds.bounds.iter())
322 .map(|((mean, var), bound)| {
323 let normal =
324 Normal::new(*mean, var.sqrt()).unwrap_or(Normal::new(*mean, 0.1).unwrap());
325
326 let mut value = normal.sample(rng);
327 let mut retries = 0;
328 while (value < bound.min || value > bound.max)
329 && retries < Self::MAX_REJECTION_RETRIES
330 {
331 value = normal.sample(rng);
332 retries += 1;
333 }
334
335 value.clamp(bound.min, bound.max)
339 })
340 .collect();
341
342 RealVector::new(genes)
343 }
344}
345
346impl<F, Fit, Term> UMDABuilder<RealVector, F, Fit, Term>
347where
348 F: FitnessValue + Send,
349 Fit: Fitness<Genome = RealVector, Value = F> + Sync,
350 Term: TerminationCriterion<RealVector, F>,
351{
352 pub fn build(self) -> Result<ContinuousUMDA<F, Fit, Term>, EvolutionError> {
354 self.config.validate()?;
356
357 let bounds = self
358 .bounds
359 .ok_or_else(|| EvolutionError::Configuration("Bounds must be specified".to_string()))?;
360
361 let fitness = self.fitness.ok_or_else(|| {
362 EvolutionError::Configuration("Fitness function must be specified".to_string())
363 })?;
364
365 let termination = self.termination.ok_or_else(|| {
366 EvolutionError::Configuration("Termination criterion must be specified".to_string())
367 })?;
368
369 Ok(ContinuousUMDA {
370 config: self.config,
371 bounds,
372 fitness,
373 termination,
374 _phantom: std::marker::PhantomData,
375 })
376 }
377}
378
379pub struct ContinuousUMDA<F, Fit, Term>
381where
382 F: FitnessValue,
383{
384 config: UMDAConfig,
385 bounds: MultiBounds,
386 fitness: Fit,
387 termination: Term,
388 _phantom: std::marker::PhantomData<F>,
389}
390
391impl<F, Fit, Term> ContinuousUMDA<F, Fit, Term>
392where
393 F: FitnessValue + Send,
394 Fit: Fitness<Genome = RealVector, Value = F> + Sync,
395 Term: TerminationCriterion<RealVector, F>,
396{
397 pub fn builder() -> UMDABuilder<RealVector, F, (), ()> {
399 UMDABuilder::new()
400 }
401
402 pub fn run<R: Rng>(
404 &self,
405 rng: &mut R,
406 ) -> Result<EvolutionResult<RealVector, F>, EvolutionError> {
407 self.run_with_model(rng).map(|(result, _model)| result)
408 }
409
410 pub fn run_with_callback<R: Rng, Cb: FnMut(usize, f64) -> bool>(
417 &self,
418 rng: &mut R,
419 on_generation: Cb,
420 ) -> Result<EvolutionResult<RealVector, F>, EvolutionError> {
421 self.run_with_model_cb(rng, on_generation)
422 .map(|(result, _model)| result)
423 }
424
425 pub fn run_with_model<R: Rng>(
429 &self,
430 rng: &mut R,
431 ) -> Result<(EvolutionResult<RealVector, F>, ContinuousUnivariateModel), EvolutionError> {
432 self.run_with_model_cb(rng, |_generation, _best_fitness| true)
434 }
435
436 fn run_with_model_cb<R: Rng, Cb: FnMut(usize, f64) -> bool>(
440 &self,
441 rng: &mut R,
442 mut on_generation: Cb,
443 ) -> Result<(EvolutionResult<RealVector, F>, ContinuousUnivariateModel), EvolutionError> {
444 let start_time = Instant::now();
445
446 let mut model = ContinuousUnivariateModel::from_bounds(&self.bounds);
448
449 let mut population: Population<RealVector, F> =
451 Population::random(self.config.population_size, &self.bounds, rng);
452 population.evaluate(&self.fitness);
453
454 let mut stats = EvolutionStats::new();
455 let mut evaluations = self.config.population_size;
456 let mut fitness_history: Vec<f64> = Vec::new();
457 let mut generation = 0usize;
458
459 let mut best = population
461 .best()
462 .ok_or(EvolutionError::EmptyPopulation)?
463 .clone();
464
465 let gen_stats = GenerationStats::from_population(&population, 0, evaluations);
467 fitness_history.push(gen_stats.best_fitness);
468 stats.record(gen_stats);
469
470 loop {
472 let state = EvolutionState {
474 generation,
475 evaluations,
476 best_fitness: best.fitness_value().to_f64(),
477 population: &population,
478 fitness_history: &fitness_history,
479 };
480
481 if self.termination.should_terminate(&state) {
482 stats.set_termination_reason(self.termination.reason());
483 break;
484 }
485
486 if !on_generation(generation, best.fitness_value().to_f64()) {
489 break;
490 }
491
492 let gen_start = Instant::now();
493
494 let select_count =
496 (self.config.population_size as f64 * self.config.selection_ratio).ceil() as usize;
497 let selected: Vec<&RealVector> = select_top_genomes(&population, select_count);
498
499 model.update(&selected, &self.config);
501
502 population = Population::with_capacity(self.config.population_size);
504 for _ in 0..self.config.population_size {
505 let genome = model.sample(&self.bounds, rng);
506 population.push(Individual::new(genome));
507 }
508
509 population.evaluate(&self.fitness);
511 evaluations += self.config.population_size;
512
513 if let Some(pop_best) = population.best() {
515 if pop_best.is_better_than(&best) {
516 best = pop_best.clone();
517 }
518 }
519
520 generation += 1;
521 population.set_generation(generation);
522
523 let timing = TimingStats::new().with_total(gen_start.elapsed());
525 let gen_stats = GenerationStats::from_population(&population, generation, evaluations)
526 .with_timing(timing);
527 fitness_history.push(gen_stats.best_fitness);
528 stats.record(gen_stats);
529 }
530
531 stats.set_runtime(start_time.elapsed());
532
533 let result =
534 EvolutionResult::new(best.genome, best.fitness.unwrap(), generation, evaluations)
535 .with_stats(stats);
536 Ok((result, model))
537 }
538}
539
540#[derive(Clone, Debug)]
546pub struct BinaryUnivariateModel {
547 pub probabilities: Vec<f64>,
549}
550
551impl BinaryUnivariateModel {
552 pub fn uniform(dimension: usize) -> Self {
554 Self {
555 probabilities: vec![0.5; dimension],
556 }
557 }
558
559 pub fn update(&mut self, selected: &[&BitString], config: &UMDAConfig) {
561 let n = selected.len() as f64;
562 if n == 0.0 {
563 return;
564 }
565
566 for i in 0..self.probabilities.len() {
567 let ones: f64 = selected
569 .iter()
570 .filter(|g| g.bits().get(i).copied().unwrap_or(false))
571 .count() as f64;
572
573 let new_prob = ones / n;
575 let bounded_prob = new_prob.clamp(config.prob_bounds.0, config.prob_bounds.1);
576 self.probabilities[i] = config.learning_rate * bounded_prob
577 + (1.0 - config.learning_rate) * self.probabilities[i];
578 }
579 }
580
581 pub fn sample<R: Rng>(&self, rng: &mut R) -> BitString {
583 let bits: Vec<bool> = self
584 .probabilities
585 .iter()
586 .map(|p| {
587 let dist = Bernoulli::new(*p).unwrap_or(Bernoulli::new(0.5).unwrap());
588 dist.sample(rng)
589 })
590 .collect();
591
592 BitString::new(bits)
593 }
594}
595
596impl<F, Fit, Term> UMDABuilder<BitString, F, Fit, Term>
597where
598 F: FitnessValue + Send,
599 Fit: Fitness<Genome = BitString, Value = F> + Sync,
600 Term: TerminationCriterion<BitString, F>,
601{
602 pub fn build(self) -> Result<BinaryUMDA<F, Fit, Term>, EvolutionError> {
604 self.config.validate()?;
606
607 let bounds = self
608 .bounds
609 .ok_or_else(|| EvolutionError::Configuration("Bounds must be specified".to_string()))?;
610
611 let fitness = self.fitness.ok_or_else(|| {
612 EvolutionError::Configuration("Fitness function must be specified".to_string())
613 })?;
614
615 let termination = self.termination.ok_or_else(|| {
616 EvolutionError::Configuration("Termination criterion must be specified".to_string())
617 })?;
618
619 Ok(BinaryUMDA {
620 config: self.config,
621 bounds,
622 fitness,
623 termination,
624 _phantom: std::marker::PhantomData,
625 })
626 }
627}
628
629pub struct BinaryUMDA<F, Fit, Term>
631where
632 F: FitnessValue,
633{
634 config: UMDAConfig,
635 bounds: MultiBounds,
636 fitness: Fit,
637 termination: Term,
638 _phantom: std::marker::PhantomData<F>,
639}
640
641impl<F, Fit, Term> BinaryUMDA<F, Fit, Term>
642where
643 F: FitnessValue + Send,
644 Fit: Fitness<Genome = BitString, Value = F> + Sync,
645 Term: TerminationCriterion<BitString, F>,
646{
647 pub fn builder() -> UMDABuilder<BitString, F, (), ()> {
649 UMDABuilder::new()
650 }
651
652 pub fn run<R: Rng>(
654 &self,
655 rng: &mut R,
656 ) -> Result<EvolutionResult<BitString, F>, EvolutionError> {
657 self.run_with_model(rng).map(|(result, _model)| result)
658 }
659
660 pub fn run_with_model<R: Rng>(
663 &self,
664 rng: &mut R,
665 ) -> Result<(EvolutionResult<BitString, F>, BinaryUnivariateModel), EvolutionError> {
666 let start_time = Instant::now();
667
668 let dimension = self.bounds.dimension();
670
671 let mut model = BinaryUnivariateModel::uniform(dimension);
673
674 let mut population: Population<BitString, F> =
676 Population::random(self.config.population_size, &self.bounds, rng);
677 population.evaluate(&self.fitness);
678
679 let mut stats = EvolutionStats::new();
680 let mut evaluations = self.config.population_size;
681 let mut fitness_history: Vec<f64> = Vec::new();
682 let mut generation = 0usize;
683
684 let mut best = population
686 .best()
687 .ok_or(EvolutionError::EmptyPopulation)?
688 .clone();
689
690 let gen_stats = GenerationStats::from_population(&population, 0, evaluations);
692 fitness_history.push(gen_stats.best_fitness);
693 stats.record(gen_stats);
694
695 loop {
697 let state = EvolutionState {
699 generation,
700 evaluations,
701 best_fitness: best.fitness_value().to_f64(),
702 population: &population,
703 fitness_history: &fitness_history,
704 };
705
706 if self.termination.should_terminate(&state) {
707 stats.set_termination_reason(self.termination.reason());
708 break;
709 }
710
711 let gen_start = Instant::now();
712
713 let select_count =
715 (self.config.population_size as f64 * self.config.selection_ratio).ceil() as usize;
716 let selected: Vec<&BitString> = select_top_genomes(&population, select_count);
717
718 model.update(&selected, &self.config);
720
721 population = Population::with_capacity(self.config.population_size);
723 for _ in 0..self.config.population_size {
724 let genome: BitString = model.sample(rng);
725 population.push(Individual::new(genome));
726 }
727
728 population.evaluate(&self.fitness);
730 evaluations += self.config.population_size;
731
732 if let Some(pop_best) = population.best() {
734 if pop_best.is_better_than(&best) {
735 best = pop_best.clone();
736 }
737 }
738
739 generation += 1;
740 population.set_generation(generation);
741
742 let timing = TimingStats::new().with_total(gen_start.elapsed());
744 let gen_stats = GenerationStats::from_population(&population, generation, evaluations)
745 .with_timing(timing);
746 fitness_history.push(gen_stats.best_fitness);
747 stats.record(gen_stats);
748 }
749
750 stats.set_runtime(start_time.elapsed());
751
752 let result =
753 EvolutionResult::new(best.genome, best.fitness.unwrap(), generation, evaluations)
754 .with_stats(stats);
755 Ok((result, model))
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use crate::fitness::benchmarks::{OneMax, Sphere};
763 use crate::genome::bounds::Bounds;
764 use crate::termination::MaxEvaluations;
765 use rand::SeedableRng;
766
767 #[test]
768 fn test_continuous_umda_builder() {
769 let bounds = MultiBounds::symmetric(5.0, 10);
770 let umda: Result<ContinuousUMDA<f64, _, _>, _> = UMDABuilder::new()
771 .population_size(50)
772 .selection_ratio(0.3)
773 .bounds(bounds)
774 .fitness(Sphere::new(10))
775 .max_generations(10)
776 .build();
777
778 assert!(umda.is_ok());
779 }
780
781 fn random_search_sphere_best<R: Rng>(
784 dim: usize,
785 half_width: f64,
786 budget: usize,
787 rng: &mut R,
788 ) -> f64 {
789 let sphere = Sphere::new(dim);
790 let mut best = f64::NEG_INFINITY;
791 for _ in 0..budget {
792 let genes: Vec<f64> = (0..dim)
793 .map(|_| rng.gen_range(-half_width..half_width))
794 .collect();
795 let f = sphere.evaluate(&RealVector::new(genes));
796 if f > best {
797 best = f;
798 }
799 }
800 best
801 }
802
803 fn random_search_onemax_best<R: Rng>(dim: usize, budget: usize, rng: &mut R) -> usize {
805 let onemax = OneMax::new(dim);
806 let mut best = 0usize;
807 for _ in 0..budget {
808 let bits: Vec<bool> = (0..dim).map(|_| rng.gen::<bool>()).collect();
809 let f = onemax.evaluate(&BitString::new(bits));
810 if f > best {
811 best = f;
812 }
813 }
814 best
815 }
816
817 #[test]
823 fn test_continuous_umda_beats_random_search() {
824 let budget = 5000;
825 let dim = 10;
826 let half_width = 5.12;
827
828 let mut rng = rand::rngs::StdRng::seed_from_u64(42);
829 let umda: ContinuousUMDA<f64, _, _> = UMDABuilder::new()
830 .population_size(100)
831 .selection_ratio(0.3)
832 .min_variance(0.001)
833 .bounds(MultiBounds::symmetric(half_width, dim))
834 .fitness(Sphere::new(dim))
835 .termination(MaxEvaluations::new(budget))
836 .build()
837 .unwrap();
838 let (result, model) = umda.run_with_model(&mut rng).unwrap();
839
840 let mut baseline_rng = rand::rngs::StdRng::seed_from_u64(42);
841 let random_best = random_search_sphere_best(dim, half_width, budget, &mut baseline_rng);
842
843 assert!(
844 result.best_fitness > random_best + 5.0,
845 "UMDA best {} should beat random search {} by a clear margin",
846 result.best_fitness,
847 random_best
848 );
849 assert!(
850 result.best_fitness > -2.0,
851 "UMDA should get close to the optimum, got {}",
852 result.best_fitness
853 );
854
855 for (i, m) in model.means.iter().enumerate() {
857 assert!(
858 m.abs() < 0.5,
859 "learned mean[{i}] = {m} did not converge toward the optimum (0)"
860 );
861 }
862 }
863
864 #[test]
868 fn test_binary_umda_beats_random_search() {
869 let budget = 3000;
870 let dim = 20;
871
872 let mut rng = rand::rngs::StdRng::seed_from_u64(7);
873 let umda: BinaryUMDA<usize, _, _> = UMDABuilder::new()
874 .population_size(100)
875 .selection_ratio(0.3)
876 .prob_bounds(0.02, 0.98)
877 .bounds(MultiBounds::uniform(Bounds::unit(), dim))
878 .fitness(OneMax::new(dim))
879 .termination(MaxEvaluations::new(budget))
880 .build()
881 .unwrap();
882 let (result, model) = umda.run_with_model(&mut rng).unwrap();
883
884 let mut baseline_rng = rand::rngs::StdRng::seed_from_u64(7);
885 let random_best = random_search_onemax_best(dim, budget, &mut baseline_rng);
886
887 assert!(
888 result.best_fitness > random_best,
889 "UMDA best {} should beat random search {}",
890 result.best_fitness,
891 random_best
892 );
893 assert!(
894 result.best_fitness >= 19,
895 "UMDA should nearly solve OneMax, got {}",
896 result.best_fitness
897 );
898
899 for (i, p) in model.probabilities.iter().enumerate() {
901 assert!(
902 *p > 0.8,
903 "learned probability[{i}] = {p} did not converge toward 1"
904 );
905 }
906 }
907
908 #[test]
911 fn test_umda_builder_validation_rejects_out_of_range() {
912 let bounds = MultiBounds::symmetric(5.0, 4);
913
914 let bad_ratio: Result<ContinuousUMDA<f64, _, _>, _> = UMDABuilder::new()
915 .population_size(50)
916 .selection_ratio(1.5)
917 .bounds(bounds.clone())
918 .fitness(Sphere::new(4))
919 .max_generations(5)
920 .build();
921 assert!(bad_ratio.is_err(), "selection_ratio 1.5 must be rejected");
922
923 let zero_lr: Result<ContinuousUMDA<f64, _, _>, _> = UMDABuilder::new()
924 .population_size(50)
925 .learning_rate(0.0)
926 .bounds(bounds.clone())
927 .fitness(Sphere::new(4))
928 .max_generations(5)
929 .build();
930 assert!(
931 zero_lr.is_err(),
932 "learning_rate 0.0 (a no-op) must be rejected"
933 );
934
935 let bad_probs: Result<BinaryUMDA<usize, _, _>, _> = UMDABuilder::new()
936 .population_size(50)
937 .prob_bounds(0.6, 0.4)
938 .bounds(MultiBounds::uniform(Bounds::unit(), 4))
939 .fitness(OneMax::new(4))
940 .max_generations(5)
941 .build();
942 assert!(
943 bad_probs.is_err(),
944 "prob_bounds with min > max must be rejected"
945 );
946
947 let ok: Result<ContinuousUMDA<f64, _, _>, _> = UMDABuilder::new()
948 .population_size(50)
949 .selection_ratio(0.3)
950 .learning_rate(0.5)
951 .bounds(bounds)
952 .fitness(Sphere::new(4))
953 .max_generations(5)
954 .build();
955 assert!(ok.is_ok(), "a valid configuration must still build");
956 }
957
958 #[test]
962 fn test_continuous_variance_is_bessel_corrected() {
963 let bounds = MultiBounds::symmetric(5.0, 1);
964 let mut model = ContinuousUnivariateModel::from_bounds(&bounds);
965 let config = UMDAConfig {
966 learning_rate: 1.0,
967 min_variance: 1e-12, ..Default::default()
969 };
970
971 let g1 = RealVector::new(vec![1.0]);
972 let g2 = RealVector::new(vec![2.0]);
973 let g3 = RealVector::new(vec![3.0]);
974 model.update(&[&g1, &g2, &g3], &config);
975
976 assert!(
977 (model.variances[0] - 1.0).abs() < 1e-9,
978 "expected Bessel-corrected variance 1.0, got {}",
979 model.variances[0]
980 );
981 }
982
983 #[test]
984 fn test_continuous_model_update() {
985 let bounds = MultiBounds::symmetric(5.0, 3);
986 let mut model = ContinuousUnivariateModel::from_bounds(&bounds);
987 let config = UMDAConfig::default();
988
989 let g1 = RealVector::new(vec![1.0, 2.0, 3.0]);
991 let g2 = RealVector::new(vec![2.0, 3.0, 4.0]);
992 let g3 = RealVector::new(vec![3.0, 4.0, 5.0]);
993 let selected = vec![&g1, &g2, &g3];
994
995 model.update(&selected, &config);
996
997 assert!((model.means[0] - 2.0).abs() < 0.01);
999 assert!((model.means[1] - 3.0).abs() < 0.01);
1000 assert!((model.means[2] - 4.0).abs() < 0.01);
1001 }
1002
1003 #[test]
1004 fn test_binary_model_update() {
1005 let mut model = BinaryUnivariateModel::uniform(4);
1006 let config = UMDAConfig::default();
1007
1008 let g1 = BitString::new(vec![true, true, false, false]);
1010 let g2 = BitString::new(vec![true, false, true, false]);
1011 let g3 = BitString::new(vec![true, false, false, true]);
1012 let selected = vec![&g1, &g2, &g3];
1013
1014 model.update(&selected, &config);
1015
1016 assert!(model.probabilities[0] > 0.9);
1021 assert!(model.probabilities[1] > 0.2 && model.probabilities[1] < 0.5);
1022 }
1023
1024 #[test]
1025 fn test_learning_rate() {
1026 let bounds = MultiBounds::symmetric(5.0, 2);
1027 let mut model = ContinuousUnivariateModel::from_bounds(&bounds);
1028 let config = UMDAConfig {
1029 learning_rate: 0.5,
1030 ..Default::default()
1031 };
1032
1033 let initial_mean = model.means[0];
1035
1036 let g1 = RealVector::new(vec![2.0, 2.0]);
1038 let selected = vec![&g1];
1039
1040 model.update(&selected, &config);
1041
1042 assert!((model.means[0] - (0.5 * 2.0 + 0.5 * initial_mean)).abs() < 0.01);
1044 }
1045
1046 #[test]
1051 fn test_sample_rejection_avoids_boundary_pileup() {
1052 let bounds = MultiBounds::symmetric(5.0, 1); let mut model = ContinuousUnivariateModel::from_bounds(&bounds);
1054 model.means[0] = 5.0; model.variances[0] = 1.0; let mut rng = rand::rngs::StdRng::seed_from_u64(123);
1058 let n = 2000;
1059 let on_boundary = (0..n)
1060 .filter(|_| {
1061 let g = model.sample(&bounds, &mut rng);
1062 (g.genes()[0] - 5.0).abs() < 1e-12
1063 })
1064 .count();
1065
1066 assert!(
1067 on_boundary <= 2,
1068 "rejection sampling should not pile samples on the boundary, got {on_boundary}/{n}"
1069 );
1070 }
1071
1072 #[derive(Clone, Debug, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
1076 struct LowerBetter(f64);
1077
1078 impl FitnessValue for LowerBetter {
1079 fn to_f64(&self) -> f64 {
1080 self.0
1081 }
1082 fn is_better_than(&self, other: &Self) -> bool {
1083 self.0 < other.0
1084 }
1085 }
1086
1087 #[test]
1092 fn test_select_top_uses_is_better_than() {
1093 let mut pop: Population<RealVector, LowerBetter> = Population::new();
1094 pop.push(Individual::with_fitness(
1095 RealVector::new(vec![30.0]),
1096 LowerBetter(3.0),
1097 ));
1098 pop.push(Individual::with_fitness(
1099 RealVector::new(vec![10.0]),
1100 LowerBetter(1.0),
1101 ));
1102 pop.push(Individual::with_fitness(
1103 RealVector::new(vec![20.0]),
1104 LowerBetter(2.0),
1105 ));
1106
1107 let top = select_top_genomes(&pop, 1);
1108 assert_eq!(top.len(), 1);
1109 assert_eq!(
1110 top[0].genes()[0],
1111 10.0,
1112 "selection should pick the is_better_than-best (lowest) individual"
1113 );
1114 }
1115
1116 #[test]
1119 fn test_umda_run_with_callback_reports_progress() {
1120 let mut rng = rand::rngs::StdRng::seed_from_u64(3);
1121 let umda: ContinuousUMDA<f64, _, _> = UMDABuilder::new()
1122 .population_size(40)
1123 .selection_ratio(0.3)
1124 .bounds(MultiBounds::symmetric(5.12, 4))
1125 .fitness(Sphere::new(4))
1126 .max_generations(12)
1127 .build()
1128 .unwrap();
1129
1130 let mut seen: Vec<usize> = Vec::new();
1131 let result = umda
1132 .run_with_callback(&mut rng, |generation, best| {
1133 assert!(best.is_finite());
1134 seen.push(generation);
1135 true
1136 })
1137 .unwrap();
1138
1139 assert_eq!(seen, (0..result.generations).collect::<Vec<_>>());
1140 assert!(!seen.is_empty());
1141 }
1142
1143 #[test]
1144 fn test_umda_run_with_callback_cancels_early() {
1145 let mut rng = rand::rngs::StdRng::seed_from_u64(4);
1146 let umda: ContinuousUMDA<f64, _, _> = UMDABuilder::new()
1147 .population_size(40)
1148 .selection_ratio(0.3)
1149 .bounds(MultiBounds::symmetric(5.12, 4))
1150 .fitness(Sphere::new(4))
1151 .max_generations(10_000)
1152 .build()
1153 .unwrap();
1154
1155 let mut calls = 0usize;
1156 let result = umda
1157 .run_with_callback(&mut rng, |_generation, _best| {
1158 calls += 1;
1159 calls < 6
1160 })
1161 .unwrap();
1162
1163 assert!(calls <= 6, "callback must stop being called after cancel");
1164 assert!(
1165 result.generations < 10,
1166 "run must stop far short of the 10k budget, got {}",
1167 result.generations
1168 );
1169 }
1170}