phantomfill-lint
Find the fields in your JSON Schema that force a model to make something up.
pip install phantomfill-lint
phantomfill-lint schemas/*.json
Zero dependencies. Python 3.8+.
The one-minute version
A language model asked "what are people saying about this post?" — where the post has engagement counts but no reply text — will tell you honestly that there is no reply data. It does this reliably in prose.
Give it this schema instead:
{
"type": "object",
"properties": {
"sentiment": { "type": "string", "enum": ["positive", "negative", "mixed"] },
"main_themes": { "type": "array", "items": {"type": "string"}, "minItems": 3 }
},
"required": ["sentiment", "main_themes"]
}
and it invents an answer. Every time. There is no legal value that means "I don't know," so the model picks one that is a lie, because the schema left it no other move.
This was measured across thirteen models. Ten of them fabricate in 100% of trials under a schema like the one above. GPT-5.5 goes from 2% fabrication in prose to 100% under required fields, on identical input. (paper)
phantomfill-lint finds those fields before you ship them.
What it flags
| rule | severity | why |
|---|---|---|
required-enum-no-escape |
high | Closed vocabulary, every value is a claim. Measured 20/20 fabrication. |
required-array-min-items |
high | minItems: 3 means "invent three things" when there are none. |
required-boolean-no-escape |
high | true/false is a two-value vocabulary; a coin flip is recorded as a finding. |
required-number-no-escape |
medium | A number carries no disclaimer; a guess is indistinguishable from a measurement. |
required-string-no-escape |
low | Models hedge in free strings (measured 0/20), but the hedge lands where a consumer reads a value. |
required-array-not-nullable |
low | [] can express absence — flagged only if you must distinguish "none found" from "not determined". |
A field is cleared if it is optional, nullable, or its enum contains a recognized abstention value (insufficient_evidence, unknown, not_available, n/a, and similar — see ESCAPE_VOCAB).
neutral and other are not escape values. They are answers.
Usage
phantomfill-lint schema.json # human-readable
phantomfill-lint 'schemas/**/*.json' # globs
phantomfill-lint tools.json --format json # machine-readable
phantomfill-lint tools.json --format github # GitHub Actions annotations
phantomfill-lint tools.json --min-severity high # only the serious ones
phantomfill-lint tools.json --fail-on never # report, never fail the build
Exit codes: 0 clean, 1 findings at or above --fail-on (default high), 2 file/parse error.
Understands bare JSON Schema, OpenAI function/tool definitions, OpenAI structured-output json_schema blocks, and Anthropic input_schema tool definitions — including lists of them, $defs/$ref, nested objects, and objects inside arrays.
As a library
from phantomfill_lint import lint
findings = lint(my_schema)
for f in findings:
print(f.severity, f.path, f.message, f.fix)
In CI
- name: Lint LLM output schemas
run: |
pip install phantomfill-lint
phantomfill-lint 'src/**/schemas/*.json' --format github
The fix is usually one line
- "enum": ["positive", "negative", "mixed"]
+ "enum": ["positive", "negative", "mixed", "insufficient_evidence"]
Two caveats the measurements insist on:
An escape value is necessary, not sufficient. Frontier models take the exit when you give them one. Every open-weight model tested fabricated anyway, at 60–100%, even with insufficient_evidence sitting in the enum. Under grammar-constrained decoding, where the escape token was guaranteed reachable by the sampler, five open models used it 0 times out of 203 on the fields that mattered — while happily using it on the one field where escaping conceded nothing. Adding the value fixes your schema; it does not fix your model.
Measure it for the model you actually deploy. Resistance to this failure does not track parameter count and does not transfer across domains. Within one model family, the smallest model refused, the mid-sized one fabricated 90% of the time, and the largest refused again.
License
MIT.
Citation
@article{rana2026phantomfill,
title = {PhantomFill: When the Form Demands an Answer, Language Models Invent One},
author = {Rana Muhammad Usman},
year = {2026}
}
Metadata
Release files for phantomfill-lint 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| phantomfill_lint-0.1.0.tar.gz | 20.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| phantomfill_lint-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 34.3 kB
Release files / phantomfill_lint-0.1.0.tar.gz
| Download URL | phantomfill_lint-0.1.0.tar.gz |
|---|---|
| Size | 20.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7833ba0a90c327c105c1cc1ee6dc8844432a2fc346a8ba62f24e61b6dd595c21
|
|
BLAKE2b-256 checksum How to use checksums |
2766e6e9672e1c87089c8d0bf3419265b75d34f8cb45d11f642b00fdad738fcf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|
Release files / phantomfill_lint-0.1.0-py3-none-any.whl
| Download URL | phantomfill_lint-0.1.0-py3-none-any.whl |
|---|---|
| Size | 14.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e6512f74caa3b5935864eedc249544b8c206c527c19a6435aa4fcb6e16586d44
|
|
BLAKE2b-256 checksum How to use checksums |
f05959f0c27c56b25948cb162ee35f8704a1d29ea57a6f39d0acd58f51220b9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|