Skip to main content

rlevo_evolution/algorithms/metaheuristic/
salp.rs

1//! Salp Swarm Algorithm.
2//!
3//! The population splits into a *leader* half and a *follower* chain:
4//!
5//! - Leaders (indices `0 .. N/2`) update toward the food source `F`
6//!   (the best-so-far position), modulated by a time-decayed coefficient
7//!   `c1 = 2·exp(−(4·t / T)²)`:
8//!
9//!   `X_i ← F ± c1 · ((ub − lb)·c2 + lb)` with `c2, c3 ∈ U[0, 1]`,
10//!   where the sign follows `c3 ≥ 0.5 → +` and `c3 < 0.5 → −`.
11//!
12//! - Followers (indices `N/2 .. N`) track the chain:
13//!
14//!   `X_i ← (X_i + X_{i−1}) / 2`.
15//!
16//! The follower rule is realized as a parallel stencil — the shifted
17//! copy is `concat([last_leader, followers[..−1]])` — rather than a
18//! host-side Python-style loop. That keeps the update GPU-friendly and
19//! deterministic.
20//!
21//! # Candor
22//!
23//! Legacy comparator. The leader update is a thinly-veiled biased
24//! random walk toward the best; the follower chain is a stencil average
25//! of old positions. Neither mechanism introduces a novel search
26//! dynamic not already present in PSO or DE. Ship it for API coverage;
27//! prefer CMA-ES or LSHADE when available.
28//!
29//! # References
30//!
31//! - Mirjalili, Gandomi, Mirjalili, Saremi, Faris & Mirjalili (2017),
32//!   *Salp Swarm Algorithm*.
33
34use std::marker::PhantomData;
35
36use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
37use rand::Rng;
38use rand::RngExt;
39
40use rlevo_core::bounds::Bounds;
41use rlevo_core::config::{self, ConfigError, Validate};
42
43use crate::ops::selection::argmax_host;
44use crate::rng::{SeedPurpose, seed_stream};
45use crate::strategy::{Strategy, StrategyMetrics};
46
47/// Static configuration for [`SalpSwarm`].
48#[derive(Debug, Clone)]
49pub struct SalpConfig {
50    /// Swarm size; must be `≥ 2` so there is at least one leader and
51    /// one follower.
52    pub pop_size: usize,
53    /// Genome dimensionality.
54    pub genome_dim: usize,
55    /// Search-space bounds. The leader update pulls from a scaled
56    /// uniform over this range.
57    pub bounds: Bounds,
58    /// Budget pacing the `c1` decay.
59    pub max_generations: usize,
60}
61
62impl SalpConfig {
63    /// Default configuration for a given population size and genome dimensionality.
64    #[must_use]
65    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
66        Self {
67            pop_size,
68            genome_dim,
69            bounds: Bounds::new(-5.12, 5.12),
70            max_generations: 500,
71        }
72    }
73}
74
75impl Validate for SalpConfig {
76    fn validate(&self) -> Result<(), ConfigError> {
77        const C: &str = "SalpConfig";
78        config::at_least(C, "pop_size", self.pop_size, 2)?;
79        config::nonzero(C, "genome_dim", self.genome_dim)?;
80        config::at_least(C, "max_generations", self.max_generations, 1)?;
81        Ok(())
82    }
83}
84
85/// Generation state for [`SalpSwarm`].
86#[derive(Debug, Clone)]
87pub struct SalpState<B: Backend> {
88    /// Current positions, shape `(pop_size, D)`.
89    pub positions: Tensor<B, 2>,
90    /// Host-side fitness cache.
91    pub fitness: Vec<f32>,
92    /// Food-source (best) genome.
93    pub best_genome: Option<Tensor<B, 2>>,
94    /// Food-source fitness.
95    pub best_fitness: f32,
96    /// Generation counter.
97    pub generation: usize,
98}
99
100/// Salp Swarm Algorithm strategy.
101///
102/// # Panics
103///
104/// [`Strategy::init`] panics if `params.pop_size < 2`, since the
105/// leader/follower split requires at least one of each.
106///
107/// [`Strategy::ask`] panics if called a second time without an intervening
108/// [`Strategy::tell`] — `state.best_genome` is `None` until the first
109/// `tell` populates it. Normal harness-driven usage prevents this: the
110/// first `ask` returns initial positions unevaluated, `tell` sets
111/// `best_genome`, and every subsequent `ask` finds it populated.
112///
113/// # Example
114///
115/// ```no_run
116/// use burn::backend::Flex;
117/// use rlevo_evolution::algorithms::metaheuristic::salp::{SalpConfig, SalpSwarm};
118///
119/// let strategy = SalpSwarm::<Flex>::new();
120/// let params = SalpConfig::default_for(32, 10);
121/// let _ = (strategy, params);
122/// ```
123#[derive(Debug, Clone, Copy, Default)]
124pub struct SalpSwarm<B: Backend> {
125    _backend: PhantomData<fn() -> B>,
126}
127
128impl<B: Backend> SalpSwarm<B> {
129    /// Builds a new (stateless) strategy object.
130    #[must_use]
131    pub fn new() -> Self {
132        Self {
133            _backend: PhantomData,
134        }
135    }
136}
137
138impl<B: Backend> Strategy<B> for SalpSwarm<B>
139where
140    B::Device: Clone,
141{
142    type Params = SalpConfig;
143    type State = SalpState<B>;
144    type Genome = Tensor<B, 2>;
145
146    /// Samples the initial swarm uniformly within [`SalpConfig::bounds`]
147    /// using the host-RNG convention and sets the generation counter to zero.
148    ///
149    /// The `pop_size >= 2` invariant is enforced by [`Validate::validate`] at
150    /// the harness chokepoint.
151    fn init(
152        &self,
153        params: &SalpConfig,
154        rng: &mut dyn Rng,
155        device: &<B as burn::tensor::backend::BackendTypes>::Device,
156    ) -> SalpState<B> {
157        debug_assert!(
158            params.validate().is_ok(),
159            "invalid SalpConfig reached init: {params:?}"
160        );
161        let (lo, hi): (f32, f32) = params.bounds.into();
162        // Host-sample the initial swarm from a deterministic `seed_stream`
163        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
164        // whose draws interleave with sibling tests under the parallel
165        // runner and are therefore not reproducible across thread schedules.
166        let pop = params.pop_size;
167        let genome_dim = params.genome_dim;
168        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
169        let mut position_rows = Vec::with_capacity(pop * genome_dim);
170        for _ in 0..pop * genome_dim {
171            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
172        }
173        let positions =
174            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
175        SalpState {
176            positions,
177            fitness: Vec::new(),
178            best_genome: None,
179            best_fitness: f32::NEG_INFINITY,
180            generation: 0,
181        }
182    }
183
184    /// Proposes the next swarm positions.
185    ///
186    /// On the first call returns the initial positions unchanged. On
187    /// subsequent calls, applies the two-phase update:
188    ///
189    /// - **Leaders** (`0..pop_size/2`): each leader moves toward the food
190    ///   source with a signed, decaying step proportional to `c1` and a
191    ///   host-sampled uniform offset over [`SalpConfig::bounds`].
192    /// - **Followers** (`pop_size/2..pop_size`): each follower averages its
193    ///   current position with the position of the salp directly ahead (a
194    ///   parallel stencil over the updated leader block).
195    ///
196    /// Results are clamped to [`SalpConfig::bounds`].
197    fn ask(
198        &self,
199        params: &SalpConfig,
200        state: &SalpState<B>,
201        rng: &mut dyn Rng,
202        device: &<B as burn::tensor::backend::BackendTypes>::Device,
203    ) -> (Tensor<B, 2>, SalpState<B>) {
204        if state.fitness.is_empty() {
205            return (state.positions.clone(), state.clone());
206        }
207
208        let pop_size = params.pop_size;
209        let genome_dim = params.genome_dim;
210        let n_leaders = pop_size / 2;
211        let (lo, hi): (f32, f32) = params.bounds.into();
212
213        // c1 decay: 2 * exp(-(4t/T)^2)
214        #[allow(clippy::cast_precision_loss)]
215        let t = state.generation as f32;
216        #[allow(clippy::cast_precision_loss)]
217        let max_t = params.max_generations.max(1) as f32;
218        let frac = (4.0 * t / max_t).min(4.0);
219        let c1 = 2.0 * (-(frac * frac)).exp();
220
221        // Host-side sampling for c2, c3 so the leader step is
222        // backend-agnostic and reproducible under the splitmix contract.
223        let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
224        let mut leader_delta: Vec<f32> = Vec::with_capacity(n_leaders * genome_dim);
225        for _ in 0..n_leaders {
226            for _ in 0..genome_dim {
227                let c2: f32 = stream.random::<f32>();
228                let c3: f32 = stream.random::<f32>();
229                let scaled = (hi - lo) * c2 + lo;
230                let sign = if c3 >= 0.5 { 1.0 } else { -1.0 };
231                leader_delta.push(sign * c1 * scaled);
232            }
233        }
234
235        let best = state
236            .best_genome
237            .as_ref()
238            .expect("best_genome populated after first tell")
239            .clone()
240            .expand([n_leaders, genome_dim]);
241        let delta = Tensor::<B, 2>::from_data(
242            TensorData::new(leader_delta, [n_leaders, genome_dim]),
243            device,
244        );
245        let new_leaders = (best + delta).clamp(lo, hi);
246
247        // Follower stencil: X_i ← (X_i + X_{i-1}) / 2.
248        let followers = state
249            .positions
250            .clone()
251            .slice([n_leaders..pop_size, 0..genome_dim]);
252        // Shifted copy: follower i reads the position of the salp
253        // directly ahead, which is the last leader for the first
254        // follower and `followers[i-1]` otherwise. We build the shifted
255        // stack by gathering indices `[n_leaders-1, n_leaders, …, pop_size-2]`
256        // from the *updated* leader block + the old followers slice.
257        let joined = Tensor::cat(vec![new_leaders.clone(), followers.clone()], 0); // (pop_size, D) — leaders are already the new ones.
258        #[allow(clippy::cast_possible_wrap)]
259        let shift_idx: Vec<i64> = (0..(pop_size - n_leaders))
260            .map(|k| (n_leaders + k - 1) as i64)
261            .collect();
262        let idx = Tensor::<B, 1, Int>::from_data(
263            TensorData::new(shift_idx, [pop_size - n_leaders]),
264            device,
265        );
266        let previous = joined.clone().select(0, idx);
267        let new_followers = (followers + previous).mul_scalar(0.5).clamp(lo, hi);
268
269        let new_positions = Tensor::cat(vec![new_leaders, new_followers], 0);
270        let mut next = state.clone();
271        next.positions.clone_from(&new_positions);
272        (new_positions, next)
273    }
274
275    /// Records evaluated fitness, updates the food-source (best-so-far), and
276    /// increments the generation counter.
277    ///
278    /// Returns the updated [`SalpState`] and a [`StrategyMetrics`] snapshot
279    /// for the completed generation.
280    fn tell(
281        &self,
282        _params: &SalpConfig,
283        population: Tensor<B, 2>,
284        fitness: Tensor<B, 1>,
285        mut state: SalpState<B>,
286        _rng: &mut dyn Rng,
287    ) -> (SalpState<B>, StrategyMetrics) {
288        let fitness_host = fitness
289            .into_data()
290            .into_vec::<f32>()
291            .expect("fitness tensor must be readable as f32");
292        state.fitness.clone_from(&fitness_host);
293        state.positions.clone_from(&population);
294        let best_idx = argmax_host(&fitness_host);
295        if fitness_host[best_idx] > state.best_fitness {
296            state.best_fitness = fitness_host[best_idx];
297            let device = population.device();
298            #[allow(clippy::cast_possible_wrap)]
299            let idx = Tensor::<B, 1, Int>::from_data(
300                TensorData::new(vec![best_idx as i64], [1]),
301                &device,
302            );
303            state.best_genome = Some(population.select(0, idx));
304        }
305        state.generation += 1;
306        let m =
307            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
308        state.best_fitness = m.best_fitness_ever();
309        (state, m)
310    }
311
312    /// Returns the food-source genome and its fitness, or `None` before the
313    /// first [`tell`](Strategy::tell) call.
314    fn best(&self, state: &SalpState<B>) -> Option<(Tensor<B, 2>, f32)> {
315        state
316            .best_genome
317            .as_ref()
318            .map(|g| (g.clone(), state.best_fitness))
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::fitness::FromFitnessEvaluable;
326    use crate::strategy::EvolutionaryHarness;
327    use burn::backend::Flex;
328    use rand::SeedableRng;
329    use rand::rngs::StdRng;
330    use rlevo_core::fitness::FitnessEvaluable;
331
332    type TestBackend = Flex;
333
334    /// Distinct finite fitness with the maximum at index 0, for direct
335    /// (non-harness) `tell` calls. Finite, so it respects ADR 0034.
336    #[allow(clippy::trivially_copy_pass_by_ref)] // mirror the by-ref device idiom
337    fn finite_fitness(
338        n: usize,
339        device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
340    ) -> Tensor<TestBackend, 1> {
341        #[allow(clippy::cast_precision_loss)]
342        let vals: Vec<f32> = (0..n).map(|i| -(i as f32) - 1.0).collect();
343        Tensor::<TestBackend, 1>::from_data(TensorData::new(vals, [n]), device)
344    }
345
346    #[test]
347    fn default_config_validates() {
348        assert!(SalpConfig::default_for(30, 10).validate().is_ok());
349    }
350
351    #[test]
352    fn rejects_pop_size_below_two() {
353        let mut cfg = SalpConfig::default_for(30, 10);
354        cfg.pop_size = 1;
355        assert_eq!(cfg.validate().unwrap_err().field, "pop_size");
356    }
357
358    struct Sphere;
359    struct SphereFit;
360    impl FitnessEvaluable for SphereFit {
361        type Individual = Vec<f64>;
362        type Landscape = Sphere;
363        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
364            x.iter().map(|v| v * v).sum()
365        }
366    }
367
368    #[test]
369    fn ssa_converges_on_sphere_d10() {
370        // SSA reduces strongly but, per module-level candor, is not
371        // expected to reach machine precision. A 1e-2 target in 600
372        // generations is the strong-reduction guarantee mirroring the
373        // DE premature-convergence test pattern.
374        let device = Default::default();
375        let strategy = SalpSwarm::<TestBackend>::new();
376        let params = SalpConfig::default_for(40, 10);
377        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
378        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
379            strategy, params, fitness_fn, 3, device, 600,
380        )
381        .expect("valid params");
382        harness.reset();
383        while !harness.step(()).done {}
384        let best = harness.latest_metrics().unwrap().best_fitness_ever();
385        assert!(best < 1e-2, "SSA D10 best={best}");
386    }
387
388    #[test]
389    fn best_is_none_until_first_tell() {
390        let device = Default::default();
391        let strategy = SalpSwarm::<TestBackend>::new();
392        let params = SalpConfig::default_for(4, 3);
393        let mut rng = StdRng::seed_from_u64(0);
394        let state = strategy.init(&params, &mut rng, &device);
395        assert!(strategy.best(&state).is_none());
396        let (pop, state) = strategy.ask(&params, &state, &mut rng, &device);
397        let fitness = finite_fitness(4, &device);
398        let (state, _m) = strategy.tell(&params, pop, fitness, state, &mut rng);
399        assert!(strategy.best(&state).is_some());
400    }
401
402    #[test]
403    fn first_ask_returns_initial_positions_unchanged() {
404        // Before any `tell`, `state.fitness` is empty, so `ask` short-circuits
405        // and hands back the untouched initial swarm.
406        let device = Default::default();
407        let strategy = SalpSwarm::<TestBackend>::new();
408        let params = SalpConfig::default_for(6, 4);
409        let mut rng = StdRng::seed_from_u64(3);
410        let state = strategy.init(&params, &mut rng, &device);
411        let expected = state
412            .positions
413            .clone()
414            .into_data()
415            .into_vec::<f32>()
416            .unwrap();
417        let (pop, _state) = strategy.ask(&params, &state, &mut rng, &device);
418        let got = pop.into_data().into_vec::<f32>().unwrap();
419        assert_eq!(expected, got);
420    }
421
422    #[test]
423    fn minimal_and_odd_pop_sizes_run() {
424        // pop_size = 2 is the leader/follower minimum; pop_size = 3 exercises
425        // the odd split (1 leader, 2 followers). Both must complete without a
426        // panic in the follower stencil.
427        for pop in [2usize, 3] {
428            let device = Default::default();
429            let strategy = SalpSwarm::<TestBackend>::new();
430            let params = SalpConfig::default_for(pop, 3);
431            let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
432            let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
433                strategy, params, fitness_fn, 0, device, 5,
434            )
435            .expect("valid params");
436            harness.reset();
437            while !harness.step(()).done {}
438            assert!(
439                harness
440                    .latest_metrics()
441                    .unwrap()
442                    .best_fitness_ever()
443                    .is_finite(),
444                "pop_size {pop} produced a non-finite best"
445            );
446        }
447    }
448
449    #[test]
450    fn ask_keeps_positions_in_bounds() {
451        // Every position proposed by the two-phase update (leader step +
452        // follower stencil) is clamped to bounds; verify across seeds.
453        let device = Default::default();
454        let strategy = SalpSwarm::<TestBackend>::new();
455        let params = SalpConfig::default_for(6, 4);
456        let (lo, hi): (f32, f32) = params.bounds.into();
457        for seed in 0..32 {
458            let mut rng = StdRng::seed_from_u64(seed);
459            let state = strategy.init(&params, &mut rng, &device);
460            let (pop1, state) = strategy.ask(&params, &state, &mut rng, &device);
461            let fitness = finite_fitness(6, &device);
462            let (state, _m) = strategy.tell(&params, pop1, fitness, state, &mut rng);
463            let (pop2, _state) = strategy.ask(&params, &state, &mut rng, &device);
464            let values = pop2.into_data().into_vec::<f32>().unwrap();
465            for v in values {
466                assert!(
467                    v >= lo - 1e-4 && v <= hi + 1e-4,
468                    "seed {seed}: position {v} out of bounds [{lo}, {hi}]"
469                );
470            }
471        }
472    }
473
474    #[test]
475    fn zero_budget_harness_produces_no_metrics() {
476        // The harness *budget* (distinct from SalpConfig::max_generations) is
477        // zero: a well-behaved driver takes no step, so `latest_metrics` stays
478        // None after `reset`.
479        let device = Default::default();
480        let strategy = SalpSwarm::<TestBackend>::new();
481        let params = SalpConfig::default_for(4, 3);
482        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
483        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
484            strategy, params, fitness_fn, 0, device, 0,
485        )
486        .expect("valid params");
487        harness.reset();
488        assert!(harness.latest_metrics().is_none());
489    }
490
491    #[test]
492    fn unit_budget_harness_runs_exactly_one_generation() {
493        let device = Default::default();
494        let strategy = SalpSwarm::<TestBackend>::new();
495        let params = SalpConfig::default_for(4, 3);
496        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
497        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
498            strategy, params, fitness_fn, 0, device, 1,
499        )
500        .expect("valid params");
501        harness.reset();
502        assert!(harness.step(()).done);
503        assert_eq!(harness.generation(), 1);
504        assert!(harness.latest_metrics().is_some());
505    }
506}