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.
@@ -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).