orizu 0.3.6 → 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.
package/dist/help.js CHANGED
@@ -259,7 +259,7 @@ export const COMMAND_DOCS = [
259
259
  },
260
260
  {
261
261
  path: ['optimizations', 'run-gepa'],
262
- usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--log-dir logs]',
262
+ usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--num-threads auto|N] [--log-dir logs]',
263
263
  summary: 'Run the bundled GEPA-style optimizer locally and stream events.',
264
264
  group: 'Optimizations',
265
265
  },
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ function printPreviewUsage() {
41
41
  printLine(`\nLocal app preview:\n\n orizu apps preview --file <path> --input-schema <json-path> --output-schema <json-path> --sample-row <json-path> [--screenshot <png-path>] [--headed] [--keep-open] [--component <name>]`);
42
42
  }
43
43
  function printOptimizationUsage() {
44
- printLine(`\nOptimization lifecycle commands:\n\n orizu optimizations start --project <team/project> --optimizer-version <id> --prompt-version <id[,id]> --selection-scorer <id> [--reflection-scorer <id>] [--pareto-scorer <id>] [--best-scorer <id>] --dataset-version <id> --split-set <id> [--train-split <name>] [--validation-split <name>] [--metadata <json|@file>] [--json]\n orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--log-dir logs] [--no-skip-perfect-parent-reflection]\n orizu optimizations export <run-id> [--out <path>] [--json]\n orizu optimizations pause <run-id> [--reason <text>] [--json]\n orizu optimizations resume <run-id> [--json]\n orizu optimizations finish <run-id> [--best-score <n>] [--best-candidate <id>] [--result-prompt-version <id>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations fail <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations cancel <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--json]`);
44
+ printLine(`\nOptimization lifecycle commands:\n\n orizu optimizations start --project <team/project> --optimizer-version <id> --prompt-version <id[,id]> --selection-scorer <id> [--reflection-scorer <id>] [--pareto-scorer <id>] [--best-scorer <id>] --dataset-version <id> --split-set <id> [--train-split <name>] [--validation-split <name>] [--metadata <json|@file>] [--json]\n orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--num-threads auto|N] [--log-dir logs] [--no-skip-perfect-parent-reflection]\n orizu optimizations export <run-id> [--out <path>] [--json]\n orizu optimizations pause <run-id> [--reason <text>] [--json]\n orizu optimizations resume <run-id> [--json]\n orizu optimizations finish <run-id> [--best-score <n>] [--best-candidate <id>] [--result-prompt-version <id>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations fail <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations cancel <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--json]`);
45
45
  }
46
46
  let cliArgs = process.argv.slice(2);
47
47
  let cliJsonOutput = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/help.ts CHANGED
@@ -284,7 +284,7 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
284
284
  },
285
285
  {
286
286
  path: ['optimizations', 'run-gepa'],
287
- usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--log-dir logs]',
287
+ usage: 'orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--num-threads auto|N] [--log-dir logs]',
288
288
  summary: 'Run the bundled GEPA-style optimizer locally and stream events.',
289
289
  group: 'Optimizations',
290
290
  },
package/src/index.ts CHANGED
@@ -288,7 +288,7 @@ function printPreviewUsage() {
288
288
  }
289
289
 
