1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum PsoVariant {
65 Inertia,
67 Constriction,
71}
72
73#[derive(Debug, Clone)]
75pub struct PsoConfig {
76 pub pop_size: usize,
78 pub genome_dim: usize,
80 pub bounds: Bounds,
82 pub inertia: f32,
84 pub c1: f32,
86 pub c2: f32,
88 pub v_max: f32,
91 pub variant: PsoVariant,
93}
94
95impl PsoConfig {
96 #[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 #[must_use]
129 pub fn constriction_chi(&self) -> f32 {
130 let phi = self.c1 + self.c2;
131 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#[derive(Debug, Clone)]
162pub struct PsoState<B: Backend> {
163 pub positions: Tensor<B, 2>,
165 pub velocities: Tensor<B, 2>,
167 pub personal_best: Tensor<B, 2>,
169 pub personal_best_fitness: Vec<f32>,
171 pub global_best: Option<Tensor<B, 2>>,
173 pub global_best_fitness: f32,
175 pub best_fitness: f32,
177 pub generation: usize,
179}
180
181#[derive(Debug, Clone, Copy, Default)]
194pub struct ParticleSwarm<B: Backend> {
195 _backend: PhantomData<fn() -> B>,
196}
197
198impl<B: Backend> ParticleSwarm<B> {
199 #[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 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 if state.personal_best_fitness.is_empty() {
271 return (state.positions.clone(), state.clone());
272 }
273
274 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 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 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 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 #[allow(clippy::trivially_copy_pass_by_ref)] 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 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 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 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 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 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(¶ms, &mut rng, &device);
559 assert!(strategy.best(&state).is_none());
561 let (pop, state) = strategy.ask(¶ms, &state, &mut rng, &device);
564 let fitness = finite_fitness(4, &device);
565 let (state, _m) = strategy.tell(¶ms, 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 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 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 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 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(¶ms, &mut rng, &device);
644 let (pop1, state) = strategy.ask(¶ms, &state, &mut rng, &device);
645 let fitness = finite_fitness(6, &device);
646 let (state, _m) = strategy.tell(¶ms, pop1, fitness, state, &mut rng);
647 let (pop2, _state) = strategy.ask(¶ms, &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 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(¶ms, &mut rng, &device);
669 let (pop1, state) = strategy.ask(¶ms, &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(¶ms, pop1, fitness, state, &mut rng);
673 let (pop2, _state) = strategy.ask(¶ms, &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}