orizu 0.3.2 → 0.3.4

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.
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from typing import Any
7
7
 
8
8
  from .client import OrizuClient, OrizuEventSink
9
+ from .local_log import LocalOptimizationLogger
9
10
  from .optimizer import TextGepaConfig, optimize_loaded_text_candidate
10
11
  from .reflection import reflect_with_provider
11
12
  from .runner import make_candidate_runner, make_scorer_runner
@@ -74,6 +75,8 @@ def main() -> None:
74
75
  parser.add_argument("--auto-promote", action="store_true")
75
76
  parser.add_argument("--promotion-label")
76
77
  parser.add_argument("--log-row-snapshots", action="store_true")
78
+ parser.add_argument("--log-dir", default="logs")
79
+ parser.add_argument("--no-local-log", action="store_true")
77
80
  parser.add_argument("--metadata", default="{}")
78
81
  args = parser.parse_args()
79
82
 
@@ -123,6 +126,19 @@ def main() -> None:
123
126
  split=args.val_split,
124
127
  )
125
128
 
129
+ run_metadata = {
130
+ **metadata,
131
+ "optimizer_package": "orizu-gepa-python",
132
+ "optimizer_family": "gepa",
133
+ "mode": "text-candidate",
134
+ "inference_lm": prompt_context.provider_settings.get("model"),
135
+ "reflection_lm": config.reflection_model,
136
+ "scorer_lm": scorer_context.provider_settings.get("model"),
137
+ "dataset_size": len(trainset) + len(valset),
138
+ "train_count": len(trainset),
139
+ "validation_count": len(valset),
140
+ }
141
+
126
142
  run_id = client.start_run(
127
143
  project=args.project,
128
144
  optimizer_version_id=args.optimizer_version_id,
@@ -132,21 +148,29 @@ def main() -> None:
132
148
  split_set_id=args.split_set_id,
133
149
  train_split=args.train_split,
134
150
  validation_split=args.val_split,
135
- metadata={
136
- **metadata,
137
- "optimizer_package": "orizu-gepa-python",
138
- "optimizer_family": "gepa",
139
- "mode": "text-candidate",
140
- "inference_lm": prompt_context.provider_settings.get("model"),
141
- "reflection_lm": config.reflection_model,
142
- "scorer_lm": scorer_context.provider_settings.get("model"),
143
- "dataset_size": len(trainset) + len(valset),
144
- "train_count": len(trainset),
145
- "validation_count": len(valset),
146
- },
151
+ metadata=run_metadata,
147
152
  )
148
153
  print(f"[orizu-gepa] started optimization run {run_id}", flush=True)
149
154
 
155
+ local_logger = None
156
+ if not args.no_local_log:
157
+ local_logger = LocalOptimizationLogger.create(args.log_dir, run_id)
158
+ local_logger.write_context(
159
+ project=args.project,
160
+ run_id=run_id,
161
+ args={
162
+ key: value
163
+ for key, value in vars(args).items()
164
+ if key not in {"metadata"}
165
+ },
166
+ prompt_context=prompt_context,
167
+ scorer_context=scorer_context,
168
+ trainset=trainset,
169
+ valset=valset,
170
+ metadata=run_metadata,
171
+ )
172
+ print(f"[orizu-gepa] local log: {local_logger.path}", flush=True)
173
+
150
174
  result = optimize_loaded_text_candidate(
151
175
  run_id=run_id,
152
176
  prompt_context=prompt_context,
@@ -156,9 +180,17 @@ def main() -> None:
156
180
  candidate_runner=make_candidate_runner(Path(args.candidate_runner_dir), run_id),
157
181
  scorer_runner=make_scorer_runner(Path(args.scorer_runner_dir), run_id),
158
182
  reflector=reflect_with_provider,
159
- event_sink=OrizuEventSink(client, run_id, fail_on_log_error=config.fail_on_log_error),
183
+ event_sink=OrizuEventSink(
184
+ client,
185
+ run_id,
186
+ fail_on_log_error=config.fail_on_log_error,
187
+ local_logger=local_logger,
188
+ ),
160
189
  config=config,
190
+ local_logger=local_logger,
161
191
  )
192
+ if local_logger is not None:
193
+ local_logger.write_result(result)
162
194
  print(json.dumps({
163
195
  "optimization_run_id": result.run_id,
164
196
  "best_candidate_id": result.best_candidate_id,
@@ -166,6 +198,7 @@ def main() -> None:
166
198
  "seed_score": result.seed_score,
167
199
  "promoted_prompt_version_id": result.promoted_prompt_version_id,
168
200
  "budget": result.budget.to_payload(),
201
+ "local_log_dir": local_logger.path if local_logger is not None else None,
169
202
  }))
170
203
 
171
204
 
@@ -255,12 +255,14 @@ class OrizuEventSink:
255
255
  *,
256
256
  fail_on_log_error: bool = True,
257
257
  max_log_retries: int = 2,
258
+ local_logger: Any | None = None,
258
259
  ):
