diff --git a/evoagentx/optimizers/map_elites_optimizer.py b/evoagentx/optimizers/map_elites_optimizer.py index f135733b..faeb5b57 100644 --- a/evoagentx/optimizers/map_elites_optimizer.py +++ b/evoagentx/optimizers/map_elites_optimizer.py @@ -48,8 +48,7 @@ def __init__( self.exploration_ratio = exploration_ratio self.random_seed = random_seed - if random_seed is not None: - random.seed(random_seed) + self._rng = random.Random(random_seed) missing = [d for d in self.feature_dimensions if d not in self.feature_ranges] if missing: @@ -70,8 +69,8 @@ def optimize(self): best_entry: Optional[ArchiveEntry] = None for step in range(self.n_iterations): - if archive and random.random() > self.exploration_ratio: - parent = random.choice(list(archive.values())) + if archive and self._rng.random() > self.exploration_ratio: + parent = self._rng.choice(list(archive.values())) cfg = self._mutate_cfg(parent.cfg) source = "mutate" else: @@ -159,17 +158,17 @@ def _bin_index(value: float, lo: float, hi: float, bins: int) -> int: return idx def _random_cfg(self) -> Dict[str, Any]: - return {name: copy.deepcopy(random.choice(values)) for name, values in self.search_space.items()} + return {name: copy.deepcopy(self._rng.choice(values)) for name, values in self.search_space.items()} def _mutate_cfg(self, parent_cfg: Dict[str, Any]) -> Dict[str, Any]: if not self.search_space: return copy.deepcopy(parent_cfg) cfg = copy.deepcopy(parent_cfg) - key = random.choice(list(self.search_space.keys())) + key = self._rng.choice(list(self.search_space.keys())) choices = self.search_space[key] if len(choices) <= 1: return cfg current = cfg.get(key) alternatives = [v for v in choices if v != current] - cfg[key] = copy.deepcopy(random.choice(alternatives)) if alternatives else current + cfg[key] = copy.deepcopy(self._rng.choice(alternatives)) if alternatives else current return cfg diff --git a/tests/src/optimizers/test_map_elites_optimizer.py b/tests/src/optimizers/test_map_elites_optimizer.py index f40ed215..8a2de029 100644 --- a/tests/src/optimizers/test_map_elites_optimizer.py +++ b/tests/src/optimizers/test_map_elites_optimizer.py @@ -54,3 +54,56 @@ def evaluator(output: Dict[str, Any]) -> Dict[str, Any]: assert len(archive) >= 5 assert best.fitness == 8.0 assert best_cfg["x"] + best_cfg["y"] == 8 + + +def make_seeded_optimizer(evaluator): + """Build an optimizer with no dependency on the global entry point.""" + program = DummyProgram() + registry = ParamRegistry() + registry.track(program, "x", name="x") + registry.track(program, "y", name="y") + return MapElitesOptimizer( + registry=registry, + evaluator=evaluator, + search_space={"x": list(range(5)), "y": list(range(5))}, + feature_dimensions=["complexity"], + feature_ranges={"complexity": (0.0, 4.0)}, + n_iterations=20, + random_seed=7, + program=program.run, + ) + + +def test_seeded_search_does_not_change_global_random_state(): + """Seeding and running an optimizer must not reseed unrelated callers.""" + import random + + before = random.getstate() + opt = make_seeded_optimizer(lambda out: {"score": out["x"] + out["y"], "complexity": out["x"]}) + opt.optimize() + + assert random.getstate() == before + + +def test_seeded_search_is_independent_of_evaluator_random_draws(): + """Evaluator randomness must not change the sequence of candidate mutations.""" + import random + + def evaluator(output): + """Score a candidate without drawing random numbers.""" + return {"score": output["x"] + output["y"], "complexity": output["x"]} + + def noisy_evaluator(output): + """Return the same score after consuming unrelated randomness.""" + random.random() + return evaluator(output) + + baseline = make_seeded_optimizer(evaluator) + noisy = make_seeded_optimizer(noisy_evaluator) + + _, baseline_result = baseline.optimize() + _, noisy_result = noisy.optimize() + + assert [step["cfg"] for step in baseline_result["history"]] == [ + step["cfg"] for step in noisy_result["history"] + ]