Skip to content

Commit 087e035

Browse files
committed
Reapply "feat(optim): add GEPA-style optimize_anything API and benchmark scaffold"
This reverts commit 118b8ad.
1 parent 0637773 commit 087e035

7 files changed

Lines changed: 469 additions & 0 deletions

File tree

adalflow/adalflow/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
TGDOptimizer,
5353
EvalFnToTextLoss,
5454
LLMAsTextLoss,
55+
optimize_anything,
56+
log,
57+
EngineConfig,
58+
GEPAConfig,
59+
OptimizeAnythingResult,
5560
)
5661

5762
from adalflow.optim.types import ParameterType
@@ -103,6 +108,11 @@
103108
"TGDOptimizer",
104109
"EvalFnToTextLoss",
105110
"LLMAsTextLoss",
111+
"optimize_anything",
112+
"log",
113+
"EngineConfig",
114+
"GEPAConfig",
115+
"OptimizeAnythingResult",
106116
"setup_env",
107117
"get_logger",
108118
"Prompt",

adalflow/adalflow/optim/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
from adalflow.utils.registry import EntityMapping
1212
from .optimizer import DemoOptimizer, TextOptimizer
1313
from .gradient import Gradient, GradientContext
14+
from .optimize_anything import (
15+
optimize_anything,
16+
log,
17+
EngineConfig,
18+
GEPAConfig,
19+
OptimizeAnythingResult,
20+
)
1421

1522

1623
__all__ = [
@@ -32,6 +39,11 @@
3239
"TextOptimizer",
3340
"Gradient",
3441
"GradientContext",
42+
"optimize_anything",
43+
"log",
44+
"EngineConfig",
45+
"GEPAConfig",
46+
"OptimizeAnythingResult",
3547
]
3648

