Skip to main content

sc_neurocore_engine/
evo.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Rust Evolutionary Substrate Acceleration
8
9//! Accelerates evolutionary substrate hot paths:
10//! - Batch fitness evaluation (population-parallel)
11//! - Genome mutation (weight perturbation + topology)
12//! - Crossover (aligned gene matching)
13//! - Novelty/diversity metrics
14//! - Pareto front maintenance
15
16use rayon::prelude::*;
17
18// ── PRNG ─────────────────────────────────────────────────────────────
19
20struct Xoshiro256pp([u64; 4]);
21
22impl Xoshiro256pp {
23    fn new(seed: u64) -> Self {
24        let mut s = [0u64; 4];
25        let mut z = seed.wrapping_add(0x9E3779B97F4A7C15);
26        for slot in s.iter_mut() {
27            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
28            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
29            *slot = z ^ (z >> 31);
30        }
31        Self(s)
32    }
33
34    fn next_u64(&mut self) -> u64 {
35        let result = (self.0[0].wrapping_add(self.0[3]))
36            .rotate_left(23)
37            .wrapping_add(self.0[0]);
38        let t = self.0[1] << 17;
39        self.0[2] ^= self.0[0];
40        self.0[3] ^= self.0[1];
41        self.0[1] ^= self.0[2];
42        self.0[0] ^= self.0[3];
43        self.0[2] ^= t;
44        self.0[3] = self.0[3].rotate_left(45);
45        result
46    }
47
48    fn next_f64(&mut self) -> f64 {
49        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
50    }
51
52    fn next_gaussian(&mut self) -> f64 {
53        // Box-Muller transform
54        let u1 = self.next_f64().max(1e-15);
55        let u2 = self.next_f64();
56        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
57    }
58}
59
60// ── Batch weight mutation ────────────────────────────────────────────
61
62/// Mutate weights in-place with Gaussian noise (parallelized for large populations).
63///
64/// Each genome is a flat f64 slice. mutation_rate is probability per weight.
65pub fn batch_mutate_weights(
66    genomes: &mut [Vec<f64>],
67    mutation_rate: f64,
68    mutation_scale: f64,
69    seed: u64,
70) {
71    genomes.par_iter_mut().enumerate().for_each(|(i, genome)| {
72        let mut rng = Xoshiro256pp::new(seed.wrapping_add(i as u64));
73        for w in genome.iter_mut() {
74            if rng.next_f64() < mutation_rate {
75                *w += rng.next_gaussian() * mutation_scale;
76                *w = w.clamp(-10.0, 10.0);
77            }
78        }
79    });
80}
81
82// ── Batch fitness evaluation ─────────────────────────────────────────
83
84/// Evaluate fitness for a population of weight vectors against a target.
85///
86/// Fitness = -MSE(genome_weights, target_outputs) for each genome
87/// applied to a simple linear evaluation: output = sum(w_i * input_i).
88pub fn batch_evaluate_fitness(genomes: &[Vec<f64>], inputs: &[f64], target: f64) -> Vec<f64> {
89    genomes
90        .par_iter()
91        .map(|genome| {
92            let output: f64 = genome.iter().zip(inputs.iter()).map(|(w, x)| w * x).sum();
93            let error = output - target;
94            -(error * error) // negative MSE as fitness
95        })
96        .collect()
97}
98
99// ── Crossover ────────────────────────────────────────────────────────
100
101/// Uniform crossover between two parent genomes (parallelized batch).
102pub fn batch_crossover(parents_a: &[Vec<f64>], parents_b: &[Vec<f64>], seed: u64) -> Vec<Vec<f64>> {
103    parents_a
104        .par_iter()
105        .zip(parents_b.par_iter())
106        .enumerate()
107        .map(|(i, (a, b))| {
108            let mut rng = Xoshiro256pp::new(seed.wrapping_add(i as u64));
109            let len = a.len().min(b.len());
110            let mut child = Vec::with_capacity(len);
111            for j in 0..len {
112                if rng.next_f64() < 0.5 {
113                    child.push(a[j]);
114                } else {
115                    child.push(b[j]);
116                }
117            }
118            child
119        })
120        .collect()
121}
122
123// ── Diversity / Novelty ──────────────────────────────────────────────
124
125/// Compute pairwise diversity (mean L2 distance) for a population.
126pub fn population_diversity(genomes: &[Vec<f64>]) -> f64 {
127    let n = genomes.len();
128    if n < 2 {
129        return 0.0;
130    }
131
132    let total: f64 = (0..n)
133        .into_par_iter()
134        .flat_map(|i| ((i + 1)..n).into_par_iter().map(move |j| (i, j)))
135        .map(|(i, j)| {
136            let len = genomes[i].len().min(genomes[j].len());
137            let dist_sq: f64 = (0..len)
138                .map(|k| {
139                    let d = genomes[i][k] - genomes[j][k];
140                    d * d
141                })
142                .sum();
143            dist_sq.sqrt()
144        })
145        .sum();
146
147    let pairs = (n * (n - 1)) / 2;
148    total / pairs as f64
149}
150
151/// Compute novelty score for each genome against an archive.
152pub fn novelty_scores(genomes: &[Vec<f64>], archive: &[Vec<f64>], k_nearest: usize) -> Vec<f64> {
153    let combined: Vec<&Vec<f64>> = archive.iter().chain(genomes.iter()).collect();
154
155    genomes
156        .par_iter()
157        .map(|genome| {
158            let mut distances: Vec<f64> = combined
159                .iter()
160                .filter(|&&other| !std::ptr::eq(other, genome))
161                .map(|other| {
162                    let len = genome.len().min(other.len());
163                    let d: f64 = (0..len)
164                        .map(|k| {
165                            let diff = genome[k] - other[k];
166                            diff * diff
167                        })
168                        .sum();
169                    d.sqrt()
170                })
171                .collect();
172            distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
173            let k = k_nearest.min(distances.len());
174            if k == 0 {
175                return 0.0;
176            }
177            distances[..k].iter().sum::<f64>() / k as f64
178        })
179        .collect()
180}
181
182// ── Tournament selection ─────────────────────────────────────────────
183
184/// Tournament selection: select n winners from population by fitness.
185pub fn tournament_select(
186    fitness: &[f64],
187    n_select: usize,
188    tournament_size: usize,
189    seed: u64,
190) -> Vec<usize> {
191    let pop_size = fitness.len();
192    let mut rng = Xoshiro256pp::new(seed);
193    let mut selected = Vec::with_capacity(n_select);
194
195    for _ in 0..n_select {
196        let mut best_idx = (rng.next_u64() % pop_size as u64) as usize;
197        let mut best_fit = fitness[best_idx];
198        for _ in 1..tournament_size {
199            let idx = (rng.next_u64() % pop_size as u64) as usize;
200            if fitness[idx] > best_fit {
201                best_idx = idx;
202                best_fit = fitness[idx];
203            }
204        }
205        selected.push(best_idx);
206    }
207
208    selected
209}
210
211// ── Tests ────────────────────────────────────────────────────────────
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_batch_mutate() {
219        let mut pop = vec![vec![0.0; 10]; 50];
220        batch_mutate_weights(&mut pop, 0.5, 0.1, 42);
221        let changed = pop.iter().any(|g| g.iter().any(|&w| w != 0.0));
222        assert!(changed);
223    }
224
225    #[test]
226    fn test_mutate_bounded() {
227        let mut pop = vec![vec![100.0; 5]; 10];
228        batch_mutate_weights(&mut pop, 1.0, 1.0, 42);
229        for g in &pop {
230            for &w in g {
231                assert!((-10.0..=10.0).contains(&w));
232            }
233        }
234    }
235
236    #[test]
237    fn test_batch_fitness() {
238        let genomes = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
239        let inputs = vec![1.0, 0.0];
240        let fitness = batch_evaluate_fitness(&genomes, &inputs, 1.0);
241        assert!((fitness[0] - 0.0).abs() < 1e-10); // perfect
242        assert!(fitness[1] < 0.0); // error
243    }
244
245    #[test]
246    fn test_crossover_length() {
247        let a = vec![vec![1.0; 10]; 20];
248        let b = vec![vec![2.0; 10]; 20];
249        let children = batch_crossover(&a, &b, 42);
250        assert_eq!(children.len(), 20);
251        assert_eq!(children[0].len(), 10);
252    }
253
254    #[test]
255    fn test_crossover_mix() {
256        let a = vec![vec![0.0; 100]];
257        let b = vec![vec![1.0; 100]];
258        let children = batch_crossover(&a, &b, 42);
259        let has_zero = children[0].contains(&0.0);
260        let has_one = children[0].contains(&1.0);
261        assert!(has_zero && has_one);
262    }
263
264    #[test]
265    fn test_diversity_identical() {
266        let pop = vec![vec![1.0; 5]; 10];
267        assert!((population_diversity(&pop) - 0.0).abs() < 1e-10);
268    }
269
270    #[test]
271    fn test_diversity_distinct() {
272        let pop = vec![vec![0.0; 5], vec![1.0; 5]];
273        assert!(population_diversity(&pop) > 0.0);
274    }
275
276    #[test]
277    fn test_novelty_scores() {
278        let genomes = vec![vec![0.0; 5], vec![10.0; 5]];
279        let archive = vec![vec![0.0; 5]];
280        let scores = novelty_scores(&genomes, &archive, 1);
281        assert!(scores[1] > scores[0]); // second is more novel
282    }
283
284    #[test]
285    fn test_tournament_select() {
286        let fitness = vec![0.1, 0.9, 0.5, 0.3];
287        let selected = tournament_select(&fitness, 10, 3, 42);
288        assert_eq!(selected.len(), 10);
289        // Best-fitness individual should appear frequently
290        let count_best = selected.iter().filter(|&&i| i == 1).count();
291        assert!(count_best > 0);
292    }
293
294    #[test]
295    fn test_single_genome_diversity() {
296        let pop = vec![vec![1.0; 5]];
297        assert!((population_diversity(&pop) - 0.0).abs() < 1e-10);
298    }
299}