Skip to content

Commit 08d83ad

Browse files
committed
add bounded parallel self-hosting controls
1 parent 411fb59 commit 08d83ad

17 files changed

Lines changed: 1727 additions & 59 deletions

openevolve/api.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import uuid
99
import inspect
1010
from typing import Union, Callable, Optional, List, Dict, Any, Tuple
11-
from dataclasses import dataclass
11+
from dataclasses import dataclass, field
1212
from pathlib import Path
1313

1414
from openevolve.controller import OpenEvolve
@@ -25,6 +25,10 @@ class EvolutionResult:
2525
best_code: str
2626
metrics: Dict[str, Any]
2727
output_dir: Optional[str]
28+
completion_reason: str = "unknown"
29+
last_completed_iteration: Optional[int] = None
30+
completed_iteration_count: int = 0
31+
llm_usage: Dict[str, Any] = field(default_factory=dict)
2832

2933
def __repr__(self):
3034
return f"EvolutionResult(best_score={self.best_score:.4f})"
@@ -178,12 +182,17 @@ async def _run_evolution_async(
178182
if numeric_metrics:
179183
best_score = sum(numeric_metrics) / len(numeric_metrics)
180184

185+
llm_usage = getattr(controller, "llm_usage", {})
181186
return EvolutionResult(
182187
best_program=best_program,
183188
best_score=best_score,
184189
best_code=best_code,
185190
metrics=metrics,
186191
output_dir=actual_output_dir if not cleanup else None,
192+
completion_reason=controller.completion_reason,
193+
last_completed_iteration=controller.last_completed_iteration,
194+
completed_iteration_count=controller.completed_iteration_count,
195+
llm_usage=dict(llm_usage) if isinstance(llm_usage, dict) else {},
187196
)
188197

189198
finally:

openevolve/config.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import re
7+
import math
78
from dataclasses import asdict, dataclass, field
89
from pathlib import Path
910
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
@@ -306,6 +307,20 @@ class PromptConfig:
306307
)
307308

308309

310+
@dataclass
311+
class ControllerSchedulerConfig:
312+
"""Optional observed-result island allocator for bounded live comparisons."""
313+
314+
enabled: bool = False
315+
exploitation_weight: float = 0.0
316+
underexplored_weight: float = 0.0
317+
validity_weight: float = 0.0
318+
diversity_weight: float = 0.0
319+
token_efficiency_weight: float = 0.0
320+
rejection_penalty: float = 0.0
321+
minimum_calls: int = 1
322+
323+
309324
@dataclass
310325
class DatabaseConfig:
311326
"""Configuration for the program database"""
@@ -326,6 +341,9 @@ class DatabaseConfig:
326341
elite_selection_ratio: float = 0.1
327342
exploration_ratio: float = 0.2
328343
exploitation_ratio: float = 0.7
344+
controller_scheduler: ControllerSchedulerConfig = field(
345+
default_factory=ControllerSchedulerConfig
346+
)
329347
# Note: diversity_metric fixed to "edit_distance"
330348
diversity_metric: str = "edit_distance" # Options: "edit_distance", "feature_based"
331349

@@ -418,6 +436,10 @@ class Config:
418436

419437
# General settings
420438
max_iterations: int = 10000
439+
# Optional per-run proposal-model budgets. Calls are reserved before
440+
# submission; provider-token totals come from completed unique receipts.
441+
max_llm_calls: Optional[int] = None
442+
max_total_provider_tokens: Optional[int] = None
421443
checkpoint_interval: int = 100
422444
log_level: str = "INFO"
423445
log_dir: Optional[str] = None
@@ -436,6 +458,9 @@ class Config:
436458
diff_based_evolution: bool = True
437459
max_code_length: int = 10000
438460
diff_pattern: str = r"<<<<<<< SEARCH\n(.*?)=======\n(.*?)>>>>>>> REPLACE"
461+
strict_diff_application: bool = False
462+
enforce_evolve_blocks: bool = False
463+
max_diff_blocks: int = 32
439464

440465
# Early stopping settings
441466
early_stopping_patience: Optional[int] = None
@@ -489,13 +514,67 @@ def from_dict(cls, config_dict: Dict[str, Any]) -> "Config":
489514
if config.database.random_seed is None and config.random_seed is not None:
490515
config.database.random_seed = config.random_seed
491516

