Skip to main content

rlevo_evolution/neuroevolution/
innovation.rs

1//! Innovation-number bookkeeping — the per-run registry that assigns the
2//! historical markers NEAT crossover aligns on.
3//!
4//! A single [`InnovationRegistry`] is created per run and shared across the NEAT
5//! harness via `Arc`. It is the **only** allocator of [`InnovationId`]s and
6//! hidden [`NodeId`]s, so two identical structural mutations occurring in
7//! different genomes *within the same run* receive the same ids — without which
8//! innovation-aligned crossover silently misclassifies genes.
9//!
10//! # Determinism
11//!
12//! Mutation is applied sequentially host-side inside `tell` under the seeded
13//! `seed_stream` RNG, so the `Mutex` never contends and id-issue order is
14//! seed-fixed. The caches additionally make the *result* of a repeated
15//! structural mutation order-independent: only first-issue assignment depends on
16//! order, and that order is seeded.
17//!
18//! Independent runs (parallel trials, different seeds) each create their own
19//! registry, so their innovation spaces are isolated — which is correct, because
20//! cross-run gene alignment is meaningless.
21
22use std::collections::HashMap;
23
24use parking_lot::Mutex;
25
26use super::topology::{InnovationId, NodeId};
27
28/// The result of an add-node mutation that splits a connection.
29///
30/// Stable for a given split innovation within a run: the same split always
31/// yields the same new node and the same two connection innovations, so the
32/// inserted node aligns across genomes during crossover.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct NodeSplit {
35    /// Id of the hidden node inserted into the split connection.
36    pub new_node: NodeId,
37    /// Innovation of the `source -> new_node` edge (canonical weight `1.0`).
38    pub in_innov: InnovationId,
39    /// Innovation of the `new_node -> target` edge (inherits the old weight).
40    pub out_innov: InnovationId,
41}
42
43/// Per-run innovation bookkeeping, shared via `Arc` across the NEAT harness.
44///
45/// Thread-safe via interior mutability behind a [`parking_lot::Mutex`]
46/// (ADR-0010). See the [module docs](self) for the determinism argument.
47#[derive(Debug)]
48pub struct InnovationRegistry {
49    inner: Mutex<RegistryInner>,
50}
51
52#[derive(Debug)]
53struct RegistryInner {
54    next_innovation: InnovationId,
55    next_node: NodeId,
56    /// `(source, target) -> innovation`, so the same add-connection re-uses its
57    /// id across genomes and generations.
58    conn_cache: HashMap<(NodeId, NodeId), InnovationId>,
59    /// split-connection innovation `-> NodeSplit`, so the same split is stable
60    /// across the run even after the surrounding topology changes.
61    node_cache: HashMap<InnovationId, NodeSplit>,
62}
63
64impl InnovationRegistry {
65    /// Create a registry whose counters start *after* the minimal seed topology.
66    ///
67    /// `initial_node_count` is the number of input + output nodes (their ids are
68    /// `0..initial_node_count`), and `initial_innovation_count` is the number of
69    /// fully-connected seed connection genes (their innovations are
70    /// `0..initial_innovation_count`). The first hidden node and the first new
71    /// connection are therefore allocated *after* the seed, matching
72    /// [`TopologyGenome::minimal`](super::topology::TopologyGenome::minimal).
73    #[must_use]
74    pub fn new(initial_node_count: usize, initial_innovation_count: usize) -> Self {
75        Self {
76            inner: Mutex::new(RegistryInner {
77                next_innovation: InnovationId::new(initial_innovation_count as u64),
78                next_node: NodeId::new(initial_node_count as u64),
79                conn_cache: HashMap::new(),
80                node_cache: HashMap::new(),
81            }),
82        }
83    }
84
85    /// Innovation id for an add-connection between `source` and `target`,
86    /// allocating a fresh one on first sight and re-using it thereafter.
87    ///
88    /// The returned id must be wired into the new [`ConnectionGene`]; discarding
89    /// it leaks an allocation, hence `#[must_use]`.
90    ///
91    /// [`ConnectionGene`]: super::topology::ConnectionGene
92    #[must_use]
93    pub fn register_connection(&self, source: NodeId, target: NodeId) -> InnovationId {
94        let mut inner = self.inner.lock();
95        if let Some(&id) = inner.conn_cache.get(&(source, target)) {
96            return id;
97        }
98        let id = inner.next_innovation;
99        inner.next_innovation = id.succ();
100        inner.conn_cache.insert((source, target), id);
101        id
102    }
103
104    /// Node + two innovations for splitting connection `split`, allocated once
105    /// and cached so the same split is stable across the run.
106    ///
107    /// Keying on the **split innovation** (not on the `(source, target)` pair)
108    /// makes a node inserted into "the same place" align even after the
109    /// surrounding topology diverges.
110    ///
111    /// The returned [`NodeSplit`] carries the node and two innovation ids the
112    /// caller must build the split connections from, hence `#[must_use]`.
113    #[must_use]
114    pub fn register_node_split(&self, split: InnovationId) -> NodeSplit {
115        let mut inner = self.inner.lock();
116        if let Some(&existing) = inner.node_cache.get(&split) {
117            return existing;
118        }
119        let new_node = inner.next_node;
120        inner.next_node = new_node.succ();
121        let in_innov = inner.next_innovation;
122        let out_innov = in_innov.succ();
123        inner.next_innovation = out_innov.succ();
124        let result = NodeSplit {
125            new_node,
126            in_innov,
127            out_innov,
128        };
129        inner.node_cache.insert(split, result);
130        result
131    }
132
133    /// Next innovation id that would be allocated (the count of innovations
134    /// issued so far). Used for checkpointing surfaces and invariant assertions.
135    #[must_use]
136    pub fn next_innovation(&self) -> InnovationId {
137        self.inner.lock().next_innovation
138    }
139
140    /// Next node id that would be allocated (the count of nodes issued so far,
141    /// including the seed inputs/outputs).
142    #[must_use]
143    pub fn next_node_id(&self) -> NodeId {
144        self.inner.lock().next_node
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_registry_starts_after_seed() {
154        let registry = InnovationRegistry::new(3, 2);
155        assert_eq!(
156            registry.next_node_id().get(),
157            3,
158            "node ids start after I+O seed nodes"
159        );
160        assert_eq!(
161            registry.next_innovation().get(),
162            2,
163            "innovations start after the I*O seed connections"
164        );
165    }
166
167    #[test]
168    fn test_register_connection_caches_id() {
169        let registry = InnovationRegistry::new(3, 2);
170        let a = registry.register_connection(NodeId::new(0), NodeId::new(2));
171        let b = registry.register_connection(NodeId::new(1), NodeId::new(2));
172        // Distinct pairs get distinct, monotone ids.
173        assert_eq!(a.get(), 2);
174        assert_eq!(b.get(), 3);
175        // The same pair re-uses the cached id (crossover alignment).
176        assert_eq!(
177            registry.register_connection(NodeId::new(0), NodeId::new(2)),
178            a
179        );
180        assert_eq!(registry.next_innovation().get(), 4);
181    }
182
183    #[test]
184    fn test_register_node_split_caches_and_allocates_one_node_two_innovs() {
185        let registry = InnovationRegistry::new(3, 2);
186        let s = registry.register_node_split(InnovationId::new(0));
187        assert_eq!(
188            s.new_node.get(),
189            3,
190            "first hidden node id is after the seed nodes"
191        );
192        assert_eq!(s.in_innov.get(), 2);
193        assert_eq!(s.out_innov.get(), 3);
194        assert_eq!(registry.next_node_id().get(), 4);
195        assert_eq!(registry.next_innovation().get(), 4);
196        // Splitting the SAME connection again returns the cached split.
197        assert_eq!(registry.register_node_split(InnovationId::new(0)), s);
198        // Splitting a DIFFERENT connection allocates a fresh node + 2 innovs.
199        let t = registry.register_node_split(InnovationId::new(1));
200        assert_eq!(t.new_node.get(), 4);
201        assert_eq!(t.in_innov.get(), 4);
202        assert_eq!(t.out_innov.get(), 5);
203    }
204
205    #[test]
206    fn test_independent_registries_replay_identical_ids() {
207        // Registry-level determinism: replaying the same allocation script on
208        // two fresh registries yields identical innovation AND node id sequences.
209        fn run() -> (Vec<InnovationId>, Vec<NodeId>) {
210            let reg = InnovationRegistry::new(3, 2);
211            let mut innovs = Vec::new();
212            let mut nodes = Vec::new();
213            innovs.push(reg.register_connection(NodeId::new(0), NodeId::new(2)));
214            let s = reg.register_node_split(InnovationId::new(0));
215            nodes.push(s.new_node);
216            innovs.push(s.in_innov);
217            innovs.push(s.out_innov);
218            innovs.push(reg.register_connection(NodeId::new(1), s.new_node));
219            (innovs, nodes)
220        }
221        let (i1, n1) = run();
222        let (i2, n2) = run();
223        assert_eq!(i1, i2, "innovation sequence is reproducible across runs");
224        assert_eq!(n1, n2, "node id sequence is reproducible across runs");
225    }
226
227    #[test]
228    fn test_shared_registry_aligns_same_split_across_genomes() {
229        // The cross-genome alignment guarantee: two genomes splitting the same
230        // connection (same innovation) via the SAME shared registry get the same
231        // node id and the same in/out innovations.
232        let registry = InnovationRegistry::new(3, 2);
233        let split_in_genome_a = registry.register_node_split(InnovationId::new(0));
234        let split_in_genome_b = registry.register_node_split(InnovationId::new(0));
235        assert_eq!(split_in_genome_a, split_in_genome_b);
236    }
237}