Skip to main content

fugue_evo/algorithms/eda/
umda.rs

1//! Univariate Marginal Distribution Algorithm (UMDA)
2//!
3//! UMDA is a simple but effective EDA that assumes independence between variables.
4//! It estimates univariate marginal distributions for each variable and samples
5//! new solutions from the product of these distributions.
6//!
7//! For continuous problems: estimates mean and variance per dimension
8//! For binary problems: estimates probability of 1 per bit
9
10use 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
26/// Select references to the genomes of the `count` best individuals, ordered by
27/// [`Individual::is_better_than`] (EV-80).
28///
29/// Using `is_better_than` rather than the `to_f64`-descending order of
30/// `Population::sort_by_fitness` keeps truncation selection correct for every
31/// `FitnessValue` — including non-scalar fitnesses whose f64 projection does not
32/// agree with their intrinsic ordering.
33fn 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/// Configuration for UMDA.
56///
57/// The builder ([`UMDABuilder`]) validates these fields in `build()` and returns
58/// an error for out-of-range values (EV-79) — it no longer silently clamps them.
59/// All fields are public, so constructing `UMDAConfig` directly is a deliberate
60/// escape hatch that **bypasses that validation**; if you build the struct by
61/// hand, keep the documented ranges yourself.
62#[derive(Clone, Debug)]
63pub struct UMDAConfig {
64    /// Population size (must be >= 1).
65    pub population_size: usize,
66    /// Selection ratio: the top proportion selected for model learning. Must lie
67    /// in the open-closed interval `(0.0, 1.0]`.
68    pub selection_ratio: f64,
69    /// Minimum variance to prevent collapse (continuous). Must be >= 0.0.
70    pub min_variance: f64,
71    /// Probability bounds `(min, max)` for binary UMDA (to prevent determinism).
72    /// Must satisfy `0.0 < min <= max < 1.0`.
73    pub prob_bounds: (f64, f64),
74    /// Learning rate for the model update (`1.0` = replace, `<1.0` = blend with
75    /// the previous model). Must lie in `(0.0, 1.0]`; `0.0` is rejected because it
76    /// makes the update a no-op (the model never learns).
77    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    /// Validate the configuration ranges (EV-79). Called by `build()`; returns a
94    /// [`EvolutionError::Configuration`] describing the first out-of-range field.
95    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
129/// Builder for UMDA
130pub 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    /// Create a new builder with default configuration
148    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    /// Set the population size
175    pub fn population_size(mut self, size: usize) -> Self {
176        self.config.population_size = size;
177        self
178    }
179
180    /// Set the selection ratio (must be in `(0.0, 1.0]`; validated in `build()`).
181    pub fn selection_ratio(mut self, ratio: f64) -> Self {
182        self.config.selection_ratio = ratio;
183        self
184    }
185
186    /// Set the minimum variance (must be >= 0.0; validated in `build()`).
187    pub fn min_variance(mut self, variance: f64) -> Self {
188        self.config.min_variance = variance;
189        self
190    }
191
192    /// Set probability bounds for binary UMDA (must satisfy
193    /// `0.0 < min <= max < 1.0`; validated in `build()`).
194    pub fn prob_bounds(mut self, min: f64, max: f64) -> Self {
195        self.config.prob_bounds = (min, max);
196        self
197    }
198
199    /// Set the learning rate for the model update (must be in `(0.0, 1.0]`;
200    /// validated in `build()`).
201    pub fn learning_rate(mut self, rate: f64) -> Self {
202        self.config.learning_rate = rate;
203        self
204    }
205
206    /// Set the search space bounds
207    pub fn bounds(mut self, bounds: MultiBounds) -> Self {
208        self.bounds = Some(bounds);
209        self
210    }
211
212    /// Set the fitness function
213    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    /// Set the termination criterion
227    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    /// Set max generations (convenience method)
241    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// ============================================================================
253// Continuous UMDA (RealVector)
254// ============================================================================
255
256/// Univariate model for continuous variables
257#[derive(Clone, Debug)]
258pub struct ContinuousUnivariateModel {
259    /// Mean for each dimension
260    pub means: Vec<f64>,
261    /// Variance for each dimension
262    pub variances: Vec<f64>,
263}
264
265impl ContinuousUnivariateModel {
266    /// Maximum number of rejection retries per coordinate before clamping (EV-39).
267    pub const MAX_REJECTION_RETRIES: usize = 100;
268
269    /// Create from bounds (initial uniform-ish distribution)
270    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    /// Update model from selected individuals
281    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            // Compute sample mean
289            let mean: f64 = selected.iter().map(|g| g.genes()[i]).sum::<f64>() / n;
290
291            // Compute the unbiased (Bessel-corrected, n-1) sample variance (EV-81).
292            // The n-1 denominator avoids systematically under-estimating spread,
293            // which would otherwise nudge the model toward premature contraction.
294            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            // Apply learning rate and bounds
301            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    /// Sample a new individual from the model.
309    ///
310    /// EV-39: each coordinate is drawn by rejection from the truncated Gaussian —
311    /// out-of-bounds draws are retried up to [`Self::MAX_REJECTION_RETRIES`] times
312    /// before falling back to a clamp. For interior-optimum problems this makes
313    /// the boundary "atoms" (probability mass piled on a bound by naive clamping)
314    /// vanish, so the accepted samples that feed the next variance estimate are
315    /// genuine spread rather than collapsed boundary points.
316    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                // Fallback only when rejection failed to land in-bounds (e.g. the
336                // whole feasible interval is deep in a Gaussian tail): clamp so the
337                // returned genome always respects the box constraints.
338                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    /// Build the continuous UMDA instance
353    pub fn build(self) -> Result<ContinuousUMDA<F, Fit, Term>, EvolutionError> {
354        // EV-79: validate configured ranges instead of silently clamping.
355        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
379/// UMDA for continuous optimization
380pub 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    /// Create a builder for continuous UMDA
398    pub fn builder() -> UMDABuilder<RealVector, F, (), ()> {
399        UMDABuilder::new()
400    }
401
402    /// Run the UMDA algorithm.
403    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    /// Run UMDA, invoking `on_generation(generation, best_fitness)` once per
411    /// generation before that generation is sampled/evaluated.
412    ///
413    /// Returning `false` from the callback cancels the run early and returns the
414    /// best-so-far result (AUDIT EV-34: lets the WASM layer report per-generation
415    /// progress and support cancellation without a separate step API).
416    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    /// Run the UMDA algorithm and also return the final learned univariate model
426    /// (EV-10), so callers/tests can inspect whether the distribution actually
427    /// converged toward the optimum rather than merely tracking a best-so-far.
428    pub fn run_with_model<R: Rng>(
429        &self,
430        rng: &mut R,
431    ) -> Result<(EvolutionResult<RealVector, F>, ContinuousUnivariateModel), EvolutionError> {
432        // No-op observer: identical behavior to the historical `run_with_model`.
433        self.run_with_model_cb(rng, |_generation, _best_fitness| true)
434    }
435
436    /// Shared UMDA loop body driving both `run_with_model` (no-op callback) and
437    /// `run_with_callback` (EV-34 progress/cancel), so there is exactly one copy
438    /// of the algorithm.
439    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        // Initialize model
447        let mut model = ContinuousUnivariateModel::from_bounds(&self.bounds);
448
449        // Initialize population
450        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        // Track best individual
460        let mut best = population
461            .best()
462            .ok_or(EvolutionError::EmptyPopulation)?
463            .clone();
464
465        // Record initial statistics
466        let gen_stats = GenerationStats::from_population(&population, 0, evaluations);
467        fitness_history.push(gen_stats.best_fitness);
468        stats.record(gen_stats);
469
470        // Main loop
471        loop {
472            // Check termination
473            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            // EV-34: per-generation progress/cancel hook. A `false` return cancels
487            // the run, returning the best individual found so far.
488            if !on_generation(generation, best.fitness_value().to_f64()) {
489                break;
490            }
491
492            let gen_start = Instant::now();
493
494            // Select the top individuals by is_better_than order (EV-80).
495            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            // Update model from selected individuals
500            model.update(&selected, &self.config);
501
502            // Sample new population from model
503            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            // Evaluate
510            population.evaluate(&self.fitness);
511            evaluations += self.config.population_size;
512
513            // Update best
514            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            // Record statistics
524            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// ============================================================================
541// Binary UMDA (BitString)
542// ============================================================================
543
544/// Univariate model for binary variables
545#[derive(Clone, Debug)]
546pub struct BinaryUnivariateModel {
547    /// Probability of 1 for each bit position
548    pub probabilities: Vec<f64>,
549}
550
551impl BinaryUnivariateModel {
552    /// Create with uniform 0.5 probability
553    pub fn uniform(dimension: usize) -> Self {
554        Self {
555            probabilities: vec![0.5; dimension],
556        }
557    }
558
559    /// Update model from selected individuals
560    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            // Count ones at position i
568            let ones: f64 = selected
569                .iter()
570                .filter(|g| g.bits().get(i).copied().unwrap_or(false))
571                .count() as f64;
572
573            // Compute probability with learning rate and bounds
574            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    /// Sample a new individual from the model
582    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    /// Build the binary UMDA instance
603    pub fn build(self) -> Result<BinaryUMDA<F, Fit, Term>, EvolutionError> {
604        // EV-79: validate configured ranges instead of silently clamping.
605        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
629/// UMDA for binary optimization
630pub 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    /// Create a builder for binary UMDA
648    pub fn builder() -> UMDABuilder<BitString, F, (), ()> {
649        UMDABuilder::new()
650    }
651
652    /// Run the UMDA algorithm.
653    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    /// Run the UMDA algorithm and also return the final learned univariate model
661    /// (EV-10), so callers/tests can check the learned probabilities converged.
662    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        // Get dimension from bounds
669        let dimension = self.bounds.dimension();
670
671        // Initialize model
672        let mut model = BinaryUnivariateModel::uniform(dimension);
673
674        // Initialize population
675        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        // Track best individual
685        let mut best = population
686            .best()
687            .ok_or(EvolutionError::EmptyPopulation)?
688            .clone();
689
690        // Record initial statistics
691        let gen_stats = GenerationStats::from_population(&population, 0, evaluations);
692        fitness_history.push(gen_stats.best_fitness);
693        stats.record(gen_stats);
694
695        // Main loop
696        loop {
697            // Check termination
698            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            // Select the top individuals by is_better_than order (EV-80).
714            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            // Update model from selected individuals
719            model.update(&selected, &self.config);
720
721            // Sample new population from model
722            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            // Evaluate
729            population.evaluate(&self.fitness);
730            evaluations += self.config.population_size;
731
732            // Update best
733            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            // Record statistics
743            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    /// Best Sphere fitness (higher = better; Sphere is negated) found by pure
782    /// uniform random search over `budget` samples — the EV-10 baseline.
783    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    /// Best OneMax fitness found by pure uniform random search — EV-10 baseline.
804    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    // regression: EV-10 — a working UMDA must (a) beat a same-budget pure random
818    // search by a fixed margin and (b) actually learn a model whose means move to
819    // the optimum. A broken UMDA that never converges would still clear the old
820    // `best_fitness > -50` bar (random search alone reaches ~-12), so this test
821    // fails for pure random search and for a non-learning model.
822    #[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        // The learned distribution itself must have moved toward the optimum.
856        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    // regression: EV-10 — binary UMDA must beat a same-budget random search and
865    // learn probabilities that converge toward 1. Random search over 3000 draws
866    // tops out around 16 ones, so a broken UMDA cannot clear the >= 19 bar.
867    #[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        // The learned probabilities must have converged toward 1.
900        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    // regression: EV-79 — build() validates configured ranges and returns an error
909    // instead of silently clamping (or accepting a no-op learning_rate of 0).
910    #[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    // regression: EV-81 — the continuous model uses the unbiased (n-1) sample
959    // variance. For selected values {1,2,3} at a dimension the Bessel-corrected
960    // variance is 1.0, whereas the old biased /n estimate gave 2/3.
961    #[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, // don't let the floor mask the estimate
968            ..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        // Create some test individuals
990        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        // Mean should be average: (1+2+3)/3=2, (2+3+4)/3=3, (3+4+5)/3=4
998        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        // Create test individuals
1009        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        // Position 0: all true -> p=0.99 (bounded)
1017        // Position 1: 1/3 true -> p=0.33
1018        // Position 2: 1/3 true -> p=0.33
1019        // Position 3: 1/3 true -> p=0.33
1020        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        // Initial means should be 0.0 (center of [-5, 5])
1034        let initial_mean = model.means[0];
1035
1036        // Create individuals at 2.0
1037        let g1 = RealVector::new(vec![2.0, 2.0]);
1038        let selected = vec![&g1];
1039
1040        model.update(&selected, &config);
1041
1042        // With learning rate 0.5, new mean = 0.5 * 2.0 + 0.5 * 0.0 = 1.0
1043        assert!((model.means[0] - (0.5 * 2.0 + 0.5 * initial_mean)).abs() < 0.01);
1044    }
1045
1046    // regression: EV-39 — sampling rejects out-of-bounds draws instead of clamping,
1047    // so a model mean sitting exactly on a bound does not pile ~50% of samples on
1048    // that boundary (which shrinks the next variance estimate). Rejection yields
1049    // essentially zero exact-boundary samples; the old clamp yielded ~half.
1050    #[test]
1051    fn test_sample_rejection_avoids_boundary_pileup() {
1052        let bounds = MultiBounds::symmetric(5.0, 1); // [-5, 5]
1053        let mut model = ContinuousUnivariateModel::from_bounds(&bounds);
1054        model.means[0] = 5.0; // mean exactly on the upper bound
1055        model.variances[0] = 1.0; // non-trivial spread
1056
1057        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    /// A fitness where LOWER is better while `to_f64` still reports the raw value —
1073    /// so `is_better_than` and `to_f64`-descending order disagree (mirrors the
1074    /// ParetoFitness hazard flagged by EV-80).
1075    #[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    // regression: EV-80 — truncation selection ranks by is_better_than, not by
1088    // to_f64-descending order. With a lower-is-better fitness, the top pick must be
1089    // the lowest value; a to_f64-descending sort (the pre-fix behavior) would pick
1090    // the highest.
1091    #[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    // regression: EV-34 — run_with_callback reports every generation in order and
1117    // returns the same result as `run` when the callback never cancels.
1118    #[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}