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 ¶ms.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 ¶ms.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}