Skip to content

Commit 0b92b9d

Browse files
Fix flaky test_remove_node_deletes_node surfaced by PyPy CI
mutate_delete_node invokes _prune_dangling_nodes after removing the chosen hidden node, so any other hidden node that can no longer reach an output is pruned along with its connections. The existing test asserted an exact one-node reduction, which only held when the random choice happened to pick a non-articulation node. Without a seed, the outcome depends on hash randomization — about 30% flaky on CPython and consistently failing on all four PyPy versions in GH Actions run 24268291745. Relax the two tests in this file that assumed exact-count deletion (test_remove_node_deletes_node and test_remove_node_deletes_associated_connections), and add test_remove_node_deletes_exactly_one which builds a genome by hand with two parallel independent input->hidden->output paths so no cascade is possible and the exact-one invariant can be checked deterministically. Verified: 15/15 PYTHONHASHSEED variations of the mutations test file clean; full suite 630 passed / 6 skipped.
1 parent 423118e commit 0b92b9d

File tree

1 file changed

+65
-13
lines changed

1 file changed

+65
-13
lines changed

tests/test_genome_mutations.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,13 @@ def test_add_connection_with_innovation_tracking(self):
401401

402402
def test_remove_node_deletes_node(self):
403403
"""
404-
Test that remove_node mutation deletes a node.
404+
Test that remove_node mutation deletes at least one node.
405405
406-
Should remove exactly one node from the genome.
406+
After deleting the chosen hidden node, mutate_delete_node also
407+
prunes any other hidden nodes that can no longer reach an output,
408+
so the reduction may be greater than one. See
409+
test_remove_node_deletes_exactly_one for an exact-count check on
410+
a genome constructed to have no prunable cascades.
407411
"""
408412
genome = self.create_minimal_genome()
409413

@@ -419,8 +423,53 @@ def test_remove_node_deletes_node(self):
419423
if initial_count > len(self.config.genome_config.output_keys):
420424
genome.mutate_delete_node(self.config.genome_config)
421425

422-
self.assertEqual(len(genome.nodes), initial_count - 1,
423-
"Should remove one node")
426+
self.assertLess(len(genome.nodes), initial_count,
427+
"Should remove at least one node")
428+
self.assertGreaterEqual(
429+
len(genome.nodes), len(self.config.genome_config.output_keys),
430+
"Output nodes must never be removed")
431+
432+
def test_remove_node_deletes_exactly_one(self):
433+
"""
434+
Test that remove_node removes exactly one node when no cascading
435+
prune is possible.
436+
437+
Constructs a genome where every hidden node has its own independent
438+
input->hidden->output path, so deleting any single hidden node leaves
439+
the remaining hidden node fully connected to the output and
440+
_prune_dangling_nodes has nothing to remove.
441+
"""
442+
config = self.config.genome_config
443+
genome = neat.DefaultGenome(1)
444+
445+
# Output node 0 plus two parallel hidden nodes 1 and 2.
446+
for node_id in (0, 1, 2):
447+
genome.nodes[node_id] = genome.create_node(config, node_id)
448+
449+
# Each hidden node gets its own full path: both inputs -> hidden -> output.
450+
edges = [
451+
(-1, 1), (-2, 1), (1, 0),
452+
(-1, 2), (-2, 2), (2, 0),
453+
]
454+
for innovation, (in_id, out_id) in enumerate(edges, start=1):
455+
conn = genome.create_connection(config, in_id, out_id, innovation)
456+
genome.connections[conn.key] = conn
457+
458+
initial_count = len(genome.nodes)
459+
self.assertEqual(initial_count, 3)
460+
461+
genome.mutate_delete_node(config)
462+
463+
self.assertEqual(len(genome.nodes), initial_count - 1,
464+
"Should remove exactly one node when no cascade is possible")
465+
# The surviving hidden node must still have both its input edges and
466+
# its output edge intact.
467+
surviving_hidden = [k for k in genome.nodes if k not in config.output_keys]
468+
self.assertEqual(len(surviving_hidden), 1)
469+
h = surviving_hidden[0]
470+
self.assertIn((-1, h), genome.connections)
471+
self.assertIn((-2, h), genome.connections)
472+
self.assertIn((h, 0), genome.connections)
424473

425474
def test_remove_node_deletes_associated_connections(self):
426475
"""
@@ -450,15 +499,18 @@ def test_remove_node_deletes_associated_connections(self):
450499
removed_nodes = nodes_before - nodes_after
451500

452501
if removed_nodes:
453-
# Exactly one node should be removed
454-
self.assertEqual(len(removed_nodes), 1, "Should remove exactly one node")
455-
removed_node = list(removed_nodes)[0]
456-
457-
# All connections involving this node should be gone
458-
for conn_key, conn in connections_before.items():
459-
if removed_node in conn_key:
460-
self.assertNotIn(conn_key, genome.connections,
461-
"Connections to deleted node should be removed")
502+
# At least one node should be removed. mutate_delete_node may also
503+
# prune hidden nodes that can no longer reach an output, so the
504+
# removed set can contain more than the explicitly chosen node.
505+
self.assertGreaterEqual(len(removed_nodes), 1,
506+
"Should remove at least one node")
507+
508+
# For every removed node, all connections touching it must be gone.
509+
for removed_node in removed_nodes:
510+
for conn_key in connections_before:
511+
if removed_node in conn_key:
512+
self.assertNotIn(conn_key, genome.connections,
513+
"Connections to deleted node should be removed")
462514

463515
def test_remove_node_protects_output_nodes(self):
464516
"""

0 commit comments

Comments
 (0)