rlevo_evolution/neuroevolution/topology.rs
1//! NEAT topology genome — a direct graph encoding of node and connection genes.
2//!
3//! A [`TopologyGenome`] is the **genotype**: two gene lists (nodes and
4//! connections) that NEAT mutates by adding neurons and edges and recombines via
5//! historical **innovation numbers**. The network actually evaluated — the
6//! *phenotype* — is built from this genome by
7//! [`crate::neuroevolution::phenotype`] and walks the enabled connections in
8//! topological order.
9//!
10//! # Invariants
11//!
12//! - `connections` is kept **sorted by `innovation`** (see
13//! [`TopologyGenome::insert_connection_sorted`]). Mutations append the
14//! largest-so-far innovation, so insertion keeps it sorted cheaply, and
15//! crossover / compatibility distance become `O(n)` merges.
16//! - The directed graph over **all** structural edges (enabled *or* disabled) is
17//! acyclic — the feedforward invariant. [`TopologyGenome::would_create_cycle`]
18//! checks against all edges so the DAG stays stable under enable/disable
19//! toggles.
20//!
21//! Unlike the tensor-backed genomes elsewhere in the crate,
22//! [`TopologyGenome`] **is** [`Clone`]: it is plain host-side data with no
23//! Burn-tensor storage aliasing.
24
25use std::collections::HashSet;
26
27use rand::Rng;
28use rand_distr::{Distribution as _, Normal};
29
30use super::innovation::InnovationRegistry;
31
32/// Stable identifier for a node gene. Monotone within a run; allocated only by
33/// the [`InnovationRegistry`] (for hidden nodes) or fixed by the minimal
34/// topology (for inputs/outputs).
35///
36/// An **opaque newtype** over `u64` (not a bare alias): a `NodeId` cannot be
37/// interchanged with an [`InnovationId`] or a raw integer, so the mutation and
38/// crossover logic can never confuse the two id spaces. It has no invariant —
39/// every `u64` is a legal id — so [`new`](NodeId::new) is infallible. Construct
40/// with `new`, read with [`get`](NodeId::get); the crate-internal
41/// `succ` is the only arithmetic, used solely by the
42/// [`InnovationRegistry`] counters.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct NodeId(u64);
45
46impl NodeId {
47 /// Wrap a raw id. Infallible — a node id has no invariant.
48 #[must_use]
49 pub const fn new(raw: u64) -> Self {
50 Self(raw)
51 }
52
53 /// The underlying id.
54 #[must_use]
55 pub const fn get(self) -> u64 {
56 self.0
57 }
58
59 /// The next id in sequence. Crate-internal because only the
60 /// [`InnovationRegistry`] allocates fresh node ids.
61 #[must_use]
62 pub(crate) const fn succ(self) -> Self {
63 Self(self.0 + 1)
64 }
65}
66
67/// Historical marker for a connection gene — the *innovation number* that lets
68/// crossover align structurally-different genomes. Globally monotone within a
69/// run, but sparse within any one genome.
70///
71/// An **opaque newtype** over `u64` (not a bare alias); see [`NodeId`] for the
72/// rationale. Its derived [`Ord`] is what keeps `connections` innovation-sorted
73/// and drives the `O(n)` crossover / compatibility-distance merges.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct InnovationId(u64);
76
77impl InnovationId {
78 /// Wrap a raw innovation number. Infallible — it has no invariant.
79 #[must_use]
80 pub const fn new(raw: u64) -> Self {
81 Self(raw)
82 }
83
84 /// The underlying innovation number.
85 #[must_use]
86 pub const fn get(self) -> u64 {
87 self.0
88 }
89
90 /// The next innovation in sequence. Crate-internal because only the
91 /// [`InnovationRegistry`] allocates fresh innovations.
92 #[must_use]
93 pub(crate) const fn succ(self) -> Self {
94 Self(self.0 + 1)
95 }
96}
97
98/// Steepening gain of the canonical NEAT logistic sigmoid (Stanley &
99/// Miikkulainen 2002 use `4.9`). Shared by the host-side
100/// [`ActivationFn::apply`] and the tensor forward pass in
101/// [`crate::neuroevolution::phenotype`] so the two never disagree.
102pub(crate) const SIGMOID_GAIN: f32 = 4.9;
103
104/// Role of a node within the network.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum NodeKind {
107 /// Sensor node: holds an input value verbatim (no bias, no activation).
108 Input,
109 /// Output node: its activation is read as a network output.
110 Output,
111 /// Hidden node introduced by an add-node mutation.
112 Hidden,
113 /// Always-on bias node. Reserved for completeness; v1's minimal topology
114 /// carries bias on the node gene ([`NodeGene::bias`]) instead, so this
115 /// variant is unused by [`TopologyGenome::minimal`].
116 Bias,
117}
118
119/// Activation applied at a node.
120///
121/// The canonical NEAT starting set. Marked `#[non_exhaustive]` so CPPN
122/// activations (`sin`/`gauss`/`abs`) needed by a future `HyperNEAT` builder are a
123/// non-breaking addition.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum ActivationFn {
127 /// Steepened logistic sigmoid (gain `4.9`) — the canonical NEAT activation.
128 Sigmoid,
129 /// Hyperbolic tangent.
130 Tanh,
131 /// Rectified linear unit.
132 Relu,
133 /// Identity.
134 Linear,
135}
136
137impl ActivationFn {
138 /// Apply the activation to a scalar, host-side.
139 ///
140 /// The tensor forward pass in [`crate::neuroevolution::phenotype`] mirrors
141 /// these exact formulas (including the `SIGMOID_GAIN` steepening) so a
142 /// hand-computed truth table matches the interpreted phenotype.
143 #[must_use]
144 pub fn apply(self, x: f32) -> f32 {
145 match self {
146 ActivationFn::Sigmoid => 1.0 / (1.0 + (-SIGMOID_GAIN * x).exp()),
147 ActivationFn::Tanh => x.tanh(),
148 ActivationFn::Relu => x.max(0.0),
149 ActivationFn::Linear => x,
150 }
151 }
152}
153
154/// A single node gene: identity, role, activation, and per-node bias.
155#[derive(Clone, Debug)]
156pub struct NodeGene {
157 /// Stable node id (see [`NodeId`]).
158 pub id: NodeId,
159 /// Node role.
160 pub kind: NodeKind,
161 /// Activation applied to this node's pre-activation sum.
162 pub activation: ActivationFn,
163 /// Additive bias folded into the node's pre-activation sum. Mutated by the
164 /// weight-perturbation operator (a bias is, functionally, a weight).
165 pub bias: f32,
166}
167
168/// A single connection gene: a weighted directed edge tagged with its
169/// historical innovation number.
170#[derive(Clone, Debug)]
171pub struct ConnectionGene {
172 /// Historical marker aligning this edge across genomes (see [`InnovationId`]).
173 pub innovation: InnovationId,
174 /// Source node id.
175 pub source: NodeId,
176 /// Target node id.
177 pub target: NodeId,
178 /// Edge weight.
179 pub weight: f32,
180 /// Whether the edge carries signal in the phenotype. Disabled genes are
181 /// skipped in the forward pass but still counted in compatibility distance
182 /// and crossover alignment.
183 pub enabled: bool,
184}
185
186/// A network genotype: a node-gene list plus an innovation-sorted
187/// connection-gene list.
188///
189/// See the [module docs](self) for the two structural invariants.
190///
191/// Fields are `pub(crate)` rather than public: the innovation-sort invariant on
192/// `connections` is maintained collectively by the NEAT mutation, crossover,
193/// and speciation operators (`neat.rs`, `species.rs`, `phenotype.rs`), which
194/// edit the vectors in place. Exposing them publicly would let external code
195/// build an unsorted genome by struct literal; instead, construct one with
196/// [`TopologyGenome::new`] / [`TopologyGenome::minimal`] (which establish the
197/// invariant) and extend it with [`insert_connection_sorted`](TopologyGenome::insert_connection_sorted).
198#[derive(Clone, Debug)]
199pub struct TopologyGenome {
200 /// Node genes (inputs, outputs, and any hidden nodes).
201 pub(crate) nodes: Vec<NodeGene>,
202 /// Connection genes, kept sorted by [`ConnectionGene::innovation`].
203 pub(crate) connections: Vec<ConnectionGene>,
204}
205
206impl TopologyGenome {
207 /// Build a genome from explicit gene lists, sorting connections by
208 /// innovation to establish the sorted invariant.
209 ///
210 /// Hand-built test genomes should prefer this over a struct literal so the
211 /// sorted invariant holds regardless of input order.
212 #[must_use]
213 pub fn new(nodes: Vec<NodeGene>, mut connections: Vec<ConnectionGene>) -> Self {
214 connections.sort_by_key(|c| c.innovation);
215 Self { nodes, connections }
216 }
217
218 /// Build the minimal seed topology: `num_inputs` input nodes fully connected
219 /// to `num_outputs` output nodes, with no hidden nodes (NEAT's
220 /// minimal-topology principle).
221 ///
222 /// Node ids are fixed by convention — inputs `0..num_inputs`, outputs
223 /// `num_inputs..num_inputs + num_outputs` — and initial connection
224 /// innovations are `input_index * num_outputs + output_index`, i.e. the
225 /// range `0..num_inputs * num_outputs`. A matching registry is created with
226 /// `InnovationRegistry::new(num_inputs + num_outputs, num_inputs *
227 /// num_outputs)` so its counters start *after* this seed; the registry is
228 /// passed only to assert that agreement (it is not used to allocate the seed
229 /// ids, which would double-count them).
230 ///
231 /// Calling this once per individual with the *same* registry yields aligned
232 /// initial genomes (identical ids, per-individual random weights).
233 ///
234 /// # Panics
235 ///
236 /// Panics if `weight_init_std` is non-finite (`+∞` or `NaN`), or (in debug
237 /// builds) if `registry`'s counters disagree with the seed sizes.
238 #[must_use]
239 pub fn minimal(
240 num_inputs: usize,
241 num_outputs: usize,
242 registry: &InnovationRegistry,
243 rng: &mut dyn Rng,
244 weight_init_std: f32,
245 ) -> Self {
246 debug_assert!(
247 registry.next_node_id().get() >= (num_inputs + num_outputs) as u64
248 && registry.next_innovation().get() >= (num_inputs * num_outputs) as u64,
249 "registry counters must start after the minimal seed (H6)"
250 );
251 let normal = Normal::new(0.0_f32, weight_init_std).unwrap_or_else(|err| {
252 panic!("weight_init_std must be finite, got {weight_init_std}: {err}")
253 });
254
255 let mut nodes: Vec<NodeGene> = Vec::with_capacity(num_inputs + num_outputs);
256 for i in 0..num_inputs {
257 nodes.push(NodeGene {
258 id: NodeId::new(i as u64),
259 kind: NodeKind::Input,
260 activation: ActivationFn::Linear,
261 bias: 0.0,
262 });
263 }
264 for o in 0..num_outputs {
265 nodes.push(NodeGene {
266 id: NodeId::new((num_inputs + o) as u64),
267 kind: NodeKind::Output,
268 activation: ActivationFn::Sigmoid,
269 bias: normal.sample(rng),
270 });
271 }
272
273 let mut connections: Vec<ConnectionGene> = Vec::with_capacity(num_inputs * num_outputs);
274 for i in 0..num_inputs {
275 for o in 0..num_outputs {
276 connections.push(ConnectionGene {
277 innovation: InnovationId::new((i * num_outputs + o) as u64),
278 source: NodeId::new(i as u64),
279 target: NodeId::new((num_inputs + o) as u64),
280 weight: normal.sample(rng),
281 enabled: true,
282 });
283 }
284 }
285 // Already innovation-sorted by construction.
286 Self { nodes, connections }
287 }
288
289 /// Insert a connection gene, preserving the innovation-sorted invariant.
290 ///
291 /// The caller must guarantee `gene.innovation` is not already present.
292 pub fn insert_connection_sorted(&mut self, gene: ConnectionGene) {
293 debug_assert!(
294 self.connections
295 .iter()
296 .all(|c| c.innovation != gene.innovation),
297 "insert_connection_sorted requires a fresh innovation id"
298 );
299 let pos = self
300 .connections
301 .partition_point(|c| c.innovation < gene.innovation);
302 self.connections.insert(pos, gene);
303 }
304
305 /// Look up a node gene by id.
306 #[must_use]
307 pub fn node(&self, id: NodeId) -> Option<&NodeGene> {
308 self.nodes.iter().find(|n| n.id == id)
309 }
310
311 /// Whether a directed edge `source -> target` already exists (enabled or
312 /// disabled). Used by add-connection to avoid duplicate edges.
313 #[must_use]
314 pub fn is_connected(&self, source: NodeId, target: NodeId) -> bool {
315 self.connections
316 .iter()
317 .any(|c| c.source == source && c.target == target)
318 }
319
320 /// Whether adding edge `source -> target` would create a cycle, considering
321 /// **all** structural edges (enabled or disabled).
322 ///
323 /// A cycle forms exactly when `target` can already reach `source`; checking
324 /// over all edges (not just enabled ones) keeps the feedforward DAG stable
325 /// under enable/disable toggles.
326 #[must_use]
327 pub fn would_create_cycle(&self, source: NodeId, target: NodeId) -> bool {
328 if source == target {
329 return true;
330 }
331 let mut stack: Vec<NodeId> = vec![target];
332 let mut visited: HashSet<NodeId> = HashSet::new();
333 while let Some(node) = stack.pop() {
334 if node == source {
335 return true;
336 }
337 if !visited.insert(node) {
338 continue;
339 }
340 for c in &self.connections {
341 if c.source == node {
342 stack.push(c.target);
343 }
344 }
345 }
346 false
347 }
348
349 /// Whether `connections` is **strictly** increasing by innovation — i.e.
350 /// sorted with no duplicate innovation ids, the full structural invariant.
351 #[must_use]
352 pub fn is_innovation_sorted(&self) -> bool {
353 self.connections
354 .windows(2)
355 .all(|w| w[0].innovation < w[1].innovation)
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use rand::SeedableRng;
363 use rand::rngs::StdRng;
364
365 #[test]
366 fn test_id_newtypes_round_trip_and_succ() {
367 // The opaque-id surface: `new`/`get` round-trip and `succ` steps by one.
368 // `NodeId` and `InnovationId` are distinct types — a program that mixed
369 // them would not compile, which is the whole point of the newtype.
370 assert_eq!(NodeId::new(7).get(), 7);
371 assert_eq!(NodeId::new(7).succ(), NodeId::new(8));
372 assert_eq!(InnovationId::new(0).get(), 0);
373 assert_eq!(InnovationId::new(0).succ().succ(), InnovationId::new(2));
374 // Ordering (needed by the innovation-sorted invariant and BTreeMap keys).
375 assert!(InnovationId::new(1) < InnovationId::new(2));
376 }
377
378 #[test]
379 fn test_activation_fn_apply_known_values() {
380 // Linear is identity; Relu clamps negatives; Tanh is odd; Sigmoid is
381 // steepened logistic centered at 0.5.
382 approx::assert_relative_eq!(ActivationFn::Linear.apply(0.7), 0.7, epsilon = 1e-6);
383 approx::assert_relative_eq!(ActivationFn::Relu.apply(-2.0), 0.0, epsilon = 1e-6);
384 approx::assert_relative_eq!(ActivationFn::Relu.apply(3.5), 3.5, epsilon = 1e-6);
385 approx::assert_relative_eq!(ActivationFn::Tanh.apply(0.0), 0.0, epsilon = 1e-6);
386 approx::assert_relative_eq!(ActivationFn::Sigmoid.apply(0.0), 0.5, epsilon = 1e-6);
387 // Steepened sigmoid saturates fast: sigmoid(4.9 * 1) ~ 0.9926.
388 approx::assert_relative_eq!(
389 ActivationFn::Sigmoid.apply(1.0),
390 1.0 / (1.0 + (-SIGMOID_GAIN).exp()),
391 epsilon = 1e-6
392 );
393 }
394
395 #[test]
396 fn test_minimal_topology_ids_and_innovations() {
397 let registry = InnovationRegistry::new(3, 2); // 2 inputs + 1 output, 2 connections
398 let mut rng = StdRng::seed_from_u64(1);
399 let g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
400
401 // 2 inputs (0, 1) + 1 output (2).
402 assert_eq!(g.nodes.len(), 3, "minimal seed has I + O nodes");
403 assert_eq!(g.node(NodeId::new(0)).unwrap().kind, NodeKind::Input);
404 assert_eq!(g.node(NodeId::new(1)).unwrap().kind, NodeKind::Input);
405 assert_eq!(g.node(NodeId::new(2)).unwrap().kind, NodeKind::Output);
406
407 // Fully connected inputs -> output with innovations 0 and 1.
408 assert_eq!(g.connections.len(), 2, "I * O connections");
409 let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
410 assert_eq!(innovs, vec![0, 1], "initial innovations are 0..I*O");
411 assert!(g.is_innovation_sorted());
412 }
413
414 #[test]
415 #[should_panic(expected = "weight_init_std")]
416 fn test_minimal_panics_on_nan_std() {
417 // `Normal::new(0.0, NaN)` returns `Err`, so `minimal`'s `unwrap_or_else`
418 // fires the documented `weight_init_std` panic. Registry is sized to
419 // satisfy the debug_assert first, so the std check is what panics.
420 let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
421 let mut rng: StdRng = StdRng::seed_from_u64(1);
422 let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, f32::NAN);
423 }
424
425 #[test]
426 #[should_panic(expected = "weight_init_std")]
427 fn test_minimal_panics_on_infinite_std() {
428 // `+∞` std is likewise rejected by `Normal::new`, reaching the panic.
429 let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
430 let mut rng: StdRng = StdRng::seed_from_u64(1);
431 let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, f32::INFINITY);
432 }
433
434 #[test]
435 #[should_panic(expected = "registry counters must start after the minimal seed")]
436 fn test_minimal_panics_on_registry_counter_disagreement() {
437 // A registry sized for a tiny seed (next_node_id = 1, next_innovation =
438 // 0) is too small for a (num_inputs=2, num_outputs=1) minimal genome,
439 // which requires next_node_id >= 3 and next_innovation >= 2. This
440 // violates the H6 precondition and trips the `debug_assert!` in
441 // `minimal`. `weight_init_std` is finite (1.0) so the *first* panic path
442 // (non-finite std) does not fire — the registry check is what panics.
443 // NOTE: this is a `debug_assert!`, so it only fires with debug
444 // assertions on; `cargo test` builds with `debug_assertions`, pinning
445 // this debug-only H6 registry precondition.
446 let registry: InnovationRegistry = InnovationRegistry::new(1, 0);
447 let mut rng: StdRng = StdRng::seed_from_u64(1);
448 let _g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
449 }
450
451 #[test]
452 fn test_minimal_negative_std_does_not_panic() {
453 // NOTE: contrary to the naive expectation, a *negative* std_dev is NOT
454 // rejected by `rand_distr::Normal::new` — only non-finite std_dev
455 // (`NaN` / `±∞`) returns `Err`. So `minimal` does not panic here; it
456 // builds a valid seed genome. This test pins that boundary so a future
457 // rand_distr change that tightens the check is caught.
458 let registry: InnovationRegistry = InnovationRegistry::new(3, 2);
459 let mut rng: StdRng = StdRng::seed_from_u64(1);
460 let g: TopologyGenome = TopologyGenome::minimal(2, 1, ®istry, &mut rng, -1.0);
461 assert_eq!(g.nodes.len(), 3, "negative std still yields the I + O seed");
462 assert_eq!(
463 g.connections.len(),
464 2,
465 "negative std still yields I * O edges"
466 );
467 }
468
469 #[test]
470 fn test_empty_genome_invariants() {
471 // An empty genome exercises the vacuous / short-circuit branches of the
472 // query methods. Every assertion below is grounded in the real code.
473 let g: TopologyGenome = TopologyGenome::new(vec![], vec![]);
474
475 // `windows(2)` over an empty slice yields nothing, so `all` is vacuously
476 // true — an empty connection list counts as sorted.
477 assert!(
478 g.is_innovation_sorted(),
479 "empty connections are vacuously innovation-sorted"
480 );
481
482 // `any` over no connections is false — nothing is connected.
483 assert!(
484 !g.is_connected(NodeId::new(0), NodeId::new(1)),
485 "no edges means no connection"
486 );
487
488 // `would_create_cycle` short-circuits `source == target` to true before
489 // touching the (empty) edge set: a self-loop is always a cycle.
490 assert!(
491 g.would_create_cycle(NodeId::new(0), NodeId::new(0)),
492 "self-loop short-circuits to a cycle even with no edges"
493 );
494 // Distinct endpoints with no reachable path: the DFS pops `target`,
495 // finds no outgoing edges, and returns false.
496 assert!(
497 !g.would_create_cycle(NodeId::new(0), NodeId::new(1)),
498 "distinct nodes with no edges cannot form a cycle"
499 );
500
501 // `find` over an empty node list is None.
502 assert!(
503 g.node(NodeId::new(0)).is_none(),
504 "no nodes means lookup returns None"
505 );
506 }
507
508 #[test]
509 fn test_activation_fn_propagates_nan() {
510 // NaN handling is per-arm, not uniform. Sigmoid, Tanh, and Linear all
511 // carry NaN through their arithmetic...
512 assert!(
513 ActivationFn::Sigmoid.apply(f32::NAN).is_nan(),
514 "Sigmoid: NaN flows through exp and the reciprocal"
515 );
516 assert!(
517 ActivationFn::Tanh.apply(f32::NAN).is_nan(),
518 "Tanh: NaN.tanh() is NaN"
519 );
520 assert!(
521 ActivationFn::Linear.apply(f32::NAN).is_nan(),
522 "Linear: identity passes NaN through"
523 );
524 // ...but Relu uses `x.max(0.0)`, and `f32::max` returns the non-NaN
525 // operand when either argument is NaN. So `NaN.max(0.0)` is 0.0, NOT
526 // NaN — Relu swallows the NaN rather than propagating it.
527 let relu_nan: f32 = ActivationFn::Relu.apply(f32::NAN);
528 assert!(
529 !relu_nan.is_nan(),
530 "Relu does NOT propagate NaN: f32::max drops the NaN operand"
531 );
532 approx::assert_relative_eq!(relu_nan, 0.0, epsilon = 1e-6);
533 }
534
535 #[test]
536 fn test_insert_connection_sorted_keeps_order() {
537 let registry = InnovationRegistry::new(3, 2);
538 let mut rng = StdRng::seed_from_u64(1);
539 let mut g = TopologyGenome::minimal(2, 1, ®istry, &mut rng, 1.0);
540 // Insert a smaller-than-max innovation out of order; must land in place.
541 g.insert_connection_sorted(ConnectionGene {
542 innovation: InnovationId::new(5),
543 source: NodeId::new(0),
544 target: NodeId::new(2),
545 weight: 0.1,
546 enabled: true,
547 });
548 g.insert_connection_sorted(ConnectionGene {
549 innovation: InnovationId::new(3),
550 source: NodeId::new(1),
551 target: NodeId::new(2),
552 weight: 0.2,
553 enabled: true,
554 });
555 assert!(
556 g.is_innovation_sorted(),
557 "sorted invariant preserved on insert"
558 );
559 let innovs: Vec<u64> = g.connections.iter().map(|c| c.innovation.get()).collect();
560 assert_eq!(innovs, vec![0, 1, 3, 5]);
561 }
562
563 #[test]
564 fn test_would_create_cycle_rejects_back_edge() {
565 // Build 0 -> 2 -> 3 (feedforward). Adding 3 -> 0 would close a cycle.
566 let nodes = vec![
567 NodeGene {
568 id: NodeId::new(0),
569 kind: NodeKind::Input,
570 activation: ActivationFn::Linear,
571 bias: 0.0,
572 },
573 NodeGene {
574 id: NodeId::new(2),
575 kind: NodeKind::Hidden,
576 activation: ActivationFn::Relu,
577 bias: 0.0,
578 },
579 NodeGene {
580 id: NodeId::new(3),
581 kind: NodeKind::Output,
582 activation: ActivationFn::Sigmoid,
583 bias: 0.0,
584 },
585 ];
586 let conns = vec![
587 ConnectionGene {
588 innovation: InnovationId::new(0),
589 source: NodeId::new(0),
590 target: NodeId::new(2),
591 weight: 1.0,
592 enabled: true,
593 },
594 ConnectionGene {
595 innovation: InnovationId::new(1),
596 source: NodeId::new(2),
597 target: NodeId::new(3),
598 weight: 1.0,
599 enabled: true,
600 },
601 ];
602 let g = TopologyGenome::new(nodes, conns);
603 assert!(
604 g.would_create_cycle(NodeId::new(3), NodeId::new(0)),
605 "3 -> 0 closes a cycle through 0 -> 2 -> 3"
606 );
607 assert!(
608 g.would_create_cycle(NodeId::new(3), NodeId::new(2)),
609 "3 -> 2 closes a cycle through 2 -> 3"
610 );
611 assert!(
612 !g.would_create_cycle(NodeId::new(0), NodeId::new(3)),
613 "0 -> 3 is a forward edge"
614 );
615 assert!(
616 g.would_create_cycle(NodeId::new(0), NodeId::new(0)),
617 "self-loop is a cycle"
618 );
619 }
620
621 #[test]
622 fn test_would_create_cycle_counts_disabled_edges() {
623 // Disabled 2 -> 3 still constrains acyclicity (H2).
624 let nodes = vec![
625 NodeGene {
626 id: NodeId::new(2),
627 kind: NodeKind::Hidden,
628 activation: ActivationFn::Relu,
629 bias: 0.0,
630 },
631 NodeGene {
632 id: NodeId::new(3),
633 kind: NodeKind::Hidden,
634 activation: ActivationFn::Relu,
635 bias: 0.0,
636 },
637 ];
638 let conns = vec![ConnectionGene {
639 innovation: InnovationId::new(0),
640 source: NodeId::new(2),
641 target: NodeId::new(3),
642 weight: 1.0,
643 enabled: false,
644 }];
645 let g = TopologyGenome::new(nodes, conns);
646 assert!(
647 g.would_create_cycle(NodeId::new(3), NodeId::new(2)),
648 "disabled edges are counted so the DAG survives re-enable"
649 );
650 }
651}