From be8ad50e869f9a78d7b9ca3be89a30e86125ae27 Mon Sep 17 00:00:00 2001 From: Weber Meng Date: Thu, 24 Sep 2026 15:46:30 +0800 Subject: [PATCH 1/2] feat: add pluggable island selection and population strategies (#464) --- README.md | 43 +++ openevolve/__init__.py | 26 +- openevolve/api.py | 37 ++- openevolve/controller.py | 10 +- openevolve/database.py | 488 +++++++++++++++++++++--------- openevolve/population.py | 67 ++++ openevolve/process_parallel.py | 125 ++++++-- openevolve/selection.py | 27 ++ tests/test_island_selector.py | 106 +++++++ tests/test_population_strategy.py | 405 +++++++++++++++++++++++++ 10 files changed, 1140 insertions(+), 194 deletions(-) create mode 100644 openevolve/population.py create mode 100644 openevolve/selection.py create mode 100644 tests/test_island_selector.py create mode 100644 tests/test_population_strategy.py diff --git a/README.md b/README.md index 785a1d5804..afaebe27de 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,49 @@ result = evolve_function( print(f"Evolved sorting algorithm: {result.best_code}") ``` +To experiment with island scheduling, pass an optional selector to `run_evolution`: + +```python +def select_island(context): + # Schedule the next iteration on the island with the fewest pending tasks. + return min(range(len(context.islands)), key=lambda i: context.pending_counts[i]) + +result = run_evolution( + initial_program="program.py", + evaluator="evaluator.py", + island_selector=select_island, +) +``` + +The selector receives a read-only `IslandSelectionContext` with the iteration number, +pending task counts, and each island's population size, best and average score, diversity, and +generation. It must return an island ID from `0` to `num_islands - 1`. It runs in the +main process before each iteration is submitted. Without a selector, OpenEvolve uses +its existing balanced island scheduling. + +Population management can also use decision hooks. For example, this policy lets a +candidate replace an island's MAP-Elites cell occupant when their scores tie: + +```python +from openevolve import PopulationStrategy, run_evolution + +strategy = PopulationStrategy( + replace_cell=lambda state, candidate, incumbent, island: ( + candidate.metrics["combined_score"] >= incumbent.metrics["combined_score"] + ), +) +result = run_evolution("program.py", "evaluator.py", population_strategy=strategy) +``` + +Other optional hooks are `admit(state, candidate, island) -> bool`, +`archive(state, candidate) -> ArchiveDecision`, +`evict(state, count, protected_ids) -> sequence of program IDs`, +`migration_due(state) -> bool`, and `migrate(state) -> sequence of MigrationMove`. +`state` is a detached `PopulationSnapshot` of programs, island memberships, cell owners, +the archive, population limits, and migration generations. Unspecified hooks retain +the existing rules. `ProgramDatabase` validates decisions and applies all changes; +the strategy never receives the mutable database. The initial seed cannot be rejected. + **Prefer Docker?** See the [Installation & Setup](#installation--setup) section for Docker options. ## See It In Action diff --git a/openevolve/__init__.py b/openevolve/__init__.py index 9a060ff333..629749b97f 100644 --- a/openevolve/__init__.py +++ b/openevolve/__init__.py @@ -3,19 +3,35 @@ """ from openevolve._version import __version__ -from openevolve.config import Config -from openevolve.controller import OpenEvolve from openevolve.api import ( - run_evolution, - evolve_function, + EvolutionResult, evolve_algorithm, evolve_code, - EvolutionResult, + evolve_function, + run_evolution, +) +from openevolve.config import Config +from openevolve.controller import OpenEvolve +from openevolve.population import ( + ArchiveDecision, + MigrationMove, + PopulationSnapshot, + PopulationStrategy, + ProgramState, ) +from openevolve.selection import IslandSelectionContext, IslandSelector, IslandState __all__ = [ "Config", "OpenEvolve", + "ArchiveDecision", + "MigrationMove", + "PopulationSnapshot", + "PopulationStrategy", + "ProgramState", + "IslandSelectionContext", + "IslandSelector", + "IslandState", "__version__", "run_evolution", "evolve_function", diff --git a/openevolve/api.py b/openevolve/api.py index 9452391763..4e34eb69cc 100644 --- a/openevolve/api.py +++ b/openevolve/api.py @@ -3,17 +3,19 @@ """ import asyncio -import tempfile +import inspect import os +import tempfile import uuid -import inspect -from typing import Union, Callable, Optional, List, Dict, Any, Tuple from dataclasses import dataclass from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from openevolve.config import Config, LLMModelConfig, load_config from openevolve.controller import OpenEvolve -from openevolve.config import Config, load_config, LLMModelConfig from openevolve.database import Program +from openevolve.population import PopulationStrategy +from openevolve.selection import IslandSelector @dataclass @@ -39,6 +41,8 @@ def run_evolution( cleanup: bool = True, target_score: Optional[float] = None, checkpoint_path: Optional[str] = None, + island_selector: Optional[IslandSelector] = None, + population_strategy: Optional[PopulationStrategy] = None, ) -> EvolutionResult: """ Run evolution with flexible inputs - the main library API @@ -58,6 +62,10 @@ def run_evolution( iterations: Number of iterations (overrides config) output_dir: Output directory (None for temp directory) cleanup: If True, clean up temp files after evolution + island_selector: Optional callable that chooses an island ID from an + IslandSelectionContext. When omitted, the existing balanced scheduling is used. + population_strategy: Optional PopulationStrategy with decision hooks for + admission, cell replacement, archive membership, eviction, and migration. Returns: EvolutionResult with best program and metrics @@ -92,7 +100,18 @@ def my_evaluator(program_path): ) """ return asyncio.run( - _run_evolution_async(initial_program, evaluator, config, iterations, output_dir, cleanup, target_score, checkpoint_path) + _run_evolution_async( + initial_program, + evaluator, + config, + iterations, + output_dir, + cleanup, + target_score, + checkpoint_path, + island_selector, + population_strategy=population_strategy, + ) ) @@ -105,6 +124,8 @@ async def _run_evolution_async( cleanup: bool, target_score: Optional[float] = None, checkpoint_path: Optional[str] = None, + island_selector: Optional[IslandSelector] = None, + population_strategy: Optional[PopulationStrategy] = None, ) -> EvolutionResult: """Async implementation of run_evolution""" @@ -158,9 +179,13 @@ async def _run_evolution_async( evaluation_file=evaluator_path, config=config_obj, output_dir=actual_output_dir, + island_selector=island_selector, + population_strategy=population_strategy, ) - best_program = await controller.run(iterations=iterations,target_score=target_score,checkpoint_path=checkpoint_path) + best_program = await controller.run( + iterations=iterations, target_score=target_score, checkpoint_path=checkpoint_path + ) # Prepare result best_score = 0.0 diff --git a/openevolve/controller.py b/openevolve/controller.py index a3f096bf8b..b05200c45a 100644 --- a/openevolve/controller.py +++ b/openevolve/controller.py @@ -17,8 +17,10 @@ from openevolve.evaluator import Evaluator from openevolve.evolution_trace import EvolutionTracer from openevolve.llm.ensemble import LLMEnsemble +from openevolve.population import PopulationStrategy from openevolve.process_parallel import ProcessParallelController from openevolve.prompt.sampler import PromptSampler +from openevolve.selection import IslandSelector from openevolve.utils.code_utils import extract_code_language from openevolve.utils.format_utils import format_improvement_safe, format_metrics_safe @@ -45,9 +47,12 @@ def __init__( evaluation_file: str, config: Config, output_dir: Optional[str] = None, + island_selector: Optional[IslandSelector] = None, + population_strategy: Optional[PopulationStrategy] = None, ): # Load configuration (loaded in main_async) self.config = config + self.island_selector = island_selector # Set up output directory self.output_dir = output_dir or os.path.join( @@ -125,7 +130,9 @@ def __init__( self.config.database.random_seed = self.config.random_seed self.config.database.novelty_llm = self.llm_ensemble - self.database = ProgramDatabase(self.config.database) + self.database = ProgramDatabase( + self.config.database, population_strategy=population_strategy + ) self.evaluator = Evaluator( self.config.evaluator, @@ -314,6 +321,7 @@ async def run( self.database, self.evolution_tracer, file_suffix=self.config.file_suffix, + island_selector=self.island_selector, ) # Set up signal handlers for graceful shutdown diff --git a/openevolve/database.py b/openevolve/database.py index 8abe2bdc0a..eb82da2de7 100644 --- a/openevolve/database.py +++ b/openevolve/database.py @@ -3,6 +3,7 @@ """ import base64 +import copy import json import logging import os @@ -10,7 +11,10 @@ import shutil import time import uuid +from contextlib import contextmanager from dataclasses import asdict, dataclass, field, fields +from numbers import Integral +from types import MappingProxyType # FileLock removed - no longer needed with threaded parallel processing from typing import Any, Dict, List, Optional, Set, Tuple, Union @@ -18,12 +22,29 @@ import numpy as np from openevolve.config import DatabaseConfig +from openevolve.population import ( + ArchiveDecision, + MigrationMove, + PopulationSnapshot, + PopulationStrategy, + ProgramState, +) from openevolve.utils.code_utils import calculate_edit_distance -from openevolve.utils.metrics_utils import safe_numeric_average, get_fitness_score +from openevolve.utils.metrics_utils import get_fitness_score, safe_numeric_average logger = logging.getLogger(__name__) +@contextmanager +def _strategy_boundary(): + """Tag strategy failures without replacing their exception chain.""" + try: + yield + except Exception as exc: + exc._openevolve_strategy_error = True + raise + + def _safe_sum_metrics(metrics: Dict[str, Any]) -> float: """Safely sum only numeric metric values, ignoring strings and other types""" numeric_values = [ @@ -121,8 +142,11 @@ class ProgramDatabase: It also tracks the absolute best program separately to ensure it's never lost. """ - def __init__(self, config: DatabaseConfig): + def __init__( + self, config: DatabaseConfig, population_strategy: Optional[PopulationStrategy] = None + ): self.config = config + self.population_strategy = population_strategy # In-memory program storage self.programs: Dict[str, Program] = {} @@ -210,9 +234,40 @@ def __init__(self, config: DatabaseConfig): ) self.similarity_threshold = config.similarity_threshold + @staticmethod + def _program_state(program: Program) -> ProgramState: + return ProgramState( + id=program.id, + code=program.code, + metrics=MappingProxyType(copy.deepcopy(program.metrics)), + generation=program.generation, + parent_id=program.parent_id, + metadata=MappingProxyType(copy.deepcopy(program.metadata)), + ) + + def _population_snapshot(self) -> PopulationSnapshot: + """Detach strategy input from mutable database collections.""" + return PopulationSnapshot( + programs=MappingProxyType( + {pid: self._program_state(program) for pid, program in self.programs.items()} + ), + islands=tuple(frozenset(island) for island in self.islands), + feature_maps=tuple(MappingProxyType(dict(grid)) for grid in self.island_feature_maps), + archive=frozenset(self.archive), + best_program_id=self.best_program_id, + population_limit=self.config.population_size, + archive_limit=self.config.archive_size, + feature_dimensions=tuple(self.config.feature_dimensions), + generations=tuple(self.island_generations), + last_migration_generation=self.last_migration_generation, + migration_interval=self.migration_interval, + migration_rate=self.migration_rate, + last_iteration=self.last_iteration, + ) + def add( self, program: Program, iteration: int = None, target_island: Optional[int] = None - ) -> str: + ) -> Optional[str]: """ Add a program to the database @@ -222,20 +277,59 @@ def add( target_island: Specific island to add to (auto-detects parent's island if None) Returns: - Program ID + Program ID, or None when the admission strategy rejects the candidate """ - # Store the program - # If iteration is provided, update the program's iteration_found - if iteration is not None: - program.iteration_found = iteration - # Update last_iteration if needed - self.last_iteration = max(self.last_iteration, iteration) - - self.programs[program.id] = program - - # Calculate feature coordinates for MAP-Elites - feature_coords = self._calculate_feature_coords(program) - + if self.population_strategy is None or not any( + ( + self.population_strategy.replace_cell, + self.population_strategy.archive, + self.population_strategy.evict, + ) + ): + return self._add(program, iteration, target_island) + + # Strategy decisions can fail after provisional writes. Keep the mutable + # population state atomic without copying Program objects or the LLM client. + before = ( + self.programs.copy(), + [island.copy() for island in self.islands], + [grid.copy() for grid in self.island_feature_maps], + self.archive.copy(), + self.best_program_id, + self.island_best_programs.copy(), + self.last_iteration, + copy.deepcopy(self.feature_stats), + copy.deepcopy(self.diversity_cache), + self.diversity_reference_set.copy(), + program.iteration_found, + program.embedding, + copy.deepcopy(program.metadata), + ) + try: + return self._add(program, iteration, target_island) + except Exception: + ( + self.programs, + self.islands, + self.island_feature_maps, + self.archive, + self.best_program_id, + self.island_best_programs, + self.last_iteration, + self.feature_stats, + self.diversity_cache, + self.diversity_reference_set, + program.iteration_found, + program.embedding, + metadata, + ) = before + program.metadata.clear() + program.metadata.update(metadata) + raise + + def _add( + self, program: Program, iteration: int = None, target_island: Optional[int] = None + ) -> Optional[str]: # Determine target island # If target_island is not specified and program has a parent, inherit parent's island if target_island is None and program.parent_id: @@ -266,6 +360,29 @@ def add( island_idx = island_idx % len(self.islands) # Ensure valid island + if self.population_strategy and self.population_strategy.admit: + snapshot = self._population_snapshot() + candidate = self._program_state(program) + with _strategy_boundary(): + decision = self.population_strategy.admit(snapshot, candidate, island_idx) + if type(decision) is not bool: + raise ValueError("population_strategy.admit must return bool") + if not decision and not self.programs: + raise ValueError("population_strategy.admit cannot reject the initial program") + if not decision: + if iteration is not None: + self.last_iteration = max(self.last_iteration, iteration) + return None + + # Store the program before novelty checks, which look it up by ID. + if iteration is not None: + program.iteration_found = iteration + self.last_iteration = max(self.last_iteration, iteration) + self.programs[program.id] = program + + # Calculate feature coordinates for MAP-Elites + feature_coords = self._calculate_feature_coords(program) + # Novelty check before adding if not self._is_novel(program.id, island_idx): logger.debug( @@ -289,7 +406,18 @@ def add( ) else: # Program exists, compare fitness - should_replace = self._is_better(program, self.programs[existing_program_id]) + if self.population_strategy and self.population_strategy.replace_cell: + snapshot = self._population_snapshot() + candidate = self._program_state(program) + incumbent = self._program_state(self.programs[existing_program_id]) + with _strategy_boundary(): + should_replace = self.population_strategy.replace_cell( + snapshot, candidate, incumbent, island_idx + ) + if type(should_replace) is not bool: + raise ValueError("population_strategy.replace_cell must return bool") + else: + should_replace = self._is_better(program, self.programs[existing_program_id]) # Track a program that gets displaced from its cell so we can remove it # from the population if it ends up orphaned (owning no cell, in no island). @@ -328,7 +456,7 @@ def add( existing_program.metrics, self.config.feature_dimensions ) logger.info( - "Island %d MAP-Elites cell improved: %s (fitness: %.3f -> %.3f)", + "Island %d MAP-Elites cell replaced: %s (fitness: %.3f -> %.3f)", island_idx, coords_dict, existing_fitness, @@ -336,7 +464,9 @@ def add( ) # use MAP-Elites to manage archive - if existing_program_id in self.archive: + if existing_program_id in self.archive and not ( + self.population_strategy and self.population_strategy.archive + ): self.archive.discard(existing_program_id) self.archive.add(program.id) @@ -356,29 +486,17 @@ def add( # Update archive self._update_archive(program) - # Enforce population size limit BEFORE updating best program tracking - # This ensures newly added programs aren't immediately removed - self._enforce_population_limit(exclude_program_id=program.id) - - # Update the absolute best program tracking (after population enforcement) + # Track the best before removing a displaced cell owner. self._update_best_program(program) - - # Update island-specific best program tracking self._update_island_best_program(program, island_idx) - # If a program was displaced from its cell by this addition, it may now be - # orphaned - owning no cell and belonging to no island. Such a program is a - # "zombie" that consumes a population slot but can never be sampled again, so - # remove it. This runs after best-program tracking is updated so the newly - # added (better) program is already recorded as best, ensuring we never drop - # the current best program here. - if ( - replaced_program_id is not None - and replaced_program_id != program.id - and replaced_program_id != self.best_program_id - ): + # Remove displaced non-best programs that no longer own a cell or island. + if replaced_program_id is not None and replaced_program_id != program.id: self._remove_program_if_orphaned(replaced_program_id) + # The just-added program is protected while enforcing the population cap. + self._enforce_population_limit(exclude_program_id=program.id) + # Save to disk if configured if self.config.db_path: self._save_program(program) @@ -389,7 +507,7 @@ def add( def get(self, program_id: str) -> Optional[Program]: """ - Get a program by ID + Get a program by ID. Args: program_id: Program ID @@ -496,9 +614,10 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]: # If no specific metric and we have a tracked best program, return it if metric is None and self.best_program_id: - if self.best_program_id in self.programs: + best = self.get(self.best_program_id) + if best is not None: logger.debug(f"Using tracked best program: {self.best_program_id}") - return self.programs[self.best_program_id] + return best else: logger.warning( f"Tracked best program {self.best_program_id} no longer exists, will recalculate" @@ -525,8 +644,10 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]: logger.debug(f"Found best program by fitness score: {sorted_programs[0].id}") # Update the best program tracking if we found a better program - if sorted_programs and ( - self.best_program_id is None or sorted_programs[0].id != self.best_program_id + if ( + not metric + and sorted_programs + and (self.best_program_id is None or sorted_programs[0].id != self.best_program_id) ): old_id = self.best_program_id self.best_program_id = sorted_programs[0].id @@ -1005,6 +1126,7 @@ def _llm_judge_novelty(self, program: Program, similar_program: Program) -> bool Use LLM to judge if a program is novel compared to a similar existing program """ import asyncio + from openevolve.novelty_judge import NOVELTY_SYSTEM_MSG, NOVELTY_USER_MSG user_msg = NOVELTY_USER_MSG.format( @@ -1145,6 +1267,39 @@ def _update_archive(self, program: Program) -> None: Args: program: Program to consider for archive """ + if self.population_strategy and self.population_strategy.archive: + self.archive.intersection_update(self.programs) + snapshot = self._population_snapshot() + candidate = self._program_state(program) + with _strategy_boundary(): + decision = self.population_strategy.archive(snapshot, candidate) + if ( + not isinstance(decision, ArchiveDecision) + or type(decision.add) is not bool + or (decision.evict_id is not None and not isinstance(decision.evict_id, str)) + ): + raise ValueError("population_strategy.archive must return ArchiveDecision") + if not decision.add and decision.evict_id is not None: + raise ValueError("archive decision cannot evict without adding") + if decision.add and program.id in self.archive and decision.evict_id is not None: + raise ValueError( + "archive decision cannot evict when candidate is already archived" + ) + if decision.add and program.id not in self.archive: + if len(self.archive) >= self.config.archive_size: + if decision.evict_id not in self.archive: + raise ValueError( + "archive decision must evict an archived program when full" + ) + elif decision.evict_id is not None: + raise ValueError("archive decision cannot evict when archive has space") + if not decision.add or program.id in self.archive: + return + if decision.evict_id is not None: + self.archive.remove(decision.evict_id) + self.archive.add(program.id) + return + # If archive not full, add program if len(self.archive) < self.config.archive_size: self.archive.add(program.id) @@ -1198,8 +1353,9 @@ def _update_best_program(self, program: Program) -> None: logger.debug(f"Set initial best program to {program.id}") return - # Compare with current best program (if it still exists) - if self.best_program_id not in self.programs: + # Compare with current best program. + current_best = self.programs.get(self.best_program_id) + if current_best is None: logger.warning( f"Best program {self.best_program_id} no longer exists, clearing reference" ) @@ -1207,8 +1363,6 @@ def _update_best_program(self, program: Program) -> None: logger.info(f"Set new best program to {program.id}") return - current_best = self.programs[self.best_program_id] - # Update if the new program is better if self._is_better(program, current_best): old_id = self.best_program_id @@ -1245,12 +1399,12 @@ def _update_island_best_program(self, program: Program, island_idx: int) -> None logger.debug(f"Set initial best program for island {island_idx} to {program.id}") return - # Check if current best still exists - if current_island_best_id not in self.programs: - logger.warning( - f"Island {island_idx} best program {current_island_best_id} no longer exists, updating to {program.id}" - ) - self.island_best_programs[island_idx] = program.id + # An archived or global-best program can still exist after leaving this island. + if ( + current_island_best_id not in self.islands[island_idx] + or current_island_best_id not in self.programs + ): + self._cleanup_stale_island_bests() return current_island_best = self.programs[current_island_best_id] @@ -1305,9 +1459,9 @@ def _sample_exploration_parent(self) -> Program: if not current_island_programs: # If current island is empty, initialize with best program or random program - if self.best_program_id and self.best_program_id in self.programs: + best_program = self.get_best_program() + if best_program: # Create a copy of best program for the empty island (don't reuse same ID) - best_program = self.programs[self.best_program_id] copy_program = Program( id=str(uuid.uuid4()), code=best_program.code, @@ -1351,9 +1505,9 @@ def _sample_exploration_parent(self) -> Program: logger.warning( f"Island {self.current_island} has no valid programs after cleanup, reinitializing" ) - if self.best_program_id and self.best_program_id in self.programs: + best_program = self.get_best_program() + if best_program: # Create a copy of best program for the empty island (don't reuse same ID) - best_program = self.programs[self.best_program_id] copy_program = Program( id=str(uuid.uuid4()), code=best_program.code, @@ -1605,16 +1759,19 @@ def _sample_inspirations( island_best_id is not None and island_best_id != parent.id and island_best_id in self.programs + and island_best_id in self.islands[parent_island] ): island_best = self.programs[island_best_id] inspirations.append(island_best) logger.debug( f"Including island {parent_island} best program {island_best_id} in inspirations" ) - elif island_best_id is not None and island_best_id not in self.programs: + elif island_best_id is not None and ( + island_best_id not in self.programs or island_best_id not in self.islands[parent_island] + ): # Clean up stale island best reference logger.warning( - f"Island {parent_island} best program {island_best_id} no longer exists, clearing reference" + f"Island {parent_island} best program {island_best_id} is no longer in the island, clearing reference" ) self.island_best_programs[parent_island] = None @@ -1703,8 +1860,8 @@ def _remove_program_if_orphaned(self, program_id: str) -> None: A program is considered orphaned when it no longer owns a MAP-Elites cell in any island's feature map and is not a member of any island. Such a program (e.g. one displaced when its cell was improved) can never be - sampled again but still counts against the population size limit, so it is - removed from ``self.programs``, the archive and any lingering references. + sampled again. The current global best is retained as historical best; + other orphans are removed from ``self.programs`` and the archive. Args: program_id: ID of the (possibly) orphaned program to check and remove @@ -1722,6 +1879,17 @@ def _remove_program_if_orphaned(self, program_id: str) -> None: if program_id in island: return + # A custom archive policy may deliberately retain a displaced elite. + if ( + self.population_strategy + and self.population_strategy.archive + and program_id in self.archive + ): + return + + if program_id == self.best_program_id: + return + # Fully orphaned - remove from all remaining structures. del self.programs[program_id] self.archive.discard(program_id) @@ -1735,46 +1903,61 @@ def _enforce_population_limit(self, exclude_program_id: Optional[str] = None) -> Args: exclude_program_id: Program ID to never remove (e.g., newly added program) """ - if len(self.programs) <= self.config.population_size: + best_id = self.best_program_id + orphan_best = ( + best_id in self.programs + and not any(best_id in island for island in self.islands) + and not any(best_id in grid.values() for grid in self.island_feature_maps) + ) + population_count = len(self.programs) - int(orphan_best) + if population_count <= self.config.population_size: return # Calculate how many programs to remove - num_to_remove = len(self.programs) - self.config.population_size + num_to_remove = population_count - self.config.population_size logger.info( - f"Population size ({len(self.programs)}) exceeds limit ({self.config.population_size}), removing {num_to_remove} programs" + f"Population size ({population_count}) exceeds limit ({self.config.population_size}), removing {num_to_remove} programs" ) - # Collect all MAP-Elites cell owners across every island. These "elite" - # programs represent occupied niches and must be protected from eviction - # to preserve diversity - a low-scoring cell owner should only be removed - # after every non-owning (homeless) program has already been removed. - elite_ids = set() - for island_map in self.island_feature_maps: - elite_ids.update(island_map.values()) - - # Never remove the best program or the excluded (just-added) program - protected_ids = {self.best_program_id, exclude_program_id} - {None} + protected_ids = {best_id, exclude_program_id} - {None} all_programs = list(self.programs.values()) - # Split into non-elite (homeless) and elite (cell owners), each sorted by - # fitness worst-first. Non-elite programs are removed before elite ones. - non_elite = sorted( - [p for p in all_programs if p.id not in elite_ids and p.id not in protected_ids], - key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions), - ) - elite = sorted( - [p for p in all_programs if p.id in elite_ids and p.id not in protected_ids], - key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions), - ) - - # Remove non-elite programs first; only fall back to evicting elite cell - # owners (worst first) if removing all homeless programs is not enough. - programs_to_remove = non_elite[:num_to_remove] - if len(programs_to_remove) < num_to_remove: - remaining = num_to_remove - len(programs_to_remove) - programs_to_remove.extend(elite[:remaining]) + if self.population_strategy and self.population_strategy.evict: + eligible_ids = {p.id for p in all_programs} - protected_ids + required = min(num_to_remove, len(eligible_ids)) + snapshot = self._population_snapshot() + with _strategy_boundary(): + chosen_ids = tuple( + self.population_strategy.evict(snapshot, required, frozenset(protected_ids)) + ) + if ( + len(chosen_ids) != required + or not all(isinstance(pid, str) for pid in chosen_ids) + or len(set(chosen_ids)) != required + or not set(chosen_ids) <= eligible_ids + ): + raise ValueError( + "population_strategy.evict must return distinct eligible program IDs" + ) + programs_to_remove = [self.programs[pid] for pid in chosen_ids] + else: + # Preserve MAP-Elites cell owners until non-owning programs are exhausted. + elite_ids = { + pid for island_map in self.island_feature_maps for pid in island_map.values() + } + non_elite = sorted( + [p for p in all_programs if p.id not in elite_ids and p.id not in protected_ids], + key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions), + ) + elite = sorted( + [p for p in all_programs if p.id in elite_ids and p.id not in protected_ids], + key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions), + ) + programs_to_remove = non_elite[:num_to_remove] + if len(programs_to_remove) < num_to_remove: + programs_to_remove.extend(elite[: num_to_remove - len(programs_to_remove)]) # Remove the selected programs for program in programs_to_remove: @@ -1827,6 +2010,13 @@ def increment_island_generation(self, island_idx: Optional[int] = None) -> None: def should_migrate(self) -> bool: """Check if migration should occur based on generation counters""" + if self.population_strategy and self.population_strategy.migration_due: + snapshot = self._population_snapshot() + with _strategy_boundary(): + decision = self.population_strategy.migration_due(snapshot) + if type(decision) is not bool: + raise ValueError("population_strategy.migration_due must return bool") + return decision max_generation = max(self.island_generations) return (max_generation - self.last_migration_generation) >= self.migration_interval @@ -1841,6 +2031,35 @@ def migrate_programs(self) -> None: logger.info("Performing migration between islands") + if self.population_strategy and self.population_strategy.migrate: + snapshot = self._population_snapshot() + with _strategy_boundary(): + moves = tuple(self.population_strategy.migrate(snapshot)) + for move in moves: + if not isinstance(move, MigrationMove): + raise ValueError( + "population_strategy.migrate must return MigrationMove entries" + ) + if not isinstance(move.program_id, str) or move.program_id not in self.programs: + raise ValueError(f"migration program does not exist: {move.program_id}") + if ( + isinstance(move.target_island, bool) + or not isinstance(move.target_island, Integral) + or not 0 <= move.target_island < len(self.islands) + ): + raise ValueError(f"invalid migration target island: {move.target_island!r}") + if not any(move.program_id in island for island in self.islands): + raise ValueError( + f"migration program is not in an island: {move.program_id}" + ) + if move.program_id in self.islands[int(move.target_island)]: + raise ValueError("migration target must differ from source island") + for move in moves: + self._migrate_one(move.program_id, int(move.target_island)) + self.last_migration_generation = max(self.island_generations) + self._validate_migration_results() + return + for i, island in enumerate(self.islands): if len(island) == 0: continue @@ -1864,66 +2083,8 @@ def migrate_programs(self) -> None: target_islands = [(i + 1) % len(self.islands), (i - 1) % len(self.islands)] for migrant in migrants: - # Prevent re-migration of already migrated programs to avoid exponential duplication. - # Analysis of actual evolution runs shows this causes severe issues: - # - Program cb5d07f2 had 183 descendant copies by iteration 850 - # - Program 5645fbd2 had 31 descendant copies - # - IDs grow exponentially: program_migrant_2_migrant_3_migrant_4_migrant_0... - # - # This is particularly problematic for OpenEvolve's MAP-Elites + Island hybrid architecture: - # 1. All copies have identical code → same complexity/diversity/performance scores - # 2. They all map to the SAME MAP-Elites cell → only 1 survives, rest discarded - # 3. Wastes computation evaluating hundreds of identical programs - # 4. Reduces actual diversity as islands fill with duplicates - # - # By preventing already-migrated programs from migrating again, we ensure: - # - Each program migrates at most once per lineage - # - True diversity is maintained between islands - # - Computational resources aren't wasted on duplicates - # - Aligns with MAP-Elites' one-program-per-cell principle - if migrant.metadata.get("migrant", False): - continue - for target_island in target_islands: - # Skip migration if target island already has a program with identical code - # Identical code produces identical metrics, so migration would be wasteful - target_island_programs = [ - self.programs[pid] - for pid in self.islands[target_island] - if pid in self.programs - ] - has_duplicate_code = any(p.code == migrant.code for p in target_island_programs) - - if has_duplicate_code: - logger.debug( - f"Skipping migration of program {migrant.id[:8]} to island {target_island} " - f"(duplicate code already exists)" - ) - continue - # Create a copy for migration with simple new UUID - import uuid - - migrant_copy = Program( - id=str(uuid.uuid4()), - code=migrant.code, - changes_description=migrant.changes_description, - language=migrant.language, - parent_id=migrant.id, - generation=migrant.generation, - metrics=migrant.metrics.copy(), - metadata={**migrant.metadata, "island": target_island, "migrant": True}, - ) - - # Use add() method to properly handle MAP-Elites deduplication, - # feature map updates, and island tracking - self.add(migrant_copy, target_island=target_island) - - # Log migration - logger.info( - "Program %s migrated to island %d", - migrant_copy.id[:8], - target_island, - ) + self._migrate_one(migrant.id, target_island) # Update last migration generation self.last_migration_generation = max(self.island_generations) @@ -1932,6 +2093,31 @@ def migrate_programs(self) -> None: # Validate migration results self._validate_migration_results() + def _migrate_one(self, program_id: str, target_island: int) -> None: + migrant = self.programs.get(program_id) + if migrant is None or migrant.metadata.get("migrant", False): + return + if any( + self.programs[pid].code == migrant.code + for pid in self.islands[target_island] + if pid in self.programs + ): + logger.debug("Skipping duplicate migration of program %s", program_id[:8]) + return + + migrant_copy = Program( + id=str(uuid.uuid4()), + code=migrant.code, + changes_description=migrant.changes_description, + language=migrant.language, + parent_id=migrant.id, + generation=migrant.generation, + metrics=migrant.metrics.copy(), + metadata={**migrant.metadata, "island": target_island, "migrant": True}, + ) + if self.add(migrant_copy, target_island=target_island) is not None: + logger.info("Program %s migrated to island %d", migrant_copy.id[:8], target_island) + def _validate_migration_results(self) -> None: """ Validate migration didn't create inconsistencies @@ -2017,8 +2203,8 @@ def _cleanup_stale_island_bests(self) -> None: # Sort by fitness and update best_program = max( island_programs, - key=lambda p: p.metrics.get( - "combined_score", safe_numeric_average(p.metrics) + key=lambda p: get_fitness_score( + p.metrics, self.config.feature_dimensions ), ) self.island_best_programs[i] = best_program.id diff --git a/openevolve/population.py b/openevolve/population.py new file mode 100644 index 0000000000..3ca52dace5 --- /dev/null +++ b/openevolve/population.py @@ -0,0 +1,67 @@ +"""Decision hooks for population management. + +Strategies inspect detached snapshots and return decisions. ProgramDatabase owns +all changes to programs, islands, feature maps, and the archive. +""" + +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional, Sequence, Tuple + + +@dataclass(frozen=True) +class ProgramState: + id: str + code: str + metrics: Mapping[str, Any] + generation: int + parent_id: Optional[str] + metadata: Mapping[str, Any] + + +@dataclass(frozen=True) +class PopulationSnapshot: + programs: Mapping[str, ProgramState] + islands: Tuple[frozenset[str], ...] + feature_maps: Tuple[Mapping[str, str], ...] + archive: frozenset[str] + best_program_id: Optional[str] + population_limit: int + archive_limit: int + feature_dimensions: Tuple[str, ...] + generations: Tuple[int, ...] + last_migration_generation: int + migration_interval: int + migration_rate: float + last_iteration: int + + +@dataclass(frozen=True) +class ArchiveDecision: + add: bool + evict_id: Optional[str] = None + + +@dataclass(frozen=True) +class MigrationMove: + program_id: str + target_island: int + + +@dataclass(frozen=True) +class PopulationStrategy: + """Override only the decisions needed; omitted hooks use existing rules. + + ``admit`` may reject a candidate after the initial seed, but cannot bypass + database novelty checks. + ``evict`` must return exactly the required number of eligible program IDs. + ``migrate`` returns moves; the database copies programs and checks duplicates. + """ + + admit: Optional[Callable[[PopulationSnapshot, ProgramState, int], bool]] = None + replace_cell: Optional[ + Callable[[PopulationSnapshot, ProgramState, ProgramState, int], bool] + ] = None + archive: Optional[Callable[[PopulationSnapshot, ProgramState], ArchiveDecision]] = None + evict: Optional[Callable[[PopulationSnapshot, int, frozenset[str]], Sequence[str]]] = None + migration_due: Optional[Callable[[PopulationSnapshot], bool]] = None + migrate: Optional[Callable[[PopulationSnapshot], Sequence[MigrationMove]]] = None diff --git a/openevolve/process_parallel.py b/openevolve/process_parallel.py index b2cfeab788..3bc3104f61 100644 --- a/openevolve/process_parallel.py +++ b/openevolve/process_parallel.py @@ -11,16 +11,22 @@ from concurrent.futures import Future, ProcessPoolExecutor from concurrent.futures import TimeoutError as FutureTimeoutError from dataclasses import asdict, dataclass +from numbers import Integral from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from openevolve.config import Config from openevolve.database import Program, ProgramDatabase +from openevolve.selection import IslandSelectionContext, IslandSelector, IslandState from openevolve.utils.metrics_utils import safe_numeric_average logger = logging.getLogger(__name__) +class _CandidateRejected(Exception): + """Stop processing an iteration after admission rejects its child.""" + + @dataclass class SerializableResult: """Result that can be pickled and sent between processes""" @@ -397,12 +403,14 @@ def __init__( database: ProgramDatabase, evolution_tracer=None, file_suffix: str = ".py", + island_selector: Optional[IslandSelector] = None, ): self.config = config self.evaluation_file = evaluation_file self.database = database self.evolution_tracer = evolution_tracer self.file_suffix = file_suffix + self.island_selector = island_selector self.executor: Optional[ProcessPoolExecutor] = None self.shutdown_event = mp.Event() @@ -525,6 +533,42 @@ def _create_database_snapshot(self) -> Dict[str, Any]: return snapshot + def _select_island(self, iteration: int, island_pending: Dict[int, List[int]]) -> int: + """Ask a custom policy for an island using a read-only state snapshot.""" + stats = self.database.get_island_stats() + context = IslandSelectionContext( + iteration=iteration, + pending_counts=tuple(len(island_pending[i]) for i in range(self.num_islands)), + islands=tuple( + IslandState( + population_size=stat["population_size"], + best_score=stat["best_score"], + average_score=stat["average_score"], + diversity=stat["diversity"], + generation=stat["generation"], + ) + for stat in stats + ), + ) + island_id = self.island_selector(context) + if ( + isinstance(island_id, bool) + or not isinstance(island_id, Integral) + or not 0 <= island_id < self.num_islands + ): + raise ValueError( + f"island_selector must return an integer island ID in [0, {self.num_islands - 1}], " + f"got {island_id!r}" + ) + return int(island_id) + + def _checkpoint_if_due(self, iteration: int, callback=None) -> None: + if iteration > 0 and iteration % self.config.checkpoint_interval == 0: + logger.info("Checkpoint interval reached at iteration %d", iteration) + self.database.log_island_status() + if callback: + callback(iteration) + async def run_evolution( self, start_iteration: int, @@ -552,15 +596,26 @@ async def run_evolution( batch_per_island = max(1, batch_size // self.num_islands) if batch_size > 0 else 0 current_iteration = start_iteration - # Round-robin distribution across islands - for island_id in range(self.num_islands): - for _ in range(batch_per_island): - if current_iteration < total_iterations: - future = self._submit_iteration(current_iteration, island_id) - if future: - pending_futures[current_iteration] = future - island_pending[island_id].append(current_iteration) - current_iteration += 1 + if self.island_selector is None: + # Preserve the original round-robin distribution by default. + for island_id in range(self.num_islands): + for _ in range(batch_per_island): + if current_iteration < total_iterations: + future = self._submit_iteration(current_iteration, island_id) + if future: + pending_futures[current_iteration] = future + island_pending[island_id].append(current_iteration) + current_iteration += 1 + else: + # Keep the original initial batch size, but let the policy place each task. + initial_slots = min(max_iterations, batch_per_island * self.num_islands) + for _ in range(initial_slots): + island_id = self._select_island(current_iteration, island_pending) + future = self._submit_iteration(current_iteration, island_id) + if future: + pending_futures[current_iteration] = future + island_pending[island_id].append(current_iteration) + current_iteration += 1 next_iteration = current_iteration completed_iterations = 0 @@ -618,11 +673,13 @@ async def run_evolution( # Add to database with explicit target_island to ensure proper island placement # This fixes issue #391: children should go to the target island, not inherit # from the parent (which may be from a different island due to fallback sampling) - self.database.add( + admitted_id = self.database.add( child_program, iteration=completed_iteration, target_island=result.target_island, ) + if admitted_id is None: + raise _CandidateRejected(child_program.id) # Store artifacts if result.artifacts: @@ -720,18 +777,7 @@ async def run_evolution( f"{child_program.id}" ) - # Checkpoint callback - # Don't checkpoint at iteration 0 (that's just the initial program) - if ( - completed_iteration > 0 - and completed_iteration % self.config.checkpoint_interval == 0 - ): - logger.info( - f"Checkpoint interval reached at iteration {completed_iteration}" - ) - self.database.log_island_status() - if checkpoint_callback: - checkpoint_callback(completed_iteration) + self._checkpoint_if_due(completed_iteration, checkpoint_callback) # Check target score if target_score is not None and child_program.metrics: @@ -800,7 +846,12 @@ async def run_evolution( self.early_stopping_triggered = True break - except FutureTimeoutError: + except _CandidateRejected as rejected: + logger.info("Admission strategy rejected program %s", rejected) + self._checkpoint_if_due(completed_iteration, checkpoint_callback) + except FutureTimeoutError as e: + if getattr(e, "_openevolve_strategy_error", False): + raise logger.error( f"⏰ Iteration {completed_iteration} timed out after {timeout_seconds}s " f"(evaluator timeout: {self.config.evaluator.timeout}s + 30s buffer). " @@ -809,6 +860,8 @@ async def run_evolution( # Cancel the future to clean up the process future.cancel() except Exception as e: + if getattr(e, "_openevolve_strategy_error", False): + raise logger.error(f"Error processing result from iteration {completed_iteration}: {e}") completed_iterations += 1 @@ -819,19 +872,29 @@ async def run_evolution( iteration_list.remove(completed_iteration) break - # Submit next iterations maintaining island balance - for island_id in range(self.num_islands): - if ( - len(island_pending[island_id]) < batch_per_island - and next_iteration < total_iterations - and not self.shutdown_event.is_set() - ): + # Submit one replacement task per completion. Custom policies control + # placement; the default path retains the original balancing logic. + if self.island_selector is not None: + if next_iteration < total_iterations and not self.shutdown_event.is_set(): + island_id = self._select_island(next_iteration, island_pending) future = self._submit_iteration(next_iteration, island_id) if future: pending_futures[next_iteration] = future island_pending[island_id].append(next_iteration) next_iteration += 1 - break # Only submit one iteration per completion to maintain balance + else: + for island_id in range(self.num_islands): + if ( + len(island_pending[island_id]) < batch_per_island + and next_iteration < total_iterations + and not self.shutdown_event.is_set() + ): + future = self._submit_iteration(next_iteration, island_id) + if future: + pending_futures[next_iteration] = future + island_pending[island_id].append(next_iteration) + next_iteration += 1 + break # Only submit one iteration per completion to maintain balance # Handle shutdown if self.shutdown_event.is_set(): diff --git a/openevolve/selection.py b/openevolve/selection.py new file mode 100644 index 0000000000..c48e04bee2 --- /dev/null +++ b/openevolve/selection.py @@ -0,0 +1,27 @@ +"""Read-only inputs for user-defined island selection policies.""" + +from dataclasses import dataclass +from typing import Callable, Tuple + + +@dataclass(frozen=True) +class IslandState: + """Current population and fitness summary for one island.""" + + population_size: int + best_score: float + average_score: float + diversity: float + generation: int + + +@dataclass(frozen=True) +class IslandSelectionContext: + """Snapshot passed to a selector before an iteration is submitted.""" + + iteration: int + pending_counts: Tuple[int, ...] + islands: Tuple[IslandState, ...] + + +IslandSelector = Callable[[IslandSelectionContext], int] diff --git a/tests/test_island_selector.py b/tests/test_island_selector.py new file mode 100644 index 0000000000..23bac13c01 --- /dev/null +++ b/tests/test_island_selector.py @@ -0,0 +1,106 @@ +"""Behavior and compatibility tests for pluggable island scheduling.""" + +import asyncio +import unittest +from concurrent.futures import Future +from dataclasses import FrozenInstanceError +from enum import IntEnum +from unittest.mock import patch + +import numpy as np + +from openevolve import IslandSelectionContext, run_evolution +from openevolve.config import Config +from openevolve.database import Program, ProgramDatabase +from openevolve.process_parallel import ProcessParallelController, SerializableResult + + +class TestIslandSelector(unittest.TestCase): + def setUp(self): + self.config = Config() + self.config.database.num_islands = 3 + self.config.evaluator.parallel_evaluations = 2 + self.database = ProgramDatabase(self.config.database) + + def _run_with_submit_mock(self, selector=None, iterations=7): + controller = ProcessParallelController( + self.config, "unused.py", self.database, island_selector=selector + ) + controller.executor = object() # run_evolution checks that a pool was started. + submitted = [] + + def submit(iteration, island_id): + submitted.append((iteration, island_id)) + future = Future() + future.set_result(SerializableResult(error="test result")) + return future + + with patch.object(controller, "_submit_iteration", side_effect=submit): + asyncio.run(controller.run_evolution(1, iterations)) + return submitted + + def test_default_round_robin_scheduling_is_unchanged(self): + with patch.object(self.database, "get_island_stats", side_effect=AssertionError): + submitted = self._run_with_submit_mock(iterations=6) + self.assertEqual(submitted, [(1, 0), (2, 1), (3, 2), (4, 0), (5, 1), (6, 2)]) + + def test_custom_selector_controls_initial_and_replacement_tasks(self): + contexts = [] + + def selector(context): + contexts.append(context) + return 2 + + submitted = self._run_with_submit_mock(selector=selector) + self.assertEqual(submitted, [(i, 2) for i in range(1, 8)]) + self.assertEqual([context.iteration for context in contexts], list(range(1, 8))) + self.assertEqual(contexts[0].pending_counts, (0, 0, 0)) + self.assertEqual(contexts[1].pending_counts, (0, 0, 1)) + self.assertEqual(contexts[0].islands[0].population_size, 0) + self.assertIsInstance(contexts[0], IslandSelectionContext) + with self.assertRaises(FrozenInstanceError): + contexts[0].pending_counts = (0, 0, 0) + + def test_invalid_island_is_rejected_before_submission(self): + for invalid in (-1, 3, True, "1"): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "island_selector must return"): + self._run_with_submit_mock(selector=lambda context: invalid) + + def test_integral_selector_results_are_normalized(self): + class Island(IntEnum): + SECOND = 1 + + for island_id in (np.int64(1), Island.SECOND): + with self.subTest(island_id=island_id): + self.assertEqual( + self._run_with_submit_mock(selector=lambda context: island_id, iterations=1), + [(1, 1)], + ) + + def test_selector_receives_population_scores(self): + self.database.add( + Program(id="candidate", code="pass", metrics={"combined_score": 0.75}), + target_island=1, + ) + contexts = [] + + def select_best(context): + contexts.append(context) + return max(range(len(context.islands)), key=lambda i: context.islands[i].best_score) + + self.assertEqual(self._run_with_submit_mock(select_best, iterations=1), [(1, 1)]) + self.assertEqual(contexts[0].islands[1].population_size, 1) + self.assertEqual(contexts[0].islands[1].best_score, 0.75) + self.assertEqual(contexts[0].islands[1].diversity, 0.0) + + def test_public_api_forwards_selector(self): + async def fake_run(*args, **kwargs): + return "forwarded" + + selector = lambda context: 0 + with patch("openevolve.api._run_evolution_async", side_effect=fake_run) as mock_run: + result = run_evolution("program", "evaluator", island_selector=selector) + + self.assertEqual(result, "forwarded") + self.assertIs(mock_run.call_args.args[-1], selector) diff --git a/tests/test_population_strategy.py b/tests/test_population_strategy.py new file mode 100644 index 0000000000..2b7c72261c --- /dev/null +++ b/tests/test_population_strategy.py @@ -0,0 +1,405 @@ +"""Decision hooks choose outcomes while ProgramDatabase owns mutations.""" + +import asyncio +import tempfile +import unittest +from concurrent.futures import Future +from enum import IntEnum +from unittest.mock import patch + +import numpy as np + +from openevolve import run_evolution +from openevolve.config import Config +from openevolve.database import Program, ProgramDatabase +from openevolve.population import ArchiveDecision, MigrationMove, PopulationStrategy +from openevolve.process_parallel import ProcessParallelController, SerializableResult + + +def program(pid, score, code=None): + return Program( + id=pid, + code=code or f"def solve(): return '{pid}'", + metrics={"combined_score": score}, + ) + + +class TestPopulationStrategy(unittest.TestCase): + def setUp(self): + self.config = Config().database + self.config.num_islands = 2 + self.config.in_memory = True + + def test_admission_can_reject_without_mutating_database(self): + seen = [] + + def admit(snapshot, candidate, island): + seen.append((candidate.id, island, len(snapshot.programs))) + with self.assertRaises(TypeError): + snapshot.programs["changed"] = candidate + return candidate.id != "rejected" + + db = ProgramDatabase(self.config, PopulationStrategy(admit=admit)) + db.add(program("accepted", 0.5)) + self.assertIsNone(db.add(program("rejected", 0.9))) + + self.assertEqual(seen, [("accepted", 0, 0), ("rejected", 0, 1)]) + self.assertEqual(set(db.programs), {"accepted"}) + self.assertNotIn("rejected", db.islands[0]) + + def test_rejected_child_does_not_advance_generation_or_store_artifacts(self): + config = Config() + config.database.num_islands = 2 + config.evaluator.parallel_evaluations = 1 + config.checkpoint_interval = 1 + db = ProgramDatabase( + config.database, + PopulationStrategy( + admit=lambda snapshot, candidate, island: candidate.id != "rejected" + ), + ) + db.add(program("seed", 0.5)) + controller = ProcessParallelController(config, "unused.py", db) + controller.executor = object() + + def submit(iteration, island): + future = Future() + future.set_result( + SerializableResult( + child_program_dict=program("rejected", 0.9).to_dict(), + target_island=island, + artifacts={"output": "should not be stored"}, + ) + ) + return future + + checkpoints = [] + with ( + patch.object(controller, "_submit_iteration", side_effect=submit), + patch.object(db, "store_artifacts") as store_artifacts, + ): + asyncio.run(controller.run_evolution(1, 1, checkpoint_callback=checkpoints.append)) + + self.assertEqual(set(db.programs), {"seed"}) + self.assertEqual(db.island_generations, [0, 0]) + self.assertEqual(db.last_iteration, 1) + self.assertEqual(checkpoints, [1]) + store_artifacts.assert_not_called() + + def test_initial_program_cannot_be_rejected(self): + db = ProgramDatabase(self.config, PopulationStrategy(admit=lambda *args: False)) + with self.assertRaisesRegex(ValueError, "initial program"): + db.add(program("seed", 0.5)) + self.assertFalse(db.programs) + + def test_only_strategy_errors_escape_evolution_loop(self): + config = Config() + config.database.num_islands = 2 + config.evaluator.parallel_evaluations = 1 + + def admit(snapshot, candidate, island): + if candidate.id == "child": + raise RuntimeError("strategy failed") from OSError("original cause") + return True + + db = ProgramDatabase(config.database, PopulationStrategy(admit=admit)) + db.add(program("seed", 0.5)) + controller = ProcessParallelController(config, "unused.py", db) + controller.executor = object() + + def submit(iteration, island): + future = Future() + future.set_result( + SerializableResult( + child_program_dict=program("child", 0.9).to_dict(), + artifacts={"output": "artifact"}, + ) + ) + return future + + with patch.object(controller, "_submit_iteration", side_effect=submit): + with self.assertRaisesRegex(RuntimeError, "strategy failed") as failure: + asyncio.run(controller.run_evolution(1, 1)) + self.assertIsInstance(failure.exception.__cause__, OSError) + self.assertEqual(set(db.programs), {"seed"}) + + db.population_strategy = PopulationStrategy(admit=lambda *args: True) + controller = ProcessParallelController(config, "unused.py", db) + controller.executor = object() + with ( + patch.object(controller, "_submit_iteration", side_effect=submit), + patch.object(db, "store_artifacts", side_effect=OSError("artifact failed")), + self.assertLogs("openevolve.process_parallel", level="ERROR") as logs, + ): + asyncio.run(controller.run_evolution(1, 1)) + self.assertTrue(any("artifact failed" in message for message in logs.output)) + + def test_cell_replacement_decision_controls_map(self): + calls = [] + + def replace(snapshot, candidate, incumbent, island): + calls.append((candidate.id, incumbent.id, island)) + return True + + db = ProgramDatabase(self.config, PopulationStrategy(replace_cell=replace)) + with ( + patch.object(db, "_calculate_feature_coords", return_value=[0, 0]), + self.assertLogs("openevolve.database", level="INFO") as logs, + ): + db.add(program("first", 0.8)) + db.add(program("second", 0.2)) + + self.assertEqual(calls, [("second", "first", 0)]) + self.assertIn("second", db.island_feature_maps[0].values()) + self.assertNotIn("first", db.islands[0]) + self.assertTrue(any("cell replaced" in message for message in logs.output)) + + def test_island_best_recomputed_when_replaced_program_remains_global_best(self): + db = ProgramDatabase(self.config, PopulationStrategy(replace_cell=lambda *args: True)) + with patch.object(db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [0, 0]]): + db.add(program("first", 0.9), target_island=0) + db.add(program("other", 0.7), target_island=0) + db.add(program("replacement", 0.1), target_island=0) + + self.assertEqual(db.best_program_id, "first") + self.assertEqual(db.get_best_program().id, "first") + self.assertIn("first", db.programs) + self.assertNotIn("first", db.islands[0]) + self.assertEqual(db.island_best_programs[0], "other") + + def test_displaced_historical_best_does_not_evict_valid_cell(self): + self.config.population_size = 2 + db = ProgramDatabase(self.config, PopulationStrategy(replace_cell=lambda *args: True)) + with patch.object(db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [0, 0]]): + db.add(program("best", 0.9), target_island=0) + db.add(program("other_cell", 0.7), target_island=0) + db.add(program("replacement", 0.1), target_island=0) + + self.assertEqual(set(db.programs), {"best", "other_cell", "replacement"}) + self.assertEqual(set(db.island_feature_maps[0].values()), {"other_cell", "replacement"}) + self.assertEqual(db.get_best_program().id, "best") + self.assertEqual(db.get_best_program("combined_score").id, "best") + snapshot = db._population_snapshot() + self.assertEqual(snapshot.best_program_id, "best") + self.assertIn("best", snapshot.programs) + + with tempfile.TemporaryDirectory() as checkpoint: + db.save(checkpoint) + restored = ProgramDatabase(self.config) + restored.load(checkpoint) + self.assertEqual(set(restored.programs), set(db.programs)) + self.assertEqual(restored.get_best_program().id, "best") + self.assertNotIn("best", restored.islands[0]) + + def test_old_historical_best_loses_protection_when_new_best_appears(self): + self.config.population_size = 2 + db = ProgramDatabase(self.config, PopulationStrategy(replace_cell=lambda *args: True)) + with patch.object( + db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [0, 0], [2, 0]] + ): + db.add(program("best", 0.9), target_island=0) + db.add(program("other_cell", 0.7), target_island=0) + db.add(program("replacement", 0.1), target_island=0) + self.assertIn("best", db.programs) + db.add(program("new_best", 1.0), target_island=0) + + self.assertEqual(db.best_program_id, "new_best") + self.assertNotIn("best", db.programs) + self.assertEqual(len(db.programs), self.config.population_size) + + def test_metric_query_does_not_change_historical_best(self): + self.config.population_size = 2 + db = ProgramDatabase(self.config, PopulationStrategy(replace_cell=lambda *args: True)) + best = program("best", 0.9) + best.metrics["aux"] = 0 + other = program("other", 0.7) + other.metrics["aux"] = 10 + with patch.object( + db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [0, 0], [2, 0]] + ): + db.add(best, target_island=0) + db.add(other, target_island=0) + db.add(program("replacement", 0.1), target_island=0) + self.assertEqual(db.get_best_program("aux").id, "other") + self.assertEqual(db.best_program_id, "best") + db.add(program("new_cell", 0.2), target_island=0) + + self.assertIn("best", db.programs) + self.assertEqual(db.get_best_program().id, "best") + + def test_archived_historical_best_does_not_displace_valid_cell(self): + self.config.population_size = 2 + strategy = PopulationStrategy( + replace_cell=lambda *args: True, + archive=lambda snapshot, candidate: ArchiveDecision(add=candidate.id == "best"), + ) + db = ProgramDatabase(self.config, strategy) + with patch.object(db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [0, 0]]): + db.add(program("best", 0.9), target_island=0) + db.add(program("other_cell", 0.7), target_island=0) + db.add(program("replacement", 0.1), target_island=0) + + self.assertEqual(set(db.programs), {"best", "other_cell", "replacement"}) + self.assertEqual(set(db.island_feature_maps[0].values()), {"other_cell", "replacement"}) + self.assertEqual(db.get_best_program().id, "best") + self.assertIn("best", db.archive) + + def test_invalid_replacement_rolls_back_provisional_writes(self): + db = ProgramDatabase( + self.config, PopulationStrategy(replace_cell=lambda *args: "not a bool") + ) + with patch.object(db, "_calculate_feature_coords", return_value=[0, 0]): + db.add(program("first", 0.8)) + before = db._population_snapshot() + candidate = program("second", 0.9) + with self.assertRaisesRegex(ValueError, "replace_cell must return bool"): + db.add(candidate, iteration=7) + + self.assertEqual(db._population_snapshot(), before) + self.assertNotIn("second", db.programs) + self.assertEqual(candidate.iteration_found, 0) + + def test_archive_hook_sees_displaced_member_before_deciding(self): + seen = [] + + def archive(snapshot, candidate): + seen.append(snapshot.archive) + return ArchiveDecision(add=candidate.id == "first") + + db = ProgramDatabase( + self.config, + PopulationStrategy(replace_cell=lambda *args: True, archive=archive), + ) + with patch.object(db, "_calculate_feature_coords", return_value=[0, 0]): + db.add(program("first", 0.8)) + db.add(program("second", 0.9)) + + self.assertEqual(seen[-1], frozenset({"first"})) + self.assertEqual(db.archive, {"first"}) + + def test_invalid_archive_decision_rolls_back_population(self): + db = ProgramDatabase( + self.config, + PopulationStrategy( + archive=lambda snapshot, candidate: ( + ArchiveDecision(add=True) if candidate.id == "first" else "invalid" + ) + ), + ) + with patch.object(db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0]]): + db.add(program("first", 0.8)) + before = db._population_snapshot() + with self.assertRaisesRegex(ValueError, "archive must return ArchiveDecision"): + db.add(program("second", 0.9), iteration=7) + + self.assertEqual(db._population_snapshot(), before) + + def test_invalid_eviction_decision_rolls_back_population(self): + self.config.population_size = 2 + db = ProgramDatabase( + self.config, + PopulationStrategy(evict=lambda snapshot, required, protected: ("first",)), + ) + with patch.object(db, "_calculate_feature_coords", side_effect=[[0, 0], [1, 0], [2, 0]]): + db.add(program("first", 0.9), target_island=0) + db.add(program("second", 0.8), target_island=1) + before = db._population_snapshot() + with self.assertRaisesRegex(ValueError, "distinct eligible"): + db.add(program("third", 0.7), iteration=7, target_island=0) + + self.assertEqual(db._population_snapshot(), before) + + def test_archive_decision_and_eviction_keep_database_consistent(self): + self.config.archive_size = 1 + self.config.population_size = 2 + + def archive(snapshot, candidate): + old = next(iter(snapshot.archive), None) + return ArchiveDecision(add=True, evict_id=old) + + def evict(snapshot, required, protected): + self.assertEqual(required, 1) + self.assertNotIn("second", protected) + return ("second",) + + db = ProgramDatabase(self.config, PopulationStrategy(archive=archive, evict=evict)) + db.add(program("first", 0.9), target_island=0) + db.add(program("second", 0.5), target_island=1) + self.assertEqual(db.archive, {"second"}) + db.add(program("third", 0.4), target_island=0) + + self.assertNotIn("second", db.programs) + self.assertNotIn("second", db.islands[1]) + self.assertNotIn("second", db.island_feature_maps[1].values()) + self.assertEqual(db.archive, {"third"}) + + def test_migration_plan_is_applied_by_database(self): + strategy = PopulationStrategy( + migration_due=lambda snapshot: True, + migrate=lambda snapshot: (MigrationMove("seed", 1),), + ) + db = ProgramDatabase(self.config, strategy) + db.add(program("seed", 0.8), target_island=0) + self.assertTrue(db.should_migrate()) + + db.migrate_programs() + + migrants = [p for p in db.programs.values() if p.metadata.get("migrant")] + self.assertEqual(len(migrants), 1) + self.assertEqual(migrants[0].parent_id, "seed") + self.assertIn(migrants[0].id, db.islands[1]) + self.assertIn("seed", db.islands[0]) + + def test_migration_accepts_integral_targets_but_not_bool(self): + class Island(IntEnum): + SECOND = 1 + + for target in (np.int64(1), Island.SECOND): + with self.subTest(target=target): + db = ProgramDatabase( + self.config, + PopulationStrategy(migrate=lambda snapshot: (MigrationMove("seed", target),)), + ) + db.add(program("seed", 0.8), target_island=0) + db.migrate_programs() + migrant = next(p for p in db.programs.values() if p.metadata.get("migrant")) + self.assertIs(type(migrant.metadata["island"]), int) + self.assertEqual(migrant.metadata["island"], 1) + + db = ProgramDatabase( + self.config, + PopulationStrategy(migrate=lambda snapshot: (MigrationMove("seed", True),)), + ) + db.add(program("seed", 0.8), target_island=0) + with self.assertRaisesRegex(ValueError, "invalid migration target island"): + db.migrate_programs() + + def test_invalid_eviction_cannot_remove_protected_best(self): + self.config.population_size = 1 + db = ProgramDatabase( + self.config, PopulationStrategy(evict=lambda snapshot, required, protected: ("best",)) + ) + db.programs["best"] = program("best", 0.9) + db.programs["other"] = program("other", 0.1) + db.best_program_id = "best" + db.islands[0].add("best") + db.island_feature_maps[0]["0-0"] = "best" + + with self.assertRaisesRegex(ValueError, "distinct eligible"): + db._enforce_population_limit() + self.assertEqual(set(db.programs), {"best", "other"}) + + def test_public_api_forwards_strategy(self): + async def fake_run(*args, **kwargs): + return "forwarded" + + strategy = PopulationStrategy(admit=lambda snapshot, candidate, island: True) + with patch("openevolve.api._run_evolution_async", side_effect=fake_run) as mock_run: + result = run_evolution("program", "evaluator", population_strategy=strategy) + + self.assertEqual(result, "forwarded") + self.assertIs(mock_run.call_args.kwargs["population_strategy"], strategy) + + +if __name__ == "__main__": + unittest.main() From db3fc9e2f2980ab00292aecff6817dae01a93488 Mon Sep 17 00:00:00 2001 From: Weber Meng Date: Thu, 24 Sep 2026 16:32:09 +0800 Subject: [PATCH 2/2] chore: drop unrelated database refactors from #464 PR --- openevolve/database.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openevolve/database.py b/openevolve/database.py index eb82da2de7..653baa6f40 100644 --- a/openevolve/database.py +++ b/openevolve/database.py @@ -507,7 +507,7 @@ def _add( def get(self, program_id: str) -> Optional[Program]: """ - Get a program by ID. + Get a program by ID Args: program_id: Program ID @@ -614,10 +614,9 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]: # If no specific metric and we have a tracked best program, return it if metric is None and self.best_program_id: - best = self.get(self.best_program_id) - if best is not None: + if self.best_program_id in self.programs: logger.debug(f"Using tracked best program: {self.best_program_id}") - return best + return self.programs[self.best_program_id] else: logger.warning( f"Tracked best program {self.best_program_id} no longer exists, will recalculate" @@ -1353,9 +1352,8 @@ def _update_best_program(self, program: Program) -> None: logger.debug(f"Set initial best program to {program.id}") return - # Compare with current best program. - current_best = self.programs.get(self.best_program_id) - if current_best is None: + # Compare with current best program (if it still exists) + if self.best_program_id not in self.programs: logger.warning( f"Best program {self.best_program_id} no longer exists, clearing reference" ) @@ -1363,6 +1361,8 @@ def _update_best_program(self, program: Program) -> None: logger.info(f"Set new best program to {program.id}") return + current_best = self.programs[self.best_program_id] + # Update if the new program is better if self._is_better(program, current_best): old_id = self.best_program_id