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 ADDED
@@ -0,0 +1,3 @@
1
+ """Run an LLM eval suite, cache every call, and compare two runs item by item with intervals."""
2
+
3
+ __version__ = "0.1.0"
evalpair/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ }