orizu 0.3.1 → 0.3.3

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,360 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .optimizer import DatasetRow, PromptContext
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class OrizuClient:
17
+ api_url: str
18
+ token: str
19
+ project: str | None = None
20
+ sequence: int = 0
21
+
22
+ @classmethod
23
+ def from_env(cls) -> "OrizuClient":
24
+ api_url = (os.environ.get("ORIZU_API_URL") or "").rstrip("/")
25
+ token = os.environ.get("ORIZU_TOKEN") or ""
26
+ if not api_url or not token:
27
+ raise RuntimeError("ORIZU_API_URL and ORIZU_TOKEN are required. Run: eval \"$(orizu env --project <team>/<project>)\"")
28
+ return cls(api_url=api_url, token=token, project=os.environ.get("ORIZU_PROJECT"))
29
+
30
+ def _request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ body: dict[str, Any] | None = None,
35
+ ) -> Any:
36
+ data = None if body is None else json.dumps(body).encode("utf-8")
37
+ request = urllib.request.Request(
38
+ f"{self.api_url}{path}",
39
+ data=data,
40
+ method=method,
41
+ headers={
42
+ "Authorization": f"Bearer {self.token}",
43
+ "Content-Type": "application/json",
44
+ },
45
+ )
46
+ try:
47
+ with urllib.request.urlopen(request, timeout=120) as response:
48
+ raw = response.read().decode("utf-8")
49
+ return json.loads(raw) if raw else {}
50
+ except urllib.error.HTTPError as error:
51
+ detail = error.read().decode("utf-8", errors="replace")
52
+ raise RuntimeError(f"{method} {path} failed: {error.code} {detail}") from error
53
+
54
+ def _request_bytes(self, path: str) -> bytes:
55
+ request = urllib.request.Request(
56
+ f"{self.api_url}{path}",
57
+ method="GET",
58
+ headers={"Authorization": f"Bearer {self.token}"},
59
+ )
60
+ try:
61
+ with urllib.request.urlopen(request, timeout=120) as response:
62
+ return response.read()
63
+ except urllib.error.HTTPError as error:
64
+ detail = error.read().decode("utf-8", errors="replace")
65
+ raise RuntimeError(f"GET {path} failed: {error.code} {detail}") from error
66
+
67
+ def start_run(
68
+ self,
69
+ *,
70
+ project: str,
71
+ optimizer_version_id: str,
72
+ prompt_version_id: str,
73
+ scorer_version_id: str,
74
+ dataset_version_id: str,
75
+ split_set_id: str,
76
+ train_split: str,
77
+ validation_split: str,
78
+ metadata: dict[str, Any] | None = None,
79
+ ) -> str:
80
+ query = urllib.parse.urlencode({"project": project})
81
+ data = self._request("POST", f"/api/cli/optimization-runs?{query}", {
82
+ "optimizerVersionId": optimizer_version_id,
83
+ "promptVersionIds": [prompt_version_id],
84
+ "scorers": [
85
+ {
86
+ "scorerVersionId": scorer_version_id,
87
+ "role": "selection",
88
+ },
89
+ {
90
+ "scorerVersionId": scorer_version_id,
91
+ "role": "reflection",
92
+ },
93
+ ],
94
+ "datasetVersionId": dataset_version_id,
95
+ "splitSetId": split_set_id,
96
+ "trainSplitName": train_split,
97
+ "validationSplitName": validation_split,
98
+ "metadata": metadata or {},
99
+ })
100
+ return data["optimization_run_id"]
101
+
102
+ def fetch_exec_context(
103
+ self,
104
+ *,
105
+ prompt_version_id: str,
106
+ runner_version_id: str,
107
+ dataset_version_id: str,
108
+ split_set_id: str,
109
+ split: str,
110
+ ) -> tuple[PromptContext, list[DatasetRow]]:
111
+ query = urllib.parse.urlencode({
112
+ "promptVersion": prompt_version_id,
113
+ "runnerVersion": runner_version_id,
114
+ "datasetVersion": dataset_version_id,
115
+ "splitSet": split_set_id,
116
+ "split": split,
117
+ })
118
+ data = self._request("GET", f"/api/cli/runners/exec-context?{query}")
119
+ prompt = data["prompt"]
120
+ rows = [
121
+ DatasetRow(id=item["id"], row=item["row"])
122
+ for item in data.get("rows", [])
123
+ ]
124
+ return PromptContext(
125
+ body=prompt.get("body"),
126
+ body_kind=prompt.get("bodyKind") or "text",
127
+ provider_settings=prompt.get("providerSettings") or {},
128
+ prompt_version_id=prompt["promptVersionId"],
129
+ runner_version_id=prompt["runnerVersionId"],
130
+ prompt_id=prompt.get("promptId"),
131
+ ), rows
132
+
133
+ def fetch_scorer_exec_context(
134
+ self,
135
+ *,
136
+ scorer_version_id: str,
137
+ runner_version_id: str | None,
138
+ dataset_version_id: str,
139
+ split_set_id: str,
140
+ split: str,
141
+ ) -> tuple[PromptContext, list[DatasetRow]]:
142
+ params = {
143
+ "scorerVersion": scorer_version_id,
144
+ "datasetVersion": dataset_version_id,
145
+ "splitSet": split_set_id,
146
+ "split": split,
147
+ }
148
+ if runner_version_id:
149
+ params["runnerVersion"] = runner_version_id
150
+ query = urllib.parse.urlencode(params)
151
+ data = self._request("GET", f"/api/cli/runners/exec-context?{query}")
152
+ prompt = data["prompt"]
153
+ scorer = data.get("scorer") or {}
154
+ higher_is_better = scorer.get("higherIsBetter")
155
+ if not isinstance(higher_is_better, bool):
156
+ higher_is_better = scorer.get("higher_is_better")
157
+ if not isinstance(higher_is_better, bool):
158
+ higher_is_better = True
159
+ rows = [
160
+ DatasetRow(id=item["id"], row=item["row"])
161
+ for item in data.get("rows", [])
162
+ ]
163
+ return PromptContext(
164
+ body=prompt.get("body"),
165
+ body_kind=prompt.get("bodyKind") or "text",
166
+ provider_settings=prompt.get("providerSettings") or {},
167
+ prompt_version_id=prompt["promptVersionId"],
168
+ runner_version_id=prompt["runnerVersionId"],
169
+ prompt_id=prompt.get("promptId"),
170
+ scorer_version_id=scorer_version_id,
171
+ metric_key=scorer.get("metricKey") or scorer.get("metric_key"),
172
+ higher_is_better=higher_is_better,
173
+ ), rows
174
+
175
+ def log_event(
176
+ self,
177
+ run_id: str,
178
+ *,
179
+ sequence: int,
180
+ event_type: str,
181
+ payload: dict[str, Any] | None = None,
182
+ event_layer: str = "core",
183
+ optimizer_family: str = "gepa",
184
+ iteration: int | None = None,
185
+ candidate_id: str | None = None,
186
+ parent_candidate_id: str | None = None,
187
+ child_candidate_id: str | None = None,
188
+ ) -> None:
189
+ event_id = f"orizu-gepa-{run_id}-{sequence}"
190
+ self._request("POST", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}/events", {
191
+ "eventId": event_id,
192
+ "sequence": sequence,
193
+ "eventType": event_type,
194
+ "eventLayer": event_layer,
195
+ "optimizerFamily": optimizer_family,
196
+ "iteration": iteration,
197
+ "candidateId": candidate_id,
198
+ "parentCandidateId": parent_candidate_id,
199
+ "childCandidateId": child_candidate_id,
200
+ "payload": payload or {},
201
+ })
202
+
203
+ def promote_candidate(
204
+ self,
205
+ run_id: str,
206
+ *,
207
+ candidate_id: str,
208
+ prompt_id: str,
209
+ parent_prompt_version_id: str,
210
+ body: str,
211
+ body_kind: str,
212
+ provider_settings: dict[str, Any],
213
+ runner_version_id: str,
214
+ label: str | None,
215
+ ) -> str:
216
+ data = self._request("POST", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}/promote", {
217
+ "candidateId": candidate_id,
218
+ "promptId": prompt_id,
219
+ "parentPromptVersionId": parent_prompt_version_id,
220
+ "body": body,
221
+ "bodyKind": body_kind,
222
+ "providerSettings": provider_settings,
223
+ "runnerVersionId": runner_version_id,
224
+ "label": label,
225
+ })
226
+ return data["promptVersionId"]
227
+
228
+ def update_run(
229
+ self,
230
+ run_id: str,
231
+ *,
232
+ status: str,
233
+ best_score: float | None = None,
234
+ best_candidate_id: str | None = None,
235
+ result_prompt_version_id: str | None = None,
236
+ metadata: dict[str, Any] | None = None,
237
+ ) -> None:
238
+ body: dict[str, Any] = {"status": status}
239
+ if best_score is not None:
240
+ body["bestScore"] = best_score
241
+ if best_candidate_id:
242
+ body["bestCandidateId"] = best_candidate_id
243
+ if result_prompt_version_id:
244
+ body["resultPromptVersionId"] = result_prompt_version_id
245
+ if metadata:
246
+ body["metadata"] = metadata
247
+ self._request("PATCH", f"/api/cli/optimization-runs/{urllib.parse.quote(run_id)}", body)
248
+
249
+
250
+ class OrizuEventSink:
251
+ def __init__(
252
+ self,
253
+ client: OrizuClient,
254
+ run_id: str,
255
+ *,
256
+ fail_on_log_error: bool = True,
257
+ max_log_retries: int = 2,
258
+ local_logger: Any | None = None,
259
+ ):
260
+ self.client = client
261
+ self.run_id = run_id
262
+ self.sequence = 0
263
+ self.fail_on_log_error = fail_on_log_error
264
+ self.max_log_retries = max(0, max_log_retries)
265
+ self.local_logger = local_logger
266
+
267
+ def log_event(
268
+ self,
269
+ event_type: str,
270
+ payload: dict[str, Any] | None = None,
271
+ *,
272
+ event_layer: str = "core",
273
+ optimizer_family: str = "gepa",
274
+ iteration: int | None = None,
275
+ candidate_id: str | None = None,
276
+ parent_candidate_id: str | None = None,
277
+ child_candidate_id: str | None = None,
278
+ ) -> None:
279
+ self.sequence += 1
280
+ local_event = {
281
+ "run_id": self.run_id,
282
+ "sequence": self.sequence,
283
+ "event_type": event_type,
284
+ "event_layer": event_layer,
285
+ "optimizer_family": optimizer_family,
286
+ "iteration": iteration,
287
+ "candidate_id": candidate_id,
288
+ "parent_candidate_id": parent_candidate_id,
289
+ "child_candidate_id": child_candidate_id,
290
+ "payload": payload or {},
291
+ }
292
+ if self.local_logger is not None:
293
+ self.local_logger.append_event(local_event)
294
+ last_error: Exception | None = None
295
+ for attempt in range(self.max_log_retries + 1):
296
+ try:
297
+ self.client.log_event(
298
+ self.run_id,
299
+ sequence=self.sequence,
300
+ event_type=event_type,
301
+ payload=payload,
302
+ event_layer=event_layer,
303
+ optimizer_family=optimizer_family,
304
+ iteration=iteration,
305
+ candidate_id=candidate_id,
306
+ parent_candidate_id=parent_candidate_id,
307
+ child_candidate_id=child_candidate_id,
308
+ )
309
+ print(f"[orizu-gepa] {self.sequence:03d} {event_type}", flush=True)
310
+ return
311
+ except Exception as exc:
312
+ last_error = exc
313
+ if attempt < self.max_log_retries:
314
+ time.sleep(0.25 * (2 ** attempt))
315
+
316
+ if self.fail_on_log_error and last_error is not None:
317
+ raise last_error
318
+ print(
319
+ f"[orizu-gepa] {self.sequence:03d} {event_type} log failed: {last_error}",
320
+ flush=True,
321
+ )
322
+
323
+ def promote_candidate(self, **kwargs: Any) -> str:
324
+ prompt_version_id = self.client.promote_candidate(self.run_id, **kwargs)
325
+ # Promotion writes a system event at max(sequence)+1 in Postgres.
326
+ self.sequence += 1
327
+ if self.local_logger is not None:
328
+ self.local_logger.append_event({
329
+ "run_id": self.run_id,
330
+ "sequence": self.sequence,
331
+ "event_type": "candidate_promoted",
332
+ "event_layer": "system",
333
+ "optimizer_family": "gepa",
334
+ "iteration": None,
335
+ "candidate_id": kwargs.get("candidate_id"),
336
+ "parent_candidate_id": None,
337
+ "child_candidate_id": None,
338
+ "payload": {
339
+ **kwargs,
340
+ "prompt_version_id": prompt_version_id,
341
+ },
342
+ })
343
+ return prompt_version_id
344
+
345
+ def finish_run(self, **kwargs: Any) -> None:
346
+ if self.local_logger is not None:
347
+ self.sequence += 1
348
+ self.local_logger.append_event({
349
+ "run_id": self.run_id,
350
+ "sequence": self.sequence,
351
+ "event_type": "run_status_updated",
352
+ "event_layer": "system",
353
+ "optimizer_family": "gepa",
354
+ "iteration": None,
355
+ "candidate_id": kwargs.get("best_candidate_id"),
356
+ "parent_candidate_id": None,
357
+ "child_candidate_id": None,
358
+ "payload": kwargs,
359
+ })
360
+ self.client.update_run(self.run_id, **kwargs)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .optimizer import DatasetRow, PromptContext, RowEvaluation, TextGepaResult
9
+
10
+
11
+ def _json_default(value: Any) -> Any:
12
+ if dataclasses.is_dataclass(value):
13
+ return dataclasses.asdict(value)
14
+ return str(value)
15
+
16
+
17
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
18
+ path.write_text(
19
+ json.dumps(payload, indent=2, ensure_ascii=False, default=_json_default) + "\n",
20
+ encoding="utf-8",
21
+ )
22
+
23
+
24
+ def _append_jsonl(path: Path, payload: dict[str, Any]) -> None:
25
+ with path.open("a", encoding="utf-8") as handle:
26
+ handle.write(json.dumps(payload, ensure_ascii=False, default=_json_default) + "\n")
27
+
28
+
29
+ class LocalOptimizationLogger:
30
+ def __init__(self, directory: Path):
31
+ self.directory = directory
32
+ self.directory.mkdir(parents=True, exist_ok=True)
33
+
34
+ @classmethod
35
+ def create(cls, root: str | Path, run_id: str) -> "LocalOptimizationLogger":
36
+ return cls(Path(root) / run_id)
37
+
38
+ @property
39
+ def path(self) -> str:
40
+ return str(self.directory)
41
+
42
+ def write_context(
43
+ self,
44
+ *,
45
+ project: str,
46
+ run_id: str,
47
+ args: dict[str, Any],
48
+ prompt_context: PromptContext,
49
+ scorer_context: PromptContext,
50
+ trainset: list[DatasetRow],
51
+ valset: list[DatasetRow],
52
+ metadata: dict[str, Any],
53
+ ) -> None:
54
+ _write_json(self.directory / "run.json", {
55
+ "schema_version": "orizu.optimization-local-log.v1",
56
+ "optimization_run_id": run_id,
57
+ "project": project,
58
+ "args": args,
59
+ "metadata": metadata,
60
+ })
61
+ _write_json(self.directory / "prompt_context.json", dataclasses.asdict(prompt_context))
62
+ _write_json(self.directory / "scorer_context.json", dataclasses.asdict(scorer_context))
63
+ _write_json(self.directory / "trainset.json", {
64
+ "rows": [dataclasses.asdict(row) for row in trainset],
65
+ })
66
+ _write_json(self.directory / "valset.json", {
67
+ "rows": [dataclasses.asdict(row) for row in valset],
68
+ })
69
+
70
+ def append_event(self, event: dict[str, Any]) -> None:
71
+ _append_jsonl(self.directory / "events.jsonl", event)
72
+
73
+ def append_evaluations(
74
+ self,
75
+ *,
76
+ stage: str,
77
+ split: str,
78
+ iteration: int | None,
79
+ candidate_id: str,
80
+ parent_candidate_id: str | None = None,
81
+ results: list[RowEvaluation],
82
+ ) -> None:
83
+ for result in results:
84
+ _append_jsonl(self.directory / "evaluations.jsonl", {
85
+ "stage": stage,
86
+ "split": split,
87
+ "iteration": iteration,
88
+ "candidate_id": candidate_id,
89
+ "parent_candidate_id": parent_candidate_id,
90
+ **result.to_payload(),
91
+ })
92
+
93
+ def append_reflection(
94
+ self,
95
+ *,
96
+ iteration: int,
97
+ parent_candidate_id: str,
98
+ child_candidate_id: str,
99
+ row_ids: list[str],
100
+ prompt: str,
101
+ response: str,
102
+ candidate_text: str,
103
+ ) -> None:
104
+ _append_jsonl(self.directory / "reflections.jsonl", {
105
+ "iteration": iteration,
106
+ "parent_candidate_id": parent_candidate_id,
107
+ "child_candidate_id": child_candidate_id,
108
+ "row_ids": row_ids,
109
+ "prompt": prompt,
110
+ "response": response,
111
+ "candidate_text": candidate_text,
112
+ })
113
+
114
+ def write_result(self, result: TextGepaResult) -> None:
115
+ _write_json(self.directory / "result.json", {
116
+ "optimization_run_id": result.run_id,
117
+ "best_candidate_id": result.best_candidate_id,
118
+ "best_candidate_text": result.best_candidate_text,
119
+ "best_score": result.best_score,
120
+ "seed_score": result.seed_score,
121
+ "promoted_prompt_version_id": result.promoted_prompt_version_id,
122
+ "budget": result.budget.to_payload(),
123
+ })