492-
if config.prompt.programs_as_changes_description and not config.diff_based_evolution:
517+
config.validate()
518+
return config
519+
520+
def validate(self) -> None:
521+
"""Validate combinations used by both YAML and programmatic callers."""
522+
for field_name in ("max_llm_calls", "max_total_provider_tokens"):
523+
value = getattr(self, field_name)
524+
if value is not None and (
525+
isinstance(value, bool) or not isinstance(value, int) or value < 1
526+
):
527+
raise ValueError(f"{field_name} must be a positive integer or None")
528+
try:
529+
re.compile(self.diff_pattern)
530+
except re.error as error:
531+
raise ValueError(f"Invalid regex pattern in diff_pattern: {error}") from error
532+
if self.prompt.programs_as_changes_description and not self.diff_based_evolution:
493533
raise ValueError(
494534
"prompt.programs_as_changes_description=true requires diff_based_evolution=true "
495535
"(full rewrites cannot reliably update code and changes_description together)"
496536
)
497-
498-
return config
537+
if self.enforce_evolve_blocks and not self.strict_diff_application:
538+
raise ValueError(
539+
"enforce_evolve_blocks=true requires strict_diff_application=true"
540+
)
541+
if self.enforce_evolve_blocks and not self.diff_based_evolution:
542+
raise ValueError(
543+
"enforce_evolve_blocks=true requires diff_based_evolution=true"
544+
)
545+
if self.max_diff_blocks < 1:
546+
raise ValueError("max_diff_blocks must be at least 1")
547+
scheduler = self.database.controller_scheduler
548+
if not isinstance(scheduler.enabled, bool):
549+
raise ValueError("database.controller_scheduler.enabled must be boolean")
550+
for field_name in (
551+
"exploitation_weight",
552+
"underexplored_weight",
553+
"validity_weight",
554+
"diversity_weight",
555+
"token_efficiency_weight",
556+
"rejection_penalty",
557+
):
558+
value = getattr(scheduler, field_name)
559+
if (
560+
isinstance(value, bool)
561+
or not isinstance(value, (int, float))
562+
or not math.isfinite(float(value))
563+
or value < 0
564+
):
565+
raise ValueError(
566+
f"database.controller_scheduler.{field_name} "
567+
"must be finite and nonnegative"
568+
)
569+
if (
570+
isinstance(scheduler.minimum_calls, bool)
571+
or not isinstance(scheduler.minimum_calls, int)
572+
or scheduler.minimum_calls < 1
573+
):
574+
raise ValueError(
575+
"database.controller_scheduler.minimum_calls "
576+
"must be a positive integer"
577+
)
499578

500579
def to_dict(self) -> Dict[str, Any]:
501580
return asdict(self)

openevolve/controller.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
):
4949
# Load configuration (loaded in main_async)
5050
self.config = config
51+
self.config.validate()
5152