290
290
  function printOptimizationUsage() {
291
- printLine(`\nOptimization lifecycle commands:\n\n orizu optimizations start --project <team/project> --optimizer-version <id> --prompt-version <id[,id]> --selection-scorer <id> [--reflection-scorer <id>] [--pareto-scorer <id>] [--best-scorer <id>] --dataset-version <id> --split-set <id> [--train-split <name>] [--validation-split <name>] [--metadata <json|@file>] [--json]\n orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--log-dir logs] [--no-skip-perfect-parent-reflection]\n orizu optimizations export <run-id> [--out <path>] [--json]\n orizu optimizations pause <run-id> [--reason <text>] [--json]\n orizu optimizations resume <run-id> [--json]\n orizu optimizations finish <run-id> [--best-score <n>] [--best-candidate <id>] [--result-prompt-version <id>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations fail <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations cancel <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--json]`)
291
+ printLine(`\nOptimization lifecycle commands:\n\n orizu optimizations start --project <team/project> --optimizer-version <id> --prompt-version <id[,id]> --selection-scorer <id> [--reflection-scorer <id>] [--pareto-scorer <id>] [--best-scorer <id>] --dataset-version <id> --split-set <id> [--train-split <name>] [--validation-split <name>] [--metadata <json|@file>] [--json]\n orizu optimizations run-gepa --project <team/project> --optimizer-version-id <id> --candidate-version-id <id> --runner-version-id <id> --candidate-runner-dir <dir> --scorer-version-id <id> --scorer-runner-version-id <id> --scorer-runner-dir <dir> --dataset-version-id <id> --split-set-id <id> [--train-split train] [--val-split validation] [--num-threads auto|N] [--log-dir logs] [--no-skip-perfect-parent-reflection]\n orizu optimizations export <run-id> [--out <path>] [--json]\n orizu optimizations pause <run-id> [--reason <text>] [--json]\n orizu optimizations resume <run-id> [--json]\n orizu optimizations finish <run-id> [--best-score <n>] [--best-candidate <id>] [--result-prompt-version <id>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations fail <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--metadata <json|@file>] [--json]\n orizu optimizations cancel <run-id> [--reason <text>] [--report <markdown|@file> | --report-file <path>] [--json]`)
292
292
  }
293
293
 
294
294
  let cliArgs = process.argv.slice(2)
@@ -1,19 +1,23 @@
1
1
  from .optimizer import (
2
2
  Budget,
3
3
  DatasetRow,
4
+ NumThreadsPlan,
4
5
  PromptContext,
5
6
  RowEvaluation,
6
7
  TextGepaConfig,
7
8
  TextGepaResult,
8
9
  optimize_text_candidate,
10
+ resolve_num_threads,
9
11
  )
10
12
 
11
13
  __all__ = [
12
14
  "Budget",
13
15
  "DatasetRow",
16
+ "NumThreadsPlan",
14
17
  "PromptContext",
15
18
  "RowEvaluation",
16
19
  "TextGepaConfig",
17
20
  "TextGepaResult",
18
21
  "optimize_text_candidate",
22
+ "resolve_num_threads",
19
23
  ]
@@ -19,6 +19,12 @@ def positive_int(value: str) -> int:
19
19
  return parsed
20
20
 
21
21
 
22
+ def num_threads_arg(value: str) -> int | str:
23
+ if value.strip().lower() == "auto":
24
+ return "auto"
25
+ return positive_int(value)
26
+
27
+
22
28
  def read_text_arg(value: str | None) -> str | None:
23
29
  if value is None:
24
30
  return None
@@ -54,9 +60,10 @@ def main() -> None:
54
60
  parser.add_argument("--split-set-id", required=True)
55
61
  parser.add_argument("--train-split", default="train")
56
62
  parser.add_argument("--val-split", default="validation")
57
- parser.add_argument("--budget", default="light", choices=["auto", "light", "medium", "high"])
63
+ parser.add_argument("--budget", default="light", choices=["auto", "light", "medium", "heavy"])
58
64
  parser.add_argument("--max-iterations", type=positive_int, default=3)
59
65
  parser.add_argument("--minibatch-size", type=positive_int, default=3)
66
+ parser.add_argument("--num-threads", type=num_threads_arg, default=TextGepaConfig.num_threads)
60
67
  parser.add_argument(
61
68
  "--candidate-selection-strategy",
62
69
  default=TextGepaConfig.candidate_selection_strategy,
@@ -102,6 +109,7 @@ def main() -> None:
102
109
  budget=args.budget,
103
110
  max_iterations=args.max_iterations,
104
111
  minibatch_size=args.minibatch_size,
112
+ num_threads=args.num_threads,
105
113
  candidate_selection_strategy=args.candidate_selection_strategy,
106
114
  epsilon=args.epsilon,
107
115
  max_metric_calls=args.max_metric_calls,
@@ -153,6 +161,7 @@ def main() -> None:
153
161
  "dataset_size": len(trainset) + len(valset),
154
162
  "train_count": len(trainset),
155
163
  "validation_count": len(valset),
164
+ "num_threads": args.num_threads,
156
165
  }
157
166
 
