evalpair 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.
- evalpair/__init__.py +3 -0
- evalpair/__main__.py +4 -0
- evalpair/analysis.py +141 -0
- evalpair/backends.py +168 -0
- evalpair/checkers.py +252 -0
- evalpair/cli.py +164 -0
- evalpair/runner.py +67 -0
- evalpair/server.py +116 -0
- evalpair/stats.py +52 -0
- evalpair/store.py +86 -0
- evalpair/suites/e2e-extract-150/items.jsonl +150 -0
- evalpair/suites/e2e-extract-150/suite.toml +6 -0
- evalpair/suites/gsm8k-160/items.jsonl +160 -0
- evalpair/suites/gsm8k-160/suite.toml +6 -0
- evalpair/suites/mmlu-hs-160/items.jsonl +160 -0
- evalpair/suites/mmlu-hs-160/suite.toml +7 -0
- evalpair/suites.py +147 -0
- evalpair-0.1.0.dist-info/METADATA +189 -0
- evalpair-0.1.0.dist-info/RECORD +22 -0
- evalpair-0.1.0.dist-info/WHEEL +4 -0
- evalpair-0.1.0.dist-info/entry_points.txt +3 -0
- evalpair-0.1.0.dist-info/licenses/LICENSE +21 -0
evalpair/__init__.py
ADDED
evalpair/__main__.py
ADDED
evalpair/analysis.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Summaries of one run and comparisons of two, built from saved rows only (no model calls).
|
|
2
|
+
|
|
3
|
+
A comparison pairs the two runs by item id over the items both answered (first pass). It reports the
|
|
4
|
+
score difference with a paired bootstrap interval, how many items flipped each way, how often the answer
|
|
5
|
+
changed at all, and, when a run was made with a repeat pass, that run's own answer-change rate: the noise
|
|
6
|
+
floor. A change rate between two runs only means something above the floor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .stats import excludes_zero, mean, mean_ci
|
|
12
|
+
|
|
13
|
+
EXCERPT = 400
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _pp(x: float) -> str:
|
|
17
|
+
return f"{100 * x:+.1f} pp"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pc(x: float) -> str:
|
|
21
|
+
return f"{100 * x:.1f}%"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ci(ci: dict, pct=_pc) -> dict:
|
|
25
|
+
return {**{k: round(v, 4) for k, v in ci.items() if k != "n"}, "n": ci["n"],
|
|
26
|
+
"text": f"{pct(ci['mean'])} [{pct(ci['lo'])}, {pct(ci['hi'])}]" if ci["n"] else "no items"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def by_pass(rows: list[dict], p: int) -> dict[str, dict]:
|
|
30
|
+
return {r["id"]: r for r in rows if r["pass"] == p}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def excerpt(text: str, n: int = EXCERPT) -> str:
|
|
34
|
+
text = text or ""
|
|
35
|
+
return text if len(text) <= n else "…" + text[-n:]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def noise_floor(rows: list[dict]) -> dict | None:
|
|
39
|
+
"""Share of items whose parsed answer changed between pass 1 and the repeat pass, and the score
|
|
40
|
+
change between them. None if the run had no repeat."""
|
|
41
|
+
a, b = by_pass(rows, 1), by_pass(rows, 2)
|
|
42
|
+
ids = [i for i in a if i in b]
|
|
43
|
+
if not ids:
|
|
44
|
+
return None
|
|
45
|
+
changed = [float(a[i]["answer"] != b[i]["answer"]) for i in ids]
|
|
46
|
+
groups = [a[i]["group"] for i in ids]
|
|
47
|
+
return {"items": len(ids), "answers_changed": int(sum(changed)),
|
|
48
|
+
"change_rate": _ci(mean_ci(changed, groups)),
|
|
49
|
+
"score_change": _ci(mean_ci([b[i]["score"] - a[i]["score"] for i in ids], groups), _pp)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def summarize(meta: dict, rows: list[dict], failures: int = 5) -> dict:
|
|
53
|
+
first = list(by_pass(rows, 1).values())
|
|
54
|
+
groups = [r["group"] for r in first]
|
|
55
|
+
wrong = [r for r in first if r["score"] < 1]
|
|
56
|
+
out = {
|
|
57
|
+
"run": meta["run"], "suite": meta["suite"], "model": meta["model"], "backend": meta["backend"],
|
|
58
|
+
"items": len(first), "score": _ci(mean_ci([r["score"] for r in first], groups)),
|
|
59
|
+
"no_answer": sum(r["answer"] is None for r in first), "truncated": sum(r["truncated"] for r in first),
|
|
60
|
+
"mean_output_tokens": round(mean(r["output_tokens"] for r in first), 1),
|
|
61
|
+
"noise_floor": noise_floor(rows),
|
|
62
|
+
"calls": meta.get("calls"), "cache_hits": meta.get("cache_hits"), "finished": meta.get("finished"),
|
|
63
|
+
}
|
|
64
|
+
if any(r["group"] is not None for r in first):
|
|
65
|
+
per = {}
|
|
66
|
+
for g in sorted({r["group"] for r in first}, key=str):
|
|
67
|
+
rs = [r for r in first if r["group"] == g]
|
|
68
|
+
per[str(g)] = {"items": len(rs), "score": round(mean(r["score"] for r in rs), 4)}
|
|
69
|
+
out["by_group"] = per
|
|
70
|
+
# unparsed and truncated replies first: they are usually the checker's or the budget's fault, not the model's
|
|
71
|
+
wrong.sort(key=lambda r: (r["answer"] is not None, not r["truncated"], r["id"]))
|
|
72
|
+
out["failures"] = [{"id": r["id"], "answer": r["answer"], "score": r["score"], "how": r["how"],
|
|
73
|
+
"truncated": r["truncated"], "reply_end": excerpt(r["text"])} for r in wrong[:failures]]
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compare(meta_a: dict, rows_a: list[dict], meta_b: dict, rows_b: list[dict], examples: int = 5) -> dict:
|
|
78
|
+
a, b = by_pass(rows_a, 1), by_pass(rows_b, 1)
|
|
79
|
+
ids = [i for i in a if i in b]
|
|
80
|
+
notes = []
|
|
81
|
+
if meta_a.get("suite_hash") != meta_b.get("suite_hash"):
|
|
82
|
+
notes.append("The two runs used different versions of the suite (prompt, checker or items differ); "
|
|
83
|
+
"only items with the same id are paired, and a difference may come from the suite.")
|
|
84
|
+
only = len(a) - len(ids), len(b) - len(ids)
|
|
85
|
+
if any(only):
|
|
86
|
+
notes.append(f"Paired on the {len(ids)} items both runs answered ({only[0]} only in A, {only[1]} only in B).")
|
|
87
|
+
if not ids:
|
|
88
|
+
return {"a": meta_a["run"], "b": meta_b["run"], "items": 0, "notes": notes + ["No items in common."]}
|
|
89
|
+
groups = [a[i]["group"] for i in ids]
|
|
90
|
+
sa, sb = [a[i]["score"] for i in ids], [b[i]["score"] for i in ids]
|
|
91
|
+
diff = mean_ci([y - x for x, y in zip(sa, sb)], groups)
|
|
92
|
+
worse = [i for i in ids if b[i]["score"] < a[i]["score"]]
|
|
93
|
+
better = [i for i in ids if b[i]["score"] > a[i]["score"]]
|
|
94
|
+
changed = [float(a[i]["answer"] != b[i]["answer"]) for i in ids]
|
|
95
|
+
out = {
|
|
96
|
+
"a": {"run": meta_a["run"], "model": meta_a["model"], "score": _ci(mean_ci(sa, groups))},
|
|
97
|
+
"b": {"run": meta_b["run"], "model": meta_b["model"], "score": _ci(mean_ci(sb, groups))},
|
|
98
|
+
"items": len(ids),
|
|
99
|
+
"difference": _ci(diff, _pp),
|
|
100
|
+
"worse_in_b": len(worse), "better_in_b": len(better),
|
|
101
|
+
"answers_changed": _ci(mean_ci(changed, groups)),
|
|
102
|
+
}
|
|
103
|
+
floors = {}
|
|
104
|
+
for name, rows in (("a", rows_a), ("b", rows_b)):
|
|
105
|
+
rep, p1 = by_pass(rows, 2), by_pass(rows, 1)
|
|
106
|
+
common = [i for i in ids if i in rep]
|
|
107
|
+
if common:
|
|
108
|
+
# per item: changed between the runs, minus changed between this run's own two passes
|
|
109
|
+
excess = [float(a[i]["answer"] != b[i]["answer"]) - float(p1[i]["answer"] != rep[i]["answer"])
|
|
110
|
+
for i in common]
|
|
111
|
+
floors[name] = {"items": len(common),
|
|
112
|
+
"change_rate": _ci(mean_ci([float(p1[i]["answer"] != rep[i]["answer"]) for i in common],
|
|
113
|
+
[a[i]["group"] for i in common])),
|
|
114
|
+
"excess_over_floor": _ci(mean_ci(excess, [a[i]["group"] for i in common]), _pp)}
|
|
115
|
+
out["noise_floor"] = floors or None
|
|
116
|
+
if excludes_zero(diff):
|
|
117
|
+
verdict = (f"B scores {'higher' if diff['mean'] > 0 else 'lower'} than A: "
|
|
118
|
+
f"{_pp(diff['mean'])} [{_pp(diff['lo'])}, {_pp(diff['hi'])}], paired over {len(ids)} items.")
|
|
119
|
+
else:
|
|
120
|
+
verdict = (f"No clear difference in score: {_pp(diff['mean'])} [{_pp(diff['lo'])}, {_pp(diff['hi'])}], "
|
|
121
|
+
f"an interval that includes zero, over {len(ids)} items.")
|
|
122
|
+
ch = out["answers_changed"]
|
|
123
|
+
verdict += f" Answers changed on {_pc(ch['mean'])} of items ({len(worse)} got worse, {len(better)} better)."
|
|
124
|
+
if floors:
|
|
125
|
+
# beyond noise only if it clears every floor measured, so a quiet run can't hide a noisy one
|
|
126
|
+
beyond = all(f["excess_over_floor"]["lo"] > 0 for f in floors.values())
|
|
127
|
+
rates = " and ".join(f"{_pc(f['change_rate']['mean'])} for {k.upper()}" for k, f in floors.items())
|
|
128
|
+
verdict += (f" The noise floor (the same model asked twice) is {rates}, so "
|
|
129
|
+
+ ("the change is beyond noise." if beyond else "the change is not clearly beyond noise."))
|
|
130
|
+
else:
|
|
131
|
+
verdict += " Neither run has a repeat pass, so there is no noise floor to compare against (run with repeat)."
|
|
132
|
+
out["verdict"] = verdict
|
|
133
|
+
out["notes"] = notes
|
|
134
|
+
|
|
135
|
+
def ex_rows(ids_):
|
|
136
|
+
return [{"id": i, "a_answer": a[i]["answer"], "b_answer": b[i]["answer"],
|
|
137
|
+
"a_score": a[i]["score"], "b_score": b[i]["score"],
|
|
138
|
+
"a_reply_end": excerpt(a[i]["text"], 240), "b_reply_end": excerpt(b[i]["text"], 240)}
|
|
139
|
+
for i in ids_[:examples]]
|
|
140
|
+
out["examples"] = {"worse_in_b": ex_rows(worse), "better_in_b": ex_rows(better)}
|
|
141
|
+
return out
|
evalpair/backends.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Model backends and the call cache.
|
|
2
|
+
|
|
3
|
+
Two backends: Ollama's native /api/chat, and any OpenAI-compatible /v1/chat/completions server (vLLM,
|
|
4
|
+
llama.cpp's server, LM Studio, or a hosted API). Every reply is cached in SQLite under a hash of
|
|
5
|
+
(backend, base URL, model, messages, options, tag), so re-running a suite or re-scoring it costs nothing.
|
|
6
|
+
`tag` is not sent to the model; it gives a repeat pass its own cache entry, so the repeat is a real second
|
|
7
|
+
call and the difference between the two passes is the noise floor.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
|
|
22
|
+
THINK_RE = re.compile(r"<think>.*?</think>", re.S)
|
|
23
|
+
DEFAULT_URLS = {"ollama": "http://localhost:11434", "openai": "http://localhost:8000/v1"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CacheMiss(RuntimeError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Cache:
|
|
35
|
+
def __init__(self, path: str):
|
|
36
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
37
|
+
self.lock = threading.Lock()
|
|
38
|
+
self.db = sqlite3.connect(path, check_same_thread=False, timeout=60)
|
|
39
|
+
self.db.execute("create table if not exists calls (key text primary key, model text, response text)")
|
|
40
|
+
self.db.commit()
|
|
41
|
+
|
|
42
|
+
def get(self, key: str) -> dict | None:
|
|
43
|
+
with self.lock:
|
|
44
|
+
row = self.db.execute("select response from calls where key = ?", (key,)).fetchone()
|
|
45
|
+
return json.loads(row[0]) if row else None
|
|
46
|
+
|
|
47
|
+
def put(self, key: str, model: str, resp: dict) -> None:
|
|
48
|
+
with self.lock:
|
|
49
|
+
self.db.execute("insert or replace into calls values (?, ?, ?)", (key, model, json.dumps(resp)))
|
|
50
|
+
self.db.commit()
|
|
51
|
+
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
with self.lock:
|
|
54
|
+
return self.db.execute("select count(*) from calls").fetchone()[0]
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
with self.lock:
|
|
58
|
+
self.db.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _post(url: str, body: dict, headers: dict, timeout: float) -> dict:
|
|
62
|
+
req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **headers})
|
|
63
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
64
|
+
return json.load(r)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Backend:
|
|
68
|
+
"""One model on one server. `chat` returns {"text", "prompt_tokens", "output_tokens", "seconds",
|
|
69
|
+
"done_reason", "cached"}."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, kind: str, model: str, cache: Cache, base_url: str | None = None,
|
|
72
|
+
api_key: str | None = None, options: dict | None = None, think: bool | None = None,
|
|
73
|
+
cache_only: bool = False, timeout: float = 600, retries: int = 4):
|
|
74
|
+
if kind not in DEFAULT_URLS:
|
|
75
|
+
raise BackendError(f"backend must be one of {sorted(DEFAULT_URLS)}")
|
|
76
|
+
self.kind, self.model, self.cache = kind, model, cache
|
|
77
|
+
self.base_url = (base_url or DEFAULT_URLS[kind]).rstrip("/")
|
|
78
|
+
self.api_key = api_key
|
|
79
|
+
self.options = {"temperature": 0, "seed": 7, **(options or {})}
|
|
80
|
+
self.think, self.cache_only, self.timeout, self.retries = think, cache_only, timeout, retries
|
|
81
|
+
self.stats = {"hits": 0, "calls": 0}
|
|
82
|
+
self.lock = threading.Lock()
|
|
83
|
+
|
|
84
|
+
def key(self, messages: list[dict], max_tokens: int, tag: str) -> str:
|
|
85
|
+
blob = json.dumps({"backend": self.kind, "url": self.base_url, "model": self.model, "messages": messages,
|
|
86
|
+
"options": self.options, "max_tokens": max_tokens, "think": self.think, "tag": tag},
|
|
87
|
+
sort_keys=True)
|
|
88
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
89
|
+
|
|
90
|
+
def body(self, messages: list[dict], max_tokens: int) -> tuple[str, dict, dict]:
|
|
91
|
+
o = self.options
|
|
92
|
+
if self.kind == "ollama":
|
|
93
|
+
body = {"model": self.model, "messages": messages, "stream": False,
|
|
94
|
+
"options": {**o, "num_predict": max_tokens}}
|
|
95
|
+
if self.think is not None:
|
|
96
|
+
body["think"] = self.think
|
|
97
|
+
return self.base_url + "/api/chat", body, {}
|
|
98
|
+
body = {"model": self.model, "messages": messages, "max_tokens": max_tokens,
|
|
99
|
+
"temperature": o.get("temperature", 0)}
|
|
100
|
+
if "seed" in o:
|
|
101
|
+
body["seed"] = o["seed"]
|
|
102
|
+
if "top_p" in o:
|
|
103
|
+
body["top_p"] = o["top_p"]
|
|
104
|
+
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
105
|
+
return self.base_url + "/chat/completions", body, headers
|
|
106
|
+
|
|
107
|
+
def parse(self, data: dict, wall: float) -> dict:
|
|
108
|
+
if self.kind == "ollama":
|
|
109
|
+
raw = data["message"]["content"]
|
|
110
|
+
return {"text": THINK_RE.sub("", raw).strip(), "raw": raw,
|
|
111
|
+
"done_reason": data.get("done_reason", ""),
|
|
112
|
+
"prompt_tokens": data.get("prompt_eval_count", 0), "output_tokens": data.get("eval_count", 0),
|
|
113
|
+
# server-side compute time, which excludes time spent queued behind other clients
|
|
114
|
+
"seconds": (data.get("prompt_eval_duration", 0) + data.get("eval_duration", 0)) / 1e9 or wall}
|
|
115
|
+
choice = data["choices"][0]
|
|
116
|
+
raw = choice["message"].get("content") or ""
|
|
117
|
+
usage = data.get("usage") or {}
|
|
118
|
+
return {"text": THINK_RE.sub("", raw).strip(), "raw": raw,
|
|
119
|
+
"done_reason": {"length": "length"}.get(choice.get("finish_reason"), choice.get("finish_reason") or ""),
|
|
120
|
+
"prompt_tokens": usage.get("prompt_tokens", 0), "output_tokens": usage.get("completion_tokens", 0),
|
|
121
|
+
"seconds": wall}
|
|
122
|
+
|
|
123
|
+
def chat(self, messages: list[dict], max_tokens: int, tag: str = "") -> dict:
|
|
124
|
+
key = self.key(messages, max_tokens, tag)
|
|
125
|
+
hit = self.cache.get(key)
|
|
126
|
+
if hit is not None:
|
|
127
|
+
with self.lock:
|
|
128
|
+
self.stats["hits"] += 1
|
|
129
|
+
return {**hit, "cached": True}
|
|
130
|
+
if self.cache_only:
|
|
131
|
+
raise CacheMiss(key)
|
|
132
|
+
url, body, headers = self.body(messages, max_tokens)
|
|
133
|
+
for attempt in range(self.retries + 1):
|
|
134
|
+
try:
|
|
135
|
+
t0 = time.time()
|
|
136
|
+
data = _post(url, body, headers, self.timeout)
|
|
137
|
+
wall = time.time() - t0
|
|
138
|
+
break
|
|
139
|
+
except urllib.error.HTTPError as e:
|
|
140
|
+
detail = e.read().decode(errors="replace")[:300]
|
|
141
|
+
# a request the server rejects won't succeed on retry; rate limits and 5xx might
|
|
142
|
+
if e.code < 500 and e.code != 429 or attempt == self.retries:
|
|
143
|
+
raise BackendError(f"{self.kind} {self.model}: HTTP {e.code}: {detail}") from None
|
|
144
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
145
|
+
if attempt == self.retries:
|
|
146
|
+
raise BackendError(f"{self.kind} at {self.base_url} unreachable: {e}") from None
|
|
147
|
+
time.sleep(min(2 ** attempt, 30))
|
|
148
|
+
try:
|
|
149
|
+
resp = self.parse(data, wall)
|
|
150
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
151
|
+
raise BackendError(f"{self.kind} {self.model}: unexpected reply shape ({e!r})") from None
|
|
152
|
+
self.cache.put(key, self.model, resp)
|
|
153
|
+
with self.lock:
|
|
154
|
+
self.stats["calls"] += 1
|
|
155
|
+
return {**resp, "cached": False}
|
|
156
|
+
|
|
157
|
+
def describe(self) -> dict:
|
|
158
|
+
"""What identifies the model, for the run record. For Ollama, the model digest."""
|
|
159
|
+
out = {"backend": self.kind, "model": self.model, "base_url": self.base_url}
|
|
160
|
+
if self.kind == "ollama":
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(self.base_url + "/api/tags", timeout=30) as r:
|
|
163
|
+
tags = json.load(r)
|
|
164
|
+
m = next((m for m in tags.get("models", []) if m.get("name") in (self.model, self.model + ":latest")), {})
|
|
165
|
+
out["digest"] = m.get("digest", "")
|
|
166
|
+
except (urllib.error.URLError, TimeoutError, ValueError):
|
|
167
|
+
out["digest"] = ""
|
|
168
|
+
return out
|
evalpair/checkers.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Deterministic answer checkers. Each returns a Check: the parsed answer, a score in [0, 1], and how the
|
|
2
|
+
answer was found, so a wrong score can be traced to the parser rather than the model.
|
|
3
|
+
|
|
4
|
+
The number and choice parsers come from the Measuring LLM Systems studies (prompt-brittleness, format-tax,
|
|
5
|
+
groupthink, quant-cliff): an "Answer:" cue first, then fallbacks. A reply with no usable answer scores 0.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
NUM = r"-?\d[\d,]*(?:\.\d+)?"
|
|
17
|
+
CUE = r"(?i:final answer|answer|a)"
|
|
18
|
+
NULLS = {"", "null", "none", "n a", "na", "unknown", "not given", "not specified", "not provided"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Check:
|
|
23
|
+
answer: str | None
|
|
24
|
+
score: float
|
|
25
|
+
how: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def norm(s: Any) -> str:
|
|
29
|
+
"""Casefolded, accents stripped, punctuation (all but £, as in the studies) to spaces, whitespace collapsed."""
|
|
30
|
+
s = unicodedata.normalize("NFKD", str(s))
|
|
31
|
+
s = "".join(ch for ch in s if not unicodedata.combining(ch)).casefold()
|
|
32
|
+
return re.sub(r"\s+", " ", re.sub(r"[^\w£ ]", " ", s)).strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean(text: str) -> str:
|
|
36
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
|
37
|
+
return text.replace("**", "").replace("\\(", "").replace("\\)", "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _join_groups(s: str) -> str:
|
|
41
|
+
"""Read "9 500" (a space, no-break or thin space between thousands) as "9500". Used only on an
|
|
42
|
+
answer line or a \\boxed{}, where a space between digit groups is one number, not two."""
|
|
43
|
+
return re.sub(r"(?<=\d)[ \u00a0\u202f](?=\d{3}\b)", "", s)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def canon_number(s: Any) -> str | None:
|
|
47
|
+
s = str(s).replace(",", "").replace("$", "").strip()
|
|
48
|
+
try:
|
|
49
|
+
x = float(s)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
if x != x or x in (float("inf"), float("-inf")):
|
|
53
|
+
return None
|
|
54
|
+
if x == int(x):
|
|
55
|
+
return str(int(x))
|
|
56
|
+
return f"{x:.6g}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_number(text: str) -> tuple[str | None, str]:
|
|
60
|
+
t = _clean(text)
|
|
61
|
+
cues = list(re.finditer(rf"(?m)^\s*[#>*\- ]*{CUE}\s*[::]\s*(.*)$", t))
|
|
62
|
+
if cues:
|
|
63
|
+
m = re.search(NUM, _join_groups(cues[-1].group(1).replace("$", "")))
|
|
64
|
+
if m:
|
|
65
|
+
return canon_number(m.group(0)), "cue"
|
|
66
|
+
boxed = re.findall(r"\\boxed\{([^{}]*)\}", t)
|
|
67
|
+
if boxed:
|
|
68
|
+
m = re.search(NUM, _join_groups(boxed[-1]))
|
|
69
|
+
if m:
|
|
70
|
+
return canon_number(m.group(0)), "boxed"
|
|
71
|
+
nums = re.findall(NUM, t.replace("$", ""))
|
|
72
|
+
if nums:
|
|
73
|
+
return canon_number(nums[-1]), "last number"
|
|
74
|
+
return None, "none"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _letter_from_text(rest: str, options: list[str] | None, letters: str) -> str | None:
|
|
78
|
+
if not options:
|
|
79
|
+
return None
|
|
80
|
+
rest = norm(rest)
|
|
81
|
+
hits = [(len(norm(o)), i) for i, o in enumerate(options) if norm(o) and rest.startswith(norm(o))]
|
|
82
|
+
return letters[max(hits)[1]] if hits else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_choice(text: str, options: list[str] | None = None, letters: str = "ABCD") -> tuple[str | None, str]:
|
|
86
|
+
t = _clean(text).strip()
|
|
87
|
+
L = f"[{letters}]"
|
|
88
|
+
cues = re.findall(rf"(?m)^\s*[#>*\- ]*{CUE}\s*[::]\s*\(?({L})\b", t)
|
|
89
|
+
if cues:
|
|
90
|
+
return cues[-1], "cue"
|
|
91
|
+
m = re.search(rf"\b(?i:answer|option|choice) is\s*:?\s*\(?({L})\b", t)
|
|
92
|
+
if m:
|
|
93
|
+
return m.group(1), "answer is"
|
|
94
|
+
m = re.fullmatch(rf"\(?({L})\)?[.:)]?", t)
|
|
95
|
+
if m:
|
|
96
|
+
return m.group(1), "bare letter"
|
|
97
|
+
m = re.match(rf"\(?({L})[.):]\s", t + " ")
|
|
98
|
+
if m:
|
|
99
|
+
return m.group(1), "leading letter"
|
|
100
|
+
m = re.search(rf"\b(?i:answer)\s*[::]?\s*\(?({L})\b", t)
|
|
101
|
+
if m:
|
|
102
|
+
return m.group(1), "inline cue"
|
|
103
|
+
cues = re.findall(rf"(?m)^\s*[#>*\- ]*{CUE}\s*[::]\s*(.+)$", t)
|
|
104
|
+
if cues:
|
|
105
|
+
a = _letter_from_text(cues[-1], options, letters)
|
|
106
|
+
if a:
|
|
107
|
+
return a, "option text"
|
|
108
|
+
return None, "none"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _json_objects(t: str) -> list[str]:
|
|
112
|
+
"""Top-level {...} spans, skipping braces inside strings."""
|
|
113
|
+
out, depth, start, in_str, esc = [], 0, None, False, False
|
|
114
|
+
for i, ch in enumerate(t):
|
|
115
|
+
if in_str:
|
|
116
|
+
if esc:
|
|
117
|
+
esc = False
|
|
118
|
+
elif ch == "\\":
|
|
119
|
+
esc = True
|
|
120
|
+
elif ch == '"':
|
|
121
|
+
in_str = False
|
|
122
|
+
continue
|
|
123
|
+
if ch == '"' and depth:
|
|
124
|
+
in_str = True
|
|
125
|
+
elif ch == "{":
|
|
126
|
+
if depth == 0:
|
|
127
|
+
start = i
|
|
128
|
+
depth += 1
|
|
129
|
+
elif ch == "}" and depth:
|
|
130
|
+
depth -= 1
|
|
131
|
+
if depth == 0:
|
|
132
|
+
out.append(t[start:i + 1])
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_json(text: str) -> tuple[dict | None, bool]:
|
|
137
|
+
"""(object, strict). `strict` is True when the whole reply is exactly one JSON object; otherwise the
|
|
138
|
+
first JSON object anywhere in the reply, so code fences and a sentence around it are tolerated."""
|
|
139
|
+
t = _clean(text).strip()
|
|
140
|
+
try:
|
|
141
|
+
obj = json.loads(t)
|
|
142
|
+
if isinstance(obj, dict):
|
|
143
|
+
return obj, True
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
pass
|
|
146
|
+
for blob in _json_objects(t):
|
|
147
|
+
try:
|
|
148
|
+
obj = json.loads(blob)
|
|
149
|
+
except json.JSONDecodeError:
|
|
150
|
+
continue
|
|
151
|
+
if isinstance(obj, dict):
|
|
152
|
+
return obj, False
|
|
153
|
+
return None, False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _is_null(x) -> bool:
|
|
157
|
+
return x is None or (isinstance(x, str) and norm(x) in NULLS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _as_bool(x):
|
|
161
|
+
if isinstance(x, bool):
|
|
162
|
+
return x
|
|
163
|
+
s = norm(x)
|
|
164
|
+
if s in ("yes", "true", "y"):
|
|
165
|
+
return True
|
|
166
|
+
if s in ("no", "false", "n"):
|
|
167
|
+
return False
|
|
168
|
+
return "other"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def field_ok(pred, gold) -> bool:
|
|
172
|
+
if gold is None:
|
|
173
|
+
return _is_null(pred)
|
|
174
|
+
if _is_null(pred):
|
|
175
|
+
return False
|
|
176
|
+
if isinstance(gold, bool):
|
|
177
|
+
return _as_bool(pred) == gold
|
|
178
|
+
if isinstance(gold, (int, float)):
|
|
179
|
+
return canon_number(pred) == canon_number(gold)
|
|
180
|
+
return norm(pred) == norm(gold)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------- the checkers a suite can name
|
|
184
|
+
|
|
185
|
+
def check_number(text: str, item: dict, cfg: dict) -> Check:
|
|
186
|
+
a, how = parse_number(text)
|
|
187
|
+
return Check(a, float(a is not None and a == canon_number(item["gold"])), how)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_choice(text: str, item: dict, cfg: dict) -> Check:
|
|
191
|
+
letters = cfg.get("letters", "ABCD")
|
|
192
|
+
a, how = parse_choice(text, item.get("options"), letters)
|
|
193
|
+
return Check(a, float(a is not None and a == str(item["gold"]).strip().upper()), how)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_exact(text: str, item: dict, cfg: dict) -> Check:
|
|
197
|
+
"""The last non-empty line after any "Answer:" cue, compared after normalisation."""
|
|
198
|
+
t = _clean(text).strip()
|
|
199
|
+
cues = re.findall(rf"(?m)^\s*[#>*\- ]*{CUE}\s*[::]\s*(.+)$", t)
|
|
200
|
+
if cues:
|
|
201
|
+
a, how = cues[-1].strip(), "cue"
|
|
202
|
+
else:
|
|
203
|
+
lines = [x for x in t.splitlines() if x.strip()]
|
|
204
|
+
a, how = (lines[-1].strip(), "last line") if lines else (None, "none")
|
|
205
|
+
golds = item["gold"] if isinstance(item["gold"], list) else [item["gold"]]
|
|
206
|
+
ok = a is not None and norm(a) in {norm(g) for g in golds}
|
|
207
|
+
return Check(a, float(ok), how)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_contains(text: str, item: dict, cfg: dict) -> Check:
|
|
211
|
+
golds = item["gold"] if isinstance(item["gold"], list) else [item["gold"]]
|
|
212
|
+
t = norm(_clean(text))
|
|
213
|
+
hit = next((g for g in golds if norm(g) and f" {norm(g)} " in f" {t} "), None)
|
|
214
|
+
return Check(hit, float(hit is not None), "contains" if hit else "none")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_regex(text: str, item: dict, cfg: dict) -> Check:
|
|
218
|
+
"""The suite's `pattern` (first group, or the whole match), last match wins."""
|
|
219
|
+
pat = cfg.get("pattern")
|
|
220
|
+
if not pat:
|
|
221
|
+
raise ValueError("the regex checker needs a 'pattern' in the suite's [checker] table")
|
|
222
|
+
ms = list(re.finditer(pat, _clean(text), flags=re.M))
|
|
223
|
+
if not ms:
|
|
224
|
+
return Check(None, 0.0, "none")
|
|
225
|
+
m = ms[-1]
|
|
226
|
+
a = (m.group(1) if m.groups() else m.group(0)).strip()
|
|
227
|
+
golds = item["gold"] if isinstance(item["gold"], list) else [item["gold"]]
|
|
228
|
+
return Check(a, float(norm(a) in {norm(g) for g in golds}), "regex")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def check_json_fields(text: str, item: dict, cfg: dict) -> Check:
|
|
232
|
+
"""Share of the gold object's fields the reply gets right. A field whose gold is null must be null,
|
|
233
|
+
missing or a null-like string in the reply. Booleans accept yes/no and true/false."""
|
|
234
|
+
gold = item["gold"]
|
|
235
|
+
if not isinstance(gold, dict) or not gold:
|
|
236
|
+
raise ValueError(f"item {item.get('id')}: the json_fields checker needs a non-empty object as gold")
|
|
237
|
+
obj, strict = parse_json(text)
|
|
238
|
+
if obj is None:
|
|
239
|
+
return Check(None, 0.0, "no json")
|
|
240
|
+
ok = {f: field_ok(obj.get(f), g) for f, g in gold.items()}
|
|
241
|
+
answer = json.dumps({f: (None if _is_null(obj.get(f)) else str(obj.get(f))) for f in gold}, sort_keys=True)
|
|
242
|
+
return Check(answer, sum(ok.values()) / len(ok), "strict json" if strict else "json in text")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
CHECKERS: dict[str, Callable[[str, dict, dict], Check]] = {
|
|
246
|
+
"number": check_number,
|
|
247
|
+
"choice": check_choice,
|
|
248
|
+
"exact": check_exact,
|
|
249
|
+
"contains": check_contains,
|
|
250
|
+
"regex": check_regex,
|
|
251
|
+
"json_fields": check_json_fields,
|
|
252
|
+
}
|