Skip to main content

rlevo_evolution/algorithms/eda/
mod.rs

1//! Estimation-of-distribution algorithms (EDAs).
2//!
3//! An EDA replaces the crossover and mutation operators of a classical GA
4//! with an explicit probabilistic model of the promising region of search
5//! space. Each generation runs a `fit` → `sample` loop:
6//!
7//! 1. Evaluate the current population (done externally by the harness).
8//! 2. Truncation-select the best fraction of the population.
9//! 3. [`fit`](crate::ProbabilityModel::fit) the model to those survivors.
10//! 4. [`sample`](crate::ProbabilityModel::sample) a fresh population.
11//!
12//! The model is supplied as a [`ProbabilityModel`]; the generic
13//! [`EdaStrategy`] driver is model-agnostic. Five reference models ship here:
14//!
15//! - [`UnivariateGaussian`] — UMDA, a per-dimension Gaussian (unweighted MLE,
16//!   `÷k` variance, `min_variance` floor; fitness is accepted but ignored).
17//! - [`UnivariateBernoulli`] — PBIL, a per-bit probability vector (no
18//!   classic probability-mutation step; fitness is used only to identify
19//!   the best/worst individual).
20//! - [`CompactGenetic`] — cGA, a virtual-population probability vector
21//!   (winner/loser come from the truncation-selected subset, not a fresh
22//!   pairwise draw as in classic cGA).
23//! - [`DependencyChain`] — a continuous-Gaussian MIMIC chain capturing
24//!   pairwise dependencies (fitness accepted but ignored).
25//! - [`BayesianNetwork`] — BOA, a BIC-scored Bayesian network (bounded-in-degree
26//!   DAG over binary genes; non-incremental, unweighted fit; Pelikan, Goldberg
27//!   & Cantú-Paz 1999).
28//!
29//! The three binary models ([`UnivariateBernoulli`], [`CompactGenetic`], and
30//! [`BayesianNetwork`]) emit raw `{0, 1}` genes; the [`EdaParams::bounds`] clamp
31//! is therefore a no-op for them.
32//!
33//! # References
34//!
35//! - Mühlenbein & Paaß (1996), *From recombination of genes to the
36//!   estimation of distributions I. Binary parameters*.
37//! - Baluja (1994), *Population-based incremental learning: a method for
38//!   integrating genetic search based function optimization and competitive
39//!   learning*.
40//! - Harik, Lobo & Goldberg (1999), *The compact genetic algorithm*.
41//! - De Bonet, Isbell & Viola (1997), *MIMIC: Finding optima by estimating
42//!   probability densities*.
43//! - Pelikan, Goldberg & Cantú-Paz (1999), *BOA: The Bayesian optimization
44//!   algorithm*.
45
46pub mod bayesian_network;
47pub mod compact_genetic;
48pub mod dependency_chain;
49pub mod univariate_bernoulli;
50pub mod univariate_gaussian;
51
52pub use bayesian_network::{BayesianNetwork, BayesianNetworkParams, BayesianNetworkState};
53pub use compact_genetic::{CompactGenetic, CompactGeneticParams, CompactGeneticState};
54pub use dependency_chain::{DependencyChain, DependencyChainParams, DependencyChainState};
55pub use univariate_bernoulli::{
56    UnivariateBernoulli, UnivariateBernoulliParams, UnivariateBernoulliState,
57};
58pub use univariate_gaussian::{
59    UnivariateGaussian, UnivariateGaussianParams, UnivariateGaussianState,
60};
61
62use std::fmt::Debug;
63use std::marker::PhantomData;
64
65use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
66use rand::Rng;
67
68use rlevo_core::bounds::Bounds;
69use rlevo_core::config::{self, ConfigError, Validate};
70
71use crate::probability_model::ProbabilityModel;
72use crate::rng::{SeedPurpose, seed_stream};
73use crate::strategy::{Strategy, StrategyMetrics};
74
75/// Static configuration for an [`EdaStrategy`] run.
76///
77/// All fields are public so callers can use struct-literal construction or
78/// update syntax. The only runtime contract enforced at call time is that
79/// `selection_ratio` lies strictly in `(0, 1)` (checked by a
80/// `debug_assert!` in [`EdaStrategy::init`]).
81#[derive(Debug, Clone)]
82pub struct EdaParams<MP> {
83    /// Number of individuals sampled per generation.
84    pub pop_size: usize,
85    /// Fraction of the population kept by truncation selection; must lie
86    /// strictly in `(0, 1)`. The effective `k` is
87    /// `ceil(selection_ratio · pop_size)` clamped to `[2, pop_size]`.
88    pub selection_ratio: f32,
89    /// Optional inclusive `[lo, hi]` clamp applied to each gene after
90    /// sampling. A no-op for binary models ([`UnivariateBernoulli`],
91    /// [`CompactGenetic`]) whose genes are already `{0, 1}`.
92    pub bounds: Option<Bounds>,
93    /// Model-specific parameters (includes `genome_dim`).
94    pub model: MP,
95}
96
97/// Validation covers the engine-level knobs shared by every EDA model:
98/// `pop_size >= 2` and `selection_ratio ∈ (0, 1)` (open on both ends, since a
99/// ratio of `0` selects no parents and `1` defeats truncation). Model-specific
100/// params `MP` (which carry their own `genome_dim` etc.) are left to the model's
101/// own `fit`; no `MP: Validate` bound is imposed, keeping `EdaParams` usable
102/// with any model.
103impl<MP> Validate for EdaParams<MP> {
104    fn validate(&self) -> Result<(), ConfigError> {
105        const C: &str = "EdaParams";
106        config::at_least(C, "pop_size", self.pop_size, 2)?;
107        config::positive(C, "selection_ratio", f64::from(self.selection_ratio))?;
108        config::ordered(C, "selection_ratio", f64::from(self.selection_ratio), 1.0)?;
109        Ok(())
110    }
111}
112
113/// Generation-to-generation state carried by [`EdaStrategy`].
114///
115/// The driver updates this in `tell` and passes it back through `ask` unchanged.
116/// Callers should treat it as an opaque blob; the public fields are exposed to
117/// enable checkpointing and observability, not mutation.
118#[derive(Debug, Clone)]
119pub struct EdaState<B: Backend, MS> {
120    /// Current fitted model state (updated once per [`EdaStrategy::tell`] call).
121    pub model_state: MS,
122    /// Best genome ever observed, shape `(genome_dim,)`. `None` before the
123    /// first [`EdaStrategy::tell`] call.
124    pub best_genome: Option<Tensor<B, 1>>,
125    /// Largest (best) fitness ever observed across all generations
126    /// (canonical maximise convention). Starts at `f32::NEG_INFINITY`.
127    pub best_fitness_ever: f32,
128    /// Number of completed generations (incremented by each `tell` call).
129    pub generation: usize,
130}
131
132/// Generic estimation-of-distribution strategy.
133///
134/// Drives the `fit` → `sample` loop of any [`ProbabilityModel`]. The strategy
135/// itself is stateless (the model is held by value; all mutable generation
136/// state lives in the returned [`EdaState`]), so it slots straight into
137/// [`EvolutionaryHarness`](crate::strategy::EvolutionaryHarness).
138///
139/// Type parameter `M` must implement [`ProbabilityModel<B>`]; in practice
140/// this is one of [`UnivariateGaussian`], [`UnivariateBernoulli`],
141/// [`CompactGenetic`], [`DependencyChain`], or [`BayesianNetwork`].
142///
143/// # Example
144///
145/// ```no_run
146/// use burn::backend::Flex;
147/// use rlevo_evolution::algorithms::eda::{EdaParams, EdaStrategy, UnivariateGaussian};
148/// use rlevo_evolution::algorithms::eda::univariate_gaussian::UnivariateGaussianParams;
149/// use rlevo_core::bounds::Bounds;
150///
151/// let strategy = EdaStrategy::<Flex, _>::new(UnivariateGaussian);
152/// let params = EdaParams {
153///     pop_size: 32,
154///     selection_ratio: 0.5,
155///     bounds: Some(Bounds::new(-5.12, 5.12)),
156///     model: UnivariateGaussianParams::default_for(8),
157/// };
158/// let _ = (strategy, params);
159/// ```
160pub struct EdaStrategy<B: Backend, M> {
161    model: M,
162    _backend: PhantomData<fn() -> B>,
163}
164
165impl<B: Backend, M: Debug> Debug for EdaStrategy<B, M> {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("EdaStrategy")
168            .field("model", &self.model)
169            .finish_non_exhaustive()
170    }
171}
172
173impl<B: Backend, M> EdaStrategy<B, M> {
174    /// Build a new EDA strategy around `model`.
175    #[must_use]
176    pub fn new(model: M) -> Self {
177        Self {
178            model,
179            _backend: PhantomData,
180        }
181    }
182}
183
184impl<B: Backend, M: ProbabilityModel<B>> Strategy<B> for EdaStrategy<B, M> {
185    type Params = EdaParams<M::Params>;
186    type State = EdaState<B, M::State>;
187    type Genome = Tensor<B, 2>;
188
189    /// Build the initial state.
190    ///
191    /// Fits the model's prior from `params.model` (passing `prev = None` and
192    /// empty `0 × 0` / length-`0` population and fitness tensors, per the
193    /// [`ProbabilityModel`] invariants). The `rng` is unused — the prior is
194    /// deterministic. The best-so-far trackers start empty.
195    ///
196    /// # Panics
197    ///
198    /// Debug-asserts that `params.selection_ratio` lies strictly in `(0, 1)`.
199    /// A ratio of `0.0` would select no parents and a ratio of `1.0` would
200    /// defeat truncation selection entirely.
201    fn init(
202        &self,
203        params: &Self::Params,
204        rng: &mut dyn Rng,
205        device: &<B as burn::tensor::backend::BackendTypes>::Device,
206    ) -> Self::State {
207        debug_assert!(
208            params.validate().is_ok(),
209            "invalid EdaParams reached init: {params:?}"
210        );
211        let _ = rng;
212        let model_state = self.model.fit(
213            &params.model,
214            None,
215            Tensor::empty([0, 0], device),
216            Tensor::empty([0], device),
217            device,
218        );
219        EdaState {
220            model_state,
221            best_genome: None,
222            best_fitness_ever: f32::NEG_INFINITY,
223            generation: 0,
224        }
225    }
226
227    /// Sample the next candidate population from the current model.
228    ///
229    /// Draws exactly one `u64` from `rng` to seed a per-generation
230    /// [`SeedPurpose::EdaSampling`] stream, samples `params.pop_size`
231    /// individuals from the model through that host stream, and applies the
232    /// optional `params.bounds` clamp. The state is returned unchanged.
233    fn ask(
234        &self,
235        params: &Self::Params,
236        state: &Self::State,
237        rng: &mut dyn Rng,
238        device: &<B as burn::tensor::backend::BackendTypes>::Device,
239    ) -> (Self::Genome, Self::State) {
240        let draw = rng.next_u64();
241        let mut stream = seed_stream(draw, state.generation as u64, SeedPurpose::EdaSampling);
242        let mut pop = self
243            .model
244            .sample(&state.model_state, params.pop_size, &mut stream, device);
245        if let Some(b) = params.bounds {
246            pop = pop.clamp(b.lo(), b.hi());
247        }
248        (pop, state.clone())
249    }
250
251    /// Consume the population's fitness and refit the model.
252    ///
253    /// Pulls fitness to host, sanitizes `NaN` → `−inf` (worst under the
254    /// maximise convention) via the crate's `sanitize_fitness` helper,
255    /// updates the best-so-far tracker, truncation-selects the best `k` rows
256    /// (descending fitness order, with
257    /// `k = ceil(selection_ratio · pop_size)` clamped to `[2, pop_size]`),
258    /// and refits the model to them (passing `prev = Some(model_state)`).
259    ///
260    /// The selected population is also sanitized before the refit as a coarse
261    /// backstop: non-finite genome values are mapped `NaN → 0.0` and `±inf`
262    /// clamped to `±f32::MAX`, so a single divergent gene cannot poison a
263    /// model's fitted statistics. Each model's `fit` keeps its own precise
264    /// finite-guards (which also protect direct trait callers that bypass this
265    /// path).
266    ///
267    /// The `fitness` tensor is forwarded to [`ProbabilityModel::fit`]; models
268    /// that weight or rank their selected individuals can use it. The five
269    /// built-in models ([`UnivariateGaussian`], [`UnivariateBernoulli`],
270    /// [`CompactGenetic`], [`DependencyChain`], [`BayesianNetwork`]) all perform
271    /// an unweighted fit and ignore it.
272    fn tell(
273        &self,
274        params: &Self::Params,
275        population: Self::Genome,
276        fitness: Tensor<B, 1>,
277        mut state: Self::State,
278        _rng: &mut dyn Rng,
279    ) -> (Self::State, StrategyMetrics) {
280        let raw = fitness
281            .into_data()
282            .into_vec::<f32>()
283            .expect("fitness tensor must be readable as f32");
284        let sanitized: Vec<f32> = raw
285            .iter()
286            .map(|&f| crate::fitness::sanitize_fitness(f))
287            .collect();
288        let n = sanitized.len();
289        let device = population.device();
290
291        // Argmax (ties → lowest index) for the best-so-far update.
292        let mut best_idx = 0_usize;
293        let mut best_f = f32::NEG_INFINITY;
294        for (i, &f) in sanitized.iter().enumerate() {
295            if f.total_cmp(&best_f) == std::cmp::Ordering::Greater {
296                best_f = f;
297                best_idx = i;
298            }
299        }
300        if best_f > state.best_fitness_ever {
301            // usize → i64 for the Burn Int index tensor; population indices are
302            // far below i64::MAX so the cast never wraps.
303            #[allow(clippy::cast_possible_wrap)]
304            let idx = Tensor::<B, 1, Int>::from_data(
305                TensorData::new(vec![best_idx as i64], [1]),
306                &device,
307            );
308            let row = population.clone().select(0, idx).squeeze_dim::<1>(0);
309            state.best_genome = Some(row);
310        }
311
312        // Truncation selection: keep the best `k` rows in descending fitness
313        // order so the model sees a deterministic, best-first population.
314        #[allow(clippy::cast_precision_loss)]
315        let target = (params.selection_ratio * params.pop_size as f32).ceil();
316        // ceil of a finite non-negative product → small usize; never truncates
317        // meaningfully for any realistic population size.
318        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
319        let k = (target as usize).max(2).min(n.max(1));
320        let mut order: Vec<usize> = (0..n).collect();
321        order.sort_by(|&a, &b| sanitized[b].total_cmp(&sanitized[a]).then(a.cmp(&b)));
322        order.truncate(k);
323
324        // usize → i64 index tensor (see best-so-far note above).
325        #[allow(clippy::cast_possible_wrap)]
326        let idx_vec: Vec<i64> = order.iter().map(|&i| i as i64).collect();
327        let idx = Tensor::<B, 1, Int>::from_data(TensorData::new(idx_vec, [k]), &device);
328        let selected = population.clone().select(0, idx);
329        // Coarse defense-in-depth backstop: sanitize non-finite genome values
330        // before forwarding to `fit`. `tell` already sanitizes *fitness*, but a
331        // single non-finite *gene* (e.g. from a divergent DRL rollout) would
332        // otherwise poison a model's fitted statistics. Replace `NaN → 0.0` and
333        // clamp `±inf → ±f32::MAX`. The per-model `fit` guards remain the precise
334        // correctness layer (and protect direct trait callers this path cannot).
335        let nan_mask = selected.clone().is_nan();
336        let selected = selected
337            .mask_fill(nan_mask, 0.0_f32)
338            .clamp(-f32::MAX, f32::MAX);
339        let selected_fitness_host: Vec<f32> = order.iter().map(|&i| sanitized[i]).collect();
340        let selected_fitness =
341            Tensor::<B, 1>::from_data(TensorData::new(selected_fitness_host, [k]), &device);
342
343        let model_state = self.model.fit(
344            &params.model,
345            Some(&state.model_state),
346            selected,
347            selected_fitness,
348            &device,
349        );
350        state.model_state = model_state;
351        state.generation += 1;
352        let metrics = StrategyMetrics::from_host_fitness(
353            state.generation,
354            &sanitized,
355            state.best_fitness_ever,
356        );
357        state.best_fitness_ever = metrics.best_fitness_ever();
358        (state, metrics)
359    }
360
361    /// Return the best-so-far genome (shape `(1, D)`) and its fitness.
362    ///
363    /// Returns `None` before the first [`tell`](Self::tell) call. The genome is
364    /// stored internally as a `(D,)` vector and unsqueezed to `(1, D)` here to
365    /// match the `Genome = Tensor<B, 2>` container.
366    fn best(&self, state: &Self::State) -> Option<(Self::Genome, f32)> {
367        state
368            .best_genome
369            .as_ref()
370            .map(|g| (g.clone().unsqueeze::<2>(), state.best_fitness_ever))
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use burn::backend::Flex;
378    use rand::SeedableRng;
379    use rand::rngs::StdRng;
380
381    type TestBackend = Flex;
382
383    fn make_pop(rows: &[f32], n: usize, d: usize) -> Tensor<TestBackend, 2> {
384        let device = Default::default();
385        Tensor::<TestBackend, 2>::from_data(TensorData::new(rows.to_vec(), [n, d]), &device)
386    }
387
388    fn make_fitness(values: &[f32]) -> Tensor<TestBackend, 1> {
389        let device = Default::default();
390        let n = values.len();
391        Tensor::<TestBackend, 1>::from_data(TensorData::new(values.to_vec(), [n]), &device)
392    }
393
394    fn params(pop_size: usize, ratio: f32, dim: usize) -> EdaParams<UnivariateGaussianParams> {
395        EdaParams {
396            pop_size,
397            selection_ratio: ratio,
398            bounds: None,
399            model: UnivariateGaussianParams::default_for(dim),
400        }
401    }
402
403    #[test]
404    fn best_is_none_before_tell_some_after() {
405        let device = Default::default();
406        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
407        let p = params(4, 0.5, 2);
408        let mut rng = StdRng::seed_from_u64(0);
409        let state = strategy.init(&p, &mut rng, &device);
410        assert!(strategy.best(&state).is_none());
411
412        let pop = make_pop(&[0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0], 4, 2);
413        let fitness = make_fitness(&[5.0, 1.0, 9.0, 7.0]);
414        let (state, _m) = strategy.tell(&p, pop, fitness, state, &mut rng);
415        let best = strategy.best(&state);
416        assert!(best.is_some());
417        let (genome, f) = best.unwrap();
418        assert_eq!(genome.dims(), [1, 2]);
419        // Canonical maximise: best is the largest fitness (9.0 at index 2).
420        approx::assert_relative_eq!(f, 9.0, epsilon = 1e-6);
421    }
422
423    #[test]
424    fn k_is_clamped_to_at_least_two_and_at_most_n() {
425        let device = Default::default();
426        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
427        let mut rng = StdRng::seed_from_u64(0);
428
429        // ratio 0.1 of 5 = ceil(0.5) = 1, clamped up to 2.
430        let p = params(5, 0.1, 1);
431        let state = strategy.init(&p, &mut rng, &device);
432        let pop = make_pop(&[5.0, 4.0, 3.0, 2.0, 1.0], 5, 1);
433        let fitness = make_fitness(&[5.0, 4.0, 3.0, 2.0, 1.0]);
434        // Refit must not panic with k clamped to 2.
435        let (state, _m) = strategy.tell(&p, pop, fitness, state, &mut rng);
436        assert_eq!(state.generation, 1);
437
438        // ratio 0.99 of 3 = ceil(2.97) = 3, clamped to n = 3.
439        let p2 = params(3, 0.99, 1);
440        let state2 = strategy.init(&p2, &mut rng, &device);
441        let pop2 = make_pop(&[1.0, 2.0, 3.0], 3, 1);
442        let fitness2 = make_fitness(&[1.0, 2.0, 3.0]);
443        let (state2, _m) = strategy.tell(&p2, pop2, fitness2, state2, &mut rng);
444        assert_eq!(state2.generation, 1);
445    }
446
447    #[test]
448    fn tie_breaking_prefers_lowest_index() {
449        let device = Default::default();
450        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
451        let mut rng = StdRng::seed_from_u64(0);
452        let p = params(4, 0.5, 1);
453        let state = strategy.init(&p, &mut rng, &device);
454        // Two individuals tie for best fitness (5.0 — largest under the
455        // maximise convention) at indices 1 and 3; the best-so-far must be
456        // index 1 (genome value 10.0), not 30.0.
457        let pop = make_pop(&[0.0, 10.0, 20.0, 30.0], 4, 1);
458        let fitness = make_fitness(&[1.0, 5.0, 1.0, 5.0]);
459        let (state, _m) = strategy.tell(&p, pop, fitness, state, &mut rng);
460        let (genome, _f) = strategy.best(&state).unwrap();
461        let v = genome
462            .into_data()
463            .into_vec::<f32>()
464            .expect("genome host-read of a tensor this test just built");
465        approx::assert_relative_eq!(v[0], 10.0, epsilon = 1e-6);
466    }
467
468    #[test]
469    fn nan_fitness_never_becomes_best_and_does_not_break_ordering() {
470        let device = Default::default();
471        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
472        let mut rng = StdRng::seed_from_u64(0);
473        let p = params(4, 0.5, 1);
474        let state = strategy.init(&p, &mut rng, &device);
475        // index 0 is NaN (→ −inf, worst under maximise); the finite best
476        // (9.0) is at index 1.
477        let pop = make_pop(&[0.0, 1.0, 2.0, 3.0], 4, 1);
478        let fitness = make_fitness(&[f32::NAN, 9.0, 2.0, 7.0]);
479        let (state, m) = strategy.tell(&p, pop, fitness, state, &mut rng);
480        let (genome, f) = strategy.best(&state).unwrap();
481        let v = genome
482            .into_data()
483            .into_vec::<f32>()
484            .expect("genome host-read of a tensor this test just built");
485        approx::assert_relative_eq!(v[0], 1.0, epsilon = 1e-6);
486        approx::assert_relative_eq!(f, 9.0, epsilon = 1e-6);
487        assert!(m.best_fitness().is_finite());
488    }
489
490    #[test]
491    fn nonfinite_genome_sanitized_before_fit_yields_finite_samples() {
492        // End-to-end (#129): a population carrying NaN/±inf genes must not
493        // poison the fitted model. The `tell` backstop sanitizes the selected
494        // population, and the per-model guards floor any residual non-finite
495        // statistics, so the next `ask` draws a finite population.
496        let device = Default::default();
497        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
498        let mut rng = StdRng::seed_from_u64(1);
499        let p = params(4, 0.75, 2);
500        let state = strategy.init(&p, &mut rng, &device);
501        let pop = make_pop(
502            &[
503                f32::NAN,
504                0.0, //
505                f32::INFINITY,
506                1.0, //
507                f32::NEG_INFINITY,
508                2.0, //
509                0.5,
510                3.0,
511            ],
512            4,
513            2,
514        );
515        let fitness = make_fitness(&[1.0, 2.0, 3.0, 4.0]);
516        let (state, _m) = strategy.tell(&p, pop, fitness, state, &mut rng);
517        // Fitted state must be finite.
518        for &m in state.model_state.mean() {
519            assert!(m.is_finite(), "fitted mean must be finite, got {m}");
520        }
521        for &v in state.model_state.variance() {
522            assert!(
523                v.is_finite() && v > 0.0,
524                "fitted variance must be finite/positive, got {v}"
525            );
526        }
527        // Sampling the next generation must yield an all-finite population.
528        let (next_pop, _s) = strategy.ask(&p, &state, &mut rng, &device);
529        for x in next_pop
530            .into_data()
531            .into_vec::<f32>()
532            .expect("population host-read of a tensor this test just built")
533        {
534            assert!(x.is_finite(), "sampled genome must be finite, got {x}");
535        }
536    }
537
538    #[test]
539    fn bounds_clamp_is_applied_in_ask() {
540        let device = Default::default();
541        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
542        let mut rng = StdRng::seed_from_u64(7);
543        // Wide init std so unclamped draws routinely exceed the bounds.
544        let p = EdaParams {
545            pop_size: 64,
546            selection_ratio: 0.5,
547            bounds: Some(Bounds::new(-0.5, 0.5)),
548            model: UnivariateGaussianParams {
549                genome_dim: 3,
550                init_mean: 0.0,
551                init_std: 5.0,
552                min_variance: 1e-6,
553            },
554        };
555        let state = strategy.init(&p, &mut rng, &device);
556        let (pop, _s) = strategy.ask(&p, &state, &mut rng, &device);
557        let values = pop
558            .into_data()
559            .into_vec::<f32>()
560            .expect("population host-read of a tensor this test just built");
561        for v in values {
562            assert!((-0.5..=0.5).contains(&v), "value {v} escaped the clamp");
563        }
564    }
565
566    #[cfg(debug_assertions)]
567    #[test]
568    #[should_panic(expected = "selection_ratio")]
569    fn selection_ratio_zero_panics() {
570        let device = Default::default();
571        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
572        let mut rng = StdRng::seed_from_u64(0);
573        let p = params(4, 0.0, 1);
574        let _ = strategy.init(&p, &mut rng, &device);
575    }
576
577    #[cfg(debug_assertions)]
578    #[test]
579    #[should_panic(expected = "selection_ratio")]
580    fn selection_ratio_one_panics() {
581        let device = Default::default();
582        let strategy = EdaStrategy::<TestBackend, _>::new(UnivariateGaussian);
583        let mut rng = StdRng::seed_from_u64(0);
584        let p = params(4, 1.0, 1);
585        let _ = strategy.init(&p, &mut rng, &device);
586    }
587}