use std::collections::HashSet;
use rand::Rng;
use rand_distr::{Distribution as _, Normal};
use super::innovation::InnovationRegistry;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(u64);
impl NodeId {
#[must_use]
pub const fn new(raw: u64) -> Self {
Self(raw)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
#[must_use]
pub(crate) const fn succ(self) -> Self {
Self(self.0 + 1)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct InnovationId(u64);
impl InnovationId {
#[must_use]
pub const fn new(raw: u64) -> Self {
Self(raw)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
#[must_use]
pub(crate) const fn succ(self) -> Self {
Self(self.0 + 1)
}
}
pub(crate) const SIGMOID_GAIN: f32 = 4.9;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeKind {
Input,
Output,
Hidden,
Bias,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ActivationFn {
Sigmoid,
Tanh,
Relu,
Linear,
}
impl ActivationFn {
#[must_use]
pub fn apply(self, x: f32) -> f32 {
match self {
ActivationFn::Sigmoid => 1.0 / (1.0 + (-SIGMOID_GAIN * x).exp()),
ActivationFn::Tanh => x.tanh(),
ActivationFn::Relu => x.max(0.0),
ActivationFn::Linear => x,
}
}
}
#[derive(Clone, Debug)]
pub struct NodeGene {
pub id: NodeId,
pub kind: NodeKind,
pub activation: ActivationFn,
pub bias: f32,
}
#[derive(Clone, Debug)]
pub struct ConnectionGene {
pub innovation: InnovationId,
pub source: NodeId,
pub target: NodeId,
pub weight: f32,
pub enabled: bool,
}
#[derive(Clone, Debug)]
pub struct TopologyGenome {
pub(crate) nodes: Vec<NodeGene>,
pub(crate) connections: Vec<ConnectionGene>,
}
impl TopologyGenome {
#[must_use]
pub fn new(nodes: Vec<NodeGene>, mut connections: Vec<ConnectionGene>) -> Self {
connections.sort_by_key(|c| c.innovation);
Self { nodes, connections }
}
#[must_use]
pub fn minimal(
num_inputs: usize,
num_outputs: usize,
registry: &InnovationRegistry,
rng: &mut dyn Rng,
weight_init_std: f32,
) -> Self {
debug_assert!(
registry.next_node_id().get() >= (num_inputs + num_outputs) as u64
&& registry.next_innovation().get() >= (num_inputs * num_outputs) as u64,
"registry counters must start after the minimal seed (H6)"
);
let normal = Normal::new(0.0_f32, weight_init_std).unwrap_or_else(|err| {
panic!("weight_init_std must be finite, got {weight_init_std}: {err}")
});
let mut nodes: Vec<NodeGene> = Vec::with_capacity(num_inputs + num_outputs);
for i in 0..num_inputs {
nodes.push(NodeGene {
id: NodeId::new(i as u64),
kind: NodeKind::Input,
activation: ActivationFn::Linear,
bias: 0.0,
});
}
for o in 0..num_outputs {
nodes.push(NodeGene {
id: NodeId::new((num_inputs + o) as u64),
kind: NodeKind::Output,
activation: ActivationFn::Sigmoid,
bias: normal.sample(rng),
});
}
let mut connections: Vec<ConnectionGene> = Vec::with_capacity(num_inputs * num_outputs);
for i in 0..num_inputs {
for o in 0..num_outputs {
connections.push(ConnectionGene {
innovation: InnovationId::new((i * num_outputs + o) as u64),
source: NodeId::new(i as u64),
target: NodeId::new((num_inputs + o) as u64),
weight: normal.sample(rng),
enabled: true,
});
}
}
Self { nodes, connections }
}
pub fn insert_connection_sorted(&mut self, gene: ConnectionGene) {
debug_assert!(
self.connections
.iter()
.all(|c| c.innovation != gene.innovation),
"insert_connection_sorted requires a fresh innovation id"
);
let pos = self
.connections
.partition_point(|c| c.innovation < gene.innovation);
self.connections.insert(pos, gene);
}
#[must_use]
pub fn node(&self, id: NodeId) -> Option<&NodeGene> {
self.nodes.iter().find(|n| n.id == id)
}
#[must_use]
pub fn is_connected(&self, source: NodeId, target: NodeId) -> bool {
self.connections
.iter()
.any(|c| c.source == source && c.target == target)
}
#[must_use]
pub fn would_create_cycle(&self, source: NodeId, target: NodeId) -> bool {
if source == target {
return true;
}
let mut stack: Vec<NodeId> = vec![target];
let mut visited: HashSet<NodeId> = HashSet::new();
while let Some(node) = stack.pop() {
if node == source {
return true;
}
if !visited.insert(node) {
continue;
}
for c in &self.connections {
if c.source == node {
stack.push(c.target);
}
}
}
false
}
#[must_use]
pub fn is_innovation_sorted(&self) -> bool {
self.connections
.windows(2)
.all(|w| w[0].innovation < w[1].innovation)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand::rngs::StdRng;
#[test]
fn test_id_newtypes_round_trip_and_succ() {
assert_eq!(NodeId::new(7).get(), 7);
assert_eq!(NodeId::new(7).succ(), NodeId::new(8));
assert_eq!(InnovationId::new(0).get(), 0);
assert_eq!(InnovationId::new(0).succ().succ(), InnovationId::new(2));
assert!(InnovationId::new(1) < InnovationId::new(2));
}
#[test]
fn test_activation_fn_apply_known_values() {
approx::assert_relative_eq!(ActivationFn::Linear.apply(0.7), 0.7, epsilon = 1e-6);
approx::assert_relative_eq!(ActivationFn::Relu.apply(-2.0), 0.0, epsilon = 1e-6);
approx::assert_relative_eq!(ActivationFn::Relu.apply(3.5), 3.5, epsilon = 1e-6);
approx::assert_relative_eq!(ActivationFn::Tanh.apply(0.0), 0.0, epsilon = 1e-6);
approx::assert_relative_eq!(ActivationFn::Sigmoid.apply(0.0), 0.5, epsilon = 1e-6);
approx::assert_relative_eq!(
ActivationFn::Sigmoid.apply(1.0),
1.0 / (1.0 + (-SIGMOID_GAIN).exp()),
epsilon = 1e-6
);
}
#[test]
fn test_minimal_topology_ids_and_innovations() {
let registry = InnovationRegistry::new(3, 2); let mut rng = StdRng::seed_from_u64(1);
let g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
assert_eq!(g.nodes.len(), 3, "minimal seed has I + O nodes");
assert_eq!(g.node(NodeId::new(0)).unwrap().kind, NodeKind::Input);
assert_eq!(g.node(NodeId::new(1)).unwrap().kind, NodeKind::Input);
assert_eq!(g.node(NodeId::new(2)).unwrap().kind, NodeKind::Output);
assert_eq!(g.connections.len(), 2, "I * O connections");
let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
assert_eq!(innovs, vec![0, 1], "initial innovations are 0..I*O");
assert!(g.is_innovation_sorted());
}
#[test]
#[should_panic(expected = "weight_init_std")]
fn test_minimal_panics_on_nan_std() {
let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
let mut rng: StdRng = StdRng::seed_from_u64(1);
let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, f32::NAN);
}
#[test]
#[should_panic(expected = "weight_init_std")]
fn test_minimal_panics_on_infinite_std() {
let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
let mut rng: StdRng = StdRng::seed_from_u64(1);
let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, f32::INFINITY);
}
#[test]
#[should_panic(expected = "registry counters must start after the minimal seed")]
fn test_minimal_panics_on_registry_counter_disagreement() {
let registry: InnovationRegistry = InnovationRegistry::new(1, 0);
let mut rng: StdRng = StdRng::seed_from_u64(1);
let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
}
#[test]
fn test_minimal_negative_std_does_not_panic() {
let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
let mut rng: StdRng = StdRng::seed_from_u64(1);
let g: TopologyGenome = TopologyGenome::minimal(2, 1, ®istry, &mut rng, -1.0);
assert_eq!(g.nodes.len(), 3, "negative std still yields the I + O seed");
assert_eq!(
g.connections.len(),
2,
"negative std still yields I * O edges"
);
}
#[test]
fn test_empty_genome_invariants() {
let g: TopologyGenome = TopologyGenome::new(vec![], vec![]);
assert!(
g.is_innovation_sorted(),
"empty connections are vacuously innovation-sorted"
);
assert!(
!g.is_connected(NodeId::new(0), NodeId::new(1)),
"no edges means no connection"
);
assert!(
g.would_create_cycle(NodeId::new(0), NodeId::new(0)),
"self-loop short-circuits to a cycle even with no edges"
);
assert!(
!g.would_create_cycle(NodeId::new(0), NodeId::new(1)),
"distinct nodes with no edges cannot form a cycle"
);
assert!(
g.node(NodeId::new(0)).is_none(),
"no nodes means lookup returns None"
);
}
#[test]
fn test_activation_fn_propagates_nan() {
assert!(
ActivationFn::Sigmoid.apply(f32::NAN).is_nan(),
"Sigmoid: NaN flows through exp and the reciprocal"
);
assert!(
ActivationFn::Tanh.apply(f32::NAN).is_nan(),
"Tanh: NaN.tanh() is NaN"
);
assert!(
ActivationFn::Linear.apply(f32::NAN).is_nan(),
"Linear: identity passes NaN through"
);
let relu_nan: f32 = ActivationFn::Relu.apply(f32::NAN);
assert!(
!relu_nan.is_nan(),
"Relu does NOT propagate NaN: f32::max drops the NaN operand"
);
approx::assert_relative_eq!(relu_nan, 0.0, epsilon = 1e-6);
}
#[test]
fn test_insert_connection_sorted_keeps_order() {
let registry = InnovationRegistry::new(3, 2);
let mut rng = StdRng::seed_from_u64(1);
let mut g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
g.insert_connection_sorted(ConnectionGene {
innovation: InnovationId::new(5),
source: NodeId::new(0),
target: NodeId::new(2),
weight: 0.1,
enabled: true,
});
g.insert_connection_sorted(ConnectionGene {
innovation: InnovationId::new(3),
source: NodeId::new(1),
target: NodeId::new(2),
weight: 0.2,
enabled: true,
});
assert!(
g.is_innovation_sorted(),
"sorted invariant preserved on insert"
);
let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
assert_eq!(innovs, vec![0, 1, 3, 5]);
}
#[test]
fn test_would_create_cycle_rejects_back_edge() {
let nodes = vec![
NodeGene {
id: NodeId::new(0),
kind: NodeKind::Input,
activation: ActivationFn::Linear,
bias: 0.0,
},
NodeGene {
id: NodeId::new(2),
kind: NodeKind::Hidden,
activation: ActivationFn::Relu,
bias: 0.0,
},
NodeGene {
id: NodeId::new(3),
kind: NodeKind::Output,
activation: ActivationFn::Sigmoid,
bias: 0.0,
},
];
let conns = vec![
ConnectionGene {
innovation: InnovationId::new(0),
source: NodeId::new(0),
target: NodeId::new(2),
weight: 1.0,
enabled: true,
},
ConnectionGene {
innovation: InnovationId::new(1),
source: NodeId::new(2),
target: NodeId::new(3),
weight: 1.0,
enabled: true,
},
];
let g = TopologyGenome::new(nodes, conns);
assert!(
g.would_create_cycle(NodeId::new(3), NodeId::new(0)),
"3 -> 0 closes a cycle through 0 -> 2 -> 3"
);
assert!(
g.would_create_cycle(NodeId::new(3), NodeId::new(2)),
"3 -> 2 closes a cycle through 2 -> 3"
);
assert!(
!g.would_create_cycle(NodeId::new(0), NodeId::new(3)),
"0 -> 3 is a forward edge"
);
assert!(
g.would_create_cycle(NodeId::new(0), NodeId::new(0)),
"self-loop is a cycle"
);
}
#[test]
fn test_would_create_cycle_counts_disabled_edges() {
let nodes = vec![
NodeGene {
id: NodeId::new(2),
kind: NodeKind::Hidden,
activation: ActivationFn::Relu,
bias: 0.0,
},
NodeGene {
id: NodeId::new(3),
kind: NodeKind::Hidden,
activation: ActivationFn::Relu,
bias: 0.0,
},
];
let conns = vec![ConnectionGene {
innovation: InnovationId::new(0),
source: NodeId::new(2),
target: NodeId::new(3),
weight: 1.0,
enabled: false,
}];
let g = TopologyGenome::new(nodes, conns);
assert!(
g.would_create_cycle(NodeId::new(3), NodeId::new(2)),
"disabled edges are counted so the DAG survives re-enable"
);
}
}