Skip to content

Commit 29048e5

Browse files
Add tests for CTRNN.
1 parent 576ea74 commit 29048e5

File tree

3 files changed

+188
-89
lines changed

3 files changed

+188
-89
lines changed

tests/test_attributes.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
- Attribute validation: Invalid configurations, boundary conditions
1010
- Edge cases: Zero mutation rates, rate=1.0, extreme values
1111
12-
These tests address gaps identified in TESTING_RECOMMENDATIONS.md for the attributes module,
13-
which had 81% coverage (lowest non-distributed module) and no dedicated test file.
1412
"""
1513

1614
import unittest

tests/test_checkpoint.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ def test_checkpoint_innovation_numbers_continue(self):
463463
conn.innovation,
464464
"All connections should have innovation numbers",
465465
)
466+
466467
def test_checkpoint_resumed_run_matches_uninterrupted_run(self):
467468
"""Doc-style example: uninterrupted vs resumed evolution.
468469
@@ -529,6 +530,7 @@ def test_checkpoint_resumed_run_matches_uninterrupted_run(self):
529530
# This example intentionally stops short of asserting that the
530531
# uninterrupted and resumed snapshots are identical, because that
531532
# stronger property is not guaranteed across Python implementations.
533+
532534
# ========== Configuration Handling ==========
533535

534536
def test_checkpoint_restore_with_same_config(self):

tests/test_ctrnn.py

Lines changed: 186 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,201 @@
1+
import os
2+
13
import neat
4+
import pytest
25
from neat.activations import sigmoid_activation
6+
from neat.genes import DefaultConnectionGene, DefaultNodeGene
37

48

5-
def test_basic():
6-
# Create a fully-connected network of two neurons with no external inputs.
9+
def _create_two_neuron_ctrnn():
10+
"""Create the 2-neuron autonomous CTRNN used in the demo-ctrnn example."""
11+
# Fully-connected 2-neuron network with no external inputs.
712
node1_inputs = [(1, 0.9), (2, 0.2)]
813
node2_inputs = [(1, -0.2), (2, 0.9)]
914

10-
node_evals = {1: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -2.75 / 5.0, 1.0, node1_inputs),
11-
2: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -1.75 / 5.0, 1.0, node2_inputs)}
15+
node_evals = {
16+
1: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -2.75 / 5.0, 1.0, node1_inputs),
17+
2: neat.ctrnn.CTRNNNodeEval(0.01, sigmoid_activation, sum, -1.75 / 5.0, 1.0, node2_inputs),
18+
}
1219

1320
net = neat.ctrnn.CTRNN([], [1, 2], node_evals)
1421

15-
init1 = 0.0
16-
init2 = 0.0
22+
# Start both neurons from 0.0, matching the example script.
23+
net.set_node_value(1, 0.0)
24+
net.set_node_value(2, 0.0)
25+
26+
return net
27+
1728

18-
net.set_node_value(1, init1)
19-
net.set_node_value(2, init2)
29+
def test_basic_two_neuron_dynamics():
30+
"""Basic numerical behavior test for a hand-constructed 2-neuron CTRNN."""
31+
net = _create_two_neuron_ctrnn()
2032

21-
times = [0.0]
22-
outputs = [[init1, init2]]
23-
for i in range(1250):
24-
output = net.advance([], 0.002, 0.002)
25-
times.append(net.time_seconds)
33+
outputs = []
34+
num_steps = 1250
35+
dt = 0.002
36+
37+
for _ in range(num_steps):
38+
output = net.advance([], dt, dt)
2639
outputs.append(output)
2740

