Skip to main content

rlevo_evolution/algorithms/metaheuristic/
aco_r.rs

1//! Ant Colony Optimization for continuous domains (`ACO_R`).
2//!
3//! Socha & Dorigo's 2008 extension of ACO to real-valued search spaces.
4//! The colony maintains an **archive** of the `k` best solutions seen
5//! so far; per generation it samples `m` offspring by drawing each
6//! dimension from a Gaussian kernel centred on an archive solution
7//! selected by rank-weighted roulette:
8//!
9//! 1. Compute per-archive, per-dim `σ_{l,d} = ξ · mean_e |x_{e,d} − x_{l,d}|`.
10//! 2. For every offspring `i` and every dimension `d`:
11//!    - Sample an archive index `l ∼ Categorical(w)` where
12//!      `w_l ∝ exp(−(rank_l − 1)² / (2·q²·k²))`.
13//!    - Sample `x_{i,d} ∼ N(x_{l,d}, σ_{l,d})`.
14//! 3. Evaluate offspring, merge with archive, keep top `k`.
15//!
16//! # References
17//!
18//! - Socha & Dorigo (2008), *Ant colony optimization for continuous domains*.
19
20use 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/// Static configuration for [`AntColonyReal`].
33#[derive(Debug, Clone)]
34pub struct AcoRConfig {
35    /// Archive size (number of "best" solutions kept). Socha & Dorigo's
36    /// recommended default is `k = 50`.
37    pub archive_size: usize,
38    /// Offspring per generation (`m` in the paper).
39    pub m: usize,
40    /// Genome dimensionality.
41    pub genome_dim: usize,
42    /// Search-space bounds.
43    pub bounds: Bounds,
44    /// Exploration scale (`ξ`). Higher → wider sampling. Canonical 0.85.
45    pub xi: f32,
46    /// Rank-weight decay (`q`). Smaller → stronger bias toward top of
47    /// the archive. Canonical `q = 0.01` (sharp) up to `q ≈ 0.5` (flat).
48    pub q: f32,
49}
50
51impl AcoRConfig {
52    /// Default configuration for a given archive size, offspring count, and dimensionality.
53    #[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    /// Steady-state offspring count per generation (`m`). Note that
66    /// the very first generation evaluates the full initial archive
67    /// (`archive_size` rows) instead — only generations ≥ 1 score `m`.
68    #[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/// Generation state for [`AntColonyReal`].
87#[derive(Debug, Clone)]
88pub struct AcoRState<B: Backend> {
89    /// Archive of `k` best solutions, shape `(k, D)`.
90    pub archive: Tensor<B, 2>,
91    /// Host-side archive fitness, sorted descending (best, i.e. highest,
92    /// first) after the first `tell`.
93    pub archive_fitness: Vec<f32>,
94    /// Cached archive weights (recomputed only when `q` or `k` change).
95    pub weights: Vec<f32>,
96    /// Best-so-far genome.
97    pub best_genome: Option<Tensor<B, 2>>,
98    /// Best-so-far fitness.
99    pub best_fitness: f32,
100    /// Generation counter.
101    pub generation: usize,
102}
103
104/// Ant Colony Optimization (continuous domains).
105///
106/// The `archive_size >= 2` (the σ computation needs at least two archive
107/// solutions to take a pairwise distance) and `m >= 1` invariants are enforced
108/// by [`Validate::validate`] at the harness chokepoint.
109///
110/// # Example
111///
112/// ```no_run
113/// use burn::backend::Flex;
114/// use rlevo_evolution::algorithms::metaheuristic::aco_r::{AcoRConfig, AntColonyReal};
115///
116/// let strategy = AntColonyReal::<Flex>::new();
117/// let params = AcoRConfig::default_for(50, 10, 10);
118/// let _ = (strategy, params);
119/// ```
120#[derive(Debug, Clone, Copy, Default)]
121pub struct AntColonyReal<B: Backend> {
122    _backend: PhantomData<fn() -> B>,
123}
124
125impl<B: Backend> AntColonyReal<B> {
126    /// Builds a new (stateless) strategy object.
127    #[must_use]
128    pub fn new() -> Self {
129        Self {
130            _backend: PhantomData,
131        }
132    }
133
134    /// Compute rank-based archive weights `w_l ∝ exp(−(l−1)² / (2·q²·k²))`.
135    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        // Drop the Gaussian-PDF normalisation constant: it cancels exactly under
140        // the sum-to-one renormalisation below, and for tiny q·k it overflows to
141        // +inf, producing inf/inf = NaN. `l` is 0-indexed here; equivalent to the
142        // paper's 1-indexed (l−1)² after normalisation.
143        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            // Degenerate q (e.g. q == 0 / underflow): fall back to uniform so the
153            // CDF sampler in `ask` never sees all-zero / NaN weights.
154            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    /// Initialises the archive by host-sampling `archive_size × genome_dim`
173    /// values uniformly from `params.bounds`.
174    ///
175    /// All random draws go through [`seed_stream`] derived from `rng` rather
176    /// than `B::seed` + `Tensor::random`; this keeps draws reproducible across
177    /// thread schedules when multiple tests or harnesses share the same
178    /// process-wide Burn RNG state.
179    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        // Host-sample the initial archive from a deterministic `seed_stream`
191        // rather than the process-wide Flex RNG (`B::seed` + `Tensor::random`),
192        // whose draws interleave with sibling tests under the parallel runner
193        // and are not reproducible across thread schedules.
194        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    /// Proposes the next population.
214    ///
215    /// **First call** (`state.archive_fitness.is_empty()`): returns the
216    /// initial archive as-is so the harness scores it before any generation
217    /// update occurs.
218    ///
219    /// **Subsequent calls**: draws `m` offspring by, for each dimension `d`
220    /// of each offspring `i`:
221    ///
222    /// 1. Selecting an archive index `l` by CDF-weighted roulette from
223    ///    `state.weights` (host-side, via `seed_stream`).
224    /// 2. Computing `σ_{l,d} = ξ · mean_e |archive_{e,d} − archive_{l,d}|`
225    ///    on-device.
226    /// 3. Sampling `x_{i,d} ~ N(archive_{l,d}, σ_{l,d})` host-side via
227    ///    `rand_distr::Normal` and a second `seed_stream`.
228    ///
229    /// Offspring are clamped to `params.bounds` before upload to the device.
230    /// The returned state is the same object passed in (no mutation occurs in
231    /// `ask`; all state changes are deferred to [`tell`](Self::tell)).
232    #[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        // First call: evaluate the initial archive.
241        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        // σ[l, j] = ξ · (1/(k-1)) · Σ_e |archive[e, j] - archive[l, j]|
250        // Computed on-device by expanding archive along axis 0 to (k, k, d),
251        // taking |a - b|, reducing along axis 0 (the "e" axis).
252        let archive_l = state.archive.clone().unsqueeze_dim::<3>(0); // (1, k, d)
253        let archive_e = state.archive.clone().unsqueeze_dim::<3>(1); // (k, 1, d)
254        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); // (k, d)
258
259        // Weighted index sampling (host-side) — `m · d` independent draws.
260        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        // Gather host-side slices for indexing.
269        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        // Sample N(mean, sigma) host-side. Using rand_distr keeps the
300        // draw on the same splitmix stream already threaded through
301        // `stream` above.
302        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            // A non-finite σ falls back to the archive mean rather than
310            // panicking (σ is already floored to 1e-12 above, so this is a
311            // belt-and-braces guard). A NaN mean would pass through unchanged;
312            // the clamp below bounds finite draws but does NOT launder a NaN —
313            // that is neutralized by the ADR-0034 fitness-hygiene chokepoint
314            // downstream.
315            *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    /// Merges `population` with the current archive and updates state.
328    ///
329    /// **First tell** (`state.archive_fitness.is_empty()`): `population` is
330    /// the initial archive returned verbatim by the first `ask`; this call
331    /// sorts it by fitness and records `best_genome` and `best_fitness`.
332    ///
333    /// **Steady-state tell**: concatenates `state.archive` (shape `(k, D)`)
334    /// with `population` (shape `(m, D)`), sorts the combined `k + m` rows by
335    /// fitness, and retains only the top `k`. Updates `best_genome` if the new
336    /// archive leader improves on `state.best_fitness`.
337    ///
338    /// Returns the updated state and a [`StrategyMetrics`] snapshot built from
339    /// `fitness` (the offspring scores, not the full archive).
340    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        // First tell: the population being scored IS the initial archive.
356        if state.archive_fitness.is_empty() {
357            // Sort archive by fitness, best (highest) first.
358            let mut idx: Vec<usize> = (0..fitness_host.len()).collect();
359            // Sanitize NaN → −inf (worst) so it can never rank as best; descending.
360            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        // Steady state: merge archive + offspring, keep top-k.
387        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        // Sanitize NaN → −inf (worst) so it can never rank as best; descending.
392        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    /// Returns the best genome seen across all generations, or `None` before
421    /// the first [`tell`](Self::tell) call.
422    ///
423    /// The returned tensor has shape `(1, genome_dim)`.
424    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        // Rank 0 (best) must be the heaviest; weights decay with rank.
480        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    // --- NaN-safe sampling coverage through the real `ask` call site ---
504    //
505    // These exercise the `sampling::normal_or_mean` guard end-to-end: a
506    // non-finite value is injected into the archive that `ask` reads, and we
507    // assert `ask` neither panics nor returns garbage where the guard should
508    // recover. Directly injectable because every `AcoRState` field is `pub`.
509
510    /// Builds a 3-row archive whose weights force the roulette in `ask` to
511    /// always select archive row 0 (`weights = [1, 0, 0]` ⇒ CDF `[1, 1, 1]` ⇒
512    /// `pick(u) == 0` for every `u ∈ [0, 1)`), so the sampled mean is
513    /// deterministically `archive[0, :]`.
514    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            // Non-empty so `ask` takes the sampling path, not the first-call
523            // early return.
524            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        // Row 1, column 0 holds +∞. The on-device σ for column 0 becomes
535        // Σ_e |archive[e,0] − archive[0,0]| ⊇ |∞ − 1| = ∞, and ∞.max(1e-12) == ∞
536        // survives the floor — so `normal_or_mean(mean=1.0, std=∞)` hits the
537        // `Err` fallback and returns the finite mean instead of panicking.
538        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        // Must not panic.
546        let (pop, _next): (Tensor<TestBackend, 2>, AcoRState<TestBackend>) =
547            strategy.ask(&params, &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        // Every offspring is finite; column 0 recovers exactly to the finite
554        // mean archive[0,0] = 1.0 (the fallback draw, clamped inside bounds).
555        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        // Row 0, column 0 holds NaN and is the always-selected mean. σ for
567        // column 0 is NaN, but NaN.max(1e-12) == 1e-12 (the floor launders the
568        // NaN σ), so `normal_or_mean(mean=NaN, std=1e-12)` takes the `Ok` path
569        // and the NaN *mean* propagates: `ask` intentionally does NOT launder it
570        // (that is the ADR-0034 fitness-hygiene chokepoint's job downstream).
571        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        // Must not panic despite the NaN in the read path.
579        let (pop, _next): (Tensor<TestBackend, 2>, AcoRState<TestBackend>) =
580            strategy.ask(&params, &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            // Column 0: NaN mean passes through unchanged.
589            assert!(
590                vals[i * 2].is_nan(),
591                "expected NaN passthrough at column 0, got {}",
592                vals[i * 2]
593            );
594            // Column 1: finite mean/σ still yield a finite draw.
595            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    /// Fitness fn: row 0 → `NaN`, the rest finite. `Maximize` so natural ==
620    /// canonical, exercising the ADR-0034 harness sanitize with no `neg()`.
621    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    // Gap (a): the best-so-far accessor is `None` until a `tell` records one.
640    #[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(&params, &mut rng, &device);
647        assert!(strategy.best(&state).is_none());
648    }
649
650    // Gap (b): the first `ask` (generation 0, empty `archive_fitness`) hits the
651    // early-return path and hands back the initial archive verbatim so the
652    // harness can score it before any Gaussian-kernel sampling occurs.
653    #[test]
654    #[allow(clippy::float_cmp)] // byte-identical: `ask` clones `state.archive`
655    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(&params, &mut rng, &device);
661        let (genome, _next) = strategy.ask(&params, &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    // Gap (c): a steady-state `ask` clamps every sampled offspring dimension
676    // into `bounds`. Swept across 32 seeds so the invariant holds independent of
677    // the draw.
678    #[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(&params, &mut rng, &device);
687            // Prime a non-empty archive fitness (descending) so `ask` samples
688            // rather than taking the bootstrap early return.
689            #[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(&params, &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    // Gap (d): the smallest archive (`archive_size = 2`, so the σ pairwise
709    // distance has exactly one term) drives a full run without panicking.
710    #[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    // Gap (d'): the `genome_dim = 1` case (issue #233). The σ reduction
733    // `diffs.sum_dim(0).squeeze_dim::<2>(0)` now removes only the reduced axis,
734    // so the `(1, k, 1)` intermediate correctly collapses to `(k, 1)` instead of
735    // panicking inside burn's `Squeeze` rank-check. Runs a full harness to
736    // completion and asserts a finite best.
737    #[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    // Gap (e): a partly-`NaN` objective is neutralized by the harness sanitize
760    // chokepoint (ADR 0034) — `best_fitness_ever` stays finite and the broken
761    // member is counted.
762    #[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}