259
260
  self.client = client
260
261
  self.run_id = run_id
261
262
  self.sequence = 0
262
263
  self.fail_on_log_error = fail_on_log_error
263
264
  self.max_log_retries = max(0, max_log_retries)
265
+ self.local_logger = local_logger
264
266
 
265
267
  def log_event(
266
268
  self,
@@ -275,6 +277,20 @@ class OrizuEventSink:
275
277
  child_candidate_id: str | None = None,
276
278
  ) -> None:
277
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)
278
294
  last_error: Exception | None = None
279
295
  for attempt in range(self.max_log_retries + 1):
280
296
  try:
@@ -308,7 +324,37 @@ class OrizuEventSink:
308
324
  prompt_version_id = self.client.promote_candidate(self.run_id, **kwargs)
309
325
  # Promotion writes a system event at max(sequence)+1 in Postgres.
310
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
+ })
311
343
  return prompt_version_id
312
344
 
313
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
+ })
314
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
+ })
@@ -658,6 +658,7 @@ def optimize_loaded_text_candidate(
658
658
  reflector: Reflector,
659
659
  event_sink: EventSink,
660
660
  config: TextGepaConfig,
661
+ local_logger: Any | None = None,
661
662
  ) -> TextGepaResult:
662
663
  seed_text = prompt_context.body or ""
663
664
  budget = Budget.from_config(config)
@@ -709,6 +710,14 @@ def optimize_loaded_text_candidate(
709
710
  budget=budget,
710
711
  evaluation_cache=evaluation_cache,
711
712
  )
713
+ if local_logger is not None:
714
+ local_logger.append_evaluations(
715
+ stage="seed_val_set",
716
+ split="validation",
717
+ iteration=None,
718
+ candidate_id="seed",
719
+ results=seed_results,
720
+ )
712
721
  if not _completed_all_rows(seed_results, valset):
713
722
  raise RuntimeError("Budget exhausted before seed validation completed")
714
723
  budget.used_full_evals += 1
@@ -804,6 +813,14 @@ def optimize_loaded_text_candidate(
804
813
  budget=budget,
805
814
  evaluation_cache=evaluation_cache,
806
815
  )
816
+ if local_logger is not None:
817
+ local_logger.append_evaluations(
818
+ stage="parent_minibatch",
819
+ split="train",
820
+ iteration=iteration,
821
+ candidate_id=parent_candidate_id,
822
+ results=parent_results,
823
+ )
807
824
  if not _completed_all_rows(parent_results, minibatch):
808
825
  event_sink.log_event(
809
826
  "budget_exhausted",
@@ -852,11 +869,21 @@ def optimize_loaded_text_candidate(
852
869
  child_text = reflection.candidate_text
853
870
  candidate_text_by_id[child_id] = child_text
854
871
  budget.used_candidate_proposals += 1
872
+ if local_logger is not None:
873
+ local_logger.append_reflection(
874
+ iteration=iteration,
875
+ parent_candidate_id=parent_candidate_id,
876
+ child_candidate_id=child_id,
877
+ row_ids=[row.id for row in minibatch],
878
+ prompt=reflection.prompt,
879
+ response=reflection.response,
880
+ candidate_text=reflection.candidate_text,
881
+ )
855
882
  event_sink.log_event(
856
883
  "reflection_completed",
857
884
  {
858
885
  **_text_log_fields("prompt", reflection.prompt, include_text=config.log_row_snapshots),
859
- **_text_log_fields("response", reflection.response, include_text=config.log_row_snapshots),
886
+ "response": reflection.response,
860
887
  "child_candidate_id": child_id,
861
888
  "candidate_text": child_text,
862
889
  },
@@ -908,6 +935,15 @@ def optimize_loaded_text_candidate(
908
935
  budget=budget,
909
936
  evaluation_cache=evaluation_cache,
910
937
  )
938
+ if local_logger is not None:
939
+ local_logger.append_evaluations(
940
+ stage="child_minibatch",
941
+ split="train",
942
+ iteration=iteration,
943
+ candidate_id=child_id,
944
+ parent_candidate_id=parent_candidate_id,
945
+ results=child_results,
946
+ )
911
947
  if not _completed_all_rows(child_results, minibatch):
912
948
  event_sink.log_event(
913
949
  "budget_exhausted",
@@ -981,6 +1017,15 @@ def optimize_loaded_text_candidate(
981
1017
  budget=budget,
982
1018
  evaluation_cache=evaluation_cache,
983
1019
  )
1020
+ if local_logger is not None:
1021
+ local_logger.append_evaluations(
1022
+ stage="child_val_set",
1023
+ split="validation",
1024
+ iteration=iteration,
1025
+ candidate_id=child_id,
1026
+ parent_candidate_id=parent_candidate_id,
1027
+ results=child_val_results,
1028
+ )
984
1029
  if not _completed_all_rows(child_val_results, valset):
985
1030
  event_sink.log_event(
986
1031
  "budget_exhausted",