1use 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#[derive(Debug, Clone)]
49pub struct SalpConfig {
50 pub pop_size: usize,
53 pub genome_dim: usize,
55 pub bounds: Bounds,
58 pub max_generations: usize,
60}
61
62impl SalpConfig {
63 #[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#[derive(Debug, Clone)]
87pub struct SalpState<B: Backend> {
88 pub positions: Tensor<B, 2>,
90 pub fitness: Vec<f32>,
92 pub best_genome: Option<Tensor<B, 2>>,
94 pub best_fitness: f32,
96 pub generation: usize,
98}
99
100#[derive(Debug, Clone, Copy, Default)]
124pub struct SalpSwarm<B: Backend> {
125 _backend: PhantomData<fn() -> B>,
126}
127
128impl<B: Backend> SalpSwarm<B> {
129 #[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 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 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 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 #[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 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 let followers = state
249 .positions
250 .clone()
251 .slice([n_leaders..pop_size, 0..genome_dim]);
252 let joined = Tensor::cat(vec![new_leaders.clone(), followers.clone()], 0); #[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 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 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 #[allow(clippy::trivially_copy_pass_by_ref)] 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 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(¶ms, &mut rng, &device);
395 assert!(strategy.best(&state).is_none());
396 let (pop, state) = strategy.ask(¶ms, &state, &mut rng, &device);
397 let fitness = finite_fitness(4, &device);
398 let (state, _m) = strategy.tell(¶ms, 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 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(¶ms, &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(¶ms, &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 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 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(¶ms, &mut rng, &device);
460 let (pop1, state) = strategy.ask(¶ms, &state, &mut rng, &device);
461 let fitness = finite_fitness(6, &device);
462 let (state, _m) = strategy.tell(¶ms, pop1, fitness, state, &mut rng);
463 let (pop2, _state) = strategy.ask(¶ms, &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 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}