Skip to main content

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, &registry, &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, &registry, &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, &registry, &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, &registry, &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, &registry, &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, &registry, &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}