158
167
  run_id = client.start_run(
@@ -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
@@ -316,6 +386,229 @@ def _metric_key(scorer_context: PromptContext) -> str:
316
386
  return scorer_context.metric_key or "score"
317
387
 
318
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
+
319
612
  def _is_better(challenger: float, incumbent: float, higher_is_better: bool) -> bool:
320
613
  return challenger > incumbent if higher_is_better else challenger < incumbent
321
614
 
@@ -564,12 +857,13 @@ def evaluate_candidate(
564
857
  scorer_runner: ScorerRunner,
565
858
  budget: Budget,
566
859
  evaluation_cache: EvaluationCache | None = None,
860
+ num_threads: int = 1,
567
861
  ) -> list[RowEvaluation]:
568
- evaluations: list[RowEvaluation] = []
569
- for row in rows:
570
- if not budget.allows_metric_call():
571
- 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]] = []
572
865
 
866
+ for index, row in enumerate(rows):
573
867
  cache_key = None
574
868
  if evaluation_cache is not None:
575
869
  cache_key = evaluation_cache.key(
@@ -581,9 +875,12 @@ def evaluate_candidate(
581
875
  )
582
876
  cached = evaluation_cache.get(cache_key)
583
877
  if cached is not None:
584
- evaluations.append(cached)
878
+ evaluations_by_index[index] = cached
585
879
  continue
586
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]:
587
884
  started = time.time()
588
885
  candidate_result = candidate_runner(candidate_text, row, prompt_context, candidate_id)
589
886
  scorer_result = scorer_runner(row, candidate_result, scorer_context, candidate_id)
@@ -610,11 +907,57 @@ def evaluate_candidate(
610
907
  if candidate_result.cost_usd is not None or scorer_result.cost_usd is not None else None,
611
908
  error=candidate_result.error or scorer_result.error,
612
909
  )
613
- 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
614
953
  if evaluation_cache is not None and cache_key is not None:
615
954
  evaluation_cache.set(cache_key, evaluation)
616
955
  budget.used_metric_calls += 1
617
- return evaluations
956
+
957
+ return [
958
+ evaluations_by_index[index]
959
+ for index in sorted(evaluations_by_index)
960
+ ]
618
961
 
619
962
 
620
963
  def _completed_all_rows(results: list[RowEvaluation], rows: list[DatasetRow]) -> bool:
@@ -636,7 +979,17 @@ def optimize_loaded_text_candidate(
636
979
  local_logger: Any | None = None,
637
980
  ) -> TextGepaResult:
638
981
  seed_text = prompt_context.body or ""
639
- 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
+ )
640
993
  evaluation_cache = EvaluationCache() if config.cache_evaluations else None
641
994
  candidate_text_by_id = {"seed": seed_text}
642
995
  val_scores_by_candidate: dict[str, dict[str, float]] = {}
@@ -664,6 +1017,7 @@ def optimize_loaded_text_candidate(
664
1017
  "train_count": len(trainset),
665
1018
  "validation_count": len(valset),
666
1019
  "config": dataclasses.asdict(config),
1020
+ "num_threads": num_threads_plan.to_payload(),
667
1021
  "budget": budget.to_payload(),
668
1022
  })
669
1023
 
@@ -684,6 +1038,7 @@ def optimize_loaded_text_candidate(
684
1038
  scorer_runner=scorer_runner,
685
1039
  budget=budget,
686
1040
  evaluation_cache=evaluation_cache,
1041
+ num_threads=num_threads_plan.resolved,
687
1042
  )
688
1043
  if local_logger is not None:
