validbutwrong 0.1.0__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 (28) hide show
  1. validbutwrong-0.1.0/LICENSE +21 -0
  2. validbutwrong-0.1.0/NOTICE +20 -0
  3. validbutwrong-0.1.0/PKG-INFO +264 -0
  4. validbutwrong-0.1.0/README.md +242 -0
  5. validbutwrong-0.1.0/pyproject.toml +31 -0
  6. validbutwrong-0.1.0/setup.cfg +4 -0
  7. validbutwrong-0.1.0/src/validbutwrong/__init__.py +9 -0
  8. validbutwrong-0.1.0/src/validbutwrong/arms.py +67 -0
  9. validbutwrong-0.1.0/src/validbutwrong/cli.py +155 -0
  10. validbutwrong-0.1.0/src/validbutwrong/data/SOURCES.md +9 -0
  11. validbutwrong-0.1.0/src/validbutwrong/data/bfcl_v3_simple.jsonl +400 -0
  12. validbutwrong-0.1.0/src/validbutwrong/data/bfcl_v3_simple_possible_answer.jsonl +400 -0
  13. validbutwrong-0.1.0/src/validbutwrong/data/gsm8k_test_first200.jsonl +200 -0
  14. validbutwrong-0.1.0/src/validbutwrong/engines.py +149 -0
  15. validbutwrong-0.1.0/src/validbutwrong/metrics.py +122 -0
  16. validbutwrong-0.1.0/src/validbutwrong/replays/bfcl-simple.jsonl +201 -0
  17. validbutwrong-0.1.0/src/validbutwrong/replays/gsm8k.jsonl +201 -0
  18. validbutwrong-0.1.0/src/validbutwrong/report.py +139 -0
  19. validbutwrong-0.1.0/src/validbutwrong/run.py +96 -0
  20. validbutwrong-0.1.0/src/validbutwrong/schema.py +66 -0
  21. validbutwrong-0.1.0/src/validbutwrong/tasks.py +277 -0
  22. validbutwrong-0.1.0/src/validbutwrong.egg-info/PKG-INFO +264 -0
  23. validbutwrong-0.1.0/src/validbutwrong.egg-info/SOURCES.txt +26 -0
  24. validbutwrong-0.1.0/src/validbutwrong.egg-info/dependency_links.txt +1 -0
  25. validbutwrong-0.1.0/src/validbutwrong.egg-info/entry_points.txt +2 -0
  26. validbutwrong-0.1.0/src/validbutwrong.egg-info/requires.txt +10 -0
  27. validbutwrong-0.1.0/src/validbutwrong.egg-info/top_level.txt +1 -0
  28. validbutwrong-0.1.0/tests/test_core.py +279 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 macamiri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ validbutwrong
