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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""validbutwrong: what grammar-constrained decoding costs (or buys) you in answer correctness."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .report import build as build_report
|
|
6
|
+
from .run import generate, read_replay
|
|
7
|
+
from .tasks import BFCLSimple, GSM8K, Item, Task
|
|
8
|
+
|
|
9
|
+
__all__ = ["Task", "Item", "GSM8K", "BFCLSimple", "generate", "read_replay", "build_report"]
|
validbutwrong/arms.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The four decoding arms. Generation only; scoring happens later from the stored text.
|
|
2
|
+
|
|
3
|
+
For each item and each schema S the task offers:
|
|
4
|
+
|
|
5
|
+
* ``unconstrained`` free text, answer extracted by the task's rule
|
|
6
|
+
* ``prompt_json[S]`` asked for JSON matching S in the prompt, NO grammar
|
|
7
|
+
* ``constrained[S]`` the SAME prompt as prompt_json[S], grammar-enforced
|
|
8
|
+
* ``reason_then_constrain[S*]`` free reasoning, then a constrained packaging turn into the
|
|
9
|
+
task's answer schema S* (CRANE-style "reason free, constrain
|
|
10
|
+
late"). Its free phase IS the unconstrained generation (same
|
|
11
|
+
prompt, greedy decoding), so it is reused rather than re-run.
|
|
12
|
+
|
|
13
|
+
prompt_json[S] vs constrained[S] differ ONLY in whether the grammar is enforced: that pair is
|
|
14
|
+
the headline comparison. unconstrained vs an answer-only constrained arm also removes the
|
|
15
|
+
reasoning text, so it mixes two effects and is never the headline.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .engines import Engine, UnsupportedSchema
|
|
21
|
+
from .tasks import Item, Task
|
|
22
|
+
|
|
23
|
+
MAX_TOKENS = {"free": 384, "json": 384, "package": 384} # package was 48: truncated BFCL argument lists
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def arm_keys(task: Task, item: Item) -> list[str]:
|
|
27
|
+
keys = ["unconstrained"]
|
|
28
|
+
for s in task.schemas_for(item):
|
|
29
|
+
keys += [f"prompt_json[{s}]", f"constrained[{s}]"]
|
|
30
|
+
keys.append(f"reason_then_constrain[{task.answer_schema}]")
|
|
31
|
+
return keys
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rec(g, **extra) -> dict:
|
|
35
|
+
return {"text": g.text, "seconds": round(g.seconds, 3),
|
|
36
|
+
"compile_seconds": round(g.compile_seconds, 5), "n_tokens": g.n_tokens, **extra}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_item(engine: Engine, task: Task, item: Item, max_tokens: dict | None = None) -> dict:
|
|
40
|
+
mt = {**MAX_TOKENS, **(max_tokens or {})}
|
|
41
|
+
schemas = task.schemas_for(item)
|
|
42
|
+
arms: dict[str, dict] = {}
|
|
43
|
+
|
|
44
|
+
free_msgs = [{"role": "user", "content": task.free_prompt(item)}]
|
|
45
|
+
free = engine.generate(free_msgs, None, mt["free"])
|
|
46
|
+
arms["unconstrained"] = _rec(free)
|
|
47
|
+
|
|
48
|
+
for s, schema in schemas.items():
|
|
49
|
+
msgs = [{"role": "user", "content": task.json_prompt(item, schema)}]
|
|
50
|
+
arms[f"prompt_json[{s}]"] = _rec(engine.generate(msgs, None, mt["json"]))
|
|
51
|
+
arms[f"constrained[{s}]"] = _constrained(engine, msgs, schema, mt["json"])
|
|
52
|
+
|
|
53
|
+
s_ans = task.answer_schema
|
|
54
|
+
pkg_msgs = free_msgs + [{"role": "assistant", "content": free.text},
|
|
55
|
+
{"role": "user", "content": task.package_prompt(item, schemas[s_ans])}]
|
|
56
|
+
arms[f"reason_then_constrain[{s_ans}]"] = _constrained(engine, pkg_msgs, schemas[s_ans], mt["package"],
|
|
57
|
+
reuses="unconstrained")
|
|
58
|
+
return {"id": item.id, "arms": arms}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _constrained(engine: Engine, msgs: list[dict], schema: dict, max_tokens: int, **extra) -> dict:
|
|
62
|
+
"""A schema the BACKEND cannot compile is a backend limitation, not a model error: record it,
|
|
63
|
+
so scoring can exclude the item (and report how many) instead of scoring the model as wrong."""
|
|
64
|
+
try:
|
|
65
|
+
return _rec(engine.generate(msgs, schema, max_tokens), **extra)
|
|
66
|
+
except UnsupportedSchema as e:
|
|
67
|
+
return {"text": "", "unsupported_schema": str(e).splitlines()[0][:200], **extra}
|
validbutwrong/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""validbutwrong CLI.
|
|
2
|
+
|
|
3
|
+
validbutwrong run # re-scores BOTH committed replays (bfcl-simple, gsm8k) in seconds
|
|
4
|
+
validbutwrong run --task bfcl-simple # one task
|
|
5
|
+
validbutwrong run --live --n 20 # opt-in regeneration (needs [live]; downloads the ~491 MB GGUF)
|
|
6
|
+
validbutwrong run --fail-if-wrong-valid-above 0.2 # exit 1 if a constrained arm exceeds it
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 ok · 1 gate tripped · 2 usage/setup error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import csv
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.request
|
|
20
|
+
from importlib import resources
|
|
21
|
+
|
|
22
|
+
from . import report as R
|
|
23
|
+
from .run import generate, read_replay
|
|
24
|
+
from .tasks import BFCLSimple, GSM8K, Task
|
|
25
|
+
|
|
26
|
+
MODEL = {
|
|
27
|
+
"repo": "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
|
|
28
|
+
"revision": "9217f5db79a29953eb74d5343926648285ec7e67",
|
|
29
|
+
"file": "qwen2.5-0.5b-instruct-q4_k_m.gguf",
|
|
30
|
+
"sha256": "74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Default = REPLAY for every task (operator ruling 2026-10-11); --live is opt-in. The pre-registered
|
|
35
|
+
# N_live measurements (10-min CPU budget, Apple M1, 4 threads) are kept as documentation of what a
|
|
36
|
+
# live run costs: gsm8k N_live = 37, bfcl-simple N_live = 104. See README "Regenerate the numbers".
|
|
37
|
+
TASKS = ["bfcl-simple", "gsm8k"] # order of `--task all` (the default)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _data(name: str) -> str:
|
|
41
|
+
return str(resources.files("validbutwrong") / "data" / name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_task(name: str) -> Task:
|
|
45
|
+
if name == "gsm8k":
|
|
46
|
+
return GSM8K(_data("gsm8k_test_first200.jsonl"))
|
|
47
|
+
if name == "bfcl-simple":
|
|
48
|
+
return BFCLSimple(_data("bfcl_v3_simple.jsonl"), _data("bfcl_v3_simple_possible_answer.jsonl"))
|
|
49
|
+
raise SystemExit(f"unknown task {name!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bundled_replay(task: str) -> str | None:
|
|
53
|
+
p = resources.files("validbutwrong") / "replays" / f"{task}.jsonl"
|
|
54
|
+
return str(p) if p.is_file() else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch_model(cache_dir: str) -> str:
|
|
58
|
+
path = os.path.join(cache_dir, MODEL["file"])
|
|
59
|
+
if not os.path.exists(path):
|
|
60
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
61
|
+
url = (f"https://huggingface.co/{MODEL['repo']}/resolve/{MODEL['revision']}/{MODEL['file']}")
|
|
62
|
+
print(f"downloading {url} (~491 MB) ...", file=sys.stderr)
|
|
63
|
+
urllib.request.urlretrieve(url, path + ".part")
|
|
64
|
+
os.replace(path + ".part", path)
|
|
65
|
+
h = hashlib.sha256()
|
|
66
|
+
with open(path, "rb") as f:
|
|
67
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
68
|
+
h.update(chunk)
|
|
69
|
+
if h.hexdigest() != MODEL["sha256"]:
|
|
70
|
+
raise SystemExit(f"sha256 mismatch for {path}; delete it and retry")
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv: list[str] | None = None) -> int:
|
|
75
|
+
ap = argparse.ArgumentParser(prog="validbutwrong", description=__doc__,
|
|
76
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
77
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
78
|
+
r = sub.add_parser("run", help="re-score the committed replays (default) or regenerate with --live")
|
|
79
|
+
r.add_argument("--task", default="all", choices=["all", *TASKS],
|
|
80
|
+
help="default 'all' = bfcl-simple then gsm8k")
|
|
81
|
+
r.add_argument("--live", action="store_true", help="regenerate (needs the [live] extra + a GGUF)")
|
|
82
|
+
r.add_argument("--n", type=int, default=None, help="items (live default 20; replay default: all 200)")
|
|
83
|
+
r.add_argument("--model", help="path to a chat GGUF (default: the pinned Qwen2.5-0.5B Q4_K_M)")
|
|
84
|
+
r.add_argument("--engine", default="outlines", choices=["outlines", "llamacpp"])
|
|
85
|
+
r.add_argument("--threads", type=int, default=4)
|
|
86
|
+
r.add_argument("--replay", help="score this replay file instead of the bundled one (single --task)")
|
|
87
|
+
r.add_argument("--out", default="validbutwrong-out", help="output directory (one subdir per task for 'all')")
|
|
88
|
+
r.add_argument("--fail-if-wrong-valid-above", type=float, default=None, metavar="RATE",
|
|
89
|
+
help="exit 1 if any constrained arm's wrong-but-valid rate exceeds RATE (0-1)")
|
|
90
|
+
a = ap.parse_args(argv)
|
|
91
|
+
|
|
92
|
+
if a.replay and a.task == "all":
|
|
93
|
+
print("--replay needs a single --task", file=sys.stderr)
|
|
94
|
+
return 2
|
|
95
|
+
if a.live:
|
|
96
|
+
try:
|
|
97
|
+
import llama_cpp, outlines # noqa: F401
|
|
98
|
+
except ImportError:
|
|
99
|
+
print("--live needs: pip install 'validbutwrong[live]'", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
names = TASKS if a.task == "all" else [a.task]
|
|
102
|
+
tripped: list[str] = []
|
|
103
|
+
for name in names:
|
|
104
|
+
out = os.path.join(a.out, name) if a.task == "all" else a.out
|
|
105
|
+
rc = _run_one(a, name, out)
|
|
106
|
+
if rc == 2:
|
|
107
|
+
return 2
|
|
108
|
+
tripped += rc
|
|
109
|
+
if tripped:
|
|
110
|
+
print(f"GATE: wrong-but-valid above {a.fail_if_wrong_valid_above} in: {', '.join(tripped)}",
|
|
111
|
+
file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_one(a: argparse.Namespace, name: str, out: str) -> list[str] | int:
|
|
117
|
+
task = make_task(name)
|
|
118
|
+
os.makedirs(out, exist_ok=True)
|
|
119
|
+
if a.live:
|
|
120
|
+
model = a.model or fetch_model(os.path.expanduser("~/.cache/validbutwrong"))
|
|
121
|
+
replay = os.path.join(out, f"{name}-live.jsonl")
|
|
122
|
+
generate(task, model, replay, a.n or 20, engine=a.engine, n_threads=a.threads,
|
|
123
|
+
model_source=None if a.model else f"{MODEL['repo']}@{MODEL['revision']}/{MODEL['file']}")
|
|
124
|
+
mode = "live"
|
|
125
|
+
else:
|
|
126
|
+
replay = a.replay or bundled_replay(name)
|
|
127
|
+
if replay is None:
|
|
128
|
+
print(f"no replay bundled for {name}; run with --live", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
mode = "replay"
|
|
131
|
+
|
|
132
|
+
meta, records = read_replay(replay)
|
|
133
|
+
if a.n and not a.live:
|
|
134
|
+
records = records[: a.n]
|
|
135
|
+
by_id = {rec["id"] for rec in records}
|
|
136
|
+
items = [it for it in task.items() if it.id in by_id]
|
|
137
|
+
rep = R.build(task, items, records, meta, mode)
|
|
138
|
+
with open(os.path.join(out, "report.json"), "w", encoding="utf-8") as f:
|
|
139
|
+
json.dump(rep, f, indent=1)
|
|
140
|
+
md = R.markdown(rep)
|
|
141
|
+
with open(os.path.join(out, "report.md"), "w", encoding="utf-8") as f:
|
|
142
|
+
f.write(md)
|
|
143
|
+
with open(os.path.join(out, "quadrants.svg"), "w", encoding="utf-8") as f:
|
|
144
|
+
f.write(R.quadrant_svg(rep))
|
|
145
|
+
with open(os.path.join(out, "ledger.csv"), "w", encoding="utf-8", newline="") as f:
|
|
146
|
+
w = csv.DictWriter(f, fieldnames=list(rep["ledger"][0]))
|
|
147
|
+
w.writeheader()
|
|
148
|
+
w.writerows(rep["ledger"])
|
|
149
|
+
print(md)
|
|
150
|
+
print(f"wrote {out}/report.json, report.md, quadrants.svg, ledger.csv\n")
|
|
151
|
+
return [f"{name}:{k}" for k in R.gate(rep, a.fail_if_wrong_valid_above)]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Bundled data — sources, revisions, licences
|
|
2
|
+
|
|
3
|
+
| File | Source | Pinned revision | Licence |
|
|
4
|
+
|---|---|---|---|
|
|
5
|
+
| `gsm8k_test_first200.jsonl` | first 200 lines of `grade_school_math/data/test.jsonl`, github.com/openai/grade-school-math | commit `3101c7d5072418e28b9008a6636bde82a006892c` | MIT (Copyright (c) 2021 OpenAI) |
|
|
6
|
+
| `bfcl_v3_simple.jsonl` | `BFCL_v3_simple.json`, huggingface.co/datasets/gorilla-llm/Berkeley-Function-Calling-Leaderboard | revision `61fc0608cfd831fcfbbaa676ebdfef0ed963eeda` | Apache-2.0 |
|
|
7
|
+
| `bfcl_v3_simple_possible_answer.jsonl` | `possible_answer/BFCL_v3_simple.json`, same dataset | same revision | Apache-2.0 |
|
|
8
|
+
|
|
9
|
+
Files are byte-for-byte copies (GSM8K: the first 200 lines only).
|