diff --git a/openevolve/database.py b/openevolve/database.py index 08482b89d..8abe2bdc0 100644 --- a/openevolve/database.py +++ b/openevolve/database.py @@ -467,21 +467,13 @@ def sample_from_island( parent = self._sample_from_island_weighted(island_id) sampling_mode = "weighted" - # Select inspirations from the same island + # Select inspirations using the same elite/diversity-aware strategy as sample(). + # Pass the requested island explicitly so parallel workers remain isolated even + # if an archive fallback returns a parent whose metadata points elsewhere. if num_inspirations is None: num_inspirations = 5 # Default for backward compatibility - # Get other programs from the island for inspirations - other_programs = [pid for pid in island_programs if pid != parent.id] - - if len(other_programs) < num_inspirations: - # Not enough programs in island, use what we have - inspiration_ids = other_programs - else: - # Sample inspirations - inspiration_ids = random.sample(other_programs, num_inspirations) - - inspirations = [self.programs[pid] for pid in inspiration_ids if pid in self.programs] + inspirations = self._sample_inspirations(parent, n=num_inspirations, island_id=island_id) logger.debug( f"Sampled parent {parent.id} and {len(inspirations)} inspirations from island {island_id} " @@ -1569,7 +1561,9 @@ def _sample_from_archive_for_island(self, island_id: int) -> Program: parent_id = random.choice(valid_archive) return self.programs[parent_id] - def _sample_inspirations(self, parent: Program, n: int = 5) -> List[Program]: + def _sample_inspirations( + self, parent: Program, n: int = 5, island_id: Optional[int] = None + ) -> List[Program]: """ Sample inspiration programs for the next evolution step. @@ -1579,14 +1573,23 @@ def _sample_inspirations(self, parent: Program, n: int = 5) -> List[Program]: Args: parent: Parent program n: Number of inspirations to sample + island_id: Explicit island to sample from. If omitted, use the + parent program's island metadata. Returns: List of inspiration programs from the current island """ inspirations = [] - # Get the parent's island (should be current_island) - parent_island = parent.metadata.get("island", self.current_island) + # Prefer an explicitly requested island. This matters for + # sample_from_island(), where archive fallback may return a parent whose + # metadata belongs to a different island. + if island_id is None: + parent_island = parent.metadata.get("island", self.current_island) + else: + parent_island = island_id + + parent_island %= len(self.islands) # Get all programs from the current island island_program_ids = list(self.islands[parent_island]) diff --git a/tests/test_sample_from_island_inspirations.py b/tests/test_sample_from_island_inspirations.py new file mode 100644 index 000000000..884853e33 --- /dev/null +++ b/tests/test_sample_from_island_inspirations.py @@ -0,0 +1,135 @@ +"""Regression tests for issue #437: island-aware inspiration sampling.""" + +import unittest +from unittest.mock import patch + +from openevolve.config import Config +from openevolve.database import Program, ProgramDatabase + + +class TestSampleFromIslandInspirations(unittest.TestCase): + def setUp(self): + config = Config() + config.database.in_memory = True + config.database.num_islands = 2 + config.database.exploration_ratio = 1.0 + config.database.exploitation_ratio = 0.0 + self.db = ProgramDatabase(config.database) + + self.parent = Program( + id="parent", + code="def parent(): pass", + metrics={"score": 0.5}, + metadata={"island": 1}, + ) + self.elite = Program( + id="elite", + code="def elite(): pass", + metrics={"score": 1.0}, + metadata={"island": 1}, + ) + + self.db.programs = { + self.parent.id: self.parent, + self.elite.id: self.elite, + } + self.db.islands = [set(), {self.parent.id, self.elite.id}] + self.db.island_best_programs = [None, self.elite.id] + + def test_sample_from_island_reuses_strategy_aware_inspiration_sampler(self): + with ( + patch.object( + self.db, + "_sample_from_island_random", + return_value=self.parent, + ), + patch.object( + self.db, + "_sample_inspirations", + return_value=[self.elite], + ) as sample_inspirations, + ): + parent, inspirations = self.db.sample_from_island( + island_id=1, + num_inspirations=2, + ) + + self.assertIs(parent, self.parent) + self.assertEqual(inspirations, [self.elite]) + sample_inspirations.assert_called_once_with( + self.parent, + n=2, + island_id=1, + ) + + def test_explicit_island_overrides_parent_metadata_for_inspirations(self): + island_zero_best = Program( + id="island-zero-best", + code="def island_zero(): pass", + metrics={"score": 2.0}, + metadata={"island": 0}, + ) + parent_from_zero = Program( + id="parent-from-zero", + code="def parent_zero(): pass", + metrics={"score": 0.1}, + metadata={"island": 0}, + ) + island_one_best = Program( + id="island-one-best", + code="def island_one(): pass", + metrics={"score": 1.0}, + metadata={"island": 1}, + ) + + self.db.programs = { + parent_from_zero.id: parent_from_zero, + island_zero_best.id: island_zero_best, + island_one_best.id: island_one_best, + } + self.db.islands = [ + {parent_from_zero.id, island_zero_best.id}, + {island_one_best.id}, + ] + self.db.island_best_programs = [ + island_zero_best.id, + island_one_best.id, + ] + + inspirations = self.db._sample_inspirations( + parent_from_zero, + n=1, + island_id=1, + ) + + self.assertEqual([program.id for program in inspirations], [island_one_best.id]) + self.assertTrue(all(program.metadata.get("island") == 1 for program in inspirations)) + + def test_zero_inspirations_is_forwarded_without_random_sampling(self): + with ( + patch.object( + self.db, + "_sample_from_island_random", + return_value=self.parent, + ), + patch.object( + self.db, + "_sample_inspirations", + return_value=[], + ) as sample_inspirations, + ): + _, inspirations = self.db.sample_from_island( + island_id=1, + num_inspirations=0, + ) + + self.assertEqual(inspirations, []) + sample_inspirations.assert_called_once_with( + self.parent, + n=0, + island_id=1, + ) + + +if __name__ == "__main__": + unittest.main()