orizu 0.3.1 → 0.3.3

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