2
+ Copyright (c) 2026 macamiri. Licensed under the MIT License (see LICENSE).
3
+
4
+ This package bundles third-party data, redistributed under their own licences:
5
+
6
+ 1. GSM8K (first 200 test problems) — src/validbutwrong/data/gsm8k_test_first200.jsonl
7
+ Source: https://github.com/openai/grade-school-math (commit 3101c7d5072418e28b9008a6636bde82a006892c)
8
+ Licence: MIT License, Copyright (c) 2021 OpenAI.
9
+ The committed replay src/validbutwrong/replays/gsm8k.jsonl contains model generations for these problems.
10
+
11
+ 2. Berkeley Function Calling Leaderboard (BFCL v3 "simple" + possible_answer) —
12
+ src/validbutwrong/data/bfcl_v3_simple.jsonl, src/validbutwrong/data/bfcl_v3_simple_possible_answer.jsonl
13
+ Source: https://huggingface.co/datasets/gorilla-llm/Berkeley-Function-Calling-Leaderboard
14
+ (revision 61fc0608cfd831fcfbbaa676ebdfef0ed963eeda)
15
+ Licence: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
16
+ The committed replay src/validbutwrong/replays/bfcl-simple.jsonl contains model generations derived from these items.
17
+ Files are unmodified copies of the upstream files.
18
+
19
+ The replays were generated with Qwen/Qwen2.5-0.5B-Instruct-GGUF (Apache-2.0); no model weights are bundled.
20
+ See src/validbutwrong/data/SOURCES.md for pinned revisions.
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: validbutwrong
3
+ Version: 0.1.0
4
+ Summary: Measure what grammar-constrained decoding costs (or buys) you in answer correctness, not just 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 wrong calls. Same model, same prompt; only the grammar was toggled.
28
+
29
+ **Grade-school math (GSM8K, N=200), same model, same toggle: no detectable effect on accuracy** (95% CI [−0.5, +5.0] pts). Validity level says little about accuracy: 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 per-item ledger separates them.
30
+
31
+ `validbutwrong` drives a real constrained-decoding backend (outlines' JSON-Schema → 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 per-item transition ledger and an exit code for CI. It measures what a grammar costs, *or buys*, on your task. It does not tell you whether constraints are good.
32
+
33
+ ```bash
34
+ pip install validbutwrong
35
+ validbutwrong run # re-scores 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. Regenerating is opt-in, with `--live` (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 toggles only the grammar at a fixed output contract** (`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 emits a function call's arguments. Scored by AST match against BFCL's `possible_answer`, never by execution.
49
+
50
+ | arm | schema validity | AST-match accuracy | wrong-but-valid |
51
+ |---|---|---|---|
52
+ | `unconstrained` (Python-syntax call, 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 +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 echo the schema's own structure back (a top-level `"properties"` or `"type": "object"`) instead of filling it in.
58
+ - In the same comparison, the wrong-but-valid rate rises +14.0 pts [+9.0, +19.5]. Per item: 108 broken outputs became right, but 29 broken outputs became well-formed *wrong* calls, and 1 right call became wrong (9 were valid-but-wrong either way).
59
+ - Secondary (confounded) comparison against the free-text baseline, a Python-syntax call 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 echoing the schema. The constraint is net-positive here *and* moves some failures out of sight of 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 low accuracy of the answer-only arms is already there *without* the grammar (`prompt_json[answer]` 3.5%). So it is not the grammar's effect. Removing 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 (no reasoning text *and* a grammar), 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%: no detectable difference from free text (0 right→wrong, 2 wrong→right).
78
+
79
+ On this model and task the GSM8K result is consistent with dottxt's point that the grammar was not the problem (no detectable effect at a fixed contract), and with the near-null fixed-prompt ablation in SOB. It is *not* consistent with dottxt's finding that JSON with a reasoning field matches free text (19.5% vs 47.0% here). It shows no grammar-specific "tax" on this task. BFCL shows the same model with a large effect on a different task. A different model, task or schema can come out differently again; that is the point of measuring.
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 that means every answer is right. It doesn't. Forcing the format can change the answers themselves, for better or for worse, and nothing in the formatted output tells you 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 hidden 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 on 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 is the constraint machinery, not the model runtime. On the tracer item the two produced token-identical text on every arm. On that one tracer item outlines was faster on CPU (16.9 s vs 26.5 s for the GSM8K item), mainly on the long constrained string (3.2 s vs 11.8 s).
107
+ - **A canary self-test before every live run.** A constraint can fail *silently*. During development, 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) silently puts the answer first and removes the reasoning. The engines never sort.
109
+ - **Generation and scoring are separate.** Scoring reads only the replay file, so results re-score in seconds without a model, and an extractor fix can be applied and measured after the fact (see Limitations).
110
+ - **Rates over all N.** Every metric divides by all items, never by the valid subset, so arms are compared on the same denominator. `unconstrained` has no contract, so its validity is `n/a`, not 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. The gate is yours to set on 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: 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): subclass 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 one-time ~491 MB model download, and generation time. Measured on the reference CPU (Apple M1, 4 threads, CPU only), 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
+ That 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. Determinism: independent repeat runs produced byte-identical generations (GSM8K, first 24 items: 24/24; BFCL: 192 of 200 items identical across two full runs, and the other 8 are the items the packaging-cap fix below changed on purpose).
195
+
196
+ CPU-only note: a stock `pip install llama-cpp-python` on macOS builds with Metal force-enabled (`CMakeLists.txt` L129 at v0.3.36 sets `GGML_METAL` ON with `FORCE`, which `CMAKE_ARGS` cannot override). The reference numbers were produced with llama-cpp-python 0.3.36 built from source with that line patched to OFF. On a stock install, timings, and possibly generations, will differ from the committed replay; this was not verified. The canary self-test still checks that the constraint binds.
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: per-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: emit 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, opt-in: validbutwrong run --task bfcl-simple --live --n 200)
214
+ ```
215
+
216
+ ## Why this exists (and what already does)
217
+
218
+ The idea is not new, and the tool doesn't claim it is.
219
+
220
+ - **The phenomenon is named and measured.** arXiv:2605.26128 (*The Constraint Tax*, 2026) reports, on sub-3B models 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 *separately*. It released no code. This tool is that recommendation as a command. It is not a replication: on a model the paper also used (Qwen2.5-0.5B; here Q4_K_M) but on different tasks, a grammar-specific accuracy effect was not 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.** JSONSchemaBench's 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 degradation. 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 degradation comparisons weren't apples-to-apples. CRANE (arXiv:2502.09061) proposes reasoning-augmented constrained decoding within one generation; the two-turn `reason_then_constrain` arm is inspired by it. *Structure Tax* (arXiv:2610.12056, October 2026) attributes the loss to schema design: reasoning-first field order matches or exceeds free-form accuracy, while answer-first order drops steeply, particularly in smaller models. On GSM8K above, reasoning-first JSON beat 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 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. What this tool adds is the packaging: point it at *your* model, task and schema, get paired per-item flips, separated metrics, a canary-checked constraint and a CI exit code.
223
+ - **The common harnesses measure validity, not correctness.** JSONSchemaBench's released harness measures efficiency and coverage (its paper's §6 quality study is not part of the harness); 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 parseable JSON object and never reads `expected` (`benchmarks/benchmark_serving_structured_output.py:708-719` at `276fbcf`). That was reported publicly by `AUTHENSOR` in [vllm#55441](https://github.com/vllm-project/vllm/issues/55441), with a fix by `he-yufeng` in [vllm#55447](https://github.com/vllm-project/vllm/pull/55447), open and unmerged as of 2026-10-11.
224
+ - **Not the same "constraint tax":** arXiv:2606.25605 uses the same phrase for a different effect, tool calls being suppressed when `response_format` is set. This tool doesn't measure that.
225
+
226
+ ## When NOT to use this
227
+
228
+ - To pick the best *model*: it compares decoding arms within one model, not models against each other.
229
+ - On tasks without a checkable answer: with no gold, it can only report validity (use JSONSchemaBench for that).
230
+ - To get a verdict on constrained decoding in general: one model and task is one data point, as the demo shows.
231
+ - For hosted APIs: v0.1 drives local GGUF models (with a chat template in their metadata) through llama.cpp only.
232
+ - With only a few dozen items: at the live default (`--n 20`) only large effects are detectable. Plan on ~200 items (≈20–46 min of CPU per task on an M1). Your own task also means writing a small Python `Task` class (see below).
233
+
234
+ ## Limitations (honest)
235
+
236
+ - **One small model, quantized.** Qwen2.5-0.5B at Q4_K_M (chosen for disk and CPU budget, not fidelity). Larger models or Q8 weights may behave differently.
237
+ - **Free-text extraction is 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. **`\boxed{}` support was added after inspecting outputs.** Without it, "\boxed{1400} bananas to last for 2 months" was read as 2. That change moved exactly one item (`gsm8k_test_156`; free-text accuracy 46.5% → 47.0%) and no conclusion.
238
+ - **Truncation:** 22 of 200 GSM8K free-text generations hit the 384-token cap and are scored as produced. `reason_then_constrain` reuses the same free-text phase, so it inherits them. The packaging turn's own cap was originally 48 tokens, which cut off 8 long BFCL argument lists mid-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 identical under either cap.
239
+ - **Backend schema coverage:** a schema the backend cannot compile is recorded per item as `unsupported_schema`. That 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 JSON type union so that outlines can compile it; 0 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}` yields 16. On `gsm8k_test_143` that matched gold by luck.
241
+ - **Executable accuracy is not computed in these demos:** GSM8K has no execution, 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 by a *simplified* AST match (it maps positional arguments by declared order, which is more lenient than the official checker, so it is charitable to the free-text baseline). It is not the official BFCL checker.
242
+ - **CPU-only reference timings** (see above). The canary proves a constraint binds; it doesn't prove the constraint matches your schema's every feature (unsupported JSON Schema keywords are the backend's responsibility).
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 assistance 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 wrong calls. Same model, same prompt; only the grammar was toggled.
6
+
7
+ **Grade-school math (GSM8K, N=200), same model, same toggle: no detectable effect on accuracy** (95% CI [−0.5, +5.0] pts). Validity level says little about accuracy: 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 per-item ledger separates them.
8
+
9
+ `validbutwrong` drives a real constrained-decoding backend (outlines' JSON-Schema → 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 per-item transition ledger and an exit code for CI. It measures what a grammar costs, *or buys*, on your task. It does not tell you whether constraints are good.
10
+
11
+ ```bash
12
+ pip install validbutwrong
13
+ validbutwrong run # re-scores 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. Regenerating is opt-in, with `--live` (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 toggles only the grammar at a fixed output contract** (`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 emits a function call's arguments. Scored by AST match against BFCL's `possible_answer`, never by execution.
27
+
28
+ | arm | schema validity | AST-match accuracy | wrong-but-valid |
29
+ |---|---|---|---|
30
+ | `unconstrained` (Python-syntax call, 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 +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 echo the schema's own structure back (a top-level `"properties"` or `"type": "object"`) instead of filling it in.
36
+ - In the same comparison, the wrong-but-valid rate rises +14.0 pts [+9.0, +19.5]. Per item: 108 broken outputs became right, but 29 broken outputs became well-formed *wrong* calls, and 1 right call became wrong (9 were valid-but-wrong either way).
37
+ - Secondary (confounded) comparison against the free-text baseline, a Python-syntax call 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 echoing the schema. The constraint is net-positive here *and* moves some failures out of sight of 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 low accuracy of the answer-only arms is already there *without* the grammar (`prompt_json[answer]` 3.5%). So it is not the grammar's effect. Removing 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 (no reasoning text *and* a grammar), 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%: no detectable difference from free text (0 right→wrong, 2 wrong→right).
56
+
57
+ On this model and task the GSM8K result is consistent with dottxt's point that the grammar was not the problem (no detectable effect at a fixed contract), and with the near-null fixed-prompt ablation in SOB. It is *not* consistent with dottxt's finding that JSON with a reasoning field matches free text (19.5% vs 47.0% here). It shows no grammar-specific "tax" on this task. BFCL shows the same model with a large effect on a different task. A different model, task or schema can come out differently again; that is the point of measuring.
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 that means every answer is right. It doesn't. Forcing the format can change the answers themselves, for better or for worse, and nothing in the formatted output tells you 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 hidden 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 on 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 is the constraint machinery, not the model runtime. On the tracer item the two produced token-identical text on every arm. On that one tracer item outlines was faster on CPU (16.9 s vs 26.5 s for the GSM8K item), mainly on the long constrained string (3.2 s vs 11.8 s).
85
+ - **A canary self-test before every live run.** A constraint can fail *silently*. During development, 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) silently puts the answer first and removes the reasoning. The engines never sort.
87
+ - **Generation and scoring are separate.** Scoring reads only the replay file, so results re-score in seconds without a model, and an extractor fix can be applied and measured after the fact (see Limitations).
88
+ - **Rates over all N.** Every metric divides by all items, never by the valid subset, so arms are compared on the same denominator. `unconstrained` has no contract, so its validity is `n/a`, not 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. The gate is yours to set on 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: 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): subclass 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 one-time ~491 MB model download, and generation time. Measured on the reference CPU (Apple M1, 4 threads, CPU only), 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
+ That 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. Determinism: independent repeat runs produced byte-identical generations (GSM8K, first 24 items: 24/24; BFCL: 192 of 200 items identical across two full runs, and the other 8 are the items the packaging-cap fix below changed on purpose).
173
+
174
+ CPU-only note: a stock `pip install llama-cpp-python` on macOS builds with Metal force-enabled (`CMakeLists.txt` L129 at v0.3.36 sets `GGML_METAL` ON with `FORCE`, which `CMAKE_ARGS` cannot override). The reference numbers were produced with llama-cpp-python 0.3.36 built from source with that line patched to OFF. On a stock install, timings, and possibly generations, will differ from the committed replay; this was not verified. The canary self-test still checks that the constraint binds.
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: per-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: emit 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, opt-in: validbutwrong run --task bfcl-simple --live --n 200)
192
+ ```
193
+
194
+ ## Why this exists (and what already does)
195
+
196
+ The idea is not new, and the tool doesn't claim it is.
197
+
198
+ - **The phenomenon is named and measured.** arXiv:2605.26128 (*The Constraint Tax*, 2026) reports, on sub-3B models 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 *separately*. It released no code. This tool is that recommendation as a command. It is not a replication: on a model the paper also used (Qwen2.5-0.5B; here Q4_K_M) but on different tasks, a grammar-specific accuracy effect was not 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.** JSONSchemaBench's 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 degradation. 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 degradation comparisons weren't apples-to-apples. CRANE (arXiv:2502.09061) proposes reasoning-augmented constrained decoding within one generation; the two-turn `reason_then_constrain` arm is inspired by it. *Structure Tax* (arXiv:2610.12056, October 2026) attributes the loss to schema design: reasoning-first field order matches or exceeds free-form accuracy, while answer-first order drops steeply, particularly in smaller models. On GSM8K above, reasoning-first JSON beat 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 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. What this tool adds is the packaging: point it at *your* model, task and schema, get paired per-item flips, separated metrics, a canary-checked constraint and a CI exit code.
201
+ - **The common harnesses measure validity, not correctness.** JSONSchemaBench's released harness measures efficiency and coverage (its paper's §6 quality study is not part of the harness); 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 parseable JSON object and never reads `expected` (`benchmarks/benchmark_serving_structured_output.py:708-719` at `276fbcf`). That was reported publicly by `AUTHENSOR` in [vllm#55441](https://github.com/vllm-project/vllm/issues/55441), with a fix by `he-yufeng` in [vllm#55447](https://github.com/vllm-project/vllm/pull/55447), open and unmerged as of 2026-10-11.
202
+ - **Not the same "constraint tax":** arXiv:2606.25605 uses the same phrase for a different effect, tool calls being suppressed when `response_format` is set. This tool doesn't measure that.
203
+
204
+ ## When NOT to use this
205
+
206
+ - To pick the best *model*: it compares decoding arms within one model, not models against each other.
207
+ - On tasks without a checkable answer: with no gold, it can only report validity (use JSONSchemaBench for that).
208
+ - To get a verdict on constrained decoding in general: one model and task is one data point, as the demo shows.
209
+ - For hosted APIs: v0.1 drives local GGUF models (with a chat template in their metadata) through llama.cpp only.
210
+ - With only a few dozen items: at the live default (`--n 20`) only large effects are detectable. Plan on ~200 items (≈20–46 min of CPU per task on an M1). Your own task also means writing a small Python `Task` class (see below).
211
+
212
+ ## Limitations (honest)
213
+
214
+ - **One small model, quantized.** Qwen2.5-0.5B at Q4_K_M (chosen for disk and CPU budget, not fidelity). Larger models or Q8 weights may behave differently.
215
+ - **Free-text extraction is 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. **`\boxed{}` support was added after inspecting outputs.** Without it, "\boxed{1400} bananas to last for 2 months" was read as 2. That change moved exactly one item (`gsm8k_test_156`; free-text accuracy 46.5% → 47.0%) and no conclusion.
216
+ - **Truncation:** 22 of 200 GSM8K free-text generations hit the 384-token cap and are scored as produced. `reason_then_constrain` reuses the same free-text phase, so it inherits them. The packaging turn's own cap was originally 48 tokens, which cut off 8 long BFCL argument lists mid-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 identical under either cap.
217
+ - **Backend schema coverage:** a schema the backend cannot compile is recorded per item as `unsupported_schema`. That 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 JSON type union so that outlines can compile it; 0 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}` yields 16. On `gsm8k_test_143` that matched gold by luck.
219
+ - **Executable accuracy is not computed in these demos:** GSM8K has no execution, 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 by a *simplified* AST match (it maps positional arguments by declared order, which is more lenient than the official checker, so it is charitable to the free-text baseline). It is not the official BFCL checker.
220
+ - **CPU-only reference timings** (see above). The canary proves a constraint binds; it doesn't prove the constraint matches your schema's every feature (unsupported JSON Schema keywords are the backend's responsibility).
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 assistance 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
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
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."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "NOTICE"]
13
+ authors = [{ name = "macamiri" }]
14
+ keywords = ["llm", "structured-output", "constrained-decoding", "json-schema", "grammar", "evaluation", "outlines", "llama-cpp"]
15
+ dependencies = ["jsonschema>=4"]
16
+
17
+ [project.optional-dependencies]
18
+ live = ["llama-cpp-python>=0.3.36", "outlines>=1.3", "numba", "jinja2"]
19
+ test = ["pytest>=8"]
20
+
21
+ [project.scripts]
22
+ validbutwrong = "validbutwrong.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/macamiri/validbutwrong"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ validbutwrong = ["data/*.jsonl", "data/*.md", "replays/*.jsonl"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]