lightcone-cli 0.2.0__py3-none-any.whl

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.
Files changed (46) hide show
  1. lightcone/cli/__init__.py +16 -0
  2. lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
  3. lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
  4. lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
  5. lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
  6. lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
  7. lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
  8. lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
  9. lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
  10. lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
  11. lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
  12. lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
  13. lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
  14. lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
  15. lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
  16. lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
  17. lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
  18. lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
  19. lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
  20. lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
  21. lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
  22. lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
  23. lightcone/cli/commands.py +2327 -0
  24. lightcone/cli/plugin.py +34 -0
  25. lightcone/engine/__init__.py +42 -0
  26. lightcone/engine/assets.py +418 -0
  27. lightcone/engine/container.py +370 -0
  28. lightcone/engine/io_manager.py +27 -0
  29. lightcone/engine/runner.py +1017 -0
  30. lightcone/engine/site_registry.py +142 -0
  31. lightcone/engine/status.py +135 -0
  32. lightcone/engine/targets.py +68 -0
  33. lightcone/engine/tree.py +245 -0
  34. lightcone/eval/__init__.py +25 -0
  35. lightcone/eval/build.py +148 -0
  36. lightcone/eval/cli.py +176 -0
  37. lightcone/eval/graders.py +192 -0
  38. lightcone/eval/harness.py +265 -0
  39. lightcone/eval/models.py +117 -0
  40. lightcone/eval/report.py +214 -0
  41. lightcone/eval/sandbox.py +394 -0
  42. lightcone_cli-0.2.0.dist-info/METADATA +16 -0
  43. lightcone_cli-0.2.0.dist-info/RECORD +46 -0
  44. lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
  45. lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
  46. lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,265 @@
