orizu 0.3.7 → 0.4.0

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.
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import sys
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
@@ -11,6 +12,8 @@ from .optimizer import TextGepaConfig, optimize_loaded_text_candidate
11
12
  from .reflection import reflect_with_provider
12
13
  from .runner import make_candidate_runner, make_scorer_runner
13
14
 
15
+ BUDGET_OPTION_FLAGS = ("--budget", "--max-metric-calls", "--max-full-evals", "--max-iterations")
16
+
14
17
 
15
18
  def positive_int(value: str) -> int:
16
19
  parsed = int(value)
@@ -46,6 +49,38 @@ def read_json_object_arg(value: str | None, flag_name: str) -> dict[str, Any] |
46
49
  return parsed
47
50
 
48
51
 
52
+ def _flag_occurrences(raw_args: list[str], flag: str) -> int:
53
+ return sum(1 for arg in raw_args if arg == flag or arg.startswith(f"{flag}="))
54
+
55
+
56
+ def _format_budget_option(flag: str, count: int) -> str:
57
+ return flag if count == 1 else f"{flag} ({count} times)"
58
+
59
+
60
+ def apply_budget_defaults(args: argparse.Namespace, raw_args: list[str]) -> None:
61
+ occurrences = {
62
+ flag: _flag_occurrences(raw_args, flag)
63
+ for flag in BUDGET_OPTION_FLAGS
64
+ }
65
+ provided = [
66
+ _format_budget_option(flag, count)
67
+ for flag, count in occurrences.items()
68
+ if count > 0
69
+ ]
70
+ total = sum(occurrences.values())
71
+
72
+ if total > 1:
73
+ allowed = ", ".join(BUDGET_OPTION_FLAGS)
74
+ received = ", ".join(provided)
75
+ raise ValueError(
76
+ f"Budget options are mutually exclusive; choose at most one of {allowed}. "
77
+ f"Received: {received}."
78
+ )
79
+
80
+ if total == 0:
81
+ args.budget = TextGepaConfig.budget
82
+
83
+
49
84
  def main() -> None:
50
85
  parser = argparse.ArgumentParser(prog="orizu-gepa")
51
86
  parser.add_argument("--project", required=True)
@@ -60,8 +95,8 @@ def main() -> None:
60
95
  parser.add_argument("--split-set-id", required=True)
61
96
  parser.add_argument("--train-split", default="train")
62
97
  parser.add_argument("--val-split", default="validation")
63
- parser.add_argument("--budget", default="light", choices=["auto", "light", "medium", "heavy"])
64
- parser.add_argument("--max-iterations", type=positive_int, default=3)
98
+ parser.add_argument("--budget", choices=["auto", "light", "medium", "heavy"])
99
+ parser.add_argument("--max-iterations", type=positive_int)
65
100
  parser.add_argument("--minibatch-size", type=positive_int, default=3)
66
101
  parser.add_argument("--num-threads", type=num_threads_arg, default=TextGepaConfig.num_threads)
