1use std::collections::{HashMap, HashSet};
58use std::marker::PhantomData;
59use std::sync::Arc;
60
61use burn::tensor::Tensor;
62use burn::tensor::backend::Backend;
63use rand::rngs::StdRng;
64use rand::{Rng, RngExt};
65use rand_distr::{Distribution as _, Normal};
66
67use crate::neuroevolution::innovation::InnovationRegistry;
68use crate::neuroevolution::phenotype::{BatchPhenotypeEvaluator, PhenotypeBuilder};
69use crate::neuroevolution::species::{self, Species, SpeciesId};
70use crate::neuroevolution::topology::{
71 ActivationFn, ConnectionGene, NodeGene, NodeId, NodeKind, TopologyGenome,
72};
73use crate::rng::{SeedPurpose, seed_stream};
74
75#[derive(Debug, Clone)]
81pub struct NeatParams {
82 pub pop_size: usize,
84 pub num_inputs: usize,
86 pub num_outputs: usize,
88 pub c1: f32,
90 pub c2: f32,
92 pub c3: f32,
94 pub compat_threshold: f32,
96 pub stagnation_limit: u64,
98 pub survival_threshold: f32,
100 pub elitism_min_species_size: usize,
103 pub p_mutate_weight: f32,
105 pub weight_perturb_std: f32,
107 pub p_weight_replace: f32,
109 pub p_add_connection: f32,
111 pub p_add_node: f32,
113 pub p_toggle_enable: f32,
115 pub p_disable_inherited: f32,
117 pub interspecies_mating_rate: f32,
119 pub mutate_only_fraction: f32,
121 pub weight_init_std: f32,
123}
124
125impl NeatParams {
126 #[must_use]
129 pub fn default_for(pop_size: usize, num_inputs: usize, num_outputs: usize) -> Self {
130 Self {
131 pop_size,
132 num_inputs,
133 num_outputs,
134 c1: 1.0,
135 c2: 1.0,
136 c3: 0.4,
137 compat_threshold: 3.0,
138 stagnation_limit: 15,
139 survival_threshold: 0.2,
140 elitism_min_species_size: 5,
141 p_mutate_weight: 0.8,
142 weight_perturb_std: 0.5,
143 p_weight_replace: 0.1,
144 p_add_connection: 0.05,
145 p_add_node: 0.03,
146 p_toggle_enable: 0.01,
147 p_disable_inherited: 0.75,
148 interspecies_mating_rate: 0.001,
149 mutate_only_fraction: 0.25,
150 weight_init_std: 1.0,
151 }
152 }
153}
154
155#[derive(Clone, Debug)]
161pub struct NeatState {
162 pub population: Vec<TopologyGenome>,
164 pub fitness: Vec<f32>,
166 pub species: Vec<Species>,
168 pub registry: Arc<InnovationRegistry>,
170 pub generation: u64,
172 pub next_species_id: SpeciesId,
174 pub best: Option<TopologyGenome>,
176 pub best_fitness: f32,
178}
179
180#[derive(Debug, Clone, Copy, Default)]
183pub struct NeatStrategy<B: Backend> {
184 _backend: PhantomData<fn() -> B>,
185}
186
187impl<B: Backend> NeatStrategy<B> {
188 #[must_use]
190 pub fn new() -> Self {
191 Self {
192 _backend: PhantomData,
193 }
194 }
195
196 #[must_use]
207 pub fn init(
208 &self,
209 params: &NeatParams,
210 rng: &mut dyn Rng,
211 device: &<B as burn::tensor::backend::BackendTypes>::Device,
212 ) -> NeatState {
213 let _ = device;
214 let registry = Arc::new(InnovationRegistry::new(
215 params.num_inputs + params.num_outputs,
216 params.num_inputs * params.num_outputs,
217 ));
218 let mut init_rng = seed_stream(rng.next_u64(), 0, SeedPurpose::Init);
219 let population: Vec<TopologyGenome> = (0..params.pop_size)
220 .map(|_| {
221 TopologyGenome::minimal(
222 params.num_inputs,
223 params.num_outputs,
224 ®istry,
225 &mut init_rng,
226 params.weight_init_std,
227 )
228 })
229 .collect();
230
231 NeatState {
232 population,
233 fitness: Vec::new(),
234 species: Vec::new(),
235 registry,
236 generation: 0,
237 next_species_id: SpeciesId::new(0),
238 best: None,
239 best_fitness: f32::NEG_INFINITY,
240 }
241 }
242
243 #[must_use]
256 pub fn ask(
257 &self,
258 params: &NeatParams,
259 state: &NeatState,
260 rng: &mut dyn Rng,
261 ) -> (Vec<TopologyGenome>, NeatState) {
262 if state.fitness.is_empty() {
264 return (state.population.clone(), state.clone());
265 }
266
267 let generation = state.generation;
268 let mut next = state.clone();
269 species::remove_stagnant(&mut next.species, generation, params.stagnation_limit);
270 let counts = species::allocate_offspring(&next.species, params.pop_size);
271
272 let mut rngs = ReproRngs {
273 selection: seed_stream(rng.next_u64(), generation, SeedPurpose::Selection),
274 crossover: seed_stream(rng.next_u64(), generation, SeedPurpose::Crossover),
275 mutation: seed_stream(rng.next_u64(), generation, SeedPurpose::Mutation),
276 misc: seed_stream(rng.next_u64(), generation, SeedPurpose::Other),
277 };
278
279 let mut offspring: Vec<TopologyGenome> = Vec::with_capacity(params.pop_size);
280 {
281 let ctx = ReproContext {
282 params,
283 population: &state.population,
284 fitness: &state.fitness,
285 species: &next.species,
286 registry: &state.registry,
287 };
288 for (si, &count) in counts.iter().enumerate() {
289 produce_offspring(&ctx, &mut rngs, si, count, &mut offspring);
290 }
291 }
292
293 while offspring.len() < params.pop_size {
296 let idx = rngs.selection.random_range(0..state.population.len());
297 let mut child = state.population[idx].clone();
298 apply_mutations(&mut child, params, &state.registry, &mut rngs.mutation);
299 offspring.push(child);
300 }
301 offspring.truncate(params.pop_size);
302
303 (offspring, next)
304 }
305
306 #[must_use]
331 pub fn tell(
332 &self,
333 params: &NeatParams,
334 population: Vec<TopologyGenome>,
335 fitness: Vec<f32>,
336 mut state: NeatState,
337 rng: &mut dyn Rng,
338 ) -> NeatState {
339 assert_eq!(
340 population.len(),
341 fitness.len(),
342 "population and fitness must have equal length"
343 );
344 let generation = state.generation;
345 let mut rep_rng = seed_stream(rng.next_u64(), generation, SeedPurpose::Representative);
346
347 state.population = population;
348 state.fitness = fitness
351 .into_iter()
352 .map(crate::fitness::sanitize_fitness)
353 .collect();
354 species::speciate(
355 &state.population,
356 &state.fitness,
357 &mut state.species,
358 params.c1,
359 params.c2,
360 params.c3,
361 params.compat_threshold,
362 &mut state.next_species_id,
363 generation,
364 &mut rep_rng,
365 );
366
367 if let Some((idx, best)) = state
369 .fitness
370 .iter()
371 .enumerate()
372 .map(|(i, &f)| (i, crate::fitness::sanitize_fitness(f)))
373 .max_by(|(_, a), (_, b)| a.total_cmp(b))
374 && best > state.best_fitness
375 {
376 state.best_fitness = best;
377 state.best = Some(state.population[idx].clone());
378 }
379
380 state.generation += 1;
381 state
382 }
383
384 #[must_use]
388 pub fn best<'s>(&self, state: &'s NeatState) -> Option<(&'s TopologyGenome, f32)> {
389 state.best.as_ref().map(|g| (g, state.best_fitness))
390 }
391}
392
393pub trait GraphFitnessFn<B: Backend>: Send + Sync {
416 fn evaluate(
422 &self,
423 population: &[TopologyGenome],
424 builder: &dyn PhenotypeBuilder<B>,
425 device: &<B as burn::tensor::backend::BackendTypes>::Device,
426 ) -> Vec<f32>;
427}
428
429pub struct BatchGraphFitness<B: Backend, E> {
443 evaluator: E,
444 obs: Tensor<B, 2>,
445 #[allow(clippy::type_complexity)]
446 reducer: Box<dyn Fn(&[f32]) -> f32 + Send + Sync>,
447}
448
449impl<B: Backend, E> BatchGraphFitness<B, E> {
450 pub fn new(
454 evaluator: E,
455 obs: Tensor<B, 2>,
456 reducer: impl Fn(&[f32]) -> f32 + Send + Sync + 'static,
457 ) -> Self {
458 Self {
459 evaluator,
460 obs,
461 reducer: Box::new(reducer),
462 }
463 }
464}
465
466impl<B: Backend, E> std::fmt::Debug for BatchGraphFitness<B, E> {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 f.debug_struct("BatchGraphFitness")
469 .field("obs", &self.obs)
470 .finish_non_exhaustive()
471 }
472}
473
474impl<B: Backend, E: BatchPhenotypeEvaluator<B>> GraphFitnessFn<B> for BatchGraphFitness<B, E> {
475 fn evaluate(
476 &self,
477 population: &[TopologyGenome],
478 _builder: &dyn PhenotypeBuilder<B>,
479 device: &<B as burn::tensor::backend::BackendTypes>::Device,
480 ) -> Vec<f32> {
481 if population.is_empty() {
482 return Vec::new();
483 }
484 let out = self
485 .evaluator
486 .evaluate_population(population, self.obs.clone(), device);
487 let [pop, batch, action] = out.dims();
488 let flat = out
489 .into_data()
490 .into_vec::<f32>()
491 .expect("output tensor must be readable as f32");
492 let slab = batch * action;
493 (0..pop)
494 .map(|p| (self.reducer)(&flat[p * slab..(p + 1) * slab]))
495 .collect()
496 }
497}
498
499struct ReproContext<'a> {
505 params: &'a NeatParams,
506 population: &'a [TopologyGenome],
507 fitness: &'a [f32],
508 species: &'a [Species],
509 registry: &'a InnovationRegistry,
510}
511
512struct ReproRngs {
514 selection: StdRng,
515 crossover: StdRng,
516 mutation: StdRng,
517 misc: StdRng,
518}
519
520fn produce_offspring(
522 ctx: &ReproContext<'_>,
523 rngs: &mut ReproRngs,
524 species_idx: usize,
525 count: usize,
526 out: &mut Vec<TopologyGenome>,
527) {
528 if count == 0 {
529 return;
530 }
531 let sp = &ctx.species[species_idx];
532 let mut members = sp.members.clone();
533 let sane: Vec<f32> = ctx
535 .fitness
536 .iter()
537 .map(|&f| crate::fitness::sanitize_fitness(f))
538 .collect();
539 members.sort_by(|&a, &b| sane[b].total_cmp(&sane[a]));
540
541 let mut produced = 0usize;
542 if members.len() > ctx.params.elitism_min_species_size {
544 out.push(ctx.population[members[0]].clone());
545 produced += 1;
546 }
547
548 let n_eligible = eligible_count(members.len(), ctx.params.survival_threshold);
549 let eligible = &members[..n_eligible];
550 while produced < count {
551 let mut child = make_child(ctx, rngs, eligible, species_idx);
552 apply_mutations(&mut child, ctx.params, ctx.registry, &mut rngs.mutation);
553 out.push(child);
554 produced += 1;
555 }
556}
557
558fn eligible_count(size: usize, survival_threshold: f32) -> usize {
560 #[allow(
562 clippy::cast_precision_loss,
563 clippy::cast_possible_truncation,
564 clippy::cast_sign_loss
565 )]
566 let raw = (size as f32 * survival_threshold).ceil() as usize;
567 raw.max(1).min(size)
568}
569
570fn make_child(
572 ctx: &ReproContext<'_>,
573 rngs: &mut ReproRngs,
574 eligible: &[usize],
575 species_idx: usize,
576) -> TopologyGenome {
577 if eligible.len() == 1 || rngs.misc.random::<f32>() < ctx.params.mutate_only_fraction {
578 let parent = eligible[rngs.selection.random_range(0..eligible.len())];
579 return ctx.population[parent].clone();
580 }
581 let pa = eligible[rngs.selection.random_range(0..eligible.len())];
582 let pb = if ctx.species.len() > 1
583 && rngs.misc.random::<f32>() < ctx.params.interspecies_mating_rate
584 {
585 let other = pick_other_species(ctx.species.len(), species_idx, &mut rngs.selection);
586 let other_members = &ctx.species[other].members;
587 other_members[rngs.selection.random_range(0..other_members.len())]
588 } else {
589 eligible[rngs.selection.random_range(0..eligible.len())]
590 };
591 crossover(
592 &ctx.population[pa],
593 ctx.fitness[pa],
594 &ctx.population[pb],
595 ctx.fitness[pb],
596 ctx.params,
597 &mut rngs.crossover,
598 )
599}
600
601fn pick_other_species(len: usize, exclude: usize, rng: &mut StdRng) -> usize {
606 let candidate = rng.random_range(0..len - 1);
607 if candidate >= exclude {
608 candidate + 1
609 } else {
610 candidate
611 }
612}
613
614fn apply_mutations(
616 genome: &mut TopologyGenome,
617 params: &NeatParams,
618 registry: &InnovationRegistry,
619 rng: &mut StdRng,
620) {
621 if rng.random::<f32>() < params.p_mutate_weight {
622 mutate_weights(genome, params, rng);
623 }
624 if rng.random::<f32>() < params.p_add_connection {
625 mutate_add_connection(genome, params, registry, rng);
626 }
627 if rng.random::<f32>() < params.p_add_node {
628 mutate_add_node(genome, registry, rng);
629 }
630 if rng.random::<f32>() < params.p_toggle_enable {
631 mutate_toggle_enable(genome, rng);
632 }
633}
634
635const ADD_CONNECTION_ATTEMPTS: usize = 20;
642
643fn mutate_weights(genome: &mut TopologyGenome, params: &NeatParams, rng: &mut StdRng) {
652 let perturb = Normal::new(0.0_f32, params.weight_perturb_std).unwrap_or_else(|err| {
653 panic!(
654 "weight_perturb_std must be finite, got {}: {err}",
655 params.weight_perturb_std
656 )
657 });
658 let replace = Normal::new(0.0_f32, params.weight_init_std).unwrap_or_else(|err| {
659 panic!(
660 "weight_init_std must be finite, got {}: {err}",
661 params.weight_init_std
662 )
663 });
664
665 for conn in &mut genome.connections {
666 if rng.random::<f32>() < params.p_weight_replace {
667 conn.weight = replace.sample(rng);
668 } else {
669 conn.weight += perturb.sample(rng);
670 }
671 }
672 for node in &mut genome.nodes {
673 if matches!(node.kind, NodeKind::Input) {
674 continue;
675 }
676 if rng.random::<f32>() < params.p_weight_replace {
677 node.bias = replace.sample(rng);
678 } else {
679 node.bias += perturb.sample(rng);
680 }
681 }
682}
683
684fn mutate_add_connection(
692 genome: &mut TopologyGenome,
693 params: &NeatParams,
694 registry: &InnovationRegistry,
695 rng: &mut StdRng,
696) {
697 let init = Normal::new(0.0_f32, params.weight_init_std).unwrap_or_else(|err| {
698 panic!(
699 "weight_init_std must be finite, got {}: {err}",
700 params.weight_init_std
701 )
702 });
703
704 let sources: Vec<NodeId> = genome
706 .nodes
707 .iter()
708 .filter(|n| !matches!(n.kind, NodeKind::Output))
709 .map(|n| n.id)
710 .collect();
711 let targets: Vec<NodeId> = genome
712 .nodes
713 .iter()
714 .filter(|n| !matches!(n.kind, NodeKind::Input))
715 .map(|n| n.id)
716 .collect();
717 if sources.is_empty() || targets.is_empty() {
718 return;
719 }
720
721 for _ in 0..ADD_CONNECTION_ATTEMPTS {
722 let source = sources[rng.random_range(0..sources.len())];
723 let target = targets[rng.random_range(0..targets.len())];
724 if source == target || genome.is_connected(source, target) {
725 continue;
726 }
727 if genome.would_create_cycle(source, target) {
728 continue;
729 }
730 let innovation = registry.register_connection(source, target);
731 genome.insert_connection_sorted(ConnectionGene {
732 innovation,
733 source,
734 target,
735 weight: init.sample(rng),
736 enabled: true,
737 });
738 return;
739 }
740}
741
742fn mutate_add_node(genome: &mut TopologyGenome, registry: &InnovationRegistry, rng: &mut StdRng) {
748 let enabled: Vec<usize> = genome
749 .connections
750 .iter()
751 .enumerate()
752 .filter(|(_, c)| c.enabled)
753 .map(|(i, _)| i)
754 .collect();
755 if enabled.is_empty() {
756 return;
757 }
758 let idx = enabled[rng.random_range(0..enabled.len())];
759 let split_innovation = genome.connections[idx].innovation;
760 let split = registry.register_node_split(split_innovation);
761
762 if genome.node(split.new_node).is_some() {
765 return;
766 }
767
768 let (source, target, old_weight) = {
769 let conn = &mut genome.connections[idx];
770 conn.enabled = false;
771 (conn.source, conn.target, conn.weight)
772 };
773
774 genome.nodes.push(NodeGene {
775 id: split.new_node,
776 kind: NodeKind::Hidden,
777 activation: ActivationFn::Sigmoid,
778 bias: 0.0,
779 });
780 genome.insert_connection_sorted(ConnectionGene {
781 innovation: split.in_innov,
782 source,
783 target: split.new_node,
784 weight: 1.0,
785 enabled: true,
786 });
787 genome.insert_connection_sorted(ConnectionGene {
788 innovation: split.out_innov,
789 source: split.new_node,
790 target,
791 weight: old_weight,
792 enabled: true,
793 });
794}
795
796fn mutate_toggle_enable(genome: &mut TopologyGenome, rng: &mut StdRng) {
799 if genome.connections.is_empty() {
800 return;
801 }
802 let idx = rng.random_range(0..genome.connections.len());
803 genome.connections[idx].enabled = !genome.connections[idx].enabled;
804}
805
806fn crossover(
820 p1: &TopologyGenome,
821 f1: f32,
822 p2: &TopologyGenome,
823 f2: f32,
824 params: &NeatParams,
825 rng: &mut StdRng,
826) -> TopologyGenome {
827 let equal = (f1 - f2).abs() <= f32::EPSILON * f1.abs().max(f2.abs()).max(1.0);
830 let p1_fitter = f1 > f2;
831
832 let c1 = &p1.connections;
833 let c2 = &p2.connections;
834 let mut candidates: Vec<ConnectionGene> = Vec::with_capacity(c1.len().max(c2.len()));
835 let (mut i, mut j) = (0usize, 0usize);
836 while i < c1.len() && j < c2.len() {
837 match c1[i].innovation.cmp(&c2[j].innovation) {
838 std::cmp::Ordering::Equal => {
839 let mut gene = if rng.random::<bool>() {
840 c1[i].clone()
841 } else {
842 c2[j].clone()
843 };
844 let disabled_in_either = !c1[i].enabled || !c2[j].enabled;
845 let disable =
846 disabled_in_either && rng.random::<f32>() < params.p_disable_inherited;
847 gene.enabled = !disable;
848 candidates.push(gene);
849 i += 1;
850 j += 1;
851 }
852 std::cmp::Ordering::Less => {
853 if equal || p1_fitter {
854 candidates.push(c1[i].clone());
855 }
856 i += 1;
857 }
858 std::cmp::Ordering::Greater => {
859 if equal || !p1_fitter {
860 candidates.push(c2[j].clone());
861 }
862 j += 1;
863 }
864 }
865 }
866 while i < c1.len() {
867 if equal || p1_fitter {
868 candidates.push(c1[i].clone());
869 }
870 i += 1;
871 }
872 while j < c2.len() {
873 if equal || !p1_fitter {
874 candidates.push(c2[j].clone());
875 }
876 j += 1;
877 }
878
879 let mut probe = TopologyGenome {
882 nodes: Vec::new(),
883 connections: Vec::new(),
884 };
885 for gene in candidates {
886 if probe.would_create_cycle(gene.source, gene.target) {
887 continue;
888 }
889 probe.connections.push(gene);
890 }
891 let child_conns = probe.connections;
892
893 let (primary, secondary) = if p1_fitter || equal {
895 (p1, p2)
896 } else {
897 (p2, p1)
898 };
899 let mut node_map: HashMap<NodeId, NodeGene> = HashMap::new();
900 for node in &primary.nodes {
901 if matches!(
902 node.kind,
903 NodeKind::Input | NodeKind::Output | NodeKind::Bias
904 ) {
905 node_map.insert(node.id, node.clone());
906 }
907 }
908 let mut referenced: HashSet<NodeId> = HashSet::new();
909 for conn in &child_conns {
910 referenced.insert(conn.source);
911 referenced.insert(conn.target);
912 }
913 for id in referenced {
914 if node_map.contains_key(&id) {
915 continue;
916 }
917 if let Some(node) = primary.node(id).or_else(|| secondary.node(id)) {
918 node_map.insert(id, node.clone());
919 }
920 }
921 let mut nodes: Vec<NodeGene> = node_map.into_values().collect();
922 nodes.sort_by_key(|n| n.id);
923
924 TopologyGenome {
925 nodes,
926 connections: child_conns,
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use crate::neuroevolution::topology::InnovationId;
934 use rand::SeedableRng;
935
936 fn node(id: u64, kind: NodeKind) -> NodeGene {
937 NodeGene {
938 id: NodeId::new(id),
939 kind,
940 activation: ActivationFn::Sigmoid,
941 bias: 0.0,
942 }
943 }
944
945 fn conn(innovation: u64, source: u64, target: u64) -> ConnectionGene {
946 ConnectionGene {
947 innovation: InnovationId::new(innovation),
948 source: NodeId::new(source),
949 target: NodeId::new(target),
950 weight: 0.5,
951 enabled: true,
952 }
953 }
954
955 #[test]
958 fn test_innovation_numbering_is_deterministic() {
959 fn replay() -> (Vec<u64>, Vec<NodeId>) {
960 let registry = InnovationRegistry::new(3, 2);
961 let mut rng = StdRng::seed_from_u64(99);
962 let params = NeatParams::default_for(10, 2, 1);
963 let mut g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
964 mutate_add_node(&mut g, ®istry, &mut rng);
965 mutate_add_connection(&mut g, ¶ms, ®istry, &mut rng);
966 mutate_add_node(&mut g, ®istry, &mut rng);
967 mutate_weights(&mut g, ¶ms, &mut rng);
968 mutate_add_connection(&mut g, ¶ms, ®istry, &mut rng);
969 let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
970 let mut nodes: Vec<NodeId> = g.nodes.iter().map(|n| n.id).collect();
971 nodes.sort_unstable();
972 (innovs, nodes)
973 }
974 let (i1, n1) = replay();
975 let (i2, n2) = replay();
976 assert_eq!(i1, i2, "innovation id sequence must be reproducible");
977 assert_eq!(n1, n2, "node id sequence must be reproducible");
978 }
979
980 #[test]
983 fn test_crossover_inherits_disjoint_excess_from_fitter() {
984 let nodes_p1 = vec![
986 node(0, NodeKind::Input),
987 node(1, NodeKind::Input),
988 node(2, NodeKind::Output),
989 node(3, NodeKind::Hidden),
990 ];
991 let p1 = TopologyGenome::new(
993 nodes_p1,
994 vec![conn(0, 0, 2), conn(1, 1, 2), conn(2, 0, 3), conn(3, 3, 2)],
995 );
996 let nodes_p2 = vec![
998 node(0, NodeKind::Input),
999 node(1, NodeKind::Input),
1000 node(2, NodeKind::Output),
1001 node(4, NodeKind::Hidden),
1002 ];
1003 let p2 = TopologyGenome::new(nodes_p2, vec![conn(0, 0, 2), conn(1, 1, 2), conn(4, 1, 4)]);
1004
1005 let params = NeatParams::default_for(10, 2, 1);
1006 let mut rng = StdRng::seed_from_u64(3);
1007 let child = crossover(&p1, 2.0, &p2, 1.0, ¶ms, &mut rng);
1008
1009 let innovs: Vec<u64> = child
1010 .connections
1011 .iter()
1012 .map(|c| c.innovation.get())
1013 .collect();
1014 assert_eq!(
1015 innovs,
1016 vec![0, 1, 2, 3],
1017 "matching (0,1) + fitter disjoint/excess (2,3); less-fit excess (4) dropped"
1018 );
1019 assert!(
1020 child.is_innovation_sorted(),
1021 "child stays innovation-sorted"
1022 );
1023 assert!(
1024 child.node(NodeId::new(4)).is_none(),
1025 "node only reachable via dropped gene is excluded"
1026 );
1027 assert!(
1028 child.node(NodeId::new(3)).is_some(),
1029 "hidden node from inherited gene is kept"
1030 );
1031 }
1032
1033 #[test]
1035 fn test_crossover_equal_fitness_takes_from_both() {
1036 let nodes_p1 = vec![
1037 node(0, NodeKind::Input),
1038 node(1, NodeKind::Input),
1039 node(2, NodeKind::Output),
1040 ];
1041 let p1 = TopologyGenome::new(nodes_p1.clone(), vec![conn(0, 0, 2), conn(2, 1, 2)]);
1042 let p2 = TopologyGenome::new(nodes_p1, vec![conn(0, 0, 2), conn(3, 1, 2)]);
1043 let params = NeatParams::default_for(10, 2, 1);
1044 let mut rng = StdRng::seed_from_u64(5);
1045 let child = crossover(&p1, 1.0, &p2, 1.0, ¶ms, &mut rng);
1046 let innovs: Vec<u64> = child
1047 .connections
1048 .iter()
1049 .map(|c| c.innovation.get())
1050 .collect();
1051 assert_eq!(
1052 innovs,
1053 vec![0, 2, 3],
1054 "equal fitness keeps disjoint/excess from both"
1055 );
1056 }
1057
1058 #[test]
1061 fn test_add_connection_rejects_cycles() {
1062 let nodes = vec![
1064 node(0, NodeKind::Input),
1065 node(2, NodeKind::Hidden),
1066 node(3, NodeKind::Hidden),
1067 node(1, NodeKind::Output),
1068 ];
1069 let conns = vec![
1070 conn(0, 0, 2),
1071 conn(1, 0, 3),
1072 conn(2, 0, 1),
1073 conn(3, 2, 3),
1074 conn(4, 2, 1),
1075 conn(5, 3, 1),
1076 ];
1077 let mut g = TopologyGenome::new(nodes, conns);
1078 let before = g.connections.len();
1079 let registry = InnovationRegistry::new(4, 6);
1080 let params = NeatParams::default_for(10, 1, 1);
1081 let mut rng = StdRng::seed_from_u64(7);
1082 for _ in 0..50 {
1085 mutate_add_connection(&mut g, ¶ms, ®istry, &mut rng);
1086 }
1087 assert_eq!(g.connections.len(), before, "no cyclic edge is ever added");
1088 assert!(
1089 !g.is_connected(NodeId::new(3), NodeId::new(2)),
1090 "the back-edge 3->2 is rejected"
1091 );
1092 }
1093
1094 #[test]
1098 fn test_add_node_splits_connection() {
1099 let registry = InnovationRegistry::new(3, 2);
1100 let mut rng = StdRng::seed_from_u64(1);
1101 let mut g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
1102 let nodes_before = g.nodes.len();
1103 let conns_before = g.connections.len();
1104
1105 mutate_add_node(&mut g, ®istry, &mut rng);
1106
1107 assert_eq!(g.nodes.len(), nodes_before + 1, "one hidden node inserted");
1108 assert_eq!(
1109 g.connections.len(),
1110 conns_before + 2,
1111 "split adds two connections"
1112 );
1113 assert_eq!(
1114 g.connections.iter().filter(|c| !c.enabled).count(),
1115 1,
1116 "the split connection is disabled"
1117 );
1118 let hidden: Vec<&NodeGene> = g
1119 .nodes
1120 .iter()
1121 .filter(|n| matches!(n.kind, NodeKind::Hidden))
1122 .collect();
1123 assert_eq!(hidden.len(), 1);
1124 assert_eq!(
1125 hidden[0].id.get(),
1126 3,
1127 "new hidden node id follows the seed nodes"
1128 );
1129 assert!(
1130 g.is_innovation_sorted(),
1131 "connections stay innovation-sorted"
1132 );
1133 assert!(
1134 g.connections
1135 .iter()
1136 .any(|c| c.target == NodeId::new(3) && c.enabled && (c.weight - 1.0).abs() < 1e-6),
1137 "the source -> new edge has the function-preserving weight 1.0"
1138 );
1139 }
1140
1141 #[test]
1143 fn test_toggle_enable_flips_one_connection() {
1144 let registry = InnovationRegistry::new(3, 2);
1145 let mut rng = StdRng::seed_from_u64(2);
1146 let mut g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
1147 let before: Vec<bool> = g.connections.iter().map(|c| c.enabled).collect();
1148 mutate_toggle_enable(&mut g, &mut rng);
1149 let after: Vec<bool> = g.connections.iter().map(|c| c.enabled).collect();
1150 let flipped = before.iter().zip(&after).filter(|(a, b)| a != b).count();
1151 assert_eq!(flipped, 1, "exactly one connection's enabled bit flips");
1152 }
1153
1154 #[test]
1158 fn test_add_node_guard_prevents_duplicate_node() {
1159 let registry = InnovationRegistry::new(3, 2);
1160 let split = registry.register_node_split(InnovationId::new(0)); let nodes = vec![
1164 node(0, NodeKind::Input),
1165 node(1, NodeKind::Input),
1166 node(2, NodeKind::Output),
1167 node(3, NodeKind::Hidden),
1168 ];
1169 let conns = vec![
1170 ConnectionGene {
1171 innovation: InnovationId::new(0),
1172 source: NodeId::new(0),
1173 target: NodeId::new(2),
1174 weight: 0.5,
1175 enabled: true,
1176 },
1177 ConnectionGene {
1178 innovation: split.in_innov,
1179 source: NodeId::new(0),
1180 target: NodeId::new(3),
1181 weight: 1.0,
1182 enabled: false,
1183 },
1184 ConnectionGene {
1185 innovation: split.out_innov,
1186 source: NodeId::new(3),
1187 target: NodeId::new(2),
1188 weight: 0.5,
1189 enabled: false,
1190 },
1191 ];
1192 let mut g = TopologyGenome::new(nodes, conns);
1193 let nodes_before = g.nodes.len();
1194 let conns_before = g.connections.len();
1195 let mut rng = StdRng::seed_from_u64(4);
1196
1197 mutate_add_node(&mut g, ®istry, &mut rng);
1198
1199 assert_eq!(
1200 g.nodes.len(),
1201 nodes_before,
1202 "guard prevents a duplicate split node"
1203 );
1204 assert_eq!(
1205 g.connections.len(),
1206 conns_before,
1207 "no duplicate split edges added"
1208 );
1209 }
1210
1211 #[test]
1214 fn test_ask_tell_smoke_preserves_pop_size_and_tracks_best() {
1215 use burn::backend::Flex;
1216 type TestBackend = Flex;
1217
1218 struct ConnCountFitness;
1219 impl GraphFitnessFn<TestBackend> for ConnCountFitness {
1220 fn evaluate(
1221 &self,
1222 population: &[TopologyGenome],
1223 _builder: &dyn PhenotypeBuilder<TestBackend>,
1224 _device: &<TestBackend as burn::tensor::backend::BackendTypes>::Device,
1225 ) -> Vec<f32> {
1226 #[allow(clippy::cast_precision_loss)]
1228 population
1229 .iter()
1230 .map(|g| g.connections.iter().filter(|c| c.enabled).count() as f32)
1231 .collect()
1232 }
1233 }
1234
1235 let device = Default::default();
1236 let mut params = NeatParams::default_for(24, 2, 1);
1237 params.p_add_node = 0.3;
1238 params.p_add_connection = 0.5;
1239
1240 let strat = NeatStrategy::<TestBackend>::new();
1241 let mut rng = StdRng::seed_from_u64(11);
1242 let mut state = strat.init(¶ms, &mut rng, &device);
1243 let builder = crate::neuroevolution::phenotype::InterpretedBuilder;
1244 let fitness_fn = ConnCountFitness;
1245
1246 for _ in 0..8 {
1247 let (population, next) = strat.ask(¶ms, &state, &mut rng);
1248 assert_eq!(
1249 population.len(),
1250 params.pop_size,
1251 "ask returns pop_size genomes"
1252 );
1253 let fitness = fitness_fn.evaluate(&population, &builder, &device);
1254 state = strat.tell(¶ms, population, fitness, next, &mut rng);
1255 assert!(
1256 !state.species.is_empty(),
1257 "speciation always yields >= 1 species"
1258 );
1259 }
1260 assert_eq!(state.generation, 8);
1261 let (_, best) = strat.best(&state).expect("best exists after tell");
1262 assert!(best >= 2.0, "best rewards enabled connections; got {best}");
1263 }
1264
1265 #[test]
1270 fn test_tell_sanitizes_nan_and_inf_fitness() {
1271 use burn::backend::Flex;
1272 type TestBackend = Flex;
1273
1274 let device = Default::default();
1275 let params = NeatParams::default_for(4, 2, 1);
1276 let strat = NeatStrategy::<TestBackend>::new();
1277 let mut rng = StdRng::seed_from_u64(7);
1278 let state = strat.init(¶ms, &mut rng, &device);
1279
1280 let (population, next) = strat.ask(¶ms, &state, &mut rng);
1281 let n = population.len();
1282 let mut fitness: Vec<f32> = vec![1.0_f32; n];
1285 fitness[0] = f32::NAN;
1286 fitness[1] = f32::INFINITY;
1287 let state = strat.tell(¶ms, population, fitness, next, &mut rng);
1288
1289 assert!(
1291 state.fitness.iter().all(|f| !f.is_nan()),
1292 "no stored fitness is NaN after the tell chokepoint"
1293 );
1294 assert!(
1295 state.fitness[0].is_infinite() && state.fitness[0].is_sign_negative(),
1296 "the NaN member is sanitized to the maximise-space worst sentinel (−∞)"
1297 );
1298 approx::assert_relative_eq!(state.fitness[1], f32::MAX);
1299
1300 let (_, best) = strat.best(&state).expect("best exists after tell");
1303 assert!(
1304 best.is_finite(),
1305 "champion fitness is finite (never NaN/±∞); got {best}"
1306 );
1307 approx::assert_relative_eq!(best, f32::MAX);
1308
1309 for s in &state.species {
1311 assert!(!s.best_fitness.is_nan(), "species best_fitness is not NaN");
1312 assert!(
1313 !s.adjusted_fitness_sum.is_nan(),
1314 "a NaN member never poisons adjusted_fitness_sum"
1315 );
1316 }
1317 }
1318}