orizu 0.3.5 → 0.3.7

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 = 8
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,6 +142,34 @@ class ReflectionResult:
113
142
  candidate_text: str
114
143
 
115
144
 
145
+ def _dspy_auto_metric_budget(
146
+ *,
147
+ num_components: int,
148
+ num_candidates: int,
149
+ valset_size: int,
150
+ minibatch_size: int = 35,
151
+ full_eval_steps: int = 5,
152
+ ) -> int:
153
+ if num_candidates <= 0:
154
+ raise ValueError("num_candidates must be > 0.")
155
+ if num_components < 0 or valset_size < 0 or minibatch_size < 0:
156
+ raise ValueError("num_components, valset_size, and minibatch_size must be >= 0.")
157
+ if full_eval_steps < 1:
158
+ raise ValueError("full_eval_steps must be >= 1.")
159
+
160
+ num_trials = int(max(2 * (num_components * 2) * math.log2(num_candidates), 1.5 * num_candidates))
161
+ total = valset_size
162
+ total += num_candidates * 5
163
+ total += num_trials * minibatch_size
164
+ if num_trials == 0:
165
+ return total
166
+
167
+ periodic_fulls = (num_trials + 1) // full_eval_steps + 1
168
+ extra_final = 1 if num_trials < full_eval_steps else 0
169
+ total += (periodic_fulls + extra_final) * valset_size
170
+ return total
171
+
172
+
116
173
  @dataclass
117
174
  class Budget:
118
175
  budget_kind: str
@@ -122,20 +179,35 @@ class Budget:
122
179
  used_candidate_proposals: int = 0
123
180
 
124
181
  @classmethod
125
- def from_config(cls, config: "TextGepaConfig") -> "Budget":
182
+ def from_config(
183
+ cls,
184
+ config: "TextGepaConfig",
185
+ *,
186
+ trainset_size: int = 0,
187
+ valset_size: int = 0,
188
+ num_components: int = 1,
189
+ ) -> "Budget":
126
190
  if config.max_metric_calls is not None:
127
191
  return cls("max_metric_calls", config.max_metric_calls)
128
192
  if config.max_full_evals is not None:
129
- return cls("max_full_evals", config.max_full_evals)
193
+ return cls("max_metric_calls", config.max_full_evals * (trainset_size + valset_size))
130
194
  if config.max_candidate_proposals is not None:
131
195
  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)
196
+ preset = "medium" if config.budget == "auto" else config.budget
197
+ preset_candidates = {
198
+ "light": 6,
199
+ "medium": 12,
200
+ "heavy": 18,
201
+ "high": 18,
202
+ }.get(preset, 6)
203
+ return cls(
204
+ "max_metric_calls",
205
+ _dspy_auto_metric_budget(
206
+ num_components=num_components,
207
+ num_candidates=preset_candidates,
208
+ valset_size=valset_size,
209
+ ),
210
+ )
139
211
 
140
212
  @property
141
213
  def used(self) -> int:
@@ -152,9 +224,6 @@ class Budget:
152
224
  def allows_iteration(self) -> bool:
153
225
  return self.remaining > 0
154
226
 
155
- def allows_metric_call(self) -> bool:
156
- return self.budget_kind != "max_metric_calls" or self.remaining > 0
157
-
158
227
  def to_payload(self) -> dict[str, Any]:
