orizu 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/index.js +171 -11
- package/package.json +12 -2
- package/scripts/vendor-gepa-python.mjs +54 -0
- package/src/index.ts +203 -4
- package/vendor/orizu-gepa-python/manifest.json +9 -0
- package/vendor/orizu-gepa-python/pyproject.toml +16 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/__init__.py +19 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/cli.py +206 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/client.py +360 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/local_log.py +123 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/optimizer.py +1276 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/reflection.py +177 -0
- package/vendor/orizu-gepa-python/src/orizu_gepa/runner.py +207 -0
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_REFLECTION_PROMPT_TEMPLATE = """I am optimizing a text parameter in my system.
|
|
15
|
+
|
|
16
|
+
The current parameter value is:
|
|
17
|
+
```
|
|
18
|
+
<current_candidate>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Below is evaluation data showing how this parameter value performed across multiple test cases. The data contains performance metrics, model outputs, judge feedback, and any relevant diagnostic information from the evaluation:
|
|
22
|
+
```
|
|
23
|
+
<evaluation_data>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Your task is to propose a new, improved parameter value that can be used as a drop-in replacement for the current one.
|
|
27
|
+
|
|
28
|
+
Carefully analyze all the evaluation data above. Look for patterns that indicate what works and what does not.
|
|
29
|
+
|
|
30
|
+
Pay special attention to:
|
|
31
|
+
- Performance metrics and how they correlate with parameter behavior
|
|
32
|
+
- Recurring issues, errors, or failure patterns across multiple test cases
|
|
33
|
+
- Successful patterns or behaviors that should be preserved or enhanced
|
|
34
|
+
- Any domain-specific requirements, constraints, or factual information revealed in the evaluation data
|
|
35
|
+
- Specific technical details that are crucial for understanding the parameter's role
|
|
36
|
+
|
|
37
|
+
Based on your analysis, produce a new parameter value that addresses the identified issues while preserving or improving what works well.
|
|
38
|
+
|
|
39
|
+
Return only the complete updated parameter text, exactly as it should be used by the system.
|
|
40
|
+
Do not include analysis, explanation, labels, XML tags, or a surrounding markdown code fence.
|
|
41
|
+
Your response will be used verbatim as the next candidate."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class DatasetRow:
|
|
46
|
+
id: str
|
|
47
|
+
row: dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PromptContext:
|
|
52
|
+
body: str | None
|
|
53
|
+
body_kind: str
|
|
54
|
+
provider_settings: dict[str, Any]
|
|
55
|
+
prompt_version_id: str
|
|
56
|
+
runner_version_id: str
|
|
57
|
+
prompt_id: str | None = None
|
|
58
|
+
scorer_version_id: str | None = None
|
|
59
|
+
metric_key: str | None = None
|
|
60
|
+
higher_is_better: bool = True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class RunnerCallResult:
|
|
65
|
+
model_response: Any = None
|
|
66
|
+
raw_api_response: Any = None
|
|
67
|
+
token_in: int | None = None
|
|
68
|
+
token_out: int | None = None
|
|
69
|
+
latency_ms: int | None = None
|
|
70
|
+
cost_usd: float | None = None
|
|
71
|
+
error: str | None = None
|
|
72
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class RowEvaluation:
|
|
77
|
+
row_id: str
|
|
78
|
+
row: dict[str, Any]
|
|
79
|
+
output: Any
|
|
80
|
+
score: float
|
|
81
|
+
feedback: str | None
|
|
82
|
+
raw_response: Any = None
|
|
83
|
+
scorer_response: Any = None
|
|
84
|
+
latency_ms: int | None = None
|
|
85
|
+
token_in: int | None = None
|
|
86
|
+
token_out: int | None = None
|
|
87
|
+
cost_usd: float | None = None
|
|
88
|
+
error: str | None = None
|
|
89
|
+
cached: bool = False
|
|
90
|
+
|
|
91
|
+
def to_payload(self) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"row_id": self.row_id,
|
|
94
|
+
"row": self.row,
|
|
95
|
+
"output": self.output,
|
|
96
|
+
"score": self.score,
|
|
97
|
+
"feedback": self.feedback,
|
|
98
|
+
"raw_response": self.raw_response,
|
|
99
|
+
"scorer_response": self.scorer_response,
|
|
100
|
+
"latency_ms": self.latency_ms,
|
|
101
|
+
"token_in": self.token_in,
|
|
102
|
+
"token_out": self.token_out,
|
|
103
|
+
"cost_usd": self.cost_usd,
|
|
104
|
+
"error": self.error,
|
|
105
|
+
"cached": self.cached,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ReflectionResult:
|
|
111
|
+
prompt: str
|
|
112
|
+
response: str
|
|
113
|
+
candidate_text: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Budget:
|
|
118
|
+
budget_kind: str
|
|
119
|
+
limit: int
|
|
120
|
+
used_metric_calls: int = 0
|
|
121
|
+
used_full_evals: int = 0
|
|
122
|
+
used_candidate_proposals: int = 0
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_config(cls, config: "TextGepaConfig") -> "Budget":
|
|
126
|
+
if config.max_metric_calls is not None:
|
|
127
|
+
return cls("max_metric_calls", config.max_metric_calls)
|
|
128
|
+
if config.max_full_evals is not None:
|
|
129
|
+
return cls("max_full_evals", config.max_full_evals)
|
|
130
|
+
if config.max_candidate_proposals is not None:
|
|
131
|
+
return cls("max_candidate_proposals", config.max_candidate_proposals)
|
|
132
|
+
preset_metric_calls = {
|
|
133
|
+
"auto": 64,
|
|
134
|
+
"light": 48,
|
|
135
|
+
"medium": 96,
|
|
136
|
+
"high": 192,
|
|
137
|
+
}.get(config.budget, 48)
|
|
138
|
+
return cls("max_metric_calls", preset_metric_calls)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def used(self) -> int:
|
|
142
|
+
if self.budget_kind == "max_full_evals":
|
|
143
|
+
return self.used_full_evals
|
|
144
|
+
if self.budget_kind == "max_candidate_proposals":
|
|
145
|
+
return self.used_candidate_proposals
|
|
146
|
+
return self.used_metric_calls
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def remaining(self) -> int:
|
|
150
|
+
return max(0, self.limit - self.used)
|
|
151
|
+
|
|
152
|
+
def allows_iteration(self) -> bool:
|
|
153
|
+
return self.remaining > 0
|
|
154
|
+
|
|
155
|
+
def allows_metric_call(self) -> bool:
|
|
156
|
+
return self.budget_kind != "max_metric_calls" or self.remaining > 0
|
|
157
|
+
|
|
158
|
+
def to_payload(self) -> dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"budget_kind": self.budget_kind,
|
|
161
|
+
"limit": self.limit,
|
|
162
|
+
"used": self.used,
|
|
163
|
+
"remaining": self.remaining,
|
|
164
|
+
"used_metric_calls": self.used_metric_calls,
|
|
165
|
+
"used_full_evals": self.used_full_evals,
|
|
166
|
+
"used_candidate_proposals": self.used_candidate_proposals,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class TextGepaConfig:
|
|
172
|
+
budget: str = "light"
|
|
173
|
+
max_iterations: int = 3
|
|
174
|
+
minibatch_size: int = 3
|
|
175
|
+
candidate_selection_strategy: str = "pareto"
|
|
176
|
+
epsilon: float = 0.1
|
|
177
|
+
max_metric_calls: int | None = None
|
|
178
|
+
max_full_evals: int | None = None
|
|
179
|
+
max_candidate_proposals: int | None = None
|
|
180
|
+
reflection_model: str = "anthropic/claude-opus-4-7"
|
|
181
|
+
reflection_temperature: float | None = None
|
|
182
|
+
reflection_max_tokens: int = 4096
|
|
183
|
+
reflection_prompt_template: str | None = None
|
|
184
|
+
reflection_provider_settings: dict[str, Any] = field(default_factory=dict)
|
|
185
|
+
objective: str = "Improve this text candidate to maximize evaluator score while preserving intended behavior."
|
|
186
|
+
seed: int = 0
|
|
187
|
+
auto_promote: bool = False
|
|
188
|
+
promotion_label: str | None = None
|
|
189
|
+
fail_on_log_error: bool = True
|
|
190
|
+
log_row_snapshots: bool = False
|
|
191
|
+
cache_evaluations: bool = True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class TextGepaResult:
|
|
196
|
+
run_id: str
|
|
197
|
+
best_candidate_id: str
|
|
198
|
+
best_candidate_text: str
|
|
199
|
+
best_score: float
|
|
200
|
+
seed_score: float
|
|
201
|
+
promoted_prompt_version_id: str | None
|
|
202
|
+
budget: Budget
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class EventSink(Protocol):
|
|
206
|
+
def log_event(
|
|
207
|
+
self,
|
|
208
|
+
event_type: str,
|
|
209
|
+
payload: dict[str, Any] | None = None,
|
|
210
|
+
*,
|
|
211
|
+
event_layer: str = "core",
|
|
212
|
+
optimizer_family: str = "gepa",
|
|
213
|
+
iteration: int | None = None,
|
|
214
|
+
candidate_id: str | None = None,
|
|
215
|
+
parent_candidate_id: str | None = None,
|
|
216
|
+
child_candidate_id: str | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
...
|
|
219
|
+
|
|
220
|
+
def promote_candidate(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
candidate_id: str,
|
|
224
|
+
prompt_id: str,
|
|
225
|
+
parent_prompt_version_id: str,
|
|
226
|
+
body: str,
|
|
227
|
+
body_kind: str,
|
|
228
|
+
provider_settings: dict[str, Any],
|
|
229
|
+
runner_version_id: str,
|
|
230
|
+
label: str | None,
|
|
231
|
+
) -> str:
|
|
232
|
+
...
|
|
233
|
+
|
|
234
|
+
def finish_run(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
status: str,
|
|
238
|
+
best_score: float | None = None,
|
|
239
|
+
best_candidate_id: str | None = None,
|
|
240
|
+
result_prompt_version_id: str | None = None,
|
|
241
|
+
metadata: dict[str, Any] | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
...
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
CandidateRunner = Callable[[str, DatasetRow, PromptContext, str], RunnerCallResult]
|
|
247
|
+
ScorerRunner = Callable[[DatasetRow, RunnerCallResult, PromptContext, str], RunnerCallResult]
|
|
248
|
+
Reflector = Callable[[str, list[RowEvaluation], TextGepaConfig], ReflectionResult]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _score_from_scorer(result: RunnerCallResult) -> tuple[float, str | None]:
|
|
252
|
+
source: Any = result.extra
|
|
253
|
+
if "score" not in source and isinstance(result.model_response, dict):
|
|
254
|
+
source = {**result.model_response, **result.extra}
|
|
255
|
+
score_value = source.get("score")
|
|
256
|
+
if isinstance(score_value, str):
|
|
257
|
+
try:
|
|
258
|
+
score_value = float(score_value)
|
|
259
|
+
except ValueError:
|
|
260
|
+
score_value = None
|
|
261
|
+
if not isinstance(score_value, (int, float)):
|
|
262
|
+
raise ValueError("Scorer result must include a numeric score")
|
|
263
|
+
score = float(score_value)
|
|
264
|
+
if not math.isfinite(score):
|
|
265
|
+
raise ValueError("Scorer result score must be finite")
|
|
266
|
+
score = max(0.0, min(1.0, score))
|
|
267
|
+
feedback = source.get("feedback")
|
|
268
|
+
if feedback is None and isinstance(result.model_response, dict):
|
|
269
|
+
feedback = result.model_response.get("feedback") or result.model_response.get("reasoning")
|
|
270
|
+
return score, str(feedback) if feedback is not None else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _mean(results: list[RowEvaluation]) -> float:
|
|
274
|
+
if not results:
|
|
275
|
+
return 0.0
|
|
276
|
+
return sum(item.score for item in results) / len(results)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _total(results: list[RowEvaluation]) -> float:
|
|
280
|
+
return sum(item.score for item in results)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _stable_json(value: Any) -> str:
|
|
284
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _hash_payload(value: Any) -> str:
|
|
288
|
+
return hashlib.sha256(_stable_json(value).encode("utf-8")).hexdigest()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _evaluation_payload(result: RowEvaluation, *, include_row: bool) -> dict[str, Any]:
|
|
292
|
+
payload = result.to_payload()
|
|
293
|
+
if include_row:
|
|
294
|
+
return payload
|
|
295
|
+
payload["row"] = None
|
|
296
|
+
payload["row_sha256"] = _hash_payload(result.row)
|
|
297
|
+
payload["row_redacted"] = True
|
|
298
|
+
return payload
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _text_log_fields(name: str, value: str, *, include_text: bool) -> dict[str, Any]:
|
|
302
|
+
if include_text:
|
|
303
|
+
return {name: value}
|
|
304
|
+
return {
|
|
305
|
+
f"{name}_sha256": _hash_payload(value),
|
|
306
|
+
f"{name}_redacted": True,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _scorer_version_id(scorer_context: PromptContext) -> str:
|
|
311
|
+
return scorer_context.scorer_version_id or scorer_context.prompt_version_id
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _metric_key(scorer_context: PromptContext) -> str:
|
|
315
|
+
return scorer_context.metric_key or "score"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _is_better(challenger: float, incumbent: float, higher_is_better: bool) -> bool:
|
|
319
|
+
return challenger > incumbent if higher_is_better else challenger < incumbent
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class EvaluationCache:
|
|
323
|
+
def __init__(self):
|
|
324
|
+
self._values: dict[str, RowEvaluation] = {}
|
|
325
|
+
|
|
326
|
+
def key(
|
|
327
|
+
self,
|
|
328
|
+
*,
|
|
329
|
+
candidate_text: str,
|
|
330
|
+
row: DatasetRow,
|
|
331
|
+
split: str,
|
|
332
|
+
prompt_context: PromptContext,
|
|
333
|
+
scorer_context: PromptContext,
|
|
334
|
+
) -> str:
|
|
335
|
+
return _hash_payload({
|
|
336
|
+
"candidate_text_hash": _hash_payload(candidate_text),
|
|
337
|
+
"row_id": row.id,
|
|
338
|
+
"row_hash": _hash_payload(row.row),
|
|
339
|
+
"split": split,
|
|
340
|
+
"prompt_body_kind": prompt_context.body_kind,
|
|
341
|
+
"prompt_provider_settings": prompt_context.provider_settings,
|
|
342
|
+
"prompt_version_id": prompt_context.prompt_version_id,
|
|
343
|
+
"runner_version_id": prompt_context.runner_version_id,
|
|
344
|
+
"scorer_body_kind": scorer_context.body_kind,
|
|
345
|
+
"scorer_provider_settings": scorer_context.provider_settings,
|
|
346
|
+
"scorer_version_id": _scorer_version_id(scorer_context),
|
|
347
|
+
"metric_key": _metric_key(scorer_context),
|
|
348
|
+
"scorer_prompt_version_id": scorer_context.prompt_version_id,
|
|
349
|
+
"scorer_runner_version_id": scorer_context.runner_version_id,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
def get(self, key: str) -> RowEvaluation | None:
|
|
353
|
+
value = self._values.get(key)
|
|
354
|
+
if value is None:
|
|
355
|
+
return None
|
|
356
|
+
return dataclasses.replace(value, cached=True)
|
|
357
|
+
|
|
358
|
+
def set(self, key: str, value: RowEvaluation) -> None:
|
|
359
|
+
self._values[key] = dataclasses.replace(value, cached=False)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _results_payload(
|
|
363
|
+
*,
|
|
364
|
+
candidate_id: str,
|
|
365
|
+
split: str,
|
|
366
|
+
results: list[RowEvaluation],
|
|
367
|
+
log_row_snapshots: bool,
|
|
368
|
+
scorer_version_id: str | None = None,
|
|
369
|
+
metric_key: str | None = None,
|
|
370
|
+
scorer_role: str | None = None,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
rows = [
|
|
373
|
+
_evaluation_payload(result, include_row=log_row_snapshots)
|
|
374
|
+
for result in results
|
|
375
|
+
]
|
|
376
|
+
return {
|
|
377
|
+
"candidate_id": candidate_id,
|
|
378
|
+
"split": split,
|
|
379
|
+
"row_results": rows,
|
|
380
|
+
"score_total": _total(results),
|
|
381
|
+
"score_mean": _mean(results),
|
|
382
|
+
"score_value": _mean(results),
|
|
383
|
+
"scorer_version_id": scorer_version_id,
|
|
384
|
+
"metric_key": metric_key,
|
|
385
|
+
"scorer_role": scorer_role,
|
|
386
|
+
"cache_hits": sum(1 for result in results if result.cached),
|
|
387
|
+
"cache_misses": sum(1 for result in results if not result.cached),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _frontier_by_row_id(
|
|
392
|
+
val_scores_by_candidate: dict[str, dict[str, float]],
|
|
393
|
+
higher_is_better: bool = True,
|
|
394
|
+
) -> dict[str, set[str]]:
|
|
395
|
+
row_ids = sorted({row_id for scores in val_scores_by_candidate.values() for row_id in scores})
|
|
396
|
+
frontier_by_row_id: dict[str, set[str]] = {}
|
|
397
|
+
for row_id in row_ids:
|
|
398
|
+
scored = {
|
|
399
|
+
candidate_id: scores[row_id]
|
|
400
|
+
for candidate_id, scores in val_scores_by_candidate.items()
|
|
401
|
+
if row_id in scores
|
|
402
|
+
}
|
|
403
|
+
if not scored:
|
|
404
|
+
continue
|
|
405
|
+
best_score = max(scored.values()) if higher_is_better else min(scored.values())
|
|
406
|
+
frontier_by_row_id[row_id] = {
|
|
407
|
+
candidate_id
|
|
408
|
+
for candidate_id, score in scored.items()
|
|
409
|
+
if score == best_score
|
|
410
|
+
}
|
|
411
|
+
return frontier_by_row_id
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _aggregate_scores(
|
|
415
|
+
val_scores_by_candidate: dict[str, dict[str, float]],
|
|
416
|
+
) -> dict[str, float]:
|
|
417
|
+
return {
|
|
418
|
+
candidate_id: (sum(scores.values()) / max(len(scores), 1))
|
|
419
|
+
for candidate_id, scores in val_scores_by_candidate.items()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _remove_dominated_frontier_candidates(
|
|
424
|
+
frontier_by_row_id: dict[str, set[str]],
|
|
425
|
+
aggregate_scores: dict[str, float],
|
|
426
|
+
higher_is_better: bool = True,
|
|
427
|
+
) -> dict[str, set[str]]:
|
|
428
|
+
remaining = {
|
|
429
|
+
candidate_id
|
|
430
|
+
for candidate_ids in frontier_by_row_id.values()
|
|
431
|
+
for candidate_id in candidate_ids
|
|
432
|
+
}
|
|
433
|
+
for candidate_id in sorted(
|
|
434
|
+
remaining,
|
|
435
|
+
key=lambda value: aggregate_scores.get(value, 0.0),
|
|
436
|
+
reverse=not higher_is_better,
|
|
437
|
+
):
|
|
438
|
+
owned_rows = [
|
|
439
|
+
row_id
|
|
440
|
+
for row_id, candidate_ids in frontier_by_row_id.items()
|
|
441
|
+
if candidate_id in candidate_ids
|
|
442
|
+
]
|
|
443
|
+
if owned_rows and all(
|
|
444
|
+
any(other_id != candidate_id and other_id in remaining for other_id in frontier_by_row_id[row_id])
|
|
445
|
+
for row_id in owned_rows
|
|
446
|
+
):
|
|
447
|
+
remaining.discard(candidate_id)
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
row_id: {candidate_id for candidate_id in candidate_ids if candidate_id in remaining}
|
|
451
|
+
for row_id, candidate_ids in frontier_by_row_id.items()
|
|
452
|
+
if any(candidate_id in remaining for candidate_id in candidate_ids)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _select_parent_candidate_id(
|
|
457
|
+
*,
|
|
458
|
+
val_scores_by_candidate: dict[str, dict[str, float]],
|
|
459
|
+
best_candidate_id: str,
|
|
460
|
+
config: TextGepaConfig,
|
|
461
|
+
iteration: int,
|
|
462
|
+
higher_is_better: bool = True,
|
|
463
|
+
) -> str:
|
|
464
|
+
strategy = config.candidate_selection_strategy
|
|
465
|
+
candidate_ids = sorted(val_scores_by_candidate)
|
|
466
|
+
if not candidate_ids:
|
|
467
|
+
return best_candidate_id
|
|
468
|
+
rng = random.Random(config.seed + iteration)
|
|
469
|
+
|
|
470
|
+
if strategy == "current_best":
|
|
471
|
+
return best_candidate_id
|
|
472
|
+
if strategy == "epsilon_greedy":
|
|
473
|
+
epsilon = max(0.0, min(1.0, config.epsilon))
|
|
474
|
+
if rng.random() < epsilon:
|
|
475
|
+
return rng.choice(candidate_ids)
|
|
476
|
+
return best_candidate_id
|
|
477
|
+
if strategy != "pareto":
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"candidate_selection_strategy must be one of: pareto, current_best, epsilon_greedy"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
frontier = _remove_dominated_frontier_candidates(
|
|
483
|
+
_frontier_by_row_id(val_scores_by_candidate, higher_is_better),
|
|
484
|
+
_aggregate_scores(val_scores_by_candidate),
|
|
485
|
+
higher_is_better,
|
|
486
|
+
)
|
|
487
|
+
frontier_counts: dict[str, int] = {}
|
|
488
|
+
for candidate_ids in frontier.values():
|
|
489
|
+
for candidate_id in candidate_ids:
|
|
490
|
+
frontier_counts[candidate_id] = frontier_counts.get(candidate_id, 0) + 1
|
|
491
|
+
sampling_list: list[str] = []
|
|
492
|
+
for candidate_id, count in sorted(frontier_counts.items()):
|
|
493
|
+
sampling_list.extend([candidate_id] * count)
|
|
494
|
+
if not sampling_list:
|
|
495
|
+
return best_candidate_id
|
|
496
|
+
return rng.choice(sampling_list)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _parent_selection_payload(
|
|
500
|
+
*,
|
|
501
|
+
val_scores_by_candidate: dict[str, dict[str, float]],
|
|
502
|
+
selected_candidate_id: str,
|
|
503
|
+
best_candidate_id: str,
|
|
504
|
+
config: TextGepaConfig,
|
|
505
|
+
higher_is_better: bool = True,
|
|
506
|
+
) -> dict[str, Any]:
|
|
507
|
+
aggregate_scores = _aggregate_scores(val_scores_by_candidate)
|
|
508
|
+
frontier = _remove_dominated_frontier_candidates(
|
|
509
|
+
_frontier_by_row_id(val_scores_by_candidate, higher_is_better),
|
|
510
|
+
aggregate_scores,
|
|
511
|
+
higher_is_better,
|
|
512
|
+
)
|
|
513
|
+
frontier_counts: dict[str, int] = {}
|
|
514
|
+
for candidate_ids in frontier.values():
|
|
515
|
+
for candidate_id in candidate_ids:
|
|
516
|
+
frontier_counts[candidate_id] = frontier_counts.get(candidate_id, 0) + 1
|
|
517
|
+
return {
|
|
518
|
+
"strategy": config.candidate_selection_strategy,
|
|
519
|
+
"selected_candidate_id": selected_candidate_id,
|
|
520
|
+
"selected_validation_score": aggregate_scores.get(selected_candidate_id),
|
|
521
|
+
"best_candidate_id": best_candidate_id,
|
|
522
|
+
"best_validation_score": aggregate_scores.get(best_candidate_id),
|
|
523
|
+
"frontier_counts": frontier_counts,
|
|
524
|
+
"epsilon": config.epsilon if config.candidate_selection_strategy == "epsilon_greedy" else None,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _pareto_payload(
|
|
529
|
+
val_scores_by_candidate: dict[str, dict[str, float]],
|
|
530
|
+
val_text_by_candidate: dict[str, str],
|
|
531
|
+
higher_is_better: bool = True,
|
|
532
|
+
) -> tuple[str, float, dict[str, Any]]:
|
|
533
|
+
frontier_by_row_id: dict[str, dict[str, Any]] = {}
|
|
534
|
+
raw_frontier = _frontier_by_row_id(val_scores_by_candidate, higher_is_better)
|
|
535
|
+
for row_id, candidate_ids in raw_frontier.items():
|
|
536
|
+
row_scores = [val_scores_by_candidate[candidate_id][row_id] for candidate_id in candidate_ids]
|
|
537
|
+
best_score = max(row_scores) if higher_is_better else min(row_scores)
|
|
538
|
+
frontier_by_row_id[row_id] = {
|
|
539
|
+
"candidate_ids": sorted(candidate_ids),
|
|
540
|
+
"score": best_score,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
aggregate_scores = _aggregate_scores(val_scores_by_candidate)
|
|
544
|
+
best_candidate_id = max(aggregate_scores, key=aggregate_scores.get) if higher_is_better else min(aggregate_scores, key=aggregate_scores.get)
|
|
545
|
+
best_score = aggregate_scores[best_candidate_id]
|
|
546
|
+
return best_candidate_id, best_score, {
|
|
547
|
+
"frontier_by_row_id": frontier_by_row_id,
|
|
548
|
+
"frontier_candidate_ids": sorted({
|
|
549
|
+
candidate_id
|
|
550
|
+
for candidate_ids in raw_frontier.values()
|
|
551
|
+
for candidate_id in candidate_ids
|
|
552
|
+
}),
|
|
553
|
+
"candidate_scores": aggregate_scores,
|
|
554
|
+
"best_candidate_id": best_candidate_id,
|
|
555
|
+
"best_score_mean": best_score,
|
|
556
|
+
"best_candidate_preview": val_text_by_candidate[best_candidate_id][:1200],
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _sample_minibatch(rows: list[DatasetRow], iteration: int, config: TextGepaConfig) -> list[DatasetRow]:
|
|
561
|
+
if len(rows) <= config.minibatch_size:
|
|
562
|
+
return list(rows)
|
|
563
|
+
minibatch_size = max(1, config.minibatch_size)
|
|
564
|
+
epoch_length = max(1, (len(rows) + minibatch_size - 1) // minibatch_size)
|
|
565
|
+
epoch = max(iteration - 1, 0) // epoch_length
|
|
566
|
+
offset = (max(iteration - 1, 0) % epoch_length) * minibatch_size
|
|
567
|
+
shuffled = list(rows)
|
|
568
|
+
random.Random(config.seed + epoch).shuffle(shuffled)
|
|
569
|
+
minibatch = shuffled[offset:offset + minibatch_size]
|
|
570
|
+
if len(minibatch) < minibatch_size:
|
|
571
|
+
minibatch.extend(shuffled[:minibatch_size - len(minibatch)])
|
|
572
|
+
return minibatch
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _candidate_id(iteration: int, text: str) -> str:
|
|
576
|
+
compact = uuid.uuid5(uuid.NAMESPACE_URL, f"orizu-gepa:{iteration}:{text}").hex[:10]
|
|
577
|
+
return f"iter-{iteration}-child-{compact}"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def evaluate_candidate(
|
|
581
|
+
*,
|
|
582
|
+
candidate_text: str,
|
|
583
|
+
candidate_id: str,
|
|
584
|
+
rows: list[DatasetRow],
|
|
585
|
+
split: str,
|
|
586
|
+
prompt_context: PromptContext,
|
|
587
|
+
scorer_context: PromptContext,
|
|
588
|
+
candidate_runner: CandidateRunner,
|
|
589
|
+
scorer_runner: ScorerRunner,
|
|
590
|
+
budget: Budget,
|
|
591
|
+
evaluation_cache: EvaluationCache | None = None,
|
|
592
|
+
) -> list[RowEvaluation]:
|
|
593
|
+
evaluations: list[RowEvaluation] = []
|
|
594
|
+
for row in rows:
|
|
595
|
+
if not budget.allows_metric_call():
|
|
596
|
+
break
|
|
597
|
+
|
|
598
|
+
cache_key = None
|
|
599
|
+
if evaluation_cache is not None:
|
|
600
|
+
cache_key = evaluation_cache.key(
|
|
601
|
+
candidate_text=candidate_text,
|
|
602
|
+
row=row,
|
|
603
|
+
split=split,
|
|
604
|
+
prompt_context=prompt_context,
|
|
605
|
+
scorer_context=scorer_context,
|
|
606
|
+
)
|
|
607
|
+
cached = evaluation_cache.get(cache_key)
|
|
608
|
+
if cached is not None:
|
|
609
|
+
evaluations.append(cached)
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
started = time.time()
|
|
613
|
+
candidate_result = candidate_runner(candidate_text, row, prompt_context, candidate_id)
|
|
614
|
+
scorer_result = scorer_runner(row, candidate_result, scorer_context, candidate_id)
|
|
615
|
+
score, feedback = _score_from_scorer(scorer_result)
|
|
616
|
+
latency_ms = None
|
|
617
|
+
if candidate_result.latency_ms is not None or scorer_result.latency_ms is not None:
|
|
618
|
+
latency_ms = (candidate_result.latency_ms or 0) + (scorer_result.latency_ms or 0)
|
|
619
|
+
elif started:
|
|
620
|
+
latency_ms = int((time.time() - started) * 1000)
|
|
621
|
+
evaluation = RowEvaluation(
|
|
622
|
+
row_id=row.id,
|
|
623
|
+
row=row.row,
|
|
624
|
+
output=candidate_result.model_response,
|
|
625
|
+
score=score,
|
|
626
|
+
feedback=feedback,
|
|
627
|
+
raw_response=candidate_result.raw_api_response,
|
|
628
|
+
scorer_response=scorer_result.model_response,
|
|
629
|
+
latency_ms=latency_ms,
|
|
630
|
+
token_in=(candidate_result.token_in or 0) + (scorer_result.token_in or 0)
|
|
631
|
+
if candidate_result.token_in is not None or scorer_result.token_in is not None else None,
|
|
632
|
+
token_out=(candidate_result.token_out or 0) + (scorer_result.token_out or 0)
|
|
633
|
+
if candidate_result.token_out is not None or scorer_result.token_out is not None else None,
|
|
634
|
+
cost_usd=(candidate_result.cost_usd or 0.0) + (scorer_result.cost_usd or 0.0)
|
|
635
|
+
if candidate_result.cost_usd is not None or scorer_result.cost_usd is not None else None,
|
|
636
|
+
error=candidate_result.error or scorer_result.error,
|
|
637
|
+
)
|
|
638
|
+
evaluations.append(evaluation)
|
|
639
|
+
if evaluation_cache is not None and cache_key is not None:
|
|
640
|
+
evaluation_cache.set(cache_key, evaluation)
|
|
641
|
+
budget.used_metric_calls += 1
|
|
642
|
+
return evaluations
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _completed_all_rows(results: list[RowEvaluation], rows: list[DatasetRow]) -> bool:
|
|
646
|
+
return len(results) == len(rows)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def optimize_loaded_text_candidate(
|
|
650
|
+
*,
|
|
651
|
+
run_id: str,
|
|
652
|
+
prompt_context: PromptContext,
|
|
653
|
+
scorer_context: PromptContext,
|
|
654
|
+
trainset: list[DatasetRow],
|
|
655
|
+
valset: list[DatasetRow],
|
|
656
|
+
candidate_runner: CandidateRunner,
|
|
657
|
+
scorer_runner: ScorerRunner,
|
|
658
|
+
reflector: Reflector,
|
|
659
|
+
event_sink: EventSink,
|
|
660
|
+
config: TextGepaConfig,
|
|
661
|
+
local_logger: Any | None = None,
|
|
662
|
+
) -> TextGepaResult:
|
|
663
|
+
seed_text = prompt_context.body or ""
|
|
664
|
+
budget = Budget.from_config(config)
|
|
665
|
+
evaluation_cache = EvaluationCache() if config.cache_evaluations else None
|
|
666
|
+
candidate_text_by_id = {"seed": seed_text}
|
|
667
|
+
val_scores_by_candidate: dict[str, dict[str, float]] = {}
|
|
668
|
+
promoted_prompt_version_id: str | None = None
|
|
669
|
+
scorer_version_id = _scorer_version_id(scorer_context)
|
|
670
|
+
metric_key = _metric_key(scorer_context)
|
|
671
|
+
higher_is_better = scorer_context.higher_is_better
|
|
672
|
+
budget_exhausted = False
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
event_sink.log_event("run_started", {
|
|
676
|
+
"prompt_version_id": prompt_context.prompt_version_id,
|
|
677
|
+
"runner_version_id": prompt_context.runner_version_id,
|
|
678
|
+
"scorer_version_id": scorer_version_id,
|
|
679
|
+
"scorer_prompt_version_id": scorer_context.prompt_version_id,
|
|
680
|
+
"scorer_runner_version_id": scorer_context.runner_version_id,
|
|
681
|
+
"seed_candidate_id": "seed",
|
|
682
|
+
"seed_candidate_text": seed_text,
|
|
683
|
+
"body_kind": prompt_context.body_kind,
|
|
684
|
+
"inference_lm": prompt_context.provider_settings.get("model"),
|
|
685
|
+
"reflection_lm": config.reflection_model,
|
|
686
|
+
"scorer_lm": scorer_context.provider_settings.get("model"),
|
|
687
|
+
"metric_key": metric_key,
|
|
688
|
+
"higher_is_better": higher_is_better,
|
|
689
|
+
"train_count": len(trainset),
|
|
690
|
+
"validation_count": len(valset),
|
|
691
|
+
"config": dataclasses.asdict(config),
|
|
692
|
+
"budget": budget.to_payload(),
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
event_sink.log_event(
|
|
696
|
+
"seed_val_set_started",
|
|
697
|
+
{"row_ids": [row.id for row in valset]},
|
|
698
|
+
event_layer="extension",
|
|
699
|
+
candidate_id="seed",
|
|
700
|
+
)
|
|
701
|
+
seed_results = evaluate_candidate(
|
|
702
|
+
candidate_text=seed_text,
|
|
703
|
+
candidate_id="seed",
|
|
704
|
+
rows=valset,
|
|
705
|
+
split="validation",
|
|
706
|
+
prompt_context=prompt_context,
|
|
707
|
+
scorer_context=scorer_context,
|
|
708
|
+
candidate_runner=candidate_runner,
|
|
709
|
+
scorer_runner=scorer_runner,
|
|
710
|
+
budget=budget,
|
|
711
|
+
evaluation_cache=evaluation_cache,
|
|
712
|
+
)
|
|
713
|
+
if local_logger is not None:
|
|
714
|
+
local_logger.append_evaluations(
|
|
715
|
+
stage="seed_val_set",
|
|
716
|
+
split="validation",
|
|
717
|
+
iteration=None,
|
|
718
|
+
candidate_id="seed",
|
|
719
|
+
results=seed_results,
|
|
720
|
+
)
|
|
721
|
+
if not _completed_all_rows(seed_results, valset):
|
|
722
|
+
raise RuntimeError("Budget exhausted before seed validation completed")
|
|
723
|
+
budget.used_full_evals += 1
|
|
724
|
+
seed_score = _mean(seed_results)
|
|
725
|
+
val_scores_by_candidate["seed"] = {result.row_id: result.score for result in seed_results}
|
|
726
|
+
event_sink.log_event(
|
|
727
|
+
"seed_val_set_completed",
|
|
728
|
+
_results_payload(
|
|
729
|
+
candidate_id="seed",
|
|
730
|
+
split="validation",
|
|
731
|
+
results=seed_results,
|
|
732
|
+
log_row_snapshots=config.log_row_snapshots,
|
|
733
|
+
scorer_version_id=scorer_version_id,
|
|
734
|
+
metric_key=metric_key,
|
|
735
|
+
scorer_role="selection",
|
|
736
|
+
),
|
|
737
|
+
event_layer="extension",
|
|
738
|
+
candidate_id="seed",
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
best_candidate_id, best_score, pareto = _pareto_payload(
|
|
742
|
+
val_scores_by_candidate,
|
|
743
|
+
candidate_text_by_id,
|
|
744
|
+
higher_is_better,
|
|
745
|
+
)
|
|
746
|
+
event_sink.log_event(
|
|
747
|
+
"pareto_front_updated",
|
|
748
|
+
pareto,
|
|
749
|
+
event_layer="extension",
|
|
750
|
+
candidate_id=best_candidate_id,
|
|
751
|
+
)
|
|
752
|
+
event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension")
|
|
753
|
+
|
|
754
|
+
for iteration in range(1, config.max_iterations + 1):
|
|
755
|
+
if not budget.allows_iteration():
|
|
756
|
+
break
|
|
757
|
+
|
|
758
|
+
parent_candidate_id = _select_parent_candidate_id(
|
|
759
|
+
val_scores_by_candidate=val_scores_by_candidate,
|
|
760
|
+
best_candidate_id=best_candidate_id,
|
|
761
|
+
config=config,
|
|
762
|
+
iteration=iteration,
|
|
763
|
+
higher_is_better=higher_is_better,
|
|
764
|
+
)
|
|
765
|
+
parent_text = candidate_text_by_id[parent_candidate_id]
|
|
766
|
+
parent_val_score = _aggregate_scores(val_scores_by_candidate).get(parent_candidate_id)
|
|
767
|
+
minibatch = _sample_minibatch(trainset, iteration, config)
|
|
768
|
+
|
|
769
|
+
event_sink.log_event(
|
|
770
|
+
"iteration_started",
|
|
771
|
+
{
|
|
772
|
+
"parent_candidate_id": parent_candidate_id,
|
|
773
|
+
"parent_val_score": parent_val_score,
|
|
774
|
+
"best_candidate_id": best_candidate_id,
|
|
775
|
+
"best_val_score": best_score,
|
|
776
|
+
"row_ids": [row.id for row in minibatch],
|
|
777
|
+
"budget": budget.to_payload(),
|
|
778
|
+
"minibatch_size": config.minibatch_size,
|
|
779
|
+
"candidate_selection_strategy": config.candidate_selection_strategy,
|
|
780
|
+
},
|
|
781
|
+
iteration=iteration,
|
|
782
|
+
candidate_id=parent_candidate_id,
|
|
783
|
+
)
|
|
784
|
+
event_sink.log_event(
|
|
785
|
+
"parent_candidate_selected",
|
|
786
|
+
_parent_selection_payload(
|
|
787
|
+
val_scores_by_candidate=val_scores_by_candidate,
|
|
788
|
+
selected_candidate_id=parent_candidate_id,
|
|
789
|
+
best_candidate_id=best_candidate_id,
|
|
790
|
+
config=config,
|
|
791
|
+
higher_is_better=higher_is_better,
|
|
792
|
+
),
|
|
793
|
+
event_layer="extension",
|
|
794
|
+
iteration=iteration,
|
|
795
|
+
candidate_id=parent_candidate_id,
|
|
796
|
+
)
|
|
797
|
+
event_sink.log_event(
|
|
798
|
+
"parent_minibatch_started",
|
|
799
|
+
{"row_ids": [row.id for row in minibatch]},
|
|
800
|
+
event_layer="extension",
|
|
801
|
+
iteration=iteration,
|
|
802
|
+
candidate_id=parent_candidate_id,
|
|
803
|
+
)
|
|
804
|
+
parent_results = evaluate_candidate(
|
|
805
|
+
candidate_text=parent_text,
|
|
806
|
+
candidate_id=parent_candidate_id,
|
|
807
|
+
rows=minibatch,
|
|
808
|
+
split="train",
|
|
809
|
+
prompt_context=prompt_context,
|
|
810
|
+
scorer_context=scorer_context,
|
|
811
|
+
candidate_runner=candidate_runner,
|
|
812
|
+
scorer_runner=scorer_runner,
|
|
813
|
+
budget=budget,
|
|
814
|
+
evaluation_cache=evaluation_cache,
|
|
815
|
+
)
|
|
816
|
+
if local_logger is not None:
|
|
817
|
+
local_logger.append_evaluations(
|
|
818
|
+
stage="parent_minibatch",
|
|
819
|
+
split="train",
|
|
820
|
+
iteration=iteration,
|
|
821
|
+
candidate_id=parent_candidate_id,
|
|
822
|
+
results=parent_results,
|
|
823
|
+
)
|
|
824
|
+
if not _completed_all_rows(parent_results, minibatch):
|
|
825
|
+
event_sink.log_event(
|
|
826
|
+
"budget_exhausted",
|
|
827
|
+
{
|
|
828
|
+
"stage": "parent_minibatch",
|
|
829
|
+
"requested_rows": len(minibatch),
|
|
830
|
+
"completed_rows": len(parent_results),
|
|
831
|
+
"budget": budget.to_payload(),
|
|
832
|
+
},
|
|
833
|
+
event_layer="extension",
|
|
834
|
+
iteration=iteration,
|
|
835
|
+
candidate_id=parent_candidate_id,
|
|
836
|
+
)
|
|
837
|
+
budget_exhausted = True
|
|
838
|
+
break
|
|
839
|
+
parent_total = _total(parent_results)
|
|
840
|
+
event_sink.log_event(
|
|
841
|
+
"parent_minibatch_completed",
|
|
842
|
+
_results_payload(
|
|
843
|
+
candidate_id=parent_candidate_id,
|
|
844
|
+
split="train",
|
|
845
|
+
results=parent_results,
|
|
846
|
+
log_row_snapshots=config.log_row_snapshots,
|
|
847
|
+
scorer_version_id=scorer_version_id,
|
|
848
|
+
metric_key=metric_key,
|
|
849
|
+
scorer_role="reflection",
|
|
850
|
+
),
|
|
851
|
+
event_layer="extension",
|
|
852
|
+
iteration=iteration,
|
|
853
|
+
candidate_id=parent_candidate_id,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
event_sink.log_event(
|
|
857
|
+
"reflection_started",
|
|
858
|
+
{
|
|
859
|
+
"parent_candidate_id": parent_candidate_id,
|
|
860
|
+
"row_ids": [row.id for row in minibatch],
|
|
861
|
+
"objective": config.objective,
|
|
862
|
+
},
|
|
863
|
+
event_layer="extension",
|
|
864
|
+
iteration=iteration,
|
|
865
|
+
candidate_id=parent_candidate_id,
|
|
866
|
+
)
|
|
867
|
+
reflection = reflector(parent_text, parent_results, config)
|
|
868
|
+
child_id = _candidate_id(iteration, reflection.candidate_text)
|
|
869
|
+
child_text = reflection.candidate_text
|
|
870
|
+
candidate_text_by_id[child_id] = child_text
|
|
871
|
+
budget.used_candidate_proposals += 1
|
|
872
|
+
if local_logger is not None:
|
|
873
|
+
local_logger.append_reflection(
|
|
874
|
+
iteration=iteration,
|
|
875
|
+
parent_candidate_id=parent_candidate_id,
|
|
876
|
+
child_candidate_id=child_id,
|
|
877
|
+
row_ids=[row.id for row in minibatch],
|
|
878
|
+
prompt=reflection.prompt,
|
|
879
|
+
response=reflection.response,
|
|
880
|
+
candidate_text=reflection.candidate_text,
|
|
881
|
+
)
|
|
882
|
+
event_sink.log_event(
|
|
883
|
+
"reflection_completed",
|
|
884
|
+
{
|
|
885
|
+
**_text_log_fields("prompt", reflection.prompt, include_text=config.log_row_snapshots),
|
|
886
|
+
"response": reflection.response,
|
|
887
|
+
"child_candidate_id": child_id,
|
|
888
|
+
"candidate_text": child_text,
|
|
889
|
+
},
|
|
890
|
+
event_layer="extension",
|
|
891
|
+
iteration=iteration,
|
|
892
|
+
candidate_id=parent_candidate_id,
|
|
893
|
+
child_candidate_id=child_id,
|
|
894
|
+
)
|
|
895
|
+
event_sink.log_event(
|
|
896
|
+
"child_candidate_created",
|
|
897
|
+
{
|
|
898
|
+
"candidate_text": child_text,
|
|
899
|
+
"candidate_preview": child_text[:1200],
|
|
900
|
+
},
|
|
901
|
+
event_layer="extension",
|
|
902
|
+
iteration=iteration,
|
|
903
|
+
candidate_id=child_id,
|
|
904
|
+
parent_candidate_id=parent_candidate_id,
|
|
905
|
+
)
|
|
906
|
+
event_sink.log_event(
|
|
907
|
+
"candidate_proposed",
|
|
908
|
+
{
|
|
909
|
+
"body_kind": "text",
|
|
910
|
+
"prompt_body": child_text,
|
|
911
|
+
"source": "reflection",
|
|
912
|
+
},
|
|
913
|
+
iteration=iteration,
|
|
914
|
+
candidate_id=child_id,
|
|
915
|
+
parent_candidate_id=parent_candidate_id,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
event_sink.log_event(
|
|
919
|
+
"child_minibatch_started",
|
|
920
|
+
{"row_ids": [row.id for row in minibatch]},
|
|
921
|
+
event_layer="extension",
|
|
922
|
+
iteration=iteration,
|
|
923
|
+
candidate_id=child_id,
|
|
924
|
+
parent_candidate_id=parent_candidate_id,
|
|
925
|
+
)
|
|
926
|
+
child_results = evaluate_candidate(
|
|
927
|
+
candidate_text=child_text,
|
|
928
|
+
candidate_id=child_id,
|
|
929
|
+
rows=minibatch,
|
|
930
|
+
split="train",
|
|
931
|
+
prompt_context=prompt_context,
|
|
932
|
+
scorer_context=scorer_context,
|
|
933
|
+
candidate_runner=candidate_runner,
|
|
934
|
+
scorer_runner=scorer_runner,
|
|
935
|
+
budget=budget,
|
|
936
|
+
evaluation_cache=evaluation_cache,
|
|
937
|
+
)
|
|
938
|
+
if local_logger is not None:
|
|
939
|
+
local_logger.append_evaluations(
|
|
940
|
+
stage="child_minibatch",
|
|
941
|
+
split="train",
|
|
942
|
+
iteration=iteration,
|
|
943
|
+
candidate_id=child_id,
|
|
944
|
+
parent_candidate_id=parent_candidate_id,
|
|
945
|
+
results=child_results,
|
|
946
|
+
)
|
|
947
|
+
if not _completed_all_rows(child_results, minibatch):
|
|
948
|
+
event_sink.log_event(
|
|
949
|
+
"budget_exhausted",
|
|
950
|
+
{
|
|
951
|
+
"stage": "child_minibatch",
|
|
952
|
+
"requested_rows": len(minibatch),
|
|
953
|
+
"completed_rows": len(child_results),
|
|
954
|
+
"budget": budget.to_payload(),
|
|
955
|
+
},
|
|
956
|
+
event_layer="extension",
|
|
957
|
+
iteration=iteration,
|
|
958
|
+
candidate_id=child_id,
|
|
959
|
+
parent_candidate_id=parent_candidate_id,
|
|
960
|
+
)
|
|
961
|
+
budget_exhausted = True
|
|
962
|
+
break
|
|
963
|
+
child_total = _total(child_results)
|
|
964
|
+
event_sink.log_event(
|
|
965
|
+
"child_minibatch_completed",
|
|
966
|
+
_results_payload(
|
|
967
|
+
candidate_id=child_id,
|
|
968
|
+
split="train",
|
|
969
|
+
results=child_results,
|
|
970
|
+
log_row_snapshots=config.log_row_snapshots,
|
|
971
|
+
scorer_version_id=scorer_version_id,
|
|
972
|
+
metric_key=metric_key,
|
|
973
|
+
scorer_role="reflection",
|
|
974
|
+
),
|
|
975
|
+
event_layer="extension",
|
|
976
|
+
iteration=iteration,
|
|
977
|
+
candidate_id=child_id,
|
|
978
|
+
parent_candidate_id=parent_candidate_id,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
proceed_to_full_eval = _is_better(child_total, parent_total, higher_is_better)
|
|
982
|
+
event_sink.log_event(
|
|
983
|
+
"acceptance_decision_made",
|
|
984
|
+
{
|
|
985
|
+
"accepted": proceed_to_full_eval,
|
|
986
|
+
"proceed_to_full_eval": proceed_to_full_eval,
|
|
987
|
+
"parent_score_total": parent_total,
|
|
988
|
+
"child_score_total": child_total,
|
|
989
|
+
"higher_is_better": higher_is_better,
|
|
990
|
+
"decision_rule": "child minibatch total must beat parent minibatch total using the scorer direction",
|
|
991
|
+
},
|
|
992
|
+
event_layer="extension",
|
|
993
|
+
iteration=iteration,
|
|
994
|
+
candidate_id=child_id,
|
|
995
|
+
parent_candidate_id=parent_candidate_id,
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
child_val_score = None
|
|
999
|
+
if proceed_to_full_eval:
|
|
1000
|
+
event_sink.log_event(
|
|
1001
|
+
"child_val_set_started",
|
|
1002
|
+
{"row_ids": [row.id for row in valset]},
|
|
1003
|
+
event_layer="extension",
|
|
1004
|
+
iteration=iteration,
|
|
1005
|
+
candidate_id=child_id,
|
|
1006
|
+
parent_candidate_id=parent_candidate_id,
|
|
1007
|
+
)
|
|
1008
|
+
child_val_results = evaluate_candidate(
|
|
1009
|
+
candidate_text=child_text,
|
|
1010
|
+
candidate_id=child_id,
|
|
1011
|
+
rows=valset,
|
|
1012
|
+
split="validation",
|
|
1013
|
+
prompt_context=prompt_context,
|
|
1014
|
+
scorer_context=scorer_context,
|
|
1015
|
+
candidate_runner=candidate_runner,
|
|
1016
|
+
scorer_runner=scorer_runner,
|
|
1017
|
+
budget=budget,
|
|
1018
|
+
evaluation_cache=evaluation_cache,
|
|
1019
|
+
)
|
|
1020
|
+
if local_logger is not None:
|
|
1021
|
+
local_logger.append_evaluations(
|
|
1022
|
+
stage="child_val_set",
|
|
1023
|
+
split="validation",
|
|
1024
|
+
iteration=iteration,
|
|
1025
|
+
candidate_id=child_id,
|
|
1026
|
+
parent_candidate_id=parent_candidate_id,
|
|
1027
|
+
results=child_val_results,
|
|
1028
|
+
)
|
|
1029
|
+
if not _completed_all_rows(child_val_results, valset):
|
|
1030
|
+
event_sink.log_event(
|
|
1031
|
+
"budget_exhausted",
|
|
1032
|
+
{
|
|
1033
|
+
"stage": "child_val_set",
|
|
1034
|
+
"requested_rows": len(valset),
|
|
1035
|
+
"completed_rows": len(child_val_results),
|
|
1036
|
+
"budget": budget.to_payload(),
|
|
1037
|
+
},
|
|
1038
|
+
event_layer="extension",
|
|
1039
|
+
iteration=iteration,
|
|
1040
|
+
candidate_id=child_id,
|
|
1041
|
+
parent_candidate_id=parent_candidate_id,
|
|
1042
|
+
)
|
|
1043
|
+
budget_exhausted = True
|
|
1044
|
+
break
|
|
1045
|
+
budget.used_full_evals += 1
|
|
1046
|
+
child_val_score = _mean(child_val_results)
|
|
1047
|
+
val_scores_by_candidate[child_id] = {
|
|
1048
|
+
result.row_id: result.score for result in child_val_results
|
|
1049
|
+
}
|
|
1050
|
+
event_sink.log_event(
|
|
1051
|
+
"child_val_set_completed",
|
|
1052
|
+
_results_payload(
|
|
1053
|
+
candidate_id=child_id,
|
|
1054
|
+
split="validation",
|
|
1055
|
+
results=child_val_results,
|
|
1056
|
+
log_row_snapshots=config.log_row_snapshots,
|
|
1057
|
+
scorer_version_id=scorer_version_id,
|
|
1058
|
+
metric_key=metric_key,
|
|
1059
|
+
scorer_role="selection",
|
|
1060
|
+
),
|
|
1061
|
+
event_layer="extension",
|
|
1062
|
+
iteration=iteration,
|
|
1063
|
+
candidate_id=child_id,
|
|
1064
|
+
parent_candidate_id=parent_candidate_id,
|
|
1065
|
+
)
|
|
1066
|
+
event_sink.log_event(
|
|
1067
|
+
"candidate_scored",
|
|
1068
|
+
{
|
|
1069
|
+
"split": "validation",
|
|
1070
|
+
"score": child_val_score,
|
|
1071
|
+
"score_value": child_val_score,
|
|
1072
|
+
"scorer_version_id": scorer_version_id,
|
|
1073
|
+
"metric_key": metric_key,
|
|
1074
|
+
"scorer_role": "selection",
|
|
1075
|
+
"higher_is_better": higher_is_better,
|
|
1076
|
+
"baseline_score": seed_score,
|
|
1077
|
+
"delta": child_val_score - seed_score,
|
|
1078
|
+
"per_row_scores": [
|
|
1079
|
+
_evaluation_payload(result, include_row=config.log_row_snapshots)
|
|
1080
|
+
for result in child_val_results
|
|
1081
|
+
],
|
|
1082
|
+
},
|
|
1083
|
+
iteration=iteration,
|
|
1084
|
+
candidate_id=child_id,
|
|
1085
|
+
parent_candidate_id=parent_candidate_id,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
best_candidate_id, best_score, pareto = _pareto_payload(
|
|
1089
|
+
val_scores_by_candidate,
|
|
1090
|
+
candidate_text_by_id,
|
|
1091
|
+
higher_is_better,
|
|
1092
|
+
)
|
|
1093
|
+
event_sink.log_event(
|
|
1094
|
+
"pareto_front_updated",
|
|
1095
|
+
pareto,
|
|
1096
|
+
event_layer="extension",
|
|
1097
|
+
iteration=iteration,
|
|
1098
|
+
candidate_id=best_candidate_id,
|
|
1099
|
+
)
|
|
1100
|
+
if best_candidate_id == child_id:
|
|
1101
|
+
improvement = (best_score - seed_score) if higher_is_better else (seed_score - best_score)
|
|
1102
|
+
event_sink.log_event(
|
|
1103
|
+
"candidate_recommended",
|
|
1104
|
+
{
|
|
1105
|
+
"score": best_score,
|
|
1106
|
+
"delta": best_score - seed_score,
|
|
1107
|
+
"improvement": improvement,
|
|
1108
|
+
"higher_is_better": higher_is_better,
|
|
1109
|
+
},
|
|
1110
|
+
iteration=iteration,
|
|
1111
|
+
candidate_id=child_id,
|
|
1112
|
+
)
|
|
1113
|
+
else:
|
|
1114
|
+
event_sink.log_event(
|
|
1115
|
+
"candidate_rejected",
|
|
1116
|
+
{
|
|
1117
|
+
"reason": "child minibatch total did not beat parent",
|
|
1118
|
+
"parent_score_total": parent_total,
|
|
1119
|
+
"child_score_total": child_total,
|
|
1120
|
+
"higher_is_better": higher_is_better,
|
|
1121
|
+
},
|
|
1122
|
+
iteration=iteration,
|
|
1123
|
+
candidate_id=child_id,
|
|
1124
|
+
parent_candidate_id=parent_candidate_id,
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
|
|
1128
|
+
event_sink.log_event(
|
|
1129
|
+
"iteration_completed",
|
|
1130
|
+
{
|
|
1131
|
+
"parent_candidate_id": parent_candidate_id,
|
|
1132
|
+
"child_candidate_id": child_id,
|
|
1133
|
+
"parent_train_score_total": parent_total,
|
|
1134
|
+
"child_train_score_total": child_total,
|
|
1135
|
+
"child_validation_score": child_val_score,
|
|
1136
|
+
"best_candidate_id": best_candidate_id,
|
|
1137
|
+
"best_validation_score": best_score,
|
|
1138
|
+
"budget": budget.to_payload(),
|
|
1139
|
+
},
|
|
1140
|
+
iteration=iteration,
|
|
1141
|
+
candidate_id=best_candidate_id,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
if budget_exhausted:
|
|
1145
|
+
event_sink.log_event(
|
|
1146
|
+
"run_paused",
|
|
1147
|
+
{
|
|
1148
|
+
"reason": "budget_exhausted",
|
|
1149
|
+
"best_candidate_id": best_candidate_id,
|
|
1150
|
+
"best_validation_score": best_score,
|
|
1151
|
+
"seed_validation_score": seed_score,
|
|
1152
|
+
"higher_is_better": higher_is_better,
|
|
1153
|
+
"budget": budget.to_payload(),
|
|
1154
|
+
},
|
|
1155
|
+
)
|
|
1156
|
+
event_sink.finish_run(
|
|
1157
|
+
status="paused",
|
|
1158
|
+
best_score=best_score,
|
|
1159
|
+
best_candidate_id=best_candidate_id,
|
|
1160
|
+
metadata={
|
|
1161
|
+
"seed_score": seed_score,
|
|
1162
|
+
"budget": budget.to_payload(),
|
|
1163
|
+
"pause_reason": "budget_exhausted",
|
|
1164
|
+
},
|
|
1165
|
+
)
|
|
1166
|
+
return TextGepaResult(
|
|
1167
|
+
run_id=run_id,
|
|
1168
|
+
best_candidate_id=best_candidate_id,
|
|
1169
|
+
best_candidate_text=candidate_text_by_id[best_candidate_id],
|
|
1170
|
+
best_score=best_score,
|
|
1171
|
+
seed_score=seed_score,
|
|
1172
|
+
promoted_prompt_version_id=promoted_prompt_version_id,
|
|
1173
|
+
budget=budget,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
if config.auto_promote and best_candidate_id != "seed":
|
|
1177
|
+
if not prompt_context.prompt_id:
|
|
1178
|
+
raise RuntimeError("Cannot auto-promote without prompt_id in prompt context")
|
|
1179
|
+
promoted_prompt_version_id = event_sink.promote_candidate(
|
|
1180
|
+
candidate_id=best_candidate_id,
|
|
1181
|
+
prompt_id=prompt_context.prompt_id,
|
|
1182
|
+
parent_prompt_version_id=prompt_context.prompt_version_id,
|
|
1183
|
+
body=candidate_text_by_id[best_candidate_id],
|
|
1184
|
+
body_kind=prompt_context.body_kind,
|
|
1185
|
+
provider_settings=prompt_context.provider_settings,
|
|
1186
|
+
runner_version_id=prompt_context.runner_version_id,
|
|
1187
|
+
label=config.promotion_label,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
event_sink.log_event(
|
|
1191
|
+
"run_completed",
|
|
1192
|
+
{
|
|
1193
|
+
"final_prompt_version_id": promoted_prompt_version_id,
|
|
1194
|
+
"best_candidate_id": best_candidate_id,
|
|
1195
|
+
"best_validation_score": best_score,
|
|
1196
|
+
"seed_validation_score": seed_score,
|
|
1197
|
+
"delta": best_score - seed_score,
|
|
1198
|
+
"improvement": (best_score - seed_score) if higher_is_better else (seed_score - best_score),
|
|
1199
|
+
"metric_key": metric_key,
|
|
1200
|
+
"higher_is_better": higher_is_better,
|
|
1201
|
+
"budget": budget.to_payload(),
|
|
1202
|
+
},
|
|
1203
|
+
)
|
|
1204
|
+
event_sink.finish_run(
|
|
1205
|
+
status="succeeded",
|
|
1206
|
+
best_score=best_score,
|
|
1207
|
+
best_candidate_id=best_candidate_id,
|
|
1208
|
+
result_prompt_version_id=promoted_prompt_version_id,
|
|
1209
|
+
metadata={
|
|
1210
|
+
"seed_score": seed_score,
|
|
1211
|
+
"metric_key": metric_key,
|
|
1212
|
+
"higher_is_better": higher_is_better,
|
|
1213
|
+
"budget": budget.to_payload(),
|
|
1214
|
+
},
|
|
1215
|
+
)
|
|
1216
|
+
return TextGepaResult(
|
|
1217
|
+
run_id=run_id,
|
|
1218
|
+
best_candidate_id=best_candidate_id,
|
|
1219
|
+
best_candidate_text=candidate_text_by_id[best_candidate_id],
|
|
1220
|
+
best_score=best_score,
|
|
1221
|
+
seed_score=seed_score,
|
|
1222
|
+
promoted_prompt_version_id=promoted_prompt_version_id,
|
|
1223
|
+
budget=budget,
|
|
1224
|
+
)
|
|
1225
|
+
except Exception as exc:
|
|
1226
|
+
try:
|
|
1227
|
+
event_sink.log_event("run_failed", {"error": str(exc)})
|
|
1228
|
+
except Exception:
|
|
1229
|
+
pass
|
|
1230
|
+
try:
|
|
1231
|
+
event_sink.finish_run(status="failed", metadata={"error": str(exc)})
|
|
1232
|
+
except Exception:
|
|
1233
|
+
pass
|
|
1234
|
+
raise
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def build_reflection_prompt(parent_text: str, parent_results: list[RowEvaluation], config: TextGepaConfig) -> str:
|
|
1238
|
+
examples = []
|
|
1239
|
+
for result in parent_results:
|
|
1240
|
+
examples.append({
|
|
1241
|
+
"row_id": result.row_id,
|
|
1242
|
+
"input": result.row,
|
|
1243
|
+
"output": result.output,
|
|
1244
|
+
"score": result.score,
|
|
1245
|
+
"feedback": result.feedback,
|
|
1246
|
+
"error": result.error,
|
|
1247
|
+
})
|
|
1248
|
+
examples_text = json.dumps({
|
|
1249
|
+
"objective": config.objective,
|
|
1250
|
+
"examples": examples,
|
|
1251
|
+
}, ensure_ascii=False, indent=2)
|
|
1252
|
+
template = config.reflection_prompt_template or DEFAULT_REFLECTION_PROMPT_TEMPLATE
|
|
1253
|
+
if "<current_candidate>" in template and "<evaluation_data>" in template:
|
|
1254
|
+
return (
|
|
1255
|
+
template
|
|
1256
|
+
.replace("<current_candidate>", parent_text)
|
|
1257
|
+
.replace("<evaluation_data>", examples_text)
|
|
1258
|
+
)
|
|
1259
|
+
if "<curr_instructions>" in template and "<inputs_outputs_feedback>" in template:
|
|
1260
|
+
return (
|
|
1261
|
+
template
|
|
1262
|
+
.replace("<curr_instructions>", parent_text)
|
|
1263
|
+
.replace("<inputs_outputs_feedback>", examples_text)
|
|
1264
|
+
)
|
|
1265
|
+
raise ValueError(
|
|
1266
|
+
"reflection_prompt_template must include either "
|
|
1267
|
+
"<current_candidate>/<evaluation_data> or "
|
|
1268
|
+
"<curr_instructions>/<inputs_outputs_feedback>"
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def extract_candidate_text(response: str) -> str:
|
|
1273
|
+
return response.strip()
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
optimize_text_candidate = optimize_loaded_text_candidate
|