evalbar 0.1.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.
evalbar/html.py ADDED
@@ -0,0 +1,156 @@
1
+ """A single self-contained HTML page for a run: no external assets, no scripts.
2
+
3
+ Everything that came from the pipeline, the dataset or a model is escaped, because LLM
4
+ output is untrusted text.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from html import escape
11
+ from typing import Any
12
+
13
+ from .report import format_cost, summarize
14
+ from .runner import CaseResult, RunResult
15
+
16
+ _CSS = """
17
+ :root { --bg:#f7f7f5; --card:#fff; --ink:#1d1d1f; --ink2:#5f6368; --line:#e3e3df;
18
+ --ok:#1a7f37; --ok-bg:#e6f4ea; --bad:#b42318; --bad-bg:#fdecea; --warn:#9a6700; --warn-bg:#fff4d6;
19
+ --bar:#2f6fde; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
20
+ @media (prefers-color-scheme: dark) { :root { --bg:#141416; --card:#1d1d20; --ink:#ececf1; --ink2:#a1a1aa;
21
+ --line:#2e2e33; --ok:#4ac26b; --ok-bg:#12261a; --bad:#ff7b72; --bad-bg:#2d1414; --warn:#e3b341;
22
+ --warn-bg:#2b230f; --bar:#6c9ef8; } }
23
+ * { box-sizing: border-box; }
24
+ body { margin:0; background:var(--bg); color:var(--ink);
25
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
26
+ main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
27
+ h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 16px; margin: 28px 0 10px; }
28
+ .muted { color: var(--ink2); font-size: 13px; }
29
+ .chips span { display:inline-block; margin:6px 6px 0 0; padding:2px 8px; border:1px solid var(--line);
30
+ border-radius:999px; font-size:12px; color:var(--ink2); }
31
+ .cards { display:grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap:10px; }
32
+ .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px; }
33
+ .card strong { display:block; font-size:20px; } .card small { color:var(--ink2); }
34
+ .tw { overflow-x:auto; background:var(--card); border:1px solid var(--line); border-radius:10px; }
35
+ table { width:100%; border-collapse:collapse; font-size:14px; }
36
+ tr:last-child > td { border-bottom:0; }
37
+ th, td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
38
+ th { color:var(--ink2); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
39
+ td.num, th.num { text-align:right; font-variant-numeric: tabular-nums; }
40
+ .bar { height:6px; background:var(--line); border-radius:3px; min-width:80px; }
41
+ .bar i { display:block; height:6px; background:var(--bar); border-radius:3px; }
42
+ details.case { background:var(--card); border:1px solid var(--line); border-radius:10px; margin:8px 0; }
43
+ details.case > summary { cursor:pointer; padding:10px 12px; display:flex; gap:10px; align-items:baseline;
44
+ flex-wrap:wrap; }
45
+ details.case > div { padding:0 12px 12px; }
46
+ .badge { font-size:12px; font-weight:600; padding:1px 8px; border-radius:6px; }
47
+ .pass .badge { color:var(--ok); background:var(--ok-bg); } .fail .badge { color:var(--bad); background:var(--bad-bg); }
48
+ .error .badge { color:var(--warn); background:var(--warn-bg); }
49
+ .cid { font-family:var(--mono); font-weight:600; }
50
+ pre { font-family:var(--mono); font-size:12.5px; background:var(--bg); border:1px solid var(--line);
51
+ border-radius:8px; padding:8px 10px; white-space:pre-wrap; word-break:break-word; margin:4px 0 10px; }
52
+ .grid2 { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:0 16px; }
53
+ .label { font-size:12px; color:var(--ink2); text-transform:uppercase; letter-spacing:.03em; margin-top:8px; }
54
+ .ok { color:var(--ok); } .bad { color:var(--bad); }
55
+ """
56
+
57
+
58
+ def render_html(run: RunResult) -> str:
59
+ summary = summarize(run)
60
+ head = (f'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
61
+ f'<meta name="viewport" content="width=device-width, initial-scale=1">'
62
+ f"<title>evalbar: {_e(run.name)}</title><style>{_CSS}</style></head><body><main>")
63
+
64
+ chips = "".join(f"<span>{_e(key)}: {_e(value)}</span>"
65
+ for key, value in {**run.metadata, **run.environment}.items())
66
+ header = (f"<h1>{_e(run.name)}</h1><div class=\"muted\">{summary.n_cases} cases, {summary.n_errors} errors, "
67
+ f"started {_e(run.started_at)}, finished {_e(run.finished_at)}</div><div class=\"chips\">{chips}</div>")
68
+
69
+ per_case = summary.n_cases or 1
70
+ cards = [
71
+ ("cost (list price)", format_cost(summary.cost_usd), f"{format_cost(summary.cost_usd / per_case)} per case"),
72
+ ("tokens", f"{summary.prompt_tokens + summary.completion_tokens:,}",
73
+ f"{summary.prompt_tokens:,} prompt / {summary.completion_tokens:,} completion"),
74
+ ("LLM latency p50", f"{summary.llm_latency_p50_s:.2f}s", f"p95 {summary.llm_latency_p95_s:.2f}s"),
75
+ ("errors", str(summary.n_errors), f"of {summary.n_cases} cases"),
76
+ ]
77
+ if summary.judge_calls:
78
+ cards.append(("judge cost", format_cost(summary.judge_cost_usd), f"{summary.judge_calls} calls, not in cost"))
79
+ if summary.n_unpriced_cases:
80
+ cards.append(("unpriced cases", str(summary.n_unpriced_cases), "cost excludes them"))
81
+ cards_html = "".join(f'<div class="card"><small>{_e(title)}</small><strong>{_e(value)}</strong>'
82
+ f"<small>{_e(note)}</small></div>" for title, value, note in cards)
83
+
84
+ score_rows = "".join(
85
+ f"<tr><td>{_e(name)}</td><td class=\"num\">{s.n}</td><td class=\"num\">{s.passed}</td>"
86
+ f"<td class=\"num\">{s.pass_rate:.1%}</td><td><div class=\"bar\"><i style=\"width:{s.pass_rate * 100:.1f}%\">"
87
+ f"</i></div></td><td class=\"num\">{s.mean:.3f}</td></tr>"
88
+ for name, s in summary.scorers.items()
89
+ )
90
+ scores_html = ('<h2>Scores</h2><div class="tw"><table><tr><th>scorer</th><th class="num">cases</th>'
91
+ '<th class="num">passed</th><th class="num">pass rate</th><th></th>'
92
+ '<th class="num">mean</th></tr>'
93
+ f"{score_rows}</table></div>")
94
+
95
+ ordered = sorted(run.cases, key=_case_rank)
96
+ cases_html = "<h2>Cases (failures first)</h2>" + "".join(_case(case) for case in ordered)
97
+ return head + header + f'<h2>Usage</h2><div class="cards">{cards_html}</div>' + scores_html + cases_html + \
98
+ "</main></body></html>\n"
99
+
100
+
101
+ def _case_rank(case: CaseResult) -> int:
102
+ if case.error is not None:
103
+ return 0
104
+ return 1 if any(not score.passed for score in case.scores) else 2
105
+
106
+
107
+ def _case(case: CaseResult) -> str:
108
+ status = ["error", "fail", "pass"][_case_rank(case)]
109
+ failed = [score.name for score in case.scores if not score.passed]
110
+ cost = case.trace.cost_usd
111
+ summary_line = (f'<span class="badge">{status}</span><span class="cid">{_e(case.case_id)}</span>'
112
+ f'<span class="muted">{case.latency_s:.2f}s · '
113
+ f'{"unpriced" if cost is None else format_cost(cost)}</span>'
114
+ + (f'<span class="bad">{_e(", ".join(failed))}</span>' if failed else ""))
115
+
116
+ body = ['<div class="grid2">',
117
+ f'<div><div class="label">input</div><pre>{_e(_pretty(case.input))}</pre></div>',
118
+ f'<div><div class="label">expected</div><pre>{_e(_pretty(case.expected))}</pre></div></div>',
119
+ f'<div class="label">output</div><pre>{_e(_pretty(case.output))}</pre>']
120
+ if case.error:
121
+ body.append(f'<div class="label">error</div><pre class="bad">{_e(case.error)}</pre>')
122
+ if case.metadata:
123
+ body.append(f'<div class="label">metadata</div><pre>{_e(_pretty(case.metadata))}</pre>')
124
+ if case.scores:
125
+ rows = "".join(f'<tr><td>{_e(s.name)}</td><td class="{"ok" if s.passed else "bad"}">'
126
+ f'{"pass" if s.passed else "fail"}</td><td class="num">{s.value:.2f}</td>'
127
+ f"<td>{_e(s.reason)}</td></tr>" for s in case.scores)
128
+ body.append('<div class="label">scores</div><div class="tw"><table><tr><th>scorer</th><th>result</th>'
129
+ f'<th class="num">value</th><th>reason</th></tr>{rows}</table></div>')
130
+ if case.trace.llm_calls:
131
+ rows = "".join(f"<tr><td>{_e(c.role)}</td><td>{_e(c.model)}</td><td class=\"num\">{c.prompt_tokens:,}</td>"
132
+ f"<td class=\"num\">{c.completion_tokens:,}</td><td class=\"num\">{c.latency_s:.2f}s</td>"
133
+ f"<td class=\"num\">{'unpriced' if c.cost_usd is None else format_cost(c.cost_usd)}</td></tr>"
134
+ for c in case.trace.llm_calls)
135
+ body.append('<div class="label">LLM calls</div><div class="tw"><table><tr><th>role</th><th>model</th>'
136
+ '<th class="num">prompt</th><th class="num">completion</th><th class="num">latency</th>'
137
+ f'<th class="num">cost</th></tr>{rows}</table></div>')
138
+ if case.trace.tool_calls:
139
+ rows = "".join(f"<tr><td>{_e(t.name)}</td><td><pre>{_e(_pretty(t.args))}</pre></td>"
140
+ f"<td><pre>{_e(t.error or _pretty(t.result))}</pre></td>"
141
+ f"<td class=\"num\">{t.latency_s:.3f}s</td></tr>" for t in case.trace.tool_calls)
142
+ body.append('<div class="label">tool calls</div><div class="tw"><table><tr><th>tool</th><th>args</th>'
143
+ f'<th>result</th><th class="num">latency</th></tr>{rows}</table></div>')
144
+
145
+ return (f'<details class="case {status}" id="case-{_e(case.case_id)}"><summary>{summary_line}</summary>'
146
+ f'<div>{"".join(body)}</div></details>')
147
+
148
+
149
+ def _pretty(value: Any) -> str:
150
+ if isinstance(value, str):
151
+ return value
152
+ return json.dumps(value, ensure_ascii=False, indent=2, default=repr)
153
+
154
+
155
+ def _e(value: Any) -> str:
156
+ return escape(str(value), quote=True)
evalbar/llm.py ADDED
@@ -0,0 +1,50 @@
1
+ """Traced LLM calls through LiteLLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any
8
+
9
+ # Use the price table bundled with the installed LiteLLM instead of fetching the latest one
10
+ # at import time: runs stay reproducible, and the run file records the LiteLLM version.
11
+ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
12
+
13
+ import litellm # noqa: E402
14
+
15
+ from .trace import record_llm_call
16
+
17
+
18
+ def completion(**kwargs: Any) -> Any:
19
+ """Call litellm.completion with the same arguments and record usage, latency and cost.
20
+
21
+ Cost comes from LiteLLM's price table (list price). A model with no known price is
22
+ recorded as None so reports never treat it as free.
23
+ """
24
+ started = time.perf_counter()
25
+ response = litellm.completion(**kwargs)
26
+ latency_s = time.perf_counter() - started
27
+
28
+ usage = getattr(response, "usage", None)
29
+ record_llm_call(
30
+ model=str(kwargs.get("model", getattr(response, "model", "unknown"))),
31
+ prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
32
+ completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
33
+ latency_s=latency_s,
34
+ cost_usd=_cost(response),
35
+ )
36
+ return response
37
+
38
+
39
+ def _cost(response: Any) -> float | None:
40
+ # LiteLLM prices the call while it still knows the provider. completion_cost() on the
41
+ # response alone can guess wrong: Groq returns model="openai/gpt-oss-120b" with no
42
+ # provider hint, so the lookup goes to OpenAI and finds no price.
43
+ hidden = getattr(response, "_hidden_params", None) or {}
44
+ if hidden.get("response_cost") is not None:
45
+ return float(hidden["response_cost"])
46
+ try:
47
+ cost = litellm.completion_cost(completion_response=response)
48
+ except Exception:
49
+ return None
50
+ return float(cost)
evalbar/report.py ADDED
@@ -0,0 +1,174 @@
1
+ """Aggregate a run, render it for the terminal, and read/write JSON run files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console, Group
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from .runner import RunResult
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ScorerSummary:
19
+ n: int
20
+ passed: int
21
+ mean: float
22
+
23
+ @property
24
+ def pass_rate(self) -> float:
25
+ return self.passed / self.n if self.n else 0.0
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Summary:
30
+ name: str
31
+ n_cases: int
32
+ n_errors: int
33
+ scorers: dict[str, ScorerSummary]
34
+ llm_calls: int
35
+ prompt_tokens: int
36
+ completion_tokens: int
37
+ cost_usd: float
38
+ n_unpriced_cases: int
39
+ judge_calls: int
40
+ judge_cost_usd: float
41
+ latency_p50_s: float
42
+ latency_p95_s: float
43
+ llm_latency_p50_s: float
44
+ llm_latency_p95_s: float
45
+
46
+
47
+ def summarize(run: RunResult) -> Summary:
48
+ scorers = {}
49
+ for name in run.scorer_names:
50
+ scores = [score for case in run.cases for score in case.scores if score.name == name]
51
+ scorers[name] = ScorerSummary(
52
+ n=len(scores),
53
+ passed=sum(score.passed for score in scores),
54
+ mean=sum(score.value for score in scores) / len(scores) if scores else 0.0,
55
+ )
56
+ latencies = sorted(case.latency_s for case in run.cases)
57
+ # Time spent inside model calls only: excludes rate-limit waits and local work.
58
+ llm_latencies = sorted(case.trace.llm_latency_s for case in run.cases if case.trace.pipeline_calls)
59
+ return Summary(
60
+ name=run.name,
61
+ n_cases=len(run.cases),
62
+ n_errors=sum(case.error is not None for case in run.cases),
63
+ scorers=scorers,
64
+ llm_calls=sum(len(case.trace.pipeline_calls) for case in run.cases),
65
+ prompt_tokens=sum(case.trace.prompt_tokens for case in run.cases),
66
+ completion_tokens=sum(case.trace.completion_tokens for case in run.cases),
67
+ cost_usd=sum(call.cost_usd or 0.0 for case in run.cases for call in case.trace.pipeline_calls),
68
+ n_unpriced_cases=sum(
69
+ 1 for case in run.cases if case.trace.pipeline_calls and case.trace.cost_usd is None
70
+ ),
71
+ judge_calls=sum(len(case.trace.judge_calls) for case in run.cases),
72
+ judge_cost_usd=sum(call.cost_usd or 0.0 for case in run.cases for call in case.trace.judge_calls),
73
+ latency_p50_s=percentile(latencies, 0.50),
74
+ latency_p95_s=percentile(latencies, 0.95),
75
+ llm_latency_p50_s=percentile(llm_latencies, 0.50),
76
+ llm_latency_p95_s=percentile(llm_latencies, 0.95),
77
+ )
78
+
79
+
80
+ def report_renderable(run: RunResult, max_failures: int = 15) -> Group:
81
+ summary = summarize(run)
82
+ parts: list = []
83
+
84
+ header = Text()
85
+ header.append(f"evalbar run: {run.name}", style="bold")
86
+ header.append(f" {summary.n_cases} cases, {summary.n_errors} errors, started {run.started_at}")
87
+ parts.append(header)
88
+ if run.metadata:
89
+ parts.append(Text(" " + ", ".join(f"{key}={value}" for key, value in run.metadata.items()), style="dim"))
90
+
91
+ scores = Table(title="Scores", title_justify="left")
92
+ for column in ("scorer", "cases", "passed", "pass rate", "mean"):
93
+ scores.add_column(column, justify="left" if column == "scorer" else "right", no_wrap=True)
94
+ for name, scorer in summary.scorers.items():
95
+ scores.add_row(name, str(scorer.n), str(scorer.passed), f"{scorer.pass_rate:.1%}", f"{scorer.mean:.3f}")
96
+ parts.append(scores)
97
+
98
+ usage = Table(title="Usage", title_justify="left")
99
+ usage.add_column("metric")
100
+ usage.add_column("value", justify="right")
101
+ per_case = summary.n_cases or 1
102
+ usage.add_row("LLM calls", str(summary.llm_calls))
103
+ usage.add_row("prompt tokens", f"{summary.prompt_tokens:,}")
104
+ usage.add_row("completion tokens", f"{summary.completion_tokens:,}")
105
+ usage.add_row("cost (list price)", format_cost(summary.cost_usd))
106
+ usage.add_row("cost per case", format_cost(summary.cost_usd / per_case))
107
+ if summary.judge_calls:
108
+ usage.add_row("judge calls", str(summary.judge_calls))
109
+ usage.add_row("judge cost (not in cost above)", format_cost(summary.judge_cost_usd))
110
+ usage.add_row("case latency p50 / p95", f"{summary.latency_p50_s:.2f}s / {summary.latency_p95_s:.2f}s")
111
+ usage.add_row("LLM latency p50 / p95", f"{summary.llm_latency_p50_s:.2f}s / {summary.llm_latency_p95_s:.2f}s")
112
+ parts.append(usage)
113
+ if summary.n_unpriced_cases:
114
+ parts.append(Text(f"{summary.n_unpriced_cases} case(s) had no known price; cost excludes them.",
115
+ style="yellow"))
116
+
117
+ failures = [
118
+ (case.case_id, score.name, score.reason)
119
+ for case in run.cases
120
+ if case.error is None
121
+ for score in case.scores
122
+ if not score.passed
123
+ ]
124
+ errors = [(case.case_id, case.error) for case in run.cases if case.error is not None]
125
+ if failures:
126
+ table = Table(title=f"Failures ({len(failures)})", title_justify="left")
127
+ table.add_column("case")
128
+ table.add_column("scorer")
129
+ table.add_column("reason", overflow="fold")
130
+ for case_id, name, reason in failures[:max_failures]:
131
+ table.add_row(case_id, name, reason)
132
+ parts.append(table)
133
+ if len(failures) > max_failures:
134
+ parts.append(Text(f"... {len(failures) - max_failures} more in the run file", style="dim"))
135
+ if errors:
136
+ table = Table(title=f"Errors ({len(errors)})", title_justify="left")
137
+ table.add_column("case")
138
+ table.add_column("error", overflow="fold")
139
+ for case_id, error in errors[:max_failures]:
140
+ table.add_row(case_id, error or "")
141
+ parts.append(table)
142
+ return Group(*parts)
143
+
144
+
145
+ def render(run: RunResult, width: int | None = None, max_failures: int = 15) -> str:
146
+ """The terminal report as plain text."""
147
+ console = Console(file=io.StringIO(), width=width or 120, record=True, color_system=None)
148
+ console.print(report_renderable(run, max_failures))
149
+ return console.export_text()
150
+
151
+
152
+ def save_run(run: RunResult, path: str | Path) -> Path:
153
+ path = Path(path)
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ path.write_text(json.dumps(run.to_dict(), indent=2, ensure_ascii=False, default=repr) + "\n",
156
+ encoding="utf-8")
157
+ return path
158
+
159
+
160
+ def load_run(path: str | Path) -> RunResult:
161
+ return RunResult.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
162
+
163
+
164
+ def format_cost(usd: float) -> str:
165
+ return f"${usd:.4f}" if usd >= 0.01 or usd == 0 else f"${usd:.6f}"
166
+
167
+
168
+ def percentile(sorted_values: list[float], fraction: float) -> float:
169
+ if not sorted_values:
170
+ return 0.0
171
+ position = (len(sorted_values) - 1) * fraction
172
+ lower = int(position)
173
+ upper = min(lower + 1, len(sorted_values) - 1)
174
+ return sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * (position - lower)
evalbar/runner.py ADDED
@@ -0,0 +1,209 @@
1
+ """Run a pipeline over a dataset, one trace per case."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import platform
7
+ import time
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass, field, replace
10
+ from datetime import datetime, timezone
11
+ from typing import Any
12
+
13
+ from .dataset import Case
14
+ from .scorers import Score, ScoreFn, Scorer, as_scorer
15
+ from .trace import Trace
16
+
17
+ SCHEMA_VERSION = 1
18
+
19
+
20
+ @dataclass
21
+ class Eval:
22
+ name: str
23
+ pipeline: Callable[[Any], Any]
24
+ dataset: Sequence[Case]
25
+ scorers: Sequence[Scorer | ScoreFn]
26
+ min_interval_s: float = 0.0
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+ def __post_init__(self) -> None:
30
+ self.scorers = [as_scorer(scorer) for scorer in self.scorers]
31
+ seen: set[str] = set()
32
+ for scorer in self.scorers:
33
+ if scorer.name in seen:
34
+ raise ValueError(f"duplicate scorer name '{scorer.name}'; pass name= to tell them apart")
35
+ seen.add(scorer.name)
36
+
37
+
38
+ @dataclass
39
+ class CaseResult:
40
+ case_id: str
41
+ input: Any
42
+ expected: dict[str, Any]
43
+ output: Any
44
+ error: str | None
45
+ latency_s: float
46
+ trace: Trace
47
+ scores: list[Score]
48
+ metadata: dict[str, Any] = field(default_factory=dict)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "case_id": self.case_id,
53
+ "input": self.input,
54
+ "expected": self.expected,
55
+ "metadata": self.metadata,
56
+ "output": self.output,
57
+ "error": self.error,
58
+ "latency_s": self.latency_s,
59
+ "trace": self.trace.to_dict(),
60
+ "scores": [score.__dict__ for score in self.scores],
61
+ }
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict[str, Any]) -> CaseResult:
65
+ return cls(
66
+ case_id=data["case_id"],
67
+ input=data["input"],
68
+ expected=data["expected"],
69
+ metadata=data.get("metadata", {}),
70
+ output=data["output"],
71
+ error=data["error"],
72
+ latency_s=data["latency_s"],
73
+ trace=Trace.from_dict(data["trace"]),
74
+ scores=[Score(**score) for score in data["scores"]],
75
+ )
76
+
77
+
78
+ @dataclass
79
+ class RunResult:
80
+ name: str
81
+ started_at: str
82
+ finished_at: str
83
+ scorer_names: list[str]
84
+ cases: list[CaseResult]
85
+ metadata: dict[str, Any] = field(default_factory=dict)
86
+ environment: dict[str, str] = field(default_factory=dict)
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "schema_version": SCHEMA_VERSION,
91
+ "name": self.name,
92
+ "started_at": self.started_at,
93
+ "finished_at": self.finished_at,
94
+ "scorer_names": self.scorer_names,
95
+ "metadata": self.metadata,
96
+ "environment": self.environment,
97
+ "cases": [case.to_dict() for case in self.cases],
98
+ }
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: dict[str, Any]) -> RunResult:
102
+ version = data.get("schema_version")
103
+ if version != SCHEMA_VERSION:
104
+ raise ValueError(f"unsupported run file schema_version {version!r} (expected {SCHEMA_VERSION})")
105
+ return cls(
106
+ name=data["name"],
107
+ started_at=data["started_at"],
108
+ finished_at=data["finished_at"],
109
+ scorer_names=list(data["scorer_names"]),
110
+ cases=[CaseResult.from_dict(case) for case in data["cases"]],
111
+ metadata=data.get("metadata", {}),
112
+ environment=data.get("environment", {}),
113
+ )
114
+
115
+
116
+ def run(
117
+ evaluation: Eval,
118
+ *,
119
+ limit: int | None = None,
120
+ on_case: Callable[[CaseResult], None] | None = None,
121
+ clock: Callable[[], float] = time.monotonic,
122
+ sleep: Callable[[float], None] = time.sleep,
123
+ ) -> RunResult:
124
+ """Run every case sequentially. Pipeline exceptions become case errors, never crashes."""
125
+ cases = list(evaluation.dataset)[:limit] if limit is not None else list(evaluation.dataset)
126
+ started_at = _now()
127
+ results: list[CaseResult] = []
128
+ last_start: float | None = None
129
+
130
+ for case in cases:
131
+ if last_start is not None and evaluation.min_interval_s > 0:
132
+ wait = evaluation.min_interval_s - (clock() - last_start)
133
+ if wait > 0:
134
+ sleep(wait)
135
+ last_start = clock()
136
+ result = _run_case(evaluation, case)
137
+ results.append(result)
138
+ if on_case is not None:
139
+ on_case(result)
140
+
141
+ return RunResult(
142
+ name=evaluation.name,
143
+ started_at=started_at,
144
+ finished_at=_now(),
145
+ scorer_names=[scorer.name for scorer in evaluation.scorers],
146
+ cases=results,
147
+ metadata=dict(evaluation.metadata),
148
+ environment=_environment(),
149
+ )
150
+
151
+
152
+ def _run_case(evaluation: Eval, case: Case) -> CaseResult:
153
+ output: Any = None
154
+ error: str | None = None
155
+ started = time.perf_counter()
156
+ with Trace() as trace:
157
+ try:
158
+ output = evaluation.pipeline(case.input)
159
+ except Exception as exc:
160
+ # Spans recorded before the failure stay on the trace: a failed call still cost money.
161
+ error = f"{type(exc).__name__}: {exc}"
162
+ latency_s = time.perf_counter() - started
163
+
164
+ with trace.recording_as("judge"):
165
+ scores = _score(evaluation, case, output, error)
166
+
167
+ return CaseResult(
168
+ case_id=case.id,
169
+ input=case.input,
170
+ expected=case.expected,
171
+ output=output,
172
+ error=error,
173
+ latency_s=latency_s,
174
+ trace=trace,
175
+ scores=scores,
176
+ metadata=case.metadata,
177
+ )
178
+
179
+
180
+ def _score(evaluation: Eval, case: Case, output: Any, error: str | None) -> list[Score]:
181
+ scores: list[Score] = []
182
+ for scorer in evaluation.scorers:
183
+ if error is not None:
184
+ if scorer.applies(case):
185
+ scores.append(Score(name=scorer.name, value=0.0, passed=False, reason="pipeline error"))
186
+ continue
187
+ try:
188
+ score = scorer(output, case)
189
+ except Exception as exc:
190
+ score = Score(name=scorer.name, value=0.0, passed=False,
191
+ reason=f"scorer error: {type(exc).__name__}: {exc}")
192
+ if score is not None:
193
+ # Reports group by scorer name, so a custom scorer cannot rename its scores.
194
+ scores.append(replace(score, name=scorer.name))
195
+ return scores
196
+
197
+
198
+ def _now() -> str:
199
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
200
+
201
+
202
+ def _environment() -> dict[str, str]:
203
+ env = {"python": platform.python_version()}
204
+ for package in ("evalbar", "litellm"):
205
+ try:
206
+ env[package] = importlib.metadata.version(package)
207
+ except importlib.metadata.PackageNotFoundError:
208
+ env[package] = "unknown"
209
+ return env