5253
# Set up output directory
5354
self.output_dir = output_dir or os.path.join(
@@ -163,6 +164,12 @@ def __init__(
163164

164165
# Initialize improved parallel processing components
165166
self.parallel_controller = None
167+
# Preserve authoritative run completion metadata after the process
168+
# controller is stopped and released.
169+
self.completion_reason = "not_started"
170+
self.last_completed_iteration: Optional[int] = None
171+
self.completed_iteration_count = 0
172+
self.llm_usage: Dict[str, Any] = {}
166173

167174
def _setup_logging(self) -> None:
168175
"""Set up logging"""
@@ -314,6 +321,7 @@ async def run(
314321
self.database,
315322
self.evolution_tracer,
316323
file_suffix=self.config.file_suffix,
324+
usage_output_path=os.path.join(self.output_dir, "llm_usage.jsonl"),
317325
)
318326

319327
# Set up signal handlers for graceful shutdown
@@ -354,6 +362,15 @@ def force_exit_handler(signum, frame):
354362
finally:
355363
# Clean up parallel processing resources
356364
if self.parallel_controller:
365+
self.completion_reason = self.parallel_controller.completion_reason
366+
self.last_completed_iteration = (
367+
self.parallel_controller.last_completed_iteration
368+
)
369+
self.completed_iteration_count = (
370+
self.parallel_controller.completed_iteration_count
371+
)
372+
llm_usage = getattr(self.parallel_controller, "llm_usage", {})
373+
self.llm_usage = dict(llm_usage) if isinstance(llm_usage, dict) else {}
357374
self.parallel_controller.stop()
358375
self.parallel_controller = None
359376

@@ -501,15 +518,36 @@ async def _run_evolution_with_checkpoints(
501518
if self.parallel_controller.shutdown_event.is_set():
502519
logger.info("Evolution stopped due to shutdown request")
503520
return
504-
elif self.parallel_controller.early_stopping_triggered:
521+
elif getattr(self.parallel_controller, "early_stopping_triggered", False) is True:
505522
logger.info("Evolution stopped due to early stopping - saving final checkpoint")
506523
# Continue to save final checkpoint for early stopping
507524

508-
# Save final checkpoint if needed
509-
# Note: start_iteration here is the evolution start (1 for fresh start, not 0)
510-
# max_iterations is the number of evolution iterations to run
511-
final_iteration = start_iteration + max_iterations - 1
512-
if final_iteration > 0 and final_iteration % self.config.checkpoint_interval == 0:
525+
# Bind the final checkpoint to work that actually completed. A target
526+
# reached by one worker can leave higher-numbered work in flight, so
527+
# the requested budget is not a valid completion cursor.
528+
final_iteration = getattr(
529+
self.parallel_controller, "last_completed_iteration", None
530+
)
531+
target_score_reached = (
532+
getattr(self.parallel_controller, "target_score_reached", False) is True
533+
)
534+
early_stopping_triggered = (
535+
getattr(self.parallel_controller, "early_stopping_triggered", False) is True
536+
)
537+
budget_triggered = bool(
538+
getattr(self.parallel_controller, "budget_completion_reason", None)
539+
)
540+
should_save_final = (
541+
isinstance(final_iteration, int)
542+
and final_iteration > 0
543+
and (
544+
target_score_reached
545+
or early_stopping_triggered
546+
or budget_triggered
547+
or final_iteration % self.config.checkpoint_interval == 0
548+
)
549+
)
550+
if should_save_final:
513551
self._save_checkpoint(final_iteration)
514552

515553
def _save_best_program(self, program: Optional[Program] = None) -> None:

openevolve/evaluation_result.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from typing import Dict, Union
88

99

10+
EVALUATION_FAILED_METRIC = "__evaluation_failed__"
11+
12+
1013
@dataclass
1114
class EvaluationResult:
1215
"""

openevolve/evaluator.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919

2020
from openevolve.config import EvaluatorConfig
2121
from openevolve.database import ProgramDatabase
22-
from openevolve.evaluation_result import EvaluationResult
22+
from openevolve.evaluation_result import (
23+
EVALUATION_FAILED_METRIC,
24+
EvaluationResult,
25+
)
2326
from openevolve.database import ProgramDatabase
2427
from openevolve.llm.ensemble import LLMEnsemble
2528
from openevolve.utils.async_utils import TaskPool, run_in_executor
@@ -262,7 +265,11 @@ async def evaluate_program(
262265
"error_type": "timeout",
263266
}
264267

265-
return {"error": 0.0, "timeout": True}
268+
return {
269+
EVALUATION_FAILED_METRIC: 1.0,
270+
"error": 0.0,
271+
"timeout": True,
272+
}
266273

267274
except Exception as e:
268275
last_exception = e
@@ -293,7 +300,7 @@ async def evaluate_program(
293300
logger.error(
294301
f"All evaluation attempts failed for program{program_id_str}. Last error: {str(last_exception)}"
295302
)
296-
return {"error": 0.0}
303+
return {EVALUATION_FAILED_METRIC: 1.0, "error": 0.0}
297304

298305
def _process_evaluation_result(self, result: Any) -> EvaluationResult:
299306
"""
@@ -314,7 +321,9 @@ def _process_evaluation_result(self, result: Any) -> EvaluationResult:
314321
else:
315322
# Error case - return error metrics
316323
logger.warning(f"Unexpected evaluation result type: {type(result)}")
317-
return EvaluationResult(metrics={"error": 0.0})
324+
return EvaluationResult(
325+
metrics={EVALUATION_FAILED_METRIC: 1.0, "error": 0.0}
326+
)
318327

319328
def get_pending_artifacts(self, program_id: str) -> Optional[Dict[str, Union[str, bytes]]]:
320329
"""

openevolve/llm/ensemble.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class LLMEnsemble:
3838

3939
def __init__(self, models_cfg: List[LLMModelConfig]):
4040
self.models_cfg = models_cfg
41+
self.last_call_metadata: Dict[str, object] = {}
4142

4243
# Initialize models from the configuration
4344
self.models = [_create_model(model_cfg) for model_cfg in models_cfg]
@@ -81,7 +82,9 @@ async def generate_with_context(
8182
) -> str:
8283
"""Generate text using a system message and conversational context"""
8384
model = self._sample_model()
84-
return await model.generate_with_context(system_message, messages, **kwargs)
85+
response = await model.generate_with_context(system_message, messages, **kwargs)
86+
self.last_call_metadata = dict(getattr(model, "last_call_metadata", {}) or {})
87+
return response
8588

8689
def _sample_model(self) -> LLMInterface:
8790
"""Sample a model from the ensemble based on weights"""

openevolve/llm/openai.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24+
def _uses_provider_managed_sampling(api_base: str | None, model: str | None) -> bool:
25+
"""Whether this provider/model pair requires sampling knobs to be omitted."""
26+
base = str(api_base or "").rstrip("/")
27+
name = str(model or "").lower()
28+
return (
29+
base.startswith("https://generativelanguage.googleapis.com/")
30+
and name.startswith(("gemini-3.5-", "gemini-3.6-"))
31+
)
32+
33+
2434
def _iso_now() -> str:
2535
return datetime.now(tz=timezone.utc).isoformat()
2636

@@ -63,6 +73,7 @@ def __init__(
6373
self.api_key = model_cfg.api_key
6474
self.random_seed = getattr(model_cfg, "random_seed", None)
6575
self.reasoning_effort = getattr(model_cfg, "reasoning_effort", None)
76+
self.last_call_metadata: Dict[str, Any] = {}
6677

6778
# Manual mode: enabled via llm.manual_mode in config.yaml
6879
self.manual_mode = (getattr(model_cfg, "manual_mode", False) is True)
@@ -154,12 +165,15 @@ async def generate_with_context(
154165
params = {
155166
"model": self.model,
156167
"messages": formatted_messages,
157-
"temperature": kwargs.get("temperature", self.temperature),
158168
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
159169
}
160-
top_p = kwargs.get("top_p", self.top_p)
161-
if top_p is not None:
162-
params["top_p"] = top_p
170+
if not _uses_provider_managed_sampling(self.api_base, self.model):
171+
temperature = kwargs.get("temperature", self.temperature)
172+
if temperature is not None:
173+
params["temperature"] = temperature
174+
top_p = kwargs.get("top_p", self.top_p)
175+
if top_p is not None:
176+
params["top_p"] = top_p
163177

164178
# Handle reasoning_effort for open source reasoning models.
165179
reasoning_effort = kwargs.get("reasoning_effort", self.reasoning_effort)
@@ -221,6 +235,22 @@ async def _call_api(self, params: Dict[str, Any]) -> str:
221235
response = await loop.run_in_executor(
222236
None, lambda: self.client.chat.completions.create(**params)
223237
)
238+
usage = getattr(response, "usage", None)
239+
usage_details = (
240+
usage.model_dump(mode="json")
241+
if usage is not None and hasattr(usage, "model_dump")
242+
else {}
243+
)
244+
self.last_call_metadata = {
245+
"provider_response_id": getattr(response, "id", None),
246+
"model": getattr(response, "model", None) or self.model,
247+
"usage": {
248+
"prompt_tokens": getattr(usage, "prompt_tokens", None),
249+
"completion_tokens": getattr(usage, "completion_tokens", None),
250+
"total_tokens": getattr(usage, "total_tokens", None),
251+
"details": usage_details,
252+
},
253+
}
224254
# Logging of system prompt, user message and response content
225255
logger = logging.getLogger(__name__)
226256
logger.debug(f"API parameters: {params}")

0 commit comments

Comments
 (0)