diff --git a/openevolve/llm/ensemble.py b/openevolve/llm/ensemble.py index 6df9dafe1..402afc888 100644 --- a/openevolve/llm/ensemble.py +++ b/openevolve/llm/ensemble.py @@ -3,10 +3,11 @@ """ import asyncio +import hashlib import logging import math import random -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from openevolve.llm.base import LLMInterface from openevolve.llm.openai import OpenAILLM @@ -97,11 +98,14 @@ def __init__(self, models_cfg: List[LLMModelConfig]): logger._ensemble_logged = True self.last_usage: Optional[Dict[str, Any]] = None + # Set by reseed(); gives each iteration's requests their own seed + self._iteration: Optional[int] = None async def generate(self, prompt: str, **kwargs) -> str: """Generate text using a randomly selected model based on weights""" - model = self._sample_model() - res = await model.generate(prompt, **kwargs) + index = self._sample_index() + model = self.models[index] + res = await model.generate(prompt, **self._request_kwargs(index, kwargs)) self.last_usage = getattr(model, "last_usage", None) return res @@ -109,8 +113,11 @@ async def generate_with_context( self, system_message: str, messages: List[Dict[str, str]], **kwargs ) -> str: """Generate text using a system message and conversational context""" - model = self._sample_model() - res = await model.generate_with_context(system_message, messages, **kwargs) + index = self._sample_index() + model = self.models[index] + res = await model.generate_with_context( + system_message, messages, **self._request_kwargs(index, kwargs) + ) self.last_usage = getattr(model, "last_usage", None) return res @@ -120,13 +127,29 @@ def reseed(self, iteration: int) -> None: seed = getattr(self.models_cfg[0], "random_seed", None) if self.models_cfg else None if seed is not None: self.random_state.seed(f"{seed}:{iteration}") + self._iteration = iteration + + def _request_kwargs(self, index: int, kwargs: Dict[str, Any]) -> Dict[str, Any]: + """After reseed(iteration), send a seed derived from the model's seed and the + iteration. A fixed seed would make every identical prompt (common early on, when + iterations share a parent) return the identical response on providers that honour it""" + if self._iteration is None or "seed" in kwargs: + return kwargs + seed = getattr(self.models_cfg[index], "random_seed", None) + if seed is None: + return kwargs + digest = hashlib.sha256(f"{seed}:{self._iteration}:request".encode()).digest() + return {**kwargs, "seed": int.from_bytes(digest[:4], "big") & 0x7FFFFFFF} + + def _sample_index(self) -> int: + """Index of a model sampled from the ensemble based on weights""" + index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0] + logger.info(f"Sampled model: {self.models_cfg[index].name}") + return index def _sample_model(self) -> LLMInterface: """Sample a model from the ensemble based on weights""" - index = self.random_state.choices(range(len(self.models)), weights=self.weights, k=1)[0] - sampled_model = self.models[index] - logger.info(f"Sampled model: {self.models_cfg[index].name}") - return sampled_model + return self.models[self._sample_index()] async def generate_multiple(self, prompt: str, n: int, **kwargs) -> List[str]: """Generate multiple texts in parallel""" diff --git a/tests/test_ensemble_worker_seed.py b/tests/test_ensemble_worker_seed.py index d5a11b428..608543f22 100644 --- a/tests/test_ensemble_worker_seed.py +++ b/tests/test_ensemble_worker_seed.py @@ -119,6 +119,49 @@ def test_same_iteration_picks_same_model_on_any_worker(self): self.assertEqual(picked_forward, picked_backward) self.assertEqual(picked_late[5], picked_forward[5]) + def _request_seeds(self, config_dict, iterations): + """The seed each iteration's LLM request carries, run in a fresh worker""" + process_parallel_module._worker_init(config_dict, "evaluator.py", None) + process_parallel_module._worker_prompt_sampler = MagicMock() + process_parallel_module._worker_prompt_sampler.build_prompt.return_value = { + "system": "sys", + "user": "usr", + } + process_parallel_module._worker_evaluator = MagicMock() + seeds = [] + + async def record_seed(llm, system_message, messages, **kwargs): + seeds.append(kwargs.get("seed")) + return None + + with patch.object(OpenAILLM, "generate_with_context", record_seed): + for iteration in iterations: + process_parallel_module._run_iteration_worker( + iteration, self.snapshot, "parent", [] + ) + return seeds + + def test_identical_prompts_get_a_different_seed_each_iteration(self): + # Same prompt every iteration: with one fixed request seed, a provider that honours + # seeds returns the same response every time + seeds = self._request_seeds(self._config_dict(seed=123), range(ITERATIONS)) + + self.assertEqual(len(set(seeds)), ITERATIONS) + self.assertTrue(all(isinstance(s, int) and 0 <= s < 2**31 for s in seeds)) + + def test_request_seed_depends_on_iteration_only(self): + config_dict = self._config_dict(seed=123) + forward = self._request_seeds(config_dict, [3, 4, 5]) + alone = self._request_seeds(config_dict, [5]) + + self.assertEqual(alone, forward[-1:]) + self.assertNotEqual( + self._request_seeds(self._config_dict(seed=124), [5]), alone, "seed is used" + ) + + def test_unseeded_requests_carry_no_seed(self): + self.assertEqual(self._request_seeds(self._config_dict(seed=None), [1, 2]), [None, None]) + def test_unseeded_selection_stays_random(self): config_dict = self._config_dict(seed=None) picks = self._worker(config_dict, [7] * 64)