When an LLM's JSON feeds downstream code, schema validity is a contract — and a model emitting valid JSON 98% of the time breaks it dozens of times a day at production volume. Measure validity as a rate under repeated sampling, test the retry/fallback path, and prove the consumer survives the malformed 2%.
Tier: Core · Workflow area: Testing AI systems — foundations
You will define the contract in Pydantic/JSON Schema, measure validity rates across strata with repeated sampling, verify the repair pipeline actually repairs, and fault-inject malformed outputs into the consumer — because the contract test is not "does the model comply" but "does the system hold when it doesn't."
KeyError/ValidationError alerts trace back to model responses.Make the contract strict and singular. One schema artifact, shared by production and tests:
from pydantic import BaseModel, Field
from typing import Literal
class TicketExtraction(BaseModel, extra="forbid"):
category: Literal["billing", "shipping", "product", "account", "other"]
priority: Literal["p1", "p2", "p3"]
summary: str = Field(max_length=400)
order_ids: list[str] = Field(default_factory=list)
extra="forbid" matters: models pad objects with unrequested fields, and tolerant parsing lets schema drift accumulate invisibly until something downstream depends on a field the model sometimes forgets.
Measure validity as a rate, stratified, under repeated sampling.
from collections import Counter
results = Counter()
for case in golden_slice: # include the hard strata
for _ in range(20):
raw = extract(case.input) # production code path
try:
TicketExtraction.model_validate_json(raw)
results[case.stratum, "valid"] += 1
except Exception as e:
results[case.stratum, type(e).__name__] += 1
log_sample(case.id, raw, e) # keep the malformed bodies!
happy_path : 400/400 valid (1.000)
long_input : 187/200 valid (0.935) — 9 truncated JSON, 4 extra field
edge_format : 278/300 valid (0.927) — 14 markdown-fenced, 8 prose preamble
Two lessons appear in every first run: validity is input-dependent (aggregate rates hide the weak stratum), and failures cluster into a small taxonomy — truncation, code-fence wrapping, prose preamble, invented fields, wrong enum casing. Keep the malformed samples; steps 4–5 feed on them.
The same rig in Promptfoo, if you prefer config:
defaultTest:
options: { repeat: 20 }
assert:
- type: is-json
value: file://schemas/ticket_extraction.json
tests:
- vars: { ticket: file://golden/long-input-01.txt }
threshold: 0.95
is-json with a schema file gives per-case validity rates and CI thresholds with no harness code — right choice when the prompt-regression gate from the previous playbook already exists.
Test the repair pipeline as its own unit. Production should already have one (strip fences → retry with error feedback → fallback); test each stage against the real malformed samples collected in step 2:
@pytest.mark.parametrize("raw", load_malformed_samples())
def test_repair_pipeline(raw):
result = repair_and_validate(raw, TicketExtraction)
assert result.ok or result.fell_back # never a raised exception
Then measure the composite rate: raw validity 93.5% + one feedback-retry may compose to 99.8%. The composite, not the raw rate, is your real contract number — and both belong in the release criteria (raw rate guards cost and latency; composite guards correctness).
Fault-inject at the consumer boundary — the half everyone skips. The contract test is bidirectional: prove the consumer survives contract breaches. Have an assistant generate the attack set from your schema:
Given this Pydantic model, generate 25 malformed variants a language
model might realistically emit: truncated mid-string, markdown-fenced,
single-quoted keys, wrong enum casing ("Billing"), extra fields,
null where a list is required, valid JSON of the WRONG shape,
prose apology followed by valid JSON. Output as a JSON array of strings.
Feed each variant into the consumer in a test. Required behavior: reject cleanly, route to fallback, log with the sample attached — never a stack trace in a user path, never a silent partial write. A consumer that only ever met valid outputs in testing has an untested error path in production by definition.
Gate and monitor on rates. CI: per-stratum raw validity ≥ its threshold (e.g. 0.95 hard strata, 0.99 happy path), composite ≥ 0.999, and the fault-injection suite green. Production: emit a schema_valid boolean metric per call and alert on rate drops — a provider-side model update can move validity overnight with zero deploys on your side, and this metric is how you find out before your users do.
"The model outputs valid JSON" is not a fact, it is a rate — per stratum, per model version, changing without notice. Systems survive by measuring the rate, repairing the gap, and proving the consumer holds when repair fails. Test all three or you have tested nothing.
extra="forbid"). Rerun step 2 after enabling — validity rises, rarely to 1.0 for strict schemas; the rig stays.json.loads succeeds.json.loads alone. Syntactic validity without schema validation misses wrong shapes, wrong enums, and extra fields — most of the taxonomy.extra="allow", optional-everything) converts contract failures into silent data-quality failures. Repair explicitly; never relax the contract to make tests pass.Version your schema like an API: when a field is added or an enum grows, bump the version, rerun the full validity matrix on the new schema before deploy, and keep the old schema's tests until the last consumer migrates. Most "the model got worse" incidents after a schema change are actually contract changes nobody re-measured.