validbutwrong 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (31) hide show
  1. validbutwrong-0.1.1/PKG-INFO +264 -0
  2. validbutwrong-0.1.1/README.md +242 -0
  3. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/pyproject.toml +2 -2
  4. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/__init__.py +1 -1
  5. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/engines.py +11 -4
  6. validbutwrong-0.1.1/src/validbutwrong.egg-info/PKG-INFO +264 -0
  7. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/tests/test_core.py +14 -0
  8. validbutwrong-0.1.0/PKG-INFO +0 -264
  9. validbutwrong-0.1.0/README.md +0 -242
  10. validbutwrong-0.1.0/src/validbutwrong.egg-info/PKG-INFO +0 -264
  11. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/LICENSE +0 -0
  12. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/NOTICE +0 -0
  13. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/setup.cfg +0 -0
  14. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/arms.py +0 -0
  15. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/cli.py +0 -0
  16. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/data/SOURCES.md +0 -0
  17. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/data/bfcl_v3_simple.jsonl +0 -0
  18. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/data/bfcl_v3_simple_possible_answer.jsonl +0 -0
  19. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/data/gsm8k_test_first200.jsonl +0 -0
  20. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/metrics.py +0 -0
  21. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/replays/bfcl-simple.jsonl +0 -0
  22. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/replays/gsm8k.jsonl +0 -0
  23. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/report.py +0 -0
  24. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/run.py +0 -0
  25. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/schema.py +0 -0
  26. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong/tasks.py +0 -0
  27. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong.egg-info/SOURCES.txt +0 -0
  28. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong.egg-info/dependency_links.txt +0 -0
  29. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong.egg-info/entry_points.txt +0 -0
  30. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong.egg-info/requires.txt +0 -0
  31. {validbutwrong-0.1.0 → validbutwrong-0.1.1}/src/validbutwrong.egg-info/top_level.txt +0 -0
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: validbutwrong
3
+ Version: 0.1.1
4
+ Summary: Measure what constrained decoding with a grammar costs or buys in answer correctness, as well as schema validity.
5
+ Author: macamiri
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/macamiri/validbutwrong
8
+ Keywords: llm,structured-output,constrained-decoding,json-schema,grammar,evaluation,outlines,llama-cpp
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Requires-Dist: jsonschema>=4
14
+ Provides-Extra: live
15
+ Requires-Dist: llama-cpp-python>=0.3.36; extra == "live"
16
+ Requires-Dist: outlines>=1.3; extra == "live"
17
+ Requires-Dist: numba; extra == "live"
18
+ Requires-Dist: jinja2; extra == "live"
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=8; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # validbutwrong
24
+
25
+ ![validity × correctness per decoding arm, BFCL simple, N=200](docs/quadrants-bfcl.svg)
26
+
27
+ **Function calling (BFCL, N=200): turning on a JSON Schema grammar raised Qwen2.5-0.5B's AST match accuracy by +54.5 pts [95% CI +47.5, +61.5] and raised its wrong-but-valid rate by +14.0 pts [+9.0, +19.5].** 29 outputs that were visibly broken without the grammar became well formed but wrong calls. Model and prompt were unchanged; only the grammar was switched on.
28
+
29
+ On grade school math (GSM8K, N=200), the same model with the same switch showed no detectable effect on accuracy (95% CI [−0.5, +5.0] pts). Validity says little about accuracy here. GSM8K's answer only `constrained` arm is 100% valid at 5.5% accuracy (94.5% wrong-but-valid), and on BFCL 29 of the 137 outputs the grammar made valid are wrong. Only the item ledger tells them apart.
30
+
31
+ `validbutwrong` drives a real constrained decoding backend (the outlines JSON Schema to token FSM logits processor, or llama.cpp's native GBNF grammar, on one llama.cpp runtime). It runs the same model on the same items through four decoding arms and reports schema validity, answer accuracy and the wrong-but-valid rate separately, with a transition ledger per item and an exit code for CI. It measures what a grammar costs or buys on your task. It doesn't judge whether constraints are good in general.
32
+
33
+ ```bash
34
+ pip install validbutwrong
35
+ validbutwrong run # rescores BOTH committed replays (BFCL, then GSM8K; N=200 each) in seconds, with no model download
36
+ ```
37
+
38
+ Every result in the demo tables and charts is replayed: generated once on CPU and committed. `--live` regenerates them (cost below).
39
+
40
+ ---
41
+
42
+ ## What the demos show
43
+
44
+ Qwen2.5-0.5B-Instruct (Q4_K_M GGUF), greedy decoding. 95% paired bootstrap CIs, 2,000 resamples, seed 42. The headline comparison keeps the output contract fixed and switches only the grammar (`prompt_json[S]` → `constrained[S]`: same prompt, grammar off vs on).
45
+
46
+ ### Function calling: BFCL v3 `simple`, first 200 items
47
+
48
+ The model writes a function call's arguments, scored by AST match against BFCL's `possible_answer`. Nothing is executed.
49
+
50
+ | arm | schema validity | AST match accuracy | wrong-but-valid |
51
+ |---|---|---|---|
52
+ | `unconstrained` (call in Python syntax, parsed) | n/a | 45.0% [38.0, 51.5] | n/a |
53
+ | `prompt_json[arguments]`: JSON asked for, no grammar | 31.5% [25.5, 37.5] | 26.0% [20.5, 32.0] | 5.5% [2.5, 9.0] |
54
+ | `constrained[arguments]`: same prompt, grammar on | 100.0% | 80.5% [75.0, 85.5] | 19.5% [14.5, 25.0] |
55
+ | `reason_then_constrain[arguments]` | 100.0% | 84.5% [79.0, 89.0] | 15.5% [11.0, 21.0] |
56
+
57
+ - Grammar on, same contract: AST accuracy rises +54.5 pts [+47.5, +61.5] (110 items wrong→right, 1 right→wrong). Without the grammar, 137 of 200 `prompt_json` outputs are invalid, and 129 of those copy the schema's own structure back (a `"properties"` key or `"type": "object"` at the top level) instead of filling it in.
58
+ - In the same comparison, wrong-but-valid rises +14.0 pts [+9.0, +19.5]. Per item, 108 broken outputs became right, but 29 broken outputs became well formed calls that are *wrong*, and 1 right call became wrong (9 were valid but wrong in both arms).
59
+ - Secondary (confounded) comparison against the free text baseline, a call in Python syntax with no JSON contract: `unconstrained` → `constrained` is +35.5 pts [+27.5, +43.5] (84 wrong→right, 13 right→wrong). It is smaller than the headline because the `prompt_json` baseline fails mostly by copying the schema. Here the constraint is a net gain and also hides some failures from a validity check. That second effect is why the tool reports the red cell separately.
60
+
61
+ ### Grade school math: GSM8K, first 200 test problems
62
+
63
+ ![validity × correctness per decoding arm, GSM8K, N=200](docs/quadrants-gsm8k.svg)
64
+
65
+ | arm | schema validity | answer accuracy | wrong-but-valid |
66
+ |---|---|---|---|
67
+ | `unconstrained` (free text, answer parsed) | n/a | 47.0% [40.0, 54.0] | n/a |
68
+ | `prompt_json[answer]`: JSON asked for, no grammar | 95.0% [92.0, 98.0] | 3.5% [1.0, 6.5] | 91.5% [87.5, 95.0] |
69
+ | `constrained[answer]`: same prompt, grammar on | 100.0% | 5.5% [2.5, 9.0] | 94.5% [91.0, 97.5] |
70
+ | `prompt_json[reasoning]`: `{"reasoning", "answer"}`, no grammar | 87.5% [82.5, 92.0] | 14.0% [9.0, 19.0] | 73.5% [67.5, 79.5] |
71
+ | `constrained[reasoning]`: same, grammar on | 99.5% [98.5, 100.0] | 19.5% [14.0, 25.0] | 80.0% [74.5, 85.5] |
72
+ | `reason_then_constrain[answer]`: reason freely, then constrained packaging | 100.0% | 48.0% [41.0, 55.0] | 52.0% [45.0, 59.0] |
73
+
74
+ - Grammar on, same contract: validity rises (+5.0 pts [+2.0, +8.0]; with a reasoning field +12.0 pts [+7.5, +16.5]). No detectable effect on accuracy at N=200 (answer only [−0.5, +5.0] pts; reasoning field [+0.0, +10.5] pts).
75
+ - The answer only arms score low even without the grammar (`prompt_json[answer]` 3.5%), so the grammar isn't the cause. Losing room to reason explains part of it (3.5% answer only vs 14.0% with a reasoning field, both without a grammar), and the JSON contract itself costs more (14.0% vs 47.0% free text).
76
+ - Secondary (confounded) comparison: `unconstrained` → answer only `constrained` is −41.5 pts [−49.5, −33.5] (90 right→wrong, 7 wrong→right). It changes two things at once (the reasoning text goes away *and* a grammar is added), so the tool reports it but never treats it as the grammar's effect.
77
+ - `reason_then_constrain` (reason freely, constrain late) is 48.0%, with no detectable difference from free text (0 right→wrong, 2 wrong→right).
78
+
79
+ On this model and task, the GSM8K result agrees with dottxt's point that the grammar wasn't the problem (no detectable effect at a fixed contract), and with the close to zero fixed prompt ablation in SOB. It disagrees with dottxt's finding that JSON with a reasoning field matches free text (19.5% vs 47.0% here). On this task there is no "tax" that comes from the grammar itself. BFCL shows the same model with a large effect on a different task. Another model, task or schema could come out differently, which is why you measure your own.
80
+
81
+ ## Who this is for
82
+
83
+ When software asks an AI model for an answer in a strict format, like a filled in form, it can force the model to follow that format exactly. Every answer then arrives neatly formatted, and it is easy to assume every answer is right as well. Often it isn't. Forcing the format can change the answers themselves, for better or worse, and the formatted output gives no way to tell which. This tool is for engineers who build products on AI models that must return structured data. Give it your model, your task and your format. It runs the same questions with and without the format rule and tells you, question by question, which answers stayed right, which went wrong, and how many wrong answers were sitting behind a perfectly valid format.
84
+
85
+ ## How it works
86
+
87
+ ```
88
+ task (items, prompts, schema, answer checker)
89
+ │
90
+ ├─ unconstrained ─────────────┐
91
+ ├─ prompt_json[S] ── no grammar ──┤ one llama.cpp runtime, one GGUF,
92
+ ├─ constrained[S] ── grammar ─────┤──▶ greedy decoding; the grammar is the
93
+ └─ reason_then_constrain[S*] ──────┘ ONLY thing that differs between pairs
94
+ │
95
+ replay file (JSONL: every generation + model sha256, versions, hardware, timings)
96
+ │
97
+ score ─▶ validity · accuracy · wrong-but-valid per arm · paired bootstrap CIs
98
+ per-item ledger · 2×2 quadrants (executable accuracy: only if a task supplies
99
+ an execution check; not computed in these demos)
100
+ │
101
+ report.json · report.md · quadrants.svg · ledger.csv · exit code
102
+ ```
103
+
104
+ Design decisions and why:
105
+
106
+ - Two constraint engines share one runtime. `--engine outlines` (default) or `--engine llamacpp` (GBNF). Both drive the same `llama_cpp.Llama` object and the same weights, so a speed difference between them comes from the constraint machinery alone. On the tracer item the two produced identical tokens on every arm. On that one tracer item outlines was faster on CPU (16.9 s vs 26.5 s for the GSM8K item), mostly on the long constrained string (3.2 s vs 11.8 s).
107
+ - A canary self test runs before every live run. A constraint can fail *silently*. During development, the outlines logits processor raised an `ImportError` inside llama.cpp's sampler callback. llama.cpp logged "Exception ignored" and carried on generating unconstrained text, which still happened to be valid JSON. Every engine now has to force a constant the model would never write unprompted (`{"canary": "vbw-canary-7f3q"}`) before a run starts. If it can't, the run refuses. A unit test checks that the canary *fails* on an engine that ignores the schema.
108
+ - Property order is part of the contract. `{"reasoning", "answer"}` must be generated reasoning first. Sorting schema keys (an easy cache key habit) quietly puts the answer first and removes the reasoning. The engines never sort.
109
+ - Generation and scoring are kept apart. Scoring reads only the replay file, so results rescore in seconds without a model, and an extractor fix can be applied and measured later (see Limitations).
110
+ - Rates are taken over all N. Every metric divides by all items, never by the valid subset, so arms share the same denominator. `unconstrained` has no contract, so its validity is `n/a` rather than 0.
111
+ - Nulls are printed as nulls. A difference whose 95% CI includes 0 is printed as "no detectable effect at N=…" with the interval, never rounded into an effect.
112
+
113
+ ## Use it in CI
114
+
115
+ ```bash
116
+ validbutwrong run --task gsm8k --fail-if-wrong-valid-above 0.20 # exit 1 if any constrained[*] arm exceeds 20% (reason_then_constrain is not gated)
117
+ ```
118
+
119
+ Exit codes: `0` ok · `1` gate tripped · `2` setup/usage error. You set the gate for your pipeline. The tool has no built in pass mark.
120
+
121
+ ## Point it at your own task
122
+
123
+ A task is about 30 lines: the items, a free text prompt, a JSON prompt, a schema, and how to read and check an answer. See [`examples/custom_task.py`](examples/custom_task.py) (exact match QA over your own JSONL). The arms, canary, metrics and report are reused unchanged.
124
+
125
+ ```bash
126
+ pip install 'validbutwrong[live]'
127
+ python examples/custom_task.py my_questions.jsonl path/to/any-chat-model.gguf
128
+ ```
129
+
130
+ ## Inputs and outputs (machine readable)
131
+
132
+ ```yaml
133
+ # INPUT 1: CLI
134
+ command: validbutwrong run [--task all|bfcl-simple|gsm8k] [--live] [--n INT] [--model PATH.gguf]
135
+ [--engine outlines|llamacpp] [--threads INT] [--replay PATH.jsonl]
136
+ [--out DIR] [--fail-if-wrong-valid-above RATE]
137
+ defaults: {task: all, mode: replay, out: validbutwrong-out, engine: outlines, live_n: 20}
138
+ exit_codes: {0: ok, 1: gate tripped, 2: usage or setup error}
139
+
140
+ # INPUT 2: your own task (Python), a subclass of validbutwrong.Task
141
+ Task:
142
+ name: str
143
+ correctness_label: str
144
+ schemas: {schema_name: JSONSchema} # used by prompt_json[S] and constrained[S]
145
+ answer_schema: str # schema_name used by reason_then_constrain
146
+ items(n: int|None) -> list[Item] # Item(id: str, prompt: str, gold: Any, meta: dict)
147
+ free_prompt(item) -> str
148
+ json_prompt(item, schema) -> str
149
+ extract_free(text, item) -> Any|None
150
+ extract_json(obj, item) -> Any|None
151
+ is_correct(answer, item) -> bool
152
+ example_row (examples/custom_task.py): {"question": str, "answer": str}
153
+
154
+ # OUTPUT: per task, report.json, report.md, quadrants.svg, ledger.csv
155
+ report.json:
156
+ task: str
157
+ correctness_label: str
158
+ n: int # scored items
159
+ mode: replay|live
160
+ excluded: {n: int, ids: [str], reason: str} # backend could not compile the schema
161
+ meta: {task, n, engine, model_sha256, model_source, versions, hardware, decoding,
162
+ max_tokens, selftest_seconds, started, ended, wall_seconds, wall_clock_seconds}
163
+ arms:
164
+ <arm_key>: # unconstrained | prompt_json[S] | constrained[S] | reason_then_constrain[S]
165
+ n: int
166
+ schema_validity: {rate: float, ci95: [lo, hi]} | null # null for unconstrained
167
+ answer_accuracy: {rate: float, ci95: [lo, hi]}
168
+ wrong_but_valid: {rate: float, ci95: [lo, hi]} | null
169
+ executable_accuracy: null # not computed in these demos
170
+ quadrant: {valid_right, valid_wrong, invalid_right, invalid_wrong} | null
171
+ comparisons:
172
+ - {from: arm_key, to: arm_key, n: int, role: str,
173
+ deltas: {<metric>: {delta: float, ci95: [lo, hi], excludes_0: bool}},
174
+ transitions: {"<state> -> <state>": int}, # state = valid|invalid|n/a "/" right|wrong
175
+ flips: {right_to_wrong: int, wrong_to_right: int}}
176
+ ledger: [{id: str, <arm_key>: "valid/right" | "valid/wrong" | "invalid/right" | "invalid/wrong" | "n/a/right" | "n/a/wrong"}]
177
+ ```
178
+
179
+ ## Regenerate the numbers (`--live`)
180
+
181
+ ```bash
182
+ pip install 'validbutwrong[live]'
183
+ validbutwrong run --live --n 20 # downloads the pinned GGUF (~491 MB, sha256-checked)
184
+ validbutwrong run --live --model my-model.gguf --n 50 --engine llamacpp
185
+ ```
186
+
187
+ What `--live` costs: the `[live]` extra (llama-cpp-python, outlines, numba), a single ~491 MB model download, and generation time. Measured on the reference CPU (Apple M1, 4 threads, CPU only), with every arm included:
188
+
189
+ | task | mean s/item (median, p90) | `--n 20` (the live default) | items that fit in 10 min | full N=200 |
190
+ |---|---|---|---|---|
191
+ | `bfcl-simple` | 5.99 (5.42, 8.64) | ≈ 2 min | 104 | 1,201.6 s ≈ 20 min |
192
+ | `gsm8k` | 13.86 (13.56, 18.08) | ≈ 6 min | 37 | 2,775.6 s ≈ 46 min |
193
+
194
+ This is why the default is the committed replay: 37 GSM8K items, the most that fit in a 10 minute live run, are too few for useful intervals. Replays live in `src/validbutwrong/replays/`, with the model sha256, backend versions, hardware and timings in their header. On determinism, independent repeat runs produced identical generations byte for byte (GSM8K, first 24 items: 24/24; BFCL: 192 of 200 items identical across two full runs, and the other 8 are the ones the packaging cap fix below changed on purpose).
195
+
196
+ A note on CPU only runs: on macOS, a stock `pip install llama-cpp-python` builds with Metal forced on (`CMakeLists.txt` L129 at v0.3.36 sets `GGML_METAL` ON with `FORCE`, which `CMAKE_ARGS` cannot override). The reference numbers used llama-cpp-python 0.3.36 built from source with that line set to OFF. On a stock install, timings and possibly generations will differ from the committed replay. This has not been verified. The canary self test still checks the constraint is applied.
197
+
198
+ ## Validated examples
199
+
200
+ ```
201
+ Dataset: GSM8K, first 200 test problems: github.com/openai/grade-school-math (MIT), commit 3101c7d
202
+ Task: answer a grade school math word problem; schemas {"answer": int} and {"reasoning": str, "answer": int}
203
+ Observed pattern: grammar raises validity; no detectable accuracy effect at a fixed contract; (secondary, confounded) removing reasoning AND adding a grammar: −41.5 pts
204
+ Recommended method: item ledger; headline = prompt_json[S] → constrained[S]
205
+ Result: table above (replayed, N=200, seed 42 for the bootstrap)
206
+ Reproducible with: validbutwrong run --task gsm8k
207
+
208
+ Dataset: BFCL v3 "simple": huggingface.co/datasets/gorilla-llm/Berkeley-Function-Calling-Leaderboard (Apache-2.0), revision 61fc060
209
+ Task: write the arguments of one function call; schema = the function's parameters (BFCL types converted to JSON Schema)
210
+ Observed pattern: grammar raises AST accuracy +54.5 pts AND raises wrong-but-valid +14.0 pts (a large effect where GSM8K's fixed contract effect was not detectable)
211
+ Recommended method: AST match accuracy + wrong-but-valid, prompt_json → constrained
212
+ Result: table above (replayed, N=200, seed 42 for the bootstrap)
213
+ Reproducible with: validbutwrong run --task bfcl-simple (live, optional: validbutwrong run --task bfcl-simple --live --n 200)
214
+ ```
215
+
216
+ ## Prior work
217
+
218
+ The idea is already in the literature, and this tool doesn't claim it.
219
+
220
+ - The effect is named and measured. arXiv:2605.26128 (*The Constraint Tax*, 2026) reports, on models under 3B and synthetic tasks, answer accuracy falling 19.7% → 11.0% while validity rose 61.5% → 100.0%. It recommends reporting validity, answer accuracy, executable accuracy and the wrong-valid rate as separate numbers. It released no code. This tool turns that recommendation into a command. It isn't a replication: on a model the paper also used (Qwen2.5-0.5B; here Q4_K_M) but on different tasks, no accuracy effect from the grammar itself was detected on GSM8K at N=200. On function calling the paper's direction is the opposite of the BFCL demo: it reports 91.5% executable accuracy with prompt only JSON vs 48.0% under a hard tool call schema (Qwen2.5-1.5B).
221
+ - Results go both ways. The JSONSchemaBench paper (arXiv:2501.10868, §6.2) found that constrained decoding, "regardless of the framework, achieves higher performance than the unconstrained setting" on GSM8K, Last Letters and Shuffle Objects (best ≈ +3%). *Let Me Speak Freely?* (arXiv:2408.02442) and ExtractBench (arXiv:2602.12247, §4.3) report a drop in accuracy. SOB (arXiv:2604.25359, §6.3) adds schema enforcement at a fixed prompt and finds Value Accuracy "changes only slightly, from −0.007 to +0.033". dottxt's [*Say What You Mean*](https://blog.dottxt.ai/say-what-you-mean.html) argues the comparisons showing a drop were not like for like. CRANE (arXiv:2502.09061) proposes reasoning augmented constrained decoding within one generation, and the two turn `reason_then_constrain` arm borrows the idea. *Structure Tax* (arXiv:2610.12056, October 2026) puts the loss down to schema design: reasoning first field order matches or beats free form accuracy, while answer first order drops sharply, especially in smaller models. On GSM8K above, JSON with the reasoning first beat JSON with the answer first (19.5% vs 5.5% under the grammar) but stayed well below free text (47.0%) for this 0.5B model.
222
+ - The ablation already exists as study code. [`appier-research/structure-gen`](https://github.com/appier-research/structure-gen) (the 2408.02442 code, with outlines/xgrammar/TGI backends) and dottxt's [`GSM8K_JSON.ipynb`](https://github.com/dottxt-ai/demos/tree/main/say-what-you-mean) each run it for their own paper on fixed tasks. This tool adds the packaging: point it at *your* model, task and schema and get paired flips per item, separate metrics, a canary checked constraint and a CI exit code.
223
+ - The usual harnesses check validity and skip correctness. JSONSchemaBench's released harness measures efficiency and coverage (the quality study in §6 of its paper is not part of the harness), and its result type has no field for whether an answer was right (`epfl-dlab/jsonschemabench` `core/types.py:146-166` at `8003e84`). lm-evaluation-harness's `jsonschema_bench` task reports `json_validity` and `schema_compliance`. In vLLM's structured output serving benchmark, `correct_rate(%)` returns true for any JSON object that parses and never reads `expected` (`benchmarks/benchmark_serving_structured_output.py:708-719` at `276fbcf`). `AUTHENSOR` reported it publicly in [vllm#55441](https://github.com/vllm-project/vllm/issues/55441), and `he-yufeng` opened a fix in [vllm#55447](https://github.com/vllm-project/vllm/pull/55447), open and unmerged as of 2026-10-11.
224
+ - A different "constraint tax": arXiv:2606.25605 uses the phrase for another effect: tool calls get suppressed when `response_format` is set. This tool doesn't measure that.
225
+
226
+ ## When to use something else
227
+
228
+ - To pick the best *model*: it compares decoding arms within one model, never models against each other.
229
+ - On tasks without a checkable answer: with no gold answer it can only report validity (use JSONSchemaBench for that).
230
+ - To get a verdict on constrained decoding in general: one model and one task make one data point, as the demo shows.
231
+ - For hosted APIs: v0.1 only drives local GGUF models (with a chat template in their metadata) through llama.cpp.
232
+ - With only a few dozen items: at the live default (`--n 20`) only large effects are detectable. Plan on ~200 items (about 20 to 46 min of CPU per task on an M1). Using your own task also means writing a small Python `Task` class (see below).
233
+
234
+ ## Limitations
235
+
236
+ - One small model, quantized. Qwen2.5-0.5B at Q4_K_M, picked for disk and CPU budget at some cost to fidelity. Larger models or Q8 weights may behave differently.
237
+ - Free text extraction is itself a measurement. The `unconstrained` arm's accuracy depends on parsing the answer out of free text. The parser reads an `Answer:` line, then LaTeX `\boxed{}`, then the last number. **Support for `\boxed{}` was added after looking at the outputs.** Before that, "\boxed{1400} bananas to last for 2 months" was read as 2. The change moved exactly one item (`gsm8k_test_156`; free text accuracy 46.5% → 47.0%) and changed no conclusion.
238
+ - Truncation: 22 of 200 GSM8K free text generations hit the 384 token cap and are scored as they are. `reason_then_constrain` reuses the same free text phase, so it inherits them. The packaging turn's own cap was first 48 tokens, which cut off 8 long BFCL argument lists partway through the JSON. It is now 384, and BFCL was regenerated. The GSM8K replay header still records 48, but its longest packaging output was 13 tokens, so those generations are the same under either cap.
239
+ - Backend schema coverage: a schema the backend cannot compile is recorded per item as `unsupported_schema`. The item is dropped from every arm (comparisons stay paired) and reported, never scored as a model error. BFCL's `any` type is mapped to an explicit union of JSON types so that outlines can compile it. No items were excluded in either demo.
240
+ - An integer schema can "rescue" a wrong answer. Reasoning to 16.5 and then packaging into `{"answer": integer}` gives 16. On `gsm8k_test_143` that matched the gold answer by luck.
241
+ - Executable accuracy is not computed in these demos. GSM8K has nothing to execute, and BFCL's executable functions aren't in the dataset. The report prints the row as "not computed" (planned for v0.2). BFCL is scored with a *simplified* AST match. It maps positional arguments by declared order, which is more lenient than the official checker and favours the free text baseline. It is not the official BFCL checker.
242
+ - Reference timings are CPU only (see above). The canary shows a constraint is applied. It doesn't show it covers every feature of your schema (unsupported JSON Schema keywords are up to the backend).
243
+
244
+ ## Reproduce everything
245
+
246
+ ```bash
247
+ git clone https://github.com/macamiri/validbutwrong && cd validbutwrong
248
+ pip install -e '.[live,test]'
249
+ pytest # no model needed
250
+ validbutwrong run # both replays → reports in seconds
251
+ validbutwrong run --task gsm8k --live --n 200 # full regeneration (~46 min on an M1, CPU)
252
+ ```
253
+
254
+ Data provenance: `src/validbutwrong/data/SOURCES.md`. Model: `Qwen/Qwen2.5-0.5B-Instruct-GGUF` revision `9217f5d`, file `qwen2.5-0.5b-instruct-q4_k_m.gguf`, sha256 `74a4da8c…a7d9db` (Apache-2.0).
255
+
256
+ Built with help from AI coding agents. The architecture, methodology, statistics and analysis are the author's.
257
+
258
+ ## References
259
+
260
+ arXiv:2605.26128 · arXiv:2610.12056 · arXiv:2408.02442 · arXiv:2502.09061 · arXiv:2604.25359 · arXiv:2602.12247 · arXiv:2501.10868 · arXiv:2407.09141 (flips over averages) · arXiv:2606.25605 (different phenomenon, same name).
261
+
262
+ ## License
263
+
264
+ MIT
@@ -0,0 +1,242 @@
1
+ # validbutwrong
2
+
3
+ ![validity × correctness per decoding arm, BFCL simple, N=200](docs/quadrants-bfcl.svg)
4
+
5
+ **Function calling (BFCL, N=200): turning on a JSON Schema grammar raised Qwen2.5-0.5B's AST match accuracy by +54.5 pts [95% CI +47.5, +61.5] and raised its wrong-but-valid rate by +14.0 pts [+9.0, +19.5].** 29 outputs that were visibly broken without the grammar became well formed but wrong calls. Model and prompt were unchanged; only the grammar was switched on.
6
+
7
+ On grade school math (GSM8K, N=200), the same model with the same switch showed no detectable effect on accuracy (95% CI [−0.5, +5.0] pts). Validity says little about accuracy here. GSM8K's answer only `constrained` arm is 100% valid at 5.5% accuracy (94.5% wrong-but-valid), and on BFCL 29 of the 137 outputs the grammar made valid are wrong. Only the item ledger tells them apart.
8
+
9
+ `validbutwrong` drives a real constrained decoding backend (the outlines JSON Schema to token FSM logits processor, or llama.cpp's native GBNF grammar, on one llama.cpp runtime). It runs the same model on the same items through four decoding arms and reports schema validity, answer accuracy and the wrong-but-valid rate separately, with a transition ledger per item and an exit code for CI. It measures what a grammar costs or buys on your task. It doesn't judge whether constraints are good in general.
10
+
11
+ ```bash
12
+ pip install validbutwrong
13
+ validbutwrong run # rescores BOTH committed replays (BFCL, then GSM8K; N=200 each) in seconds, with no model download
14
+ ```
15
+
16
+ Every result in the demo tables and charts is replayed: generated once on CPU and committed. `--live` regenerates them (cost below).
17
+
18
+ ---
19
+
20
+ ## What the demos show
21
+
22
+ Qwen2.5-0.5B-Instruct (Q4_K_M GGUF), greedy decoding. 95% paired bootstrap CIs, 2,000 resamples, seed 42. The headline comparison keeps the output contract fixed and switches only the grammar (`prompt_json[S]` → `constrained[S]`: same prompt, grammar off vs on).
23
+
24
+ ### Function calling: BFCL v3 `simple`, first 200 items
25
+
26
+ The model writes a function call's arguments, scored by AST match against BFCL's `possible_answer`. Nothing is executed.
27
+
28
+ | arm | schema validity | AST match accuracy | wrong-but-valid |
29
+ |---|---|---|---|
30
+ | `unconstrained` (call in Python syntax, parsed) | n/a | 45.0% [38.0, 51.5] | n/a |
31
+ | `prompt_json[arguments]`: JSON asked for, no grammar | 31.5% [25.5, 37.5] | 26.0% [20.5, 32.0] | 5.5% [2.5, 9.0] |
32
+ | `constrained[arguments]`: same prompt, grammar on | 100.0% | 80.5% [75.0, 85.5] | 19.5% [14.5, 25.0] |
33
+ | `reason_then_constrain[arguments]` | 100.0% | 84.5% [79.0, 89.0] | 15.5% [11.0, 21.0] |
34
+
35
+ - Grammar on, same contract: AST accuracy rises +54.5 pts [+47.5, +61.5] (110 items wrong→right, 1 right→wrong). Without the grammar, 137 of 200 `prompt_json` outputs are invalid, and 129 of those copy the schema's own structure back (a `"properties"` key or `"type": "object"` at the top level) instead of filling it in.
36
+ - In the same comparison, wrong-but-valid rises +14.0 pts [+9.0, +19.5]. Per item, 108 broken outputs became right, but 29 broken outputs became well formed calls that are *wrong*, and 1 right call became wrong (9 were valid but wrong in both arms).
37
+ - Secondary (confounded) comparison against the free text baseline, a call in Python syntax with no JSON contract: `unconstrained` → `constrained` is +35.5 pts [+27.5, +43.5] (84 wrong→right, 13 right→wrong). It is smaller than the headline because the `prompt_json` baseline fails mostly by copying the schema. Here the constraint is a net gain and also hides some failures from a validity check. That second effect is why the tool reports the red cell separately.
38
+
39
+ ### Grade school math: GSM8K, first 200 test problems
40
+
41
+ ![validity × correctness per decoding arm, GSM8K, N=200](docs/quadrants-gsm8k.svg)
42
+
43
+ | arm | schema validity | answer accuracy | wrong-but-valid |
44
+ |---|---|---|---|
45
+ | `unconstrained` (free text, answer parsed) | n/a | 47.0% [40.0, 54.0] | n/a |
46
+ | `prompt_json[answer]`: JSON asked for, no grammar | 95.0% [92.0, 98.0] | 3.5% [1.0, 6.5] | 91.5% [87.5, 95.0] |
47
+ | `constrained[answer]`: same prompt, grammar on | 100.0% | 5.5% [2.5, 9.0] | 94.5% [91.0, 97.5] |
48
+ | `prompt_json[reasoning]`: `{"reasoning", "answer"}`, no grammar | 87.5% [82.5, 92.0] | 14.0% [9.0, 19.0] | 73.5% [67.5, 79.5] |
49
+ | `constrained[reasoning]`: same, grammar on | 99.5% [98.5, 100.0] | 19.5% [14.0, 25.0] | 80.0% [74.5, 85.5] |
50
+ | `reason_then_constrain[answer]`: reason freely, then constrained packaging | 100.0% | 48.0% [41.0, 55.0] | 52.0% [45.0, 59.0] |
51
+
52
+ - Grammar on, same contract: validity rises (+5.0 pts [+2.0, +8.0]; with a reasoning field +12.0 pts [+7.5, +16.5]). No detectable effect on accuracy at N=200 (answer only [−0.5, +5.0] pts; reasoning field [+0.0, +10.5] pts).
53
+ - The answer only arms score low even without the grammar (`prompt_json[answer]` 3.5%), so the grammar isn't the cause. Losing room to reason explains part of it (3.5% answer only vs 14.0% with a reasoning field, both without a grammar), and the JSON contract itself costs more (14.0% vs 47.0% free text).
54
+ - Secondary (confounded) comparison: `unconstrained` → answer only `constrained` is −41.5 pts [−49.5, −33.5] (90 right→wrong, 7 wrong→right). It changes two things at once (the reasoning text goes away *and* a grammar is added), so the tool reports it but never treats it as the grammar's effect.
55
+ - `reason_then_constrain` (reason freely, constrain late) is 48.0%, with no detectable difference from free text (0 right→wrong, 2 wrong→right).
56
+
57
+ On this model and task, the GSM8K result agrees with dottxt's point that the grammar wasn't the problem (no detectable effect at a fixed contract), and with the close to zero fixed prompt ablation in SOB. It disagrees with dottxt's finding that JSON with a reasoning field matches free text (19.5% vs 47.0% here). On this task there is no "tax" that comes from the grammar itself. BFCL shows the same model with a large effect on a different task. Another model, task or schema could come out differently, which is why you measure your own.
58
+
59
+ ## Who this is for
60
+
61
+ When software asks an AI model for an answer in a strict format, like a filled in form, it can force the model to follow that format exactly. Every answer then arrives neatly formatted, and it is easy to assume every answer is right as well. Often it isn't. Forcing the format can change the answers themselves, for better or worse, and the formatted output gives no way to tell which. This tool is for engineers who build products on AI models that must return structured data. Give it your model, your task and your format. It runs the same questions with and without the format rule and tells you, question by question, which answers stayed right, which went wrong, and how many wrong answers were sitting behind a perfectly valid format.
62
+
63
+ ## How it works
64
+
65
+ ```
66
+ task (items, prompts, schema, answer checker)
67
+ │
68
+ ├─ unconstrained ─────────────┐
69
+ ├─ prompt_json[S] ── no grammar ──┤ one llama.cpp runtime, one GGUF,
70
+ ├─ constrained[S] ── grammar ─────┤──▶ greedy decoding; the grammar is the
71
+ └─ reason_then_constrain[S*] ──────┘ ONLY thing that differs between pairs
72
+ │
73
+ replay file (JSONL: every generation + model sha256, versions, hardware, timings)
74
+ │
75
+ score ─▶ validity · accuracy · wrong-but-valid per arm · paired bootstrap CIs
76
+ per-item ledger · 2×2 quadrants (executable accuracy: only if a task supplies
77
+ an execution check; not computed in these demos)
78
+ │
79
+ report.json · report.md · quadrants.svg · ledger.csv · exit code
80
+ ```
81
+
82
+ Design decisions and why:
83
+
84
+ - Two constraint engines share one runtime. `--engine outlines` (default) or `--engine llamacpp` (GBNF). Both drive the same `llama_cpp.Llama` object and the same weights, so a speed difference between them comes from the constraint machinery alone. On the tracer item the two produced identical tokens on every arm. On that one tracer item outlines was faster on CPU (16.9 s vs 26.5 s for the GSM8K item), mostly on the long constrained string (3.2 s vs 11.8 s).
85
+ - A canary self test runs before every live run. A constraint can fail *silently*. During development, the outlines logits processor raised an `ImportError` inside llama.cpp's sampler callback. llama.cpp logged "Exception ignored" and carried on generating unconstrained text, which still happened to be valid JSON. Every engine now has to force a constant the model would never write unprompted (`{"canary": "vbw-canary-7f3q"}`) before a run starts. If it can't, the run refuses. A unit test checks that the canary *fails* on an engine that ignores the schema.
86
+ - Property order is part of the contract. `{"reasoning", "answer"}` must be generated reasoning first. Sorting schema keys (an easy cache key habit) quietly puts the answer first and removes the reasoning. The engines never sort.
87
+ - Generation and scoring are kept apart. Scoring reads only the replay file, so results rescore in seconds without a model, and an extractor fix can be applied and measured later (see Limitations).
88
+ - Rates are taken over all N. Every metric divides by all items, never by the valid subset, so arms share the same denominator. `unconstrained` has no contract, so its validity is `n/a` rather than 0.
89
+ - Nulls are printed as nulls. A difference whose 95% CI includes 0 is printed as "no detectable effect at N=…" with the interval, never rounded into an effect.
90
+
91
+ ## Use it in CI
92
+
93
+ ```bash
94
+ validbutwrong run --task gsm8k --fail-if-wrong-valid-above 0.20 # exit 1 if any constrained[*] arm exceeds 20% (reason_then_constrain is not gated)
95
+ ```
96
+
97
+ Exit codes: `0` ok · `1` gate tripped · `2` setup/usage error. You set the gate for your pipeline. The tool has no built in pass mark.
98
+
99
+ ## Point it at your own task
100
+
101
+ A task is about 30 lines: the items, a free text prompt, a JSON prompt, a schema, and how to read and check an answer. See [`examples/custom_task.py`](examples/custom_task.py) (exact match QA over your own JSONL). The arms, canary, metrics and report are reused unchanged.
102
+
103
+ ```bash
104
+ pip install 'validbutwrong[live]'
105
+ python examples/custom_task.py my_questions.jsonl path/to/any-chat-model.gguf
106
+ ```
107
+
108
+ ## Inputs and outputs (machine readable)
109
+
110
+ ```yaml
111
+ # INPUT 1: CLI
112
+ command: validbutwrong run [--task all|bfcl-simple|gsm8k] [--live] [--n INT] [--model PATH.gguf]
113
+ [--engine outlines|llamacpp] [--threads INT] [--replay PATH.jsonl]
114
+ [--out DIR] [--fail-if-wrong-valid-above RATE]
115
+ defaults: {task: all, mode: replay, out: validbutwrong-out, engine: outlines, live_n: 20}
116
+ exit_codes: {0: ok, 1: gate tripped, 2: usage or setup error}
117
+
118
+ # INPUT 2: your own task (Python), a subclass of validbutwrong.Task
119
+ Task:
120
+ name: str
121
+ correctness_label: str
122
+ schemas: {schema_name: JSONSchema} # used by prompt_json[S] and constrained[S]
123
+ answer_schema: str # schema_name used by reason_then_constrain
124
+ items(n: int|None) -> list[Item] # Item(id: str, prompt: str, gold: Any, meta: dict)
125
+ free_prompt(item) -> str
126
+ json_prompt(item, schema) -> str
127
+ extract_free(text, item) -> Any|None
128
+ extract_json(obj, item) -> Any|None
129
+ is_correct(answer, item) -> bool
130
+ example_row (examples/custom_task.py): {"question": str, "answer": str}
131
+
132
+ # OUTPUT: per task, report.json, report.md, quadrants.svg, ledger.csv
133
+ report.json:
134
+ task: str
135
+ correctness_label: str
136
+ n: int # scored items
137
+ mode: replay|live
138
+ excluded: {n: int, ids: [str], reason: str} # backend could not compile the schema
139
+ meta: {task, n, engine, model_sha256, model_source, versions, hardware, decoding,
140
+ max_tokens, selftest_seconds, started, ended, wall_seconds, wall_clock_seconds}
141
+ arms:
142
+ <arm_key>: # unconstrained | prompt_json[S] | constrained[S] | reason_then_constrain[S]
143
+ n: int
144
+ schema_validity: {rate: float, ci95: [lo, hi]} | null # null for unconstrained
145
+ answer_accuracy: {rate: float, ci95: [lo, hi]}
146
+ wrong_but_valid: {rate: float, ci95: [lo, hi]} | null
147
+ executable_accuracy: null # not computed in these demos
148
+ quadrant: {valid_right, valid_wrong, invalid_right, invalid_wrong} | null
149
+ comparisons:
150
+ - {from: arm_key, to: arm_key, n: int, role: str,
151
+ deltas: {<metric>: {delta: float, ci95: [lo, hi], excludes_0: bool}},
152
+ transitions: {"<state> -> <state>": int}, # state = valid|invalid|n/a "/" right|wrong
153
+ flips: {right_to_wrong: int, wrong_to_right: int}}
154
+ ledger: [{id: str, <arm_key>: "valid/right" | "valid/wrong" | "invalid/right" | "invalid/wrong" | "n/a/right" | "n/a/wrong"}]
155
+ ```
156
+
157
+ ## Regenerate the numbers (`--live`)
158
+
159
+ ```bash
160
+ pip install 'validbutwrong[live]'
161
+ validbutwrong run --live --n 20 # downloads the pinned GGUF (~491 MB, sha256-checked)
162
+ validbutwrong run --live --model my-model.gguf --n 50 --engine llamacpp
163
+ ```
164
+
165
+ What `--live` costs: the `[live]` extra (llama-cpp-python, outlines, numba), a single ~491 MB model download, and generation time. Measured on the reference CPU (Apple M1, 4 threads, CPU only), with every arm included:
166
+
167
+ | task | mean s/item (median, p90) | `--n 20` (the live default) | items that fit in 10 min | full N=200 |
168
+ |---|---|---|---|---|
169
+ | `bfcl-simple` | 5.99 (5.42, 8.64) | ≈ 2 min | 104 | 1,201.6 s ≈ 20 min |
170
+ | `gsm8k` | 13.86 (13.56, 18.08) | ≈ 6 min | 37 | 2,775.6 s ≈ 46 min |
171
+
172
+ This is why the default is the committed replay: 37 GSM8K items, the most that fit in a 10 minute live run, are too few for useful intervals. Replays live in `src/validbutwrong/replays/`, with the model sha256, backend versions, hardware and timings in their header. On determinism, independent repeat runs produced identical generations byte for byte (GSM8K, first 24 items: 24/24; BFCL: 192 of 200 items identical across two full runs, and the other 8 are the ones the packaging cap fix below changed on purpose).
173
+
174
+ A note on CPU only runs: on macOS, a stock `pip install llama-cpp-python` builds with Metal forced on (`CMakeLists.txt` L129 at v0.3.36 sets `GGML_METAL` ON with `FORCE`, which `CMAKE_ARGS` cannot override). The reference numbers used llama-cpp-python 0.3.36 built from source with that line set to OFF. On a stock install, timings and possibly generations will differ from the committed replay. This has not been verified. The canary self test still checks the constraint is applied.
175
+
176
+ ## Validated examples
177
+
178
+ ```
179
+ Dataset: GSM8K, first 200 test problems: github.com/openai/grade-school-math (MIT), commit 3101c7d
180
+ Task: answer a grade school math word problem; schemas {"answer": int} and {"reasoning": str, "answer": int}
181
+ Observed pattern: grammar raises validity; no detectable accuracy effect at a fixed contract; (secondary, confounded) removing reasoning AND adding a grammar: −41.5 pts
182
+ Recommended method: item ledger; headline = prompt_json[S] → constrained[S]
183
+ Result: table above (replayed, N=200, seed 42 for the bootstrap)
184
+ Reproducible with: validbutwrong run --task gsm8k
185
+
186
+ Dataset: BFCL v3 "simple": huggingface.co/datasets/gorilla-llm/Berkeley-Function-Calling-Leaderboard (Apache-2.0), revision 61fc060
187
+ Task: write the arguments of one function call; schema = the function's parameters (BFCL types converted to JSON Schema)
188
+ Observed pattern: grammar raises AST accuracy +54.5 pts AND raises wrong-but-valid +14.0 pts (a large effect where GSM8K's fixed contract effect was not detectable)
189
+ Recommended method: AST match accuracy + wrong-but-valid, prompt_json → constrained
190
+ Result: table above (replayed, N=200, seed 42 for the bootstrap)
191
+ Reproducible with: validbutwrong run --task bfcl-simple (live, optional: validbutwrong run --task bfcl-simple --live --n 200)
192
+ ```
193
+
194
+ ## Prior work
195
+
196
+ The idea is already in the literature, and this tool doesn't claim it.
197
+
198
+ - The effect is named and measured. arXiv:2605.26128 (*The Constraint Tax*, 2026) reports, on models under 3B and synthetic tasks, answer accuracy falling 19.7% → 11.0% while validity rose 61.5% → 100.0%. It recommends reporting validity, answer accuracy, executable accuracy and the wrong-valid rate as separate numbers. It released no code. This tool turns that recommendation into a command. It isn't a replication: on a model the paper also used (Qwen2.5-0.5B; here Q4_K_M) but on different tasks, no accuracy effect from the grammar itself was detected on GSM8K at N=200. On function calling the paper's direction is the opposite of the BFCL demo: it reports 91.5% executable accuracy with prompt only JSON vs 48.0% under a hard tool call schema (Qwen2.5-1.5B).
199
+ - Results go both ways. The JSONSchemaBench paper (arXiv:2501.10868, §6.2) found that constrained decoding, "regardless of the framework, achieves higher performance than the unconstrained setting" on GSM8K, Last Letters and Shuffle Objects (best ≈ +3%). *Let Me Speak Freely?* (arXiv:2408.02442) and ExtractBench (arXiv:2602.12247, §4.3) report a drop in accuracy. SOB (arXiv:2604.25359, §6.3) adds schema enforcement at a fixed prompt and finds Value Accuracy "changes only slightly, from −0.007 to +0.033". dottxt's [*Say What You Mean*](https://blog.dottxt.ai/say-what-you-mean.html) argues the comparisons showing a drop were not like for like. CRANE (arXiv:2502.09061) proposes reasoning augmented constrained decoding within one generation, and the two turn `reason_then_constrain` arm borrows the idea. *Structure Tax* (arXiv:2610.12056, October 2026) puts the loss down to schema design: reasoning first field order matches or beats free form accuracy, while answer first order drops sharply, especially in smaller models. On GSM8K above, JSON with the reasoning first beat JSON with the answer first (19.5% vs 5.5% under the grammar) but stayed well below free text (47.0%) for this 0.5B model.
200
+ - The ablation already exists as study code. [`appier-research/structure-gen`](https://github.com/appier-research/structure-gen) (the 2408.02442 code, with outlines/xgrammar/TGI backends) and dottxt's [`GSM8K_JSON.ipynb`](https://github.com/dottxt-ai/demos/tree/main/say-what-you-mean) each run it for their own paper on fixed tasks. This tool adds the packaging: point it at *your* model, task and schema and get paired flips per item, separate metrics, a canary checked constraint and a CI exit code.
201
+ - The usual harnesses check validity and skip correctness. JSONSchemaBench's released harness measures efficiency and coverage (the quality study in §6 of its paper is not part of the harness), and its result type has no field for whether an answer was right (`epfl-dlab/jsonschemabench` `core/types.py:146-166` at `8003e84`). lm-evaluation-harness's `jsonschema_bench` task reports `json_validity` and `schema_compliance`. In vLLM's structured output serving benchmark, `correct_rate(%)` returns true for any JSON object that parses and never reads `expected` (`benchmarks/benchmark_serving_structured_output.py:708-719` at `276fbcf`). `AUTHENSOR` reported it publicly in [vllm#55441](https://github.com/vllm-project/vllm/issues/55441), and `he-yufeng` opened a fix in [vllm#55447](https://github.com/vllm-project/vllm/pull/55447), open and unmerged as of 2026-10-11.
202
+ - A different "constraint tax": arXiv:2606.25605 uses the phrase for another effect: tool calls get suppressed when `response_format` is set. This tool doesn't measure that.
203
+
204
+ ## When to use something else
205
+
206
+ - To pick the best *model*: it compares decoding arms within one model, never models against each other.
207
+ - On tasks without a checkable answer: with no gold answer it can only report validity (use JSONSchemaBench for that).
208
+ - To get a verdict on constrained decoding in general: one model and one task make one data point, as the demo shows.
209
+ - For hosted APIs: v0.1 only drives local GGUF models (with a chat template in their metadata) through llama.cpp.
210
+ - With only a few dozen items: at the live default (`--n 20`) only large effects are detectable. Plan on ~200 items (about 20 to 46 min of CPU per task on an M1). Using your own task also means writing a small Python `Task` class (see below).
211
+
212
+ ## Limitations
213
+
214
+ - One small model, quantized. Qwen2.5-0.5B at Q4_K_M, picked for disk and CPU budget at some cost to fidelity. Larger models or Q8 weights may behave differently.
215
+ - Free text extraction is itself a measurement. The `unconstrained` arm's accuracy depends on parsing the answer out of free text. The parser reads an `Answer:` line, then LaTeX `\boxed{}`, then the last number. **Support for `\boxed{}` was added after looking at the outputs.** Before that, "\boxed{1400} bananas to last for 2 months" was read as 2. The change moved exactly one item (`gsm8k_test_156`; free text accuracy 46.5% → 47.0%) and changed no conclusion.
216
+ - Truncation: 22 of 200 GSM8K free text generations hit the 384 token cap and are scored as they are. `reason_then_constrain` reuses the same free text phase, so it inherits them. The packaging turn's own cap was first 48 tokens, which cut off 8 long BFCL argument lists partway through the JSON. It is now 384, and BFCL was regenerated. The GSM8K replay header still records 48, but its longest packaging output was 13 tokens, so those generations are the same under either cap.
217
+ - Backend schema coverage: a schema the backend cannot compile is recorded per item as `unsupported_schema`. The item is dropped from every arm (comparisons stay paired) and reported, never scored as a model error. BFCL's `any` type is mapped to an explicit union of JSON types so that outlines can compile it. No items were excluded in either demo.
218
+ - An integer schema can "rescue" a wrong answer. Reasoning to 16.5 and then packaging into `{"answer": integer}` gives 16. On `gsm8k_test_143` that matched the gold answer by luck.
219
+ - Executable accuracy is not computed in these demos. GSM8K has nothing to execute, and BFCL's executable functions aren't in the dataset. The report prints the row as "not computed" (planned for v0.2). BFCL is scored with a *simplified* AST match. It maps positional arguments by declared order, which is more lenient than the official checker and favours the free text baseline. It is not the official BFCL checker.
220
+ - Reference timings are CPU only (see above). The canary shows a constraint is applied. It doesn't show it covers every feature of your schema (unsupported JSON Schema keywords are up to the backend).
221
+
222
+ ## Reproduce everything
223
+
224
+ ```bash
225
+ git clone https://github.com/macamiri/validbutwrong && cd validbutwrong
226
+ pip install -e '.[live,test]'
227
+ pytest # no model needed
228
+ validbutwrong run # both replays → reports in seconds
229
+ validbutwrong run --task gsm8k --live --n 200 # full regeneration (~46 min on an M1, CPU)
230
+ ```
231
+
232
+ Data provenance: `src/validbutwrong/data/SOURCES.md`. Model: `Qwen/Qwen2.5-0.5B-Instruct-GGUF` revision `9217f5d`, file `qwen2.5-0.5b-instruct-q4_k_m.gguf`, sha256 `74a4da8c…a7d9db` (Apache-2.0).
233
+
234
+ Built with help from AI coding agents. The architecture, methodology, statistics and analysis are the author's.
235
+
236
+ ## References
237
+
238
+ arXiv:2605.26128 · arXiv:2610.12056 · arXiv:2408.02442 · arXiv:2502.09061 · arXiv:2604.25359 · arXiv:2602.12247 · arXiv:2501.10868 · arXiv:2407.09141 (flips over averages) · arXiv:2606.25605 (different phenomenon, same name).
239
+
240
+ ## License
241
+
242
+ MIT
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "validbutwrong"
7
- version = "0.1.0"
8
- description = "Measure what grammar-constrained decoding costs (or buys) you in answer correctness, not just schema validity."
7
+ version = "0.1.1"
8
+ description = "Measure what constrained decoding with a grammar costs or buys in answer correctness, as well as schema validity."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  """validbutwrong: what grammar-constrained decoding costs (or buys) you in answer correctness."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
4
4
 
5
5
  from .report import build as build_report
6
6
  from .run import generate, read_replay
@@ -31,6 +31,16 @@ class Generation:
31
31
  n_tokens: int
32
32
 
33
33
 
34
+ def render_chat_template(template: str, messages: list[dict]) -> str:
35
+ """Render a GGUF chat template. The template comes from the model file, so it is untrusted
36
+ input: render it in jinja2's immutable sandbox, the fix llama-cpp-python adopted for
37
+ CVE-2024-34359. A template that reaches for Python internals raises SecurityError."""
38
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
39
+
40
+ env = ImmutableSandboxedEnvironment()
41
+ return env.from_string(template).render(messages=messages, add_generation_prompt=True)
42
+
43
+
34
44
  class Engine:
35
45
  name = "engine"
36
46
 
@@ -50,10 +60,7 @@ class Engine:
50
60
  def format(self, messages: list[dict]) -> str:
51
61
  if not self._template:
52
62
  raise ValueError("GGUF has no tokenizer.chat_template; pass a chat model")
53
- import jinja2
54
-
55
- env = jinja2.Environment(loader=jinja2.BaseLoader())
56
- return env.from_string(self._template).render(messages=messages, add_generation_prompt=True)
63
+ return render_chat_template(self._template, messages)
57
64
 
58
65
  def generate(self, messages: list[dict], schema: dict | None, max_tokens: int) -> Generation:
59
66
  raise NotImplementedError