Skip to main content

rlevo_evolution/algorithms/metaheuristic/
gwo.rs

1//! Grey Wolf Optimizer.
2//!
3//! Each generation ranks the pack by fitness, promotes the top three
4//! wolves to `α`, `β`, `δ`, and updates every wolf toward a weighted
5//! average of the three. A linearly decreasing coefficient `a ∈ [2, 0]`
6//! drives the exploration-to-exploitation transition.
7//!
8//! # Update rule
9//!
10//! For each wolf `i` and each leader `k ∈ {α, β, δ}`:
11//!
12//! - `A_k = 2·a·r1 − a`, `C_k = 2·r2` with `r1, r2 ∈ U[0, 1]`,
13//! - `D_k = |C_k · X_k − X_i|`,
14//! - `X_k' = X_k − A_k · D_k`,
15//! - `X_i ← (X_α' + X_β' + X_δ') / 3`.
16//!
17//! # Candor
18//!
19//! Legacy comparator. Camacho Villalón, Dorigo & Stützle (2020)
20//! demonstrate that GWO is algorithmically equivalent to a weighted
21//! PSO-like update; it has no novel search mechanism over Kennedy &
22//! Eberhart (1995). Ship it for API completeness — users who expect a
23//! GWO implementation will find one — but prefer CMA-ES or LSHADE for
24//! serious work when those are available.
25//!
26//! # References
27//!
28//! - Mirjalili, Mirjalili & Lewis (2014), *Grey Wolf Optimizer*.
29//! - Camacho Villalón, Dorigo & Stützle (2020), *Grey Wolf, Firefly and
30//!   Bat Algorithms: Three Widespread Algorithms that Do Not Contain
31//!   Any Novelty*.
32
33use std::marker::PhantomData;
34
35use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
36use rand::Rng;
37use rand::RngExt;
38
39use rlevo_core::bounds::Bounds;
40use rlevo_core::config::{self, ConfigError, Validate};
41
42use super::len_matches_pop;
43use crate::ops::selection::argmax_host;
44use crate::rng::{SeedPurpose, seed_stream};
45use crate::strategy::{Strategy, StrategyMetrics};
46
47/// Static configuration for [`GreyWolfOptimizer`].
48#[derive(Debug, Clone)]
49pub struct GwoConfig {
50    /// Pack size. The algorithm requires `pop_size ≥ 3`.
51    pub pop_size: usize,
52    /// Genome dimensionality.
53    pub genome_dim: usize,
54    /// Search-space bounds.
55    pub bounds: Bounds,
56    /// Budget used to schedule `a = 2·(1 − t/max_generations)`.
57    /// The strategy itself is memoryless with respect to the harness's
58    /// stopping criterion, so this value simply paces the exploration
59    /// coefficient and need not equal the harness budget.
60    pub max_generations: usize,
61}
62
63impl GwoConfig {
64    /// Default configuration for a given population size and genome dimensionality.
65    #[must_use]
66    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
67        Self {
68            pop_size,
69            genome_dim,
70            bounds: Bounds::new(-5.12, 5.12),
71            max_generations: 500,
72        }
73    }
74}
75
76impl Validate for GwoConfig {
77    fn validate(&self) -> Result<(), ConfigError> {
78        const C: &str = "GwoConfig";
79        config::at_least(C, "pop_size", self.pop_size, 3)?;
80        config::nonzero(C, "genome_dim", self.genome_dim)?;
81        config::at_least(C, "max_generations", self.max_generations, 1)?;
82        Ok(())
83    }
84}
85
86/// Generation state for [`GreyWolfOptimizer`].
87#[derive(Debug, Clone)]
88pub struct GwoState<B: Backend> {
89    /// Current pack positions, shape `(pop_size, D)`.
90    pack: Tensor<B, 2>,
91    /// Host-side fitness cache.
92    fitness: Vec<f32>,
93    /// Best-so-far genome, shape `(1, D)` — corresponds to α.
94    best_genome: Option<Tensor<B, 2>>,
95    /// Best-so-far fitness.
96    best_fitness: f32,
97    /// Generation counter.
98    generation: usize,
99}
100
101impl<B: Backend> GwoState<B> {
102    /// Assembles a wolf-pack state, checking the fitness cache matches `pop`.
103    ///
104    /// # Errors
105    ///
106    /// Returns a [`ConfigError`] if `pack` has zero rows or if `fitness` is
107    /// non-empty with a length other than `pop_size`.
108    pub fn try_new(
109        pack: Tensor<B, 2>,
110        fitness: Vec<f32>,
111        best_genome: Option<Tensor<B, 2>>,
112        best_fitness: f32,
113        generation: usize,
114    ) -> Result<Self, ConfigError> {
115        let pop = pack.dims()[0];
116        config::nonzero("GwoState", "pop_size", pop)?;
117        len_matches_pop("GwoState", "fitness", pop, fitness.len())?;
118        Ok(Self {
119            pack,
120            fitness,
121            best_genome,
122            best_fitness,
123            generation,
124        })
125    }
126
127    /// Current pack positions, shape `(pop_size, D)`.
128    #[must_use]
129    pub fn pack(&self) -> &Tensor<B, 2> {
130        &self.pack
131    }
132
133    /// Host-side fitness cache (empty at bootstrap, else `pop_size` long).
134    #[must_use]
135    pub fn fitness(&self) -> &[f32] {
136        &self.fitness
137    }
138
139    /// Best-so-far genome (α), or `None` before the first `tell`.
140    #[must_use]
141    pub fn best_genome(&self) -> Option<&Tensor<B, 2>> {
142        self.best_genome.as_ref()
143    }
144
145    /// Best-so-far (canonical, maximise) fitness.
146    #[must_use]
147    pub fn best_fitness(&self) -> f32 {
148        self.best_fitness
149    }
150
151    /// Generation counter.
152    #[must_use]
153    pub fn generation(&self) -> usize {
154        self.generation
155    }
156}
157
158/// Grey Wolf Optimizer strategy.
159///
160/// # Panics
161///
162/// [`Strategy::init`] panics if `params.pop_size < 3`, since the update
163/// rule needs three distinct leaders (`α`, `β`, `δ`).
164///
165/// # Example
166///
167/// ```no_run
168/// use burn::backend::Flex;
169/// use rlevo_evolution::algorithms::metaheuristic::gwo::{GreyWolfOptimizer, GwoConfig};
170///
171/// let strategy = GreyWolfOptimizer::<Flex>::new();
172/// let params = GwoConfig::default_for(32, 10);
173/// let _ = (strategy, params);
174/// ```
175#[derive(Debug, Clone, Copy, Default)]
176pub struct GreyWolfOptimizer<B: Backend> {
177    _backend: PhantomData<fn() -> B>,
178}
179
180impl<B: Backend> GreyWolfOptimizer<B> {
181    /// Builds a new (stateless) strategy object.
182    #[must_use]
183    pub fn new() -> Self {
184        Self {
185            _backend: PhantomData,
186        }
187    }
188
189    fn sample_initial(
190        params: &GwoConfig,
191        rng: &mut dyn Rng,
192        device: &<B as burn::tensor::backend::BackendTypes>::Device,
193    ) -> Tensor<B, 2> {
194        let (lo, hi): (f32, f32) = params.bounds.into();
195        // Host-sample from a deterministic `seed_stream` rather than the
196        // process-wide Flex RNG (`B::seed` + `Tensor::random`), whose draws
197        // interleave with sibling tests under the parallel runner and are
198        // not reproducible across thread schedules.
199        let pop = params.pop_size;
200        let genome_dim = params.genome_dim;
201        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
202        let mut rows = Vec::with_capacity(pop * genome_dim);
203        for _ in 0..pop * genome_dim {
204            rows.push(lo + (hi - lo) * stream.random::<f32>());
205        }
206        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
207    }
208}
209
210impl<B: Backend> Strategy<B> for GreyWolfOptimizer<B>
211where
212    B::Device: Clone,
213{
214    type Params = GwoConfig;
215    type State = GwoState<B>;
216    type Genome = Tensor<B, 2>;
217
218    /// Samples the initial pack uniformly within [`GwoConfig::bounds`] using
219    /// the host-RNG convention and sets the generation counter to zero.
220    ///
221    /// The `pop_size >= 3` invariant is enforced by [`Validate::validate`] at
222    /// the harness chokepoint.
223    fn init(
224        &self,
225        params: &GwoConfig,
226        rng: &mut dyn Rng,
227        device: &<B as burn::tensor::backend::BackendTypes>::Device,
228    ) -> GwoState<B> {
229        debug_assert!(
230            params.validate().is_ok(),
231            "invalid GwoConfig reached init: {params:?}"
232        );
233        let pack = Self::sample_initial(params, rng, device);
234        GwoState {
235            pack,
236            fitness: Vec::new(),
237            best_genome: None,
238            best_fitness: f32::NEG_INFINITY,
239            generation: 0,
240        }
241    }
242
243    /// Proposes the next pack positions.
244    ///
245    /// On the first call (before any [`tell`](Strategy::tell)), returns the
246    /// initial pack unchanged so it can be evaluated before the first rank
247    /// step. On subsequent calls, promotes the three highest-fitness wolves to
248    /// `α`, `β`, `δ` and computes the weighted-average update, then clamps
249    /// the result to [`GwoConfig::bounds`].
250    fn ask(
251        &self,
252        params: &GwoConfig,
253        state: &GwoState<B>,
254        rng: &mut dyn Rng,
255        device: &<B as burn::tensor::backend::BackendTypes>::Device,
256    ) -> (Tensor<B, 2>, GwoState<B>) {
257        // First call: evaluate initial pack so `tell` can rank it.
258        if state.fitness.is_empty() {
259            return (state.pack.clone(), state.clone());
260        }
261
262        let pop_size = params.pop_size;
263        let genome_dim = params.genome_dim;
264
265        // Rank the pack host-side — O(N log N) is noise compared to the
266        // device ops below, and sorting on the host keeps us free of
267        // backend-specific `argsort` quirks.
268        let top3 = argtop3_max(&state.fitness);
269
270        #[allow(clippy::cast_possible_wrap)]
271        let idx = Tensor::<B, 1, Int>::from_data(
272            TensorData::new(vec![top3[0] as i64, top3[1] as i64, top3[2] as i64], [3]),
273            device,
274        );
275        let leaders = state.pack.clone().select(0, idx); // (3, D)
276
277        // Linearly decrease a from 2 to 0.
278        #[allow(clippy::cast_precision_loss)]
279        let t = state.generation as f32;
280        #[allow(clippy::cast_precision_loss)]
281        let max_t = params.max_generations.max(1) as f32;
282        let a = 2.0 * (1.0 - (t / max_t).min(1.0));
283
284        let mut update = Tensor::<B, 2>::zeros([pop_size, genome_dim], device);
285        // Host-sample r1, r2 ∈ U[0,1) per leader from deterministic
286        // `seed_stream`s (distinct purposes) so the draws stay reproducible
287        // across thread schedules, rather than racing the global Flex RNG.
288        #[allow(clippy::cast_sign_loss)]
289        for k in 0..3 {
290            let gen_k = state.generation as u64 * 3 + k as u64;
291            let r1 = {
292                let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Other);
293                let mut rows = Vec::with_capacity(pop_size * genome_dim);
294                for _ in 0..pop_size * genome_dim {
295                    rows.push(s.random::<f32>());
296                }
297                Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
298            };
299            let r2 = {
300                let mut s = seed_stream(rng.next_u64(), gen_k, SeedPurpose::Mutation);
301                let mut rows = Vec::with_capacity(pop_size * genome_dim);
302                for _ in 0..pop_size * genome_dim {
303                    rows.push(s.random::<f32>());
304                }
305                Tensor::<B, 2>::from_data(TensorData::new(rows, [pop_size, genome_dim]), device)
306            };
307            let a_mat = r1.mul_scalar(2.0 * a).sub_scalar(a);
308            let c_mat = r2.mul_scalar(2.0);
309
310            #[allow(clippy::single_range_in_vec_init)]
311            let leader_row = leaders.clone().slice([k..k + 1]);
312            let leader_exp = leader_row.expand([pop_size, genome_dim]);
313            let d_k = (c_mat.mul(leader_exp.clone()) - state.pack.clone()).abs();
314            let x_k_prime = leader_exp - a_mat.mul(d_k);
315            update = update + x_k_prime;
316        }
317        let new_pack = update.div_scalar(3.0);
318        let (lo, hi): (f32, f32) = params.bounds.into();
319        let new_pack = new_pack.clamp(lo, hi);
320
321        let mut next = state.clone();
322        next.pack.clone_from(&new_pack);
323        (new_pack, next)
324    }
325
326    /// Records evaluated fitness, updates best-so-far, and increments the
327    /// generation counter.
328    ///
329    /// Returns the updated [`GwoState`] and a [`StrategyMetrics`] snapshot
330    /// for the completed generation.
331    fn tell(
332        &self,
333        _params: &GwoConfig,
334        population: Tensor<B, 2>,
335        fitness: Tensor<B, 1>,
336        mut state: GwoState<B>,
337        _rng: &mut dyn Rng,
338    ) -> (GwoState<B>, StrategyMetrics) {
339        let fitness_host = fitness
340            .into_data()
341            .into_vec::<f32>()
342            .expect("fitness tensor must be readable as f32");
343        state.fitness.clone_from(&fitness_host);
344        state.pack.clone_from(&population);
345        let best_idx = argmax_host(&fitness_host);
346        if fitness_host[best_idx] > state.best_fitness {
347            state.best_fitness = fitness_host[best_idx];
348            let device = population.device();
349            #[allow(clippy::cast_possible_wrap)]
350            let idx = Tensor::<B, 1, Int>::from_data(
351                TensorData::new(vec![best_idx as i64], [1]),
352                &device,
353            );
354            state.best_genome = Some(population.select(0, idx));
355        }
356        state.generation += 1;
357        let m =
358            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
359        state.best_fitness = m.best_fitness_ever();
360        (state, m)
361    }
362
363    /// Returns the α (best-so-far) genome and its fitness, or `None` before
364    /// the first [`tell`](Strategy::tell) call.
365    fn best(&self, state: &GwoState<B>) -> Option<(Tensor<B, 2>, f32)> {
366        state
367            .best_genome
368            .as_ref()
369            .map(|g| (g.clone(), state.best_fitness))
370    }
371}
372
373/// Indices of the three largest values in `xs` — the α, β, δ leaders.
374///
375/// Values are sanitised per the maximise convention (`rules.md` §3,
376/// ADR 0034) before comparison: `NaN → −∞` (worst), `+∞ → f32::MAX`. The
377/// harness chokepoint already pre-sanitises the fitness a [`Strategy`]
378/// receives, but a direct (non-harness) caller — a unit test or a custom
379/// driver — can seed `state.fitness` with a raw `NaN`; sanitising here keeps a
380/// non-finite value from becoming a permanent leader (a `NaN` in `xs[2]` would
381/// otherwise never lose the `v > vals[2]` comparison and be pinned as δ).
382///
383/// # Panics
384///
385/// Panics if `xs.len() < 3`.
386fn argtop3_max(xs: &[f32]) -> [usize; 3] {
387    assert!(xs.len() >= 3, "argtop3_max requires at least 3 elements");
388    let sane = |i: usize| crate::fitness::sanitize_fitness(xs[i]);
389    let mut idx = [0usize, 1, 2];
390    let mut vals = [sane(0), sane(1), sane(2)];
391    // Sort the initial three descending.
392    if vals[0] < vals[1] {
393        vals.swap(0, 1);
394        idx.swap(0, 1);
395    }
396    if vals[1] < vals[2] {
397        vals.swap(1, 2);
398        idx.swap(1, 2);
399    }
400    if vals[0] < vals[1] {
401        vals.swap(0, 1);
402        idx.swap(0, 1);
403    }
404    for i in 3..xs.len() {
405        let v = sane(i);
406        if v > vals[2] {
407            vals[2] = v;
408            idx[2] = i;
409            if vals[1] < vals[2] {
410                vals.swap(1, 2);
411                idx.swap(1, 2);
412            }
413            if vals[0] < vals[1] {
414                vals.swap(0, 1);
415                idx.swap(0, 1);
416            }
417        }
418    }
419    idx
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::fitness::{BatchFitnessFn, FromFitnessEvaluable};
426    use crate::strategy::EvolutionaryHarness;
427    use burn::backend::Flex;
428    use rand::SeedableRng;
429    use rand::rngs::StdRng;
430    use rlevo_core::fitness::FitnessEvaluable;
431    use rlevo_core::objective::ObjectiveSense;
432
433    type TestBackend = Flex;
434
435    /// Objective whose row 0 evaluates to `NaN` (the rest finite). `Maximize`
436    /// so natural == canonical; only the harness sanitize keeps the run finite.
437    struct NanFitness;
438    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for NanFitness {
439        fn evaluate_batch(
440            &mut self,
441            population: &Tensor<B, 2>,
442            device: &<B as burn::tensor::backend::BackendTypes>::Device,
443        ) -> Tensor<B, 1> {
444            let n = population.dims()[0];
445            #[allow(clippy::cast_precision_loss)]
446            let mut vals: Vec<f32> = (0..n).map(|i| i as f32).collect();
447            vals[0] = f32::NAN;
448            Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
449        }
450        fn sense(&self) -> ObjectiveSense {
451            ObjectiveSense::Maximize
452        }
453    }
454
455    #[test]
456    fn try_new_checks_fitness_length() {
457        let device = Default::default();
458        let pack = Tensor::<TestBackend, 2>::zeros([3, 2], &device);
459        assert!(GwoState::try_new(pack.clone(), vec![1.0; 3], None, 1.0, 1).is_ok());
460        assert!(GwoState::try_new(pack.clone(), vec![], None, f32::MIN, 0).is_ok());
461        assert!(GwoState::try_new(pack, vec![1.0; 2], None, 1.0, 1).is_err());
462        let empty = Tensor::<TestBackend, 2>::zeros([0, 2], &device);
463        assert!(GwoState::try_new(empty, vec![], None, 1.0, 0).is_err());
464    }
465
466    #[test]
467    fn default_config_validates() {
468        assert!(GwoConfig::default_for(30, 10).validate().is_ok());
469    }
470
471    #[test]
472    fn rejects_pop_size_below_three() {
473        let mut cfg = GwoConfig::default_for(30, 10);
474        cfg.pop_size = 2;
475        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
476    }
477
478    struct Sphere;
479    struct SphereFit;
480    impl FitnessEvaluable for SphereFit {
481        type Individual = Vec<f64>;
482        type Landscape = Sphere;
483        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
484            x.iter().map(|v| v * v).sum()
485        }
486    }
487
488    #[test]
489    fn argtop3_max_finds_three_largest() {
490        let xs = [5.0, 2.0, 8.0, 1.0, 3.0, 9.0, 0.5];
491        let top = argtop3_max(&xs);
492        // Values at indices: 5 (9.0), 2 (8.0), 0 (5.0).
493        assert_eq!(top, [5, 2, 0]);
494    }
495
496    // Regression for the direct-caller (non-harness) bypass path: a raw NaN in
497    // `state.fitness` must sanitise to −∞ (worst) and never be pinned as a
498    // leader. Before the sanitise fix a NaN in `xs[2]` was never displaced by
499    // `v > vals[2]` (NaN loses every comparison), silently freezing it as δ.
500    #[test]
501    fn argtop3_max_nan_never_becomes_a_leader() {
502        // NaN sits at index 2 — exactly the slot that survived unsanitised.
503        let xs = [5.0_f32, 2.0, f32::NAN, 8.0, 3.0, 9.0];
504        let top = argtop3_max(&xs);
505        // The three finite maxima are at indices 5 (9.0), 3 (8.0), 0 (5.0);
506        // the NaN row (index 2) must be excluded from the leader set.
507        assert!(
508            !top.contains(&2),
509            "NaN-fitness row must not be selected as an α/β/δ leader, got {top:?}"
510        );
511        assert_eq!(
512            top,
513            [5, 3, 0],
514            "leaders must be the three strictly-largest finite rows"
515        );
516    }
517
518    // The strictly-highest-fitness row must be picked as α (index 0 of the
519    // returned triple), proving the comparison direction matches maximise.
520    #[test]
521    fn argtop3_max_alpha_is_the_strict_maximum() {
522        let xs = [1.0_f32, 7.0, 3.0, 42.0, 2.0, 5.0];
523        let top = argtop3_max(&xs);
524        assert_eq!(
525            top[0], 3,
526            "the strictly-highest-fitness row (index 3) must be the α leader, got {top:?}"
527        );
528    }
529
530    #[test]
531    fn gwo_converges_on_sphere_d10() {
532        // GWO is a "legacy comparator" per the module-level candor note;
533        // it converges strongly on Sphere but does not reach machine
534        // precision as quickly as DE/Rand1/bin. Budget 600 gens keeps
535        // the test within the acceptance bar (rank-1 acceptable within
536        // 2× of the classical baselines).
537        let device = Default::default();
538        let strategy = GreyWolfOptimizer::<TestBackend>::new();
539        let params = GwoConfig::default_for(32, 10);
540        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
541        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
542            strategy, params, fitness_fn, 11, device, 600,
543        )
544        .expect("valid params");
545        harness.reset();
546        while !harness.step(()).done {}
547        let best = harness.latest_metrics().unwrap().best_fitness_ever();
548        assert!(best < 1e-3, "GWO D10 best={best}");
549    }
550
551    #[test]
552    fn argtop3_max_all_equal_returns_stable_prefix() {
553        // All values tie: the strict `<`/`>` comparisons never fire, so the
554        // initial index prefix [0, 1, 2] is returned unchanged.
555        let xs = [5.0_f32, 5.0, 5.0, 5.0];
556        assert_eq!(argtop3_max(&xs), [0, 1, 2]);
557    }
558
559    #[test]
560    fn argtop3_max_handles_duplicate_maxima() {
561        // Three rows share the maximum value; the leader set must be exactly
562        // those three, each a distinct index.
563        let xs = [3.0_f32, 9.0, 9.0, 1.0, 9.0];
564        let top = argtop3_max(&xs);
565        assert!(
566            top[0] != top[1] && top[1] != top[2] && top[0] != top[2],
567            "leaders must be distinct rows, got {top:?}"
568        );
569        for &i in &top {
570            approx::assert_relative_eq!(xs[i], 9.0);
571        }
572    }
573
574    #[test]
575    fn minimal_pack_of_three_runs() {
576        // pop_size = 3 is the α/β/δ minimum — every wolf is also a leader.
577        let device = Default::default();
578        let strategy = GreyWolfOptimizer::<TestBackend>::new();
579        let params = GwoConfig::default_for(3, 3);
580        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
581        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
582            strategy, params, fitness_fn, 0, device, 5,
583        )
584        .expect("valid params");
585        harness.reset();
586        while !harness.step(()).done {}
587        assert!(
588            harness
589                .latest_metrics()
590                .unwrap()
591                .best_fitness_ever()
592                .is_finite()
593        );
594    }
595
596    #[test]
597    fn rejects_max_generations_zero() {
598        // The validator rejects `max_generations = 0`; the `.max(1)` in `ask`
599        // is defensive-only and unreachable through the harness chokepoint.
600        let mut cfg = GwoConfig::default_for(3, 3);
601        cfg.max_generations = 0;
602        assert_eq!(cfg.validate().unwrap_err().field, "max_generations");
603    }
604
605    #[test]
606    fn ask_survives_zero_max_generations_via_guard() {
607        // Exercise the `.max(1)` division guard directly: an invalid
608        // `max_generations = 0` cannot reach `init` (its debug_assert would
609        // fire), so we build the post-bootstrap state through the public
610        // `try_new` constructor and drive one `ask`. The guard must prevent a
611        // divide-by-zero and yield a finite, in-bounds pack.
612        let device = Default::default();
613        let strategy = GreyWolfOptimizer::<TestBackend>::new();
614        let mut cfg = GwoConfig::default_for(3, 3);
615        cfg.max_generations = 0;
616        let (lo, hi): (f32, f32) = cfg.bounds.into();
617        let pack = Tensor::<TestBackend, 2>::zeros([3, 3], &device);
618        let best = pack.clone().slice([0..1, 0..3]);
619        let state =
620            GwoState::try_new(pack, vec![1.0, 2.0, 3.0], Some(best), 3.0, 0).expect("valid state");
621        let mut rng = StdRng::seed_from_u64(0);
622        let (new_pack, _next) = strategy.ask(&cfg, &state, &mut rng, &device);
623        let values = new_pack.into_data().into_vec::<f32>().unwrap();
624        for v in values {
625            assert!(v.is_finite(), "guard failed: non-finite {v}");
626            assert!(v >= lo - 1e-4 && v <= hi + 1e-4, "out of bounds: {v}");
627        }
628    }
629
630    #[test]
631    fn inverted_bounds_are_unrepresentable() {
632        // As for the other metaheuristics, bound ordering is a type invariant
633        // (`Bounds`, ADR 0027) rather than a `GwoConfig::validate` check.
634        assert!(Bounds::try_new(5.12, -5.12).is_err());
635        assert!(Bounds::try_new(3.0, 3.0).is_ok());
636    }
637
638    #[test]
639    fn first_ask_returns_initial_pack_unchanged() {
640        let device = Default::default();
641        let strategy = GreyWolfOptimizer::<TestBackend>::new();
642        let params = GwoConfig::default_for(4, 3);
643        let mut rng = StdRng::seed_from_u64(2);
644        let state = strategy.init(&params, &mut rng, &device);
645        let expected = state.pack().clone().into_data().into_vec::<f32>().unwrap();
646        let (pack, _state) = strategy.ask(&params, &state, &mut rng, &device);
647        let got = pack.into_data().into_vec::<f32>().unwrap();
648        assert_eq!(expected, got);
649    }
650
651    #[test]
652    fn nan_fitness_through_harness_stays_finite() {
653        let device = Default::default();
654        let strategy = GreyWolfOptimizer::<TestBackend>::new();
655        let params = GwoConfig::default_for(3, 3);
656        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
657            strategy, params, NanFitness, 1, device, 3,
658        )
659        .expect("valid params");
660        harness.reset();
661        while !harness.step(()).done {}
662        let m = harness.latest_metrics().unwrap();
663        assert!(
664            m.best_fitness_ever().is_finite(),
665            "best={}",
666            m.best_fitness_ever()
667        );
668        assert!(m.broken_count() >= 1, "the NaN row must be counted broken");
669    }
670}