1
+ """Core eval loop — runs trials with ThreadPoolExecutor concurrency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import signal
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from collections.abc import Callable
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from lightcone.eval.build import build_eval_wheels
19
+ from lightcone.eval.graders import compute_composite_score, run_graders
20
+ from lightcone.eval.models import (
21
+ EvalRun,
22
+ EvalRunConfig,
23
+ IterationResult,
24
+ TaskSpec,
25
+ TrialResult,
26
+ )
27
+ from lightcone.eval.sandbox import BUILD_COMPLETE_MARKER, EvalSandbox
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ DEFAULT_LOOP_PROMPT = """\
32
+ /lc-build this analysis and make sure to cover universe {{UNIVERSE}}.
33
+ Do NOT ask for plan approval — skip straight to building. This is an automated eval run.
34
+ """
35
+
36
+
37
+ def load_task(evals_dir: Path, task_id: str) -> TaskSpec:
38
+ """Load a TaskSpec from evals/tasks/<task_id>/task.yaml."""
39
+ task_file = evals_dir / "tasks" / task_id / "task.yaml"
40
+ try:
41
+ data = yaml.safe_load(task_file.read_text())
42
+ except FileNotFoundError:
43
+ raise FileNotFoundError(f"Task not found: {task_file}") from None
44
+ return TaskSpec(**data)
45
+
46
+
47
+ def load_run_config(config_path: Path) -> EvalRunConfig:
48
+ """Load an EvalRunConfig from a YAML file."""
49
+ data = yaml.safe_load(config_path.read_text())
50
+ return EvalRunConfig(**data)
51
+
52
+
53
+ def _get_loop_prompt(evals_dir: Path, task_id: str) -> str:
54
+ """Get loop prompt template: task-specific or default."""
55
+ task_prompt = evals_dir / "tasks" / task_id / "loop-prompt.md"
56
+ if task_prompt.exists():
57
+ return task_prompt.read_text()
58
+ return DEFAULT_LOOP_PROMPT
59
+
60
+
61
+ def run_trial(
62
+ task: TaskSpec,
63
+ trial_number: int,
64
+ *,
65
+ evals_dir: Path,
66
+ config: EvalRunConfig,
67
+ run_id: str,
68
+ wheels: list[Path],
69
+ sidecar_dir: Path | None = None,
70
+ ) -> TrialResult:
71
+ """Run a single trial: create sandbox -> run /lc-build -> grade -> teardown."""
72
+ trial_id = f"{run_id}-{task.id}-{trial_number}"
73
+ trial = TrialResult(
74
+ trial_id=trial_id,
75
+ task_id=task.id,
76
+ trial_number=trial_number,
77
+ started_at=datetime.now(UTC),
78
+ )
79
+
80
+ env_vars = {
81
+ "LIGHTCONE_EVAL_RUN_ID": run_id,
82
+ "CLAUDE_CODE_SESSION_ID": f"eval-{trial_id}",
83
+ }
84
+
85
+ sandbox = EvalSandbox(
86
+ task_id=task.id,
87
+ trial_id=trial_id,
88
+ sandbox_image=config.sandbox_image,
89
+ env_vars=env_vars,
90
+ )
91
+
92
+ try:
93
+ sandbox.create()
94
+
95
+ seed_dir = evals_dir / "tasks" / task.id
96
+ loop_prompt = _get_loop_prompt(evals_dir, task.id)
97
+
98
+ sandbox.setup(
99
+ seed_dir=seed_dir,
100
+ universe=task.universe,
101
+ loop_prompt_template=loop_prompt,
102
+ wheels=wheels,
103
+ )
104
+
105
+ # Single invocation: /lc-build handles its own loop internally
106
+ start = time.monotonic()
107
+ try:
108
+ claude_result = sandbox.exec_claude(
109
+ max_turns=task.max_turns,
110
+ timeout=task.trial_timeout,
111
+ )
112
+ duration = time.monotonic() - start
113
+
114
+ build_complete = BUILD_COMPLETE_MARKER in claude_result.result_text
115
+ iteration = IterationResult(
116
+ iteration=0,
117
+ cost_usd=claude_result.cost_usd,
118
+ num_turns=claude_result.num_turns,
119
+ duration_seconds=duration,
120
+ build_complete=build_complete,
121
+ output_summary=(
122
+ "" if claude_result.is_error else claude_result.result_text[:500]
123
+ ),
124
+ error=claude_result.result_text[:500] if claude_result.is_error else None,
125
+ )
126
+
127
+ # Save transcript sidecar
128
+ if sidecar_dir is not None and claude_result.raw_jsonl:
129
+ trial_log_dir = sidecar_dir / trial_id
130
+ trial_log_dir.mkdir(parents=True, exist_ok=True)
131
+ jsonl_path = trial_log_dir / "transcript.jsonl"
132
+ jsonl_path.write_text(claude_result.raw_jsonl)
133
+ iteration.transcript_path = str(
134
+ jsonl_path.relative_to(sidecar_dir.parent)
135
+ )
136
+ except Exception as exc:
137
+ duration = time.monotonic() - start
138
+ iteration = IterationResult(
139
+ iteration=0,
140
+ duration_seconds=duration,
141
+ error=str(exc),
142
+ )
143
+
144
+ trial.iterations.append(iteration)
145
+ trial.build_complete = iteration.build_complete
146
+
147
+ # Run graders
148
+ trial.grader_results = run_graders(sandbox, task.graders, evals_dir, task.id)
149
+ trial.composite_score = compute_composite_score(trial.grader_results)
150
+
151
+ # Aggregate metrics
152
+ trial.total_cost_usd = sum(it.cost_usd for it in trial.iterations)
153
+ trial.total_turns = sum(it.num_turns for it in trial.iterations)
154
+ trial.total_duration_seconds = sum(it.duration_seconds for it in trial.iterations)
155
+
156
+ except Exception as exc:
157
+ logger.error("Trial %s failed: %s", trial_id, exc, exc_info=True)
158
+ trial.error = str(exc)
159
+ finally:
160
+ sandbox.teardown()
161
+
162
+ trial.finished_at = datetime.now(UTC)
163
+ return trial
164
+
165
+
166
+ def run_eval(
167
+ config: EvalRunConfig,
168
+ evals_dir: Path,
169
+ *,
170
+ progress_callback: Callable[[TrialResult], None] | None = None,
171
+ dry_run: bool = False,
172
+ ) -> EvalRun:
173
+ """Run all trials: tasks x num_trials with ThreadPoolExecutor."""
174
+ run_id = config.id or str(uuid.uuid4())[:8]
175
+
176
+ # Load all tasks
177
+ tasks = [load_task(evals_dir, tid) for tid in config.tasks]
178
+
179
+ # Build trial schedule
180
+ schedule: list[dict[str, Any]] = []
181
+ for task in tasks:
182
+ for n in range(config.num_trials):
183
+ schedule.append({"task": task, "trial_number": n})
184
+
185
+ if dry_run:
186
+ return EvalRun(
187
+ config=config,
188
+ started_at=datetime.now(UTC),
189
+ finished_at=datetime.now(UTC),
190
+ summary={"dry_run": True, "total_trials": len(schedule), "schedule": [
191
+ {"task": s["task"].id, "trial": s["trial_number"]}
192
+ for s in schedule
193
+ ]},
194
+ )
195
+
196
+ # Build lightcone-cli wheel from current working tree + collect ASTRA wheel
197
+ version_info, wheels = build_eval_wheels(evals_dir)
198
+
199
+ # Compute run stem using git commit for traceability
200
+ commit_short = version_info.lightcone_commit[:8] or "unknown"
201
+ run_stem = f"{run_id}-{commit_short}"
202
+ output_base = Path(config.output_dir)
203
+ sidecar_dir = output_base / run_stem / "logs"
204
+
205
+ eval_run = EvalRun(
206
+ config=config,
207
+ version=version_info,
208
+ started_at=datetime.now(UTC),
209
+ run_stem=run_stem,
210
+ transcript_dir=str(sidecar_dir),
211
+ )
212
+
213
+ # Handle SIGINT: save partial results
214
+ interrupted = False
215
+
216
+ def _signal_handler(signum: int, frame: Any) -> None:
217
+ nonlocal interrupted
218
+ interrupted = True
219
+ logger.warning("SIGINT received — finishing current trials and saving partial results")
220
+
221
+ is_main_thread = threading.current_thread() is threading.main_thread()
222
+ if is_main_thread:
223
+ original_handler = signal.getsignal(signal.SIGINT)
224
+ signal.signal(signal.SIGINT, _signal_handler)
225
+
226
+ try:
227
+ with ThreadPoolExecutor(max_workers=config.max_concurrency) as pool:
228
+ futures = {
229
+ pool.submit(
230
+ run_trial,
231
+ s["task"],
232
+ s["trial_number"],
233
+ evals_dir=evals_dir,
234
+ config=config,
235
+ run_id=run_id,
236
+ wheels=wheels,
237
+ sidecar_dir=sidecar_dir,
238
+ ): s
239
+ for s in schedule
240
+ }
241
+
242
+ for future in as_completed(futures):
243
+ if interrupted:
244
+ break
245
+
246
+ try:
247
+ trial = future.result()
248
+ except Exception as exc:
249
+ s = futures[future]
250
+ trial = TrialResult(
251
+ trial_id=f"{run_id}-error",
252
+ task_id=s["task"].id,
253
+ trial_number=s["trial_number"],
254
+ error=str(exc),
255
+ )
256
+
257
+ eval_run.trials.append(trial)
258
+ if progress_callback:
259
+ progress_callback(trial)
260
+ finally:
261
+ if is_main_thread:
262
+ signal.signal(signal.SIGINT, original_handler)
263
+
264
+ eval_run.finished_at = datetime.now(UTC)
265
+ return eval_run
@@ -0,0 +1,117 @@
1
+ """Pydantic data models for the eval harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class GraderType(StrEnum):
13
+ """Supported grader types."""
14
+
15
+ command = "command"
16
+ status = "status"
17
+ script = "script"
18
+
19
+
20
+ class GraderSpec(BaseModel):
21
+ """Specification for a single grader."""
22
+
23
+ name: str
24
+ type: GraderType
25
+ command: str | None = None
26
+ script: str | None = None
27
+ weight: float = 1.0
28
+ timeout: int = 120
29
+
30
+
31
+ class TaskSpec(BaseModel):
32
+ """Specification for an eval task (loaded from task.yaml)."""
33
+
34
+ id: str
35
+ description: str = ""
36
+ seed_project: str = ""
37
+ universe: str = "baseline"
38
+ max_turns: int = 200
39
+ trial_timeout: int = 7200
40
+ graders: list[GraderSpec] = Field(default_factory=list)
41
+
42
+
43
+ class IterationResult(BaseModel):
44
+ """Result from a single build-loop iteration."""
45
+
46
+ iteration: int
47
+ cost_usd: float = 0.0
48
+ num_turns: int = 0
49
+ duration_seconds: float = 0.0
50
+ build_complete: bool = False
51
+ output_summary: str = ""
52
+ error: str | None = None
53
+ transcript_path: str | None = None
54
+
55
+
56
+ class GraderResult(BaseModel):
57
+ """Result from a single grader."""
58
+
59
+ name: str
60
+ type: GraderType
61
+ passed: bool = False
62
+ score: float = 0.0
63
+ weight: float = 1.0
64
+ details: str = ""
65
+ error: str | None = None
66
+
67
+
68
+ class TrialResult(BaseModel):
69
+ """Result of a single trial (one task x repetition)."""
70
+
71
+ trial_id: str
72
+ task_id: str
73
+ trial_number: int = 0
74
+ started_at: datetime | None = None
75
+ finished_at: datetime | None = None
76
+ iterations: list[IterationResult] = Field(default_factory=list)
77
+ grader_results: list[GraderResult] = Field(default_factory=list)
78
+ composite_score: float = 0.0
79
+ build_complete: bool = False
80
+ total_cost_usd: float = 0.0
81
+ total_turns: int = 0
82
+ total_duration_seconds: float = 0.0
83
+ error: str | None = None
84
+
85
+
86
+ class EvalRunConfig(BaseModel):
87
+ """Configuration for an eval run (loaded from run config YAML)."""
88
+
89
+ id: str = ""
90
+ tasks: list[str] = Field(default_factory=list)
91
+ num_trials: int = 3
92
+ max_concurrency: int = 4
93
+ sandbox_image: str | None = None
94
+ output_dir: str = "eval-results"
95
+
96
+
97
+ class VersionInfo(BaseModel):
98
+ """Git and wheel version metadata for reproducibility."""
99
+
100
+ lightcone_commit: str = ""
101
+ lightcone_branch: str = ""
102
+ lightcone_dirty: bool = False
103
+ lightcone_version: str = ""
104
+ astra_version: str = ""
105
+
106
+
107
+ class EvalRun(BaseModel):
108
+ """Complete results of an eval run."""
109
+
110
+ config: EvalRunConfig
111
+ version: VersionInfo = Field(default_factory=VersionInfo)
112
+ started_at: datetime | None = None
113
+ finished_at: datetime | None = None
114
+ trials: list[TrialResult] = Field(default_factory=list)
115
+ summary: dict[str, Any] = Field(default_factory=dict)
116
+ transcript_dir: str | None = None
117
+ run_stem: str | None = None
@@ -0,0 +1,214 @@
1
+ """Aggregation, display, and persistence for eval results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections import defaultdict
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from lightcone.eval.models import EvalRun, TrialResult
16
+
17
+
18
+ def compute_summary(eval_run: EvalRun) -> dict[str, Any]:
19
+ """Group trials by task and compute aggregate statistics."""
20
+ groups: dict[str, list[TrialResult]] = defaultdict(list)
21
+ for trial in eval_run.trials:
22
+ groups[trial.task_id].append(trial)
23
+
24
+ summary: dict[str, Any] = {"groups": {}, "totals": {}}
25
+
26
+ all_costs: list[float] = []
27
+ all_durations: list[float] = []
28
+
29
+ for task_id, trials in groups.items():
30
+ scores: list[float] = []
31
+ costs: list[float] = []
32
+ durations: list[float] = []
33
+ completions = 0
34
+ errors = 0
35
+
36
+ for t in trials:
37
+ if t.error is not None:
38
+ errors += 1
39
+ continue
40
+ scores.append(t.composite_score)
41
+ costs.append(t.total_cost_usd)
42
+ durations.append(t.total_duration_seconds)
43
+ if t.build_complete:
44
+ completions += 1
45
+
46
+ n = len(scores)
47
+ mean_score = sum(scores) / n if n > 0 else 0.0
48
+ stderr_score = (
49
+ math.sqrt(sum((s - mean_score) ** 2 for s in scores) / (n - 1)) / math.sqrt(n)
50
+ if n > 1
51
+ else 0.0
52
+ )
53
+ mean_cost = sum(costs) / n if n > 0 else 0.0
54
+ mean_duration = sum(durations) / n if n > 0 else 0.0
55
+
56
+ summary["groups"][task_id] = {
57
+ "task_id": task_id,
58
+ "num_trials": len(trials),
59
+ "num_errors": errors,
60
+ "mean_score": round(mean_score, 4),
61
+ "stderr_score": round(stderr_score, 4),
62
+ "pass_at_k": completions / len(trials) if trials else 0.0,
63
+ "mean_cost_usd": round(mean_cost, 4),
64
+ "mean_duration_seconds": round(mean_duration, 1),
65
+ }
66
+
67
+ all_costs.extend(costs)
68
+ all_durations.extend(durations)
69
+
70
+ summary["totals"] = {
71
+ "total_trials": len(eval_run.trials),
72
+ "total_cost_usd": round(sum(all_costs), 4),
73
+ "total_duration_seconds": round(sum(all_durations), 1),
74
+ }
75
+
76
+ return summary
77
+
78
+
79
+ def _score_cell(g: dict[str, Any]) -> str:
80
+ """Format a score cell with color coding."""
81
+ score = g["mean_score"]
82
+ stderr = g["stderr_score"]
83
+ completion = g["pass_at_k"]
84
+
85
+ if score >= 0.8:
86
+ color = "green"
87
+ elif score >= 0.5:
88
+ color = "yellow"
89
+ else:
90
+ color = "red"
91
+
92
+ cell = f"[{color}]{score:.2f}[/{color}] +/- {stderr:.2f}\npass@k: {completion:.0%}"
93
+ if g["num_errors"] > 0:
94
+ cell += f"\n[red]{g['num_errors']} errors[/red]"
95
+ return cell
96
+
97
+
98
+ def _cost_cell(g: dict[str, Any]) -> str:
99
+ """Format a cost/duration cell."""
100
+ cost = g["mean_cost_usd"]
101
+ dur = g["mean_duration_seconds"]
102
+ return f"${cost:.2f}\n{dur:.0f}s"
103
+
104
+
105
+ def print_comparison_table(
106
+ eval_run: EvalRun,
107
+ console: Console | None = None,
108
+ ) -> None:
109
+ """Print a Rich summary table of eval results."""
110
+ if console is None:
111
+ console = Console()
112
+
113
+ summary = eval_run.summary or compute_summary(eval_run)
114
+ groups = summary.get("groups", {})
115
+
116
+ if not groups:
117
+ console.print("[yellow]No results to display.[/yellow]")
118
+ return
119
+
120
+ # Scores table
121
+ score_table = Table(title="Eval Results: Scores", show_lines=True)
122
+ score_table.add_column("Task", style="bold")
123
+ score_table.add_column("Score", justify="center")
124
+ for task_id, g in groups.items():
125
+ score_table.add_row(task_id, _score_cell(g))
126
+ console.print(score_table)
127
+
128
+ # Cost table
129
+ console.print()
130
+ cost_table = Table(title="Eval Results: Cost & Duration", show_lines=True)
131
+ cost_table.add_column("Task", style="bold")
132
+ cost_table.add_column("Cost / Duration", justify="center")
133
+ for task_id, g in groups.items():
134
+ cost_table.add_row(task_id, _cost_cell(g))
135
+ console.print(cost_table)
136
+
137
+ # Totals
138
+ totals = summary.get("totals", {})
139
+ if totals:
140
+ console.print(
141
+ f"\n[bold]Total:[/bold] {totals.get('total_trials', 0)} trials, "
142
+ f"${totals.get('total_cost_usd', 0):.2f}, "
143
+ f"{totals.get('total_duration_seconds', 0):.0f}s"
144
+ )
145
+
146
+
147
+ def print_comparison_between(
148
+ run1: EvalRun,
149
+ run2: EvalRun,
150
+ console: Console | None = None,
151
+ ) -> None:
152
+ """Print a comparison between two eval runs."""
153
+ if console is None:
154
+ console = Console()
155
+
156
+ s1 = run1.summary or compute_summary(run1)
157
+ s2 = run2.summary or compute_summary(run2)
158
+
159
+ g1 = s1.get("groups", {})
160
+ g2 = s2.get("groups", {})
161
+
162
+ all_keys = sorted(set(g1.keys()) | set(g2.keys()))
163
+ if not all_keys:
164
+ console.print("[yellow]No results to compare.[/yellow]")
165
+ return
166
+
167
+ table = Table(title="Eval Comparison", show_lines=True)
168
+ table.add_column("Task", style="bold")
169
+ table.add_column("Run 1 Score", justify="center")
170
+ table.add_column("Run 2 Score", justify="center")
171
+ table.add_column("Delta", justify="center")
172
+
173
+ for key in all_keys:
174
+ r1 = g1.get(key)
175
+ r2 = g2.get(key)
176
+
177
+ s1_score = f"{r1['mean_score']:.2f} +/- {r1['stderr_score']:.2f}" if r1 else "-"
178
+ s2_score = f"{r2['mean_score']:.2f} +/- {r2['stderr_score']:.2f}" if r2 else "-"
179
+
180
+ if r1 and r2:
181
+ delta = r2["mean_score"] - r1["mean_score"]
182
+ color = "green" if delta > 0 else ("red" if delta < 0 else "white")
183
+ delta_str = f"[{color}]{delta:+.2f}[/{color}]"
184
+ else:
185
+ delta_str = "-"
186
+
187
+ table.add_row(key, s1_score, s2_score, delta_str)
188
+
189
+ console.print(table)
190
+
191
+
192
+ def save_results(eval_run: EvalRun, output_dir: str | Path) -> Path:
193
+ """Save full EvalRun to JSON inside the run's sidecar directory."""
194
+ output_dir = Path(output_dir)
195
+
196
+ if eval_run.run_stem:
197
+ run_dir = output_dir / eval_run.run_stem
198
+ else:
199
+ timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
200
+ run_id = eval_run.config.id or "eval"
201
+ run_dir = output_dir / f"{run_id}-{timestamp}"
202
+
203
+ run_dir.mkdir(parents=True, exist_ok=True)
204
+ output_path = run_dir / "results.json"
205
+ data = eval_run.model_dump(mode="json")
206
+ output_path.write_text(json.dumps(data, indent=2, default=str))
207
+
208
+ return output_path
209
+
210
+
211
+ def load_results(path: Path) -> EvalRun:
212
+ """Load an EvalRun from a JSON file."""
213
+ data = json.loads(path.read_text())
214
+ return EvalRun.model_validate(data)