Skip to main content

rlevo_evolution/ops/
selection.rs

1//! Parent-selection operators.
2//!
3//! Selection operators turn a fitness slice into winner indices that the
4//! caller uses (via `Tensor::select`) to gather parents out of the
5//! population. The baseline is host-side sampling + a single device
6//! gather; a fused [kernel](super::kernels) variant is tracked as
7//! follow-up work.
8//!
9//! All operators that draw random numbers accept an explicit `&mut dyn Rng`
10//! (the host-RNG convention). Operators never touch thread-local or
11//! process-wide backend RNG state (`B::seed` / `Tensor::random`), which
12//! would race with sibling tests under the parallel test runner.
13//!
14//! # Fitness convention
15//!
16//! Fitness values are **canonical (maximise)**: *higher is better*.
17//! Tournament selection retains the *largest* fitness seen across all
18//! candidates in a single draw; truncation selection returns the `top_k`
19//! entries with the largest fitnesses; [`argmax_host`] reduces a fitness
20//! slice to the index of its single largest entry.
21
22use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
23use rand::{Rng, RngExt};
24
25/// Returns the index of the largest fitness value.
26///
27/// `fitness` is canonical (maximise): *higher is better*. The scan keeps
28/// the running best under a strict `>` seeded from `NEG_INFINITY`, so:
29///
30/// - ties resolve to the lowest index,
31/// - `NaN` and `−inf` entries never displace the running best (every
32///   comparison against them or the seed is strict), and
33/// - a slice with no entry above `−inf` (e.g. all-`NaN`) falls back to
34///   index `0`.
35///
36/// # Examples
37///
38/// ```
39/// use rlevo_evolution::ops::selection::argmax_host;
40///
41/// let fitness = [1.0_f32, 5.0, 3.0];
42/// assert_eq!(argmax_host(&fitness), 1);
43/// ```
44///
45/// # Panics
46///
47/// Panics if `fitness.is_empty()`.
48#[must_use]
49pub fn argmax_host(fitness: &[f32]) -> usize {
50    assert!(!fitness.is_empty(), "fitness must be non-empty");
51    let mut best_idx = 0usize;
52    let mut best = f32::NEG_INFINITY;
53    for (i, &v) in fitness.iter().enumerate() {
54        if v > best {
55            best = v;
56            best_idx = i;
57        }
58    }
59    best_idx
60}
61
62/// K-ary tournament selection over a fitness slice.
63///
64/// Draws `tournament_size` candidate indices uniformly at random (with
65/// replacement) and retains the one with the largest fitness. Repeats
66/// the draw `n_winners` times and returns the winning index for each
67/// draw. Randomness is drawn entirely from the caller-supplied `rng`;
68/// no global or backend RNG state is touched.
69///
70/// `fitness` is a flat slice where index `i` is the canonical fitness of
71/// population member `i` (higher is better). `tournament_size` controls
72/// selection pressure: larger values yield stronger pressure toward
73/// higher-fitness members. A `tournament_size` of 1 degenerates to
74/// pressure-free uniform-random selection.
75///
76/// # Examples
77///
78/// ```
79/// use rand::SeedableRng;
80/// use rand::rngs::StdRng;
81/// use rlevo_evolution::ops::selection::tournament_indices_host;
82///
83/// let fitness = [1.0_f32, 10.0, 1.0, 1.0];
84/// let mut rng = StdRng::seed_from_u64(0);
85/// let winners = tournament_indices_host(&fitness, 2, 100, &mut rng);
86/// // The unique best member (index 1) is selected far more often than any
87/// // single lower-fitness member.
88/// let best_wins = winners.iter().filter(|&&w| w == 1).count();
89/// let low_fitness_wins = winners.iter().filter(|&&w| w == 0).count();
90/// assert!(best_wins > low_fitness_wins);
91/// ```
92///
93/// # Panics
94///
95/// Panics if `fitness.is_empty()` or if `tournament_size == 0`.
96#[must_use]
97pub fn tournament_indices_host(
98    fitness: &[f32],
99    tournament_size: usize,
100    n_winners: usize,
101    rng: &mut dyn Rng,
102) -> Vec<i32> {
103    assert!(!fitness.is_empty(), "fitness must be non-empty");
104    assert!(tournament_size >= 1, "tournament size must be >= 1");
105    let pop_size = fitness.len();
106    let mut winners = Vec::with_capacity(n_winners);
107    for _ in 0..n_winners {
108        let mut best_idx = rng.random_range(0..pop_size);
109        let mut best_f = fitness[best_idx];
110        for _ in 1..tournament_size {
111            let idx = rng.random_range(0..pop_size);
112            if fitness[idx] > best_f {
113                best_f = fitness[idx];
114                best_idx = idx;
115            }
116        }
117        #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
118        winners.push(best_idx as i32);
119    }
120    winners
121}
122
123/// Gathers `n_winners` rows out of a population tensor by tournament.
124///
125/// Convenience wrapper that runs [`tournament_indices_host`] on the
126/// host and then performs a single `Tensor::select` gather on the
127/// device. The returned tensor has shape `(n_winners, genome_dim)`.
128///
129/// `population` must have shape `(N, D)` where `N` matches
130/// `fitness.len()`. `tournament_size` and `n_winners` are forwarded
131/// directly to [`tournament_indices_host`].
132///
133/// # Panics
134///
135/// Inherits the panic conditions of [`tournament_indices_host`]:
136/// `fitness.is_empty()` or `tournament_size == 0`.
137///
138/// Panics if `population.dims()[0] != fitness.len()`.
139#[must_use]
140pub fn tournament_select<B: Backend>(
141    population: &Tensor<B, 2>,
142    fitness: &[f32],
143    tournament_size: usize,
144    n_winners: usize,
145    rng: &mut dyn Rng,
146    device: &<B as burn::tensor::backend::BackendTypes>::Device,
147) -> Tensor<B, 2> {
148    assert_eq!(
149        population.dims()[0],
150        fitness.len(),
151        "tournament_select: population rows ({}) must equal fitness.len() ({})",
152        population.dims()[0],
153        fitness.len(),
154    );
155    let winners = tournament_indices_host(fitness, tournament_size, n_winners, rng);
156    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [n_winners]), device);
157    population.clone().select(0, indices)
158}
159
160/// Returns the indices of the `top_k` highest-fitness members.
161///
162/// Sorts the population by fitness (descending, i.e. highest first) and
163/// returns the first `top_k` indices. The returned `Vec` is ordered from
164/// best to worst among the selected members. `NaN` fitnesses are sanitised to
165/// `−inf` (worst, per the maximise convention) and ordered with `f32::total_cmp`,
166/// so a `NaN`-fitness member always sorts last and can never be selected as best.
167///
168/// This is the host-side building block; call [`truncation_select`] when
169/// you need the corresponding population rows as a tensor.
170///
171/// # Examples
172///
173/// ```
174/// use rlevo_evolution::ops::selection::truncation_indices_host;
175///
176/// let fitness = [5.0_f32, 1.0, 3.0, 2.0, 4.0];
177/// let idx = truncation_indices_host(&fitness, 3);
178/// assert_eq!(idx.len(), 3);
179/// // The three fittest members are at original indices 0 (5.0), 4 (4.0), 2 (3.0).
180/// assert!(idx.contains(&0));
181/// assert!(idx.contains(&4));
182/// assert!(idx.contains(&2));
183/// ```
184///
185/// # Panics
186///
187/// Panics if `top_k > fitness.len()` or `fitness.is_empty()`.
188#[must_use]
189pub fn truncation_indices_host(fitness: &[f32], top_k: usize) -> Vec<i32> {
190    assert!(!fitness.is_empty(), "fitness must be non-empty");
191    assert!(top_k <= fitness.len(), "top_k must be <= population size");
192    // Sanitize NaN → −inf (worst under maximise) so a NaN-fitness member can
193    // never rank as best; `total_cmp` then gives a deterministic total order.
194    let mut indexed: Vec<(usize, f32)> = fitness
195        .iter()
196        .map(|&f| crate::fitness::sanitize_fitness(f))
197        .enumerate()
198        .collect();
199    // Descending: highest fitness first; sanitized NaN sorts last.
200    indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
201    #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
202    indexed
203        .into_iter()
204        .take(top_k)
205        .map(|(i, _)| i as i32)
206        .collect()
207}
208
209/// Gathers the `top_k` highest-fitness rows out of a population tensor.
210///
211/// Convenience wrapper that runs [`truncation_indices_host`] on the
212/// host and then performs a single `Tensor::select` gather on the
213/// device. The returned tensor has shape `(top_k, genome_dim)` with
214/// rows ordered from best to worst fitness.
215///
216/// `population` must have shape `(N, D)` where `N` matches
217/// `fitness.len()`.
218///
219/// # Panics
220///
221/// Inherits the panic conditions of [`truncation_indices_host`]:
222/// `fitness.is_empty()` or `top_k > fitness.len()`.
223///
224/// Panics if `population.dims()[0] != fitness.len()`.
225#[must_use]
226pub fn truncation_select<B: Backend>(
227    population: &Tensor<B, 2>,
228    fitness: &[f32],
229    top_k: usize,
230    device: &<B as burn::tensor::backend::BackendTypes>::Device,
231) -> Tensor<B, 2> {
232    assert_eq!(
233        population.dims()[0],
234        fitness.len(),
235        "truncation_select: population rows ({}) must equal fitness.len() ({})",
236        population.dims()[0],
237        fitness.len(),
238    );
239    let winners = truncation_indices_host(fitness, top_k);
240    let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(winners, [top_k]), device);
241    population.clone().select(0, indices)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use burn::backend::Flex;
248    use rand::SeedableRng;
249    use rand::rngs::StdRng;
250
251    type TestBackend = Flex;
252
253    // ANCHOR: tournament_expectation_test
254    #[test]
255    fn tournament_prefers_better_fitness_in_expectation() {
256        let mut rng = StdRng::seed_from_u64(1);
257        let fitness = [0.0f32, 100.0, 0.0, 0.0];
258        let winners = tournament_indices_host(&fitness, 2, 1000, &mut rng);
259        let wins_for_best = winners.iter().filter(|&&w| w == 1).count();
260        // For pop_size=4 and tournament_size=2, P(best wins) =
261        // 1 − (3/4)² = 7/16 ≈ 0.4375 → ~437 wins per 1000 trials.
262        // Use a generous band to stay stable across RNG versions.
263        assert!(
264            (350..=550).contains(&wins_for_best),
265            "wins_for_best={wins_for_best} (expected ~437)",
266        );
267    }
268    // ANCHOR_END: tournament_expectation_test
269
270    // ANCHOR: truncation_ordering_test
271    #[test]
272    fn truncation_returns_largest_fitness_first() {
273        let fitness = [5.0f32, 1.0, 3.0, 2.0, 4.0];
274        let idx = truncation_indices_host(&fitness, 3);
275        // The three largest fitnesses live at indices 0 (=5.0), 4 (=4.0), 2 (=3.0).
276        assert_eq!(idx.len(), 3);
277        assert!(idx.contains(&0));
278        assert!(idx.contains(&4));
279        assert!(idx.contains(&2));
280    }
281    // ANCHOR_END: truncation_ordering_test
282
283    #[test]
284    fn argmax_returns_index_of_largest() {
285        assert_eq!(argmax_host(&[1.0, 5.0, 3.0]), 1);
286        assert_eq!(argmax_host(&[-3.0, -1.0, -2.0]), 1);
287        assert_eq!(argmax_host(&[7.0]), 0);
288    }
289
290    #[test]
291    fn argmax_tie_resolves_to_lowest_index() {
292        assert_eq!(argmax_host(&[2.0, 5.0, 5.0, 5.0]), 1);
293    }
294
295    #[test]
296    fn argmax_nan_never_wins() {
297        assert_eq!(argmax_host(&[1.0, f32::NAN, 3.0]), 2);
298        assert_eq!(argmax_host(&[f32::NAN, 1.0]), 1);
299    }
300
301    #[test]
302    fn argmax_degenerate_slice_falls_back_to_zero() {
303        // No entry strictly exceeds the NEG_INFINITY seed, so the scan
304        // keeps the initial index 0.
305        assert_eq!(argmax_host(&[f32::NAN, f32::NAN]), 0);
306        assert_eq!(argmax_host(&[f32::NAN, f32::NEG_INFINITY]), 0);
307        assert_eq!(argmax_host(&[f32::NEG_INFINITY, f32::NEG_INFINITY]), 0);
308    }
309
310    #[test]
311    #[should_panic(expected = "fitness must be non-empty")]
312    fn argmax_empty_panics() {
313        let _ = argmax_host(&[]);
314    }
315
316    #[test]
317    fn tournament_size_one_is_uniform_random() {
318        // A 1-ary "tournament" is a single uniform draw: the dominant
319        // member wins ~1/4 of the time, not most of the time.
320        let mut rng = StdRng::seed_from_u64(3);
321        let fitness = [0.0f32, 100.0, 0.0, 0.0];
322        let winners = tournament_indices_host(&fitness, 1, 1000, &mut rng);
323        let wins_for_best = winners.iter().filter(|&&w| w == 1).count();
324        assert!(
325            (150..=350).contains(&wins_for_best),
326            "wins_for_best={wins_for_best} (expected ~250)",
327        );
328    }
329
330    #[test]
331    fn truncation_sorts_nan_fitness_last() {
332        // `total_cmp` must place NaN-fitness members last (never panic), so a
333        // full-population truncation ranks the finite members ahead of the NaN.
334        let fitness = [3.0f32, f32::NAN, 5.0, 1.0];
335        let idx = truncation_indices_host(&fitness, fitness.len());
336        assert_eq!(idx.len(), 4);
337        // Best-first among finite fitnesses: 5.0 (idx 2), 3.0 (idx 0), 1.0 (idx 3).
338        assert_eq!(&idx[..3], &[2, 0, 3]);
339        // The NaN-fitness member (idx 1) sorts last.
340        assert_eq!(idx[3], 1);
341    }
342
343    // ANCHOR: tournament_select_shape_test
344    #[test]
345    fn tournament_select_returns_shaped_tensor() {
346        let device = Default::default();
347        let data = TensorData::new(vec![0.0f32, 1.0, 2.0, 3.0, 4.0, 5.0], [3, 2]);
348        let pop = Tensor::<TestBackend, 2>::from_data(data, &device);
349        let fitness = [0.0_f32, 10.0, 0.0];
350        let mut rng = StdRng::seed_from_u64(2);
351        let parents = tournament_select(&pop, &fitness, 2, 4, &mut rng, &device);
352        assert_eq!(parents.dims(), [4, 2]);
353    }
354    // ANCHOR_END: tournament_select_shape_test
355}