diff --git a/openevolve/_version.py b/openevolve/_version.py index 30c7df0a..6ce4e8a5 100644 --- a/openevolve/_version.py +++ b/openevolve/_version.py @@ -1,3 +1,3 @@ """Version information for openevolve package.""" -__version__ = "0.2.9" +__version__ = "0.2.10" diff --git a/openevolve/config.py b/openevolve/config.py index 82776ae9..dbcb9cef 100644 --- a/openevolve/config.py +++ b/openevolve/config.py @@ -73,8 +73,7 @@ def __post_init__(self): if self.primary_model: # Create primary model primary_model = LLMModelConfig( - name=self.primary_model, - weight=self.primary_model_weight or 1.0 + name=self.primary_model, weight=self.primary_model_weight or 1.0 ) self.models.append(primary_model) @@ -83,14 +82,22 @@ def __post_init__(self): if self.secondary_model_weight is None or self.secondary_model_weight > 0: secondary_model = LLMModelConfig( name=self.secondary_model, - weight=self.secondary_model_weight if self.secondary_model_weight is not None else 0.2 + weight=( + self.secondary_model_weight + if self.secondary_model_weight is not None + else 0.2 + ), ) self.models.append(secondary_model) # Only validate if this looks like a user config (has some model info) # Don't validate during internal/default initialization - if (self.primary_model or self.secondary_model or - self.primary_model_weight or self.secondary_model_weight) and not self.models: + if ( + self.primary_model + or self.secondary_model + or self.primary_model_weight + or self.secondary_model_weight + ) and not self.models: raise ValueError( "No LLM models configured. Please specify 'models' array or " "'primary_model' in your configuration." @@ -198,11 +205,11 @@ class DatabaseConfig: default_factory=lambda: ["complexity", "diversity"], metadata={ "help": "List of feature dimensions for MAP-Elites grid. " - "Built-in dimensions: 'complexity', 'diversity', 'score'. " - "Custom dimensions: Must match metric names from evaluator. " - "IMPORTANT: Evaluators must return raw continuous values for custom dimensions, " - "NOT pre-computed bin indices. OpenEvolve handles all scaling and binning internally." - } + "Built-in dimensions: 'complexity', 'diversity', 'score'. " + "Custom dimensions: Must match metric names from evaluator. " + "IMPORTANT: Evaluators must return raw continuous values for custom dimensions, " + "NOT pre-computed bin indices. OpenEvolve handles all scaling and binning internally." + }, ) feature_bins: Union[int, Dict[str, int]] = 10 # Can be int (all dims) or dict (per-dim) diversity_reference_size: int = 20 # Size of reference set for diversity calculation @@ -271,7 +278,7 @@ class Config: # Evolution settings diff_based_evolution: bool = True max_code_length: int = 10000 - + # Early stopping settings early_stopping_patience: Optional[int] = None convergence_threshold: float = 0.001 diff --git a/openevolve/controller.py b/openevolve/controller.py index 8100277e..d4c9ed21 100644 --- a/openevolve/controller.py +++ b/openevolve/controller.py @@ -353,10 +353,20 @@ def force_exit_handler(signum, frame): best_program = best_by_combined if best_program: - logger.info( - f"Evolution complete. Best program has metrics: " - f"{format_metrics_safe(best_program.metrics)}" - ) + if ( + hasattr(self, "parallel_controller") + and self.parallel_controller + and self.parallel_controller.early_stopping_triggered + ): + logger.info( + f"🛑 Evolution complete via early stopping. Best program has metrics: " + f"{format_metrics_safe(best_program.metrics)}" + ) + else: + logger.info( + f"Evolution complete. Best program has metrics: " + f"{format_metrics_safe(best_program.metrics)}" + ) self._save_best_program(best_program) return best_program else: @@ -467,10 +477,13 @@ async def _run_evolution_with_checkpoints( start_iteration, max_iterations, target_score, checkpoint_callback=self._save_checkpoint ) - # Check if shutdown was requested + # Check if shutdown or early stopping was triggered if self.parallel_controller.shutdown_event.is_set(): logger.info("Evolution stopped due to shutdown request") return + elif self.parallel_controller.early_stopping_triggered: + logger.info("Evolution stopped due to early stopping - saving final checkpoint") + # Continue to save final checkpoint for early stopping # Save final checkpoint if needed # Note: start_iteration here is the evolution start (1 for fresh start, not 0) diff --git a/openevolve/database.py b/openevolve/database.py index 050f7b7f..a6ea4ff1 100644 --- a/openevolve/database.py +++ b/openevolve/database.py @@ -248,7 +248,9 @@ def add( if existing_program_id in self.programs: existing_program = self.programs[existing_program_id] new_fitness = get_fitness_score(program.metrics, self.config.feature_dimensions) - existing_fitness = get_fitness_score(existing_program.metrics, self.config.feature_dimensions) + existing_fitness = get_fitness_score( + existing_program.metrics, self.config.feature_dimensions + ) logger.info( "MAP-Elites cell improved: %s (fitness: %.3f -> %.3f)", coords_dict, @@ -290,7 +292,7 @@ def add( else: # No parent and no target specified, use current island island_idx = self.current_island - + island_idx = island_idx % len(self.islands) # Ensure valid island self.islands[island_idx].add(program.id) @@ -547,7 +549,7 @@ def load(self, path: str) -> None: self.current_island = metadata.get("current_island", 0) self.island_generations = metadata.get("island_generations", [0] * len(saved_islands)) self.last_migration_generation = metadata.get("last_migration_generation", 0) - + # Load feature_stats for MAP-Elites grid stability self.feature_stats = self._deserialize_feature_stats(metadata.get("feature_stats", {})) @@ -839,7 +841,7 @@ def _feature_coords_to_key(self, coords: List[int]) -> str: def _is_better(self, program1: Program, program2: Program) -> bool: """ Determine if program1 has better FITNESS than program2 - + Uses fitness calculation that excludes MAP-Elites feature dimensions to prevent pollution of fitness comparisons. @@ -901,7 +903,8 @@ def _update_archive(self, program: Program) -> None: # Find worst program among valid programs if valid_archive_programs: worst_program = min( - valid_archive_programs, key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions) + valid_archive_programs, + key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions), ) # Replace if new program is better @@ -1848,7 +1851,7 @@ def _scale_feature_value_minmax(self, feature_name: str, value: float) -> float: def _serialize_feature_stats(self) -> Dict[str, Any]: """ Serialize feature_stats for JSON storage - + Returns: Dictionary that can be JSON-serialized """ @@ -1866,26 +1869,28 @@ def _serialize_feature_stats(self) -> Dict[str, Any]: serialized_stats[key] = value else: # Convert numpy types to Python native types - if hasattr(value, 'item'): # numpy scalar + if hasattr(value, "item"): # numpy scalar serialized_stats[key] = value.item() else: serialized_stats[key] = value serialized[feature_name] = serialized_stats return serialized - - def _deserialize_feature_stats(self, stats_dict: Dict[str, Any]) -> Dict[str, Dict[str, Union[float, List[float]]]]: + + def _deserialize_feature_stats( + self, stats_dict: Dict[str, Any] + ) -> Dict[str, Dict[str, Union[float, List[float]]]]: """ Deserialize feature_stats from loaded JSON - + Args: stats_dict: Dictionary loaded from JSON - + Returns: Properly formatted feature_stats dictionary """ if not stats_dict: return {} - + deserialized = {} for feature_name, stats in stats_dict.items(): if isinstance(stats, dict): @@ -1897,8 +1902,10 @@ def _deserialize_feature_stats(self, stats_dict: Dict[str, Any]) -> Dict[str, Di } deserialized[feature_name] = deserialized_stats else: - logger.warning(f"Skipping malformed feature_stats entry for '{feature_name}': {stats}") - + logger.warning( + f"Skipping malformed feature_stats entry for '{feature_name}': {stats}" + ) + return deserialized def log_island_status(self) -> None: diff --git a/openevolve/evaluation_result.py b/openevolve/evaluation_result.py index 9d1b69b1..cdc35553 100644 --- a/openevolve/evaluation_result.py +++ b/openevolve/evaluation_result.py @@ -15,8 +15,8 @@ class EvaluationResult: This maintains backward compatibility with the existing dict[str, float] contract while adding a side-channel for arbitrary artifacts (text or binary data). - IMPORTANT: For custom MAP-Elites features, metrics values must be raw continuous - scores (e.g., actual counts, percentages, continuous measurements), NOT pre-computed + IMPORTANT: For custom MAP-Elites features, metrics values must be raw continuous + scores (e.g., actual counts, percentages, continuous measurements), NOT pre-computed bin indices. The database handles all binning internally using min-max scaling. Examples: diff --git a/openevolve/evaluator.py b/openevolve/evaluator.py index 6cfc00fa..6355b8c3 100644 --- a/openevolve/evaluator.py +++ b/openevolve/evaluator.py @@ -44,7 +44,7 @@ def __init__( llm_ensemble: Optional[LLMEnsemble] = None, prompt_sampler: Optional[PromptSampler] = None, database: Optional[ProgramDatabase] = None, - suffix: Optional[str]=".py", + suffix: Optional[str] = ".py", ): self.config = config self.evaluation_file = evaluation_file @@ -565,7 +565,9 @@ async def _llm_evaluate(self, program_code: str, program_id: str = "") -> Dict[s # Create prompt for LLM feature_dimensions = self.database.config.feature_dimensions if self.database else [] prompt = self.prompt_sampler.build_prompt( - current_program=program_code, template_key="evaluation", feature_dimensions=feature_dimensions + current_program=program_code, + template_key="evaluation", + feature_dimensions=feature_dimensions, ) # Get LLM response diff --git a/openevolve/llm/openai.py b/openevolve/llm/openai.py index 189c5e0b..e9fe539b 100644 --- a/openevolve/llm/openai.py +++ b/openevolve/llm/openai.py @@ -70,20 +70,24 @@ async def generate_with_context( # These models don't support temperature/top_p and use different parameters OPENAI_REASONING_MODEL_PREFIXES = ( # O-series reasoning models - "o1-", "o1", # o1, o1-mini, o1-preview - "o3-", "o3", # o3, o3-mini, o3-pro - "o4-", # o4-mini + "o1-", + "o1", # o1, o1-mini, o1-preview + "o3-", + "o3", # o3, o3-mini, o3-pro + "o4-", # o4-mini # GPT-5 series are also reasoning models - "gpt-5-", "gpt-5", # gpt-5, gpt-5-mini, gpt-5-nano + "gpt-5-", + "gpt-5", # gpt-5, gpt-5-mini, gpt-5-nano # The GPT OSS series are also reasoning models - "gpt-oss-120b", "gpt-oss-20b" + "gpt-oss-120b", + "gpt-oss-20b", ) # Check if this is an OpenAI reasoning model model_lower = str(self.model).lower() is_openai_reasoning_model = ( - self.api_base == "https://api.openai.com/v1" and - model_lower.startswith(OPENAI_REASONING_MODEL_PREFIXES) + self.api_base == "https://api.openai.com/v1" + and model_lower.startswith(OPENAI_REASONING_MODEL_PREFIXES) ) if is_openai_reasoning_model: diff --git a/openevolve/process_parallel.py b/openevolve/process_parallel.py index f508b881..c9da4163 100644 --- a/openevolve/process_parallel.py +++ b/openevolve/process_parallel.py @@ -186,17 +186,11 @@ def _run_iteration_worker( ) except Exception as e: logger.error(f"LLM generation failed: {e}") - return SerializableResult( - error=f"LLM generation failed: {str(e)}", - iteration=iteration - ) + return SerializableResult(error=f"LLM generation failed: {str(e)}", iteration=iteration) # Check for None response if llm_response is None: - return SerializableResult( - error="LLM returned None response", - iteration=iteration - ) + return SerializableResult(error="LLM returned None response", iteration=iteration) # Parse response based on evolution mode if _worker_config.diff_based_evolution: @@ -281,14 +275,15 @@ def __init__(self, config: Config, evaluation_file: str, database: ProgramDataba self.executor: Optional[ProcessPoolExecutor] = None self.shutdown_event = mp.Event() + self.early_stopping_triggered = False # Number of worker processes self.num_workers = config.evaluator.parallel_evaluations - + # Worker-to-island pinning for true island isolation self.num_islands = config.database.num_islands self.worker_island_map = {} - + # Distribute workers across islands using modulo for worker_id in range(self.num_workers): island_id = worker_id % self.num_islands @@ -407,7 +402,7 @@ async def run_evolution( # Submit initial batch - distribute across islands 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): @@ -424,15 +419,17 @@ async def run_evolution( # Island management programs_per_island = max(1, max_iterations // (self.config.database.num_islands * 10)) current_island_counter = 0 - + # Early stopping tracking early_stopping_enabled = self.config.early_stopping_patience is not None if early_stopping_enabled: - best_score = float('-inf') + best_score = float("-inf") iterations_without_improvement = 0 - logger.info(f"Early stopping enabled: patience={self.config.early_stopping_patience}, " - f"threshold={self.config.convergence_threshold}, " - f"metric={self.config.early_stopping_metric}") + logger.info( + f"Early stopping enabled: patience={self.config.early_stopping_patience}, " + f"threshold={self.config.convergence_threshold}, " + f"metric={self.config.early_stopping_metric}" + ) else: logger.info("Early stopping disabled") @@ -582,7 +579,9 @@ async def run_evolution( current_score = safe_numeric_average(child_program.metrics) else: # User specified a custom metric that doesn't exist - logger.warning(f"Early stopping metric '{self.config.early_stopping_metric}' not found, using safe numeric average") + logger.warning( + f"Early stopping metric '{self.config.early_stopping_metric}' not found, using safe numeric average" + ) current_score = safe_numeric_average(child_program.metrics) if current_score is not None and isinstance(current_score, (int, float)): @@ -591,15 +590,23 @@ async def run_evolution( if improvement >= self.config.convergence_threshold: best_score = current_score iterations_without_improvement = 0 - logger.debug(f"New best score: {best_score:.4f} (improvement: {improvement:+.4f})") + logger.debug( + f"New best score: {best_score:.4f} (improvement: {improvement:+.4f})" + ) else: iterations_without_improvement += 1 - logger.debug(f"No improvement: {iterations_without_improvement}/{self.config.early_stopping_patience}") + logger.debug( + f"No improvement: {iterations_without_improvement}/{self.config.early_stopping_patience}" + ) # Check if we should stop - if iterations_without_improvement >= self.config.early_stopping_patience: + if ( + iterations_without_improvement + >= self.config.early_stopping_patience + ): + self.early_stopping_triggered = True logger.info( - f"Early stopping triggered at iteration {completed_iteration}: " + f"🛑 Early stopping triggered at iteration {completed_iteration}: " f"No improvement for {iterations_without_improvement} iterations " f"(best score: {best_score:.4f})" ) @@ -609,7 +616,7 @@ async def run_evolution( logger.error(f"Error processing result from iteration {completed_iteration}: {e}") completed_iterations += 1 - + # Remove completed iteration from island tracking for island_id, iteration_list in island_pending.items(): if completed_iteration in iteration_list: @@ -618,9 +625,11 @@ async def run_evolution( # 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()): + 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 @@ -634,23 +643,33 @@ async def run_evolution( for future in pending_futures.values(): future.cancel() - logger.info("Evolution completed") + # Log completion reason + if self.early_stopping_triggered: + logger.info("✅ Evolution completed - Early stopping triggered due to convergence") + elif self.shutdown_event.is_set(): + logger.info("✅ Evolution completed - Shutdown requested") + else: + logger.info("✅ Evolution completed - Maximum iterations reached") return self.database.get_best_program() - def _submit_iteration(self, iteration: int, island_id: Optional[int] = None) -> Optional[Future]: + def _submit_iteration( + self, iteration: int, island_id: Optional[int] = None + ) -> Optional[Future]: """Submit an iteration to the process pool, optionally pinned to a specific island""" try: # Use specified island or current island target_island = island_id if island_id is not None else self.database.current_island - + # Temporarily set database to target island for sampling original_island = self.database.current_island self.database.current_island = target_island - + try: # Sample parent and inspirations from the target island - parent, inspirations = self.database.sample(num_inspirations=self.config.prompt.num_top_programs) + parent, inspirations = self.database.sample( + num_inspirations=self.config.prompt.num_top_programs + ) finally: # Always restore original island state self.database.current_island = original_island diff --git a/openevolve/prompt/sampler.py b/openevolve/prompt/sampler.py index 3050c019..eeb2f0c3 100644 --- a/openevolve/prompt/sampler.py +++ b/openevolve/prompt/sampler.py @@ -9,7 +9,11 @@ from openevolve.config import PromptConfig from openevolve.prompt.templates import TemplateManager from openevolve.utils.format_utils import format_metrics_safe -from openevolve.utils.metrics_utils import safe_numeric_average, get_fitness_score, format_feature_coordinates +from openevolve.utils.metrics_utils import ( + safe_numeric_average, + get_fitness_score, + format_feature_coordinates, +) logger = logging.getLogger(__name__) @@ -132,7 +136,7 @@ def build_prompt( feature_dimensions = feature_dimensions or [] fitness_score = get_fitness_score(program_metrics, feature_dimensions) feature_coords = format_feature_coordinates(program_metrics, feature_dimensions) - + # Format the final user message user_message = user_template.format( metrics=metrics_str, @@ -175,66 +179,53 @@ def _identify_improvement_areas( feature_dimensions: Optional[List[str]] = None, ) -> str: """Identify improvement areas with proper fitness/feature separation""" - + improvement_areas = [] feature_dimensions = feature_dimensions or [] - + # Calculate fitness (excluding feature dimensions) current_fitness = get_fitness_score(metrics, feature_dimensions) - + # Track fitness changes (not individual metrics) if previous_programs: prev_metrics = previous_programs[-1].get("metrics", {}) prev_fitness = get_fitness_score(prev_metrics, feature_dimensions) - + if current_fitness > prev_fitness: msg = self.template_manager.get_fragment( - "fitness_improved", - prev=prev_fitness, - current=current_fitness + "fitness_improved", prev=prev_fitness, current=current_fitness ) improvement_areas.append(msg) elif current_fitness < prev_fitness: msg = self.template_manager.get_fragment( - "fitness_declined", - prev=prev_fitness, - current=current_fitness + "fitness_declined", prev=prev_fitness, current=current_fitness ) improvement_areas.append(msg) elif abs(current_fitness - prev_fitness) < 1e-6: # Essentially unchanged - msg = self.template_manager.get_fragment( - "fitness_stable", - current=current_fitness - ) + msg = self.template_manager.get_fragment("fitness_stable", current=current_fitness) improvement_areas.append(msg) - + # Note feature exploration (not good/bad, just informational) if feature_dimensions: feature_coords = format_feature_coordinates(metrics, feature_dimensions) if feature_coords != "No feature coordinates": msg = self.template_manager.get_fragment( - "exploring_region", - features=feature_coords + "exploring_region", features=feature_coords ) improvement_areas.append(msg) - + # Code length check (configurable threshold) threshold = ( self.config.suggest_simplification_after_chars or self.config.code_length_threshold ) if threshold and len(current_program) > threshold: - msg = self.template_manager.get_fragment( - "code_too_long", - threshold=threshold - ) + msg = self.template_manager.get_fragment("code_too_long", threshold=threshold) improvement_areas.append(msg) - + # Default guidance if nothing specific if not improvement_areas: - improvement_areas.append( - self.template_manager.get_fragment("no_specific_guidance") - ) - + improvement_areas.append(self.template_manager.get_fragment("no_specific_guidance")) + return "\n".join(f"- {area}" for area in improvement_areas) def _format_evolution_history( @@ -402,7 +393,9 @@ def _format_evolution_history( combined_programs_str = top_programs_str + diverse_programs_str # Format inspirations section - inspirations_section_str = self._format_inspirations_section(inspirations, language, feature_dimensions) + inspirations_section_str = self._format_inspirations_section( + inspirations, language, feature_dimensions + ) # Combine into full history return history_template.format( @@ -412,7 +405,10 @@ def _format_evolution_history( ) def _format_inspirations_section( - self, inspirations: List[Dict[str, Any]], language: str, feature_dimensions: Optional[List[str]] = None + self, + inspirations: List[Dict[str, Any]], + language: str, + feature_dimensions: Optional[List[str]] = None, ) -> str: """ Format the inspirations section for the prompt @@ -462,7 +458,9 @@ def _format_inspirations_section( inspiration_programs=inspiration_programs_str.strip() ) - def _determine_program_type(self, program: Dict[str, Any], feature_dimensions: Optional[List[str]] = None) -> str: + def _determine_program_type( + self, program: Dict[str, Any], feature_dimensions: Optional[List[str]] = None + ) -> str: """ Determine the type/category of an inspiration program diff --git a/openevolve/prompt/templates.py b/openevolve/prompt/templates.py index 291435b4..465f92d7 100644 --- a/openevolve/prompt/templates.py +++ b/openevolve/prompt/templates.py @@ -176,14 +176,14 @@ def __init__(self, custom_template_dir: Optional[str] = None): # Get default template directory self.default_dir = Path(__file__).parent.parent / "prompts" / "defaults" self.custom_dir = Path(custom_template_dir) if custom_template_dir else None - + # Load templates with cascading priority self.templates = {} self.fragments = {} - + # 1. Load defaults self._load_from_directory(self.default_dir) - + # 2. Override with custom templates (if provided) if self.custom_dir and self.custom_dir.exists(): self._load_from_directory(self.custom_dir) @@ -192,17 +192,17 @@ def _load_from_directory(self, directory: Path) -> None: """Load all templates and fragments from a directory""" if not directory.exists(): return - + # Load .txt templates for txt_file in directory.glob("*.txt"): template_name = txt_file.stem - with open(txt_file, 'r') as f: + with open(txt_file, "r") as f: self.templates[template_name] = f.read() - + # Load fragments.json if exists fragments_file = directory / "fragments.json" if fragments_file.exists(): - with open(fragments_file, 'r') as f: + with open(fragments_file, "r") as f: loaded_fragments = json.load(f) self.fragments.update(loaded_fragments) @@ -211,7 +211,7 @@ def get_template(self, name: str) -> str: if name not in self.templates: raise ValueError(f"Template '{name}' not found") return self.templates[name] - + def get_fragment(self, name: str, **kwargs) -> str: """Get and format a fragment""" if name not in self.fragments: @@ -220,11 +220,11 @@ def get_fragment(self, name: str, **kwargs) -> str: return self.fragments[name].format(**kwargs) except KeyError as e: return f"[Fragment formatting error: {e}]" - + def add_template(self, template_name: str, template: str) -> None: """Add or update a template""" self.templates[template_name] = template - + def add_fragment(self, fragment_name: str, fragment: str) -> None: """Add or update a fragment""" self.fragments[fragment_name] = fragment diff --git a/openevolve/utils/metrics_utils.py b/openevolve/utils/metrics_utils.py index e21567ba..3ea0ffa2 100644 --- a/openevolve/utils/metrics_utils.py +++ b/openevolve/utils/metrics_utils.py @@ -67,36 +67,35 @@ def safe_numeric_sum(metrics: Dict[str, Any]) -> float: def get_fitness_score( - metrics: Dict[str, Any], - feature_dimensions: Optional[List[str]] = None + metrics: Dict[str, Any], feature_dimensions: Optional[List[str]] = None ) -> float: """ Calculate fitness score, excluding MAP-Elites feature dimensions - + This ensures that MAP-Elites features don't pollute the fitness calculation when combined_score is not available. - + Args: metrics: All metrics from evaluation feature_dimensions: List of MAP-Elites dimensions to exclude from fitness - + Returns: Fitness score (combined_score if available, otherwise average of non-feature metrics) """ if not metrics: return 0.0 - + # Always prefer combined_score if available if "combined_score" in metrics: try: return float(metrics["combined_score"]) except (ValueError, TypeError): pass - + # Otherwise, average only non-feature metrics feature_dimensions = feature_dimensions or [] fitness_metrics = {} - + for key, value in metrics.items(): # Exclude MAP feature dimensions from fitness calculation if key not in feature_dimensions: @@ -107,25 +106,22 @@ def get_fitness_score( fitness_metrics[key] = float_val except (ValueError, TypeError, OverflowError): continue - + # If no non-feature metrics, fall back to all metrics (backward compatibility) if not fitness_metrics: return safe_numeric_average(metrics) - + return safe_numeric_average(fitness_metrics) -def format_feature_coordinates( - metrics: Dict[str, Any], - feature_dimensions: List[str] -) -> str: +def format_feature_coordinates(metrics: Dict[str, Any], feature_dimensions: List[str]) -> str: """ Format feature coordinates for display in prompts - + Args: metrics: All metrics from evaluation feature_dimensions: List of MAP-Elites feature dimensions - + Returns: Formatted string showing feature coordinates """ @@ -142,8 +138,8 @@ def format_feature_coordinates( feature_values.append(f"{dim}={value}") else: feature_values.append(f"{dim}={value}") - + if not feature_values: return "No feature coordinates" - + return ", ".join(feature_values)