orizu 0.3.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
+ import { join, resolve } from 'path'
3
+
4
+ export const WORKSPACE_DIR_NAME = '.orizu'
5
+
6
+ export interface WorkspaceInitOptions {
7
+ cwd?: string
8
+ baseUrl?: string | null
9
+ cliVersion?: string | null
10
+ dryRun?: boolean
11
+ }
12
+
13
+ export interface WorkspaceInitResult {
14
+ root: string
15
+ state: 'created' | 'exists' | 'would-create'
16
+ gitignoreUpdated: boolean
17
+ actions: string[]
18
+ }
19
+
20
+ const WORKSPACE_README = `# Orizu workspace
21
+
22
+ Local Orizu artifacts for this repository. This folder is gitignored by
23
+ default: generated and server-derived content here is not guaranteed to stay
24
+ in sync with the Orizu server, so checking it in would risk drift. Safe,
25
+ user-authored configuration may move to a committed location once a sync model
26
+ exists.
27
+
28
+ - \`workspace.json\`: safe local metadata (no secrets; credentials stay in
29
+ \`~/.config/orizu\`).
30
+ - \`generated/\`: runner artifacts, exports, and other generated files.
31
+ `
32
+
33
+ export function getWorkspaceRoot(cwd?: string): string {
34
+ return resolve(cwd || process.cwd(), WORKSPACE_DIR_NAME)
35
+ }
36
+
37
+ export function workspaceExists(cwd?: string): boolean {
38
+ return existsSync(join(getWorkspaceRoot(cwd), 'workspace.json'))
39
+ }
40
+
41
+ function ensureGitignoreEntry(cwd: string, dryRun: boolean): { updated: boolean, action: string | null } {
42
+ const gitignorePath = resolve(cwd, '.gitignore')
43
+ const entry = `${WORKSPACE_DIR_NAME}/`
44
+
45
+ if (existsSync(gitignorePath)) {
46
+ const existing = readFileSync(gitignorePath, 'utf8')
47
+ const hasEntry = existing
48
+ .split('\n')
49
+ .some(line => line.trim() === entry || line.trim() === WORKSPACE_DIR_NAME)
50
+ if (hasEntry) {
51
+ return { updated: false, action: null }
52
+ }
53
+ if (!dryRun) {
54
+ const separator = existing.endsWith('\n') || existing.length === 0 ? '' : '\n'
55
+ writeFileSync(gitignorePath, `${existing}${separator}\n# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
56
+ }
57
+ return { updated: true, action: `append ${entry} to .gitignore` }
58
+ }
59
+
60
+ if (!dryRun) {
61
+ writeFileSync(gitignorePath, `# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
62
+ }
63
+ return { updated: true, action: `create .gitignore with ${entry}` }
64
+ }
65
+
66
+ export function initOrizuWorkspace(options?: WorkspaceInitOptions): WorkspaceInitResult {
67
+ const cwd = resolve(options?.cwd || process.cwd())
68
+ const root = getWorkspaceRoot(cwd)
69
+ const dryRun = Boolean(options?.dryRun)
70
+ const exists = workspaceExists(cwd)
71
+ const actions: string[] = []
72
+
73
+ if (!exists) {
74
+ actions.push(`create ${WORKSPACE_DIR_NAME}/workspace.json, README.md, and generated/`)
75
+ }
76
+
77
+ const gitignore = ensureGitignoreEntry(cwd, dryRun || exists)
78
+ if (!exists && gitignore.action) {
79
+ actions.push(gitignore.action)
80
+ }
81
+
82
+ if (dryRun) {
83
+ return {
84
+ root,
85
+ state: exists ? 'exists' : 'would-create',
86
+ gitignoreUpdated: false,
87
+ actions,
88
+ }
89
+ }
90
+
91
+ if (exists) {
92
+ return { root, state: 'exists', gitignoreUpdated: false, actions: [] }
93
+ }
94
+
95
+ mkdirSync(join(root, 'generated'), { recursive: true })
96
+ writeFileSync(join(root, 'README.md'), WORKSPACE_README, 'utf8')
97
+ writeFileSync(
98
+ join(root, 'workspace.json'),
99
+ `${JSON.stringify(
100
+ {
101
+ version: 1,
102
+ server: options?.baseUrl || null,
103
+ createdByCliVersion: options?.cliVersion || null,
104
+ },
105
+ null,
106
+ 2
107
+ )}\n`,
108
+ 'utf8'
109
+ )
110
+
111
+ return { root, state: 'created', gitignoreUpdated: gitignore.updated, actions }
112
+ }
@@ -3,6 +3,7 @@ from .optimizer import (
3
3
  DatasetRow,
4
4
  NumThreadsPlan,
5
5
  PromptContext,
6
+ RetryableReflectionError,
6
7
  RowEvaluation,
7
8
  TextGepaConfig,
8
9
  TextGepaResult,
@@ -15,6 +16,7 @@ __all__ = [
15
16
  "DatasetRow",
16
17
  "NumThreadsPlan",
17
18
  "PromptContext",
19
+ "RetryableReflectionError",
18
20
  "RowEvaluation",
19
21
  "TextGepaConfig",
20
22
  "TextGepaResult",
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import sys
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
@@ -11,6 +12,8 @@ from .optimizer import TextGepaConfig, optimize_loaded_text_candidate
11
12
  from .reflection import reflect_with_provider
12
13
  from .runner import make_candidate_runner, make_scorer_runner
13
14
 
15
+ BUDGET_OPTION_FLAGS = ("--budget", "--max-metric-calls", "--max-full-evals", "--max-iterations")
16
+
14
17
 
15
18
  def positive_int(value: str) -> int:
16
19
  parsed = int(value)
@@ -46,6 +49,38 @@ def read_json_object_arg(value: str | None, flag_name: str) -> dict[str, Any] |
46
49
  return parsed
47
50
 
48
51
 
52
+ def _flag_occurrences(raw_args: list[str], flag: str) -> int:
53
+ return sum(1 for arg in raw_args if arg == flag or arg.startswith(f"{flag}="))
54
+
55
+
56
+ def _format_budget_option(flag: str, count: int) -> str:
57
+ return flag if count == 1 else f"{flag} ({count} times)"
58
+
59
+
60
+ def apply_budget_defaults(args: argparse.Namespace, raw_args: list[str]) -> None:
61
+ occurrences = {
62
+ flag: _flag_occurrences(raw_args, flag)
63
+ for flag in BUDGET_OPTION_FLAGS
64
+ }
65
+ provided = [
66
+ _format_budget_option(flag, count)
67
+ for flag, count in occurrences.items()
68
+ if count > 0
69
+ ]
70
+ total = sum(occurrences.values())
71
+
72
+ if total > 1:
73
+ allowed = ", ".join(BUDGET_OPTION_FLAGS)
74
+ received = ", ".join(provided)
75
+ raise ValueError(
76
+ f"Budget options are mutually exclusive; choose at most one of {allowed}. "
77
+ f"Received: {received}."
78
+ )
79
+
80
+ if total == 0:
81
+ args.budget = TextGepaConfig.budget
82
+
83
+
49
84
  def main() -> None:
50
85
  parser = argparse.ArgumentParser(prog="orizu-gepa")
51
86
  parser.add_argument("--project", required=True)
@@ -60,8 +95,8 @@ def main() -> None:
60
95
  parser.add_argument("--split-set-id", required=True)
61
96
  parser.add_argument("--train-split", default="train")
62
97
  parser.add_argument("--val-split", default="validation")
63
- parser.add_argument("--budget", default="light", choices=["auto", "light", "medium", "heavy"])
64
- parser.add_argument("--max-iterations", type=positive_int, default=3)
98
+ parser.add_argument("--budget", choices=["auto", "light", "medium", "heavy"])
99
+ parser.add_argument("--max-iterations", type=positive_int)
65
100
  parser.add_argument("--minibatch-size", type=positive_int, default=3)
66
101
  parser.add_argument("--num-threads", type=num_threads_arg, default=TextGepaConfig.num_threads)
67
102
  parser.add_argument(
@@ -76,6 +111,18 @@ def main() -> None:
76
111
  parser.add_argument("--reflection-model", default=TextGepaConfig.reflection_model)
77
112
  parser.add_argument("--reflection-temperature", type=float)
78
113
  parser.add_argument("--reflection-max-tokens", type=positive_int)
114
+ parser.add_argument(
115
+ "--reflection-retry-attempts",
116
+ type=positive_int,
117
+ default=TextGepaConfig.reflection_retry_attempts,
118
+ help="Total attempts for retryable reflection HTTP failures.",
119
+ )
120
+ parser.add_argument(
121
+ "--reflection-http-timeout-seconds",
122
+ type=positive_int,
123
+ default=TextGepaConfig.reflection_http_timeout_seconds,
124
+ help="Per-attempt timeout for reflection HTTP calls.",
125
+ )
79
126
  parser.add_argument("--reflection-prompt-template")
80
127
  parser.add_argument("--reflection-provider-settings")
81
128
  parser.add_argument(
@@ -99,7 +146,12 @@ def main() -> None:
99
146
  parser.add_argument("--log-dir", default="logs")
100
147
  parser.add_argument("--no-local-log", action="store_true")
101
148
  parser.add_argument("--metadata", default="{}")
102
- args = parser.parse_args()
149
+ raw_args = sys.argv[1:]
150
+ args = parser.parse_args(raw_args)
151
+ try:
152
+ apply_budget_defaults(args, raw_args)
153
+ except ValueError as error:
154
+ parser.error(str(error))
103
155
 
104
156
  metadata = json.loads(args.metadata)
105
157
  if not isinstance(metadata, dict):
@@ -117,6 +169,8 @@ def main() -> None:
117
169
  reflection_model=args.reflection_model,
118
170
  reflection_temperature=args.reflection_temperature,
119
171
  reflection_max_tokens=args.reflection_max_tokens,
172
+ reflection_retry_attempts=args.reflection_retry_attempts,
173
+ reflection_http_timeout_seconds=args.reflection_http_timeout_seconds,
120
174
  reflection_prompt_template=read_text_arg(args.reflection_prompt_template),
121
175
  reflection_provider_settings=read_json_object_arg(args.reflection_provider_settings, "--reflection-provider-settings") or {},
122
176
  skip_perfect_parent_reflection=args.skip_perfect_parent_reflection,
@@ -12,6 +12,39 @@ from typing import Any
12
12
  from .optimizer import DatasetRow, PromptContext
13
13
 
14
14
 
15
+ def _format_number(value: Any) -> str | None:
16
+ if isinstance(value, bool):
17
+ return None
18
+ if isinstance(value, int):
19
+ return str(value)
20
+ if isinstance(value, float):
21
+ return f"{value:g}"
22
+ return None
23
+
24
+
25
+ def _progress_log_suffix(event_type: str, payload: dict[str, Any] | None) -> str:
26
+ if event_type != "optimization_progress" or not isinstance(payload, dict):
27
+ return ""
28
+
29
+ percent_value = payload.get("percent")
30
+ if percent_value is None:
31
+ percent_value = payload.get("progress_percent")
32
+ percent = _format_number(percent_value)
33
+ metric_calls_remaining = _format_number(payload.get("metric_calls_remaining"))
34
+ metric_call_budget_value = payload.get("metric_call_budget")
35
+ if metric_call_budget_value is None:
36
+ metric_call_budget_value = payload.get("approx_metric_call_budget")
37
+ metric_call_budget = _format_number(metric_call_budget_value)
38
+ parts: list[str] = []
39
+ if percent is not None:
40
+ parts.append(f"{percent}%")
41
+ if metric_calls_remaining is not None:
42
+ budget_suffix = f" / {metric_call_budget}" if metric_call_budget is not None else ""
43
+ parts.append(f"{metric_calls_remaining}{budget_suffix} metric calls left")
44
+
45
+ return f" {'; '.join(parts)}" if parts else ""
46
+
47
+
15
48
  @dataclass(frozen=True)
16
49
  class OrizuClient:
17
50
  api_url: str
@@ -306,7 +339,10 @@ class OrizuEventSink:
306
339
  parent_candidate_id=parent_candidate_id,
307
340
  child_candidate_id=child_candidate_id,
308
341
  )
309
- print(f"[orizu-gepa] {self.sequence:03d} {event_type}", flush=True)
342
+ print(
343
+ f"[orizu-gepa] {self.sequence:03d} {event_type}{_progress_log_suffix(event_type, payload)}",
344
+ flush=True,
345
+ )
310
346
  return
311
347
  except Exception as exc:
312
348
  last_error = exc