orizu 0.3.6 → 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.
@@ -4,9 +4,13 @@ import dataclasses
4
4
  import hashlib
5
5
  import json
6
6
  import math
7
+ import os
7
8
  import random
9
+ import subprocess
10
+ import sys
8
11
  import time
9
12
  import uuid
13
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
10
14
  from dataclasses import dataclass, field
11
15
  from typing import Any, Callable, Protocol
12
16
 
@@ -40,6 +44,31 @@ Return only the complete updated parameter text, exactly as it should be used by
40
44
  Do not include analysis, explanation, labels, XML tags, or a surrounding markdown code fence.
41
45
  Your response will be used verbatim as the next candidate."""
42
46
 
47
+ AUTO_NUM_THREADS_HARD_CAP = 64
48
+ AUTO_NUM_THREADS_WORKER_MEMORY_BYTES = 512 * 1024 * 1024
49
+ AUTO_NUM_THREADS_MEMORY_RESERVE_BYTES = 1024 * 1024 * 1024
50
+ AUTO_NUM_THREADS_FD_HEADROOM = 64
51
+ AUTO_NUM_THREADS_FDS_PER_WORKER = 16
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class NumThreadsPlan:
56
+ requested: int | str
57
+ resolved: int
58
+ row_bound: int
59
+ cpu_bound: int
60
+ memory_bound: int | None
61
+ fd_bound: int | None
62
+ hard_cap: int
63
+ worker_memory_bytes: int
64
+ available_memory_bytes: int | None
65
+ total_memory_bytes: int | None
66
+ memory_reserve_bytes: int | None
67
+ limiting_factor: str
68
+
69
+ def to_payload(self) -> dict[str, Any]:
70
+ return dataclasses.asdict(self)
71
+
43
72
 
44
73
  @dataclass(frozen=True)
45
74
  class DatasetRow:
@@ -113,29 +142,128 @@ class ReflectionResult:
113
142
  candidate_text: str
114
143
 
115
144
 
145
+ class RetryableReflectionError(RuntimeError):
146
+ """Raised when a transient reflection-provider failure remains after retries."""
147
+
148
+
149
+ def _dspy_auto_metric_budget(
150
+ *,
151
+ num_components: int,
152
+ num_candidates: int,
153
+ valset_size: int,
154
+ minibatch_size: int = 35,
155
+ full_eval_steps: int = 5,
156
+ ) -> int:
157
+ if num_candidates <= 0:
158
+ raise ValueError("num_candidates must be > 0.")
159
+ if num_components < 0 or valset_size < 0 or minibatch_size < 0:
160
+ raise ValueError("num_components, valset_size, and minibatch_size must be >= 0.")
161
+ if full_eval_steps < 1:
162
+ raise ValueError("full_eval_steps must be >= 1.")
163
+
164
+ num_trials = int(max(2 * (num_components * 2) * math.log2(num_candidates), 1.5 * num_candidates))
165
+ total = valset_size
166
+ total += num_candidates * 5
167
+ total += num_trials * minibatch_size
168
+ if num_trials == 0:
169
+ return total
170
+
171
+ periodic_fulls = (num_trials + 1) // full_eval_steps + 1
172
+ extra_final = 1 if num_trials < full_eval_steps else 0
173
+ total += (periodic_fulls + extra_final) * valset_size
174
+ return total
175
+
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
+
116
194
  @dataclass
117
195
  class Budget:
118
196
  budget_kind: str
119
197
  limit: int
198
+ approx_metric_call_limit: int | None = None
120
199
  used_metric_calls: int = 0
200
+ used_reflection_failure_metric_charges: int = 0
121
201
  used_full_evals: int = 0
122
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
123
208
 
124
209
  @classmethod
125
- def from_config(cls, config: "TextGepaConfig") -> "Budget":
210
+ def from_config(
211
+ cls,
212
+ config: "TextGepaConfig",
213
+ *,
214
+ trainset_size: int = 0,
215
+ valset_size: int = 0,
216
+ num_components: int = 1,
217
+ ) -> "Budget":
126
218
  if config.max_metric_calls is not None:
127
- 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
+ )
128
224
  if config.max_full_evals is not None:
129
- return cls("max_full_evals", config.max_full_evals)
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
+ )
130
231
  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)
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.")
246
+ preset = "medium" if config.budget == "auto" else config.budget
247
+ preset_candidates = {
248
+ "light": 6,
249
+ "medium": 12,
250
+ "heavy": 18,
251
+ "high": 18,
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
+ )
258
+ return cls(
259
+ "max_metric_calls",
260
+ metric_limit,
261
+ approx_metric_call_limit=metric_limit,
262
+ )
263
+
264
+ @property
265
+ def metric_budget_used(self) -> int:
266
+ return self.used_metric_calls + self.used_reflection_failure_metric_charges
139
267
 
140
268
  @property
141
269
  def used(self) -> int:
@@ -143,17 +271,41 @@ class Budget:
143
271
  return self.used_full_evals
144
272
  if self.budget_kind == "max_candidate_proposals":
145
273
  return self.used_candidate_proposals
146
- return self.used_metric_calls
274
+ if self.budget_kind == "max_iterations":
275
+ return self.used_iterations
276
+ return self.metric_budget_used
147
277
 
148
278
  @property
149
279
  def remaining(self) -> int:
150
280
  return max(0, self.limit - self.used)
151
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
+
152
303
  def allows_iteration(self) -> bool:
153
304
  return self.remaining > 0
154
305
 
155
- def allows_metric_call(self) -> bool:
156
- return self.budget_kind != "max_metric_calls" or self.remaining > 0
306
+ def charge_retryable_reflection_failure(self) -> None:
307
+ if self.budget_kind == "max_metric_calls":
308
+ self.used_reflection_failure_metric_charges += 1
157
309
 
158
310
  def to_payload(self) -> dict[str, Any]:
159
311
  return {
@@ -161,17 +313,45 @@ class Budget:
161
313
  "limit": self.limit,
162
314
  "used": self.used,
163
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,
164
320
  "used_metric_calls": self.used_metric_calls,
321
+ "used_reflection_failure_metric_charges": self.used_reflection_failure_metric_charges,
165
322
  "used_full_evals": self.used_full_evals,
166
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(),
167
346
  }
168
347
 
169
348
 
170
349
  @dataclass(frozen=True)
171
350
  class TextGepaConfig:
172
- budget: str = "light"
173
- max_iterations: int = 3
351
+ budget: str | None = "auto"
352
+ max_iterations: int | None = None
174
353
  minibatch_size: int = 3
354
+ num_threads: int | str = "auto"
175
355
  candidate_selection_strategy: str = "pareto"
176
356
  epsilon: float = 0.1
177
357
  max_metric_calls: int | None = None
@@ -180,6 +360,8 @@ class TextGepaConfig:
180
360
  reflection_model: str = "anthropic/claude-opus-4-7"
181
361
  reflection_temperature: float | None = None
182
362
  reflection_max_tokens: int | None = None
363
+ reflection_retry_attempts: int = 3
364
+ reflection_http_timeout_seconds: int = 180
183
365
  reflection_prompt_template: str | None = None
184
366
  reflection_provider_settings: dict[str, Any] = field(default_factory=dict)
185
367
  objective: str = "Improve this text candidate to maximize evaluator score while preserving intended behavior."
@@ -316,6 +498,230 @@ def _metric_key(scorer_context: PromptContext) -> str:
316
498
  return scorer_context.metric_key or "score"
317
499
 
318
500
 
501
+ def _positive_int_env(name: str, default: int) -> int:
502
+ raw = os.environ.get(name)
503
+ if raw is None:
504
+ return default
505
+ try:
506
+ value = int(raw)
507
+ except ValueError:
508
+ return default
509
+ return value if value > 0 else default
510
+
511
+
512
+ def _proc_meminfo_bytes(key: str) -> int | None:
513
+ try:
514
+ with open("/proc/meminfo", encoding="utf-8") as handle:
515
+ for line in handle:
516
+ if not line.startswith(f"{key}:"):
517
+ continue
518
+ parts = line.split()
519
+ if len(parts) >= 2:
520
+ return int(parts[1]) * 1024
521
+ except (OSError, ValueError):
522
+ return None
523
+ return None
524
+
525
+
526
+ def _sysconf_memory_bytes(pages_name: str) -> int | None:
527
+ try:
528
+ pages = os.sysconf(pages_name)
529
+ page_size = os.sysconf("SC_PAGE_SIZE")
530
+ except (AttributeError, OSError, ValueError):
531
+ return None
532
+ if not isinstance(pages, int) or not isinstance(page_size, int):
533
+ return None
534
+ if pages <= 0 or page_size <= 0:
535
+ return None
536
+ return pages * page_size
537
+
538
+
539
+ def _darwin_available_memory_bytes() -> int | None:
540
+ if sys.platform != "darwin":
541
+ return None
542
+ try:
543
+ output = subprocess.check_output(["vm_stat"], text=True)
544
+ except (OSError, subprocess.SubprocessError):
545
+ return None
546
+
547
+ lines = output.splitlines()
548
+ if not lines:
549
+ return None
550
+ page_size_marker = "page size of "
551
+ if page_size_marker not in lines[0]:
552
+ return None
553
+ try:
554
+ page_size = int(lines[0].split(page_size_marker, 1)[1].split()[0])
555
+ except (IndexError, ValueError):
556
+ return None
557
+
558
+ available_pages = 0
559
+ available_keys = {
560
+ "Pages free",
561
+ "Pages inactive",
562
+ "Pages speculative",
563
+ "Pages purgeable",
564
+ }
565
+ for line in lines[1:]:
566
+ key, separator, value = line.partition(":")
567
+ if separator != ":" or key not in available_keys:
568
+ continue
569
+ normalized_value = value.strip().rstrip(".").replace(",", "")
570
+ try:
571
+ available_pages += int(normalized_value)
572
+ except ValueError:
573
+ continue
574
+ return available_pages * page_size if available_pages > 0 else None
575
+
576
+
577
+ def _available_memory_bytes() -> int | None:
578
+ return (
579
+ _proc_meminfo_bytes("MemAvailable")
580
+ or _darwin_available_memory_bytes()
581
+ or _sysconf_memory_bytes("SC_AVPHYS_PAGES")
582
+ or _total_memory_bytes()
583
+ )
584
+
585
+
586
+ def _total_memory_bytes() -> int | None:
587
+ return _proc_meminfo_bytes("MemTotal") or _sysconf_memory_bytes("SC_PHYS_PAGES")
588
+
589
+
590
+ def _open_file_soft_limit() -> int | None:
591
+ try:
592
+ import resource
593
+ except ImportError:
594
+ return None
595
+ try:
596
+ soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
597
+ except (OSError, ValueError):
598
+ return None
599
+ if soft_limit <= 0 or soft_limit == resource.RLIM_INFINITY:
600
+ return None
601
+ return int(soft_limit)
602
+
603
+
604
+ def _parse_requested_num_threads(requested: int | str) -> int | str:
605
+ if isinstance(requested, int):
606
+ if requested <= 0:
607
+ raise ValueError("num_threads must be positive or 'auto'")
608
+ return requested
609
+ if isinstance(requested, str):
610
+ normalized = requested.strip().lower()
611
+ if normalized == "auto":
612
+ return "auto"
613
+ try:
614
+ parsed = int(normalized)
615
+ except ValueError as exc:
616
+ raise ValueError("num_threads must be positive or 'auto'") from exc
617
+ if parsed <= 0:
618
+ raise ValueError("num_threads must be positive or 'auto'")
619
+ return parsed
620
+ raise ValueError("num_threads must be positive or 'auto'")
621
+
622
+
623
+ def resolve_num_threads(
624
+ requested: int | str,
625
+ *,
626
+ minibatch_size: int,
627
+ validation_count: int,
628
+ cpu_count: int | None = None,
629
+ available_memory_bytes: int | None = None,
630
+ total_memory_bytes: int | None = None,
631
+ fd_limit: int | None = None,
632
+ hard_cap: int | None = None,
633
+ worker_memory_bytes: int | None = None,
634
+ ) -> NumThreadsPlan:
635
+ """Resolve an explicit or automatic row-evaluation thread count for one GEPA run."""
636
+ parsed = _parse_requested_num_threads(requested)
637
+ row_bound = max(1, max(minibatch_size, validation_count))
638
+ detected_cpu_count = cpu_count if cpu_count is not None else os.cpu_count()
639
+ normalized_cpu_count = max(1, detected_cpu_count or 1)
640
+ cpu_bound = normalized_cpu_count * 2
641
+ resolved_hard_cap = hard_cap or _positive_int_env(
642
+ "ORIZU_GEPA_AUTO_THREADS_MAX",
643
+ AUTO_NUM_THREADS_HARD_CAP,
644
+ )
645
+ resolved_worker_memory_bytes = worker_memory_bytes or (
646
+ _positive_int_env(
647
+ "ORIZU_GEPA_WORKER_MEMORY_MB",
648
+ AUTO_NUM_THREADS_WORKER_MEMORY_BYTES // (1024 * 1024),
649
+ ) * 1024 * 1024
650
+ )
651
+
652
+ detected_total_memory_bytes = (
653
+ total_memory_bytes if total_memory_bytes is not None else _total_memory_bytes()
654
+ )
655
+ detected_available_memory_bytes = (
656
+ available_memory_bytes
657
+ if available_memory_bytes is not None
658
+ else _available_memory_bytes()
659
+ )
660
+ memory_reserve_bytes = None
661
+ memory_bound = None
662
+ if detected_available_memory_bytes is not None:
663
+ total_reserve = (
664
+ detected_total_memory_bytes // 4
665
+ if detected_total_memory_bytes is not None
666
+ else 0
667
+ )
668
+ memory_reserve_bytes = max(AUTO_NUM_THREADS_MEMORY_RESERVE_BYTES, total_reserve)
669
+ usable_memory_bytes = max(0, detected_available_memory_bytes - memory_reserve_bytes)
670
+ memory_bound = max(1, usable_memory_bytes // max(1, resolved_worker_memory_bytes))
671
+
672
+ detected_fd_limit = fd_limit if fd_limit is not None else _open_file_soft_limit()
673
+ fd_bound = None
674
+ if detected_fd_limit is not None:
675
+ usable_fds = max(0, detected_fd_limit - AUTO_NUM_THREADS_FD_HEADROOM)
676
+ fd_bound = max(1, usable_fds // AUTO_NUM_THREADS_FDS_PER_WORKER)
677
+
678
+ if parsed != "auto":
679
+ return NumThreadsPlan(
680
+ requested=requested,
681
+ resolved=parsed,
682
+ row_bound=row_bound,
683
+ cpu_bound=cpu_bound,
684
+ memory_bound=memory_bound,
685
+ fd_bound=fd_bound,
686
+ hard_cap=resolved_hard_cap,
687
+ worker_memory_bytes=resolved_worker_memory_bytes,
688
+ available_memory_bytes=detected_available_memory_bytes,
689
+ total_memory_bytes=detected_total_memory_bytes,
690
+ memory_reserve_bytes=memory_reserve_bytes,
691
+ limiting_factor="explicit",
692
+ )
693
+
694
+ limits = {
695
+ "rows": row_bound,
696
+ "cpu": cpu_bound,
697
+ "hard_cap": resolved_hard_cap,
698
+ }
699
+ if memory_bound is not None:
700
+ limits["memory"] = memory_bound
701
+ if fd_bound is not None:
702
+ limits["file_descriptors"] = fd_bound
703
+ resolved = max(1, min(limits.values()))
704
+ limiting_factor = next(
705
+ name
706
+ for name in ("rows", "cpu", "memory", "file_descriptors", "hard_cap")
707
+ if limits.get(name) == resolved
708
+ )
709
+ return NumThreadsPlan(
710
+ requested=requested,
711
+ resolved=resolved,
712
+ row_bound=row_bound,
713
+ cpu_bound=cpu_bound,
714
+ memory_bound=memory_bound,
715
+ fd_bound=fd_bound,
716
+ hard_cap=resolved_hard_cap,
717
+ worker_memory_bytes=resolved_worker_memory_bytes,
718
+ available_memory_bytes=detected_available_memory_bytes,
719
+ total_memory_bytes=detected_total_memory_bytes,
720
+ memory_reserve_bytes=memory_reserve_bytes,
721
+ limiting_factor=limiting_factor,
722
+ )
723
+
724
+
319
725
  def _is_better(challenger: float, incumbent: float, higher_is_better: bool) -> bool:
320
726
  return challenger > incumbent if higher_is_better else challenger < incumbent
321
727
 
@@ -564,12 +970,13 @@ def evaluate_candidate(
564
970
  scorer_runner: ScorerRunner,
565
971
  budget: Budget,
566
972
  evaluation_cache: EvaluationCache | None = None,
973
+ num_threads: int = 1,
567
974
  ) -> list[RowEvaluation]:
568
- evaluations: list[RowEvaluation] = []
569
- for row in rows:
570
- if not budget.allows_metric_call():
571
- break
975
+ """Evaluate one candidate over rows, preserving row order while parallelizing uncached calls."""
976
+ evaluations_by_index: dict[int, RowEvaluation] = {}
977
+ pending: list[tuple[int, DatasetRow, str | None]] = []
572
978
 
979
+ for index, row in enumerate(rows):
573
980
  cache_key = None
574
981
  if evaluation_cache is not None:
575
982
  cache_key = evaluation_cache.key(
@@ -581,9 +988,12 @@ def evaluate_candidate(
581
988
  )
582
989
  cached = evaluation_cache.get(cache_key)
583
990
  if cached is not None:
584
- evaluations.append(cached)
991
+ evaluations_by_index[index] = cached
585
992
  continue
586
993
 
994
+ pending.append((index, row, cache_key))
995
+
996
+ def run_uncached_row(index: int, row: DatasetRow, cache_key: str | None) -> tuple[int, str | None, RowEvaluation]:
587
997
  started = time.time()
588
998
  candidate_result = candidate_runner(candidate_text, row, prompt_context, candidate_id)
589
999
  scorer_result = scorer_runner(row, candidate_result, scorer_context, candidate_id)
@@ -610,17 +1020,83 @@ def evaluate_candidate(
610
1020
  if candidate_result.cost_usd is not None or scorer_result.cost_usd is not None else None,
611
1021
  error=candidate_result.error or scorer_result.error,
612
1022
  )
613
- evaluations.append(evaluation)
1023
+ return index, cache_key, evaluation
1024
+
1025
+ worker_count = max(1, min(max(1, num_threads), len(pending) or 1))
1026
+ if worker_count == 1:
1027
+ completed = [run_uncached_row(index, row, cache_key) for index, row, cache_key in pending]
1028
+ else:
1029
+ completed = []
1030
+ next_pending_index = 0
1031
+ executor = ThreadPoolExecutor(max_workers=worker_count)
1032
+ futures = set()
1033
+
1034
+ def submit_next_pending() -> bool:
1035
+ nonlocal next_pending_index
1036
+ if next_pending_index >= len(pending):
1037
+ return False
1038
+ index, row, cache_key = pending[next_pending_index]
1039
+ next_pending_index += 1
1040
+ futures.add(executor.submit(run_uncached_row, index, row, cache_key))
1041
+ return True
1042
+
1043
+ try:
1044
+ for _ in range(worker_count):
1045
+ if not submit_next_pending():
1046
+ break
1047
+ while futures:
1048
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
1049
+ batch = []
1050
+ for future in done:
1051
+ futures.remove(future)
1052
+ batch.append(future.result())
1053
+ completed.extend(batch)
1054
+ for _ in batch:
1055
+ submit_next_pending()
1056
+ except BaseException:
1057
+ for future in futures:
1058
+ future.cancel()
1059
+ executor.shutdown(wait=True)
1060
+ raise
1061
+ else:
1062
+ executor.shutdown(wait=True)
1063
+
1064
+ for index, cache_key, evaluation in completed:
1065
+ evaluations_by_index[index] = evaluation
614
1066
  if evaluation_cache is not None and cache_key is not None:
615
1067
  evaluation_cache.set(cache_key, evaluation)
616
1068
  budget.used_metric_calls += 1
617
- return evaluations
1069
+
1070
+ return [
1071
+ evaluations_by_index[index]
1072
+ for index in sorted(evaluations_by_index)
1073
+ ]
1074
+
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
+ )
618
1090
 
619
1091
 
620
1092
  def _completed_all_rows(results: list[RowEvaluation], rows: list[DatasetRow]) -> bool:
621
1093
  return len(results) == len(rows)
622
1094
 
623
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
+
624
1100
  def optimize_loaded_text_candidate(
625
1101
  *,
626
1102
  run_id: str,
@@ -636,7 +1112,17 @@ def optimize_loaded_text_candidate(
636
1112
  local_logger: Any | None = None,
637
1113
  ) -> TextGepaResult:
638
1114
  seed_text = prompt_context.body or ""
639
- budget = Budget.from_config(config)
1115
+ budget = Budget.from_config(
1116
+ config,
1117
+ trainset_size=len(trainset),
1118
+ valset_size=len(valset),
1119
+ num_components=1,
1120
+ )
1121
+ num_threads_plan = resolve_num_threads(
1122
+ config.num_threads,
1123
+ minibatch_size=config.minibatch_size,
1124
+ validation_count=len(valset),
1125
+ )
640
1126
  evaluation_cache = EvaluationCache() if config.cache_evaluations else None
641
1127
  candidate_text_by_id = {"seed": seed_text}
642
1128
  val_scores_by_candidate: dict[str, dict[str, float]] = {}
@@ -645,6 +1131,7 @@ def optimize_loaded_text_candidate(
645
1131
  metric_key = _metric_key(scorer_context)
646
1132
  higher_is_better = scorer_context.higher_is_better
647
1133
  budget_exhausted = False
1134
+ failed_reflection_count = 0
648
1135
 
649
1136
  try:
650
1137
  event_sink.log_event("run_started", {
@@ -664,6 +1151,7 @@ def optimize_loaded_text_candidate(
664
1151
  "train_count": len(trainset),
665
1152
  "validation_count": len(valset),
666
1153
  "config": dataclasses.asdict(config),
1154
+ "num_threads": num_threads_plan.to_payload(),
667
1155
  "budget": budget.to_payload(),
668
1156
  })
669
1157
 
@@ -684,6 +1172,7 @@ def optimize_loaded_text_candidate(
684
1172
  scorer_runner=scorer_runner,
685
1173
  budget=budget,
686
1174
  evaluation_cache=evaluation_cache,
1175
+ num_threads=num_threads_plan.resolved,
687
1176
  )
688
1177
  if local_logger is not None:
689
1178
  local_logger.append_evaluations(
@@ -694,7 +1183,7 @@ def optimize_loaded_text_candidate(
694
1183
  results=seed_results,
695
1184
  )
696
1185
  if not _completed_all_rows(seed_results, valset):
697
- raise RuntimeError("Budget exhausted before seed validation completed")
1186
+ raise RuntimeError("Seed validation did not complete all requested rows")
698
1187
  budget.used_full_evals += 1
699
1188
  seed_score = _mean(seed_results)
700
1189
  val_scores_by_candidate["seed"] = {result.row_id: result.score for result in seed_results}
@@ -725,8 +1214,23 @@ def optimize_loaded_text_candidate(
725
1214
  candidate_id=best_candidate_id,
726
1215
  )
727
1216
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension")
1217
+ if not budget.allows_iteration():
1218
+ event_sink.log_event(
1219
+ "budget_exhausted",
1220
+ {
1221
+ "stage": "seed_val_set_completed",
1222
+ "requested_rows": len(valset),
1223
+ "completed_rows": len(seed_results),
1224
+ "budget": budget.to_payload(),
1225
+ "decision_rule": "budget is checked only after completing seed validation or a full iteration",
1226
+ },
1227
+ event_layer="extension",
1228
+ candidate_id="seed",
1229
+ )
1230
+ budget_exhausted = True
728
1231
 
729
- for iteration in range(1, config.max_iterations + 1):
1232
+ iteration = 1
1233
+ while config.max_iterations is None or iteration <= config.max_iterations:
730
1234
  if not budget.allows_iteration():
731
1235
  break
732
1236
 
@@ -787,6 +1291,7 @@ def optimize_loaded_text_candidate(
787
1291
  scorer_runner=scorer_runner,
788
1292
  budget=budget,
789
1293
  evaluation_cache=evaluation_cache,
1294
+ num_threads=num_threads_plan.resolved,
790
1295
  )
791
1296
  if local_logger is not None:
792
1297
  local_logger.append_evaluations(
@@ -845,6 +1350,7 @@ def optimize_loaded_text_candidate(
845
1350
  iteration=iteration,
846
1351
  candidate_id=parent_candidate_id,
847
1352
  )
1353
+ budget.used_iterations += 1
848
1354
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
849
1355
  event_sink.log_event(
850
1356
  "iteration_completed",
@@ -863,6 +1369,28 @@ def optimize_loaded_text_candidate(
863
1369
  iteration=iteration,
864
1370
  candidate_id=best_candidate_id,
865
1371
  )
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():
1379
+ event_sink.log_event(
1380
+ "budget_exhausted",
1381
+ {
1382
+ "stage": "iteration_completed",
1383
+ "completed_iteration": iteration,
1384
+ "budget": budget.to_payload(),
1385
+ "decision_rule": "budget is checked only between iterations",
1386
+ },
1387
+ event_layer="extension",
1388
+ iteration=iteration,
1389
+ candidate_id=best_candidate_id,
1390
+ )
1391
+ budget_exhausted = True
1392
+ break
1393
+ iteration += 1
866
1394
  continue
867
1395
 
868
1396
  event_sink.log_event(
@@ -876,7 +1404,68 @@ def optimize_loaded_text_candidate(
876
1404
  iteration=iteration,
877
1405
  candidate_id=parent_candidate_id,
878
1406
  )
879
- 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
880
1469
  child_id = _candidate_id(iteration, reflection.candidate_text)
881
1470
  child_text = reflection.candidate_text
882
1471
  candidate_text_by_id[child_id] = child_text
@@ -946,6 +1535,7 @@ def optimize_loaded_text_candidate(
946
1535
  scorer_runner=scorer_runner,
947
1536
  budget=budget,
948
1537
  evaluation_cache=evaluation_cache,
1538
+ num_threads=num_threads_plan.resolved,
949
1539
  )
950
1540
  if local_logger is not None:
951
1541
  local_logger.append_evaluations(
@@ -1028,6 +1618,7 @@ def optimize_loaded_text_candidate(
1028
1618
  scorer_runner=scorer_runner,
1029
1619
  budget=budget,
1030
1620
  evaluation_cache=evaluation_cache,
1621
+ num_threads=num_threads_plan.resolved,
1031
1622
  )
1032
1623
  if local_logger is not None:
1033
1624
  local_logger.append_evaluations(
@@ -1136,6 +1727,7 @@ def optimize_loaded_text_candidate(
1136
1727
  parent_candidate_id=parent_candidate_id,
1137
1728
  )
1138
1729
 
1730
+ budget.used_iterations += 1
1139
1731
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
1140
1732
  event_sink.log_event(
1141
1733
  "iteration_completed",
@@ -1152,6 +1744,28 @@ def optimize_loaded_text_candidate(
1152
1744
  iteration=iteration,
1153
1745
  candidate_id=best_candidate_id,
1154
1746
  )
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():
1754
+ event_sink.log_event(
1755
+ "budget_exhausted",
1756
+ {
1757
+ "stage": "iteration_completed",
1758
+ "completed_iteration": iteration,
1759
+ "budget": budget.to_payload(),
1760
+ "decision_rule": "budget is checked only between iterations",
1761
+ },
1762
+ event_layer="extension",
1763
+ iteration=iteration,
1764
+ candidate_id=best_candidate_id,
1765
+ )
1766
+ budget_exhausted = True
1767
+ break
1768
+ iteration += 1
1155
1769
 
1156
1770
  if budget_exhausted:
1157
1771
  event_sink.log_event(
@@ -1163,6 +1777,7 @@ def optimize_loaded_text_candidate(
1163
1777
  "seed_validation_score": seed_score,
1164
1778
  "higher_is_better": higher_is_better,
1165
1779
  "budget": budget.to_payload(),
1780
+ "failed_reflection_count": failed_reflection_count,
1166
1781
  },
1167
1782
  )
1168
1783
  event_sink.finish_run(
@@ -1173,6 +1788,7 @@ def optimize_loaded_text_candidate(
1173
1788
  "seed_score": seed_score,
1174
1789
  "budget": budget.to_payload(),
1175
1790
  "pause_reason": "budget_exhausted",
1791
+ "failed_reflection_count": failed_reflection_count,
1176
1792
  },
1177
1793
  )
1178
1794
  return TextGepaResult(
@@ -1211,6 +1827,7 @@ def optimize_loaded_text_candidate(
1211
1827
  "metric_key": metric_key,
1212
1828
  "higher_is_better": higher_is_better,
1213
1829
  "budget": budget.to_payload(),
1830
+ "failed_reflection_count": failed_reflection_count,
1214
1831
  },
1215
1832
  )
1216
1833
  event_sink.finish_run(
@@ -1223,6 +1840,7 @@ def optimize_loaded_text_candidate(
1223
1840
  "metric_key": metric_key,
1224
1841
  "higher_is_better": higher_is_better,
1225
1842
  "budget": budget.to_payload(),
1843
+ "failed_reflection_count": failed_reflection_count,
1226
1844
  },
1227
1845
  )
1228
1846
  return TextGepaResult(