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+ )
0 commit comments