Skip to content

Commit 65cbbe8

Browse files
authored
Fix island-based evolution not distributing programs across islands (#392)
* Create test_island_child_placement.py * Pass target_island to database.add Fix issue #391 by ensuring children are placed in the intended target island instead of inheriting the parent's island. Add target_island to SerializableResult, capture sampling_island in the worker, and pass result.target_island into database.add when inserting child programs. Update tests to reflect the fixed behavior (children go to the target island) and add regression tests that demonstrate the old buggy behavior when target_island is not provided and the correct behavior when it is.
1 parent e13e7f6 commit 65cbbe8

File tree

2 files changed

+342
-3
lines changed

2 files changed

+342
-3
lines changed

openevolve/process_parallel.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class SerializableResult:
3333
artifacts: Optional[Dict[str, Any]] = None
3434
iteration: int = 0
3535
error: Optional[str] = None
36+
target_island: Optional[int] = None # Island where child should be placed
3637

3738

3839
def _worker_init(config_dict: dict, evaluation_file: str, parent_env: dict = None) -> None:
@@ -312,6 +313,9 @@ def _run_iteration_worker(
312313

313314
iteration_time = time.time() - iteration_start
314315

316+
# Get target island from snapshot (where child should be placed)
317+
target_island = db_snapshot.get("sampling_island")
318+
315319
return SerializableResult(
316320
child_program_dict=child_program.to_dict(),
317321
parent_id=parent.id,
@@ -320,6 +324,7 @@ def _run_iteration_worker(
320324
llm_response=llm_response,
321325
artifacts=artifacts,
322326
iteration=iteration,
327+
target_island=target_island,
323328
)
324329

325330
except Exception as e:
@@ -554,9 +559,14 @@ async def run_evolution(
554559
# Reconstruct program from dict
555560
child_program = Program(**result.child_program_dict)
556561

557-
# Add to database (will auto-inherit parent's island)
558-
# No need to specify target_island - database will handle parent island inheritance
559-
self.database.add(child_program, iteration=completed_iteration)
562+
# Add to database with explicit target_island to ensure proper island placement
563+
# This fixes issue #391: children should go to the target island, not inherit
564+
# from the parent (which may be from a different island due to fallback sampling)
565+
self.database.add(
566+
child_program,
567+
iteration=completed_iteration,
568+
target_island=result.target_island,
569+
)
560570

561571
# Store artifacts
562572
if result.artifacts:
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""
2+
Tests for verifying child programs are placed in the correct target island.
3+
4+
This test specifically catches the bug where children inherit their parent's island
5+
instead of being placed in the target island that was requested for the iteration.
6+
"""
7+
8+
import unittest
9+
10+
from openevolve.config import Config, DatabaseConfig
11+
from openevolve.database import ProgramDatabase, Program
12+
13+
14+
class TestIslandChildPlacement(unittest.TestCase):
15+
"""Test that child programs are placed in the correct island"""
16+
17+
def setUp(self):
18+
"""Set up test database with multiple islands"""
19+
config = Config()
20+
config.database.num_islands = 3
21+
config.database.population_size = 100
22+
self.db = ProgramDatabase(config.database)
23+
24+
def test_child_inherits_parent_island_when_no_target_specified(self):
25+
"""Test that child inherits parent's island when no target_island is given"""
26+
# Add parent to island 0
27+
parent = Program(
28+
id="parent_0",
29+
code="def parent(): pass",
30+
generation=0,
31+
metrics={"combined_score": 0.5},
32+
)
33+
self.db.add(parent, target_island=0)
34+
35+
# Add child without specifying target_island
36+
child = Program(
37+
id="child_0",
38+
code="def child(): pass",
39+
generation=1,
40+
parent_id="parent_0",
41+
metrics={"combined_score": 0.6},
42+
)
43+
self.db.add(child) # No target_island specified
44+
45+
# Child should inherit parent's island (island 0)
46+
self.assertEqual(child.metadata.get("island"), 0)
47+
self.assertIn("child_0", self.db.islands[0])
48+
49+
def test_child_placed_in_target_island_when_specified(self):
50+
"""Test that child is placed in target_island when explicitly specified"""
51+
# Add parent to island 0
52+
parent = Program(
53+
id="parent_1",
54+
code="def parent(): pass",
55+
generation=0,
56+
metrics={"combined_score": 0.5},
57+
)
58+
self.db.add(parent, target_island=0)
59+
60+
# Add child with explicit target_island=2
61+
child = Program(
62+
id="child_1",
63+
code="def child(): pass",
64+
generation=1,
65+
parent_id="parent_1",
66+
metrics={"combined_score": 0.6},
67+
)
68+
self.db.add(child, target_island=2)
69+
70+
# Child should be in island 2, NOT island 0
71+
self.assertEqual(child.metadata.get("island"), 2)
72+
self.assertIn("child_1", self.db.islands[2])
73+
self.assertNotIn("child_1", self.db.islands[0])
74+
75+
76+
class TestEmptyIslandChildPlacement(unittest.TestCase):
77+
"""
78+
Test the critical bug: when sampling from an empty island falls back to
79+
another island's parent, the child should still go to the TARGET island.
80+
"""
81+
82+
def setUp(self):
83+
"""Set up test database with programs only in island 0"""
84+
config = Config()
85+
config.database.num_islands = 3
86+
config.database.population_size = 100
87+
self.db = ProgramDatabase(config.database)
88+
89+
# Add programs ONLY to island 0
90+
for i in range(5):
91+
program = Program(
92+
id=f"island0_prog_{i}",
93+
code=f"def func_{i}(): pass",
94+
generation=0,
95+
metrics={"combined_score": 0.5 + i * 0.1},
96+
)
97+
self.db.add(program, target_island=0)
98+
99+
# Verify setup: island 0 has programs, islands 1 and 2 are empty
100+
self.assertGreater(len(self.db.islands[0]), 0)
101+
self.assertEqual(len(self.db.islands[1]), 0)
102+
self.assertEqual(len(self.db.islands[2]), 0)
103+
104+
def test_sample_from_empty_island_returns_fallback_parent(self):
105+
"""Test that sampling from empty island falls back to available programs"""
106+
# Sample from empty island 1
107+
parent, inspirations = self.db.sample_from_island(island_id=1)
108+
109+
# Should return a parent (from island 0 via fallback)
110+
self.assertIsNotNone(parent)
111+
# Parent is from island 0
112+
self.assertEqual(parent.metadata.get("island"), 0)
113+
114+
def test_child_should_go_to_target_island_not_parent_island(self):
115+
"""
116+
CRITICAL TEST: This tests the fix for issue #391.
117+
118+
When we want to add a child to island 1 (empty), but the parent came
119+
from island 0 (via fallback sampling), the child should still be
120+
placed in island 1 (the TARGET), not island 0 (the parent's island).
121+
122+
The fix: process_parallel.py now passes target_island to database.add()
123+
"""
124+
target_island = 1 # We want to add child to island 1
125+
126+
# Sample from empty island 1 - will fall back to island 0
127+
parent, inspirations = self.db.sample_from_island(island_id=target_island)
128+
129+
# Parent is from island 0 (the only island with programs)
130+
self.assertEqual(parent.metadata.get("island"), 0)
131+
132+
# Create a child program
133+
child = Program(
134+
id="child_for_island_1",
135+
code="def evolved(): pass",
136+
generation=1,
137+
parent_id=parent.id,
138+
metrics={"combined_score": 0.8},
139+
)
140+
141+
# FIX: Pass target_island explicitly (this is what process_parallel.py now does)
142+
self.db.add(child, target_island=target_island)
143+
144+
# Child should be in island 1 (target), not island 0 (parent's island)
145+
self.assertEqual(
146+
child.metadata.get("island"), 1,
147+
"Child should be in target island 1, not parent's island 0."
148+
)
149+
self.assertIn("child_for_island_1", self.db.islands[1])
150+
151+
def test_explicit_target_island_overrides_parent_inheritance(self):
152+
"""Test that explicit target_island works even with fallback parent"""
153+
# Sample from empty island 2 - will fall back to island 0
154+
parent, inspirations = self.db.sample_from_island(island_id=2)
155+
156+
# Parent is from island 0
157+
self.assertEqual(parent.metadata.get("island"), 0)
158+
159+
# Create child and explicitly specify target island
160+
child = Program(
161+
id="child_for_island_2",
162+
code="def evolved(): pass",
163+
generation=1,
164+
parent_id=parent.id,
165+
metrics={"combined_score": 0.8},
166+
)
167+
168+
# With explicit target_island, child should go to island 2
169+
self.db.add(child, target_island=2)
170+
171+
# This should work - explicit target_island is respected
172+
self.assertEqual(child.metadata.get("island"), 2)
173+
self.assertIn("child_for_island_2", self.db.islands[2])
174+
175+
176+
class TestIslandPopulationGrowth(unittest.TestCase):
177+
"""
178+
Test that simulates multiple evolution iterations and checks
179+
that all islands eventually get populated.
180+
"""
181+
182+
def setUp(self):
183+
"""Set up test database"""
184+
config = Config()
185+
config.database.num_islands = 3
186+
config.database.population_size = 100
187+
self.db = ProgramDatabase(config.database)
188+
189+
def test_islands_should_all_get_populated(self):
190+
"""
191+
Simulate evolution and verify all islands get programs.
192+
193+
With the fix for issue #391, children are placed in the target island
194+
even when the parent came from a different island (via fallback sampling).
195+
"""
196+
# Start with initial program in island 0 only
197+
initial = Program(
198+
id="initial",
199+
code="def initial(): pass",
200+
generation=0,
201+
metrics={"combined_score": 0.5},
202+
)
203+
self.db.add(initial, target_island=0)
204+
205+
# Simulate 9 iterations, targeting islands in round-robin fashion
206+
for i in range(9):
207+
target_island = i % 3 # 0, 1, 2, 0, 1, 2, 0, 1, 2
208+
209+
# Sample from target island (may fall back if empty)
210+
parent, _ = self.db.sample_from_island(island_id=target_island)
211+
212+
# Create child
213+
child = Program(
214+
id=f"child_{i}",
215+
code=f"def child_{i}(): pass",
216+
generation=1,
217+
parent_id=parent.id,
218+
metrics={"combined_score": 0.5 + i * 0.05},
219+
)
220+
221+
# FIX: Pass target_island explicitly (this is what process_parallel.py now does)
222+
self.db.add(child, target_island=target_island)
223+
224+
# Check island populations
225+
island_sizes = [len(self.db.islands[i]) for i in range(3)]
226+
227+
# With the fix, programs should be distributed across all islands
228+
self.assertGreater(
229+
island_sizes[1], 0,
230+
f"Island 1 should have programs but has {island_sizes[1]}. "
231+
f"All islands: {island_sizes}."
232+
)
233+
self.assertGreater(
234+
island_sizes[2], 0,
235+
f"Island 2 should have programs but has {island_sizes[2]}. "
236+
f"All islands: {island_sizes}."
237+
)
238+
239+
# With the fix, all islands should have at least 1 program
240+
# (some programs may be deduplicated, but distribution should happen)
241+
for i, size in enumerate(island_sizes):
242+
self.assertGreaterEqual(size, 1, f"Island {i} should have at least 1 program")
243+
244+
245+
class TestRegressionOldBehavior(unittest.TestCase):
246+
"""
247+
Regression tests to ensure we don't revert to the old buggy behavior.
248+
These tests verify that NOT passing target_island causes the bug.
249+
"""
250+
251+
def setUp(self):
252+
"""Set up test database"""
253+
config = Config()
254+
config.database.num_islands = 3
255+
config.database.population_size = 100
256+
self.db = ProgramDatabase(config.database)
257+
258+
def test_without_target_island_child_inherits_parent(self):
259+
"""
260+
Verify that without explicit target_island, child inherits parent's island.
261+
This is the OLD buggy behavior that we need to avoid in process_parallel.py.
262+
"""
263+
# Add parent to island 0
264+
parent = Program(
265+
id="parent",
266+
code="def parent(): pass",
267+
generation=0,
268+
metrics={"combined_score": 0.5},
269+
)
270+
self.db.add(parent, target_island=0)
271+
272+
# Sample from empty island 2 (will fall back to island 0)
273+
sampled_parent, _ = self.db.sample_from_island(island_id=2)
274+
self.assertEqual(sampled_parent.metadata.get("island"), 0)
275+
276+
# Create child WITHOUT passing target_island (old buggy behavior)
277+
child = Program(
278+
id="child",
279+
code="def child(): pass",
280+
generation=1,
281+
parent_id=sampled_parent.id,
282+
metrics={"combined_score": 0.6},
283+
)
284+
self.db.add(child) # No target_island!
285+
286+
# Without target_island, child inherits parent's island (0), not target (2)
287+
# This is the BUG - child should be in island 2 but ends up in island 0
288+
self.assertEqual(
289+
child.metadata.get("island"), 0,
290+
"Without target_island, child incorrectly inherits parent's island"
291+
)
292+
293+
def test_with_target_island_child_goes_to_target(self):
294+
"""
295+
Verify that WITH explicit target_island, child goes to target island.
296+
This is the FIXED behavior implemented in process_parallel.py.
297+
"""
298+
# Add parent to island 0
299+
parent = Program(
300+
id="parent2",
301+
code="def parent(): pass",
302+
generation=0,
303+
metrics={"combined_score": 0.5},
304+
)
305+
self.db.add(parent, target_island=0)
306+
307+
# Sample from empty island 2 (will fall back to island 0)
308+
sampled_parent, _ = self.db.sample_from_island(island_id=2)
309+
self.assertEqual(sampled_parent.metadata.get("island"), 0)
310+
311+
# Create child WITH target_island (fixed behavior)
312+
child = Program(
313+
id="child2",
314+
code="def child(): pass",
315+
generation=1,
316+
parent_id=sampled_parent.id,
317+
metrics={"combined_score": 0.6},
318+
)
319+
self.db.add(child, target_island=2) # Explicit target!
320+
321+
# With target_island, child goes to island 2 (correct)
322+
self.assertEqual(
323+
child.metadata.get("island"), 2,
324+
"With target_island, child should go to target island"
325+
)
326+
327+
328+
if __name__ == "__main__":
329+
unittest.main()

0 commit comments

Comments
 (0)