67
102
  parser.add_argument(
@@ -76,6 +111,18 @@ def main() -> None:
76
111
  parser.add_argument("--reflection-model", default=TextGepaConfig.reflection_model)
77
112
  parser.add_argument("--reflection-temperature", type=float)
78
113
  parser.add_argument("--reflection-max-tokens", type=positive_int)
114
+ parser.add_argument(
115
+ "--reflection-retry-attempts",
116
+ type=positive_int,
117
+ default=TextGepaConfig.reflection_retry_attempts,
118
+ help="Total attempts for retryable reflection HTTP failures.",
119
+ )
120
+ parser.add_argument(
121
+ "--reflection-http-timeout-seconds",
122
+ type=positive_int,
123
+ default=TextGepaConfig.reflection_http_timeout_seconds,
124
+ help="Per-attempt timeout for reflection HTTP calls.",
125
+ )
79
126
  parser.add_argument("--reflection-prompt-template")
80
127
  parser.add_argument("--reflection-provider-settings")
81
128
  parser.add_argument(
@@ -99,7 +146,12 @@ def main() -> None:
99
146
  parser.add_argument("--log-dir", default="logs")
100
147
  parser.add_argument("--no-local-log", action="store_true")
101
148
  parser.add_argument("--metadata", default="{}")
102
- args = parser.parse_args()
149
+ raw_args = sys.argv[1:]
150
+ args = parser.parse_args(raw_args)
151
+ try:
152
+ apply_budget_defaults(args, raw_args)
153
+ except ValueError as error:
154
+ parser.error(str(error))
103
155
 
104
156
  metadata = json.loads(args.metadata)
105
157
  if not isinstance(metadata, dict):
@@ -117,6 +169,8 @@ def main() -> None:
117
169
  reflection_model=args.reflection_model,
118
170
  reflection_temperature=args.reflection_temperature,
119
171
  reflection_max_tokens=args.reflection_max_tokens,
172
+ reflection_retry_attempts=args.reflection_retry_attempts,
173
+ reflection_http_timeout_seconds=args.reflection_http_timeout_seconds,
120
174
  reflection_prompt_template=read_text_arg(args.reflection_prompt_template),
121
175
  reflection_provider_settings=read_json_object_arg(args.reflection_provider_settings, "--reflection-provider-settings") or {},
122
176
  skip_perfect_parent_reflection=args.skip_perfect_parent_reflection,
@@ -12,6 +12,39 @@ from typing import Any
12
12
  from .optimizer import DatasetRow, PromptContext
13
13
 
14
14
 
15
+ def _format_number(value: Any) -> str | None:
16
+ if isinstance(value, bool):
17
+ return None
18
+ if isinstance(value, int):
19
+ return str(value)
20
+ if isinstance(value, float):
21
+ return f"{value:g}"
22
+ return None
23
+
24
+
25
+ def _progress_log_suffix(event_type: str, payload: dict[str, Any] | None) -> str:
26
+ if event_type != "optimization_progress" or not isinstance(payload, dict):
27
+ return ""
28
+
29
+ percent_value = payload.get("percent")
30
+ if percent_value is None:
31
+ percent_value = payload.get("progress_percent")
32
+ percent = _format_number(percent_value)
33
+ metric_calls_remaining = _format_number(payload.get("metric_calls_remaining"))
34
+ metric_call_budget_value = payload.get("metric_call_budget")
35
+ if metric_call_budget_value is None:
36
+ metric_call_budget_value = payload.get("approx_metric_call_budget")
37
+ metric_call_budget = _format_number(metric_call_budget_value)
38
+ parts: list[str] = []
39
+ if percent is not None:
40
+ parts.append(f"{percent}%")
41
+ if metric_calls_remaining is not None:
42
+ budget_suffix = f" / {metric_call_budget}" if metric_call_budget is not None else ""
43
+ parts.append(f"{metric_calls_remaining}{budget_suffix} metric calls left")
44
+
45
+ return f" {'; '.join(parts)}" if parts else ""
46
+
47
+
15
48
  @dataclass(frozen=True)
16
49
  class OrizuClient:
17
50
  api_url: str
@@ -306,7 +339,10 @@ class OrizuEventSink:
306
339
  parent_candidate_id=parent_candidate_id,
307
340
  child_candidate_id=child_candidate_id,
308
341
  )
309
- print(f"[orizu-gepa] {self.sequence:03d} {event_type}", flush=True)
342
+ print(
343
+ f"[orizu-gepa] {self.sequence:03d} {event_type}{_progress_log_suffix(event_type, payload)}",
344
+ flush=True,
345
+ )
310
346
  return
311
347
  except Exception as exc:
312
348
  last_error = exc
@@ -44,7 +44,7 @@ Return only the complete updated parameter text, exactly as it should be used by
44
44
  Do not include analysis, explanation, labels, XML tags, or a surrounding markdown code fence.
45
45
  Your response will be used verbatim as the next candidate."""
46
46
 
47
- AUTO_NUM_THREADS_HARD_CAP = 8
47
+ AUTO_NUM_THREADS_HARD_CAP = 64
48
48
  AUTO_NUM_THREADS_WORKER_MEMORY_BYTES = 512 * 1024 * 1024
49
49
  AUTO_NUM_THREADS_MEMORY_RESERVE_BYTES = 1024 * 1024 * 1024
50
50
  AUTO_NUM_THREADS_FD_HEADROOM = 64
@@ -142,6 +142,10 @@ class ReflectionResult:
142
142
  candidate_text: str
143
143
 
144
144
 
145
+ class RetryableReflectionError(RuntimeError):
146
+ """Raised when a transient reflection-provider failure remains after retries."""
147
+
148
+
145
149
  def _dspy_auto_metric_budget(
146
150
  *,
147
151
  num_components: int,
@@ -170,13 +174,37 @@ def _dspy_auto_metric_budget(
170
174
  return total
171
175
 
172
176
 
177
+ def _approx_metric_calls_for_candidate_budget(
178
+ *,
179
+ candidate_proposals: int,
180
+ trainset_size: int,
181
+ valset_size: int,
182
+ minibatch_size: int,
183
+ ) -> int:
184
+ """Estimate row-level metric calls for proposal-count budgets."""
185
+ safe_proposals = max(0, candidate_proposals)
186
+ safe_valset_size = max(0, valset_size)
187
+ safe_trainset_size = max(0, trainset_size)
188
+ safe_minibatch_size = max(0, minibatch_size)
189
+ sampled_train_rows = min(safe_trainset_size, safe_minibatch_size)
190
+ per_iteration = (2 * sampled_train_rows) + safe_valset_size
191
+ return safe_valset_size + (safe_proposals * per_iteration)
192
+
193
+
173
194
  @dataclass
174
195
  class Budget:
175
196
  budget_kind: str
176
197
  limit: int
198
+ approx_metric_call_limit: int | None = None
177
199
  used_metric_calls: int = 0
200
+ used_reflection_failure_metric_charges: int = 0
178
201
  used_full_evals: int = 0
179
202
  used_candidate_proposals: int = 0
203
+ used_iterations: int = 0
204
+
205
+ def __post_init__(self) -> None:
206
+ if self.approx_metric_call_limit is None and self.budget_kind == "max_metric_calls":
207
+ self.approx_metric_call_limit = self.limit
180
208
 
181
209
  @classmethod
182
210
  def from_config(
@@ -188,11 +216,33 @@ class Budget:
188
216
  num_components: int = 1,
189
217
  ) -> "Budget":
190
218
  if config.max_metric_calls is not None:
191
- return cls("max_metric_calls", config.max_metric_calls)
219
+ return cls(
220
+ "max_metric_calls",
221
+ config.max_metric_calls,
222
+ approx_metric_call_limit=config.max_metric_calls,
223
+ )
192
224
  if config.max_full_evals is not None:
193
- return cls("max_metric_calls", config.max_full_evals * (trainset_size + valset_size))
225
+ metric_limit = config.max_full_evals * (trainset_size + valset_size)
226
+ return cls(
227
+ "max_metric_calls",
228
+ metric_limit,
229
+ approx_metric_call_limit=metric_limit,
230
+ )
194
231
  if config.max_candidate_proposals is not None:
195
- return cls("max_candidate_proposals", config.max_candidate_proposals)
232
+ return cls(
233
+ "max_candidate_proposals",
234
+ config.max_candidate_proposals,
235
+ approx_metric_call_limit=_approx_metric_calls_for_candidate_budget(
236
+ candidate_proposals=config.max_candidate_proposals,
237
+ trainset_size=trainset_size,
238
+ valset_size=valset_size,
239
+ minibatch_size=config.minibatch_size,
240
+ ),
241
+ )
242
+ if config.max_iterations is not None:
243
+ return cls("max_iterations", config.max_iterations)
244
+ if not config.budget:
245
+ raise ValueError("No optimization budget configured.")
196
246
  preset = "medium" if config.budget == "auto" else config.budget
197
247
  preset_candidates = {
198
248
  "light": 6,
@@ -200,46 +250,106 @@ class Budget:
200
250
  "heavy": 18,
201
251
  "high": 18,
202
252
  }.get(preset, 6)
253
+ metric_limit = _dspy_auto_metric_budget(
254
+ num_components=num_components,
255
+ num_candidates=preset_candidates,
256
+ valset_size=valset_size,
257
+ )
203
258
  return cls(
204
259
  "max_metric_calls",
205
- _dspy_auto_metric_budget(
206
- num_components=num_components,
207
- num_candidates=preset_candidates,
208
- valset_size=valset_size,
209
- ),
260
+ metric_limit,
261
+ approx_metric_call_limit=metric_limit,
210
262
  )
211
263
 
264
+ @property
265
+ def metric_budget_used(self) -> int:
266
+ return self.used_metric_calls + self.used_reflection_failure_metric_charges
267
+
212
268
  @property
213
269
  def used(self) -> int:
214
270
  if self.budget_kind == "max_full_evals":
215
271
  return self.used_full_evals
216
272
  if self.budget_kind == "max_candidate_proposals":
217
273
  return self.used_candidate_proposals
218
- return self.used_metric_calls
274
+ if self.budget_kind == "max_iterations":
275
+ return self.used_iterations
276
+ return self.metric_budget_used
219
277
 
220
278
  @property
221
279
  def remaining(self) -> int:
222
280
  return max(0, self.limit - self.used)
223
281
 
282
+ @property
283
+ def metric_calls_remaining(self) -> int:
284
+ if self.approx_metric_call_limit is None:
285
+ return 0
286
+ if self.remaining == 0:
287
+ return 0
288
+ return max(0, self.approx_metric_call_limit - self.metric_budget_used)
289
+
290
+ @property
291
+ def progress_percent(self) -> float:
292
+ if self.remaining == 0:
293
+ return 100.0
294
+ if self.approx_metric_call_limit is None:
295
+ if self.limit <= 0:
296
+ return 100.0 if self.used > 0 else 0.0
297
+ return min(100.0, max(0.0, (self.used / self.limit) * 100.0))
298
+ if self.approx_metric_call_limit <= 0:
299
+ # A zero-sized budget with any recorded metric work should still read as complete.
300
+ return 100.0 if self.metric_budget_used > 0 else 0.0
301
+ return min(100.0, max(0.0, (self.metric_budget_used / self.approx_metric_call_limit) * 100.0))
302
+
224
303
  def allows_iteration(self) -> bool:
225
304
  return self.remaining > 0
226
305
 
306
+ def charge_retryable_reflection_failure(self) -> None:
307
+ if self.budget_kind == "max_metric_calls":
308
+ self.used_reflection_failure_metric_charges += 1
309
+
227
310
  def to_payload(self) -> dict[str, Any]:
228
311
  return {
229
312
  "budget_kind": self.budget_kind,
230
313
  "limit": self.limit,
231
314
  "used": self.used,
232
315
  "remaining": self.remaining,
316
+ "approx_metric_call_limit": self.approx_metric_call_limit,
317
+ "metric_call_budget": self.approx_metric_call_limit,
318
+ "metric_calls_remaining": self.metric_calls_remaining,
319
+ "metric_budget_used": self.metric_budget_used,
233
320
  "used_metric_calls": self.used_metric_calls,
321
+ "used_reflection_failure_metric_charges": self.used_reflection_failure_metric_charges,
234
322
  "used_full_evals": self.used_full_evals,
235
323
  "used_candidate_proposals": self.used_candidate_proposals,
324
+ "used_iterations": self.used_iterations,
325
+ "iteration_budget": self.limit if self.budget_kind == "max_iterations" else None,
326
+ "iterations_remaining": self.remaining if self.budget_kind == "max_iterations" else None,
327
+ }
328
+
329
+ def progress_payload(self, *, stage: str, iteration: int | None) -> dict[str, Any]:
330
+ percent = round(self.progress_percent, 1)
331
+ return {
332
+ "stage": stage,
333
+ "iteration": iteration,
334
+ "percent": percent,
335
+ "progress_percent": percent,
336
+ "metric_calls_used": self.used_metric_calls,
337
+ "metric_budget_used": self.metric_budget_used,
338
+ "metric_call_budget": self.approx_metric_call_limit,
339
+ "approx_metric_call_budget": self.approx_metric_call_limit,
340
+ "metric_calls_remaining": self.metric_calls_remaining,
341
+ "is_over_budget": (
342
+ self.approx_metric_call_limit is not None and
343
+ self.metric_budget_used > self.approx_metric_call_limit
344
+ ),
345
+ "budget": self.to_payload(),
236
346
  }
237
347
 
238
348
 
239
349
  @dataclass(frozen=True)
240
350
  class TextGepaConfig:
241
- budget: str = "light"
242
- max_iterations: int = 3
351
+ budget: str | None = "auto"
352
+ max_iterations: int | None = None
243
353
  minibatch_size: int = 3
244
354
  num_threads: int | str = "auto"
245
355
  candidate_selection_strategy: str = "pareto"
@@ -250,6 +360,8 @@ class TextGepaConfig:
250
360
  reflection_model: str = "anthropic/claude-opus-4-7"
251
361
  reflection_temperature: float | None = None
252
362
  reflection_max_tokens: int | None = None
363
+ reflection_retry_attempts: int = 3
364
+ reflection_http_timeout_seconds: int = 180
253
365
  reflection_prompt_template: str | None = None
254
366
  reflection_provider_settings: dict[str, Any] = field(default_factory=dict)
255
367
  objective: str = "Improve this text candidate to maximize evaluator score while preserving intended behavior."
@@ -524,7 +636,8 @@ def resolve_num_threads(
524
636
  parsed = _parse_requested_num_threads(requested)
525
637
  row_bound = max(1, max(minibatch_size, validation_count))
526
638
  detected_cpu_count = cpu_count if cpu_count is not None else os.cpu_count()
527
- cpu_bound = max(1, detected_cpu_count or 1)
639
+ normalized_cpu_count = max(1, detected_cpu_count or 1)
640
+ cpu_bound = normalized_cpu_count * 2
528
641
  resolved_hard_cap = hard_cap or _positive_int_env(
529
642
  "ORIZU_GEPA_AUTO_THREADS_MAX",
530
643
  AUTO_NUM_THREADS_HARD_CAP,
@@ -960,10 +1073,30 @@ def evaluate_candidate(
960
1073
  ]
961
1074
 
962
1075
 
1076
+ def _log_iteration_progress(
1077
+ event_sink: EventSink,
1078
+ *,
1079
+ budget: Budget,
1080
+ iteration: int,
1081
+ candidate_id: str | None,
1082
+ ) -> None:
1083
+ event_sink.log_event(
1084
+ "optimization_progress",
1085
+ budget.progress_payload(stage="iteration_completed", iteration=iteration),
1086
+ event_layer="extension",
1087
+ iteration=iteration,
1088
+ candidate_id=candidate_id,
1089
+ )
1090
+
1091
+
963
1092
  def _completed_all_rows(results: list[RowEvaluation], rows: list[DatasetRow]) -> bool:
964
1093
  return len(results) == len(rows)
965
1094
 
966
1095
 
1096
+ def _has_future_iteration(config: TextGepaConfig, iteration: int) -> bool:
1097
+ return config.max_iterations is None or iteration < config.max_iterations
1098
+
1099
+
967
1100
  def optimize_loaded_text_candidate(
968
1101
  *,
969
1102
  run_id: str,
@@ -998,6 +1131,7 @@ def optimize_loaded_text_candidate(
998
1131
  metric_key = _metric_key(scorer_context)
999
1132
  higher_is_better = scorer_context.higher_is_better
1000
1133
  budget_exhausted = False
1134
+ failed_reflection_count = 0
1001
1135
 
1002
1136
  try:
1003
1137
  event_sink.log_event("run_started", {
@@ -1095,7 +1229,8 @@ def optimize_loaded_text_candidate(
1095
1229
  )
1096
1230
  budget_exhausted = True
1097
1231
 
1098
- for iteration in range(1, config.max_iterations + 1):
1232
+ iteration = 1
1233
+ while config.max_iterations is None or iteration <= config.max_iterations:
1099
1234
  if not budget.allows_iteration():
1100
1235
  break
1101
1236
 
@@ -1215,6 +1350,7 @@ def optimize_loaded_text_candidate(
1215
1350
  iteration=iteration,
1216
1351
  candidate_id=parent_candidate_id,
1217
1352
  )
1353
+ budget.used_iterations += 1
1218
1354
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
1219
1355
  event_sink.log_event(
1220
1356
  "iteration_completed",
@@ -1233,7 +1369,13 @@ def optimize_loaded_text_candidate(
1233
1369
  iteration=iteration,
1234
1370
  candidate_id=best_candidate_id,
1235
1371
  )
1236
- if iteration < config.max_iterations and not budget.allows_iteration():
1372
+ _log_iteration_progress(
1373
+ event_sink,
1374
+ budget=budget,
1375
+ iteration=iteration,
1376
+ candidate_id=best_candidate_id,
1377
+ )
1378
+ if _has_future_iteration(config, iteration) and not budget.allows_iteration():
1237
1379
  event_sink.log_event(
1238
1380
  "budget_exhausted",
1239
1381
  {
@@ -1248,6 +1390,7 @@ def optimize_loaded_text_candidate(
1248
1390
  )
1249
1391
  budget_exhausted = True
1250
1392
  break
1393
+ iteration += 1
1251
1394
  continue
1252
1395
 
1253
1396
  event_sink.log_event(
@@ -1261,7 +1404,68 @@ def optimize_loaded_text_candidate(
1261
1404
  iteration=iteration,
1262
1405
  candidate_id=parent_candidate_id,
1263
1406
  )
1264
- reflection = reflector(parent_text, parent_results, config)
1407
+ try:
1408
+ reflection = reflector(parent_text, parent_results, config)
1409
+ except RetryableReflectionError as error:
1410
+ failed_reflection_count += 1
1411
+ budget.used_candidate_proposals += 1
1412
+ budget.used_iterations += 1
1413
+ budget.charge_retryable_reflection_failure()
1414
+ event_sink.log_event(
1415
+ "reflection_failed",
1416
+ {
1417
+ "parent_candidate_id": parent_candidate_id,
1418
+ "row_ids": [row.id for row in minibatch],
1419
+ "error": str(error),
1420
+ "error_type": type(error).__name__,
1421
+ "budget": budget.to_payload(),
1422
+ },
1423
+ event_layer="extension",
1424
+ iteration=iteration,
1425
+ candidate_id=parent_candidate_id,
1426
+ )
1427
+ event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
1428
+ event_sink.log_event(
1429
+ "iteration_completed",
1430
+ {
1431
+ "parent_candidate_id": parent_candidate_id,
1432
+ "child_candidate_id": None,
1433
+ "parent_train_score_total": parent_total,
1434
+ "child_train_score_total": None,
1435
+ "child_validation_score": None,
1436
+ "best_candidate_id": best_candidate_id,
1437
+ "best_validation_score": best_score,
1438
+ "reflection_failed": True,
1439
+ "reflection_error": str(error),
1440
+ "reflection_error_type": type(error).__name__,
1441
+ "budget": budget.to_payload(),
1442
+ },
1443
+ iteration=iteration,
1444
+ candidate_id=best_candidate_id,
1445
+ )
1446
+ _log_iteration_progress(
1447
+ event_sink,
1448
+ budget=budget,
1449
+ iteration=iteration,
1450
+ candidate_id=best_candidate_id,
1451
+ )
1452
+ if _has_future_iteration(config, iteration) and not budget.allows_iteration():
1453
+ event_sink.log_event(
1454
+ "budget_exhausted",
1455
+ {
1456
+ "stage": "iteration_completed",
1457
+ "completed_iteration": iteration,
1458
+ "budget": budget.to_payload(),
1459
+ "decision_rule": "budget is checked only between iterations",
1460
+ },
1461
+ event_layer="extension",
1462
+ iteration=iteration,
1463
+ candidate_id=best_candidate_id,
1464
+ )
1465
+ budget_exhausted = True
1466
+ break
1467
+ iteration += 1
1468
+ continue
1265
1469
  child_id = _candidate_id(iteration, reflection.candidate_text)
1266
1470
  child_text = reflection.candidate_text
1267
1471
  candidate_text_by_id[child_id] = child_text
@@ -1523,6 +1727,7 @@ def optimize_loaded_text_candidate(
1523
1727
  parent_candidate_id=parent_candidate_id,
1524
1728
  )
1525
1729
 
1730
+ budget.used_iterations += 1
1526
1731
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
1527
1732
  event_sink.log_event(
1528
1733
  "iteration_completed",
@@ -1539,7 +1744,13 @@ def optimize_loaded_text_candidate(
1539
1744
  iteration=iteration,
1540
1745
  candidate_id=best_candidate_id,
1541
1746
  )
1542
- if iteration < config.max_iterations and not budget.allows_iteration():
1747
+ _log_iteration_progress(
1748
+ event_sink,
1749
+ budget=budget,
1750
+ iteration=iteration,
1751
+ candidate_id=best_candidate_id,
1752
+ )
1753
+ if _has_future_iteration(config, iteration) and not budget.allows_iteration():
1543
1754
  event_sink.log_event(
1544
1755
  "budget_exhausted",
1545
1756
  {
@@ -1554,6 +1765,7 @@ def optimize_loaded_text_candidate(
1554
1765
  )
1555
1766
  budget_exhausted = True
1556
1767
  break
1768
+ iteration += 1
1557
1769
 
1558
1770
  if budget_exhausted:
1559
1771
  event_sink.log_event(
@@ -1565,6 +1777,7 @@ def optimize_loaded_text_candidate(
1565
1777
  "seed_validation_score": seed_score,
1566
1778
  "higher_is_better": higher_is_better,
1567
1779
  "budget": budget.to_payload(),
1780
+ "failed_reflection_count": failed_reflection_count,
1568
1781
  },
1569
1782
  )
1570
1783
  event_sink.finish_run(
@@ -1575,6 +1788,7 @@ def optimize_loaded_text_candidate(
1575
1788
  "seed_score": seed_score,
1576
1789
  "budget": budget.to_payload(),
1577
1790
  "pause_reason": "budget_exhausted",
1791
+ "failed_reflection_count": failed_reflection_count,
1578
1792
  },
1579
1793
  )
1580
1794
  return TextGepaResult(
@@ -1613,6 +1827,7 @@ def optimize_loaded_text_candidate(
1613
1827
  "metric_key": metric_key,
1614
1828
  "higher_is_better": higher_is_better,
1615
1829
  "budget": budget.to_payload(),
1830
+ "failed_reflection_count": failed_reflection_count,
1616
1831
  },
1617
1832
  )
1618
1833
  event_sink.finish_run(
@@ -1625,6 +1840,7 @@ def optimize_loaded_text_candidate(
1625
1840
  "metric_key": metric_key,
1626
1841
  "higher_is_better": higher_is_better,
1627
1842
  "budget": budget.to_payload(),
1843
+ "failed_reflection_count": failed_reflection_count,
1628
1844
  },
1629
1845
  )
1630
1846
  return TextGepaResult(