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.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
lightcone/eval/models.py
ADDED
|
@@ -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
|
lightcone/eval/report.py
ADDED
|
@@ -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)
|