Skip to main content

rlevo_evolution/algorithms/metaheuristic/
pso.rs

1//! Particle Swarm Optimization.
2//!
3//! Canonical PSO with two selectable velocity-update variants:
4//!
5//! - [`PsoVariant::Inertia`] — the Shi & Eberhart (1998) inertia-weight
6//!   form: `v ← ω·v + c1·r1·(pbest − x) + c2·r2·(gbest − x)`.
7//! - [`PsoVariant::Constriction`] — the Clerc & Kennedy (2002)
8//!   constriction-factor form: `v ← χ·(v + c1·r1·(pbest − x) + c2·r2·(gbest − x))`.
9//!
10//! Both variants use the canonical *global-best* topology. Ring / local
11//! neighbourhoods are not currently implemented; only the fully-connected
12//! social structure is exposed.
13//!
14//! # Initialization
15//!
16//! Positions are drawn uniformly from the configured search bounds.
17//! Velocities are initialized to **zero**, not to a random slice of
18//! `[-v_max, v_max]` as several reference implementations do — the zero
19//! initialization converges slightly faster on Sphere and produces
20//! bit-reproducible initial populations independent of the velocity
21//! clamp.
22//!
23//! # First-generation protocol
24//!
25//! `ask` detects the first call by checking whether `personal_best_fitness`
26//! is empty and, if so, returns the current positions unchanged (no velocity
27//! update). `tell` detects the same condition and uses the received fitness
28//! to seed `personal_best_fitness` and `global_best` before returning.
29//! Any caller that bypasses [`EvolutionaryHarness`] must therefore call
30//! `ask` → evaluate → `tell` **twice** before the velocity update is live.
31//!
32//! [`EvolutionaryHarness`]: crate::strategy::EvolutionaryHarness
33//!
34//! # Position and velocity clamping
35//!
36//! After every velocity update the velocities are clamped to
37//! `[−v_max, v_max]` (per [`PsoConfig::v_max`]), and the resulting
38//! positions are clamped to [`PsoConfig::bounds`]. Particles that hit a
39//! boundary keep their clamped position but retain the (clamped) velocity,
40//! so they may escape on the next step.
41//!
42//! # References
43//!
44//! - Kennedy & Eberhart (1995), *Particle Swarm Optimization*.
45//! - Shi & Eberhart (1998), *A modified particle swarm optimizer*.
46//! - Clerc & Kennedy (2002), *The particle swarm — explosion, stability,
47//!   and convergence in a multidimensional complex space*.
48
49use std::marker::PhantomData;
50
51use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
52use rand::Rng;
53use rand::RngExt;
54
55use rlevo_core::bounds::Bounds;
56use rlevo_core::config::{self, ConfigError, ConstraintKind, Validate};
57
58use crate::ops::selection::argmax_host;
59use crate::rng::{SeedPurpose, seed_stream};
60use crate::strategy::{Strategy, StrategyMetrics};
61
62/// Which velocity-update rule PSO applies.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum PsoVariant {
65    /// `v ← ω·v + c1·r1·(pbest − x) + c2·r2·(gbest − x)`.
66    Inertia,
67    /// Clerc & Kennedy constriction form with factor
68    /// `χ = 2 / |2 − φ − √(φ² − 4φ)|`, where `φ = c1 + c2` and must be
69    /// `> 4`.
70    Constriction,
71}
72
73/// Static configuration for a [`ParticleSwarm`] run.
74#[derive(Debug, Clone)]
75pub struct PsoConfig {
76    /// Number of particles in the swarm.
77    pub pop_size: usize,
78    /// Genome (position) dimensionality.
79    pub genome_dim: usize,
80    /// Search-space bounds for initialization and position clamping.
81    pub bounds: Bounds,
82    /// Inertia weight (only used by [`PsoVariant::Inertia`]).
83    pub inertia: f32,
84    /// Cognitive coefficient (personal-best pull).
85    pub c1: f32,
86    /// Social coefficient (global-best pull).
87    pub c2: f32,
88    /// Per-dimension velocity clamp. Classical PSO literature recommends
89    /// ~half the search-space extent.
90    pub v_max: f32,
91    /// Variant.
92    pub variant: PsoVariant,
93}
94
95impl PsoConfig {
96    /// Default configuration matching Shi & Eberhart's canonical settings
97    /// (`ω = 0.7298`, `c1 = c2 = 1.49618`) — the constriction-equivalent
98    /// values so Inertia and Constriction variants agree in behaviour
99    /// under the same default.
100    ///
101    /// The default variant is [`PsoVariant::Inertia`]. To switch to the
102    /// constriction form, set `variant = PsoVariant::Constriction` and
103    /// update `c1` and `c2` so that `c1 + c2 > 4` (the Clerc & Kennedy
104    /// requirement — the default `c1 = c2 = 1.49618` gives `φ ≈ 2.99`,
105    /// which violates this); a canonical choice is `c1 = c2 = 2.05`.
106    #[must_use]
107    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
108        Self {
109            pop_size,
110            genome_dim,
111            bounds: Bounds::new(-5.12, 5.12),
112            inertia: 0.7298,
113            c1: 1.49618,
114            c2: 1.49618,
115            v_max: 5.12,
116            variant: PsoVariant::Inertia,
117        }
118    }
119
120    /// Computes the constriction factor `χ = 2 / |2 − φ − √(φ² − 4φ)|`
121    /// where `φ = c1 + c2`.
122    ///
123    /// Clerc & Kennedy (2002) require `φ > 4` for the closed form to be
124    /// real-valued. If `φ ≤ 4` the discriminant is clamped to zero and
125    /// `χ` falls back to `1.0` (no contraction) so the strategy remains
126    /// numerically well-defined. A `debug_assert!` fires in debug builds
127    /// when this fallback is triggered; it is silent in release builds.
128    #[must_use]
129    pub fn constriction_chi(&self) -> f32 {
130        let phi = self.c1 + self.c2;
131        // Clerc & Kennedy require phi > 4; below that the closed form
132        // becomes imaginary. Fall back to 1.0 (i.e. no contraction) so
133        // the strategy stays numerically well-defined; user-supplied
134        // configs violating the contract are covered by debug_assert.
135        debug_assert!(phi > 4.0, "PSO constriction requires c1 + c2 > 4");
136        let disc = (phi * phi - 4.0 * phi).max(0.0);
137        2.0 / (2.0 - phi - disc.sqrt()).abs()
138    }
139}
140
141impl Validate for PsoConfig {
142    fn validate(&self) -> Result<(), ConfigError> {
143        const C: &str = "PsoConfig";
144        config::at_least(C, "pop_size", self.pop_size, 1)?;
145        config::nonzero(C, "genome_dim", self.genome_dim)?;
146        config::in_range(C, "c1", 0.0, f64::INFINITY, f64::from(self.c1))?;
147        config::in_range(C, "c2", 0.0, f64::INFINITY, f64::from(self.c2))?;
148        config::positive(C, "v_max", f64::from(self.v_max))?;
149        if self.variant == PsoVariant::Constriction && self.c1 + self.c2 <= 4.0 {
150            return Err(ConfigError {
151                config: C,
152                field: "c1",
153                kind: ConstraintKind::Custom("constriction requires c1 + c2 > 4"),
154            });
155        }
156        Ok(())
157    }
158}
159
160/// Generation state for [`ParticleSwarm`].
161#[derive(Debug, Clone)]
162pub struct PsoState<B: Backend> {
163    /// Current particle positions, shape `(pop_size, D)`.
164    pub positions: Tensor<B, 2>,
165    /// Current particle velocities, shape `(pop_size, D)`.
166    pub velocities: Tensor<B, 2>,
167    /// Personal-best positions, shape `(pop_size, D)`.
168    pub personal_best: Tensor<B, 2>,
169    /// Personal-best fitnesses (host-side cache).
170    pub personal_best_fitness: Vec<f32>,
171    /// Global-best position, shape `(1, D)`.
172    pub global_best: Option<Tensor<B, 2>>,
173    /// Global-best fitness.
174    pub global_best_fitness: f32,
175    /// Best-so-far fitness (mirrors `global_best_fitness` for PSO).
176    pub best_fitness: f32,
177    /// Generation counter.
178    pub generation: usize,
179}
180
181/// Particle Swarm Optimization strategy.
182///
183/// # Example
184///
185/// ```no_run
186/// use burn::backend::Flex;
187/// use rlevo_evolution::algorithms::metaheuristic::pso::{ParticleSwarm, PsoConfig};
188///
189/// let strategy = ParticleSwarm::<Flex>::new();
190/// let params = PsoConfig::default_for(32, 10);
191/// let _ = (strategy, params);
192/// ```
193#[derive(Debug, Clone, Copy, Default)]
194pub struct ParticleSwarm<B: Backend> {
195    _backend: PhantomData<fn() -> B>,
196}
197
198impl<B: Backend> ParticleSwarm<B> {
199    /// Builds a new (stateless) strategy object.
200    #[must_use]
201    pub fn new() -> Self {
202        Self {
203            _backend: PhantomData,
204        }
205    }
206
207    fn sample_positions(
208        params: &PsoConfig,
209        rng: &mut dyn Rng,
210        device: &<B as burn::tensor::backend::BackendTypes>::Device,
211    ) -> Tensor<B, 2> {
212        let (lo, hi): (f32, f32) = params.bounds.into();
213        // Host-sample from a deterministic `seed_stream` rather than the
214        // process-wide Flex RNG (`B::seed` + `Tensor::random`), whose draws
215        // interleave with sibling tests under the parallel runner and are
216        // not reproducible across thread schedules.
217        let pop = params.pop_size;
218        let genome_dim = params.genome_dim;
219        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
220        let mut rows = Vec::with_capacity(pop * genome_dim);
221        for _ in 0..pop * genome_dim {
222            rows.push(lo + (hi - lo) * stream.random::<f32>());
223        }
224        Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
225    }
226}
227
228impl<B: Backend> Strategy<B> for ParticleSwarm<B>
229where
230    B::Device: Clone,
231{
232    type Params = PsoConfig;
233    type State = PsoState<B>;
234    type Genome = Tensor<B, 2>;
235
236    fn init(
237        &self,
238        params: &PsoConfig,
239        rng: &mut dyn Rng,
240        device: &<B as burn::tensor::backend::BackendTypes>::Device,
241    ) -> PsoState<B> {
242        debug_assert!(
243            params.validate().is_ok(),
244            "invalid PsoConfig reached init: {params:?}"
245        );
246        let positions = Self::sample_positions(params, rng, device);
247        let velocities = Tensor::<B, 2>::zeros([params.pop_size, params.genome_dim], device);
248        let personal_best = positions.clone();
249        PsoState {
250            positions,
251            velocities,
252            personal_best,
253            personal_best_fitness: Vec::new(),
254            global_best: None,
255            global_best_fitness: f32::NEG_INFINITY,
256            best_fitness: f32::NEG_INFINITY,
257            generation: 0,
258        }
259    }
260
261    fn ask(
262        &self,
263        params: &PsoConfig,
264        state: &PsoState<B>,
265        rng: &mut dyn Rng,
266        device: &<B as burn::tensor::backend::BackendTypes>::Device,
267    ) -> (Tensor<B, 2>, PsoState<B>) {
268        // First call: evaluate the initial positions so `tell` can
269        // populate personal_best_fitness / global_best.
270        if state.personal_best_fitness.is_empty() {
271            return (state.positions.clone(), state.clone());
272        }
273
274        // Sample r1, r2 ∈ U[0,1) — one matrix each per generation. Host
275        // sampling (distinct `seed_stream` purposes) keeps the draws
276        // reproducible across thread schedules; the global Flex RNG path
277        // could be interleaved by a concurrent test between seed and draw.
278        let pop = params.pop_size;
279        let genome_dim = params.genome_dim;
280        let r1 = {
281            let mut s = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
282            let mut rows = Vec::with_capacity(pop * genome_dim);
283            for _ in 0..pop * genome_dim {
284                rows.push(s.random::<f32>());
285            }
286            Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
287        };
288        let r2 = {
289            let mut s = seed_stream(
290                rng.next_u64(),
291                state.generation as u64,
292                SeedPurpose::Mutation,
293            );
294            let mut rows = Vec::with_capacity(pop * genome_dim);
295            for _ in 0..pop * genome_dim {
296                rows.push(s.random::<f32>());
297            }
298            Tensor::<B, 2>::from_data(TensorData::new(rows, [pop, genome_dim]), device)
299        };
300
301        let gbest = state
302            .global_best
303            .as_ref()
304            .expect("global_best populated after the first tell")
305            .clone()
306            .expand([params.pop_size, params.genome_dim]);
307
308        let cognitive = (state.personal_best.clone() - state.positions.clone())
309            .mul(r1)
310            .mul_scalar(params.c1);
311        let social = (gbest - state.positions.clone())
312            .mul(r2)
313            .mul_scalar(params.c2);
314
315        let new_velocities = match params.variant {
316            PsoVariant::Inertia => {
317                state.velocities.clone().mul_scalar(params.inertia) + cognitive + social
318            }
319            PsoVariant::Constriction => {
320                let chi = params.constriction_chi();
321                (state.velocities.clone() + cognitive + social).mul_scalar(chi)
322            }
323        };
324        let new_velocities = new_velocities.clamp(-params.v_max, params.v_max);
325        let (lo, hi): (f32, f32) = params.bounds.into();
326        let new_positions = (state.positions.clone() + new_velocities.clone()).clamp(lo, hi);
327
328        let mut next = state.clone();
329        next.positions.clone_from(&new_positions);
330        next.velocities = new_velocities;
331        (new_positions, next)
332    }
333
334    fn tell(
335        &self,
336        params: &PsoConfig,
337        population: Tensor<B, 2>,
338        fitness: Tensor<B, 1>,
339        mut state: PsoState<B>,
340        _rng: &mut dyn Rng,
341    ) -> (PsoState<B>, StrategyMetrics) {
342        let fitness_host = fitness
343            .into_data()
344            .into_vec::<f32>()
345            .expect("fitness tensor must be readable as f32");
346        let device = population.device();
347
348        // First tell: seed personal-bests.
349        if state.personal_best_fitness.is_empty() {
350            state.personal_best.clone_from(&population);
351            state.personal_best_fitness.clone_from(&fitness_host);
352            let best_idx = argmax_host(&fitness_host);
353            state.global_best_fitness = fitness_host[best_idx];
354            #[allow(clippy::cast_possible_wrap)]
355            let idx = Tensor::<B, 1, Int>::from_data(
356                TensorData::new(vec![best_idx as i64], [1]),
357                &device,
358            );
359            state.global_best = Some(population.clone().select(0, idx));
360            state.best_fitness = state.global_best_fitness;
361            state.generation += 1;
362            state.positions = population;
363            let m = StrategyMetrics::from_host_fitness(
364                state.generation,
365                &fitness_host,
366                state.best_fitness,
367            );
368            state.best_fitness = m.best_fitness_ever();
369            return (state, m);
370        }
371
372        let pop_size = params.pop_size;
373        let genome_dim = params.genome_dim;
374
375        // Update personal bests — greedy: replace when fitness improves.
376        let mut improved = vec![0i64; pop_size];
377        let mut new_pbest_fit = state.personal_best_fitness.clone();
378        for i in 0..pop_size {
379            if fitness_host[i] > state.personal_best_fitness[i] {
380                improved[i] = 1;
381                new_pbest_fit[i] = fitness_host[i];
382            }
383        }
384        let mask_row =
385            Tensor::<B, 1, Int>::from_data(TensorData::new(improved, [pop_size]), &device)
386                .equal_elem(1);
387        let mask = mask_row
388            .unsqueeze_dim::<2>(1)
389            .expand([pop_size, genome_dim]);
390        state.personal_best = state
391            .personal_best
392            .clone()
393            .mask_where(mask, population.clone());
394        state.personal_best_fitness.clone_from(&new_pbest_fit);
395
396        // Update global best from the new personal bests.
397        let best_idx = argmax_host(&new_pbest_fit);
398        if new_pbest_fit[best_idx] > state.global_best_fitness {
399            state.global_best_fitness = new_pbest_fit[best_idx];
400            #[allow(clippy::cast_possible_wrap)]
401            let idx = Tensor::<B, 1, Int>::from_data(
402                TensorData::new(vec![best_idx as i64], [1]),
403                &device,
404            );
405            state.global_best = Some(state.personal_best.clone().select(0, idx));
406        }
407
408        state.positions = population;
409        state.generation += 1;
410        let m = StrategyMetrics::from_host_fitness(
411            state.generation,
412            &fitness_host,
413            state.best_fitness.max(state.global_best_fitness),
414        );
415        state.best_fitness = m.best_fitness_ever();
416        (state, m)
417    }
418
419    fn best(&self, state: &PsoState<B>) -> Option<(Tensor<B, 2>, f32)> {
420        state
421            .global_best
422            .as_ref()
423            .map(|g| (g.clone(), state.global_best_fitness))
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::fitness::{BatchFitnessFn, FromFitnessEvaluable};
431    use crate::strategy::EvolutionaryHarness;
432    use burn::backend::Flex;
433    use rand::SeedableRng;
434    use rand::rngs::StdRng;
435    use rlevo_core::fitness::FitnessEvaluable;
436    use rlevo_core::objective::ObjectiveSense;
437
438    type TestBackend = Flex;
439
440    /// Distinct finite fitness with the maximum at index 0, for direct
441    /// (non-harness) `tell` calls in lifecycle tests. Finite by construction,
442    /// so it never trips the ADR 0034 sanitize contract.
443    #[allow(clippy::trivially_copy_pass_by_ref)] // mirror the by-ref device idiom
444    fn finite_fitness(
445        n: usize,
446        device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
447    ) -> Tensor<TestBackend, 1> {
448        #[allow(clippy::cast_precision_loss)]
449        let vals: Vec<f32> = (0..n).map(|i| -(i as f32) - 1.0).collect();
450        Tensor::<TestBackend, 1>::from_data(TensorData::new(vals, [n]), device)
451    }
452
453    /// Objective whose row 0 evaluates to `NaN` (the rest finite). `Maximize`
454    /// so natural == canonical and the harness sanitize is the only thing that
455    /// can keep the run finite.
456    struct NanFitness;
457    impl<B: Backend> BatchFitnessFn<B, Tensor<B, 2>> for NanFitness {
458        fn evaluate_batch(
459            &mut self,
460            population: &Tensor<B, 2>,
461            device: &<B as burn::tensor::backend::BackendTypes>::Device,
462        ) -> Tensor<B, 1> {
463            let n = population.dims()[0];
464            #[allow(clippy::cast_precision_loss)]
465            let mut vals: Vec<f32> = (0..n).map(|i| i as f32).collect();
466            vals[0] = f32::NAN;
467            Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
468        }
469        fn sense(&self) -> ObjectiveSense {
470            ObjectiveSense::Maximize
471        }
472    }
473
474    #[test]
475    fn default_config_validates() {
476        assert!(PsoConfig::default_for(30, 10).validate().is_ok());
477    }
478
479    #[test]
480    fn rejects_constriction_with_insufficient_phi() {
481        let mut cfg = PsoConfig::default_for(30, 10);
482        cfg.variant = PsoVariant::Constriction;
483        // default c1 = c2 = 1.49618 gives phi ≈ 2.99 < 4.
484        assert_eq!(cfg.validate().unwrap_err().field, "c1");
485    }
486
487    struct Sphere;
488    struct SphereFit;
489    impl FitnessEvaluable for SphereFit {
490        type Individual = Vec<f64>;
491        type Landscape = Sphere;
492        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
493            x.iter().map(|v| v * v).sum()
494        }
495    }
496
497    fn run_pso(variant: PsoVariant, dim: usize, generations: usize, seed: u64) -> f32 {
498        let device = Default::default();
499        let strategy = ParticleSwarm::<TestBackend>::new();
500        let mut params = PsoConfig::default_for(32, dim);
501        params.variant = variant;
502        if variant == PsoVariant::Constriction {
503            // Constriction requires φ = c1 + c2 > 4 (Clerc & Kennedy 2002).
504            params.c1 = 2.05;
505            params.c2 = 2.05;
506        }
507        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
508        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
509            strategy,
510            params,
511            fitness_fn,
512            seed,
513            device,
514            generations,
515        )
516        .expect("valid params");
517        harness.reset();
518        loop {
519            let step = harness.step(());
520            if step.done {
521                break;
522            }
523        }
524        harness.latest_metrics().unwrap().best_fitness_ever()
525    }
526
527    #[test]
528    fn inertia_converges_on_sphere_d10() {
529        // PSO on Sphere D=10: inertia variant. Budget 500 generations
530        // chosen to stay well within the acceptance envelope (within 2×
531        // of the best-in-class classical baseline on Rastrigin; on
532        // Sphere we want < 1e-6).
533        let best = run_pso(PsoVariant::Inertia, 10, 500, 42);
534        assert!(best < 1e-6, "PSO inertia D10 best={best}");
535    }
536
537    #[test]
538    fn constriction_converges_on_sphere_d10() {
539        let best = run_pso(PsoVariant::Constriction, 10, 500, 7);
540        assert!(best < 1e-6, "PSO constriction D10 best={best}");
541    }
542
543    #[test]
544    fn constriction_chi_matches_canonical_value() {
545        // φ = 4.1 → χ ≈ 0.729843788...
546        let mut cfg = PsoConfig::default_for(2, 2);
547        cfg.c1 = 2.05;
548        cfg.c2 = 2.05;
549        approx::assert_relative_eq!(cfg.constriction_chi(), 0.7298, epsilon = 1e-3);
550    }
551
552    #[test]
553    fn best_is_none_until_first_tell() {
554        let device = Default::default();
555        let strategy = ParticleSwarm::<TestBackend>::new();
556        let params = PsoConfig::default_for(4, 3);
557        let mut rng = StdRng::seed_from_u64(0);
558        let state = strategy.init(&params, &mut rng, &device);
559        // Fresh init: global_best is unset, so `best` reports nothing.
560        assert!(strategy.best(&state).is_none());
561        // First ask returns the initial positions unchanged; the first tell
562        // seeds personal-/global-best from their fitness.
563        let (pop, state) = strategy.ask(&params, &state, &mut rng, &device);
564        let fitness = finite_fitness(4, &device);
565        let (state, _m) = strategy.tell(&params, pop, fitness, state, &mut rng);
566        assert!(strategy.best(&state).is_some());
567    }
568
569    #[test]
570    fn degenerate_single_particle_single_dim_runs() {
571        // pop_size = 1, genome_dim = 1: the smallest swarm the validator
572        // accepts. It must run through the harness without a panic (gbest is
573        // the lone particle, so the social term is zero every step).
574        let device = Default::default();
575        let strategy = ParticleSwarm::<TestBackend>::new();
576        let params = PsoConfig::default_for(1, 1);
577        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
578        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
579            strategy, params, fitness_fn, 0, device, 5,
580        )
581        .expect("valid params");
582        harness.reset();
583        while !harness.step(()).done {}
584        assert!(
585            harness
586                .latest_metrics()
587                .unwrap()
588                .best_fitness_ever()
589                .is_finite()
590        );
591    }
592
593    #[test]
594    fn rejects_pop_size_zero() {
595        let mut cfg = PsoConfig::default_for(1, 3);
596        cfg.pop_size = 0;
597        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
598    }
599
600    #[test]
601    fn inverted_bounds_are_unrepresentable() {
602        // `PsoConfig::validate` never re-checks bound ordering because `Bounds`
603        // is self-validating (ADR 0027): an inverted range cannot be
604        // constructed. A single-point (zero-width) range is deliberately valid.
605        assert!(Bounds::try_new(5.12, -5.12).is_err());
606        assert!(Bounds::try_new(3.0, 3.0).is_ok());
607    }
608
609    #[test]
610    fn nan_fitness_through_harness_stays_finite() {
611        // Row 0 evaluates to NaN every generation. The harness sanitize
612        // chokepoint (ADR 0034) must keep the best-ever finite and flag the
613        // broken member rather than poisoning the run.
614        let device = Default::default();
615        let strategy = ParticleSwarm::<TestBackend>::new();
616        let params = PsoConfig::default_for(4, 3);
617        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
618            strategy, params, NanFitness, 1, device, 3,
619        )
620        .expect("valid params");
621        harness.reset();
622        while !harness.step(()).done {}
623        let m = harness.latest_metrics().unwrap();
624        assert!(
625            m.best_fitness_ever().is_finite(),
626            "best={}",
627            m.best_fitness_ever()
628        );
629        assert!(m.broken_count() >= 1, "the NaN row must be counted broken");
630    }
631
632    #[test]
633    fn ask_keeps_positions_in_bounds() {
634        // Invariant: every position proposed by the velocity-update `ask`
635        // (i.e. the second `ask`, after a `tell` primes gbest) stays inside the
636        // configured bounds, across a spread of seeds.
637        let device = Default::default();
638        let strategy = ParticleSwarm::<TestBackend>::new();
639        let params = PsoConfig::default_for(6, 4);
640        let (lo, hi): (f32, f32) = params.bounds.into();
641        for seed in 0..32 {
642            let mut rng = StdRng::seed_from_u64(seed);
643            let state = strategy.init(&params, &mut rng, &device);
644            let (pop1, state) = strategy.ask(&params, &state, &mut rng, &device);
645            let fitness = finite_fitness(6, &device);
646            let (state, _m) = strategy.tell(&params, pop1, fitness, state, &mut rng);
647            let (pop2, _state) = strategy.ask(&params, &state, &mut rng, &device);
648            let values = pop2.into_data().into_vec::<f32>().unwrap();
649            for v in values {
650                assert!(
651                    v >= lo - 1e-4 && v <= hi + 1e-4,
652                    "seed {seed}: position {v} out of bounds [{lo}, {hi}]"
653                );
654            }
655        }
656    }
657
658    #[test]
659    fn second_ask_moves_particles() {
660        // First-call protocol: init → ask → tell → ask. The second `ask`
661        // applies the velocity update, so at least some particle position must
662        // change from the initial swarm (the social pull toward gbest is
663        // non-zero for every non-best particle).
664        let device = Default::default();
665        let strategy = ParticleSwarm::<TestBackend>::new();
666        let params = PsoConfig::default_for(6, 4);
667        let mut rng = StdRng::seed_from_u64(9);
668        let state = strategy.init(&params, &mut rng, &device);
669        let (pop1, state) = strategy.ask(&params, &state, &mut rng, &device);
670        let initial = pop1.clone().into_data().into_vec::<f32>().unwrap();
671        let fitness = finite_fitness(6, &device);
672        let (state, _m) = strategy.tell(&params, pop1, fitness, state, &mut rng);
673        let (pop2, _state) = strategy.ask(&params, &state, &mut rng, &device);
674        let moved = pop2.into_data().into_vec::<f32>().unwrap();
675        assert!(
676            initial
677                .iter()
678                .zip(moved.iter())
679                .any(|(a, b)| (a - b).abs() > 1e-6),
680            "velocity update left every particle stationary"
681        );
682    }
683}