1use std::marker::PhantomData;
21
22use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
23use rand::Rng;
24use rand::RngExt;
25
26use rlevo_core::bounds::Bounds;
27use rlevo_core::config::{self, ConfigError, Validate};
28
29use crate::rng::{SeedPurpose, seed_stream};
30use crate::strategy::{Strategy, StrategyMetrics};
31
32#[derive(Debug, Clone)]
34pub struct AcoRConfig {
35 pub archive_size: usize,
38 pub m: usize,
40 pub genome_dim: usize,
42 pub bounds: Bounds,
44 pub xi: f32,
46 pub q: f32,
49}
50
51impl AcoRConfig {
52 #[must_use]
54 pub fn default_for(archive_size: usize, m: usize, genome_dim: usize) -> Self {
55 Self {
56 archive_size,
57 m,
58 genome_dim,
59 bounds: Bounds::new(-5.12, 5.12),
60 xi: 0.85,
61 q: 0.1,
62 }
63 }
64
65 #[must_use]
69 pub fn steady_state_pop_size(&self) -> usize {
70 self.m
71 }
72}
73
74impl Validate for AcoRConfig {
75 fn validate(&self) -> Result<(), ConfigError> {
76 const C: &str = "AcoRConfig";
77 config::at_least(C, "archive_size", self.archive_size, 2)?;
78 config::at_least(C, "m", self.m, 1)?;
79 config::nonzero(C, "genome_dim", self.genome_dim)?;
80 config::positive(C, "xi", f64::from(self.xi))?;
81 config::positive(C, "q", f64::from(self.q))?;
82 Ok(())
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct AcoRState<B: Backend> {
89 pub archive: Tensor<B, 2>,
91 pub archive_fitness: Vec<f32>,
94 pub weights: Vec<f32>,
96 pub best_genome: Option<Tensor<B, 2>>,
98 pub best_fitness: f32,
100 pub generation: usize,
102}
103
104#[derive(Debug, Clone, Copy, Default)]
121pub struct AntColonyReal<B: Backend> {
122 _backend: PhantomData<fn() -> B>,
123}
124
125impl<B: Backend> AntColonyReal<B> {
126 #[must_use]
128 pub fn new() -> Self {
129 Self {
130 _backend: PhantomData,
131 }
132 }
133
134 fn compute_weights(archive_size: usize, q: f32) -> Vec<f32> {
136 #[allow(clippy::cast_precision_loss)]
137 let k = archive_size as f32;
138 let denom = 2.0 * q * q * k * k;
139 let mut w: Vec<f32> = (0..archive_size)
144 .map(|l| {
145 #[allow(clippy::cast_precision_loss)]
146 let rank = l as f32;
147 (-(rank * rank) / denom).exp()
148 })
149 .collect();
150 let total: f32 = w.iter().sum();
151 if !total.is_finite() || total == 0.0 {
152 w.fill(1.0 / k);
155 } else {
156 for v in &mut w {
157 *v /= total;
158 }
159 }
160 w
161 }
162}
163
164impl<B: Backend> Strategy<B> for AntColonyReal<B>
165where
166 B::Device: Clone,
167{
168 type Params = AcoRConfig;
169 type State = AcoRState<B>;
170 type Genome = Tensor<B, 2>;
171
172 fn init(
180 &self,
181 params: &AcoRConfig,
182 rng: &mut dyn Rng,
183 device: &<B as burn::tensor::backend::BackendTypes>::Device,
184 ) -> AcoRState<B> {
185 debug_assert!(
186 params.validate().is_ok(),
187 "invalid AcoRConfig reached init: {params:?}"
188 );
189 let (lo, hi): (f32, f32) = params.bounds.into();
190 let rows = params.archive_size;
195 let genome_dim = params.genome_dim;
196 let mut stream = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
197 let mut archive_rows = Vec::with_capacity(rows * genome_dim);
198 for _ in 0..rows * genome_dim {
199 archive_rows.push(lo + (hi - lo) * stream.random::<f32>());
200 }
201 let archive =
202 Tensor::<B, 2>::from_data(TensorData::new(archive_rows, [rows, genome_dim]), device);
203 AcoRState {
204 archive,
205 archive_fitness: Vec::new(),
206 weights: Self::compute_weights(params.archive_size, params.q),
207 best_genome: None,
208 best_fitness: f32::NEG_INFINITY,
209 generation: 0,
210 }
211 }
212
213 #[allow(clippy::many_single_char_names)]
233 fn ask(
234 &self,
235 params: &AcoRConfig,
236 state: &AcoRState<B>,
237 rng: &mut dyn Rng,
238 device: &<B as burn::tensor::backend::BackendTypes>::Device,
239 ) -> (Tensor<B, 2>, AcoRState<B>) {
240 if state.archive_fitness.is_empty() {
242 return (state.archive.clone(), state.clone());
243 }
244
245 let k = params.archive_size;
246 let m = params.m;
247 let d = params.genome_dim;
248
249 let archive_l = state.archive.clone().unsqueeze_dim::<3>(0); let archive_e = state.archive.clone().unsqueeze_dim::<3>(1); let diffs = (archive_l.expand([k, k, d]) - archive_e.expand([k, k, d])).abs();
255 #[allow(clippy::cast_precision_loss)]
256 let inv = params.xi / ((k - 1).max(1) as f32);
257 let sigma = diffs.sum_dim(0).squeeze_dim::<2>(0).mul_scalar(inv); let mut stream = seed_stream(
261 rng.next_u64(),
262 state.generation as u64,
263 SeedPurpose::Selection,
264 );
265 let mut mean_rows = vec![0f32; m * d];
266 let mut sigma_rows = vec![0f32; m * d];
267
268 let archive_host = state
270 .archive
271 .clone()
272 .into_data()
273 .into_vec::<f32>()
274 .expect("archive tensor must be readable as f32");
275 let sigma_host = sigma
276 .into_data()
277 .into_vec::<f32>()
278 .expect("sigma tensor must be readable as f32");
279 let cdf: Vec<f32> = {
280 let mut acc = 0.0;
281 let mut v = Vec::with_capacity(k);
282 for &w in &state.weights {
283 acc += w;
284 v.push(acc);
285 }
286 v
287 };
288 let pick = |u: f32| -> usize { cdf.iter().position(|&c| u <= c).unwrap_or(k - 1) };
289
290 for i in 0..m {
291 for j in 0..d {
292 let u: f32 = stream.random::<f32>();
293 let l = pick(u);
294 mean_rows[i * d + j] = archive_host[l * d + j];
295 sigma_rows[i * d + j] = sigma_host[l * d + j].max(1e-12);
296 }
297 }
298
299 let mut offspring = vec![0f32; m * d];
303 let mut sample_rng = seed_stream(
304 rng.next_u64(),
305 state.generation as u64,
306 SeedPurpose::Mutation,
307 );
308 for (idx, out) in offspring.iter_mut().enumerate() {
309 *out =
316 crate::sampling::normal_or_mean(mean_rows[idx], sigma_rows[idx], &mut sample_rng);
317 }
318 let (lo, hi): (f32, f32) = params.bounds.into();
319 for v in &mut offspring {
320 *v = v.clamp(lo, hi);
321 }
322 let new_pop = Tensor::<B, 2>::from_data(TensorData::new(offspring, [m, d]), device);
323
324 (new_pop, state.clone())
325 }
326
327 fn tell(
341 &self,
342 params: &AcoRConfig,
343 population: Tensor<B, 2>,
344 fitness: Tensor<B, 1>,
345 mut state: AcoRState<B>,
346 _rng: &mut dyn Rng,
347 ) -> (AcoRState<B>, StrategyMetrics) {
348 let fitness_host = fitness
349 .into_data()
350 .into_vec::<f32>()
351 .expect("fitness tensor must be readable as f32");
352 let device = population.device();
353 let k = params.archive_size;
354
355 if state.archive_fitness.is_empty() {
357 let mut idx: Vec<usize> = (0..fitness_host.len()).collect();
359 let sane: Vec<f32> = fitness_host
361 .iter()
362 .map(|&f| crate::fitness::sanitize_fitness(f))
363 .collect();
364 idx.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
365 #[allow(clippy::cast_possible_wrap)]
366 let sorted_idx = Tensor::<B, 1, Int>::from_data(
367 TensorData::new(idx.iter().map(|&i| i as i64).collect::<Vec<_>>(), [k]),
368 &device,
369 );
370 state.archive = population.clone().select(0, sorted_idx);
371 state.archive_fitness = idx.iter().map(|&i| fitness_host[i]).collect();
372 state.best_fitness = state.archive_fitness[0];
373 let first_idx =
374 Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64], [1]), &device);
375 state.best_genome = Some(state.archive.clone().select(0, first_idx));
376 state.generation += 1;
377 let m = StrategyMetrics::from_host_fitness(
378 state.generation,
379 &fitness_host,
380 state.best_fitness,
381 );
382 state.best_fitness = m.best_fitness_ever();
383 return (state, m);
384 }
385
386 let combined = Tensor::cat(vec![state.archive.clone(), population.clone()], 0);
388 let mut combined_f: Vec<f32> = state.archive_fitness.clone();
389 combined_f.extend_from_slice(&fitness_host);
390 let mut idx: Vec<usize> = (0..combined_f.len()).collect();
391 let sane: Vec<f32> = combined_f
393 .iter()
394 .map(|&f| crate::fitness::sanitize_fitness(f))
395 .collect();
396 idx.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
397 idx.truncate(k);
398 #[allow(clippy::cast_possible_wrap)]
399 let top_idx = Tensor::<B, 1, Int>::from_data(
400 TensorData::new(idx.iter().map(|&i| i as i64).collect::<Vec<_>>(), [k]),
401 &device,
402 );
403 state.archive = combined.select(0, top_idx);
404 state.archive_fitness = idx.iter().map(|&i| combined_f[i]).collect();
405
406 if state.archive_fitness[0] > state.best_fitness {
407 state.best_fitness = state.archive_fitness[0];
408 let first_idx =
409 Tensor::<B, 1, Int>::from_data(TensorData::new(vec![0_i64], [1]), &device);
410 state.best_genome = Some(state.archive.clone().select(0, first_idx));
411 }
412
413 state.generation += 1;
414 let m =
415 StrategyMetrics::from_host_fitness(state.generation, &fitness_host, state.best_fitness);
416 state.best_fitness = m.best_fitness_ever();
417 (state, m)
418 }
419
420 fn best(&self, state: &AcoRState<B>) -> Option<(Tensor<B, 2>, f32)> {
425 state
426 .best_genome
427 .as_ref()
428 .map(|g| (g.clone(), state.best_fitness))
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use crate::fitness::FromFitnessEvaluable;
436 use crate::strategy::EvolutionaryHarness;
437 use burn::backend::Flex;
438 use burn::backend::flex::FlexDevice;
439 use rand::SeedableRng;
440 use rand::rngs::StdRng;
441 use rlevo_core::fitness::FitnessEvaluable;
442
443 #[test]
444 fn default_config_validates() {
445 assert!(AcoRConfig::default_for(50, 30, 10).validate().is_ok());
446 }
447
448 #[test]
449 fn rejects_archive_below_two() {
450 let mut cfg = AcoRConfig::default_for(50, 30, 10);
451 cfg.archive_size = 1;
452 assert_eq!(cfg.validate().unwrap_err().field, "archive_size");
453 }
454
455 type TestBackend = Flex;
456
457 struct Sphere;
458 struct SphereFit;
459 impl FitnessEvaluable for SphereFit {
460 type Individual = Vec<f64>;
461 type Landscape = Sphere;
462 fn evaluate(&self, x: &Self::Individual, _: &Self::Landscape) -> f64 {
463 x.iter().map(|v| v * v).sum()
464 }
465 }
466
467 #[test]
468 fn weights_sum_to_one() {
469 let w = AntColonyReal::<TestBackend>::compute_weights(10, 0.1);
470 let total: f32 = w.iter().sum();
471 approx::assert_relative_eq!(total, 1.0, epsilon = 1e-5);
472 }
473
474 #[test]
475 fn weights_normal_case_monotone_non_increasing() {
476 let w: Vec<f32> = AntColonyReal::<TestBackend>::compute_weights(10, 0.1);
477 let total: f32 = w.iter().sum();
478 approx::assert_relative_eq!(total, 1.0, epsilon = 1e-5);
479 for pair in w.windows(2) {
481 assert!(
482 pair[0] >= pair[1],
483 "weights must be non-increasing: {} < {}",
484 pair[0],
485 pair[1]
486 );
487 }
488 }
489
490 #[test]
491 fn weights_degenerate_q_fall_back_to_uniform() {
492 for q in [1e-30_f32, 0.0_f32] {
493 let w: Vec<f32> = AntColonyReal::<TestBackend>::compute_weights(10, q);
494 assert!(
495 w.iter().all(|v| v.is_finite() && *v >= 0.0),
496 "degenerate q={q} produced non-finite / negative weights: {w:?}"
497 );
498 let total: f32 = w.iter().sum();
499 approx::assert_relative_eq!(total, 1.0, epsilon = 1e-5);
500 }
501 }
502
503 fn state_forcing_row_zero(
515 archive_vals: Vec<f32>,
516 device: FlexDevice,
517 ) -> AcoRState<TestBackend> {
518 let archive: Tensor<TestBackend, 2> =
519 Tensor::from_data(TensorData::new(archive_vals, [3, 2]), &device);
520 AcoRState {
521 archive,
522 archive_fitness: vec![3.0, 0.0, -3.0],
525 weights: vec![1.0, 0.0, 0.0],
526 best_genome: None,
527 best_fitness: f32::NEG_INFINITY,
528 generation: 1,
529 }
530 }
531
532 #[test]
533 fn ask_recovers_from_infinite_sigma_via_mean_fallback() {
534 let device: FlexDevice = Default::default();
539 let strategy: AntColonyReal<TestBackend> = AntColonyReal::new();
540 let params: AcoRConfig = AcoRConfig::default_for(3, 4, 2);
541 let state: AcoRState<TestBackend> =
542 state_forcing_row_zero(vec![1.0, 2.0, f32::INFINITY, 0.5, -1.0, -2.0], device);
543
544 let mut rng: StdRng = StdRng::seed_from_u64(21);
545 let (pop, _next): (Tensor<TestBackend, 2>, AcoRState<TestBackend>) =
547 strategy.ask(¶ms, &state, &mut rng, &device);
548 let vals: Vec<f32> = pop
549 .into_data()
550 .into_vec::<f32>()
551 .expect("offspring tensor must be readable as f32");
552
553 assert_eq!(vals.len(), 4 * 2);
556 for (idx, &v) in vals.iter().enumerate() {
557 assert!(v.is_finite(), "offspring[{idx}] = {v} is not finite");
558 }
559 for i in 0..4 {
560 approx::assert_relative_eq!(vals[i * 2], 1.0_f32, epsilon = 1e-6);
561 }
562 }
563
564 #[test]
565 fn ask_passes_nan_mean_through_for_downstream_hygiene() {
566 let device: FlexDevice = Default::default();
572 let strategy: AntColonyReal<TestBackend> = AntColonyReal::new();
573 let params: AcoRConfig = AcoRConfig::default_for(3, 4, 2);
574 let state: AcoRState<TestBackend> =
575 state_forcing_row_zero(vec![f32::NAN, 2.0, 1.0, 0.5, -1.0, -2.0], device);
576
577 let mut rng: StdRng = StdRng::seed_from_u64(23);
578 let (pop, _next): (Tensor<TestBackend, 2>, AcoRState<TestBackend>) =
580 strategy.ask(¶ms, &state, &mut rng, &device);
581 let vals: Vec<f32> = pop
582 .into_data()
583 .into_vec::<f32>()
584 .expect("offspring tensor must be readable as f32");
585
586 assert_eq!(vals.len(), 4 * 2);
587 for i in 0..4 {
588 assert!(
590 vals[i * 2].is_nan(),
591 "expected NaN passthrough at column 0, got {}",
592 vals[i * 2]
593 );
594 assert!(
596 vals[i * 2 + 1].is_finite(),
597 "column 1 offspring should stay finite, got {}",
598 vals[i * 2 + 1]
599 );
600 }
601 }
602
603 #[test]
604 fn aco_r_converges_on_sphere_d10() {
605 let device = Default::default();
606 let strategy = AntColonyReal::<TestBackend>::new();
607 let params = AcoRConfig::default_for(30, 15, 10);
608 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
609 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
610 strategy, params, fitness_fn, 17, device, 400,
611 )
612 .expect("valid params");
613 harness.reset();
614 while !harness.step(()).done {}
615 let best = harness.latest_metrics().unwrap().best_fitness_ever();
616 assert!(best < 1e-3, "ACO_R D10 best={best}");
617 }
618
619 struct PartialNanFitness;
622 impl<B: Backend> crate::fitness::BatchFitnessFn<B, Tensor<B, 2>> for PartialNanFitness {
623 fn evaluate_batch(
624 &mut self,
625 population: &Tensor<B, 2>,
626 device: &<B as burn::tensor::backend::BackendTypes>::Device,
627 ) -> Tensor<B, 1> {
628 let n = population.dims()[0];
629 #[allow(clippy::cast_precision_loss)]
630 let mut vals: Vec<f32> = (0..n).map(|i| -(i as f32)).collect();
631 vals[0] = f32::NAN;
632 Tensor::<B, 1>::from_data(TensorData::new(vals, [n]), device)
633 }
634 fn sense(&self) -> rlevo_core::objective::ObjectiveSense {
635 rlevo_core::objective::ObjectiveSense::Maximize
636 }
637 }
638
639 #[test]
641 fn best_is_none_before_first_tell() {
642 let device: FlexDevice = Default::default();
643 let strategy = AntColonyReal::<TestBackend>::new();
644 let params = AcoRConfig::default_for(10, 5, 4);
645 let mut rng = StdRng::seed_from_u64(1);
646 let state = strategy.init(¶ms, &mut rng, &device);
647 assert!(strategy.best(&state).is_none());
648 }
649
650 #[test]
654 #[allow(clippy::float_cmp)] fn first_ask_returns_archive_verbatim() {
656 let device: FlexDevice = Default::default();
657 let strategy = AntColonyReal::<TestBackend>::new();
658 let params = AcoRConfig::default_for(8, 4, 3);
659 let mut rng = StdRng::seed_from_u64(2);
660 let state = strategy.init(¶ms, &mut rng, &device);
661 let (genome, _next) = strategy.ask(¶ms, &state, &mut rng, &device);
662 let before = state
663 .archive
664 .clone()
665 .into_data()
666 .into_vec::<f32>()
667 .expect("archive readable as f32");
668 let after = genome
669 .into_data()
670 .into_vec::<f32>()
671 .expect("genome readable as f32");
672 assert_eq!(before, after);
673 }
674
675 #[test]
679 fn offspring_stay_within_bounds() {
680 let device: FlexDevice = Default::default();
681 let strategy = AntColonyReal::<TestBackend>::new();
682 let params = AcoRConfig::default_for(10, 12, 4);
683 let (lo, hi): (f32, f32) = params.bounds.into();
684 for seed in 0..32 {
685 let mut rng = StdRng::seed_from_u64(seed);
686 let mut state = strategy.init(¶ms, &mut rng, &device);
687 #[allow(clippy::cast_precision_loss)]
690 {
691 state.archive_fitness = (0..params.archive_size).map(|i| -(i as f32)).collect();
692 }
693 state.generation = 1;
694 let (pop, _next) = strategy.ask(¶ms, &state, &mut rng, &device);
695 let vals = pop
696 .into_data()
697 .into_vec::<f32>()
698 .expect("offspring readable as f32");
699 for &v in &vals {
700 assert!(
701 v >= lo && v <= hi,
702 "offspring {v} out of bounds [{lo}, {hi}] (seed {seed})"
703 );
704 }
705 }
706 }
707
708 #[test]
711 fn boundary_archive_size_two_runs() {
712 let device: FlexDevice = Default::default();
713 let strategy = AntColonyReal::<TestBackend>::new();
714 let params = AcoRConfig::default_for(2, 4, 3);
715 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
716 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
717 strategy, params, fitness_fn, 6, device, 6,
718 )
719 .expect("valid params");
720 harness.reset();
721 while !harness.step(()).done {}
722 assert!(
723 harness
724 .latest_metrics()
725 .unwrap()
726 .best_fitness_ever()
727 .is_finite(),
728 "non-finite best for archive_size = 2"
729 );
730 }
731
732 #[test]
738 fn boundary_genome_dim_one_runs() {
739 let device: FlexDevice = Default::default();
740 let strategy = AntColonyReal::<TestBackend>::new();
741 let params = AcoRConfig::default_for(8, 4, 1);
742 let fitness_fn = FromFitnessEvaluable::new(SphereFit, Sphere);
743 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
744 strategy, params, fitness_fn, 6, device, 6,
745 )
746 .expect("valid params");
747 harness.reset();
748 while !harness.step(()).done {}
749 assert!(
750 harness
751 .latest_metrics()
752 .unwrap()
753 .best_fitness_ever()
754 .is_finite(),
755 "non-finite best for genome_dim = 1"
756 );
757 }
758
759 #[test]
763 fn nan_fitness_survives_harness() {
764 let device: FlexDevice = Default::default();
765 let strategy = AntColonyReal::<TestBackend>::new();
766 let params = AcoRConfig::default_for(8, 6, 3);
767 let mut harness = EvolutionaryHarness::<TestBackend, _, _>::new(
768 strategy,
769 params,
770 PartialNanFitness,
771 4,
772 device,
773 4,
774 )
775 .expect("valid params");
776 harness.reset();
777 while !harness.step(()).done {}
778 let m = harness.latest_metrics().unwrap();
779 assert!(
780 m.best_fitness_ever().is_finite(),
781 "best_fitness_ever not finite: {}",
782 m.best_fitness_ever()
783 );
784 assert!(m.broken_count() > 0, "expected a broken (NaN) member");
785 }
786}