synth-optimizers 0.1.0__py3-none-any.whl
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.
- gepa/__init__.py +5 -0
- prompt_opt/__init__.py +16 -0
- prompt_opt/adapters/__init__.py +18 -0
- prompt_opt/adapters/synth_container.py +170 -0
- prompt_opt/adapters/synth_offline.py +81 -0
- prompt_opt/dspy/__init__.py +5 -0
- prompt_opt/dspy/gepa.py +5 -0
- prompt_opt/dspy/miprov2.py +281 -0
- prompt_opt/gepa_ai_compat.py +225 -0
- prompt_opt/mipro.py +114 -0
- prompt_opt/sdk/__init__.py +2 -0
- prompt_opt/sdk/optimization/__init__.py +14 -0
- prompt_opt/sdk/optimization/internal/__init__.py +2 -0
- prompt_opt/sdk/optimization/internal/configs/__init__.py +16 -0
- prompt_opt/sdk/optimization/internal/configs/prompt_learning.py +230 -0
- prompt_opt/sdk/optimization/internal/configs/shared.py +12 -0
- prompt_opt/sdk/optimization/internal/prompt_learning.py +302 -0
- prompt_opt/sdk/optimization/internal/runtime.py +1465 -0
- prompt_opt/sdk/optimization/internal/utils.py +29 -0
- prompt_opt/sdk/optimization/models.py +71 -0
- prompt_opt/sdk/optimization/policy/__init__.py +6 -0
- prompt_opt/sdk/optimization/policy/v1.py +470 -0
- synth_optimizers-0.1.0.dist-info/METADATA +243 -0
- synth_optimizers-0.1.0.dist-info/RECORD +27 -0
- synth_optimizers-0.1.0.dist-info/WHEEL +5 -0
- synth_optimizers-0.1.0.dist-info/licenses/LICENSE +176 -0
- synth_optimizers-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
"""Shared in-process runtime for local offline GEPA and MIPRO jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib import request
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from ..models import PolicyCandidate, PolicyCandidatePage, PromptLearningResult
|
|
20
|
+
from .configs.prompt_learning import (
|
|
21
|
+
GEPAConfig,
|
|
22
|
+
MIPROAlgorithmConfig,
|
|
23
|
+
MessagePatternConfig,
|
|
24
|
+
PromptCandidateConfig,
|
|
25
|
+
PromptLearningConfig,
|
|
26
|
+
PromptStageConfig,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _utc_now_iso() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _new_id(prefix: str) -> str:
|
|
35
|
+
return f"{prefix}_{uuid4().hex[:16]}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _as_list_of_dicts(value: Any) -> list[dict[str, Any]]:
|
|
39
|
+
if not isinstance(value, list):
|
|
40
|
+
return []
|
|
41
|
+
return [dict(item) for item in value if isinstance(item, dict)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_sync(coro: Any) -> Any:
|
|
45
|
+
try:
|
|
46
|
+
asyncio.get_running_loop()
|
|
47
|
+
except RuntimeError:
|
|
48
|
+
return asyncio.run(coro)
|
|
49
|
+
raise RuntimeError("Cannot block on a coroutine from an active event loop")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _maybe_await(value: Any) -> Any:
|
|
53
|
+
if inspect.isawaitable(value):
|
|
54
|
+
return await value
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _normalize_text(value: Any) -> str:
|
|
59
|
+
if value is None:
|
|
60
|
+
return ""
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
return value
|
|
63
|
+
return json.dumps(value, sort_keys=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clean_key(value: Any) -> str:
|
|
67
|
+
if not isinstance(value, str):
|
|
68
|
+
return ""
|
|
69
|
+
return value.strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _render_pattern(pattern: str, values: dict[str, Any]) -> str:
|
|
73
|
+
rendered = pattern
|
|
74
|
+
for key, value in values.items():
|
|
75
|
+
rendered = rendered.replace(f"{{{key}}}", _normalize_text(value))
|
|
76
|
+
return rendered
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _first_message_text(stage: PromptStageConfig) -> str:
|
|
80
|
+
if not stage.messages:
|
|
81
|
+
return ""
|
|
82
|
+
ordered = sorted(stage.messages, key=lambda item: item.order)
|
|
83
|
+
return ordered[0].pattern
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_instruction_index(stage: PromptStageConfig) -> int:
|
|
87
|
+
ordered = sorted(enumerate(stage.messages), key=lambda item: item[1].order)
|
|
88
|
+
for original_index, message in ordered:
|
|
89
|
+
if message.role == "system":
|
|
90
|
+
return original_index
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _extract_stage_text(stage: PromptStageConfig) -> str:
|
|
95
|
+
if not stage.messages:
|
|
96
|
+
return ""
|
|
97
|
+
return stage.messages[_find_instruction_index(stage)].pattern
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_instruction_fragment(value: str) -> str:
|
|
101
|
+
return "\n".join(line.strip() for line in value.strip().splitlines() if line.strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _append_stage_text_once(base_text: str, fragment: str) -> str:
|
|
105
|
+
normalized_fragment = _normalize_instruction_fragment(fragment)
|
|
106
|
+
if not normalized_fragment:
|
|
107
|
+
return base_text.strip()
|
|
108
|
+
paragraphs = {
|
|
109
|
+
_normalize_instruction_fragment(part)
|
|
110
|
+
for part in base_text.split("\n\n")
|
|
111
|
+
if _normalize_instruction_fragment(part)
|
|
112
|
+
}
|
|
113
|
+
if normalized_fragment in paragraphs:
|
|
114
|
+
return base_text.strip()
|
|
115
|
+
if not base_text.strip():
|
|
116
|
+
return fragment.strip()
|
|
117
|
+
return f"{base_text.rstrip()}\n\n{fragment.strip()}".strip()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _replace_stage_text(stage: PromptStageConfig, updated_text: str) -> PromptStageConfig:
|
|
121
|
+
messages = list(stage.messages)
|
|
122
|
+
if not messages:
|
|
123
|
+
messages = [MessagePatternConfig(role="system", pattern=updated_text, order=0)]
|
|
124
|
+
return PromptStageConfig(
|
|
125
|
+
id=stage.id,
|
|
126
|
+
name=stage.name,
|
|
127
|
+
messages=messages,
|
|
128
|
+
wildcards=dict(stage.wildcards),
|
|
129
|
+
metadata=dict(stage.metadata),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
target_index = _find_instruction_index(stage)
|
|
133
|
+
updated_messages: list[MessagePatternConfig] = []
|
|
134
|
+
for index, message in enumerate(messages):
|
|
135
|
+
if index == target_index:
|
|
136
|
+
updated_messages.append(
|
|
137
|
+
MessagePatternConfig(role=message.role, pattern=updated_text, order=message.order)
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
updated_messages.append(message)
|
|
141
|
+
return PromptStageConfig(
|
|
142
|
+
id=stage.id,
|
|
143
|
+
name=stage.name,
|
|
144
|
+
messages=updated_messages,
|
|
145
|
+
wildcards=dict(stage.wildcards),
|
|
146
|
+
metadata=dict(stage.metadata),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class AtomicInstructionTransform:
|
|
152
|
+
"""One stage-local prompt edit."""
|
|
153
|
+
|
|
154
|
+
transform_id: str
|
|
155
|
+
stage_id: str
|
|
156
|
+
description: str
|
|
157
|
+
appended_text: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class CandidateBundle:
|
|
162
|
+
"""Internal multi-stage candidate representation."""
|
|
163
|
+
|
|
164
|
+
candidate_id: str
|
|
165
|
+
stages: list[PromptStageConfig]
|
|
166
|
+
parent_id: str | None
|
|
167
|
+
generation: int
|
|
168
|
+
transform_ids_by_stage: dict[str, str | None]
|
|
169
|
+
created_at: str
|
|
170
|
+
avg_reward: float | None = None
|
|
171
|
+
status: str = "proposed"
|
|
172
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
173
|
+
|
|
174
|
+
def rendered_messages(self, example: dict[str, Any]) -> list[dict[str, Any]]:
|
|
175
|
+
rendered: list[dict[str, Any]] = []
|
|
176
|
+
for stage in self.stages:
|
|
177
|
+
ordered = sorted(stage.messages, key=lambda item: item.order)
|
|
178
|
+
for message in ordered:
|
|
179
|
+
rendered.append(
|
|
180
|
+
{
|
|
181
|
+
"role": message.role,
|
|
182
|
+
"content": _render_pattern(message.pattern, example),
|
|
183
|
+
"stage_id": stage.id,
|
|
184
|
+
"stage_name": stage.name,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return rendered
|
|
188
|
+
|
|
189
|
+
def candidate_content(self) -> str:
|
|
190
|
+
parts: list[str] = []
|
|
191
|
+
for stage in self.stages:
|
|
192
|
+
stage_name = stage.name or stage.id or "stage"
|
|
193
|
+
text = _extract_stage_text(stage)
|
|
194
|
+
parts.append(f"[{stage_name}]\n{text}".strip())
|
|
195
|
+
return "\n\n".join(part for part in parts if part.strip())
|
|
196
|
+
|
|
197
|
+
def legacy_prompt_map(self) -> dict[str, str]:
|
|
198
|
+
content = self.candidate_content()
|
|
199
|
+
legacy: dict[str, str] = {"candidate_content": content}
|
|
200
|
+
if self.stages:
|
|
201
|
+
first_stage = self.stages[0]
|
|
202
|
+
legacy["system_prompt"] = _extract_stage_text(first_stage)
|
|
203
|
+
legacy["prompt"] = legacy["system_prompt"]
|
|
204
|
+
legacy["instruction"] = legacy["system_prompt"]
|
|
205
|
+
return legacy
|
|
206
|
+
|
|
207
|
+
def to_payload(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
job_id: str,
|
|
211
|
+
system_id: str,
|
|
212
|
+
system_name: str,
|
|
213
|
+
algorithm: str,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
candidate_payload = {
|
|
216
|
+
"candidate_id": self.candidate_id,
|
|
217
|
+
"job_id": job_id,
|
|
218
|
+
"system_id": system_id,
|
|
219
|
+
"system_name": system_name,
|
|
220
|
+
"algorithm": algorithm,
|
|
221
|
+
"mode": "offline",
|
|
222
|
+
"status": self.status,
|
|
223
|
+
"avg_reward": self.avg_reward,
|
|
224
|
+
"created_at": self.created_at,
|
|
225
|
+
"updated_at": _utc_now_iso(),
|
|
226
|
+
"generation": self.generation,
|
|
227
|
+
"parent_id": self.parent_id,
|
|
228
|
+
"metadata": dict(self.metadata),
|
|
229
|
+
"candidate": {
|
|
230
|
+
"candidate_id": self.candidate_id,
|
|
231
|
+
"stages": [
|
|
232
|
+
{
|
|
233
|
+
"id": stage.id,
|
|
234
|
+
"name": stage.name,
|
|
235
|
+
"messages": [
|
|
236
|
+
{
|
|
237
|
+
"role": message.role,
|
|
238
|
+
"pattern": message.pattern,
|
|
239
|
+
"content": message.pattern,
|
|
240
|
+
"order": message.order,
|
|
241
|
+
}
|
|
242
|
+
for message in sorted(stage.messages, key=lambda item: item.order)
|
|
243
|
+
],
|
|
244
|
+
"wildcards": dict(stage.wildcards),
|
|
245
|
+
"metadata": dict(stage.metadata),
|
|
246
|
+
}
|
|
247
|
+
for stage in self.stages
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
"stages": [
|
|
251
|
+
{
|
|
252
|
+
"id": stage.id,
|
|
253
|
+
"name": stage.name,
|
|
254
|
+
"messages": [
|
|
255
|
+
{
|
|
256
|
+
"role": message.role,
|
|
257
|
+
"pattern": message.pattern,
|
|
258
|
+
"content": message.pattern,
|
|
259
|
+
"order": message.order,
|
|
260
|
+
}
|
|
261
|
+
for message in sorted(stage.messages, key=lambda item: item.order)
|
|
262
|
+
],
|
|
263
|
+
"wildcards": dict(stage.wildcards),
|
|
264
|
+
"metadata": dict(stage.metadata),
|
|
265
|
+
}
|
|
266
|
+
for stage in self.stages
|
|
267
|
+
],
|
|
268
|
+
"transform_ids_by_stage": dict(self.transform_ids_by_stage),
|
|
269
|
+
"candidate_content": self.candidate_content(),
|
|
270
|
+
"content": self.candidate_content(),
|
|
271
|
+
"artifact_payload": {"text": self.candidate_content()},
|
|
272
|
+
"messages": self.rendered_messages({}),
|
|
273
|
+
}
|
|
274
|
+
candidate_payload.update(self.legacy_prompt_map())
|
|
275
|
+
return candidate_payload
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class SeedEvalRecord:
|
|
280
|
+
candidate_id: str
|
|
281
|
+
split: str
|
|
282
|
+
seed: int
|
|
283
|
+
reward: float
|
|
284
|
+
rollout_id: str
|
|
285
|
+
success: bool
|
|
286
|
+
metadata: dict[str, Any]
|
|
287
|
+
created_at: str
|
|
288
|
+
|
|
289
|
+
def to_payload(self, job_id: str) -> dict[str, Any]:
|
|
290
|
+
return {
|
|
291
|
+
"seed_eval_id": f"{job_id}:{self.candidate_id}:{self.split}:{self.seed}:{self.rollout_id}",
|
|
292
|
+
"job_id": job_id,
|
|
293
|
+
"candidate_id": self.candidate_id,
|
|
294
|
+
"split": self.split,
|
|
295
|
+
"seed": self.seed,
|
|
296
|
+
"reward": self.reward,
|
|
297
|
+
"avg_reward": self.reward,
|
|
298
|
+
"success": self.success,
|
|
299
|
+
"metadata": dict(self.metadata),
|
|
300
|
+
"created_at": self.created_at,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class AsyncDiscreteTpe:
|
|
305
|
+
"""Small async-friendly discrete TPE implementation."""
|
|
306
|
+
|
|
307
|
+
def __init__(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
seed: int = 0,
|
|
311
|
+
gamma: float = 0.3,
|
|
312
|
+
n_candidates: int = 32,
|
|
313
|
+
n_startup_trials: int = 5,
|
|
314
|
+
alpha: float = 1.0,
|
|
315
|
+
epsilon: float = 1e-6,
|
|
316
|
+
) -> None:
|
|
317
|
+
self._rng = random.Random(seed)
|
|
318
|
+
self._gamma = max(0.05, min(gamma, 0.95))
|
|
319
|
+
self._n_candidates = max(4, int(n_candidates))
|
|
320
|
+
self._n_startup_trials = max(1, int(n_startup_trials))
|
|
321
|
+
self._alpha = max(float(alpha), 1e-6)
|
|
322
|
+
self._epsilon = max(float(epsilon), 1e-9)
|
|
323
|
+
self._trials: list[tuple[dict[str, Any], float]] = []
|
|
324
|
+
self._seen_signatures: set[tuple[tuple[str, str], ...]] = set()
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _signature(config: dict[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
328
|
+
return tuple(sorted((key, repr(value)) for key, value in config.items()))
|
|
329
|
+
|
|
330
|
+
async def tell(self, config: dict[str, Any], score: float) -> None:
|
|
331
|
+
signature = self._signature(config)
|
|
332
|
+
self._seen_signatures.add(signature)
|
|
333
|
+
self._trials.append((dict(config), float(score)))
|
|
334
|
+
|
|
335
|
+
def _uniform_config(self, search_space: dict[str, list[Any]]) -> dict[str, Any]:
|
|
336
|
+
return {key: self._rng.choice(values) for key, values in search_space.items()}
|
|
337
|
+
|
|
338
|
+
def _startup_config(self, search_space: dict[str, list[Any]]) -> dict[str, Any]:
|
|
339
|
+
candidate: dict[str, Any] = {}
|
|
340
|
+
trial_index = len(self._trials)
|
|
341
|
+
for dimension_index, (key, values) in enumerate(sorted(search_space.items())):
|
|
342
|
+
if not values:
|
|
343
|
+
continue
|
|
344
|
+
offset = 1 if len(values) > 1 and values[0] is None else 0
|
|
345
|
+
choice_index = (trial_index + dimension_index + offset) % len(values)
|
|
346
|
+
candidate[key] = values[choice_index]
|
|
347
|
+
return candidate
|
|
348
|
+
|
|
349
|
+
def _smoothed_probability(
|
|
350
|
+
self,
|
|
351
|
+
*,
|
|
352
|
+
counts: Counter,
|
|
353
|
+
total: int,
|
|
354
|
+
choices: list[Any],
|
|
355
|
+
) -> dict[Any, float]:
|
|
356
|
+
denominator = total + self._alpha * max(len(choices), 1)
|
|
357
|
+
return {
|
|
358
|
+
choice: (counts.get(repr(choice), 0) + self._alpha) / max(denominator, self._epsilon)
|
|
359
|
+
for choice in choices
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async def suggest(
|
|
363
|
+
self,
|
|
364
|
+
search_space: dict[str, list[Any]],
|
|
365
|
+
*,
|
|
366
|
+
taboo_signatures: set[tuple[tuple[str, str], ...]] | None = None,
|
|
367
|
+
) -> dict[str, Any] | None:
|
|
368
|
+
taboo = taboo_signatures or set()
|
|
369
|
+
if not search_space:
|
|
370
|
+
return {}
|
|
371
|
+
|
|
372
|
+
if len(self._trials) < self._n_startup_trials:
|
|
373
|
+
candidate = self._startup_config(search_space)
|
|
374
|
+
signature = self._signature(candidate)
|
|
375
|
+
if signature not in self._seen_signatures and signature not in taboo:
|
|
376
|
+
return candidate
|
|
377
|
+
for _ in range(256):
|
|
378
|
+
candidate = self._uniform_config(search_space)
|
|
379
|
+
signature = self._signature(candidate)
|
|
380
|
+
if signature not in self._seen_signatures and signature not in taboo:
|
|
381
|
+
return candidate
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
ranked = sorted(self._trials, key=lambda item: item[1], reverse=True)
|
|
385
|
+
split_index = max(1, int(math.ceil(len(ranked) * self._gamma)))
|
|
386
|
+
good = ranked[:split_index]
|
|
387
|
+
bad = ranked[split_index:] or ranked[-1:]
|
|
388
|
+
|
|
389
|
+
per_dimension_good: dict[str, Counter] = {}
|
|
390
|
+
per_dimension_bad: dict[str, Counter] = {}
|
|
391
|
+
for dimension, _choices in search_space.items():
|
|
392
|
+
per_dimension_good[dimension] = Counter(repr(config[dimension]) for config, _score in good)
|
|
393
|
+
per_dimension_bad[dimension] = Counter(repr(config[dimension]) for config, _score in bad)
|
|
394
|
+
|
|
395
|
+
best_candidate: dict[str, Any] | None = None
|
|
396
|
+
best_acquisition = float("-inf")
|
|
397
|
+
for _ in range(self._n_candidates):
|
|
398
|
+
candidate: dict[str, Any] = {}
|
|
399
|
+
acquisition = 0.0
|
|
400
|
+
for dimension, choices in search_space.items():
|
|
401
|
+
good_probs = self._smoothed_probability(
|
|
402
|
+
counts=per_dimension_good[dimension],
|
|
403
|
+
total=len(good),
|
|
404
|
+
choices=choices,
|
|
405
|
+
)
|
|
406
|
+
bad_probs = self._smoothed_probability(
|
|
407
|
+
counts=per_dimension_bad[dimension],
|
|
408
|
+
total=len(bad),
|
|
409
|
+
choices=choices,
|
|
410
|
+
)
|
|
411
|
+
weights = [
|
|
412
|
+
max((good_probs[choice] / max(bad_probs[choice], self._epsilon)), self._epsilon)
|
|
413
|
+
for choice in choices
|
|
414
|
+
]
|
|
415
|
+
picked = self._rng.choices(choices, weights=weights, k=1)[0]
|
|
416
|
+
candidate[dimension] = picked
|
|
417
|
+
acquisition += math.log(good_probs[picked] + self._epsilon) - math.log(
|
|
418
|
+
bad_probs[picked] + self._epsilon
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
signature = self._signature(candidate)
|
|
422
|
+
if signature in self._seen_signatures or signature in taboo:
|
|
423
|
+
continue
|
|
424
|
+
if acquisition > best_acquisition:
|
|
425
|
+
best_candidate = candidate
|
|
426
|
+
best_acquisition = acquisition
|
|
427
|
+
|
|
428
|
+
if best_candidate is not None:
|
|
429
|
+
return best_candidate
|
|
430
|
+
|
|
431
|
+
for _ in range(256):
|
|
432
|
+
candidate = self._uniform_config(search_space)
|
|
433
|
+
signature = self._signature(candidate)
|
|
434
|
+
if signature not in self._seen_signatures and signature not in taboo:
|
|
435
|
+
return candidate
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass
|
|
440
|
+
class LocalJobRecord:
|
|
441
|
+
"""Mutable in-memory job state."""
|
|
442
|
+
|
|
443
|
+
job_id: str
|
|
444
|
+
kind: str
|
|
445
|
+
technique: str
|
|
446
|
+
system_id: str
|
|
447
|
+
system_name: str
|
|
448
|
+
config: PromptLearningConfig
|
|
449
|
+
raw_config: dict[str, Any]
|
|
450
|
+
metadata: dict[str, Any]
|
|
451
|
+
auto_start: bool
|
|
452
|
+
created_at: str
|
|
453
|
+
api_version: str = "v1"
|
|
454
|
+
state: str = "created"
|
|
455
|
+
result_payload: dict[str, Any] = field(default_factory=dict)
|
|
456
|
+
candidates: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
457
|
+
candidate_versions: dict[str, int] = field(default_factory=dict)
|
|
458
|
+
seed_evals: list[SeedEvalRecord] = field(default_factory=list)
|
|
459
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
460
|
+
artifacts_payload: dict[str, Any] = field(default_factory=dict)
|
|
461
|
+
trial_queue: list[dict[str, Any]] = field(default_factory=list)
|
|
462
|
+
rollout_queue: list[dict[str, Any]] = field(default_factory=list)
|
|
463
|
+
baseline_info: dict[str, Any] = field(default_factory=dict)
|
|
464
|
+
state_envelope: dict[str, Any] = field(default_factory=dict)
|
|
465
|
+
checkpoint_payload: dict[str, Any] = field(default_factory=dict)
|
|
466
|
+
best_candidate_id: str | None = None
|
|
467
|
+
selected_candidate_id: str | None = None
|
|
468
|
+
baseline_candidate_id: str | None = None
|
|
469
|
+
best_reward: float | None = None
|
|
470
|
+
error_message: str | None = None
|
|
471
|
+
local_runtime_options: dict[str, Any] = field(default_factory=dict)
|
|
472
|
+
_thread: threading.Thread | None = field(default=None, repr=False)
|
|
473
|
+
_condition: threading.Condition = field(
|
|
474
|
+
default_factory=lambda: threading.Condition(threading.RLock()),
|
|
475
|
+
repr=False,
|
|
476
|
+
)
|
|
477
|
+
_next_event_seq: int = field(default=1, repr=False)
|
|
478
|
+
_next_candidate_version: int = field(default=1, repr=False)
|
|
479
|
+
_cancel_requested: bool = field(default=False, repr=False)
|
|
480
|
+
|
|
481
|
+
def append_event(self, event_type: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
482
|
+
with self._condition:
|
|
483
|
+
event = {
|
|
484
|
+
"seq": self._next_event_seq,
|
|
485
|
+
"event_type": event_type,
|
|
486
|
+
"timestamp": _utc_now_iso(),
|
|
487
|
+
"job_id": self.job_id,
|
|
488
|
+
"system_id": self.system_id,
|
|
489
|
+
"payload": dict(payload or {}),
|
|
490
|
+
}
|
|
491
|
+
self._next_event_seq += 1
|
|
492
|
+
self.events.append(event)
|
|
493
|
+
self._condition.notify_all()
|
|
494
|
+
return event
|
|
495
|
+
|
|
496
|
+
def wait_if_paused(self) -> None:
|
|
497
|
+
with self._condition:
|
|
498
|
+
while self.state == "paused" and not self._cancel_requested:
|
|
499
|
+
self._condition.wait(timeout=0.2)
|
|
500
|
+
if self._cancel_requested:
|
|
501
|
+
raise RuntimeError("job_cancelled")
|
|
502
|
+
|
|
503
|
+
def check_cancelled(self) -> None:
|
|
504
|
+
with self._condition:
|
|
505
|
+
if self._cancel_requested:
|
|
506
|
+
raise RuntimeError("job_cancelled")
|
|
507
|
+
|
|
508
|
+
def set_state(self, state: str) -> None:
|
|
509
|
+
with self._condition:
|
|
510
|
+
self.state = state
|
|
511
|
+
self._condition.notify_all()
|
|
512
|
+
|
|
513
|
+
def request_cancel(self) -> None:
|
|
514
|
+
with self._condition:
|
|
515
|
+
self._cancel_requested = True
|
|
516
|
+
self._condition.notify_all()
|
|
517
|
+
|
|
518
|
+
def register_candidate(self, bundle: CandidateBundle, *, algorithm: str) -> dict[str, Any]:
|
|
519
|
+
with self._condition:
|
|
520
|
+
payload = bundle.to_payload(
|
|
521
|
+
job_id=self.job_id,
|
|
522
|
+
system_id=self.system_id,
|
|
523
|
+
system_name=self.system_name,
|
|
524
|
+
algorithm=algorithm,
|
|
525
|
+
)
|
|
526
|
+
self.candidates[bundle.candidate_id] = payload
|
|
527
|
+
self.candidate_versions[bundle.candidate_id] = self._next_candidate_version
|
|
528
|
+
self._next_candidate_version += 1
|
|
529
|
+
self.selected_candidate_id = bundle.candidate_id
|
|
530
|
+
if self.baseline_candidate_id is None:
|
|
531
|
+
self.baseline_candidate_id = bundle.candidate_id
|
|
532
|
+
self._refresh_envelopes()
|
|
533
|
+
self._condition.notify_all()
|
|
534
|
+
return payload
|
|
535
|
+
|
|
536
|
+
def update_candidate_score(
|
|
537
|
+
self,
|
|
538
|
+
candidate_id: str,
|
|
539
|
+
*,
|
|
540
|
+
reward: float,
|
|
541
|
+
status: str = "evaluated",
|
|
542
|
+
) -> None:
|
|
543
|
+
with self._condition:
|
|
544
|
+
candidate = self.candidates[candidate_id]
|
|
545
|
+
candidate["avg_reward"] = reward
|
|
546
|
+
candidate["score"] = reward
|
|
547
|
+
candidate["status"] = status
|
|
548
|
+
candidate["updated_at"] = _utc_now_iso()
|
|
549
|
+
if self.best_reward is None or reward >= self.best_reward:
|
|
550
|
+
self.best_reward = reward
|
|
551
|
+
self.best_candidate_id = candidate_id
|
|
552
|
+
self.selected_candidate_id = candidate_id
|
|
553
|
+
self._refresh_envelopes()
|
|
554
|
+
self._condition.notify_all()
|
|
555
|
+
|
|
556
|
+
def record_seed_eval(self, seed_eval: SeedEvalRecord) -> None:
|
|
557
|
+
with self._condition:
|
|
558
|
+
self.seed_evals.append(seed_eval)
|
|
559
|
+
self._refresh_envelopes()
|
|
560
|
+
self._condition.notify_all()
|
|
561
|
+
|
|
562
|
+
def _refresh_envelopes(self) -> None:
|
|
563
|
+
lever_summary = {
|
|
564
|
+
"prompt_lever_id": "prompt_lever",
|
|
565
|
+
"candidate_lever_versions": dict(self.candidate_versions),
|
|
566
|
+
"best_candidate_id": self.best_candidate_id,
|
|
567
|
+
"selected_candidate_id": self.selected_candidate_id,
|
|
568
|
+
"baseline_candidate_id": self.baseline_candidate_id,
|
|
569
|
+
"lever_count": len(self.candidates),
|
|
570
|
+
"mutation_count": max(0, len(self.candidates) - 1),
|
|
571
|
+
"latest_version": max(self.candidate_versions.values(), default=0),
|
|
572
|
+
}
|
|
573
|
+
state_value = {
|
|
574
|
+
"job_id": self.job_id,
|
|
575
|
+
"system_id": self.system_id,
|
|
576
|
+
"algorithm_kind": "mipro" if "mipro" in self.kind else "gepa",
|
|
577
|
+
"execution_mode": "retrieved",
|
|
578
|
+
"best_candidate_id": self.best_candidate_id,
|
|
579
|
+
"selected_candidate_id": self.selected_candidate_id,
|
|
580
|
+
"baseline_candidate_id": self.baseline_candidate_id,
|
|
581
|
+
"best_reward": self.best_reward,
|
|
582
|
+
"rollout_count": len(self.seed_evals),
|
|
583
|
+
"candidate_lever_versions": dict(self.candidate_versions),
|
|
584
|
+
"lever_summary": lever_summary,
|
|
585
|
+
"candidates": dict(self.candidates),
|
|
586
|
+
"seed_evals": [item.to_payload(self.job_id) for item in self.seed_evals],
|
|
587
|
+
}
|
|
588
|
+
self.state_envelope = {
|
|
589
|
+
"job_id": self.job_id,
|
|
590
|
+
"system_id": self.system_id,
|
|
591
|
+
"state": state_value,
|
|
592
|
+
"baseline_info": dict(self.baseline_info),
|
|
593
|
+
}
|
|
594
|
+
self.checkpoint_payload = {
|
|
595
|
+
"job_id": self.job_id,
|
|
596
|
+
"system_id": self.system_id,
|
|
597
|
+
"created_at": self.created_at,
|
|
598
|
+
"state": state_value,
|
|
599
|
+
"config": self.raw_config,
|
|
600
|
+
}
|
|
601
|
+
best_candidate = (
|
|
602
|
+
dict(self.candidates[self.best_candidate_id]) if self.best_candidate_id in self.candidates else None
|
|
603
|
+
)
|
|
604
|
+
self.result_payload = {
|
|
605
|
+
"job_id": self.job_id,
|
|
606
|
+
"status": self.state,
|
|
607
|
+
"system": {"id": self.system_id, "name": self.system_name},
|
|
608
|
+
"best_reward": self.best_reward,
|
|
609
|
+
"best_score": self.best_reward,
|
|
610
|
+
"best_candidate_id": self.best_candidate_id,
|
|
611
|
+
"selected_candidate_id": self.selected_candidate_id,
|
|
612
|
+
"baseline_candidate_id": self.baseline_candidate_id,
|
|
613
|
+
"best_candidate": best_candidate,
|
|
614
|
+
"lever_summary": lever_summary,
|
|
615
|
+
"lever_versions": dict(self.candidate_versions),
|
|
616
|
+
"best_lever_version": self.candidate_versions.get(
|
|
617
|
+
self.best_candidate_id or self.baseline_candidate_id or "",
|
|
618
|
+
),
|
|
619
|
+
"result": {
|
|
620
|
+
"best_candidate": best_candidate,
|
|
621
|
+
"best_reward": self.best_reward,
|
|
622
|
+
"lever_summary": lever_summary,
|
|
623
|
+
"lever_versions": dict(self.candidate_versions),
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
if self.error_message:
|
|
627
|
+
self.result_payload["error"] = self.error_message
|
|
628
|
+
|
|
629
|
+
def status_payload(self) -> dict[str, Any]:
|
|
630
|
+
with self._condition:
|
|
631
|
+
payload = {
|
|
632
|
+
"job_id": self.job_id,
|
|
633
|
+
"kind": self.kind,
|
|
634
|
+
"technique": self.technique,
|
|
635
|
+
"state": self.state,
|
|
636
|
+
"status": self.state,
|
|
637
|
+
"created_at": self.created_at,
|
|
638
|
+
"updated_at": _utc_now_iso(),
|
|
639
|
+
"system": {"id": self.system_id, "name": self.system_name},
|
|
640
|
+
"best_reward": self.best_reward,
|
|
641
|
+
"best_score": self.best_reward,
|
|
642
|
+
"best_candidate_id": self.best_candidate_id,
|
|
643
|
+
"selected_candidate_id": self.selected_candidate_id,
|
|
644
|
+
"baseline_candidate_id": self.baseline_candidate_id,
|
|
645
|
+
"lever_summary": self.result_payload.get("lever_summary", {}),
|
|
646
|
+
"lever_versions": self.result_payload.get("lever_versions", {}),
|
|
647
|
+
"best_lever_version": self.result_payload.get("best_lever_version"),
|
|
648
|
+
}
|
|
649
|
+
if self.error_message:
|
|
650
|
+
payload["error"] = self.error_message
|
|
651
|
+
return payload
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class LocalPromptLearningRuntime:
|
|
655
|
+
"""Singleton local runtime backing the mirrored SDK."""
|
|
656
|
+
|
|
657
|
+
TERMINAL_STATES = {"succeeded", "failed", "cancelled"}
|
|
658
|
+
|
|
659
|
+
def __init__(self) -> None:
|
|
660
|
+
self._lock = threading.RLock()
|
|
661
|
+
self._jobs: dict[str, LocalJobRecord] = {}
|
|
662
|
+
self._systems_by_name: dict[str, str] = {}
|
|
663
|
+
self._systems_by_id: dict[str, str] = {}
|
|
664
|
+
|
|
665
|
+
def list_jobs(
|
|
666
|
+
self,
|
|
667
|
+
*,
|
|
668
|
+
state: str | None = None,
|
|
669
|
+
kind: str | None = None,
|
|
670
|
+
system_id: str | None = None,
|
|
671
|
+
system_name: str | None = None,
|
|
672
|
+
limit: int = 100,
|
|
673
|
+
) -> dict[str, Any]:
|
|
674
|
+
with self._lock:
|
|
675
|
+
items = list(self._jobs.values())
|
|
676
|
+
filtered: list[dict[str, Any]] = []
|
|
677
|
+
for job in items:
|
|
678
|
+
if state and job.state != state:
|
|
679
|
+
continue
|
|
680
|
+
if kind and job.kind != kind:
|
|
681
|
+
continue
|
|
682
|
+
if system_id and job.system_id != system_id:
|
|
683
|
+
continue
|
|
684
|
+
if system_name and job.system_name != system_name:
|
|
685
|
+
continue
|
|
686
|
+
filtered.append(job.status_payload())
|
|
687
|
+
filtered.sort(key=lambda item: str(item.get("created_at", "")), reverse=True)
|
|
688
|
+
return {"items": filtered[: max(1, limit)], "next_cursor": None}
|
|
689
|
+
|
|
690
|
+
def get_job(self, job_id: str) -> LocalJobRecord:
|
|
691
|
+
with self._lock:
|
|
692
|
+
try:
|
|
693
|
+
return self._jobs[job_id]
|
|
694
|
+
except KeyError as exc:
|
|
695
|
+
raise ValueError(f"Unknown local job_id={job_id!r}") from exc
|
|
696
|
+
|
|
697
|
+
def create_job(
|
|
698
|
+
self,
|
|
699
|
+
*,
|
|
700
|
+
kind: str,
|
|
701
|
+
technique: str,
|
|
702
|
+
system_name: str,
|
|
703
|
+
system_id: str | None,
|
|
704
|
+
reuse_system: bool,
|
|
705
|
+
config: dict[str, Any],
|
|
706
|
+
metadata: dict[str, Any] | None,
|
|
707
|
+
auto_start: bool,
|
|
708
|
+
api_version: str,
|
|
709
|
+
) -> LocalJobRecord:
|
|
710
|
+
parsed_config = PromptLearningConfig.from_mapping(config)
|
|
711
|
+
local_runtime_options = self._resolve_local_runtime_options(config, parsed_config)
|
|
712
|
+
|
|
713
|
+
with self._lock:
|
|
714
|
+
resolved_system_id = system_id
|
|
715
|
+
if reuse_system and system_name in self._systems_by_name:
|
|
716
|
+
resolved_system_id = self._systems_by_name[system_name]
|
|
717
|
+
if not resolved_system_id:
|
|
718
|
+
resolved_system_id = _new_id("system")
|
|
719
|
+
job_id = _new_id("pl")
|
|
720
|
+
record = LocalJobRecord(
|
|
721
|
+
job_id=job_id,
|
|
722
|
+
kind=kind,
|
|
723
|
+
technique=technique,
|
|
724
|
+
system_id=resolved_system_id,
|
|
725
|
+
system_name=system_name,
|
|
726
|
+
config=parsed_config,
|
|
727
|
+
raw_config=dict(config),
|
|
728
|
+
metadata=dict(metadata or {}),
|
|
729
|
+
auto_start=auto_start,
|
|
730
|
+
created_at=_utc_now_iso(),
|
|
731
|
+
api_version=api_version,
|
|
732
|
+
local_runtime_options=local_runtime_options,
|
|
733
|
+
)
|
|
734
|
+
record.append_event("prompt_learning.created", {"status": "created"})
|
|
735
|
+
self._jobs[job_id] = record
|
|
736
|
+
self._systems_by_name[system_name] = resolved_system_id
|
|
737
|
+
self._systems_by_id[resolved_system_id] = system_name
|
|
738
|
+
|
|
739
|
+
if auto_start:
|
|
740
|
+
self.start_job(job_id)
|
|
741
|
+
return record
|
|
742
|
+
|
|
743
|
+
def _resolve_local_runtime_options(
|
|
744
|
+
self,
|
|
745
|
+
raw_config: dict[str, Any],
|
|
746
|
+
parsed_config: PromptLearningConfig,
|
|
747
|
+
) -> dict[str, Any]:
|
|
748
|
+
root_payload = dict(raw_config.get("prompt_learning", raw_config))
|
|
749
|
+
options = root_payload.get("local_runtime")
|
|
750
|
+
if not isinstance(options, dict):
|
|
751
|
+
options = {}
|
|
752
|
+
mipro_payload = root_payload.get("mipro")
|
|
753
|
+
if isinstance(mipro_payload, dict):
|
|
754
|
+
if "parallel_batches" in mipro_payload and "parallel_batches" not in options:
|
|
755
|
+
options["parallel_batches"] = mipro_payload["parallel_batches"]
|
|
756
|
+
if "parallel_batch_size" in mipro_payload and "parallel_batch_size" not in options:
|
|
757
|
+
options["parallel_batch_size"] = mipro_payload["parallel_batch_size"]
|
|
758
|
+
if parsed_config.container_url and "container_url" not in options:
|
|
759
|
+
options["container_url"] = parsed_config.container_url
|
|
760
|
+
if parsed_config.execution_mode and "execution_mode" not in options:
|
|
761
|
+
options["execution_mode"] = parsed_config.execution_mode
|
|
762
|
+
return options
|
|
763
|
+
|
|
764
|
+
def start_job(self, job_id: str) -> None:
|
|
765
|
+
record = self.get_job(job_id)
|
|
766
|
+
with record._condition:
|
|
767
|
+
if record._thread is not None and record._thread.is_alive():
|
|
768
|
+
return
|
|
769
|
+
record.state = "queued"
|
|
770
|
+
record.append_event("prompt_learning.queued", {"status": "queued"})
|
|
771
|
+
record._thread = threading.Thread(
|
|
772
|
+
target=self._run_job,
|
|
773
|
+
args=(job_id,),
|
|
774
|
+
daemon=True,
|
|
775
|
+
)
|
|
776
|
+
record._thread.start()
|
|
777
|
+
|
|
778
|
+
def _run_job(self, job_id: str) -> None:
|
|
779
|
+
record = self.get_job(job_id)
|
|
780
|
+
record.set_state("running")
|
|
781
|
+
record.append_event("prompt_learning.running", {"status": "running"})
|
|
782
|
+
try:
|
|
783
|
+
if "mipro" in record.kind:
|
|
784
|
+
result = _run_sync(self._run_mipro(record))
|
|
785
|
+
else:
|
|
786
|
+
result = _run_sync(self._run_gepa(record))
|
|
787
|
+
with record._condition:
|
|
788
|
+
if record.state not in self.TERMINAL_STATES:
|
|
789
|
+
record.state = "succeeded"
|
|
790
|
+
if isinstance(result, dict):
|
|
791
|
+
record.result_payload.update(result)
|
|
792
|
+
record._refresh_envelopes()
|
|
793
|
+
record.append_event(
|
|
794
|
+
"prompt_learning.succeeded",
|
|
795
|
+
{"best_candidate_id": record.best_candidate_id, "best_reward": record.best_reward},
|
|
796
|
+
)
|
|
797
|
+
except RuntimeError as exc:
|
|
798
|
+
if str(exc) == "job_cancelled":
|
|
799
|
+
with record._condition:
|
|
800
|
+
record.state = "cancelled"
|
|
801
|
+
record.error_message = "cancelled"
|
|
802
|
+
record._refresh_envelopes()
|
|
803
|
+
record.append_event("prompt_learning.cancelled", {"status": "cancelled"})
|
|
804
|
+
return
|
|
805
|
+
with record._condition:
|
|
806
|
+
record.state = "failed"
|
|
807
|
+
record.error_message = str(exc)
|
|
808
|
+
record._refresh_envelopes()
|
|
809
|
+
record.append_event("prompt_learning.failed", {"error": str(exc)})
|
|
810
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
811
|
+
with record._condition:
|
|
812
|
+
record.state = "failed"
|
|
813
|
+
record.error_message = str(exc)
|
|
814
|
+
record._refresh_envelopes()
|
|
815
|
+
record.append_event("prompt_learning.failed", {"error": str(exc)})
|
|
816
|
+
|
|
817
|
+
def _resolve_algorithm_config(self, record: LocalJobRecord) -> GEPAConfig | MIPROAlgorithmConfig:
|
|
818
|
+
if "mipro" in record.kind:
|
|
819
|
+
if record.config.mipro is None:
|
|
820
|
+
return MIPROAlgorithmConfig(
|
|
821
|
+
initial_candidate=PromptCandidateConfig(stages=[]),
|
|
822
|
+
execution_mode=record.config.execution_mode,
|
|
823
|
+
)
|
|
824
|
+
return record.config.mipro
|
|
825
|
+
if record.config.gepa is None:
|
|
826
|
+
return GEPAConfig(
|
|
827
|
+
initial_candidate=PromptCandidateConfig(stages=[]),
|
|
828
|
+
execution_mode=record.config.execution_mode,
|
|
829
|
+
)
|
|
830
|
+
return record.config.gepa
|
|
831
|
+
|
|
832
|
+
def _seed_examples(
|
|
833
|
+
self,
|
|
834
|
+
task_data: Any,
|
|
835
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
836
|
+
if task_data is None:
|
|
837
|
+
return ([], [])
|
|
838
|
+
|
|
839
|
+
train_examples = _as_list_of_dicts(task_data.train_examples)
|
|
840
|
+
val_examples = _as_list_of_dicts(task_data.validation_examples)
|
|
841
|
+
generic_examples = _as_list_of_dicts(task_data.examples)
|
|
842
|
+
examples_by_seed = dict(task_data.examples_by_seed)
|
|
843
|
+
train_pools = task_data.train_pools
|
|
844
|
+
train_seeds: list[int] = []
|
|
845
|
+
if train_pools is not None:
|
|
846
|
+
train_seeds.extend(int(seed) for seed in train_pools.reflection_seeds)
|
|
847
|
+
train_seeds.extend(int(seed) for seed in train_pools.pareto_seeds)
|
|
848
|
+
train_seeds.extend(int(seed) for seed in train_pools.seeds)
|
|
849
|
+
validation_seeds = [int(seed) for seed in task_data.validation_seeds]
|
|
850
|
+
|
|
851
|
+
if not train_examples and not val_examples and generic_examples:
|
|
852
|
+
split_index = max(1, len(generic_examples) - max(1, len(generic_examples) // 4))
|
|
853
|
+
train_examples = generic_examples[:split_index]
|
|
854
|
+
val_examples = generic_examples[split_index:] or generic_examples[:1]
|
|
855
|
+
|
|
856
|
+
if examples_by_seed:
|
|
857
|
+
if not train_examples:
|
|
858
|
+
train_examples = [
|
|
859
|
+
{"seed": seed, **dict(examples_by_seed.get(str(seed), {}))}
|
|
860
|
+
for seed in train_seeds
|
|
861
|
+
]
|
|
862
|
+
if not val_examples:
|
|
863
|
+
val_examples = [
|
|
864
|
+
{"seed": seed, **dict(examples_by_seed.get(str(seed), {}))}
|
|
865
|
+
for seed in validation_seeds
|
|
866
|
+
]
|
|
867
|
+
|
|
868
|
+
if not train_examples and train_seeds:
|
|
869
|
+
train_examples = [{"seed": seed} for seed in train_seeds]
|
|
870
|
+
if not val_examples and validation_seeds:
|
|
871
|
+
val_examples = [{"seed": seed} for seed in validation_seeds]
|
|
872
|
+
|
|
873
|
+
return (train_examples, val_examples or train_examples[:1])
|
|
874
|
+
|
|
875
|
+
def _baseline_candidate(self, record: LocalJobRecord) -> CandidateBundle:
|
|
876
|
+
algorithm_config = self._resolve_algorithm_config(record)
|
|
877
|
+
initial_candidate = getattr(algorithm_config, "initial_candidate", None)
|
|
878
|
+
stages = list(getattr(initial_candidate, "stages", []) or [])
|
|
879
|
+
if not stages:
|
|
880
|
+
stages = [
|
|
881
|
+
PromptStageConfig(
|
|
882
|
+
id="default",
|
|
883
|
+
name="Default",
|
|
884
|
+
messages=[MessagePatternConfig(role="system", pattern="You are a helpful assistant.", order=0)],
|
|
885
|
+
wildcards={},
|
|
886
|
+
metadata={},
|
|
887
|
+
)
|
|
888
|
+
]
|
|
889
|
+
for index, stage in enumerate(stages):
|
|
890
|
+
if not stage.id:
|
|
891
|
+
stage.id = f"stage_{index}"
|
|
892
|
+
if stage.name is None:
|
|
893
|
+
stage.name = stage.id
|
|
894
|
+
|
|
895
|
+
bundle = CandidateBundle(
|
|
896
|
+
candidate_id="baseline",
|
|
897
|
+
stages=stages,
|
|
898
|
+
parent_id=None,
|
|
899
|
+
generation=0,
|
|
900
|
+
transform_ids_by_stage={stage.id or f"stage_{index}": None for index, stage in enumerate(stages)},
|
|
901
|
+
created_at=_utc_now_iso(),
|
|
902
|
+
metadata={"kind": "baseline"},
|
|
903
|
+
status="evaluated",
|
|
904
|
+
)
|
|
905
|
+
payload = record.register_candidate(bundle, algorithm="mipro" if "mipro" in record.kind else "gepa")
|
|
906
|
+
record.baseline_info = {
|
|
907
|
+
"baseline_candidate_id": bundle.candidate_id,
|
|
908
|
+
"candidate": payload,
|
|
909
|
+
"stages": payload.get("stages", []),
|
|
910
|
+
}
|
|
911
|
+
record._refresh_envelopes()
|
|
912
|
+
return bundle
|
|
913
|
+
|
|
914
|
+
def _build_request_payload(
|
|
915
|
+
self,
|
|
916
|
+
record: LocalJobRecord,
|
|
917
|
+
example: dict[str, Any],
|
|
918
|
+
candidate_payload: dict[str, Any],
|
|
919
|
+
*,
|
|
920
|
+
split: str,
|
|
921
|
+
) -> dict[str, Any]:
|
|
922
|
+
trace_correlation_id = f"{record.job_id}:{candidate_payload['candidate_id']}:{split}:{uuid4().hex[:8]}"
|
|
923
|
+
env_seed = example.get("seed")
|
|
924
|
+
request_payload = {
|
|
925
|
+
"trace_correlation_id": trace_correlation_id,
|
|
926
|
+
"env": {
|
|
927
|
+
"env_name": record.system_name,
|
|
928
|
+
"seed": env_seed,
|
|
929
|
+
"config": {
|
|
930
|
+
"example": dict(example),
|
|
931
|
+
"split": split,
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
"policy": {
|
|
935
|
+
"policy_name": "prompt-opt-local",
|
|
936
|
+
"config": {
|
|
937
|
+
"candidate_id": candidate_payload["candidate_id"],
|
|
938
|
+
"candidate": candidate_payload,
|
|
939
|
+
"candidate_content": candidate_payload.get("candidate_content"),
|
|
940
|
+
"stages": candidate_payload.get("stages"),
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
"on_done": "reset",
|
|
944
|
+
"safety": {"max_time_s": 300},
|
|
945
|
+
}
|
|
946
|
+
custom_builder = record.local_runtime_options.get("request_builder")
|
|
947
|
+
if callable(custom_builder):
|
|
948
|
+
payload = custom_builder(
|
|
949
|
+
example=example,
|
|
950
|
+
candidate=candidate_payload,
|
|
951
|
+
trace_correlation_id=trace_correlation_id,
|
|
952
|
+
split=split,
|
|
953
|
+
)
|
|
954
|
+
if isinstance(payload, dict):
|
|
955
|
+
return payload
|
|
956
|
+
return request_payload
|
|
957
|
+
|
|
958
|
+
async def _evaluate_candidate(
|
|
959
|
+
self,
|
|
960
|
+
record: LocalJobRecord,
|
|
961
|
+
candidate_id: str,
|
|
962
|
+
examples: list[dict[str, Any]],
|
|
963
|
+
*,
|
|
964
|
+
split: str,
|
|
965
|
+
) -> float:
|
|
966
|
+
candidate_payload = self.get_candidate(record.job_id, candidate_id)
|
|
967
|
+
adapter = record.local_runtime_options.get("adapter")
|
|
968
|
+
task_model = record.local_runtime_options.get("task_model")
|
|
969
|
+
score_fn = record.local_runtime_options.get("score_fn")
|
|
970
|
+
container_url = record.local_runtime_options.get("container_url")
|
|
971
|
+
score_extractor = record.local_runtime_options.get("score_extractor")
|
|
972
|
+
headers = dict(record.local_runtime_options.get("headers") or {})
|
|
973
|
+
timeout_seconds = float(record.local_runtime_options.get("timeout_seconds", 30.0))
|
|
974
|
+
parallel_enabled = bool(record.local_runtime_options.get("parallel_batches", True))
|
|
975
|
+
max_parallel = int(
|
|
976
|
+
record.local_runtime_options.get(
|
|
977
|
+
"parallel_batch_size",
|
|
978
|
+
record.local_runtime_options.get(
|
|
979
|
+
"max_concurrent_rollouts",
|
|
980
|
+
record.local_runtime_options.get("max_concurrent", 100),
|
|
981
|
+
),
|
|
982
|
+
)
|
|
983
|
+
)
|
|
984
|
+
concurrency = max(1, min(len(examples), max_parallel if parallel_enabled else 1))
|
|
985
|
+
|
|
986
|
+
async def evaluate_example(index: int, example: dict[str, Any]) -> SeedEvalRecord:
|
|
987
|
+
await asyncio.to_thread(record.wait_if_paused)
|
|
988
|
+
await asyncio.to_thread(record.check_cancelled)
|
|
989
|
+
|
|
990
|
+
if adapter is not None:
|
|
991
|
+
eval_batch = await asyncio.to_thread(
|
|
992
|
+
adapter.evaluate,
|
|
993
|
+
[example],
|
|
994
|
+
candidate_payload,
|
|
995
|
+
False,
|
|
996
|
+
)
|
|
997
|
+
score_list = getattr(eval_batch, "scores", None) or [0.0]
|
|
998
|
+
reward = float(score_list[0])
|
|
999
|
+
metadata = {
|
|
1000
|
+
"adapter_output": getattr(eval_batch, "outputs", []),
|
|
1001
|
+
"adapter_objective_scores": getattr(eval_batch, "objective_scores", []),
|
|
1002
|
+
}
|
|
1003
|
+
elif callable(task_model):
|
|
1004
|
+
messages = candidate_payload.get("candidate", {}).get("stages") or candidate_payload.get("stages", [])
|
|
1005
|
+
rendered_messages: list[dict[str, Any]] = []
|
|
1006
|
+
for stage in messages:
|
|
1007
|
+
if not isinstance(stage, dict):
|
|
1008
|
+
continue
|
|
1009
|
+
for message in sorted(stage.get("messages", []), key=lambda item: item.get("order", 0)):
|
|
1010
|
+
content = _render_pattern(str(message.get("pattern") or message.get("content") or ""), example)
|
|
1011
|
+
rendered_messages.append(
|
|
1012
|
+
{
|
|
1013
|
+
"role": message.get("role", "user"),
|
|
1014
|
+
"content": content,
|
|
1015
|
+
"stage_id": stage.get("id"),
|
|
1016
|
+
}
|
|
1017
|
+
)
|
|
1018
|
+
prompt_text = "\n\n".join(item["content"] for item in rendered_messages if item["content"])
|
|
1019
|
+
task_output = await _maybe_await(task_model(prompt_text))
|
|
1020
|
+
if callable(score_fn):
|
|
1021
|
+
reward = float(await _maybe_await(score_fn(example, candidate_payload, task_output)))
|
|
1022
|
+
else:
|
|
1023
|
+
expected = example.get("answer", example.get("expected", example.get("label")))
|
|
1024
|
+
reward = 1.0 if _normalize_text(task_output).strip() == _normalize_text(expected).strip() else 0.0
|
|
1025
|
+
metadata = {"expected": example.get("answer", example.get("expected")), "predicted": task_output}
|
|
1026
|
+
elif container_url:
|
|
1027
|
+
payload = self._build_request_payload(record, example, candidate_payload, split=split)
|
|
1028
|
+
body = json.dumps(payload).encode("utf-8")
|
|
1029
|
+
http_headers = {"content-type": "application/json", **headers}
|
|
1030
|
+
http_request = request.Request(
|
|
1031
|
+
f"{str(container_url).rstrip('/')}/rollout",
|
|
1032
|
+
data=body,
|
|
1033
|
+
headers=http_headers,
|
|
1034
|
+
method="POST",
|
|
1035
|
+
)
|
|
1036
|
+
with await asyncio.to_thread(request.urlopen, http_request, timeout=timeout_seconds) as response:
|
|
1037
|
+
response_payload = json.loads(response.read().decode("utf-8"))
|
|
1038
|
+
if callable(score_extractor):
|
|
1039
|
+
reward = float(score_extractor(response_payload))
|
|
1040
|
+
else:
|
|
1041
|
+
metrics = response_payload.get("metrics") or response_payload.get("reward_info") or {}
|
|
1042
|
+
reward = float(metrics.get("outcome_reward", metrics.get("reward", 0.0)))
|
|
1043
|
+
metadata = {
|
|
1044
|
+
"response": response_payload,
|
|
1045
|
+
"request": payload,
|
|
1046
|
+
}
|
|
1047
|
+
else:
|
|
1048
|
+
raise RuntimeError("Local runtime requires either local_runtime.task_model or container_url")
|
|
1049
|
+
|
|
1050
|
+
reward = float(reward)
|
|
1051
|
+
return SeedEvalRecord(
|
|
1052
|
+
candidate_id=candidate_id,
|
|
1053
|
+
split=split,
|
|
1054
|
+
seed=int(example.get("seed", index)),
|
|
1055
|
+
reward=reward,
|
|
1056
|
+
rollout_id=f"{split}-{candidate_id}-{index}",
|
|
1057
|
+
success=True,
|
|
1058
|
+
metadata=metadata,
|
|
1059
|
+
created_at=_utc_now_iso(),
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
rewards: list[float] = []
|
|
1063
|
+
if concurrency == 1:
|
|
1064
|
+
for index, example in enumerate(examples):
|
|
1065
|
+
seed_eval = await evaluate_example(index, example)
|
|
1066
|
+
rewards.append(seed_eval.reward)
|
|
1067
|
+
record.record_seed_eval(seed_eval)
|
|
1068
|
+
else:
|
|
1069
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
1070
|
+
tasks = []
|
|
1071
|
+
try:
|
|
1072
|
+
for index, example in enumerate(examples):
|
|
1073
|
+
async def run_one(i: int = index, ex: dict[str, Any] = example) -> SeedEvalRecord:
|
|
1074
|
+
async with semaphore:
|
|
1075
|
+
return await evaluate_example(i, ex)
|
|
1076
|
+
tasks.append(asyncio.create_task(run_one()))
|
|
1077
|
+
for task in asyncio.as_completed(tasks):
|
|
1078
|
+
seed_eval = await task
|
|
1079
|
+
rewards.append(seed_eval.reward)
|
|
1080
|
+
record.record_seed_eval(seed_eval)
|
|
1081
|
+
except Exception:
|
|
1082
|
+
for task in tasks:
|
|
1083
|
+
task.cancel()
|
|
1084
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
1085
|
+
raise
|
|
1086
|
+
return sum(rewards) / max(1, len(rewards))
|
|
1087
|
+
|
|
1088
|
+
def get_candidate(self, job_id: str, candidate_id: str) -> dict[str, Any]:
|
|
1089
|
+
record = self.get_job(job_id)
|
|
1090
|
+
with record._condition:
|
|
1091
|
+
try:
|
|
1092
|
+
return dict(record.candidates[candidate_id])
|
|
1093
|
+
except KeyError as exc:
|
|
1094
|
+
raise ValueError(f"Unknown candidate_id={candidate_id!r} for local job {job_id!r}") from exc
|
|
1095
|
+
|
|
1096
|
+
def list_candidates(
|
|
1097
|
+
self,
|
|
1098
|
+
job_id: str,
|
|
1099
|
+
*,
|
|
1100
|
+
limit: int = 100,
|
|
1101
|
+
status: str | None = None,
|
|
1102
|
+
) -> dict[str, Any]:
|
|
1103
|
+
record = self.get_job(job_id)
|
|
1104
|
+
with record._condition:
|
|
1105
|
+
items = list(record.candidates.values())
|
|
1106
|
+
if status is not None:
|
|
1107
|
+
items = [item for item in items if item.get("status") == status]
|
|
1108
|
+
items.sort(key=lambda item: str(item.get("created_at", "")))
|
|
1109
|
+
return {"items": items[: max(1, limit)], "next_cursor": None}
|
|
1110
|
+
|
|
1111
|
+
def list_seed_evals(
|
|
1112
|
+
self,
|
|
1113
|
+
job_id: str,
|
|
1114
|
+
*,
|
|
1115
|
+
candidate_id: str | None = None,
|
|
1116
|
+
split: str | None = None,
|
|
1117
|
+
limit: int = 100,
|
|
1118
|
+
) -> dict[str, Any]:
|
|
1119
|
+
record = self.get_job(job_id)
|
|
1120
|
+
payloads = [item.to_payload(job_id) for item in record.seed_evals]
|
|
1121
|
+
if candidate_id is not None:
|
|
1122
|
+
payloads = [item for item in payloads if item.get("candidate_id") == candidate_id]
|
|
1123
|
+
if split is not None:
|
|
1124
|
+
payloads = [item for item in payloads if item.get("split") == split]
|
|
1125
|
+
return {"items": payloads[: max(1, limit)], "next_cursor": None}
|
|
1126
|
+
|
|
1127
|
+
def update_job_state(self, job_id: str, *, action: str) -> dict[str, Any]:
|
|
1128
|
+
record = self.get_job(job_id)
|
|
1129
|
+
if action == "pause":
|
|
1130
|
+
record.set_state("paused")
|
|
1131
|
+
record.append_event("prompt_learning.paused", {"status": "paused"})
|
|
1132
|
+
elif action == "resume":
|
|
1133
|
+
if record.state == "created":
|
|
1134
|
+
self.start_job(job_id)
|
|
1135
|
+
else:
|
|
1136
|
+
record.set_state("running")
|
|
1137
|
+
record.append_event("prompt_learning.resumed", {"status": "running"})
|
|
1138
|
+
elif action == "cancel":
|
|
1139
|
+
record.request_cancel()
|
|
1140
|
+
return record.status_payload()
|
|
1141
|
+
|
|
1142
|
+
async def _run_mipro(self, record: LocalJobRecord) -> dict[str, Any]:
|
|
1143
|
+
baseline = self._baseline_candidate(record)
|
|
1144
|
+
train_examples, val_examples = self._seed_examples(record.config.task_data)
|
|
1145
|
+
if not train_examples and not val_examples:
|
|
1146
|
+
raise RuntimeError("No task_data examples or seeds available for local MIPRO execution")
|
|
1147
|
+
if not val_examples:
|
|
1148
|
+
val_examples = train_examples[:1]
|
|
1149
|
+
|
|
1150
|
+
baseline_score = await self._evaluate_candidate(record, baseline.candidate_id, val_examples, split="validation")
|
|
1151
|
+
record.update_candidate_score(baseline.candidate_id, reward=baseline_score)
|
|
1152
|
+
record.append_event(
|
|
1153
|
+
"prompt_learning.baseline.evaluated",
|
|
1154
|
+
{"candidate_id": baseline.candidate_id, "reward": baseline_score},
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
cfg = record.config.mipro or MIPROAlgorithmConfig()
|
|
1158
|
+
best_candidate_id = baseline.candidate_id
|
|
1159
|
+
best_score = baseline_score
|
|
1160
|
+
patience = 0
|
|
1161
|
+
transform_bank: dict[str, list[AtomicInstructionTransform]] = {}
|
|
1162
|
+
taboo: set[tuple[tuple[str, str], ...]] = set()
|
|
1163
|
+
tpe = AsyncDiscreteTpe(seed=int(cfg.seed), n_startup_trials=max(3, min(8, cfg.num_candidates)))
|
|
1164
|
+
|
|
1165
|
+
baseline_stages = [
|
|
1166
|
+
PromptStageConfig.model_validate(stage.model_dump(mode="python"))
|
|
1167
|
+
if hasattr(stage, "model_dump")
|
|
1168
|
+
else PromptStageConfig.model_validate(stage)
|
|
1169
|
+
for stage in baseline.stages
|
|
1170
|
+
]
|
|
1171
|
+
max_iterations = max(
|
|
1172
|
+
1,
|
|
1173
|
+
int(cfg.max_iterations or 1),
|
|
1174
|
+
int((cfg.termination_conditions.total_rollouts if cfg.termination_conditions else 0) // max(1, len(val_examples))) if cfg.termination_conditions and cfg.termination_conditions.total_rollouts else 1,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
for iteration in range(max_iterations):
|
|
1178
|
+
record.wait_if_paused()
|
|
1179
|
+
record.check_cancelled()
|
|
1180
|
+
for stage in baseline_stages:
|
|
1181
|
+
stage_id = stage.id or "stage"
|
|
1182
|
+
if stage_id not in transform_bank:
|
|
1183
|
+
transform_bank[stage_id] = []
|
|
1184
|
+
next_transforms = self._propose_stage_transforms(
|
|
1185
|
+
stage,
|
|
1186
|
+
train_examples,
|
|
1187
|
+
iteration=iteration,
|
|
1188
|
+
existing_count=len(transform_bank[stage_id]),
|
|
1189
|
+
)
|
|
1190
|
+
existing_ids = {item.transform_id for item in transform_bank[stage_id]}
|
|
1191
|
+
for transform in next_transforms:
|
|
1192
|
+
if transform.transform_id not in existing_ids:
|
|
1193
|
+
transform_bank[stage_id].append(transform)
|
|
1194
|
+
existing_ids.add(transform.transform_id)
|
|
1195
|
+
|
|
1196
|
+
search_space = {
|
|
1197
|
+
stage.id or "stage": [None] + [item.transform_id for item in transform_bank[stage.id or "stage"]]
|
|
1198
|
+
for stage in baseline_stages
|
|
1199
|
+
}
|
|
1200
|
+
config_choice = await tpe.suggest(search_space, taboo_signatures=taboo)
|
|
1201
|
+
if config_choice is None:
|
|
1202
|
+
break
|
|
1203
|
+
|
|
1204
|
+
candidate_bundle = self._materialize_candidate(
|
|
1205
|
+
baseline=baseline,
|
|
1206
|
+
transform_bank=transform_bank,
|
|
1207
|
+
config_choice=config_choice,
|
|
1208
|
+
generation=iteration + 1,
|
|
1209
|
+
metadata={"algorithm": "mipro", "iteration": iteration},
|
|
1210
|
+
)
|
|
1211
|
+
record.register_candidate(candidate_bundle, algorithm="mipro")
|
|
1212
|
+
candidate_score = await self._evaluate_candidate(
|
|
1213
|
+
record,
|
|
1214
|
+
candidate_bundle.candidate_id,
|
|
1215
|
+
val_examples,
|
|
1216
|
+
split="validation",
|
|
1217
|
+
)
|
|
1218
|
+
record.update_candidate_score(candidate_bundle.candidate_id, reward=candidate_score)
|
|
1219
|
+
await tpe.tell(config_choice, candidate_score)
|
|
1220
|
+
|
|
1221
|
+
improved = candidate_score > best_score + float(cfg.min_improvement)
|
|
1222
|
+
record.append_event(
|
|
1223
|
+
"prompt_learning.candidate.evaluated",
|
|
1224
|
+
{
|
|
1225
|
+
"candidate_id": candidate_bundle.candidate_id,
|
|
1226
|
+
"reward": candidate_score,
|
|
1227
|
+
"iteration": iteration,
|
|
1228
|
+
"improved": improved,
|
|
1229
|
+
},
|
|
1230
|
+
)
|
|
1231
|
+
record.append_event(
|
|
1232
|
+
"prompt_learning.iteration.completed",
|
|
1233
|
+
{
|
|
1234
|
+
"iteration": iteration,
|
|
1235
|
+
"candidate_id": candidate_bundle.candidate_id,
|
|
1236
|
+
"reward": candidate_score,
|
|
1237
|
+
},
|
|
1238
|
+
)
|
|
1239
|
+
if improved:
|
|
1240
|
+
best_candidate_id = candidate_bundle.candidate_id
|
|
1241
|
+
best_score = candidate_score
|
|
1242
|
+
patience = 0
|
|
1243
|
+
else:
|
|
1244
|
+
patience += 1
|
|
1245
|
+
if patience >= max(1, int(cfg.early_stop_rounds)):
|
|
1246
|
+
break
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
"best_candidate_id": best_candidate_id,
|
|
1250
|
+
"best_reward": best_score,
|
|
1251
|
+
"best_candidate": self.get_candidate(record.job_id, best_candidate_id),
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async def _run_gepa(self, record: LocalJobRecord) -> dict[str, Any]:
|
|
1255
|
+
baseline = self._baseline_candidate(record)
|
|
1256
|
+
train_examples, val_examples = self._seed_examples(record.config.task_data)
|
|
1257
|
+
if not train_examples and not val_examples:
|
|
1258
|
+
raise RuntimeError("No task_data examples or seeds available for local GEPA execution")
|
|
1259
|
+
if not val_examples:
|
|
1260
|
+
val_examples = train_examples[:1]
|
|
1261
|
+
|
|
1262
|
+
baseline_score = await self._evaluate_candidate(record, baseline.candidate_id, val_examples, split="validation")
|
|
1263
|
+
record.update_candidate_score(baseline.candidate_id, reward=baseline_score)
|
|
1264
|
+
best_candidate_id = baseline.candidate_id
|
|
1265
|
+
best_score = baseline_score
|
|
1266
|
+
|
|
1267
|
+
cfg = record.config.gepa or GEPAConfig()
|
|
1268
|
+
population = cfg.population or type("Population", (), {"num_generations": 1, "children_per_generation": 4})()
|
|
1269
|
+
num_generations = max(1, int(population.num_generations))
|
|
1270
|
+
children_per_generation = max(1, int(population.children_per_generation))
|
|
1271
|
+
current_parent = baseline
|
|
1272
|
+
|
|
1273
|
+
for generation in range(1, num_generations + 1):
|
|
1274
|
+
record.wait_if_paused()
|
|
1275
|
+
record.check_cancelled()
|
|
1276
|
+
record.append_event("prompt_learning.generation.started", {"generation": generation})
|
|
1277
|
+
generation_candidates: list[tuple[str, float]] = []
|
|
1278
|
+
base_transforms: dict[str, list[AtomicInstructionTransform]] = {}
|
|
1279
|
+
generation_parent = current_parent
|
|
1280
|
+
next_parent = current_parent
|
|
1281
|
+
for stage in generation_parent.stages:
|
|
1282
|
+
stage_id = stage.id or "stage"
|
|
1283
|
+
base_transforms[stage_id] = self._propose_stage_transforms(
|
|
1284
|
+
stage,
|
|
1285
|
+
train_examples,
|
|
1286
|
+
iteration=generation,
|
|
1287
|
+
existing_count=0,
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
for child_index in range(children_per_generation):
|
|
1291
|
+
config_choice: dict[str, Any] = {}
|
|
1292
|
+
for stage in current_parent.stages:
|
|
1293
|
+
stage_id = stage.id or "stage"
|
|
1294
|
+
transforms = base_transforms[stage_id]
|
|
1295
|
+
picked = transforms[child_index % len(transforms)] if transforms else None
|
|
1296
|
+
config_choice[stage_id] = None if picked is None else picked.transform_id
|
|
1297
|
+
candidate_bundle = self._materialize_candidate(
|
|
1298
|
+
baseline=generation_parent,
|
|
1299
|
+
transform_bank=base_transforms,
|
|
1300
|
+
config_choice=config_choice,
|
|
1301
|
+
generation=generation,
|
|
1302
|
+
metadata={"algorithm": "gepa", "generation": generation},
|
|
1303
|
+
)
|
|
1304
|
+
record.register_candidate(candidate_bundle, algorithm="gepa")
|
|
1305
|
+
reward = await self._evaluate_candidate(
|
|
1306
|
+
record,
|
|
1307
|
+
candidate_bundle.candidate_id,
|
|
1308
|
+
val_examples,
|
|
1309
|
+
split="validation",
|
|
1310
|
+
)
|
|
1311
|
+
record.update_candidate_score(candidate_bundle.candidate_id, reward=reward)
|
|
1312
|
+
generation_candidates.append((candidate_bundle.candidate_id, reward))
|
|
1313
|
+
record.append_event(
|
|
1314
|
+
"prompt_learning.candidate.evaluated",
|
|
1315
|
+
{
|
|
1316
|
+
"candidate_id": candidate_bundle.candidate_id,
|
|
1317
|
+
"generation": generation,
|
|
1318
|
+
"reward": reward,
|
|
1319
|
+
},
|
|
1320
|
+
)
|
|
1321
|
+
if reward >= best_score:
|
|
1322
|
+
best_candidate_id = candidate_bundle.candidate_id
|
|
1323
|
+
best_score = reward
|
|
1324
|
+
next_parent = candidate_bundle
|
|
1325
|
+
record.append_event(
|
|
1326
|
+
"prompt_learning.frontier.updated",
|
|
1327
|
+
{
|
|
1328
|
+
"generation": generation,
|
|
1329
|
+
"best_candidate_id": best_candidate_id,
|
|
1330
|
+
"best_reward": best_score,
|
|
1331
|
+
},
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
current_parent = next_parent
|
|
1335
|
+
record.append_event(
|
|
1336
|
+
"prompt_learning.generation.completed",
|
|
1337
|
+
{
|
|
1338
|
+
"generation": generation,
|
|
1339
|
+
"best_candidate_id": best_candidate_id,
|
|
1340
|
+
"best_reward": best_score,
|
|
1341
|
+
"evaluated_candidates": generation_candidates,
|
|
1342
|
+
},
|
|
1343
|
+
)
|
|
1344
|
+
|
|
1345
|
+
return {
|
|
1346
|
+
"best_candidate_id": best_candidate_id,
|
|
1347
|
+
"best_reward": best_score,
|
|
1348
|
+
"best_candidate": self.get_candidate(record.job_id, best_candidate_id),
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
def _propose_stage_transforms(
|
|
1352
|
+
self,
|
|
1353
|
+
stage: PromptStageConfig,
|
|
1354
|
+
train_examples: list[dict[str, Any]],
|
|
1355
|
+
*,
|
|
1356
|
+
iteration: int,
|
|
1357
|
+
existing_count: int,
|
|
1358
|
+
) -> list[AtomicInstructionTransform]:
|
|
1359
|
+
base_text = _extract_stage_text(stage)
|
|
1360
|
+
labels = sorted(
|
|
1361
|
+
{
|
|
1362
|
+
_clean_key(example.get("answer") or example.get("expected") or example.get("label"))
|
|
1363
|
+
for example in train_examples
|
|
1364
|
+
if _clean_key(example.get("answer") or example.get("expected") or example.get("label"))
|
|
1365
|
+
}
|
|
1366
|
+
)
|
|
1367
|
+
label_hint = f"Return exactly one of: {', '.join(labels)}." if labels else "Return only the final answer."
|
|
1368
|
+
choices = [
|
|
1369
|
+
"Be concise and deterministic.",
|
|
1370
|
+
"Follow the requested output schema exactly.",
|
|
1371
|
+
"Think carefully, then respond with only the final answer.",
|
|
1372
|
+
label_hint,
|
|
1373
|
+
"State no preamble and no extra explanation.",
|
|
1374
|
+
"Prefer the highest-confidence answer consistent with the examples.",
|
|
1375
|
+
]
|
|
1376
|
+
transforms: list[AtomicInstructionTransform] = []
|
|
1377
|
+
stage_id = stage.id or "stage"
|
|
1378
|
+
for offset, text in enumerate(choices):
|
|
1379
|
+
transform_id = f"{stage_id}-tx-{existing_count + iteration}-{offset}"
|
|
1380
|
+
if text.strip() in base_text:
|
|
1381
|
+
continue
|
|
1382
|
+
transforms.append(
|
|
1383
|
+
AtomicInstructionTransform(
|
|
1384
|
+
transform_id=transform_id,
|
|
1385
|
+
stage_id=stage_id,
|
|
1386
|
+
description=text,
|
|
1387
|
+
appended_text=text,
|
|
1388
|
+
)
|
|
1389
|
+
)
|
|
1390
|
+
return transforms or [
|
|
1391
|
+
AtomicInstructionTransform(
|
|
1392
|
+
transform_id=f"{stage_id}-tx-{existing_count + iteration}-fallback",
|
|
1393
|
+
stage_id=stage_id,
|
|
1394
|
+
description="Restate task requirements clearly.",
|
|
1395
|
+
appended_text="Restate the task requirements clearly and respond precisely.",
|
|
1396
|
+
)
|
|
1397
|
+
]
|
|
1398
|
+
|
|
1399
|
+
def _materialize_candidate(
|
|
1400
|
+
self,
|
|
1401
|
+
*,
|
|
1402
|
+
baseline: CandidateBundle,
|
|
1403
|
+
transform_bank: dict[str, list[AtomicInstructionTransform]],
|
|
1404
|
+
config_choice: dict[str, Any],
|
|
1405
|
+
generation: int,
|
|
1406
|
+
metadata: dict[str, Any],
|
|
1407
|
+
) -> CandidateBundle:
|
|
1408
|
+
transform_lookup = {
|
|
1409
|
+
transform.transform_id: transform
|
|
1410
|
+
for transforms in transform_bank.values()
|
|
1411
|
+
for transform in transforms
|
|
1412
|
+
}
|
|
1413
|
+
stages: list[PromptStageConfig] = []
|
|
1414
|
+
transform_ids_by_stage: dict[str, str | None] = {}
|
|
1415
|
+
for stage in baseline.stages:
|
|
1416
|
+
stage_id = stage.id or "stage"
|
|
1417
|
+
selected_transform_id = config_choice.get(stage_id)
|
|
1418
|
+
transform_ids_by_stage[stage_id] = selected_transform_id
|
|
1419
|
+
if selected_transform_id is None:
|
|
1420
|
+
stages.append(stage)
|
|
1421
|
+
continue
|
|
1422
|
+
transform = transform_lookup[selected_transform_id]
|
|
1423
|
+
base_text = _extract_stage_text(stage)
|
|
1424
|
+
updated_text = _append_stage_text_once(base_text, transform.appended_text)
|
|
1425
|
+
stages.append(_replace_stage_text(stage, updated_text))
|
|
1426
|
+
return CandidateBundle(
|
|
1427
|
+
candidate_id=_new_id("cand"),
|
|
1428
|
+
stages=stages,
|
|
1429
|
+
parent_id=baseline.candidate_id,
|
|
1430
|
+
generation=generation,
|
|
1431
|
+
transform_ids_by_stage=transform_ids_by_stage,
|
|
1432
|
+
created_at=_utc_now_iso(),
|
|
1433
|
+
metadata=dict(metadata),
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
def create_prompt_learning_result(self, job_id: str) -> PromptLearningResult:
|
|
1437
|
+
record = self.get_job(job_id)
|
|
1438
|
+
return PromptLearningResult.from_response(job_id, record.result_payload)
|
|
1439
|
+
|
|
1440
|
+
def wait_for_terminal(
|
|
1441
|
+
self,
|
|
1442
|
+
job_id: str,
|
|
1443
|
+
*,
|
|
1444
|
+
timeout: float,
|
|
1445
|
+
interval: float,
|
|
1446
|
+
) -> dict[str, Any]:
|
|
1447
|
+
deadline = time.time() + timeout
|
|
1448
|
+
record = self.get_job(job_id)
|
|
1449
|
+
while time.time() < deadline:
|
|
1450
|
+
status = record.status_payload()
|
|
1451
|
+
if status["status"] in self.TERMINAL_STATES:
|
|
1452
|
+
return record.result_payload or status
|
|
1453
|
+
time.sleep(interval)
|
|
1454
|
+
raise TimeoutError(f"Local job {job_id} did not reach a terminal state within {timeout} seconds")
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
RUNTIME = LocalPromptLearningRuntime()
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def list_candidates_typed(job_id: str, *, limit: int = 100, status: str | None = None) -> PolicyCandidatePage:
|
|
1461
|
+
return PolicyCandidatePage.from_dict(RUNTIME.list_candidates(job_id, limit=limit, status=status))
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def get_candidate_typed(job_id: str, candidate_id: str) -> PolicyCandidate:
|
|
1465
|
+
return PolicyCandidate.from_dict(RUNTIME.get_candidate(job_id, candidate_id))
|