689
1044
  local_logger.append_evaluations(
@@ -694,7 +1049,7 @@ def optimize_loaded_text_candidate(
694
1049
  results=seed_results,
695
1050
  )
696
1051
  if not _completed_all_rows(seed_results, valset):
697
- raise RuntimeError("Budget exhausted before seed validation completed")
1052
+ raise RuntimeError("Seed validation did not complete all requested rows")
698
1053
  budget.used_full_evals += 1
699
1054
  seed_score = _mean(seed_results)
700
1055
  val_scores_by_candidate["seed"] = {result.row_id: result.score for result in seed_results}
@@ -725,6 +1080,20 @@ def optimize_loaded_text_candidate(
725
1080
  candidate_id=best_candidate_id,
726
1081
  )
727
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
728
1097
 
729
1098
  for iteration in range(1, config.max_iterations + 1):
730
1099
  if not budget.allows_iteration():
@@ -787,6 +1156,7 @@ def optimize_loaded_text_candidate(
787
1156
  scorer_runner=scorer_runner,
788
1157
  budget=budget,
789
1158
  evaluation_cache=evaluation_cache,
1159
+ num_threads=num_threads_plan.resolved,
790
1160
  )
791
1161
  if local_logger is not None:
792
1162
  local_logger.append_evaluations(
@@ -863,6 +1233,21 @@ def optimize_loaded_text_candidate(
863
1233
  iteration=iteration,
864
1234
  candidate_id=best_candidate_id,
865
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
866
1251
  continue
867
1252
 
868
1253
  event_sink.log_event(
@@ -946,6 +1331,7 @@ def optimize_loaded_text_candidate(
946
1331
  scorer_runner=scorer_runner,
947
1332
  budget=budget,
948
1333
  evaluation_cache=evaluation_cache,
1334
+ num_threads=num_threads_plan.resolved,
949
1335
  )
950
1336
  if local_logger is not None:
951
1337
  local_logger.append_evaluations(
@@ -1028,6 +1414,7 @@ def optimize_loaded_text_candidate(
1028
1414
  scorer_runner=scorer_runner,
1029
1415
  budget=budget,
1030
1416
  evaluation_cache=evaluation_cache,
1417
+ num_threads=num_threads_plan.resolved,
1031
1418
  )
1032
1419
  if local_logger is not None:
1033
1420
  local_logger.append_evaluations(
@@ -1152,6 +1539,21 @@ def optimize_loaded_text_candidate(
1152
1539
  iteration=iteration,
1153
1540
  candidate_id=best_candidate_id,
1154
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
1155
1557
 
1156
1558
  if budget_exhausted:
1157
1559
  event_sink.log_event(
@@ -148,6 +148,7 @@ Bundled `run-gepa` reflection behavior:
148
148
  - Reflection max-token limits are explicit config, not implicit defaults. Use `--reflection-max-tokens <n>` when a provider requires an output cap; this maps to Anthropic `max_tokens` and OpenAI `max_output_tokens`.
149
149
  - Anthropic native Messages reflection requires `max_tokens`, so `run-gepa` with an `anthropic/...` reflection model must receive `--reflection-max-tokens <n>` and should fail fast if it is omitted. OpenAI reflection may omit `max_output_tokens` unless the user explicitly wants a cap.
150
150
  - By default, `run-gepa` skips reflection and child creation when every row in the selected parent mini-batch is already perfect (`1.0` for higher-is-better scorers, `0.0` for lower-is-better scorers). This saves reflection tokens because the child cannot beat a saturated mini-batch. Use `--no-skip-perfect-parent-reflection` to force reflection anyway, or `--skip-perfect-parent-reflection` to make the default explicit in scripted runs.
151
+ - By default, `run-gepa` sets `--num-threads auto` for row evaluations. Auto uses mini-batch size, validation-set size, CPU count, memory estimate, file-descriptor limit, and an 8-thread hard cap to avoid launching too many runner subprocesses at once. Use `--num-threads <n>` only when the runner/provider capacity is known.
151
152
  - `run-gepa` writes complete local logs by default under `logs/<optimization_run_id>`; override with `--log-dir <dir>` or disable with `--no-local-log`.
152
153
  - Server optimization events redact row snapshots and reflection prompts by default; the local log keeps full row inputs, outputs, scores, feedback, reflection prompts, and reflection responses for later agent analysis.
153
154
  - OpenAI example: `--reflection-model openai/gpt-5 --reflection-provider-settings '{"reasoning":{"effort":"medium","summary":"auto"}}'`.
@@ -232,12 +232,13 @@ For prompts, judges, runners, run submission, optimizer artifacts, live event lo
232
232
  Optimization trace commands:
233
233
 
234
234
  ```bash
235
- orizu optimizations run-gepa ... [--log-dir logs] [--no-local-log]
235
+ orizu optimizations run-gepa ... [--num-threads auto|N] [--log-dir logs] [--no-local-log]
236
236
  orizu optimizations export <optimization-run-id> --out ./optimization.json
237
237
  orizu optimizations export <optimization-run-id> --json
238
238
  ```
239
239
 
240
240
  Behavior:
241
+ - `run-gepa` defaults `--num-threads` to `auto`, resolving a conservative row-evaluation parallelism cap from mini-batch size, validation-set size, CPU count, memory estimate, file-descriptor limit, and an 8-thread hard cap.
241
242
  - `run-gepa` writes a complete local trace under `logs/<optimization_run_id>` by default.
242
243
  - The local trace is the best artifact for coding-agent analysis because it includes full rows, outputs, scores, feedback, scorer responses, reflection prompts, reflection responses, candidate text, and `result.json`.
243
244
  - `optimizations export` writes a portable JSON artifact from server data when the local log is unavailable or the run happened elsewhere.
@@ -84,7 +84,7 @@ Report:
84
84
  - Acceptance rate by iteration. Many rejections suggest reflection is exploring but not finding hills; many acceptances suggest it is climbing a real signal.
85
85
  - Budget state. If budget was exhausted, the run paused before auto-promotion; manually decide whether to promote/finish. If max iterations was reached with budget remaining, the run likely converged under the configured search.
86
86
  - `scoreOverTime` / validation trajectory. Still climbing at the end suggests raising budget or iterations. Plateaued curves point toward scorer, feedback, or data work upstream.
87
- - Reflection model, inference model, scorer model, provider settings, minibatch size, budget preset/limit, candidate selection strategy, seed, and cache settings.
87
+ - Reflection model, inference model, scorer model, provider settings, minibatch size, resolved thread count, budget preset/limit, candidate selection strategy, seed, and cache settings.
88
88
 
89
89
  ### 5. Recommendations
90
90
 
@@ -130,9 +130,9 @@ Set an explicit expectation before the run: for example, "if best kappa does not
130
130
 
131
131
  ### Budget Versus Iteration Cap
132
132
 
133
- Default budget presets are metric-call budgets: `auto = 64`, `light = 48`, `medium = 96`, and `high = 192`, unless `--max-metric-calls`, `--max-full-evals`, or `--max-candidate-proposals` overrides them.
133
+ Default budget presets are DSPy-style metric-call budgets. `light`, `medium`, and `heavy` map to DSPy's `auto="light"`, `auto="medium"`, and `auto="heavy"` scales; `auto` is treated as the balanced `medium` preset. The final metric-call limit scales with validation-set size, rather than using fixed tiny constants. `--max-metric-calls` overrides this directly, while `--max-full-evals` is converted to a metric-call budget over `train + validation` rows.
134
134
 
135
- Each iteration costs the minibatch evaluation plus full validation calls for accepted candidates, so medium budget can be exhausted well before a high `--max-iterations` target. If a run ends as `budget_exhausted`, it pauses and does not auto-promote. If auto-promotion matters, either increase budget to fit the iteration target or lower `--max-iterations` to fit the budget.
135
+ Budget is checked only between iterations. Once an iteration starts, `run-gepa` completes the parent minibatch, child minibatch, and full validation eval for an accepted child even if that overshoots the nominal budget. If a run ends as `budget_exhausted`, it pauses and does not auto-promote. If auto-promotion matters, either increase budget to fit the iteration target or lower `--max-iterations` to fit the budget.
136
136
 
137
137
  ### Reflection Signal Quality
138
138
 
@@ -609,13 +609,15 @@ orizu --local optimizations run-gepa \
609
609
  --log-dir ./logs \
610
610
  --budget light \
611
611
  --max-iterations 3 \
612
- --minibatch-size 3
612
+ --minibatch-size 3 \
613
+ --num-threads auto
613
614
  ```
614
615
 
615
616
  Useful GEPA flags:
616
617
 
617
- - `--budget auto|light|medium|high`, `--max-metric-calls <n>`, `--max-full-evals <n>`.
618
+ - `--budget auto|light|medium|heavy`, `--max-metric-calls <n>`, `--max-full-evals <n>`.
618
619
  - `--minibatch-size <n>` defaults to 3.
620
+ - `--num-threads auto|N` defaults to `auto`; auto caps row-evaluation concurrency from mini-batch size, validation-set size, CPU count, memory estimate, file-descriptor limit, and an 8-thread hard cap.
619
621
  - `--candidate-selection-strategy pareto|current_best|epsilon_greedy`; default is `pareto`.
620
622
  - `--reflection-model <provider/model>`, `--reflection-temperature <n>`, `--reflection-prompt-template <text|@file>`.
621
623
  - `--reflection-max-tokens <n>` is explicit provider config, not a global default. It maps to Anthropic `max_tokens` and OpenAI `max_output_tokens`; Anthropic native Messages reflection requires it, while OpenAI can omit it unless the user wants a cap.