diff --git a/README.md b/README.md
index 785a1d5804..e749c77d39 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,32 @@
---
+## Feature: Pareto selection over several objectives
+
+This fork adds multi-objective selection. Set `database.objectives` to two or more metric names your evaluator
+returns, and programs are compared by **Pareto dominance** instead of by one number: A beats B only if A is at least
+as good on every objective and strictly better on one. When neither dominates, NSGA-II's secondary keys decide,
+computed against the current population: lower **rank** first (rank 0 is the non-dominated front; rank k is what is
+non-dominated once fronts 0..k-1 are removed), then larger **crowding distance** (the normalized gap to the nearest
+neighbours on the front; boundary programs count as infinitely isolated), which keeps the front spread out. No
+objective is weighted or ordered above another, and nothing is summed.
+
+```yaml
+database:
+ objectives: [primary_score, availability_robustness, changeover_robustness] # evaluator metrics, larger is better
+ objective_directions: [max, max, max] # optional; "min" flips an objective
+ best_selection: first_objective # what get_best_program() reports from the front (reporting only)
+```
+
+What changes with `objectives` set: MAP-Elites cell replacement, archive membership, parent and inspiration
+sampling, island migration and population trimming all use dominance / rank / crowding (`openevolve/pareto.py`,
+`ProgramDatabase.fitness`, `ProgramDatabase._is_better`); the prompt shows the LLM every objective's value for the
+current and the top programs instead of a single score; each checkpoint writes `pareto_front.json` with the front
+and its objective values. `get_best_program()` returns one front member under `best_selection` (`first_objective`, the default: the best
+value of `objectives[0]` on the front, which is the program a single-objective run would report from the same
+population; `knee`: objectives normalized to their range on the front, the member closest to the ideal point;
+`crowding`: the most isolated member, an extreme), a reporting rule that selection never uses. With `objectives` empty, behaviour is identical to upstream OpenEvolve. Tests: `tests/test_pareto.py`.
+
## Why OpenEvolve?
diff --git a/configs/default_config.yaml b/configs/default_config.yaml
index 14ae54556b..d9f3c06082 100644
--- a/configs/default_config.yaml
+++ b/configs/default_config.yaml
@@ -121,6 +121,11 @@ database:
# - "diversity": Code structure diversity
#
# You can mix built-in features with custom metrics from your evaluator:
+ # Pareto selection (this fork): name two or more evaluator metrics here and programs are compared by dominance,
+ # then NSGA-II rank and crowding distance, with no weights and no order between objectives (see README).
+ # objectives: [primary_score, robustness_a, robustness_b]
+ # objective_directions: [max, max, max] # optional, "min" flips an objective
+ # best_selection: first_objective # reporting rule for the single "best": first_objective | knee | crowding
feature_dimensions: # Dimensions for MAP-Elites feature map (for diversity, NOT fitness)
- "complexity" # Code length (built-in)
- "diversity" # Code diversity (built-in)
diff --git a/openevolve/config.py b/openevolve/config.py
index c19ab4ca1d..f040fa4fae 100644
--- a/openevolve/config.py
+++ b/openevolve/config.py
@@ -334,6 +334,26 @@ class DatabaseConfig:
# CRITICAL: For custom dimensions, evaluators must return RAW VALUES, not bin indices
# Built-in: "complexity", "diversity", "score" (always available)
# Custom: Any metric from your evaluator (must be continuous values)
+ # Pareto selection over several objectives (openevolve/pareto.py). When `objectives` names two or more evaluator
+ # metrics, programs are compared by dominance (better on every objective, strictly on one) and, when neither
+ # dominates, by NSGA-II rank then crowding distance against the current population. No weights, no order between
+ # objectives. Empty (default): the usual single fitness (combined_score, else the average of non-feature metrics).
+ objectives: List[str] = field(
+ default_factory=list,
+ metadata={"help": "Metric names to optimise jointly by Pareto dominance; empty = single-fitness selection."},
+ )
+ # Per-objective direction, "max" (default) or "min"; shorter lists are padded with "max".
+ objective_directions: List[str] = field(default_factory=list)
+ # Which front member get_best_program() reports when objectives are set (reporting only: the search never uses it;
+ # the whole front is written to pareto_front.json at every checkpoint):
+ # "first_objective" (default): the best value of objectives[0] on the front. The primary-best program of a
+ # population is always on the front, so this reports the same program a single-objective run
+ # would report from the same population, which keeps arms comparable;
+ # "knee": objectives normalized to their range on the front, the member closest to the ideal point, i.e. the
+ # most balanced trade-off, with no objective preferred;
+ # "crowding": the most isolated member, which is a boundary point, i.e. an extreme.
+ best_selection: str = "first_objective"
+
feature_dimensions: List[str] = field(
default_factory=lambda: ["complexity", "diversity"],
metadata={
diff --git a/openevolve/controller.py b/openevolve/controller.py
index a3f096bf8b..d367b04a1b 100644
--- a/openevolve/controller.py
+++ b/openevolve/controller.py
@@ -116,8 +116,8 @@ def __init__(
self.llm_ensemble = LLMEnsemble(self.config.llm.models)
self.llm_evaluator_ensemble = LLMEnsemble(self.config.llm.evaluator_models)
- self.prompt_sampler = PromptSampler(self.config.prompt)
- self.evaluator_prompt_sampler = PromptSampler(self.config.prompt)
+ self.prompt_sampler = PromptSampler(self.config.prompt, objectives=list(self.config.database.objectives or []))
+ self.evaluator_prompt_sampler = PromptSampler(self.config.prompt, objectives=list(self.config.database.objectives or []))
self.evaluator_prompt_sampler.set_templates("evaluator_system_message")
# Pass random seed to database if specified
diff --git a/openevolve/database.py b/openevolve/database.py
index 8abe2bdc0a..e4d28defaa 100644
--- a/openevolve/database.py
+++ b/openevolve/database.py
@@ -20,6 +20,7 @@
from openevolve.config import DatabaseConfig
from openevolve.utils.code_utils import calculate_edit_distance
from openevolve.utils.metrics_utils import safe_numeric_average, get_fitness_score
+from openevolve.pareto import dominates, objective_values, pareto_keys, key_of
logger = logging.getLogger(__name__)
@@ -126,6 +127,9 @@ def __init__(self, config: DatabaseConfig):
# In-memory program storage
self.programs: Dict[str, Program] = {}
+ # Pareto selection (config.objectives): (rank, crowding, key) per program, recomputed when the population changes
+ self._pareto_cache: Dict[str, Tuple[int, float, float]] = {}
+ self._pareto_dirty: bool = True
# Per-island feature grids for MAP-Elites
self.island_feature_maps: List[Dict[str, str]] = [{} for _ in range(config.num_islands)]
@@ -232,6 +236,7 @@ def add(
self.last_iteration = max(self.last_iteration, iteration)
self.programs[program.id] = program
+ self._pareto_dirty = True
# Calculate feature coordinates for MAP-Elites
feature_coords = self._calculate_feature_coords(program)
@@ -323,10 +328,8 @@ def add(
existing_program_id = island_feature_map[feature_key]
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
- )
+ new_fitness = self.fitness(program)
+ existing_fitness = self.fitness(existing_program)
logger.info(
"Island %d MAP-Elites cell improved: %s (fitness: %.3f -> %.3f)",
island_idx,
@@ -494,6 +497,15 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]:
if not self.programs:
return None
+ # Pareto selection: the reported best is the front member chosen by config.best_selection
+ if metric is None and self.config.objectives:
+ best = self._best_on_front()
+ if best is not None:
+ if best.id != self.best_program_id:
+ logger.info(f"Updated best program tracking from {self.best_program_id} to {best.id} (Pareto front, {self.config.best_selection})")
+ self.best_program_id = best.id
+ return best
+
# 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:
@@ -518,7 +530,7 @@ def get_best_program(self, metric: Optional[str] = None) -> Optional[Program]:
# Sort by fitness (excluding feature dimensions)
sorted_programs = sorted(
self.programs.values(),
- key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
+ key=lambda p: self.fitness(p),
reverse=True,
)
if sorted_programs:
@@ -593,7 +605,7 @@ def get_top_programs(
# Sort by combined_score if available, otherwise by average of all numeric metrics
sorted_programs = sorted(
candidates,
- key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
+ key=lambda p: self.fitness(p),
reverse=True,
)
@@ -646,6 +658,17 @@ def save(self, path: Optional[str] = None, iteration: int = 0) -> None:
with open(os.path.join(save_path, "metadata.json"), "w") as f:
json.dump(metadata, f)
+ if self.config.objectives:
+ front = self.pareto_front()
+ with open(os.path.join(save_path, "pareto_front.json"), "w") as f:
+ json.dump({
+ "objectives": list(self.config.objectives),
+ "best_selection": self.config.best_selection,
+ "best_program_id": self.best_program_id,
+ "front": [{"id": p.id, "objectives": {o: p.metrics.get(o) for o in self.config.objectives},
+ "crowding": self._pareto_cache[p.id][1]} for p in front],
+ }, f, indent=1)
+
logger.info(f"Saved database with {len(self.programs)} programs to {save_path}")
def load(self, path: str) -> None:
@@ -699,6 +722,7 @@ def load(self, path: str) -> None:
program = Program.from_dict(program_data)
self.programs[program.id] = program
+ self._pareto_dirty = True
except Exception as e:
logger.warning(f"Error loading program {program_file}: {str(e)}")
@@ -888,7 +912,7 @@ def _calculate_feature_coords(self, program: Program) -> List[int]:
bin_idx = 0
else:
# Use fitness score for "score" dimension (consistent with rest of system)
- avg_score = get_fitness_score(program.metrics, self.config.feature_dimensions)
+ avg_score = self.fitness(program)
# Update stats and scale
self._update_feature_stats("score", avg_score)
scaled_value = self._scale_feature_value("score", avg_score)
@@ -1108,6 +1132,66 @@ def _is_novel(self, program_id: int, island_idx: int) -> bool:
return self._llm_judge_novelty(program, self.programs[max_smlty_pid])
+ # ---- Pareto selection -------------------------------------------------------------------------------------
+ def objective_values(self, program: Program):
+ """The program's objective vector (larger is better on every coordinate), or None if it lacks an objective."""
+ if not self.config.objectives:
+ return None
+ return objective_values(program.metrics, self.config.objectives, self.config.objective_directions)
+
+ def _refresh_pareto(self) -> None:
+ self._pareto_cache = pareto_keys([(pid, self.objective_values(p)) for pid, p in self.programs.items()])
+ self._pareto_dirty = False
+
+ def pareto_rank(self, program: Program) -> Tuple[int, float]:
+ """(rank, crowding distance) of a program against the current population."""
+ if self._pareto_dirty or program.id not in self._pareto_cache:
+ self._refresh_pareto()
+ if program.id in self._pareto_cache:
+ r, c, _ = self._pareto_cache[program.id]
+ return r, c
+ # not in the database (a candidate): rank it among the population without caching
+ items = [(pid, self.objective_values(p)) for pid, p in self.programs.items()] + [(program.id, self.objective_values(program))]
+ r, c, _ = pareto_keys(items)[program.id]
+ return r, c
+
+ def fitness(self, program: Program) -> float:
+ """The scalar every ranking in this class sorts on: the single fitness (combined_score or the average of
+ non-feature metrics) or, with config.objectives, the Pareto key -rank + crowding term (openevolve/pareto.py)."""
+ if not self.config.objectives:
+ return get_fitness_score(program.metrics, self.config.feature_dimensions)
+ r, c = self.pareto_rank(program)
+ return key_of(r, c)
+
+ def pareto_front(self) -> List[Program]:
+ """The rank-0 programs (empty without objectives)."""
+ if not self.config.objectives or not self.programs:
+ return []
+ if self._pareto_dirty:
+ self._refresh_pareto()
+ return [self.programs[pid] for pid, (r, _, _) in self._pareto_cache.items() if r == 0 and pid in self.programs]
+
+ def _best_on_front(self) -> Optional[Program]:
+ """The reported best under config.best_selection; None without objectives or programs."""
+ front = self.pareto_front()
+ if not front:
+ return None
+ rule = self.config.best_selection
+ if rule == "crowding": # the most isolated member: a boundary point, i.e. an extreme
+ return max(front, key=lambda p: self._pareto_cache[p.id][1])
+ if rule == "first_objective": # orders the objectives, for reporting only
+ return max(front, key=lambda p: (self.objective_values(p) or (float("-inf"),))[0])
+ # knee (default): each objective normalized to its range on the front, the member closest to the ideal point
+ # (the best value of every objective at once). No objective is preferred; a front of one or two members or a
+ # degenerate range falls back to the member with the largest normalized sum.
+ vals = {p.id: self.objective_values(p) for p in front}
+ m = len(self.config.objectives)
+ lo = [min(v[j] for v in vals.values()) for j in range(m)]
+ hi = [max(v[j] for v in vals.values()) for j in range(m)]
+ def norm(v):
+ return [((v[j] - lo[j]) / (hi[j] - lo[j])) if hi[j] > lo[j] else 1.0 for j in range(m)]
+ return min(front, key=lambda p: sum((1.0 - x) ** 2 for x in norm(vals[p.id])))
+
def _is_better(self, program1: Program, program2: Program) -> bool:
"""
Determine if program1 has better FITNESS than program2
@@ -1132,9 +1216,22 @@ def _is_better(self, program1: Program, program2: Program) -> bool:
if not program1.metrics and program2.metrics:
return False
+ # Pareto: dominance decides when it can; otherwise rank, then crowding (both inside self.fitness)
+ if self.config.objectives:
+ v1, v2 = self.objective_values(program1), self.objective_values(program2)
+ if v1 is not None and v2 is not None:
+ if dominates(v1, v2):
+ return True
+ if dominates(v2, v1):
+ return False
+ elif v1 is not None:
+ return True
+ elif v2 is not None:
+ return False
+
# Compare fitness (excluding feature dimensions)
- fitness1 = get_fitness_score(program1.metrics, self.config.feature_dimensions)
- fitness2 = get_fitness_score(program2.metrics, self.config.feature_dimensions)
+ fitness1 = self.fitness(program1)
+ fitness2 = self.fitness(program2)
return fitness1 > fitness2
@@ -1174,7 +1271,7 @@ def _update_archive(self, program: Program) -> None:
if valid_archive_programs:
worst_program = min(
valid_archive_programs,
- key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
+ key=lambda p: self.fitness(p),
)
# Replace if new program is better
@@ -1209,6 +1306,13 @@ def _update_best_program(self, program: Program) -> None:
current_best = self.programs[self.best_program_id]
+ if self.config.objectives:
+ best = self._best_on_front()
+ if best is not None and best.id != self.best_program_id:
+ logger.info(f"New best program {best.id} replaces {self.best_program_id} (Pareto front, {self.config.best_selection})")
+ self.best_program_id = best.id
+ return
+
# Update if the new program is better
if self._is_better(program, current_best):
old_id = self.best_program_id
@@ -1468,7 +1572,7 @@ def _sample_from_island_weighted(self, island_id: int) -> Program:
# Calculate weights based on fitness scores
weights = []
for prog in island_program_objects:
- fitness = get_fitness_score(prog.metrics, self.config.feature_dimensions)
+ fitness = self.fitness(prog)
# Add small epsilon to avoid zero weights
weights.append(max(fitness, 0.001))
@@ -1724,6 +1828,7 @@ def _remove_program_if_orphaned(self, program_id: str) -> None:
# Fully orphaned - remove from all remaining structures.
del self.programs[program_id]
+ self._pareto_dirty = True
self.archive.discard(program_id)
self._cleanup_stale_island_bests()
logger.debug(f"Removed orphaned program {program_id} displaced from its cell")
@@ -1762,11 +1867,11 @@ def _enforce_population_limit(self, exclude_program_id: Optional[str] = None) ->
# 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),
+ key=lambda p: self.fitness(p),
)
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),
+ key=lambda p: self.fitness(p),
)
# Remove non-elite programs first; only fall back to evicting elite cell
@@ -1783,6 +1888,7 @@ def _enforce_population_limit(self, exclude_program_id: Optional[str] = None) ->
# Remove from main programs dict
if program_id in self.programs:
del self.programs[program_id]
+ self._pareto_dirty = True
# Remove from island feature maps
for island_idx, island_map in enumerate(self.island_feature_maps):
@@ -1852,7 +1958,7 @@ def migrate_programs(self) -> None:
# Sort by fitness (using combined_score or average metrics)
island_programs.sort(
- key=lambda p: get_fitness_score(p.metrics, self.config.feature_dimensions),
+ key=lambda p: self.fitness(p),
reverse=True,
)
@@ -2033,7 +2139,7 @@ def get_island_stats(self) -> List[dict]:
if island_programs:
scores = [
- get_fitness_score(p.metrics, self.config.feature_dimensions)
+ self.fitness(p)
for p in island_programs
]
diff --git a/openevolve/pareto.py b/openevolve/pareto.py
new file mode 100644
index 0000000000..cf9efdb835
--- /dev/null
+++ b/openevolve/pareto.py
@@ -0,0 +1,109 @@
+"""Pareto (non-dominated) selection over several objectives.
+
+With ``database.objectives`` set, programs are compared by dominance instead of by one scalar: A is better than B if A
+is at least as good on every objective and strictly better on one. Programs that neither dominates are ordered by
+NSGA-II's two secondary keys, computed against the current population:
+
+* **rank**: peel the non-dominated set (rank 0), remove it, peel again (rank 1), and so on; lower is better. A rank-k
+ program is dominated only by programs of lower rank.
+* **crowding distance**: within a front, the sum over objectives of the normalized gap between a program's two
+ nearest neighbours on that objective; boundary programs (the best on some objective) get infinity. Larger is better,
+ because it prefers programs in sparsely populated parts of the front and keeps the front spread out instead of
+ letting near-duplicates accumulate.
+
+These are the rules of Deb et al., NSGA-II (2002). No objective is weighted or ordered above another.
+
+The database turns (rank, crowding) into one float, ``key = -rank + 0.999 * crowding / (1 + crowding)`` (infinity ->
+0.999), so that every existing "sort by fitness" call can keep working: rank always dominates, crowding only orders
+programs of equal rank. The key is relative to the population and is recomputed whenever the population changes.
+"""
+from __future__ import annotations
+
+import math
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+import numpy as np
+
+Values = Tuple[float, ...]
+
+
+def objective_values(metrics: Dict[str, Any], objectives: Sequence[str], directions: Optional[Sequence[str]] = None) -> Optional[Values]:
+ """The objective vector of a metrics dict, oriented so that larger is better on every coordinate
+ (``directions`` entries are "max" (default) or "min"). None if any objective is missing or not a finite number."""
+ directions = list(directions or [])
+ directions += ["max"] * (len(objectives) - len(directions))
+ out = []
+ for name, direction in zip(objectives, directions):
+ v = metrics.get(name)
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
+ return None
+ v = float(v)
+ if not math.isfinite(v):
+ return None
+ out.append(v if direction == "max" else -v)
+ return tuple(out)
+
+
+def dominates(a: Values, b: Values) -> bool:
+ """a is at least as good as b everywhere and strictly better somewhere."""
+ return all(x >= y for x, y in zip(a, b)) and any(x > y for x, y in zip(a, b))
+
+
+def non_dominated_sort(points: Sequence[Values]) -> List[List[int]]:
+ """Fronts as lists of indices into ``points``; fronts[0] is the non-dominated set."""
+ n = len(points)
+ if n == 0:
+ return []
+ P = np.asarray(points, dtype=float)
+ ge = (P[:, None, :] >= P[None, :, :]).all(axis=2)
+ gt = (P[:, None, :] > P[None, :, :]).any(axis=2)
+ dom = ge & gt # dom[i, j]: i dominates j
+ remaining = np.ones(n, dtype=bool)
+ fronts: List[List[int]] = []
+ while remaining.any():
+ dominated_by_remaining = (dom & remaining[:, None]).any(axis=0) # j is dominated by some remaining i
+ front = np.where(remaining & ~dominated_by_remaining)[0]
+ fronts.append(front.tolist())
+ remaining[front] = False
+ return fronts
+
+
+def crowding_distance(points: Sequence[Values]) -> List[float]:
+ """NSGA-II crowding distance of each point within one front (boundary points: infinity)."""
+ n = len(points)
+ if n == 0:
+ return []
+ if n <= 2:
+ return [math.inf] * n
+ P = np.asarray(points, dtype=float)
+ d = np.zeros(n)
+ for j in range(P.shape[1]):
+ order = np.argsort(P[:, j], kind="stable")
+ lo, hi = P[order[0], j], P[order[-1], j]
+ d[order[0]] = d[order[-1]] = math.inf
+ if hi > lo:
+ gaps = (P[order[2:], j] - P[order[:-2], j]) / (hi - lo)
+ d[order[1:-1]] += gaps
+ return d.tolist()
+
+
+def key_of(rank: int, crowding: float) -> float:
+ c = 0.999 if math.isinf(crowding) else 0.999 * crowding / (1.0 + crowding)
+ return -float(rank) + c
+
+
+def pareto_keys(items: Sequence[Tuple[str, Optional[Values]]]) -> Dict[str, Tuple[int, float, float]]:
+ """{id: (rank, crowding, key)} for a population; items whose values are None (missing objectives) get the rank
+ below the last front and no crowding."""
+ valid = [(i, v) for i, v in items if v is not None]
+ out: Dict[str, Tuple[int, float, float]] = {}
+ fronts = non_dominated_sort([v for _, v in valid])
+ for rank, front in enumerate(fronts):
+ cd = crowding_distance([valid[i][1] for i in front])
+ for i, c in zip(front, cd):
+ out[valid[i][0]] = (rank, c, key_of(rank, c))
+ worst = len(fronts)
+ for i, v in items:
+ if v is None:
+ out[i] = (worst, 0.0, key_of(worst, 0.0))
+ return out
diff --git a/openevolve/process_parallel.py b/openevolve/process_parallel.py
index b2cfeab788..a7c2b0dec6 100644
--- a/openevolve/process_parallel.py
+++ b/openevolve/process_parallel.py
@@ -109,7 +109,7 @@ def _lazy_init_worker_components():
if _worker_prompt_sampler is None:
from openevolve.prompt.sampler import PromptSampler
- _worker_prompt_sampler = PromptSampler(_worker_config.prompt)
+ _worker_prompt_sampler = PromptSampler(_worker_config.prompt, objectives=list(_worker_config.database.objectives or []))
if _worker_evaluator is None:
from openevolve.evaluator import Evaluator
@@ -118,7 +118,7 @@ def _lazy_init_worker_components():
# Create evaluator-specific components
evaluator_llm = LLMEnsemble(_worker_config.llm.evaluator_models)
- evaluator_prompt = PromptSampler(_worker_config.prompt)
+ evaluator_prompt = PromptSampler(_worker_config.prompt, objectives=list(_worker_config.database.objectives or []))
evaluator_prompt.set_templates("evaluator_system_message")
_worker_evaluator = Evaluator(
@@ -154,11 +154,17 @@ def _run_iteration_worker(
programs[pid] for pid in db_snapshot["islands"][parent_island] if pid in programs
]
- # Sort by metrics for top programs
- island_programs.sort(
- key=lambda p: p.metrics.get("combined_score", safe_numeric_average(p.metrics)),
- reverse=True,
- )
+ # Sort by metrics for top programs (Pareto key against the island when objectives are configured)
+ objectives = list(getattr(_worker_config.database, "objectives", []) or [])
+ if objectives:
+ from openevolve.pareto import objective_values, pareto_keys
+ keys = pareto_keys([(p.id, objective_values(p.metrics, objectives, _worker_config.database.objective_directions)) for p in island_programs])
+ island_programs.sort(key=lambda p: keys[p.id][2], reverse=True)
+ else:
+ island_programs.sort(
+ key=lambda p: p.metrics.get("combined_score", safe_numeric_average(p.metrics)),
+ reverse=True,
+ )
# Use config values for limits instead of hardcoding
# Programs for LLM display (includes both top and diverse for inspiration)
diff --git a/openevolve/prompt/sampler.py b/openevolve/prompt/sampler.py
index 0febbe5fd4..0f59ac74e1 100644
--- a/openevolve/prompt/sampler.py
+++ b/openevolve/prompt/sampler.py
@@ -10,9 +10,9 @@
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,
+ get_fitness_score,
+ safe_numeric_average,
)
logger = logging.getLogger(__name__)
@@ -21,8 +21,10 @@
class PromptSampler:
"""Generates prompts for code evolution"""
- def __init__(self, config: PromptConfig):
+ def __init__(self, config: PromptConfig, objectives: Optional[List[str]] = None):
self.config = config
+ # Pareto selection (database.objectives): the prompt shows every objective's value instead of one fitness
+ self.objectives = list(objectives or [])
self.template_manager = TemplateManager(custom_template_dir=config.template_dir)
# Store custom template mappings
@@ -150,12 +152,13 @@ def build_prompt(
# Calculate fitness and feature coordinates for the new template format
feature_dimensions = feature_dimensions or []
fitness_score = get_fitness_score(program_metrics, feature_dimensions)
+ fitness_str = self._score_str(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,
- fitness_score=f"{fitness_score:.4f}",
+ fitness_score=fitness_str,
feature_coords=feature_coords,
feature_dimensions=", ".join(feature_dimensions) if feature_dimensions else "None",
improvement_areas=improvement_areas,
@@ -179,6 +182,16 @@ def build_prompt(
"user": user_message,
}
+ def _score_str(self, metrics: Dict[str, Any], feature_dimensions: Optional[List[str]] = None) -> str:
+ """One fitness as text; with Pareto objectives, every objective's value (no single number exists)."""
+ if self.objectives:
+ parts = []
+ for o in self.objectives:
+ v = metrics.get(o)
+ parts.append(f"{o}={v:.4f}" if isinstance(v, (int, float)) and not isinstance(v, bool) else f"{o}=n/a")
+ return ", ".join(parts)
+ return f"{get_fitness_score(metrics, feature_dimensions or []):.4f}"
+
def _format_metrics(self, metrics: Dict[str, float]) -> str:
"""Format metrics for the prompt using safe formatting"""
# Use safe formatting to handle mixed numeric and string values
@@ -345,7 +358,7 @@ def _format_evolution_history(
program_code = "" if use_changes else ""
# Calculate fitness score (prefers combined_score, excludes feature dimensions)
- score = get_fitness_score(program.get("metrics", {}), feature_dimensions or [])
+ score = self._score_str(program.get("metrics", {}), feature_dimensions or [])
# Extract key features (this could be more sophisticated)
key_features = program.get("key_features", [])
@@ -374,7 +387,7 @@ def _format_evolution_history(
top_programs_str += (
top_program_template.format(
program_number=i + 1,
- score=f"{score:.4f}",
+ score=score,
language=("text" if self.config.programs_as_changes_description else language),
program_snippet=program_code,
key_features=key_features_str,
@@ -415,7 +428,7 @@ def _format_evolution_history(
program_code = "" if use_changes else ""
# Calculate fitness score (prefers combined_score, excludes feature dimensions)
- score = get_fitness_score(program.get("metrics", {}), feature_dimensions or [])
+ score = self._score_str(program.get("metrics", {}), feature_dimensions or [])
# Extract key features
key_features = program.get("key_features", [])
@@ -433,7 +446,7 @@ def _format_evolution_history(
diverse_programs_str += (
top_program_template.format(
program_number=f"D{i + 1}",
- score=f"{score:.4f}",
+ score=score,
language=(
"text" if self.config.programs_as_changes_description else language
),
@@ -500,7 +513,7 @@ def _format_inspirations_section(
program_code = "" if use_changes else ""
# Calculate fitness score (prefers combined_score, excludes feature dimensions)
- score = get_fitness_score(program.get("metrics", {}), feature_dimensions or [])
+ score = self._score_str(program.get("metrics", {}), feature_dimensions or [])
# Determine program type based on metadata and score
program_type = self._determine_program_type(program, feature_dimensions or [])
@@ -511,7 +524,7 @@ def _format_inspirations_section(
inspiration_programs_str += (
inspiration_program_template.format(
program_number=i + 1,
- score=f"{score:.4f}",
+ score=score,
program_type=program_type,
language=("text" if self.config.programs_as_changes_description else language),
program_snippet=program_code,
@@ -537,7 +550,7 @@ def _determine_program_type(
String describing the program type
"""
metadata = program.get("metadata", {})
- score = get_fitness_score(program.get("metrics", {}), feature_dimensions or [])
+ score = get_fitness_score(program.get("metrics", {}), feature_dimensions or []) # numeric: classified below
# Check metadata for explicit type markers
if metadata.get("diverse", False):
diff --git a/tests/test_pareto.py b/tests/test_pareto.py
new file mode 100644
index 0000000000..b426cfe651
--- /dev/null
+++ b/tests/test_pareto.py
@@ -0,0 +1,111 @@
+"""Pareto selection (config.database.objectives): dominance, fronts, crowding, and the database's use of them."""
+import math
+import tempfile
+import unittest
+
+from openevolve.config import Config
+from openevolve.database import Program, ProgramDatabase
+from openevolve.pareto import crowding_distance, dominates, non_dominated_sort, objective_values, pareto_keys
+
+
+class TestParetoModule(unittest.TestCase):
+ def test_dominance(self):
+ self.assertTrue(dominates((2, 2), (1, 2)))
+ self.assertFalse(dominates((2, 1), (1, 2))) # trade-off: neither dominates
+ self.assertFalse(dominates((1, 2), (2, 1)))
+ self.assertFalse(dominates((1, 1), (1, 1))) # equal: not strictly better anywhere
+
+ def test_fronts(self):
+ pts = [(1, 1), (2, 2), (3, 1), (1, 3), (0, 0)]
+ fronts = non_dominated_sort(pts)
+ self.assertEqual(sorted(fronts[0]), [1, 2, 3]) # (2,2), (3,1), (1,3) are mutually non-dominated
+ self.assertEqual(fronts[1], [0]) # (1,1) is dominated by (2,2) only
+ self.assertEqual(fronts[2], [4])
+
+ def test_crowding_boundaries_and_interior(self):
+ cd = crowding_distance([(0, 3), (1, 2), (2, 1), (3, 0)])
+ self.assertTrue(math.isinf(cd[0]) and math.isinf(cd[3]))
+ self.assertAlmostEqual(cd[1], (2 - 0) / 3 + (3 - 1) / 3)
+ self.assertAlmostEqual(cd[1], cd[2])
+ self.assertEqual(crowding_distance([(1, 1)]), [math.inf])
+
+ def test_directions_and_missing(self):
+ self.assertEqual(objective_values({"a": 1.0, "b": 2.0}, ["a", "b"], ["max", "min"]), (1.0, -2.0))
+ self.assertIsNone(objective_values({"a": 1.0}, ["a", "b"]))
+ self.assertIsNone(objective_values({"a": True, "b": 1.0}, ["a", "b"]))
+
+ def test_keys_order_rank_before_crowding(self):
+ keys = pareto_keys([("p", (2, 2)), ("q", (3, 1)), ("r", (1, 1)), ("s", None)])
+ self.assertEqual(keys["p"][0], 0); self.assertEqual(keys["q"][0], 0); self.assertEqual(keys["r"][0], 1)
+ self.assertGreater(min(keys["p"][2], keys["q"][2]), keys["r"][2]) # any rank-0 key beats any rank-1 key
+ self.assertGreater(keys["r"][2], keys["s"][2]) # missing objectives rank last
+
+
+def _db(objectives=None, **kw):
+ config = Config()
+ config.database.in_memory = True
+ config.database.num_islands = 1
+ config.database.feature_dimensions = ["complexity"]
+ if objectives:
+ config.database.objectives = objectives
+ for k, v in kw.items():
+ setattr(config.database, k, v)
+ return ProgramDatabase(config.database)
+
+
+def _prog(pid, **metrics):
+ return Program(id=pid, code=f"def f_{pid}(): pass # " + "x" * len(pid), language="python", metrics=metrics)
+
+
+class TestParetoDatabase(unittest.TestCase):
+ def test_single_fitness_unchanged_without_objectives(self):
+ db = _db()
+ a, b = _prog("a", combined_score=1.0), _prog("b", combined_score=2.0)
+ db.add(a); db.add(b)
+ self.assertTrue(db._is_better(b, a))
+ self.assertEqual(db.get_best_program().id, "b")
+ self.assertEqual(db.pareto_front(), [])
+
+ def test_dominance_decides(self):
+ db = _db(["primary", "robust"])
+ a, b = _prog("a", primary=1.0, robust=1.0), _prog("b", primary=2.0, robust=1.0)
+ db.add(a); db.add(b)
+ self.assertTrue(db._is_better(b, a)); self.assertFalse(db._is_better(a, b))
+
+ def test_tradeoff_uses_rank_then_crowding(self):
+ db = _db(["primary", "robust"])
+ for pid, p, r in [("a", 3.0, 0.0), ("b", 2.0, 2.0), ("c", 2.1, 1.9), ("d", 0.0, 3.0), ("e", 1.0, 1.0)]:
+ db.add(_prog(pid, primary=p, robust=r))
+ front = {p.id for p in db.pareto_front()}
+ self.assertEqual(front, {"a", "b", "c", "d"})
+ self.assertEqual(db.pareto_rank(db.programs["e"])[0], 1)
+ # boundary members (best on one objective) are the least crowded: they outrank the interior pair
+ self.assertGreater(db.fitness(db.programs["a"]), db.fitness(db.programs["b"]))
+ self.assertTrue(db._is_better(db.programs["a"], db.programs["e"])) # rank 0 beats rank 1 without dominance
+ self.assertFalse(db._is_better(db.programs["e"], db.programs["a"]))
+
+ def test_best_selection_rules_and_front_file(self):
+ db = _db(["primary", "robust"])
+ db.add(_prog("a", primary=3.0, robust=0.0)); db.add(_prog("b", primary=0.0, robust=3.0)); db.add(_prog("c", primary=1.0, robust=1.0))
+ db.add(_prog("d", primary=0.5, robust=0.5)) # dominated by c
+ self.assertEqual(db.get_best_program().id, "a") # first_objective (default): best primary on the front
+ db.config.best_selection = "knee"
+ self.assertEqual(db.get_best_program().id, "c") # knee: the balanced member (1,1), closest to the ideal (3,3) after normalization
+ db.config.best_selection = "crowding"
+ self.assertIn(db.get_best_program().id, {"a", "b"}) # both boundary points have infinite crowding
+ with tempfile.TemporaryDirectory() as d:
+ db.save(d)
+ import json, os
+ front = json.load(open(os.path.join(d, "pareto_front.json")))
+ self.assertEqual({f["id"] for f in front["front"]}, {"a", "b", "c"}) # (1,1) is dominated by neither boundary point
+ self.assertEqual(front["objectives"], ["primary", "robust"])
+
+ def test_missing_objective_ranks_last(self):
+ db = _db(["primary", "robust"])
+ db.add(_prog("a", primary=1.0, robust=1.0)); db.add(_prog("b", primary=5.0))
+ self.assertTrue(db._is_better(db.programs["a"], db.programs["b"]))
+ self.assertEqual(db.get_best_program().id, "a")
+
+
+if __name__ == "__main__":
+ unittest.main()