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}