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.
@@ -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))