41+
# Total simulated time should be close to 2.5 seconds (1250 * 0.002).
42+
assert abs(net.time_seconds - 2.5) < 1e-9
43+
44+
# All outputs should remain in [0, 1] due to sigmoid activation.
45+
for o in outputs:
46+
assert 0.0 <= o[0] <= 1.0
47+
assert 0.0 <= o[1] <= 1.0
48+
49+
# Check specific reference values at selected timesteps to guard against
50+
# regressions in the CTRNN integration behavior.
51+
reference = {
52+
0: (0.0, 0.0),
53+
100: (0.3746852284, 0.8115273872),
54+
500: (0.5634208426, 0.1952743492),
55+
1149: (0.4092483263, 0.8024978195),
56+
1249: (0.7531862678, 0.3112381247),
57+
}
58+
tol = 1e-6
59+
for idx, (exp0, exp1) in reference.items():
60+
o = outputs[idx]
61+
assert abs(o[0] - exp0) < tol
62+
assert abs(o[1] - exp1) < tol
63+
64+
65+
def test_reset_and_deterministic_trajectory():
66+
"""CTRNN.reset should zero state and trajectories should be deterministic."""
67+
net = _create_two_neuron_ctrnn()
68+
69+
def run(num_steps):
70+
seq = []
71+
for _ in range(num_steps):
72+
seq.append(net.advance([], 0.002, 0.002))
73+
return seq
74+
75+
first_outputs = run(200)
76+
77+
# State and time should have advanced.
78+
assert net.time_seconds > 0.0
79+
assert any(any(abs(v) > 0.0 for v in layer.values()) for layer in net.values)
80+
81+
# Reset should restore time and all stored values to zero.
82+
net.reset()
83+
assert net.time_seconds == 0.0
84+
assert all(all(value == 0.0 for value in layer.values()) for layer in net.values)
85+
86+
second_outputs = run(200)
87+
88+
# Trajectories from the same initial conditions should match.
89+
assert len(first_outputs) == len(second_outputs)
90+
for o1, o2 in zip(first_outputs, second_outputs):
91+
for v1, v2 in zip(o1, o2):
92+
assert abs(v1 - v2) < 1e-12
93+
94+
95+
def test_advance_input_validation():
96+
"""advance should enforce input length and raise RuntimeError on mismatch."""
97+
# Simple CTRNN with a single input node feeding a single output node.
98+
node_inputs = [(0, 1.0)]
99+
node_evals = {
100+
1: neat.ctrnn.CTRNNNodeEval(1.0, sigmoid_activation, sum, 0.0, 1.0, node_inputs),
101+
}
102+
net = neat.ctrnn.CTRNN([0], [1], node_evals)
103+
104+
# Sanity check: correct-length input works.
105+
net.advance([0.5], 0.1, 0.1)
106+
107+
# Too few inputs.
108+
with pytest.raises(RuntimeError, match="Expected 1 inputs, got 0"):
109+
net.advance([], 0.1, 0.1)
110+
111+
# Too many inputs.
112+
with pytest.raises(RuntimeError, match="Expected 1 inputs, got 2"):
113+
net.advance([0.1, 0.2], 0.1, 0.1)
114+
115+
116+
def test_ctrnn_create_from_genome_prunes_and_builds_expected_structure():
117+
"""CTRNN.create should respect required_for_output and build correct node_evals."""
118+
local_dir = os.path.dirname(__file__)
119+
config_path = os.path.join(local_dir, "test_configuration")
120+
config = neat.Config(
121+
neat.DefaultGenome,
122+
neat.DefaultReproduction,
123+
neat.DefaultSpeciesSet,
124+
neat.DefaultStagnation,
125+
config_path,
126+
)
127+
128+
genome = neat.DefaultGenome(1)
129+
130+
# Manually create nodes: one output (0), one used hidden (1), one unused hidden (2).
131+
node0 = DefaultNodeGene(0)
132+
node0.bias = 0.1
133+
node0.response = 1.0
134+
node0.activation = "sigmoid"
135+
node0.aggregation = "sum"
136+
137+
node1 = DefaultNodeGene(1)
138+
node1.bias = -0.2
139+
node1.response = 1.0
140+
node1.activation = "sigmoid"
141+
node1.aggregation = "sum"
142+
143+
node2 = DefaultNodeGene(2)
144+
node2.bias = 1.5
145+
node2.response = 1.0
146+
node2.activation = "sigmoid"
147+
node2.aggregation = "sum"
148+
149+
genome.nodes[0] = node0
150+
genome.nodes[1] = node1
151+
genome.nodes[2] = node2
152+
153+
# Connections: input -1 -> hidden 1, hidden 1 -> output 0.
154+
conn1_key = (-1, 1)
155+
conn1 = DefaultConnectionGene(conn1_key, innovation=0)
156+
conn1.weight = 0.5
157+
conn1.enabled = True
158+
159+
conn2_key = (1, 0)
160+
conn2 = DefaultConnectionGene(conn2_key, innovation=1)
161+
conn2.weight = 1.5
162+
conn2.enabled = True
163+
164+
genome.connections[conn1_key] = conn1
165+
genome.connections[conn2_key] = conn2
166+
167+
time_constant = 0.01
168+
net = neat.ctrnn.CTRNN.create(genome, config, time_constant)
169+
170+
genome_config = config.genome_config
171+
172+
# Input and output node lists should come from the genome config.
173+
assert net.input_nodes == genome_config.input_keys
174+
assert net.output_nodes == genome_config.output_keys
175+
176+
# Only nodes that are actually required for the outputs should have node_evals.
177+
assert set(net.node_evals.keys()) == {0, 1}
178+
179+
ne_hidden = net.node_evals[1]
180+
assert ne_hidden.time_constant == time_constant
181+
assert ne_hidden.bias == node1.bias
182+
assert ne_hidden.response == node1.response
183+
assert ne_hidden.activation is genome_config.activation_defs.get(node1.activation)
184+
assert ne_hidden.aggregation is genome_config.aggregation_function_defs.get(node1.aggregation)
185+
assert ne_hidden.links == [(-1, 0.5)]
186+
187+
ne_output = net.node_evals[0]
188+
assert ne_output.time_constant == time_constant
189+
assert ne_output.bias == node0.bias
190+
assert ne_output.response == node0.response
191+
assert ne_output.activation is genome_config.activation_defs.get(node0.activation)
192+
assert ne_output.aggregation is genome_config.aggregation_function_defs.get(node0.aggregation)
193+
assert ne_output.links == [(1, 1.5)]
194+
28195

