evalblink 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.
- evalblink/__init__.py +3 -0
- evalblink/analysis.py +113 -0
- evalblink/cache.py +95 -0
- evalblink/compare.py +404 -0
- evalblink/estimate.py +116 -0
- evalblink/evaluator.py +250 -0
- evalblink/main.py +895 -0
- evalblink/openrouter.py +333 -0
- evalblink/reporter.py +497 -0
- evalblink/runner.py +338 -0
- evalblink/schemas.py +143 -0
- evalblink/validator.py +396 -0
- evalblink-0.1.0.dist-info/METADATA +699 -0
- evalblink-0.1.0.dist-info/RECORD +18 -0
- evalblink-0.1.0.dist-info/WHEEL +5 -0
- evalblink-0.1.0.dist-info/entry_points.txt +2 -0
- evalblink-0.1.0.dist-info/licenses/LICENSE +21 -0
- evalblink-0.1.0.dist-info/top_level.txt +1 -0
evalblink/__init__.py
ADDED
evalblink/analysis.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Derives insights from a finished run's raw results.
|
|
2
|
+
|
|
3
|
+
Pure functions over the ``results`` list that ``runner.run`` returns — no I/O, no
|
|
4
|
+
network. This module owns the *decisions* the reporter is forbidden to make:
|
|
5
|
+
which combo wins on quality, which wins on value, and how each combo's quality
|
|
6
|
+
breaks down per tag. The reporter only formats what ``summarize`` returns.
|
|
7
|
+
|
|
8
|
+
A "combo" is one ``model × prompt`` entry of ``results``; each carries a ``score``
|
|
9
|
+
(pass-rate %, ``success/total*100``) and a ``test_cases`` list whose entries hold
|
|
10
|
+
``tags`` and ``match_result``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# Any tag whose pass-rate falls below this gets flagged (README: "below 70%").
|
|
16
|
+
WARN_THRESHOLD = 70.0
|
|
17
|
+
|
|
18
|
+
# Floor on cost so best-value ranking never divides by zero (free models cost $0).
|
|
19
|
+
_COST_FLOOR = 1e-4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def tag_breakdown(combo) -> list[dict]:
|
|
23
|
+
"""Per-tag pass-rate for one combo.
|
|
24
|
+
|
|
25
|
+
Returns ``[{"tag", "cases", "scored", "errors", "quality", "warn"}, ...]``. A
|
|
26
|
+
test case carrying several tags counts toward each of them. Quality is the
|
|
27
|
+
pass-rate over **scored** cases only (mean of ``match_result`` as 0/1);
|
|
28
|
+
pipeline failures (``match_score is None`` — a judge API/parse error) are
|
|
29
|
+
excluded from the denominator and tallied under ``errors`` rather than counted
|
|
30
|
+
as a real failure. A tag with no scored cases has ``quality=None`` and never
|
|
31
|
+
warns. Untagged cases are omitted. Sorted warnings-first, then by tag name.
|
|
32
|
+
"""
|
|
33
|
+
passed: dict[str, int] = {}
|
|
34
|
+
scored: dict[str, int] = {}
|
|
35
|
+
counts: dict[str, int] = {}
|
|
36
|
+
for tc in combo["test_cases"]:
|
|
37
|
+
errored = tc.get("match_score") is None
|
|
38
|
+
for tag in tc.get("tags") or []:
|
|
39
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
40
|
+
if errored:
|
|
41
|
+
continue
|
|
42
|
+
scored[tag] = scored.get(tag, 0) + 1
|
|
43
|
+
if tc["match_result"]:
|
|
44
|
+
passed[tag] = passed.get(tag, 0) + 1
|
|
45
|
+
|
|
46
|
+
rows = []
|
|
47
|
+
for tag, n in counts.items():
|
|
48
|
+
n_scored = scored.get(tag, 0)
|
|
49
|
+
quality = passed.get(tag, 0) / n_scored * 100 if n_scored else None
|
|
50
|
+
warn = quality is not None and quality < WARN_THRESHOLD
|
|
51
|
+
rows.append(
|
|
52
|
+
{
|
|
53
|
+
"tag": tag,
|
|
54
|
+
"cases": n,
|
|
55
|
+
"scored": n_scored,
|
|
56
|
+
"errors": n - n_scored,
|
|
57
|
+
"quality": quality,
|
|
58
|
+
"warn": warn,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
rows.sort(key=lambda r: (not r["warn"], r["tag"]))
|
|
62
|
+
return rows
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def best_quality(results) -> dict:
|
|
66
|
+
"""Combo with the highest score; ties broken by lower total cost."""
|
|
67
|
+
return max(results, key=lambda c: (c["score"], -c["total_cost"]))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def best_value(results) -> dict:
|
|
71
|
+
"""Combo with the best score-per-dollar.
|
|
72
|
+
|
|
73
|
+
``score / max(total_cost, _COST_FLOOR)`` — with free models (cost 0) the floor
|
|
74
|
+
makes this fall back to the highest-scoring combo. A documented heuristic; easy
|
|
75
|
+
to retune later.
|
|
76
|
+
"""
|
|
77
|
+
return max(results, key=lambda c: c["score"] / max(c["total_cost"], _COST_FLOOR))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _gate_score(combo) -> float:
|
|
81
|
+
"""Pass rate (%) with unscored cases counted as failures."""
|
|
82
|
+
total = combo.get("total")
|
|
83
|
+
if total is None: # a hand-built combo without counts: its score is all we have
|
|
84
|
+
return combo["score"]
|
|
85
|
+
return combo.get("success", 0) / total * 100 if total else 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def summarize(results, quality_threshold) -> dict | None:
|
|
89
|
+
"""Single entry point: winners, the best combo's tag breakdown, and the gate.
|
|
90
|
+
|
|
91
|
+
``passed`` is the CI/CD verdict against ``quality_threshold``. It is
|
|
92
|
+
fail-safe: ``gate_score`` is the best combo's pass rate with every unscored
|
|
93
|
+
(pipeline-error) case counted as a failure, so a run where most calls failed
|
|
94
|
+
can't pass on the few that were scored. When ``quality_threshold`` is
|
|
95
|
+
``None`` no gate is configured, so ``passed`` is ``True``. Returns ``None`` when ``results`` is empty.
|
|
96
|
+
"""
|
|
97
|
+
if not results:
|
|
98
|
+
return None
|
|
99
|
+
top = best_quality(results)
|
|
100
|
+
breakdown = tag_breakdown(top)
|
|
101
|
+
errors = sum(c.get("errors") or 0 for c in results)
|
|
102
|
+
gate_score = max(_gate_score(c) for c in results)
|
|
103
|
+
return {
|
|
104
|
+
"best_quality": top,
|
|
105
|
+
"best_value": best_value(results),
|
|
106
|
+
"tag_breakdown": breakdown,
|
|
107
|
+
"warnings": [r["tag"] for r in breakdown if r["warn"]],
|
|
108
|
+
# Run total (every combo), matching the end-of-run pipeline-error warning.
|
|
109
|
+
"errors": errors,
|
|
110
|
+
"gate_score": gate_score,
|
|
111
|
+
"passed": quality_threshold is None or gate_score >= quality_threshold,
|
|
112
|
+
"quality_threshold": quality_threshold,
|
|
113
|
+
}
|
evalblink/cache.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SHA256 file cache for OpenRouter responses.
|
|
2
|
+
|
|
3
|
+
A request is fully determined by its payload (model, messages, temperature,
|
|
4
|
+
max_tokens). We hash that payload and store the resulting completion under
|
|
5
|
+
``.evalblink_cache/<sha256>.json`` so re-running an identical benchmark costs
|
|
6
|
+
nothing and skips the network entirely.
|
|
7
|
+
|
|
8
|
+
Two public functions: :func:`get` and :func:`set`. :func:`sha256_key` builds
|
|
9
|
+
the key from a request payload.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
CACHE_DIR = ".evalblink_cache"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def sha256_key(payload: dict) -> str:
|
|
23
|
+
"""Stable SHA256 of a request payload.
|
|
24
|
+
|
|
25
|
+
``sort_keys`` makes the hash insensitive to dict ordering so the same
|
|
26
|
+
logical request always maps to the same key.
|
|
27
|
+
"""
|
|
28
|
+
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
29
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path(key: str) -> str:
|
|
33
|
+
return os.path.join(CACHE_DIR, f"{key}.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get(key: str) -> dict | None:
|
|
37
|
+
"""Return the cached value for ``key``, or ``None`` on a miss.
|
|
38
|
+
|
|
39
|
+
A corrupt, truncated or unreadable entry also reads as a miss (it is
|
|
40
|
+
overwritten on the next successful call) rather than aborting the run. The
|
|
41
|
+
caller still validates the entry's contents.
|
|
42
|
+
"""
|
|
43
|
+
path = _path(key)
|
|
44
|
+
if not os.path.exists(path):
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
with open(path, encoding="utf-8") as f:
|
|
48
|
+
value = json.load(f)
|
|
49
|
+
except (OSError, ValueError, RecursionError):
|
|
50
|
+
return None
|
|
51
|
+
return value if isinstance(value, dict) and "response" in value else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set(key: str, value: dict) -> None:
|
|
55
|
+
"""Persist ``value`` under ``key`` atomically.
|
|
56
|
+
|
|
57
|
+
Write to a unique temp file then ``os.replace`` (atomic on POSIX) so a
|
|
58
|
+
concurrent reader never sees a half-written file — the runner now populates
|
|
59
|
+
the cache from multiple worker threads, and two threads can race on the same
|
|
60
|
+
key when distinct test cases render to an identical request.
|
|
61
|
+
"""
|
|
62
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
63
|
+
fd, tmp = tempfile.mkstemp(dir=CACHE_DIR, suffix=".tmp")
|
|
64
|
+
try:
|
|
65
|
+
with os.fdopen(fd, "w") as f:
|
|
66
|
+
json.dump(value, f, indent=4)
|
|
67
|
+
os.replace(tmp, _path(key))
|
|
68
|
+
except BaseException:
|
|
69
|
+
# Never leave a stray temp file behind on failure.
|
|
70
|
+
if os.path.exists(tmp):
|
|
71
|
+
os.remove(tmp)
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_file(name: str) -> bool:
|
|
76
|
+
return os.path.isfile(os.path.join(CACHE_DIR, name))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def stats() -> dict:
|
|
80
|
+
"""Entry count and total size of the cache directory."""
|
|
81
|
+
if not os.path.isdir(CACHE_DIR):
|
|
82
|
+
return {"entries": 0, "size_bytes": 0}
|
|
83
|
+
files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json") and _is_file(f)]
|
|
84
|
+
size = sum(os.path.getsize(os.path.join(CACHE_DIR, f)) for f in files)
|
|
85
|
+
return {"entries": len(files), "size_bytes": size}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear() -> int:
|
|
89
|
+
"""Delete all cached responses. Returns number of files removed."""
|
|
90
|
+
if not os.path.isdir(CACHE_DIR):
|
|
91
|
+
return 0
|
|
92
|
+
files = [f for f in os.listdir(CACHE_DIR) if f.endswith(".json") and _is_file(f)]
|
|
93
|
+
for f in files:
|
|
94
|
+
os.remove(os.path.join(CACHE_DIR, f))
|
|
95
|
+
return len(files)
|
evalblink/compare.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Diffs two finished run records into a per-combo quality/cost delta.
|
|
2
|
+
|
|
3
|
+
Pure functions over the JSON records the reporter persists — no network, no
|
|
4
|
+
scoring. Like ``analysis``, this module owns *decisions* (which combos changed,
|
|
5
|
+
by how much, and the overall verdict); the reporter only formats what ``diff``
|
|
6
|
+
returns. A combo is keyed by ``(model, prompt_id)``; each carries a ``score``
|
|
7
|
+
(pass-rate %) and ``total_cost``.
|
|
8
|
+
|
|
9
|
+
Latency is intentionally absent: the runner does not capture it, so comparing it
|
|
10
|
+
would be inventing data. Quality and cost only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
from .openrouter import is_number
|
|
18
|
+
|
|
19
|
+
# Score deltas smaller than this (in percentage points) read as "stable" — keeps
|
|
20
|
+
# floating-point noise from showing as spurious ↑/↓.
|
|
21
|
+
_SCORE_EPS = 0.05
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RecordError(ValueError):
|
|
25
|
+
"""A run record that can't be read or isn't the shape ``reporter.write`` persists."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _record_problem(record) -> str | None:
|
|
29
|
+
"""What makes ``record`` unusable by report / compare / history, or ``None``.
|
|
30
|
+
|
|
31
|
+
Checks exactly the fields those commands read, with the types they need:
|
|
32
|
+
strings where they are sorted or used as keys/filenames, finite numbers where
|
|
33
|
+
they are formatted or subtracted.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(record, dict):
|
|
36
|
+
return f"expected a JSON object, got {type(record).__name__}"
|
|
37
|
+
for key in ("benchmark", "timestamp"):
|
|
38
|
+
if not isinstance(record.get(key), str):
|
|
39
|
+
return f"'{key}' is missing or not a string"
|
|
40
|
+
if record.get("run_id") is not None and not isinstance(record["run_id"], str):
|
|
41
|
+
return "'run_id' is not a string"
|
|
42
|
+
results = record.get("results")
|
|
43
|
+
if not isinstance(results, list):
|
|
44
|
+
return "'results' is missing or not a list"
|
|
45
|
+
for i, combo in enumerate(results):
|
|
46
|
+
where = f"results[{i}]"
|
|
47
|
+
if not isinstance(combo, dict):
|
|
48
|
+
return f"{where} is not an object"
|
|
49
|
+
for key in ("model", "prompt_id"):
|
|
50
|
+
if not isinstance(combo.get(key), str):
|
|
51
|
+
return f"{where}.{key} is missing or not a string"
|
|
52
|
+
for key in (
|
|
53
|
+
"score",
|
|
54
|
+
"total_cost",
|
|
55
|
+
"success",
|
|
56
|
+
"total",
|
|
57
|
+
"total_prompt_tokens",
|
|
58
|
+
"total_completion_tokens",
|
|
59
|
+
):
|
|
60
|
+
if not is_number(combo.get(key)):
|
|
61
|
+
return f"{where}.{key} is missing or not a finite number"
|
|
62
|
+
for key in ("scored", "errors"):
|
|
63
|
+
if combo.get(key) is not None and not is_number(combo[key]):
|
|
64
|
+
return f"{where}.{key} is not a finite number"
|
|
65
|
+
cases = combo.get("test_cases")
|
|
66
|
+
if not isinstance(cases, list):
|
|
67
|
+
return f"{where}.test_cases is missing or not a list"
|
|
68
|
+
for j, tc in enumerate(cases):
|
|
69
|
+
here = f"{where}.test_cases[{j}]"
|
|
70
|
+
if not isinstance(tc, dict) or not isinstance(tc.get("id"), str):
|
|
71
|
+
return f"{here} is not an object with a string 'id'"
|
|
72
|
+
if "match_score" not in tc:
|
|
73
|
+
return f"{here}.match_score is missing"
|
|
74
|
+
if tc["match_score"] is not None and not is_number(tc["match_score"]):
|
|
75
|
+
return f"{here}.match_score is not a finite number"
|
|
76
|
+
if tc["match_score"] is not None and not isinstance(
|
|
77
|
+
tc.get("match_result"), bool
|
|
78
|
+
):
|
|
79
|
+
return f"{here}.match_result is missing or not true/false"
|
|
80
|
+
if tc.get("status") is not None and not isinstance(tc["status"], str):
|
|
81
|
+
return f"{here}.status is not a string"
|
|
82
|
+
if not is_number(tc.get("total_cost")):
|
|
83
|
+
return f"{here}.total_cost is missing or not a finite number"
|
|
84
|
+
tags = tc.get("tags")
|
|
85
|
+
if tags is not None and not (
|
|
86
|
+
isinstance(tags, list) and all(isinstance(t, str) for t in tags)
|
|
87
|
+
):
|
|
88
|
+
return f"{here}.tags is not a list of strings"
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_record(path) -> dict:
|
|
93
|
+
"""Load and shape-check a persisted run record.
|
|
94
|
+
|
|
95
|
+
Raises :class:`RecordError` (with a one-line reason) for a missing,
|
|
96
|
+
unreadable, non-JSON or wrongly-shaped file, so every caller can fail
|
|
97
|
+
cleanly. A missing ``schema_version`` defaults to 0: records written before
|
|
98
|
+
the field existed are legacy version 0; ``diff`` still compares them rather
|
|
99
|
+
than hard-blocking.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
with open(path, encoding="utf-8") as f:
|
|
103
|
+
record = json.load(f)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
raise RecordError(f"Run record not found: {path}") from None
|
|
106
|
+
except OSError as exc: # a directory, no permission, ...
|
|
107
|
+
raise RecordError(
|
|
108
|
+
f"Cannot read run record {path}: {exc.strerror or exc}"
|
|
109
|
+
) from None
|
|
110
|
+
except (ValueError, RecursionError) as exc: # not JSON / not UTF-8 / too deep
|
|
111
|
+
raise RecordError(f"{path} is not a valid JSON run record: {exc}") from None
|
|
112
|
+
problem = _record_problem(record)
|
|
113
|
+
if problem:
|
|
114
|
+
raise RecordError(f"{path} is not an evalblink run record: {problem}")
|
|
115
|
+
record.setdefault("schema_version", 0)
|
|
116
|
+
return record
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _index(record) -> dict:
|
|
120
|
+
"""Map ``(model, prompt_id)`` → combo for one record's ``results``."""
|
|
121
|
+
return {(c["model"], c["prompt_id"]): c for c in record.get("results", [])}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def diff(record_a, record_b) -> dict:
|
|
125
|
+
"""Per-combo deltas from run A to run B over the union of their combos.
|
|
126
|
+
|
|
127
|
+
Each row is ``{model, prompt_id, score_a, score_b, score_delta, cost_a,
|
|
128
|
+
cost_b, cost_delta, present_in}`` where ``present_in`` is
|
|
129
|
+
``"both" | "a_only" | "b_only"``. Combos missing from one side carry ``None``
|
|
130
|
+
for that side's score/cost and a ``None`` delta. Rows are sorted with
|
|
131
|
+
changed-or-missing combos first, then by model/prompt name.
|
|
132
|
+
"""
|
|
133
|
+
index_a = _index(record_a)
|
|
134
|
+
index_b = _index(record_b)
|
|
135
|
+
keys = sorted(set(index_a) | set(index_b))
|
|
136
|
+
|
|
137
|
+
rows = []
|
|
138
|
+
improved = 0
|
|
139
|
+
comparable = 0
|
|
140
|
+
for key in keys:
|
|
141
|
+
a = index_a.get(key)
|
|
142
|
+
b = index_b.get(key)
|
|
143
|
+
if a and b:
|
|
144
|
+
present_in = "both"
|
|
145
|
+
score_delta = b["score"] - a["score"]
|
|
146
|
+
cost_delta = b["total_cost"] - a["total_cost"]
|
|
147
|
+
comparable += 1
|
|
148
|
+
if score_delta > _SCORE_EPS:
|
|
149
|
+
improved += 1
|
|
150
|
+
elif b:
|
|
151
|
+
present_in = "b_only"
|
|
152
|
+
score_delta = cost_delta = None
|
|
153
|
+
else:
|
|
154
|
+
present_in = "a_only"
|
|
155
|
+
score_delta = cost_delta = None
|
|
156
|
+
rows.append(
|
|
157
|
+
{
|
|
158
|
+
"model": key[0],
|
|
159
|
+
"prompt_id": key[1],
|
|
160
|
+
"score_a": a["score"] if a else None,
|
|
161
|
+
"score_b": b["score"] if b else None,
|
|
162
|
+
"score_delta": score_delta,
|
|
163
|
+
"cost_a": a["total_cost"] if a else None,
|
|
164
|
+
"cost_b": b["total_cost"] if b else None,
|
|
165
|
+
"cost_delta": cost_delta,
|
|
166
|
+
"present_in": present_in,
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
rows.sort(
|
|
171
|
+
key=lambda r: (
|
|
172
|
+
r["present_in"] == "both" and _is_stable(r),
|
|
173
|
+
r["model"],
|
|
174
|
+
r["prompt_id"],
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
schema_a = record_a.get("schema_version", 0)
|
|
179
|
+
schema_b = record_b.get("schema_version", 0)
|
|
180
|
+
version_note = (
|
|
181
|
+
f"Record schema versions differ (A=v{schema_a}, B=v{schema_b}); "
|
|
182
|
+
"comparing on shared fields only."
|
|
183
|
+
if schema_a != schema_b
|
|
184
|
+
else None
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"run_a": record_a.get("run_id"),
|
|
189
|
+
"run_b": record_b.get("run_id"),
|
|
190
|
+
"schema_a": schema_a,
|
|
191
|
+
"schema_b": schema_b,
|
|
192
|
+
"version_note": version_note,
|
|
193
|
+
"rows": rows,
|
|
194
|
+
"verdict": _verdict(improved, comparable, rows),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_stable(row) -> bool:
|
|
199
|
+
"""Whether a both-sides row's quality is unchanged (for sort ordering)."""
|
|
200
|
+
delta = row["score_delta"]
|
|
201
|
+
return delta is not None and abs(delta) <= _SCORE_EPS
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _verdict(improved, comparable, rows) -> str:
|
|
205
|
+
"""One-line summary of B versus A."""
|
|
206
|
+
only = [r for r in rows if r["present_in"] != "both"]
|
|
207
|
+
only_note = f" {len(only)} combo(s) present in only one run." if only else ""
|
|
208
|
+
if not comparable:
|
|
209
|
+
return "No combos in common between the two runs." + only_note
|
|
210
|
+
if improved == comparable:
|
|
211
|
+
return f"Run B improves quality on all {comparable} shared combo(s).{only_note}"
|
|
212
|
+
if improved == 0:
|
|
213
|
+
return f"Run B improves quality on no shared combo(s).{only_note}"
|
|
214
|
+
return (
|
|
215
|
+
f"Run B improves quality on {improved}/{comparable} shared combo(s).{only_note}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Test-level diff (the --detailed view)
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
#
|
|
223
|
+
# Outcome of a single case, read the same way analysis.py distinguishes a
|
|
224
|
+
# pipeline error from a real 0: ``match_score is None`` means the judge
|
|
225
|
+
# API/parse failed — it is an "error", never a real fail.
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _state(tc) -> str:
|
|
229
|
+
"""One of ``"pass" | "fail" | "error"`` for a test-case result dict."""
|
|
230
|
+
if tc.get("match_score") is None:
|
|
231
|
+
return "error"
|
|
232
|
+
return "pass" if tc.get("match_result") else "fail"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _classify_case(a, b) -> str:
|
|
236
|
+
"""Transition label for a case present in both runs (A → B)."""
|
|
237
|
+
sa, sb = _state(a), _state(b)
|
|
238
|
+
if sa == "error" and sb == "error":
|
|
239
|
+
return "still_error"
|
|
240
|
+
if sa != "error" and sb == "error":
|
|
241
|
+
return "new_error"
|
|
242
|
+
if sa == "error" and sb != "error":
|
|
243
|
+
return "recovered"
|
|
244
|
+
# Both scored.
|
|
245
|
+
if sa == "pass" and sb == "fail":
|
|
246
|
+
return "regressed"
|
|
247
|
+
if sa == "fail" and sb == "pass":
|
|
248
|
+
return "improved"
|
|
249
|
+
return "unchanged"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# Transitions the reporter lists as a "change"; ``unchanged``/``still_error``
|
|
253
|
+
# collapse into the footer count.
|
|
254
|
+
CHANGED_TRANSITIONS = {
|
|
255
|
+
"regressed",
|
|
256
|
+
"improved",
|
|
257
|
+
"new_error",
|
|
258
|
+
"recovered",
|
|
259
|
+
"added",
|
|
260
|
+
"removed",
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def case_diff(combo_a, combo_b) -> dict:
|
|
265
|
+
"""Per-test-case transitions within one shared ``(model, prompt)`` combo.
|
|
266
|
+
|
|
267
|
+
Indexes each combo's ``test_cases`` by ``id`` and classifies the union. Each
|
|
268
|
+
row is ``{id, tags, transition, a_state, b_state, score_a, score_b,
|
|
269
|
+
score_delta, present_in}``; ``rollup`` tallies every transition kind.
|
|
270
|
+
"""
|
|
271
|
+
by_id_a = {tc["id"]: tc for tc in combo_a.get("test_cases", [])}
|
|
272
|
+
by_id_b = {tc["id"]: tc for tc in combo_b.get("test_cases", [])}
|
|
273
|
+
|
|
274
|
+
rollup = {
|
|
275
|
+
k: 0
|
|
276
|
+
for k in (
|
|
277
|
+
"regressed",
|
|
278
|
+
"improved",
|
|
279
|
+
"unchanged",
|
|
280
|
+
"new_error",
|
|
281
|
+
"recovered",
|
|
282
|
+
"still_error",
|
|
283
|
+
"added",
|
|
284
|
+
"removed",
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
rows = []
|
|
288
|
+
for case_id in sorted(set(by_id_a) | set(by_id_b)):
|
|
289
|
+
a = by_id_a.get(case_id)
|
|
290
|
+
b = by_id_b.get(case_id)
|
|
291
|
+
if a and b:
|
|
292
|
+
transition = _classify_case(a, b)
|
|
293
|
+
present_in = "both"
|
|
294
|
+
sa, sb = _state(a), _state(b)
|
|
295
|
+
score_a, score_b = a.get("match_score"), b.get("match_score")
|
|
296
|
+
score_delta = (
|
|
297
|
+
score_b - score_a
|
|
298
|
+
if score_a is not None and score_b is not None
|
|
299
|
+
else None
|
|
300
|
+
)
|
|
301
|
+
tags = b.get("tags") or a.get("tags") or []
|
|
302
|
+
elif b:
|
|
303
|
+
transition, present_in = "added", "b_only"
|
|
304
|
+
sa, sb = None, _state(b)
|
|
305
|
+
score_a, score_b, score_delta = None, b.get("match_score"), None
|
|
306
|
+
tags = b.get("tags") or []
|
|
307
|
+
else:
|
|
308
|
+
assert a is not None # union guarantees the case is in A when not in B
|
|
309
|
+
transition, present_in = "removed", "a_only"
|
|
310
|
+
sa, sb = _state(a), None
|
|
311
|
+
score_a, score_b, score_delta = a.get("match_score"), None, None
|
|
312
|
+
tags = a.get("tags") or []
|
|
313
|
+
rollup[transition] += 1
|
|
314
|
+
rows.append(
|
|
315
|
+
{
|
|
316
|
+
"id": case_id,
|
|
317
|
+
"tags": tags,
|
|
318
|
+
"transition": transition,
|
|
319
|
+
"a_state": sa,
|
|
320
|
+
"b_state": sb,
|
|
321
|
+
"score_a": score_a,
|
|
322
|
+
"score_b": score_b,
|
|
323
|
+
"score_delta": score_delta,
|
|
324
|
+
"present_in": present_in,
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
return {"rows": rows, "rollup": rollup}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def detailed_diff(record_a, record_b) -> dict:
|
|
331
|
+
"""Combo diff plus a per-case breakdown and a global summary.
|
|
332
|
+
|
|
333
|
+
Extends :func:`diff` with ``case_combos`` (one entry per *shared* combo,
|
|
334
|
+
carrying its :func:`case_diff`) and ``summary`` — change totals + verdict,
|
|
335
|
+
the cases that regressed across the most combos, and net change per tag.
|
|
336
|
+
"""
|
|
337
|
+
result = diff(record_a, record_b)
|
|
338
|
+
index_a = _index(record_a)
|
|
339
|
+
index_b = _index(record_b)
|
|
340
|
+
|
|
341
|
+
case_combos = []
|
|
342
|
+
totals = {"regressed": 0, "improved": 0, "new_error": 0, "recovered": 0}
|
|
343
|
+
regressed_by_id: dict[str, int] = {}
|
|
344
|
+
tag_net: dict[str, int] = {}
|
|
345
|
+
|
|
346
|
+
for row in result["rows"]:
|
|
347
|
+
if row["present_in"] != "both":
|
|
348
|
+
continue
|
|
349
|
+
key = (row["model"], row["prompt_id"])
|
|
350
|
+
cd = case_diff(index_a[key], index_b[key])
|
|
351
|
+
case_combos.append({"model": key[0], "prompt_id": key[1], **cd})
|
|
352
|
+
for k in totals:
|
|
353
|
+
totals[k] += cd["rollup"][k]
|
|
354
|
+
for case_row in cd["rows"]:
|
|
355
|
+
if case_row["transition"] == "regressed":
|
|
356
|
+
regressed_by_id[case_row["id"]] = (
|
|
357
|
+
regressed_by_id.get(case_row["id"], 0) + 1
|
|
358
|
+
)
|
|
359
|
+
for tag in case_row["tags"]:
|
|
360
|
+
tag_net[tag] = tag_net.get(tag, 0) - 1
|
|
361
|
+
elif case_row["transition"] == "improved":
|
|
362
|
+
for tag in case_row["tags"]:
|
|
363
|
+
tag_net[tag] = tag_net.get(tag, 0) + 1
|
|
364
|
+
|
|
365
|
+
worst_regressed = [
|
|
366
|
+
{"id": case_id, "count": n}
|
|
367
|
+
for case_id, n in sorted(
|
|
368
|
+
regressed_by_id.items(), key=lambda kv: (-kv[1], kv[0])
|
|
369
|
+
)
|
|
370
|
+
]
|
|
371
|
+
tag_net_rows = [
|
|
372
|
+
{"tag": tag, "net": net}
|
|
373
|
+
for tag, net in sorted(tag_net.items(), key=lambda kv: (kv[1], kv[0]))
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
result["case_combos"] = case_combos
|
|
377
|
+
result["summary"] = {
|
|
378
|
+
**totals,
|
|
379
|
+
"net": totals["improved"] - totals["regressed"],
|
|
380
|
+
"worst_regressed": worst_regressed,
|
|
381
|
+
"tag_net": tag_net_rows,
|
|
382
|
+
"verdict": _case_verdict(totals),
|
|
383
|
+
}
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _case_verdict(totals) -> str:
|
|
388
|
+
"""One-line synthesis of the test-level change counts."""
|
|
389
|
+
net = totals["improved"] - totals["regressed"]
|
|
390
|
+
if not any(totals.values()):
|
|
391
|
+
return "No test-case changes across shared combos."
|
|
392
|
+
if net > 0:
|
|
393
|
+
direction = f"net +{net} case(s) improved"
|
|
394
|
+
elif net < 0:
|
|
395
|
+
direction = f"net {net} case(s) regressed"
|
|
396
|
+
else:
|
|
397
|
+
direction = "no net quality change"
|
|
398
|
+
err = ""
|
|
399
|
+
if totals["new_error"]:
|
|
400
|
+
err = f"; {totals['new_error']} new pipeline error(s)"
|
|
401
|
+
return (
|
|
402
|
+
f"Run B: {totals['improved']} improved, {totals['regressed']} regressed "
|
|
403
|
+
f"({direction}){err}."
|
|
404
|
+
)
|