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 crate::rng::{SeedPurpose, seed_stream};
41use crate::strategy::{Strategy, StrategyMetrics};
42
43/// Static configuration for [`SalpSwarm`].
44#[derive(Debug, Clone)]
45pub struct SalpConfig {
46    /// Swarm size; must be `≥ 2` so there is at least one leader and
47    /// one follower.
48    pub pop_size: usize,
49    /// Genome dimensionality.
50    pub genome_dim: usize,
51    /// Search-space bounds. The leader update pulls from a scaled
52    /// uniform over this range.
53    pub bounds: (f32, f32),
54    /// Budget pacing the `c1` decay.
55    pub max_generations: usize,
56}
57
58impl SalpConfig {
59    /// Default configuration for a given population size and genome dimensionality.
60    #[must_use]
61    pub fn default_for(pop_size: usize, genome_dim: usize) -> Self {
62        Self {
63            pop_size,
64            genome_dim,
65            bounds: (-5.12, 5.12),
66            max_generations: 500,
67        }
68    }
69}
70
71/// Generation state for [`SalpSwarm`].
72#[derive(Debug, Clone)]
73pub struct SalpState<B: Backend> {
74    /// Current positions, shape `(pop_size, D)`.
75    pub positions: Tensor<B, 2>,
76    /// Host-side fitness cache.
77    pub fitness: Vec<f32>,
78    /// Food-source (best) genome.
79    pub best_genome: Option<Tensor<B, 2>>,
80    /// Food-source fitness.
81    pub best_fitness: f32,
82    /// Generation counter.
83    pub generation: usize,
84}
85
86/// Salp Swarm Algorithm strategy.
87///
88/// # Panics
89///
90/// [`Strategy::init`] panics if `params.pop_size < 2`, since the
91/// leader/follower split requires at least one of each.
92///
93/// [`Strategy::ask`] panics if called a second time without an intervening
94/// [`Strategy::tell`] — `state.best_genome` is `None` until the first
95/// `tell` populates it. Normal harness-driven usage prevents this: the
96/// first `ask` returns initial positions unevaluated, `tell` sets
97/// `best_genome`, and every subsequent `ask` finds it populated.
98///
99/// # Example
100///
101/// ```no_run
102/// use burn::backend::Flex;
103/// use rlevo_evolution::algorithms::metaheuristic::salp::{SalpConfig, SalpSwarm};
104///
105/// let strategy = SalpSwarm::<Flex>::new();
106/// let params = SalpConfig::default_for(32, 10);
107/// let _ = (strategy, params);
108/// ```
109#[derive(Debug, Clone, Copy, Default)]
110pub struct SalpSwarm<B: Backend> {
111    _backend: PhantomData<fn() -> B>,
112}
113
114impl<B: Backend> SalpSwarm<B> {
115    /// Builds a new (stateless) strategy object.
116    #[must_use]
117    pub fn new() -> Self {
118        Self {
119            _backend: PhantomData,
120        }
121    }
122}
123
124impl<B: Backend> Strategy<B> for SalpSwarm<B>
125where
126    B::Device: Clone,
127{
128    type Params = SalpConfig;
129    type State = SalpState<B>;
130    type Genome = Tensor<B, 2>;
131
132    /// Samples the initial swarm uniformly within [`SalpConfig::bounds`]
133    /// using the host-RNG convention and sets the generation counter to zero.
134    ///
135    /// # Panics
136    ///
137    /// Panics if `params.pop_size < 2`.
138    fn init(&self, params: &SalpConfig, rng: &mut dyn Rng, device: &<B as burn::tensor::backend::BackendTypes>::Device) -> SalpState<B> {
139        assert!(params.pop_size >= 2, "SSA requires pop_size >= 2");
140        let (lo, hi) = params.bounds;
141        // Host-sample the initial swarm from a deterministic `seed_stream`
142        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
143        // whose draws interleave with sibling tests under the parallel
144        // runner and are therefore not reproducible across thread schedules.
145        let pop = params.pop_size;
146        let genome_dim = params.genome_dim;
147        let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
148        let mut position_rows = Vec::with_capacity(pop * genome_dim);
149        for _ in 0..pop * genome_dim {
150            position_rows.push(lo + (hi - lo) * stream.random::<f32>());
151        }
152        let positions =
153            Tensor::<B, 2>::from_data(TensorData::new(position_rows, [pop, genome_dim]), device);
154        SalpState {
155            positions,
156            fitness: Vec::new(),
157            best_genome: None,
158            best_fitness: f32::INFINITY,
159            generation: 0,
160        }
161    }
162
163    /// Proposes the next swarm positions.
164    ///
165    /// On the first call returns the initial positions unchanged. On
166    /// subsequent calls, applies the two-phase update:
167    ///
168    /// - **Leaders** (`0..pop_size/2`): each leader moves toward the food
169    ///   source with a signed, decaying step proportional to `c1` and a
170    ///   host-sampled uniform offset over [`SalpConfig::bounds`].
171    /// - **Followers** (`pop_size/2..pop_size`): each follower averages its
172    ///   current position with the position of the salp directly ahead (a
173    ///   parallel stencil over the updated leader block).
174    ///
175    /// Results are clamped to [`SalpConfig::bounds`].
176    fn ask(
177        &self,
178        params: &SalpConfig,
179        state: &SalpState<B>,
180        rng: &mut dyn Rng,
181        device: &<B as burn::tensor::backend::BackendTypes>::Device,
182    ) -> (Tensor<B, 2>, SalpState<B>) {
183        if state.fitness.is_empty() {
184            return (state.positions.clone(), state.clone());
185        }
186
187        let pop_size = params.pop_size;
188        let genome_dim = params.genome_dim;
189        let n_leaders = pop_size / 2;
190        let (lo, hi) = params.bounds;
191
192        // c1 decay: 2 * exp(-(4t/T)^2)
193        #[allow(clippy::cast_precision_loss)]
194        let t = state.generation as f32;
195        #[allow(clippy::cast_precision_loss)]
196        let max_t = params.max_generations.max(1) as f32;
197        let frac = (4.0 * t / max_t).min(4.0);
198        let c1 = 2.0 * (-(frac * frac)).exp();
199
200        // Host-side sampling for c2, c3 so the leader step is
201        // backend-agnostic and reproducible under the splitmix contract.
202        let mut stream = seed_stream(rng.next_u64(), state.generation as u64, SeedPurpose::Other);
203        let mut leader_delta: Vec<f32> = Vec::with_capacity(n_leaders * genome_dim);
204        for _ in 0..n_leaders {
205            for _ in 0..genome_dim {
206                let c2: f32 = stream.random::<f32>();
207                let c3: f32 = stream.random::<f32>();
208                let scaled = (hi - lo) * c2 + lo;
209                let sign = if c3 >= 0.5 { 1.0 } else { -1.0 };
210                leader_delta.push(sign * c1 * scaled);
211            }
212        }
213
214        let best = state
215            .best_genome
216            .as_ref()
217            .expect("best_genome populated after first tell")
218            .clone()
219            .expand([n_leaders, genome_dim]);
220        let delta = Tensor::<B, 2>::from_data(
221            TensorData::new(leader_delta, [n_leaders, genome_dim]),
222            device,
223        );
224        let new_leaders = (best + delta).clamp(lo, hi);
225
226        // Follower stencil: X_i ← (X_i + X_{i-1}) / 2.
227        let followers = state
228            .positions
229            .clone()
230            .slice([n_leaders..pop_size, 0..genome_dim]);
231        // Shifted copy: follower i reads the position of the salp
232        // directly ahead, which is the last leader for the first
233        // follower and `followers[i-1]` otherwise. We build the shifted
234        // stack by gathering indices `[n_leaders-1, n_leaders, …, pop_size-2]`
235        // from the *updated* leader block + the old followers slice.
236        let joined = Tensor::cat(vec![new_leaders.clone(), followers.clone()], 0); // (pop_size, D) — leaders are already the new ones.
237        #[allow(clippy::cast_possible_wrap)]
238        let shift_idx: Vec<i64> = (0..(pop_size - n_leaders))
239            .map(|k| (n_leaders + k - 1) as i64)
240            .collect();
241        let idx = Tensor::<B, 1, Int>::from_data(
242            TensorData::new(shift_idx, [pop_size - n_leaders]),
243            device,
244        );
245        let previous = joined.clone().select(0, idx);
246        let new_followers = (followers + previous).mul_scalar(0.5).clamp(lo, hi);
247
248        let new_positions = Tensor::cat(vec![new_leaders, new_followers], 0);
249        let mut next = state.clone();
250        next.positions.clone_from(&new_positions);
251        (new_positions, next)
252    }
253
254    /// Records evaluated fitness, updates the food-source (best-so-far), and
255    /// increments the generation counter.
256    ///
257    /// Returns the updated [`SalpState`] and a [`StrategyMetrics`] snapshot
258    /// for the completed generation.
259    fn tell(
260        &self,
261        _params: &SalpConfig,
262        population: Tensor<B, 2>,
263        fitness: Tensor<B, 1>,
264        mut state: SalpState<B>,
265        _rng: &mut dyn Rng,
266    ) -> (SalpState<B>, StrategyMetrics) {
267        let fitness_host = fitness.into_data().into_vec::<f32>().unwrap_or_default();
268        state.fitness.clone_from(&fitness_host);
269        state.positions.clone_from(&population);
270        let best_idx = argmin(&fitness_host);
271        if fitness_host[best_idx] < state.best_fitness {
272            state.best_fitness = fitness_host[best_idx];
273            let device = population.device();
274            #[allow(clippy::cast_possible_wrap)]
275            let idx = Tensor::<B, 1, Int>::from_data(
276                TensorData::new(vec![best_idx as i64], [1]),
277                &device,
278            );
279            state.best_genome = Some(population.select(0, idx));
280        }
281        state.generation += 1;
282        let m =
283            StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
284        state.best_fitness = m.best_fitness_ever;
285        (state, m)
286    }
287
288    /// Returns the food-source genome and its fitness, or `None` before the
289    /// first [`tell`](Strategy::tell) call.
290    fn best(&self, state: &SalpState<B>) -> Option<(Tensor<B, 2>, f32)> {
291        state
292            .best_genome
293            .as_ref()
294            .map(|g| (g.clone(), state.best_fitness))
295    }
296}
297
298fn argmin(xs: &[f32]) -> usize {
299    let mut best_idx = 0usize;
300    let mut best = f32::INFINITY;
301    for (i, &v) in xs.iter().enumerate() {
302        if v < best {
303            best = v;
304            best_idx = i;
305        }
306    }
307    best_idx
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::fitness::FromFitnessEvaluable;
314    use crate::strategy::EvolutionaryHarness;
315    use burn::backend::Flex;
316    use rlevo_core::fitness::FitnessEvaluable;
317
318    type TestBackend = Flex;
319
320    struct Sphere;
321    struct SphereFit;
322    impl FitnessEvaluable for SphereFit {
323        type Individual = Vec<f64>;
324        type Landscape = Sphere;
325        fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
326            x.iter().map(|v| v * v).sum()
327        }
328    }
329
330    #[test]
331    fn ssa_converges_on_sphere_d10() {
332        // SSA reduces strongly but, per module-level candor, is not
333        // expected to reach machine precision. A 1e-2 target in 600
334        // generations is the strong-reduction guarantee mirroring the
335        // DE premature-convergence test pattern.
336        let device = Default::default();
337        let strategy = SalpSwarm::<TestBackend>::new();
338        let params = SalpConfig::default_for(40, 10);
339        let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
340        let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
341            strategy, params, fitness_fn, 3, device, 600,
342        );
343        harness.reset();
344        while !harness.step(()).done {}
345        let best = harness.latest_metrics().unwrap().best_fitness_ever;
346        assert!(best < 1e-2, "SSA D10 best={best}");
347    }
348}