3749
for name in __all__:
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""GEPA-style optimize_anything API for arbitrary text artifacts."""
2+
3+
from __future__ import annotations
4+
5+
from contextvars import ContextVar
6+
from dataclasses import dataclass, field
7+
from random import Random
8+
from time import perf_counter
9+
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
10+
11+
from adalflow.core.base_data_class import DataClass
12+
13+
14+
_EVAL_LOG_BUFFER: ContextVar[Optional[List[str]]] = ContextVar(
15+
"optimize_anything_eval_log_buffer", default=None
16+
)
17+
18+
19+
def log(message: str) -> None:
20+
"""Log actionable side information during evaluator execution."""
21+
buffer = _EVAL_LOG_BUFFER.get()
22+
if buffer is None:
23+
return
24+
buffer.append(str(message))
25+
26+
27+
@dataclass
28+
class EngineConfig(DataClass):
29+
"""Execution budget and runtime controls."""
30+
31+
max_metric_calls: int = field(default=100)
32+
max_parallel: int = field(default=1)
33+
random_seed: int = field(default=0)
34+
35+
36+
@dataclass
37+
class GEPAConfig(DataClass):
38+
"""Optimization controls for evolutionary + Pareto search."""
39+
40+
engine: EngineConfig = field(default_factory=EngineConfig)
41+
population_size: int = field(default=8)
42+
elite_size: int = field(default=3)
43+
mutation_rate: float = field(default=0.7)
44+
crossover_rate: float = field(default=0.2)
45+
stop_score: Optional[float] = field(default=None)
46+
47+
48+
@dataclass
49+
class CandidateEvaluation(DataClass):
50+
candidate: str = field(default="")
51+
score: float = field(default=0.0)
52+
token_cost: int = field(default=0)
53+
latency_ms: float = field(default=0.0)
54+
side_info: List[str] = field(default_factory=list)
55+
56+
57+
@dataclass
58+
class OptimizeAnythingResult(DataClass):
59+
best_candidate: str = field(default="")
60+
best_score: float = field(default=0.0)
61+
metric_calls: int = field(default=0)
62+
history: List[CandidateEvaluation] = field(default_factory=list)
63+
pareto_frontier: List[CandidateEvaluation] = field(default_factory=list)
64+
objective: str = field(default="")
65+
66+
67+
def _extract_eval_output(result: Union[float, int, Dict[str, Any]]) -> Dict[str, float]:
68+
if isinstance(result, (float, int)):
69+
return {"score": float(result)}
70+
if isinstance(result, dict):
71+
if "score" not in result:
72+
raise ValueError("evaluator dict output must include 'score'")
73+
output = {"score": float(result["score"])}
74+
if "token_cost" in result and result["token_cost"] is not None:
75+
output["token_cost"] = float(result["token_cost"])
76+
if "latency_ms" in result and result["latency_ms"] is not None:
77+
output["latency_ms"] = float(result["latency_ms"])
78+
return output
79+
raise TypeError("evaluator must return float/int or dict containing 'score'")
80+
81+
82+
def _estimate_token_cost(candidate: str) -> int:
83+
stripped = candidate.strip()
84+
if not stripped:
85+
return 0
86+
return len(stripped.split())
87+
88+
89+
def _dominates(a: CandidateEvaluation, b: CandidateEvaluation) -> bool:
90+
not_worse = (
91+
a.score >= b.score
92+
and a.token_cost <= b.token_cost
93+
and a.latency_ms <= b.latency_ms
94+
)
95+
strictly_better = (
96+
a.score > b.score
97+
or a.token_cost < b.token_cost
98+
or a.latency_ms < b.latency_ms
99+
)
100+
return not_worse and strictly_better
101+
102+
103+
def _compute_pareto_frontier(
104+
records: Sequence[CandidateEvaluation],
105+
) -> List[CandidateEvaluation]:
106+
frontier: List[CandidateEvaluation] = []
107+
for candidate in records:
108+
dominated = False
109+
for other in records:
110+
if other is candidate:
111+
continue
112+
if _dominates(other, candidate):
113+
dominated = True
114+
break
115+
if not dominated:
116+
frontier.append(candidate)
117+
frontier.sort(key=lambda item: (-item.score, item.token_cost, item.latency_ms))
118+
return frontier
119+
120+
121+
def _mutate(candidate: str, objective: str, side_info: Sequence[str], rng: Random) -> str:
122+
lines = candidate.splitlines()
123+
hints = [item for item in side_info if item]
124+
hint = hints[-1][:180] if hints else f"Objective: {objective[:120]}"
125+
126+
operation = rng.choice(["append_hint", "prepend_hint", "line_swap", "dedupe_spaces"])
127+
128+
if operation == "append_hint":
129+
return f"{candidate}\n# Hint: {hint}".strip()
130+
if operation == "prepend_hint":
131+
return f"# Objective: {objective}\n{candidate}".strip()
132+
if operation == "line_swap" and len(lines) >= 2:
133+
idx_a = rng.randrange(len(lines))
134+
idx_b = rng.randrange(len(lines))
135+
lines[idx_a], lines[idx_b] = lines[idx_b], lines[idx_a]
136+
return "\n".join(lines)
137+
return " ".join(candidate.split())
138+
139+
140+
def _crossover(left: str, right: str, rng: Random) -> str:
141+
left_lines = left.splitlines()
142+
right_lines = right.splitlines()
143+
if not left_lines or not right_lines:
144+
return left if left else right
145+
left_cut = rng.randrange(1, len(left_lines) + 1)
146+
right_cut = rng.randrange(0, len(right_lines))
147+
merged = left_lines[:left_cut] + right_lines[right_cut:]
148+
return "\n".join(merged).strip()
149+
150+
151+
def optimize_anything(
152+
seed_candidate: str,
153+
evaluator: Callable[[str], Union[float, int, Dict[str, Any]]],
154+
objective: str,
155+
config: GEPAConfig,
156+
) -> OptimizeAnythingResult:
157+
"""Optimize any text artifact (prompt/code/config/svg) with evolutionary Pareto search."""
158+
if not isinstance(seed_candidate, str):
159+
raise TypeError("seed_candidate must be a string")
160+
if config.engine.max_metric_calls <= 0:
161+
raise ValueError("config.engine.max_metric_calls must be > 0")
162+
if config.population_size <= 0:
163+
raise ValueError("config.population_size must be > 0")
164+
if config.elite_size <= 0:
165+
raise ValueError("config.elite_size must be > 0")
166+
167+
rng = Random(config.engine.random_seed)
168+
seen_candidates = set()
169+
history: List[CandidateEvaluation] = []
170+
metric_calls = 0
171+
172+
def evaluate_candidate(candidate: str) -> CandidateEvaluation:
173+
nonlocal metric_calls
174+
log_buffer: List[str] = []
175+
token = _EVAL_LOG_BUFFER.set(log_buffer)
176+
started_at = perf_counter()
177+
try:
178+
eval_output = _extract_eval_output(evaluator(candidate))
179+
finally:
180+
elapsed_ms = (perf_counter() - started_at) * 1000.0
181+
_EVAL_LOG_BUFFER.reset(token)
182+
183+
metric_calls += 1
184+
token_cost = int(eval_output.get("token_cost", _estimate_token_cost(candidate)))
185+
latency_ms = float(eval_output.get("latency_ms", elapsed_ms))
186+
return CandidateEvaluation(
187+
candidate=candidate,
188+
score=float(eval_output["score"]),
189+
token_cost=token_cost,
190+
latency_ms=latency_ms,
191+
side_info=log_buffer,
192+
)
193+
194+
seed_eval = evaluate_candidate(seed_candidate)
195+
history.append(seed_eval)
196+
seen_candidates.add(seed_candidate)
197+
best_eval = seed_eval
198+
199+
population: List[CandidateEvaluation] = [seed_eval]
200+
201+
while metric_calls < config.engine.max_metric_calls:
202+
frontier = _compute_pareto_frontier(population)
203+
elites = frontier[: min(len(frontier), config.elite_size)]
204+
if not elites:
205+
elites = sorted(
206+
population, key=lambda item: (-item.score, item.token_cost, item.latency_ms)
207+
)[: config.elite_size]
208+
209+
next_generation: List[CandidateEvaluation] = list(elites)
210+
attempts = 0
211+
max_attempts = max(10, config.population_size * 8)
212+
generation_start_calls = metric_calls
213+
214+
while (
215+
len(next_generation) < config.population_size
216+
and metric_calls < config.engine.max_metric_calls
217+
and attempts < max_attempts
218+
):
219+
attempts += 1
220+
parent = rng.choice(elites)
221+
child_text = parent.candidate
222+
223+
if rng.random() < config.crossover_rate and len(population) > 1:
224+
other = rng.choice(population)
225+
child_text = _crossover(parent.candidate, other.candidate, rng)
226+
227+
if rng.random() < config.mutation_rate:
228+
child_text = _mutate(child_text, objective, parent.side_info, rng)
229+
230+
if not child_text:
231+
continue
232+
if child_text in seen_candidates:
233+
child_text = f"{child_text}\n# variant:{metric_calls}:{attempts}"
234+
235+
child_eval = evaluate_candidate(child_text)
236+
seen_candidates.add(child_text)
237+
history.append(child_eval)
238+
next_generation.append(child_eval)
239+
240+
if child_eval.score > best_eval.score:
241+
best_eval = child_eval
242+
243+
if config.stop_score is not None and child_eval.score >= config.stop_score:
244+
frontier_now = _compute_pareto_frontier(next_generation + population)
245+
return OptimizeAnythingResult(
246+
best_candidate=best_eval.candidate,
247+
best_score=best_eval.score,
248+
metric_calls=metric_calls,
249+
history=history,
250+
pareto_frontier=frontier_now,
251+
objective=objective,
252+
)
253+
254+
if metric_calls == generation_start_calls:
255+
break
256+
257+
population = sorted(
258+
next_generation, key=lambda item: (-item.score, item.token_cost, item.latency_ms)
259+
)[: config.population_size]
260+
261+
final_frontier = _compute_pareto_frontier(history)
262+
return OptimizeAnythingResult(
263+
best_candidate=best_eval.candidate,
264+
best_score=best_eval.score,
265+
metric_calls=metric_calls,
266+
history=history,
267+
pareto_frontier=final_frontier,
268+
objective=objective,
269+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from adalflow.optim.optimize_anything import (
2+
EngineConfig,
3+
GEPAConfig,
4+
optimize_anything,
5+
log,
6+
)
7+
8+
9+
def test_optimize_anything_respects_max_metric_calls():
10+
calls = {"count": 0}
11+
12+
def evaluator(candidate: str) -> float:
13+
calls["count"] += 1
14+
log(f"len={len(candidate)}")
15+
return float(candidate.count("good"))
16+
17+
result = optimize_anything(
18+
seed_candidate="good",
19+
evaluator=evaluator,
20+
objective="increase 'good' count",
21+
config=GEPAConfig(engine=EngineConfig(max_metric_calls=5, random_seed=1)),
22+
)
23+
24+
assert result.metric_calls == 5
25+
assert calls["count"] == 5
26+
assert len(result.history) == 5
27+
28+
29+
def test_optimize_anything_returns_seed_when_no_improvement():
30+
def evaluator(candidate: str) -> float:
31+
log("constant score")
32+
return 0.5
33+
34+
seed = "artifact"
35+
result = optimize_anything(
36+
seed_candidate=seed,
37+
evaluator=evaluator,
38+
objective="no-op",
39+
config=GEPAConfig(engine=EngineConfig(max_metric_calls=4, random_seed=2)),
40+
)
41+
42+
assert result.best_candidate == seed
43+
assert result.best_score == 0.5
44+
45+
46+
def test_optimize_anything_improves_on_toy_objective():
47+
def evaluator(candidate: str):
48+
# shorter candidate is better, but still uses score max convention
49+
score = 1.0 / (1 + len(candidate))
50+
log(f"candidate={candidate[:20]}")
51+
return {"score": score}
52+
53+
result = optimize_anything(
54+
seed_candidate="this is a very long seed candidate",
55+
evaluator=evaluator,
56+
objective="minimize length",
57+
config=GEPAConfig(
58+
engine=EngineConfig(max_metric_calls=12, random_seed=7),
59+
population_size=6,
60+
elite_size=2,
61+
mutation_rate=0.9,
62+
crossover_rate=0.0,
63+
),
64+
)
65+
66+
assert result.best_score >= result.history[0].score
67+
assert result.pareto_frontier
68+
69+
70+
def test_optimize_anything_is_exported_from_top_level():
71+
import adalflow as adal
72+
73+
assert hasattr(adal, "optimize_anything")
74+
assert hasattr(adal, "GEPAConfig")
75+
assert hasattr(adal, "EngineConfig")

0 commit comments

Comments
 (0)