29-
#
30-
#
31-
# def create_simple():
32-
# neurons = [Neuron('INPUT', 1, 0.0, 5.0, 'sigmoid'),
33-
# Neuron('HIDDEN', 2, 0.0, 5.0, 'sigmoid'),
34-
# Neuron('OUTPUT', 3, 0.0, 5.0, 'sigmoid')]
35-
# connections = [(1, 2, 0.5), (1, 3, 0.5), (2, 3, 0.5)]
36-
# map(repr, neurons)
37-
#
38-
# return Network(neurons, connections, 1)
39-
#
40-
#
41-
# def test_manual_network():
42-
# net = create_simple()
43-
# repr(net)
44-
# str(net)
45-
# net.serial_activate([0.04])
46-
# net.parallel_activate([0.04])
47-
# repr(net)
48-
# str(net)
49-
#
50-
#
51-
# def test_evolve():
52-
# test_values = [random.random() for _ in range(10)]
53-
#
54-
# def evaluate_genome(genomes):
55-
# for g in genomes:
56-
# net = ctrnn.create_phenotype(g)
57-
#
58-
# fitness = 0.0
59-
# for t in test_values:
60-
# net.reset()
61-
# output = net.serial_activate([t])
62-
#
63-
# expected = t ** 2
64-
#
65-
# error = output[0] - expected
66-
# fitness -= error ** 2
67-
#
68-
# g.fitness = fitness
69-
#
70-
# # Load the config file, which is assumed to live in
71-
# # the same directory as this script.
72-
# local_dir = os.path.dirname(__file__)
73-
# config = Config(os.path.join(local_dir, 'ctrnn_config'))
74-
# config.node_gene_type = ctrnn.CTNodeGene
75-
# config.prob_mutate_time_constant = 0.1
76-
# config.checkpoint_time_interval = 0.1
77-
# config.checkpoint_gen_interval = 1
78-
#
79-
# pop = population.Population(config)
80-
# pop.run(evaluate_genome, 10)
81-
#
82-
# # Save the winner.
83-
# print('Number of evaluations: {0:d}'.format(pop.total_evaluations))
84-
# winner = pop.statistics.best_genome()
85-
# with open('winner_genome', 'wb') as f:
86-
# pickle.dump(winner, f)
87-
#
88-
# repr(winner)
89-
# str(winner)
90-
#
91-
# for g in winner.node_genes:
92-
# repr(g)
93-
# str(g)
94-
# for g in winner.conn_genes:
95-
# repr(g)
96-
# str(g)
97-
#
98-
#
99-
if __name__ == '__main__':
100-
test_basic()
101-
# test_evolve()
102-
# test_manual_network()
196+
if __name__ == "__main__":
197+
# Allow running this module directly for quick manual checks.
198+
test_basic_two_neuron_dynamics()
199+
test_reset_and_deterministic_trajectory()
200+
test_advance_input_validation()
201+
test_ctrnn_create_from_genome_prunes_and_builds_expected_structure()

0 commit comments

Comments
 (0)