159
228
  return {
160
229
  "budget_kind": self.budget_kind,
@@ -172,6 +241,7 @@ class TextGepaConfig:
172
241
  budget: str = "light"
173
242
  max_iterations: int = 3
174
243
  minibatch_size: int = 3
244
+ num_threads: int | str = "auto"
175
245
  candidate_selection_strategy: str = "pareto"
176
246
  epsilon: float = 0.1
177
247
  max_metric_calls: int | None = None
@@ -189,6 +259,7 @@ class TextGepaConfig:
189
259
  fail_on_log_error: bool = True
190
260
  log_row_snapshots: bool = False
191
261
  cache_evaluations: bool = True
262
+ skip_perfect_parent_reflection: bool = True
192
263
 
193
264
 
194
265
  @dataclass(frozen=True)
@@ -315,10 +386,241 @@ def _metric_key(scorer_context: PromptContext) -> str:
315
386
  return scorer_context.metric_key or "score"
316
387
 
317
388
 
389
+ def _positive_int_env(name: str, default: int) -> int:
390
+ raw = os.environ.get(name)
391
+ if raw is None:
392
+ return default
393
+ try:
394
+ value = int(raw)
395
+ except ValueError:
396
+ return default
397
+ return value if value > 0 else default
398
+
399
+
400
+ def _proc_meminfo_bytes(key: str) -> int | None:
401
+ try:
402
+ with open("/proc/meminfo", encoding="utf-8") as handle:
403
+ for line in handle:
404
+ if not line.startswith(f"{key}:"):
405
+ continue
406
+ parts = line.split()
407
+ if len(parts) >= 2:
408
+ return int(parts[1]) * 1024
409
+ except (OSError, ValueError):
410
+ return None
411
+ return None
412
+
413
+
414
+ def _sysconf_memory_bytes(pages_name: str) -> int | None:
415
+ try:
416
+ pages = os.sysconf(pages_name)
417
+ page_size = os.sysconf("SC_PAGE_SIZE")
418
+ except (AttributeError, OSError, ValueError):
419
+ return None
420
+ if not isinstance(pages, int) or not isinstance(page_size, int):
421
+ return None
422
+ if pages <= 0 or page_size <= 0:
423
+ return None
424
+ return pages * page_size
425
+
426
+
427
+ def _darwin_available_memory_bytes() -> int | None:
428
+ if sys.platform != "darwin":
429
+ return None
430
+ try:
431
+ output = subprocess.check_output(["vm_stat"], text=True)
432
+ except (OSError, subprocess.SubprocessError):
433
+ return None
434
+
435
+ lines = output.splitlines()
436
+ if not lines:
437
+ return None
438
+ page_size_marker = "page size of "
439
+ if page_size_marker not in lines[0]:
440
+ return None
441
+ try:
442
+ page_size = int(lines[0].split(page_size_marker, 1)[1].split()[0])
443
+ except (IndexError, ValueError):
444
+ return None
445
+
446
+ available_pages = 0
447
+ available_keys = {
448
+ "Pages free",
449
+ "Pages inactive",
450
+ "Pages speculative",
451
+ "Pages purgeable",
452
+ }
453
+ for line in lines[1:]:
454
+ key, separator, value = line.partition(":")
455
+ if separator != ":" or key not in available_keys:
456
+ continue
457
+ normalized_value = value.strip().rstrip(".").replace(",", "")
458
+ try:
459
+ available_pages += int(normalized_value)
460
+ except ValueError:
461
+ continue
462
+ return available_pages * page_size if available_pages > 0 else None
463
+
464
+
465
+ def _available_memory_bytes() -> int | None:
466
+ return (
467
+ _proc_meminfo_bytes("MemAvailable")
468
+ or _darwin_available_memory_bytes()
469
+ or _sysconf_memory_bytes("SC_AVPHYS_PAGES")
470
+ or _total_memory_bytes()
471
+ )
472
+
473
+
474
+ def _total_memory_bytes() -> int | None:
475
+ return _proc_meminfo_bytes("MemTotal") or _sysconf_memory_bytes("SC_PHYS_PAGES")
476
+
477
+
478
+ def _open_file_soft_limit() -> int | None:
479
+ try:
480
+ import resource
481
+ except ImportError:
482
+ return None
483
+ try:
484
+ soft_limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
485
+ except (OSError, ValueError):
486
+ return None
487
+ if soft_limit <= 0 or soft_limit == resource.RLIM_INFINITY:
488
+ return None
489
+ return int(soft_limit)
490
+
491
+
492
+ def _parse_requested_num_threads(requested: int | str) -> int | str:
493
+ if isinstance(requested, int):
494
+ if requested <= 0:
495
+ raise ValueError("num_threads must be positive or 'auto'")
496
+ return requested
497
+ if isinstance(requested, str):
498
+ normalized = requested.strip().lower()
499
+ if normalized == "auto":
500
+ return "auto"
501
+ try:
502
+ parsed = int(normalized)
503
+ except ValueError as exc:
504
+ raise ValueError("num_threads must be positive or 'auto'") from exc
505
+ if parsed <= 0:
506
+ raise ValueError("num_threads must be positive or 'auto'")
507
+ return parsed
508
+ raise ValueError("num_threads must be positive or 'auto'")
509
+
510
+
511
+ def resolve_num_threads(
512
+ requested: int | str,
513
+ *,
514
+ minibatch_size: int,
515
+ validation_count: int,
516
+ cpu_count: int | None = None,
517
+ available_memory_bytes: int | None = None,
518
+ total_memory_bytes: int | None = None,
519
+ fd_limit: int | None = None,
520
+ hard_cap: int | None = None,
521
+ worker_memory_bytes: int | None = None,
522
+ ) -> NumThreadsPlan:
523
+ """Resolve an explicit or automatic row-evaluation thread count for one GEPA run."""
524
+ parsed = _parse_requested_num_threads(requested)
525
+ row_bound = max(1, max(minibatch_size, validation_count))
526
+ 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)
528
+ resolved_hard_cap = hard_cap or _positive_int_env(
529
+ "ORIZU_GEPA_AUTO_THREADS_MAX",
530
+ AUTO_NUM_THREADS_HARD_CAP,
531
+ )
532
+ resolved_worker_memory_bytes = worker_memory_bytes or (
533
+ _positive_int_env(
534
+ "ORIZU_GEPA_WORKER_MEMORY_MB",
535
+ AUTO_NUM_THREADS_WORKER_MEMORY_BYTES // (1024 * 1024),
536
+ ) * 1024 * 1024
537
+ )
538
+
539
+ detected_total_memory_bytes = (
540
+ total_memory_bytes if total_memory_bytes is not None else _total_memory_bytes()
541
+ )
542
+ detected_available_memory_bytes = (
543
+ available_memory_bytes
544
+ if available_memory_bytes is not None
545
+ else _available_memory_bytes()
546
+ )
547
+ memory_reserve_bytes = None
548
+ memory_bound = None
549
+ if detected_available_memory_bytes is not None:
550
+ total_reserve = (
551
+ detected_total_memory_bytes // 4
552
+ if detected_total_memory_bytes is not None
553
+ else 0
554
+ )
555
+ memory_reserve_bytes = max(AUTO_NUM_THREADS_MEMORY_RESERVE_BYTES, total_reserve)
556
+ usable_memory_bytes = max(0, detected_available_memory_bytes - memory_reserve_bytes)
557
+ memory_bound = max(1, usable_memory_bytes // max(1, resolved_worker_memory_bytes))
558
+
559
+ detected_fd_limit = fd_limit if fd_limit is not None else _open_file_soft_limit()
560
+ fd_bound = None
561
+ if detected_fd_limit is not None:
562
+ usable_fds = max(0, detected_fd_limit - AUTO_NUM_THREADS_FD_HEADROOM)
563
+ fd_bound = max(1, usable_fds // AUTO_NUM_THREADS_FDS_PER_WORKER)
564
+
565
+ if parsed != "auto":
566
+ return NumThreadsPlan(
567
+ requested=requested,
568
+ resolved=parsed,
569
+ row_bound=row_bound,
570
+ cpu_bound=cpu_bound,
571
+ memory_bound=memory_bound,
572
+ fd_bound=fd_bound,
573
+ hard_cap=resolved_hard_cap,
574
+ worker_memory_bytes=resolved_worker_memory_bytes,
575
+ available_memory_bytes=detected_available_memory_bytes,
576
+ total_memory_bytes=detected_total_memory_bytes,
577
+ memory_reserve_bytes=memory_reserve_bytes,
578
+ limiting_factor="explicit",
579
+ )
580
+
581
+ limits = {
582
+ "rows": row_bound,
583
+ "cpu": cpu_bound,
584
+ "hard_cap": resolved_hard_cap,
585
+ }
586
+ if memory_bound is not None:
587
+ limits["memory"] = memory_bound
588
+ if fd_bound is not None:
589
+ limits["file_descriptors"] = fd_bound
590
+ resolved = max(1, min(limits.values()))
591
+ limiting_factor = next(
592
+ name
593
+ for name in ("rows", "cpu", "memory", "file_descriptors", "hard_cap")
594
+ if limits.get(name) == resolved
595
+ )
596
+ return NumThreadsPlan(
597
+ requested=requested,
598
+ resolved=resolved,
599
+ row_bound=row_bound,
600
+ cpu_bound=cpu_bound,
601
+ memory_bound=memory_bound,
602
+ fd_bound=fd_bound,
603
+ hard_cap=resolved_hard_cap,
604
+ worker_memory_bytes=resolved_worker_memory_bytes,
605
+ available_memory_bytes=detected_available_memory_bytes,
606
+ total_memory_bytes=detected_total_memory_bytes,
607
+ memory_reserve_bytes=memory_reserve_bytes,
608
+ limiting_factor=limiting_factor,
609
+ )
610
+
611
+
318
612
  def _is_better(challenger: float, incumbent: float, higher_is_better: bool) -> bool:
319
613
  return challenger > incumbent if higher_is_better else challenger < incumbent
320
614
 
321
615
 
616
+ def _is_perfect_minibatch(results: list[RowEvaluation], higher_is_better: bool) -> bool:
617
+ if not results:
618
+ return False
619
+ if higher_is_better:
620
+ return all(result.score >= 1.0 for result in results)
621
+ return all(result.score <= 0.0 for result in results)
622
+
623
+
322
624
  class EvaluationCache:
323
625
  def __init__(self):
324
626
  self._values: dict[str, RowEvaluation] = {}
@@ -555,12 +857,13 @@ def evaluate_candidate(
555
857
  scorer_runner: ScorerRunner,
556
858
  budget: Budget,
557
859
  evaluation_cache: EvaluationCache | None = None,
860
+ num_threads: int = 1,
558
861
  ) -> list[RowEvaluation]:
559
- evaluations: list[RowEvaluation] = []
560
- for row in rows:
561
- if not budget.allows_metric_call():
562
- break
862
+ """Evaluate one candidate over rows, preserving row order while parallelizing uncached calls."""
863
+ evaluations_by_index: dict[int, RowEvaluation] = {}
864
+ pending: list[tuple[int, DatasetRow, str | None]] = []
563
865
 
866
+ for index, row in enumerate(rows):
564
867
  cache_key = None
565
868
  if evaluation_cache is not None:
566
869
  cache_key = evaluation_cache.key(
@@ -572,9 +875,12 @@ def evaluate_candidate(
572
875
  )
573
876
  cached = evaluation_cache.get(cache_key)
574
877
  if cached is not None:
575
- evaluations.append(cached)
878
+ evaluations_by_index[index] = cached
576
879
  continue
577
880
 
881
+ pending.append((index, row, cache_key))
882
+
883
+ def run_uncached_row(index: int, row: DatasetRow, cache_key: str | None) -> tuple[int, str | None, RowEvaluation]:
578
884
  started = time.time()
579
885
  candidate_result = candidate_runner(candidate_text, row, prompt_context, candidate_id)
580
886
  scorer_result = scorer_runner(row, candidate_result, scorer_context, candidate_id)
@@ -601,11 +907,57 @@ def evaluate_candidate(
601
907
  if candidate_result.cost_usd is not None or scorer_result.cost_usd is not None else None,
602
908
  error=candidate_result.error or scorer_result.error,
603
909
  )
604
- evaluations.append(evaluation)
910
+ return index, cache_key, evaluation
911
+
912
+ worker_count = max(1, min(max(1, num_threads), len(pending) or 1))
913
+ if worker_count == 1:
914
+ completed = [run_uncached_row(index, row, cache_key) for index, row, cache_key in pending]
915
+ else:
916
+ completed = []
917
+ next_pending_index = 0
918
+ executor = ThreadPoolExecutor(max_workers=worker_count)
919
+ futures = set()
920
+
921
+ def submit_next_pending() -> bool:
922
+ nonlocal next_pending_index
923
+ if next_pending_index >= len(pending):
924
+ return False
925
+ index, row, cache_key = pending[next_pending_index]
926
+ next_pending_index += 1
927
+ futures.add(executor.submit(run_uncached_row, index, row, cache_key))
928
+ return True
929
+
930
+ try:
931
+ for _ in range(worker_count):
932
+ if not submit_next_pending():
933
+ break
934
+ while futures:
935
+ done, _ = wait(futures, return_when=FIRST_COMPLETED)
936
+ batch = []
937
+ for future in done:
938
+ futures.remove(future)
939
+ batch.append(future.result())
940
+ completed.extend(batch)
941
+ for _ in batch:
942
+ submit_next_pending()
943
+ except BaseException:
944
+ for future in futures:
945
+ future.cancel()
946
+ executor.shutdown(wait=True)
947
+ raise
948
+ else:
949
+ executor.shutdown(wait=True)
950
+
951
+ for index, cache_key, evaluation in completed:
952
+ evaluations_by_index[index] = evaluation
605
953
  if evaluation_cache is not None and cache_key is not None:
606
954
  evaluation_cache.set(cache_key, evaluation)
607
955
  budget.used_metric_calls += 1
608
- return evaluations
956
+
957
+ return [
958
+ evaluations_by_index[index]
959
+ for index in sorted(evaluations_by_index)
960
+ ]
609
961
 
610
962
 
611
963
  def _completed_all_rows(results: list[RowEvaluation], rows: list[DatasetRow]) -> bool:
@@ -627,7 +979,17 @@ def optimize_loaded_text_candidate(
627
979
  local_logger: Any | None = None,
628
980
  ) -> TextGepaResult:
629
981
  seed_text = prompt_context.body or ""
630
- budget = Budget.from_config(config)
982
+ budget = Budget.from_config(
983
+ config,
984
+ trainset_size=len(trainset),
985
+ valset_size=len(valset),
986
+ num_components=1,
987
+ )
988
+ num_threads_plan = resolve_num_threads(
989
+ config.num_threads,
990
+ minibatch_size=config.minibatch_size,
991
+ validation_count=len(valset),
992
+ )
631
993
  evaluation_cache = EvaluationCache() if config.cache_evaluations else None
632
994
  candidate_text_by_id = {"seed": seed_text}
633
995
  val_scores_by_candidate: dict[str, dict[str, float]] = {}
@@ -655,6 +1017,7 @@ def optimize_loaded_text_candidate(
655
1017
  "train_count": len(trainset),
656
1018
  "validation_count": len(valset),
657
1019
  "config": dataclasses.asdict(config),
1020
+ "num_threads": num_threads_plan.to_payload(),
658
1021
  "budget": budget.to_payload(),
659
1022
  })
660
1023
 
@@ -675,6 +1038,7 @@ def optimize_loaded_text_candidate(
675
1038
  scorer_runner=scorer_runner,
676
1039
  budget=budget,
677
1040
  evaluation_cache=evaluation_cache,
1041
+ num_threads=num_threads_plan.resolved,
678
1042
  )
679
1043
  if local_logger is not None:
680
1044
  local_logger.append_evaluations(
@@ -685,7 +1049,7 @@ def optimize_loaded_text_candidate(
685
1049
  results=seed_results,
686
1050
  )
687
1051
  if not _completed_all_rows(seed_results, valset):
688
- raise RuntimeError("Budget exhausted before seed validation completed")
1052
+ raise RuntimeError("Seed validation did not complete all requested rows")
689
1053
  budget.used_full_evals += 1
690
1054
  seed_score = _mean(seed_results)
691
1055
  val_scores_by_candidate["seed"] = {result.row_id: result.score for result in seed_results}
@@ -716,6 +1080,20 @@ def optimize_loaded_text_candidate(
716
1080
  candidate_id=best_candidate_id,
717
1081
  )
718
1082
  event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension")
1083
+ if not budget.allows_iteration():
1084
+ event_sink.log_event(
1085
+ "budget_exhausted",
1086
+ {
1087
+ "stage": "seed_val_set_completed",
1088
+ "requested_rows": len(valset),
1089
+ "completed_rows": len(seed_results),
1090
+ "budget": budget.to_payload(),
1091
+ "decision_rule": "budget is checked only after completing seed validation or a full iteration",
1092
+ },
1093
+ event_layer="extension",
1094
+ candidate_id="seed",
1095
+ )
1096
+ budget_exhausted = True
719
1097
 
720
1098
  for iteration in range(1, config.max_iterations + 1):
721
1099
  if not budget.allows_iteration():
@@ -778,6 +1156,7 @@ def optimize_loaded_text_candidate(
778
1156
  scorer_runner=scorer_runner,
779
1157
  budget=budget,
780
1158
  evaluation_cache=evaluation_cache,
1159
+ num_threads=num_threads_plan.resolved,
781
1160
  )
782
1161
  if local_logger is not None:
783
1162
  local_logger.append_evaluations(
@@ -819,6 +1198,58 @@ def optimize_loaded_text_candidate(
819
1198
  candidate_id=parent_candidate_id,
820
1199
  )
821
1200
 
1201
+ parent_is_perfect = _is_perfect_minibatch(parent_results, higher_is_better)
1202
+ if config.skip_perfect_parent_reflection and parent_is_perfect:
1203
+ event_sink.log_event(
1204
+ "reflection_skipped",
1205
+ {
1206
+ "reason": "parent_minibatch_perfect",
1207
+ "parent_candidate_id": parent_candidate_id,
1208
+ "parent_score_total": parent_total,
1209
+ "parent_score_mean": _mean(parent_results),
1210
+ "row_ids": [row.id for row in minibatch],
1211
+ "higher_is_better": higher_is_better,
1212
+ "decision_rule": "skip reflection when every parent minibatch row is already perfect",
1213
+ },
1214
+ event_layer="extension",
1215
+ iteration=iteration,
1216
+ candidate_id=parent_candidate_id,
1217
+ )
1218
+ event_sink.log_event("budget_updated", budget.to_payload(), event_layer="extension", iteration=iteration)
1219
+ event_sink.log_event(
1220
+ "iteration_completed",
1221
+ {
1222
+ "parent_candidate_id": parent_candidate_id,
1223
+ "child_candidate_id": None,
1224
+ "parent_train_score_total": parent_total,
1225
+ "child_train_score_total": None,
1226
+ "child_validation_score": None,
1227
+ "best_candidate_id": best_candidate_id,
1228
+ "best_validation_score": best_score,
1229
+ "skipped_reflection": True,
1230
+ "skip_reason": "parent_minibatch_perfect",
1231
+ "budget": budget.to_payload(),
1232
+ },
1233
+ iteration=iteration,
1234
+ candidate_id=best_candidate_id,
1235
+ )
1236
+ if iteration < config.max_iterations and not budget.allows_iteration():
1237
+ event_sink.log_event(
1238
+ "budget_exhausted",
1239
+ {
1240
+ "stage": "iteration_completed",
1241
+ "completed_iteration": iteration,
1242
+ "budget": budget.to_payload(),
1243
+ "decision_rule": "budget is checked only between iterations",
1244
+ },
1245
+ event_layer="extension",
1246
+ iteration=iteration,
1247
+ candidate_id=best_candidate_id,
1248
+ )
1249
+ budget_exhausted = True
1250
+ break
1251
+ continue
1252
+
822
1253
  event_sink.log_event(
823
1254
  "reflection_started",
824
1255
  {
@@ -900,6 +1331,7 @@ def optimize_loaded_text_candidate(
900
1331
  scorer_runner=scorer_runner,
901
1332
  budget=budget,
902
1333
  evaluation_cache=evaluation_cache,
1334
+ num_threads=num_threads_plan.resolved,
903
1335
  )
904
1336
  if local_logger is not None:
905
1337
  local_logger.append_evaluations(
@@ -982,6 +1414,7 @@ def optimize_loaded_text_candidate(
982
1414
  scorer_runner=scorer_runner,
983
1415
  budget=budget,
984
1416
  evaluation_cache=evaluation_cache,
1417
+ num_threads=num_threads_plan.resolved,
985
1418
  )
986
1419
  if local_logger is not None:
987
1420
  local_logger.append_evaluations(
@@ -1106,6 +1539,21 @@ def optimize_loaded_text_candidate(
1106
1539
  iteration=iteration,
1107
1540
  candidate_id=best_candidate_id,
1108
1541
  )
1542
+ if iteration < config.max_iterations and not budget.allows_iteration():
1543
+ event_sink.log_event(
1544
+ "budget_exhausted",
1545
+ {
1546
+ "stage": "iteration_completed",
1547
+ "completed_iteration": iteration,
1548
+ "budget": budget.to_payload(),
1549
+ "decision_rule": "budget is checked only between iterations",
1550
+ },
1551
+ event_layer="extension",
1552
+ iteration=iteration,
1553
+ candidate_id=best_candidate_id,
1554
+ )
1555
+ budget_exhausted = True
1556
+ break
1109
1557
 
1110
1558
  if budget_exhausted:
1111
1559
  event_sink.log_event(