validbutwrong 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.
- validbutwrong/__init__.py +9 -0
- validbutwrong/arms.py +67 -0
- validbutwrong/cli.py +155 -0
- validbutwrong/data/SOURCES.md +9 -0
- validbutwrong/data/bfcl_v3_simple.jsonl +400 -0
- validbutwrong/data/bfcl_v3_simple_possible_answer.jsonl +400 -0
- validbutwrong/data/gsm8k_test_first200.jsonl +200 -0
- validbutwrong/engines.py +149 -0
- validbutwrong/metrics.py +122 -0
- validbutwrong/replays/bfcl-simple.jsonl +201 -0
- validbutwrong/replays/gsm8k.jsonl +201 -0
- validbutwrong/report.py +139 -0
- validbutwrong/run.py +96 -0
- validbutwrong/schema.py +66 -0
- validbutwrong/tasks.py +277 -0
- validbutwrong-0.1.0.dist-info/METADATA +264 -0
- validbutwrong-0.1.0.dist-info/RECORD +22 -0
- validbutwrong-0.1.0.dist-info/WHEEL +5 -0
- validbutwrong-0.1.0.dist-info/entry_points.txt +2 -0
- validbutwrong-0.1.0.dist-info/licenses/LICENSE +21 -0
- validbutwrong-0.1.0.dist-info/licenses/NOTICE +20 -0
- validbutwrong-0.1.0.dist-info/top_level.txt +1 -0
validbutwrong/report.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Build the report: JSON (machine), markdown (human), SVG quadrant chart (README/CI artifact)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from . import metrics as M
|
|
9
|
+
from .tasks import Item, Task
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def comparisons(keys: list[str]) -> list[tuple[str, str, str]]:
|
|
13
|
+
"""(from, to, role). The headline pairs toggle ONLY the grammar at a fixed output contract."""
|
|
14
|
+
out = []
|
|
15
|
+
for k in keys:
|
|
16
|
+
if k.startswith("constrained["):
|
|
17
|
+
out.append((k.replace("constrained[", "prompt_json[", 1), k, "headline: grammar off -> on, same contract"))
|
|
18
|
+
for k in keys:
|
|
19
|
+
if k.startswith("constrained[") or k.startswith("reason_then_constrain["):
|
|
20
|
+
out.append(("unconstrained", k, "secondary: also changes whether the model reasons in text (confounded)"))
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build(task: Task, items: list[Item], records: list[dict], meta: dict, mode: str) -> dict[str, Any]:
|
|
25
|
+
excluded = M.excluded_ids(records)
|
|
26
|
+
scored = [it for it in items if it.id not in set(excluded)]
|
|
27
|
+
states = M.score_run(task, items, records)
|
|
28
|
+
keys = list(states)
|
|
29
|
+
return {
|
|
30
|
+
"task": task.name, "correctness_label": task.correctness_label, "n": len(scored), "mode": mode,
|
|
31
|
+
"excluded": {"n": len(excluded), "ids": excluded,
|
|
32
|
+
"reason": "the decoding backend could not compile the schema"},
|
|
33
|
+
"meta": meta,
|
|
34
|
+
"arms": {k: M.arm_metrics(states[k]) for k in keys},
|
|
35
|
+
"comparisons": [{**M.compare(states[a], states[b], a, b), "role": role}
|
|
36
|
+
for a, b, role in comparisons(keys) if a in states and b in states],
|
|
37
|
+
"ledger": M.ledger(scored, states),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pct(x: float | None) -> str:
|
|
42
|
+
return "n/a" if x is None else f"{100 * x:.1f}%"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cell(m: dict | None) -> str:
|
|
46
|
+
if m is None:
|
|
47
|
+
return "n/a"
|
|
48
|
+
lo, hi = m["ci95"]
|
|
49
|
+
return f"{_pct(m['rate'])} [{_pct(lo)}, {_pct(hi)}]"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def verdict_phrase(d: dict, n: int) -> str:
|
|
53
|
+
lo, hi = d["ci95"]
|
|
54
|
+
rng = f"95% CI [{100 * lo:+.1f}, {100 * hi:+.1f}] pts"
|
|
55
|
+
if not d["excludes_0"]:
|
|
56
|
+
return f"no detectable effect at N={n} ({rng})"
|
|
57
|
+
return f"{100 * d['delta']:+.1f} pts ({rng})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def markdown(r: dict) -> str:
|
|
61
|
+
n = r["n"]
|
|
62
|
+
meta = r["meta"]
|
|
63
|
+
lines = [f"# validbutwrong report: {r['task']} (N={n}, {r['mode']})", ""]
|
|
64
|
+
if r["mode"] == "replay":
|
|
65
|
+
lines.append(f"Scored from a committed replay generated {meta.get('started', '?')} in "
|
|
66
|
+
f"{meta.get('wall_seconds', '?')} s on {meta.get('hardware', {}).get('cpu', '?')} "
|
|
67
|
+
f"({meta.get('engine', {}).get('engine')}, {meta.get('engine', {}).get('model_file')}). "
|
|
68
|
+
"Numbers are REPLAYED, not regenerated; use `--live` to regenerate.")
|
|
69
|
+
lines.append("")
|
|
70
|
+
if r["excluded"]["n"]:
|
|
71
|
+
lines += [f"**{r['excluded']['n']} item(s) excluded** from every arm ({r['excluded']['reason']}): "
|
|
72
|
+
+ ", ".join(r["excluded"]["ids"]), ""]
|
|
73
|
+
lines += ["## Metrics per arm (rate over all N items, 95% bootstrap CI)", "",
|
|
74
|
+
f"| arm | schema validity | {r['correctness_label']} | executable accuracy | wrong-but-valid |",
|
|
75
|
+
"|---|---|---|---|---|"]
|
|
76
|
+
for k, a in r["arms"].items():
|
|
77
|
+
lines.append(f"| `{k}` | {_cell(a['schema_validity'])} | {_cell(a['answer_accuracy'])} | "
|
|
78
|
+
f"not computed in this run (task has no execution check) | {_cell(a['wrong_but_valid'])} |")
|
|
79
|
+
lines += ["", "## Paired comparisons (same items)", ""]
|
|
80
|
+
for c in r["comparisons"]:
|
|
81
|
+
f = c["flips"]
|
|
82
|
+
lines.append(f"**`{c['from']}` → `{c['to']}`** ({c['role']})")
|
|
83
|
+
for m, d in c["deltas"].items():
|
|
84
|
+
lines.append(f"- {m.replace('_', ' ')}: {verdict_phrase(d, n)}")
|
|
85
|
+
lines.append(f"- per-item flips: {f['right_to_wrong']} right→wrong, {f['wrong_to_right']} wrong→right")
|
|
86
|
+
lines.append("")
|
|
87
|
+
widths = [(k, a["answer_accuracy"]["ci95"][1] - a["answer_accuracy"]["ci95"][0]) for k, a in r["arms"].items()]
|
|
88
|
+
lines.append("CI widths (answer accuracy): " + ", ".join(f"`{k}` ±{50 * w:.1f} pts" for k, w in widths))
|
|
89
|
+
lines.append("")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quadrant_svg(r: dict) -> str:
|
|
94
|
+
"""One 2x2 panel per contract arm: rows valid/invalid, columns right/wrong. Zero dependencies."""
|
|
95
|
+
arms = [(k, a["quadrant"]) for k, a in r["arms"].items() if a["quadrant"]]
|
|
96
|
+
cell, gap, pad_top, pad_left = 64, 34, 64, 70
|
|
97
|
+
panel_w = 2 * cell + 20
|
|
98
|
+
width = pad_left + len(arms) * (panel_w + gap)
|
|
99
|
+
height = pad_top + 2 * cell + 64
|
|
100
|
+
n = r["n"]
|
|
101
|
+
ink, muted, grid, accent = "#1f2328", "#59636e", "#d0d7de", "#cf222e"
|
|
102
|
+
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
103
|
+
f'viewBox="0 0 {width} {height}" font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif">',
|
|
104
|
+
f'<rect width="100%" height="100%" fill="#ffffff"/>',
|
|
105
|
+
f'<text x="{pad_left}" y="22" font-size="14" font-weight="600" fill="{ink}">'
|
|
106
|
+
f'{escape(r["task"])}: validity × correctness per arm (N={n})</text>',
|
|
107
|
+
f'<text x="{pad_left}" y="40" font-size="11" fill="{muted}">red cell = valid but wrong: '
|
|
108
|
+
f'the answers a 100%-valid dashboard hides</text>',
|
|
109
|
+
f'<text x="{pad_left - 8}" y="{pad_top + cell / 2 + 4}" font-size="11" fill="{muted}" text-anchor="end">valid</text>',
|
|
110
|
+
f'<text x="{pad_left - 8}" y="{pad_top + 1.5 * cell + 4}" font-size="11" fill="{muted}" text-anchor="end">invalid</text>']
|
|
111
|
+
for i, (k, q) in enumerate(arms):
|
|
112
|
+
x0 = pad_left + i * (panel_w + gap)
|
|
113
|
+
for row, v in enumerate(("valid", "invalid")):
|
|
114
|
+
for col, c in enumerate(("right", "wrong")):
|
|
115
|
+
cnt = q[f"{v}_{c}"]
|
|
116
|
+
share = cnt / n if n else 0
|
|
117
|
+
hot = v == "valid" and c == "wrong"
|
|
118
|
+
fill = accent if hot else "#57606a"
|
|
119
|
+
x, y = x0 + col * cell, pad_top + row * cell
|
|
120
|
+
out.append(f'<rect x="{x}" y="{y}" width="{cell - 2}" height="{cell - 2}" fill="{fill}" '
|
|
121
|
+
f'fill-opacity="{0.08 + 0.82 * share:.3f}" stroke="{grid}"/>')
|
|
122
|
+
tc = "#ffffff" if share > 0.45 else ink
|
|
123
|
+
out.append(f'<text x="{x + cell / 2 - 1}" y="{y + cell / 2 + 5}" font-size="14" '
|
|
124
|
+
f'font-weight="{600 if hot else 400}" text-anchor="middle" fill="{tc}">{cnt}</text>')
|
|
125
|
+
out.append(f'<text x="{x0 + cell / 2}" y="{pad_top - 8}" font-size="10" fill="{muted}" text-anchor="middle">right</text>')
|
|
126
|
+
out.append(f'<text x="{x0 + 1.5 * cell}" y="{pad_top - 8}" font-size="10" fill="{muted}" text-anchor="middle">wrong</text>')
|
|
127
|
+
out.append(f'<text x="{x0 + cell}" y="{pad_top + 2 * cell + 18}" font-size="11" fill="{ink}" '
|
|
128
|
+
f'text-anchor="middle">{escape(k)}</text>')
|
|
129
|
+
out.append("</svg>")
|
|
130
|
+
return "\n".join(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def gate(r: dict, threshold: float | None) -> list[str]:
|
|
134
|
+
"""Arms whose wrong-but-valid rate exceeds the threshold (the CI exit-code gate)."""
|
|
135
|
+
if threshold is None:
|
|
136
|
+
return []
|
|
137
|
+
return [k for k, a in r["arms"].items()
|
|
138
|
+
if a["wrong_but_valid"] is not None and k.startswith("constrained[")
|
|
139
|
+
and a["wrong_but_valid"]["rate"] > threshold]
|
validbutwrong/run.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Live generation: run every item through every arm and write a replay file (JSONL).
|
|
2
|
+
|
|
3
|
+
Line 1 is ``{"meta": {...}}`` (model file + sha256, backend versions, hardware, timings);
|
|
4
|
+
each following line is one item's generations. Scoring never needs the model: ``score``
|
|
5
|
+
re-reads this file, so a committed replay re-scores in seconds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
|
|
18
|
+
from .arms import MAX_TOKENS, run_item
|
|
19
|
+
from .engines import ENGINES
|
|
20
|
+
from .tasks import Task
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version(pkg: str) -> str | None:
|
|
24
|
+
try:
|
|
25
|
+
return metadata.version(pkg)
|
|
26
|
+
except metadata.PackageNotFoundError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sha256(path: str) -> str:
|
|
31
|
+
h = hashlib.sha256()
|
|
32
|
+
with open(path, "rb") as f:
|
|
33
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
34
|
+
h.update(chunk)
|
|
35
|
+
return h.hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _hardware(n_threads: int) -> dict:
|
|
39
|
+
cpu = platform.processor()
|
|
40
|
+
if platform.system() == "Darwin":
|
|
41
|
+
try:
|
|
42
|
+
cpu = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"],
|
|
43
|
+
capture_output=True, text=True).stdout.strip() or cpu
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
return {"cpu": cpu, "machine": platform.machine(), "os": platform.platform(),
|
|
47
|
+
"logical_cpus": os.cpu_count(), "n_threads": n_threads}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate(task: Task, model_path: str, out_path: str, n: int, engine: str = "outlines",
|
|
51
|
+
n_threads: int = 4, model_source: str | None = None, progress: bool = True) -> dict:
|
|
52
|
+
t_start = time.perf_counter()
|
|
53
|
+
wall_start = time.time() # perf_counter pauses while the machine sleeps; time.time() does not
|
|
54
|
+
eng = ENGINES[engine](model_path, n_threads=n_threads)
|
|
55
|
+
t_st = time.perf_counter()
|
|
56
|
+
eng.selftest() # refuses to run if the constraint does not bind
|
|
57
|
+
selftest_s = time.perf_counter() - t_st
|
|
58
|
+
items = task.items(n)
|
|
59
|
+
meta = {
|
|
60
|
+
"task": task.name, "n": len(items), "engine": eng.info(),
|
|
61
|
+
"model_sha256": _sha256(model_path), "model_source": model_source,
|
|
62
|
+
"versions": {p: _version(p) for p in ("validbutwrong", "outlines", "outlines-core",
|
|
63
|
+
"llama-cpp-python", "numba", "jsonschema")},
|
|
64
|
+
"hardware": _hardware(n_threads), "decoding": "greedy (temperature 0)",
|
|
65
|
+
"max_tokens": MAX_TOKENS, "selftest_seconds": round(selftest_s, 3),
|
|
66
|
+
"started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
67
|
+
}
|
|
68
|
+
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
|
69
|
+
with open(out_path + ".partial", "w", encoding="utf-8") as f:
|
|
70
|
+
f.write(json.dumps({"meta": meta}) + "\n")
|
|
71
|
+
for i, it in enumerate(items):
|
|
72
|
+
t0 = time.perf_counter()
|
|
73
|
+
rec = run_item(eng, task, it)
|
|
74
|
+
rec["item_seconds"] = round(time.perf_counter() - t0, 3)
|
|
75
|
+
f.write(json.dumps(rec) + "\n")
|
|
76
|
+
f.flush()
|
|
77
|
+
if progress:
|
|
78
|
+
print(f"[{i + 1}/{len(items)}] {it.id} {rec['item_seconds']:.1f}s", flush=True)
|
|
79
|
+
meta["wall_seconds"] = round(time.perf_counter() - t_start, 1) # compute time
|
|
80
|
+
meta["wall_clock_seconds"] = round(time.time() - wall_start, 1) # includes any sleep
|
|
81
|
+
meta["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
82
|
+
# rewrite the header with the final timings, then publish atomically
|
|
83
|
+
with open(out_path + ".partial", encoding="utf-8") as f:
|
|
84
|
+
lines = f.readlines()
|
|
85
|
+
lines[0] = json.dumps({"meta": meta}) + "\n"
|
|
86
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
87
|
+
f.writelines(lines)
|
|
88
|
+
os.remove(out_path + ".partial")
|
|
89
|
+
return meta
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_replay(path: str) -> tuple[dict, list[dict]]:
|
|
93
|
+
with open(path, encoding="utf-8") as f:
|
|
94
|
+
meta = json.loads(f.readline())["meta"]
|
|
95
|
+
records = [json.loads(line) for line in f if line.strip()]
|
|
96
|
+
return meta, records
|
validbutwrong/schema.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""JSON helpers: BFCL->JSON Schema conversion, first-object extraction, and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jsonschema
|
|
9
|
+
|
|
10
|
+
_BFCL_TYPES = {"dict": "object", "float": "number", "tuple": "array",
|
|
11
|
+
# "any" as an explicit union: some backends (outlines) cannot compile a typeless leaf
|
|
12
|
+
"any": ["string", "number", "integer", "boolean", "array", "object", "null"]}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def bfcl_to_jsonschema(params: dict) -> dict:
|
|
16
|
+
"""Convert BFCL's parameter dialect (``dict``/``float``/``tuple``/``any``) to JSON Schema.
|
|
17
|
+
|
|
18
|
+
BFCL schemas are not valid JSON Schema as published; a grammar compiler or ``jsonschema``
|
|
19
|
+
needs this conversion first. Descriptions and ``enum`` are kept; ``default`` is dropped
|
|
20
|
+
(it is documentation, not a constraint). Objects are closed (``additionalProperties: false``).
|
|
21
|
+
"""
|
|
22
|
+
out: dict[str, Any] = {}
|
|
23
|
+
t = params.get("type")
|
|
24
|
+
if t is not None:
|
|
25
|
+
out["type"] = _BFCL_TYPES.get(t, t)
|
|
26
|
+
for key in ("description", "enum"):
|
|
27
|
+
if key in params:
|
|
28
|
+
out[key] = params[key]
|
|
29
|
+
if "properties" in params:
|
|
30
|
+
out["properties"] = {k: bfcl_to_jsonschema(v) for k, v in params["properties"].items()}
|
|
31
|
+
out["type"] = "object"
|
|
32
|
+
out["additionalProperties"] = False
|
|
33
|
+
if params.get("required"):
|
|
34
|
+
out["required"] = list(params["required"])
|
|
35
|
+
if "items" in params:
|
|
36
|
+
out["items"] = bfcl_to_jsonschema(params["items"])
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def first_json_object(text: str) -> Any:
|
|
41
|
+
"""Return the first parseable top-level JSON object in ``text``, or None.
|
|
42
|
+
|
|
43
|
+
Unlike a ``{.*}`` regex, this scans balanced braces and never rewrites the payload
|
|
44
|
+
(no whitespace stripping), so string values are graded as the model produced them.
|
|
45
|
+
"""
|
|
46
|
+
dec = json.JSONDecoder()
|
|
47
|
+
i = text.find("{")
|
|
48
|
+
while i != -1:
|
|
49
|
+
try:
|
|
50
|
+
obj, _ = dec.raw_decode(text[i:])
|
|
51
|
+
if isinstance(obj, dict):
|
|
52
|
+
return obj
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
pass
|
|
55
|
+
i = text.find("{", i + 1)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_valid(obj: Any, schema: dict) -> bool:
|
|
60
|
+
if obj is None:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
jsonschema.validate(obj, schema)
|
|
64
|
+
return True
|
|
65
|
+
except jsonschema.ValidationError:
|
|
66
|
+
return False
|
validbutwrong/tasks.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Tasks: where items, prompts, gold answers and per-arm answer extraction live.
|
|
2
|
+
|
|
3
|
+
A task supplies everything that is task-specific. The arms, engines, metrics and
|
|
4
|
+
report never look inside an item; they only call the task's methods. Adding your
|
|
5
|
+
own task means writing one of these classes (see ``Task``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .schema import bfcl_to_jsonschema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Item:
|
|
21
|
+
id: str
|
|
22
|
+
prompt: str # the user-facing problem text
|
|
23
|
+
gold: Any # task-specific gold
|
|
24
|
+
meta: dict = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Task:
|
|
28
|
+
"""Interface every task implements.
|
|
29
|
+
|
|
30
|
+
``schemas`` name -> JSON Schema for the contract arms (prompt_json / constrained).
|
|
31
|
+
``answer_schema`` which schema name reason_then_constrain packages into.
|
|
32
|
+
``free_prompt`` instruction for the unconstrained arm (free text).
|
|
33
|
+
``json_prompt`` instruction for the contract arms, given a schema.
|
|
34
|
+
``extract_free`` free text -> answer (or None).
|
|
35
|
+
``extract_json`` parsed JSON object -> answer (or None).
|
|
36
|
+
``is_correct`` (answer, item) -> bool.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "task"
|
|
40
|
+
correctness_label = "answer accuracy"
|
|
41
|
+
schemas: dict[str, dict] = {}
|
|
42
|
+
answer_schema = "answer"
|
|
43
|
+
|
|
44
|
+
def items(self, n: int | None = None) -> list[Item]:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def schemas_for(self, item: Item) -> dict[str, dict]:
|
|
48
|
+
return self.schemas
|
|
49
|
+
|
|
50
|
+
def free_prompt(self, item: Item) -> str:
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def json_prompt(self, item: Item, schema: dict) -> str:
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def package_prompt(self, item: Item, schema: dict) -> str:
|
|
57
|
+
return ("Now give your final answer as ONLY a JSON object matching this JSON Schema, "
|
|
58
|
+
"with no other text:\n" + json.dumps(schema))
|
|
59
|
+
|
|
60
|
+
def extract_free(self, text: str, item: Item) -> Any:
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def extract_json(self, obj: Any, item: Item) -> Any:
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def is_correct(self, answer: Any, item: Item) -> bool:
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- GSM8K
|
|
71
|
+
|
|
72
|
+
_NUM = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _to_number(x: Any) -> float | None:
|
|
76
|
+
if isinstance(x, bool):
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(x, (int, float)):
|
|
79
|
+
return float(x)
|
|
80
|
+
if isinstance(x, str):
|
|
81
|
+
m = _NUM.findall(x.replace("$", ""))
|
|
82
|
+
if m:
|
|
83
|
+
try:
|
|
84
|
+
return float(m[-1].replace(",", ""))
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GSM8K(Task):
|
|
91
|
+
"""GSM8K test split (openai/grade-school-math, MIT). Gold = the number after '####'."""
|
|
92
|
+
|
|
93
|
+
name = "gsm8k"
|
|
94
|
+
correctness_label = "answer accuracy"
|
|
95
|
+
schemas = {
|
|
96
|
+
"answer": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {"answer": {"type": "integer"}},
|
|
99
|
+
"required": ["answer"],
|
|
100
|
+
"additionalProperties": False,
|
|
101
|
+
},
|
|
102
|
+
"reasoning": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {"reasoning": {"type": "string"}, "answer": {"type": "integer"}},
|
|
105
|
+
"required": ["reasoning", "answer"],
|
|
106
|
+
"additionalProperties": False,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
answer_schema = "answer"
|
|
110
|
+
|
|
111
|
+
def __init__(self, path: str):
|
|
112
|
+
self.path = path
|
|
113
|
+
|
|
114
|
+
def items(self, n: int | None = None) -> list[Item]:
|
|
115
|
+
out = []
|
|
116
|
+
with open(self.path, encoding="utf-8") as f:
|
|
117
|
+
for i, line in enumerate(f):
|
|
118
|
+
if n is not None and i >= n:
|
|
119
|
+
break
|
|
120
|
+
row = json.loads(line)
|
|
121
|
+
gold = float(row["answer"].split("####")[-1].strip().replace(",", ""))
|
|
122
|
+
out.append(Item(id=f"gsm8k_test_{i}", prompt=row["question"], gold=gold))
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
def free_prompt(self, item: Item) -> str:
|
|
126
|
+
return (f"{item.prompt}\n\nSolve the problem. Think step by step, then finish with a "
|
|
127
|
+
"final line of the form 'Answer: <number>'.")
|
|
128
|
+
|
|
129
|
+
def json_prompt(self, item: Item, schema: dict) -> str:
|
|
130
|
+
return (f"{item.prompt}\n\nSolve the problem. Respond with ONLY a JSON object matching "
|
|
131
|
+
f"this JSON Schema, with no other text:\n{json.dumps(schema)}")
|
|
132
|
+
|
|
133
|
+
def extract_free(self, text: str, item: Item) -> Any:
|
|
134
|
+
# Priority: an explicit "Answer: N" line, then LaTeX \boxed{N} (Qwen's habit), then the
|
|
135
|
+
# last number in the text. \boxed{} support was added after inspecting real outputs:
|
|
136
|
+
# without it, "\boxed{1400} bananas to last for 2 months" was read as 2.
|
|
137
|
+
m = re.findall(r"[Aa]nswer\s*[:=]\s*\**\s*\$?\s*(-?\d[\d,]*(?:\.\d+)?)", text)
|
|
138
|
+
if m:
|
|
139
|
+
return _to_number(m[-1])
|
|
140
|
+
boxed = re.findall(r"\\boxed\{([^{}]*)\}", text)
|
|
141
|
+
if boxed and _to_number(boxed[-1]) is not None:
|
|
142
|
+
return _to_number(boxed[-1])
|
|
143
|
+
return _to_number(text) # fall back to the last number in the text
|
|
144
|
+
|
|
145
|
+
def extract_json(self, obj: Any, item: Item) -> Any:
|
|
146
|
+
if isinstance(obj, dict) and "answer" in obj:
|
|
147
|
+
return _to_number(obj["answer"])
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def is_correct(self, answer: Any, item: Item) -> bool:
|
|
151
|
+
return answer is not None and abs(answer - item.gold) < 1e-6
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------- BFCL
|
|
155
|
+
|
|
156
|
+
def _norm_str(s: str) -> str:
|
|
157
|
+
# BFCL-style string normalisation: case-insensitive, ignore spaces and common punctuation.
|
|
158
|
+
return re.sub(r"[ ,./\-_*^]", "", s.lower())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _value_matches(value: Any, allowed: Any) -> bool:
|
|
162
|
+
if isinstance(allowed, str):
|
|
163
|
+
return isinstance(value, str) and _norm_str(value) == _norm_str(allowed)
|
|
164
|
+
if isinstance(allowed, bool) or isinstance(value, bool):
|
|
165
|
+
return value is allowed
|
|
166
|
+
if isinstance(allowed, (int, float)):
|
|
167
|
+
return isinstance(value, (int, float)) and float(value) == float(allowed)
|
|
168
|
+
if isinstance(allowed, list):
|
|
169
|
+
return (isinstance(value, (list, tuple)) and len(value) == len(allowed)
|
|
170
|
+
and all(_value_matches(v, a) for v, a in zip(value, allowed)))
|
|
171
|
+
if isinstance(allowed, dict):
|
|
172
|
+
# nested dict in possible_answer: each key maps to a list of allowed values
|
|
173
|
+
return isinstance(value, dict) and ast_match(value, allowed, {"properties": dict.fromkeys(allowed)})
|
|
174
|
+
return value == allowed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ast_match(args: dict | None, gold: dict, params: dict) -> bool:
|
|
178
|
+
"""Simplified BFCL-style AST match of one call's arguments against possible_answer.
|
|
179
|
+
|
|
180
|
+
Rules: every gold parameter must be present unless its allowed list contains "" (optional);
|
|
181
|
+
a present value must match one allowed value; no parameter outside the function's schema.
|
|
182
|
+
This mirrors the official checker's structure; it is NOT the official checker.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(args, dict):
|
|
185
|
+
return False
|
|
186
|
+
known = set(params.get("properties", {}))
|
|
187
|
+
if any(k not in known for k in args):
|
|
188
|
+
return False
|
|
189
|
+
for k, allowed in gold.items():
|
|
190
|
+
if k not in args:
|
|
191
|
+
if "" in allowed:
|
|
192
|
+
continue
|
|
193
|
+
return False
|
|
194
|
+
if not any(_value_matches(args[k], a) for a in allowed if a != ""):
|
|
195
|
+
return False
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class BFCLSimple(Task):
|
|
200
|
+
"""BFCL v3 'simple' category (gorilla-llm/Berkeley-Function-Calling-Leaderboard, apache-2.0).
|
|
201
|
+
|
|
202
|
+
One function per item. The contract arms emit the call's ARGUMENTS as a JSON object; the
|
|
203
|
+
schema is the function's parameter schema converted to JSON Schema. The unconstrained arm
|
|
204
|
+
writes a Python-syntax call, parsed with ``ast``. Scored by AST match, never by execution.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
name = "bfcl-simple"
|
|
208
|
+
correctness_label = "AST-match accuracy"
|
|
209
|
+
answer_schema = "arguments"
|
|
210
|
+
|
|
211
|
+
def __init__(self, questions_path: str, answers_path: str):
|
|
212
|
+
self.questions_path = questions_path
|
|
213
|
+
self.answers_path = answers_path
|
|
214
|
+
|
|
215
|
+
def items(self, n: int | None = None) -> list[Item]:
|
|
216
|
+
with open(self.answers_path, encoding="utf-8") as f:
|
|
217
|
+
gold = {r["id"]: r["ground_truth"][0] for r in map(json.loads, f)}
|
|
218
|
+
out = []
|
|
219
|
+
with open(self.questions_path, encoding="utf-8") as f:
|
|
220
|
+
for i, line in enumerate(f):
|
|
221
|
+
if n is not None and i >= n:
|
|
222
|
+
break
|
|
223
|
+
row = json.loads(line)
|
|
224
|
+
fn = row["function"][0]
|
|
225
|
+
(gname, gargs), = gold[row["id"]].items()
|
|
226
|
+
out.append(Item(id=row["id"], prompt=row["question"][0][-1]["content"],
|
|
227
|
+
gold=gargs, meta={"function": fn, "gold_name": gname}))
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
def schemas_for(self, item: Item) -> dict[str, dict]:
|
|
231
|
+
return {"arguments": bfcl_to_jsonschema(item.meta["function"]["parameters"])}
|
|
232
|
+
|
|
233
|
+
def _fn_doc(self, item: Item) -> str:
|
|
234
|
+
return json.dumps(item.meta["function"])
|
|
235
|
+
|
|
236
|
+
def free_prompt(self, item: Item) -> str:
|
|
237
|
+
fn = item.meta["function"]["name"]
|
|
238
|
+
return (f"You can call this function:\n{self._fn_doc(item)}\n\nRequest: {item.prompt}\n\n"
|
|
239
|
+
f"Write the single call to `{fn}` that fulfils the request, in Python syntax, "
|
|
240
|
+
f"e.g. {fn}(arg=value). Write only the call.")
|
|
241
|
+
|
|
242
|
+
def json_prompt(self, item: Item, schema: dict) -> str:
|
|
243
|
+
fn = item.meta["function"]["name"]
|
|
244
|
+
return (f"You can call this function:\n{self._fn_doc(item)}\n\nRequest: {item.prompt}\n\n"
|
|
245
|
+
f"Give the arguments for the call to `{fn}` as ONLY a JSON object matching this "
|
|
246
|
+
f"JSON Schema, with no other text:\n{json.dumps(schema)}")
|
|
247
|
+
|
|
248
|
+
def package_prompt(self, item: Item, schema: dict) -> str:
|
|
249
|
+
return ("Now give the arguments of that call as ONLY a JSON object matching this JSON "
|
|
250
|
+
"Schema, with no other text:\n" + json.dumps(schema))
|
|
251
|
+
|
|
252
|
+
def extract_free(self, text: str, item: Item) -> Any:
|
|
253
|
+
m = re.search(r"[\w.]+\s*\(.*\)", text.replace("\n", " "))
|
|
254
|
+
if not m:
|
|
255
|
+
return None
|
|
256
|
+
try:
|
|
257
|
+
call = ast.parse(m.group(0).strip(), mode="eval").body
|
|
258
|
+
except SyntaxError:
|
|
259
|
+
return None
|
|
260
|
+
if not isinstance(call, ast.Call):
|
|
261
|
+
return None
|
|
262
|
+
# Positional arguments are mapped by the function's declared parameter order. That is
|
|
263
|
+
# MORE lenient than BFCL's checker, deliberately: being charitable to the free-text
|
|
264
|
+
# baseline keeps any "the constraint helps" result conservative.
|
|
265
|
+
order = list(item.meta["function"]["parameters"].get("properties", {}))
|
|
266
|
+
try:
|
|
267
|
+
args = {order[i]: ast.literal_eval(a) for i, a in enumerate(call.args) if i < len(order)}
|
|
268
|
+
args.update({kw.arg: ast.literal_eval(kw.value) for kw in call.keywords if kw.arg})
|
|
269
|
+
return args
|
|
270
|
+
except (ValueError, SyntaxError):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def extract_json(self, obj: Any, item: Item) -> Any:
|
|
274
|
+
return obj if isinstance(obj, dict) else None
|
|
275
|
+
|
|
276
|
+
def is_correct(self, answer: Any, item: Item) -> bool:
|
|
277
|
+
return ast_match(answer, item.gold, self.schemas_for(item)["arguments"])
|