rlevo_evolution/ops/crossover.rs
1//! Recombination / crossover operators for real-valued genomes.
2//!
3//! Operators in this module consume two parent tensors of shape
4//! `(N, D)` and produce an offspring tensor of the same shape. Each
5//! operator draws its randomness from a caller-supplied host `rng` and
6//! materialises the draws via `Tensor::from_data`, rather than seeding
7//! the process-wide backend RNG (`B::seed` + `Tensor::random`). Host
8//! sampling keeps results reproducible across thread schedules: the
9//! global Flex RNG mutex would otherwise interleave draws with sibling
10//! tests under the parallel runner.
11//!
12//! # BLX-α
13//!
14//! For each gene, child ∈ `U(min(a,b) − α·|a−b|, max(a,b) + α·|a−b|)`.
15//! A common default is α = 0.5.
16//!
17//! # Uniform
18//!
19//! For each gene, child takes parent A's value with probability `p` and
20//! parent B's otherwise. Pure swap crossover — no blending — so the
21//! distribution is exactly preserved. A binary-genome variant
22//! ([`binary_uniform_crossover`]) operates on `Tensor<B, 2, Int>` with
23//! values in `{0, 1}`.
24
25use burn::tensor::{Int, Tensor, TensorData, backend::Backend};
26use rand::{Rng, RngExt};
27use rlevo_core::probability::Probability;
28use rlevo_core::rate::NonNegativeRate;
29
30/// Builds an `(n·d,)` host vector of `U[0, 1)` draws sized for a
31/// `(n, d)` genome tensor.
32fn unit_uniform_rows(n: usize, d: usize, rng: &mut dyn Rng) -> Vec<f32> {
33 let mut rows = Vec::with_capacity(n * d);
34 for _ in 0..n * d {
35 rows.push(rng.random::<f32>());
36 }
37 rows
38}
39
40/// BLX-α (Blend Crossover α) between two parent populations.
41///
42/// For each gene position `i`, the child's value is drawn uniformly from the
43/// extended interval
44///
45/// ```text
46/// U(min(a_i, b_i) − α·|a_i − b_i|, max(a_i, b_i) + α·|a_i − b_i|)
47/// ```
48///
49/// When `α = 0` the child lies strictly within the parents' bounding box.
50/// `α = 0.5` is the conventional default and allows mild extrapolation beyond
51/// either parent. All `n·d` draws are taken from the caller-supplied host `rng`
52/// and loaded onto the device via [`Tensor::from_data`]; no backend-global RNG
53/// state is touched.
54///
55/// Both parent tensors must have shape `(N, D)` where `N` is the population
56/// size and `D` is the genome length; the returned offspring tensor has the
57/// same shape.
58///
59/// # Panics
60///
61/// Panics if `parent_a` and `parent_b` do not have identical shapes.
62#[must_use]
63pub fn blx_alpha<B: Backend>(
64 parent_a: Tensor<B, 2>,
65 parent_b: Tensor<B, 2>,
66 alpha: NonNegativeRate,
67 rng: &mut dyn Rng,
68 device: &<B as burn::tensor::backend::BackendTypes>::Device,
69) -> Tensor<B, 2> {
70 assert_eq!(
71 parent_a.dims(),
72 parent_b.dims(),
73 "BLX-α: parents must have identical shapes"
74 );
75 let [n, d] = parent_a.dims();
76
77 let alpha = alpha.get();
78 let min = parent_a.clone().min_pair(parent_b.clone());
79 let max = parent_a.max_pair(parent_b);
80 let diff = max.clone() - min.clone();
81 let lo = min - diff.clone().mul_scalar(alpha);
82 let hi = max + diff.mul_scalar(alpha);
83
84 let u = Tensor::<B, 2>::from_data(
85 TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
86 device,
87 );
88 lo.clone() + u * (hi - lo)
89}
90
91/// Uniform crossover: per-gene Bernoulli swap between two parents.
92///
93/// For each gene position, the child inherits the value from `parent_a` with
94/// probability `p` and from `parent_b` with probability `1 − p`. No blending
95/// occurs; the child's gene values are drawn exclusively from the two parents'
96/// existing alleles, so the distribution over individual gene values is
97/// exactly preserved.
98///
99/// `p = 0.5` gives an unbiased mix; `p = 1.0` returns a clone of `parent_a`;
100/// `p = 0.0` returns a clone of `parent_b`. All `n·d` Bernoulli draws are
101/// taken from the caller-supplied host `rng` and loaded onto the device via
102/// [`Tensor::from_data`]; no backend-global RNG state is touched.
103///
104/// Both parent tensors must have shape `(N, D)` where `N` is the population
105/// size and `D` is the genome length; the returned offspring tensor has the
106/// same shape.
107///
108/// For binary genomes (`Tensor<B, 2, Int>` with values in `{0, 1}`) see
109/// [`binary_uniform_crossover`].
110///
111/// # Panics
112///
113/// Panics if `parent_a` and `parent_b` do not have identical shapes.
114#[must_use]
115pub fn uniform_crossover<B: Backend>(
116 parent_a: Tensor<B, 2>,
117 parent_b: Tensor<B, 2>,
118 p: Probability,
119 rng: &mut dyn Rng,
120 device: &<B as burn::tensor::backend::BackendTypes>::Device,
121) -> Tensor<B, 2> {
122 assert_eq!(
123 parent_a.dims(),
124 parent_b.dims(),
125 "uniform crossover: parents must have identical shapes"
126 );
127 let [n, d] = parent_a.dims();
128 let u = Tensor::<B, 2>::from_data(
129 TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
130 device,
131 );
132 let keep_a = u.lower_elem(p.get());
133 parent_a.mask_where(keep_a.bool_not(), parent_b)
134}
135
136/// Binary uniform crossover on `Tensor<B, 2, Int>` populations.
137///
138/// The Int-tensor counterpart of [`uniform_crossover`], intended for binary
139/// genomes. For each gene, the child inherits `parent_a`'s allele with
140/// probability `p` and `parent_b`'s allele with probability `1 − p`. No
141/// blending is performed; the operation is a pure bitwise swap.
142///
143/// Both parents must hold values in `{0, 1}`. The returned tensor has the
144/// same shape and element type as the inputs. All `n·d` Bernoulli draws are
145/// taken from the caller-supplied host `rng` and loaded onto the device via
146/// [`Tensor::from_data`]; no backend-global RNG state is touched.
147///
148/// # Panics
149///
150/// Panics if `parent_a` and `parent_b` do not have identical shapes.
151#[must_use]
152pub fn binary_uniform_crossover<B: Backend>(
153 parent_a: Tensor<B, 2, Int>,
154 parent_b: Tensor<B, 2, Int>,
155 p: Probability,
156 rng: &mut dyn Rng,
157 device: &<B as burn::tensor::backend::BackendTypes>::Device,
158) -> Tensor<B, 2, Int> {
159 assert_eq!(
160 parent_a.dims(),
161 parent_b.dims(),
162 "binary uniform crossover: parents must have identical shapes"
163 );
164 let [n, d] = parent_a.dims();
165 let u = Tensor::<B, 2>::from_data(
166 TensorData::new(unit_uniform_rows(n, d, rng), [n, d]),
167 device,
168 );
169 let keep_a = u.lower_elem(p.get());
170 parent_a.mask_where(keep_a.bool_not(), parent_b)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use burn::backend::{Flex, flex::FlexDevice};
177 #[allow(unused_imports)]
178 use burn::tensor::backend::Backend as _;
179 use rand::SeedableRng;
180 use rand::rngs::StdRng;
181 type TestBackend = Flex;
182
183 #[test]
184 fn blx_alpha_lies_between_bounds() {
185 let device: FlexDevice = Default::default();
186 let mut rng = StdRng::seed_from_u64(13);
187 let a = Tensor::<TestBackend, 2>::from_data(
188 TensorData::new(vec![0.0_f32, 0.0, 0.0, 0.0], [2, 2]),
189 &device,
190 );
191 let b = Tensor::<TestBackend, 2>::from_data(
192 TensorData::new(vec![1.0_f32, 1.0, 1.0, 1.0], [2, 2]),
193 &device,
194 );
195 let c = blx_alpha(a, b, NonNegativeRate::new(0.0), &mut rng, &device);
196 let values = c
197 .into_data()
198 .into_vec::<f32>()
199 .expect("genome host-read of a tensor this test just built");
200 // α = 0: children lie strictly in [0, 1].
201 for v in values {
202 assert!((0.0..=1.0).contains(&v), "value out of bounds: {v}");
203 }
204 }
205
206 #[test]
207 fn uniform_all_from_a_when_p_is_one() {
208 let device: FlexDevice = Default::default();
209 let mut rng = StdRng::seed_from_u64(5);
210 let a = Tensor::<TestBackend, 2>::from_data(
211 TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
212 &device,
213 );
214 let b = Tensor::<TestBackend, 2>::from_data(
215 TensorData::new(vec![-7.0_f32, -7.0, -7.0, -7.0], [2, 2]),
216 &device,
217 );
218 let c = uniform_crossover(a, b, Probability::new(1.0), &mut rng, &device);
219 let values = c
220 .into_data()
221 .into_vec::<f32>()
222 .expect("genome host-read of a tensor this test just built");
223 for v in values {
224 approx::assert_relative_eq!(v, 7.0, epsilon = 1e-6);
225 }
226 }
227
228 #[test]
229 fn uniform_all_from_b_when_p_is_zero() {
230 let device: FlexDevice = Default::default();
231 let mut rng = StdRng::seed_from_u64(5);
232 let a = Tensor::<TestBackend, 2>::from_data(
233 TensorData::new(vec![7.0_f32, 7.0, 7.0, 7.0], [2, 2]),
234 &device,
235 );
236 let b = Tensor::<TestBackend, 2>::from_data(
237 TensorData::new(vec![-7.0_f32, -7.0, -7.0, -7.0], [2, 2]),
238 &device,
239 );
240 let c = uniform_crossover(a, b, Probability::new(0.0), &mut rng, &device);
241 let values = c
242 .into_data()
243 .into_vec::<f32>()
244 .expect("genome host-read of a tensor this test just built");
245 for v in values {
246 approx::assert_relative_eq!(v, -7.0, epsilon = 1e-6);
247 }
248 }
249
250 #[test]
251 fn nan_and_inf_rates_are_unconstructable() {
252 // Regression for #144 (ADR 0031): the crossover/mutation rate scalars
253 // used to be bare `f32`s, so a NaN silently degenerated `u.lower_elem(p)`
254 // to an all-false mask (a no-op / one-parent clone) and a NaN/Inf BLX-α
255 // poisoned the whole offspring tensor. The operators now take validated
256 // newtypes, so those inputs cannot even be constructed — the hazard is
257 // unrepresentable rather than silently mishandled.
258 assert!(Probability::try_new(f32::NAN).is_err());
259 assert!(Probability::try_new(1.5).is_err());
260 assert!(NonNegativeRate::try_new(f32::NAN).is_err());
261 assert!(NonNegativeRate::try_new(f32::INFINITY).is_err());
262 assert!(NonNegativeRate::try_new(-1.0).is_err());
263 }
264}