Lesson 4 - Structured-Output & Schema Checks
Welcome to Structured-Output & Schema Checks
So far this module has graded Docent – our assistant for the Meridian serverless database docs – on free text: exact match, token-level F1, ROUGE. Those all assume the output is a sentence a human reads. But a huge, underrated share of real LLM work isn’t prose at all. It’s structured output: JSON that another program consumes. A router that must return {"intent": "billing"}. An extractor that must return a list of typed fields. A tool call whose arguments must match a schema. And, in this lesson, a version of Docent that answers as {"answer": ..., "doc_id": ..., "confidence": ...} so a downstream UI can show the source page and a confidence badge.
For output like this, “correct” doesn’t start with “is the answer right.” It starts with “does it parse and validate at all.” A JSON answer that’s wrapped in markdown fences, or missing the confidence field, or carries a doc_id that isn’t a real Meridian page, breaks the program that consumes it – before anyone even asks whether the answer text was accurate. That failure is completely deterministic to detect: you either can load the object and check its shape, or you can’t. No model, no human, no judgement – just code that returns the same verdict every time.
This lesson builds that check. You’ll make Docent emit JSON, define a schema for the exact shape you require, and grade every reply against a check ladder – parse, fields, types, enums and ranges, cross-field constraints – reporting a single structured-output pass rate.
In this lesson, you will:
- See why structured output is its own category of eval, where “correct” begins with “parses and validates”
- Walk the five-rung check ladder and learn which real failure each rung catches
- Build a
pydanticvalidator for Docent’s JSON shape, withdoc_idconstrained to Meridian’s 8 pages andconfidencein - Write a robust
extract_json()that strips markdown fences and surrounding prose - Run the validator live on Docent for a real pass rate, and deterministically on hand-crafted good and broken JSON so the logic is reproducible
Why structured output is its own eval problem
When Docent returns a sentence, a wrong answer still runs – a human reads “the Pro plan costs 30 dollars,” notices it’s wrong, and moves on. When Docent returns JSON that a program parses, a malformed reply doesn’t just look wrong, it crashes the caller or, worse, silently drops into an error path the user never sees. The stakes of format are different, so the evaluation is different: before you score whether the answer is right, you score whether it’s usable.
That gives structured-output eval a useful property. Free-text quality is fuzzy and often needs a model or a human to judge. Format validity is crisp: a reply either conforms to the schema or it doesn’t, and a few lines of code decide which. This is the most deterministic corner of the whole eval landscape – and one teams routinely skip, shipping a “JSON mode” feature whose real-world parse rate they’ve never actually measured.
The mental shift is to treat the schema as the reference. In the earlier lessons the reference was a correct answer string. Here the reference is a specification of shape: which keys must exist, what type each holds, which values are allowed. Docent’s contract for this lesson is exactly:
answer– a string (the short answer text)doc_id– one of Meridian’s 8 doc ids (auth,ratelimits,plans,regions,errors,backups,sdks,deletion) ornullwhen the docs don’t answer the questionconfidence– a number in
Any reply that satisfies all three is a structured-output pass. Everything else is a fail, and the reason it failed tells you what to fix.
The check ladder
Validation isn’t one test, it’s a sequence of them, and the order matters: you can’t check a field’s type until you know the field exists, and you can’t check that until the text parses as JSON at all. Think of it as a ladder where each rung is a deterministic gate. A reply climbs until it either clears the top or falls out at the first rung it fails.
Rung 1 – does it parse as JSON? The most common failure isn’t a subtle one. Models love to be helpful: they wrap the JSON in a ```json fence, or prefix it with “Sure! Here’s the JSON:”, or append a trailing comma after the last field. All of those make json.loads() throw. Rung 1 is where a robust extract_json() earns its keep – it strips the fences and surrounding prose so a reply that’s structurally fine but cosmetically wrapped still gets a fair parse.
Rung 2 – are the required fields present? A parsed object might be missing confidence, or have docid where you expected doc_id. The downstream program that reads reply["confidence"] would KeyError. This rung asserts every required key exists.
Rung 3 – correct types? A classic model tic is emitting a number as a string: "confidence": "0.9". It parses, the field exists, but reply["confidence"] > 0.8 compares a string to a float and either errors or lies. This rung asserts each field holds the type the schema demands.
Rung 4 – values in the allowed set or range? doc_id must be one of 8 known pages or null – a model that invents "pricing" (a plausible-sounding page that doesn’t exist) fails here, because your UI can’t link to a page that isn’t in the corpus. confidence must be within – a 1.5 is nonsense a badge can’t render. Enums and bounds are where hallucinated-but-well-typed values get caught.
Rung 5 – cross-field constraints. The subtlest rung: fields that are individually valid but jointly contradictory. For Docent, a natural constraint is if doc_id is null, the answer should be a refusal – claiming no source while confidently stating a fact is incoherent. Not every schema needs this rung, but when your fields relate, it’s where you enforce the relationship.
Report the rung, not just the verdict
A structured-output pass rate is the headline, but the distribution of failures across rungs is what you act on. If most failures land on rung 1, your model is emitting valid JSON that your parser is choking on – fix extract_json, or turn on the provider’s JSON mode, and the pass rate jumps without touching the model. If failures cluster on rung 4, the model is hallucinating enum values – that’s a prompt or fine-tuning problem, not a parsing one. Same pass rate, completely different fix. Always record which rung caught each failure.
Building the validator
We’ll express the schema as a pydantic model, which gives us rungs 2, 3, and 4 almost for free: declaring answer: str enforces presence and type, and a field validator constrains doc_id to the allowed set. confidence: float with ge=0.0, le=1.0 enforces the range. Rung 1 – parsing, with fence-stripping – we handle ourselves in extract_json, because that’s exactly the messy real-world step a schema library assumes has already happened.
Here’s the validator and the parse helper. extract_json first looks for a fenced block and pulls its contents; failing that, it grabs everything between the first { and the last }, which strips leading “Sure! Here’s…” prose and trailing “hope that helps!” alike:
import json, re
from typing import Optional
from pydantic import BaseModel, ValidationError, Field, field_validator
DOC_IDS = {"auth", "ratelimits", "plans", "regions", "errors", "backups", "sdks", "deletion"}
class DocentReply(BaseModel):
answer: str
doc_id: Optional[str] = None
confidence: float = Field(ge=0.0, le=1.0)
@field_validator("doc_id")
@classmethod
def doc_id_in_corpus(cls, v):
if v is not None and v not in DOC_IDS:
raise ValueError(f"doc_id {v!r} is not one of the 8 Meridian ids or null")
return v
def extract_json(text):
"""Pull the first JSON object out of a model reply, stripping markdown fences."""
fenced = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
candidate = fenced.group(1) if fenced else text
start = candidate.find("{")
end = candidate.rfind("}")
if start == -1 or end == -1 or end < start:
return None
return candidate[start:end + 1]
def validate(raw):
"""Run the check ladder. Returns (ok, stage_reached, detail)."""
payload = extract_json(raw)
if payload is None:
return (False, "parse", "no JSON object found in the text")
try:
obj = json.loads(payload)
except json.JSONDecodeError as e:
return (False, "parse", f"JSON did not parse: {e.msg}")
try:
DocentReply(**obj)
except ValidationError as e:
err = e.errors()[0]
loc = ".".join(str(p) for p in err["loc"]) or "(root)"
return (False, "schema", f"{loc}: {err['msg']}")
return (True, "ok", "valid")validate returns three things, not just a boolean: whether it passed, which stage it reached (parse, schema, or ok), and a human-readable detail. That’s the “report the rung, not just the verdict” advice made concrete – the return value tells you why, which is what you’ll aggregate to decide what to fix.
A deterministic pass over hand-crafted cases
Before we spend a single live call, we validate the validator on inputs we control. This is the reproducible heart of the lesson: eight hand-crafted strings – two well-formed, six broken in a different way each – run through validate. Because there’s no model in the loop, the verdicts are identical every run, so we can assert exactly what a schema check should and shouldn’t catch. The broken cases mirror the real failures from the ladder: a missing field, an out-of-corpus enum, a confidence of 1.5, a number sent as a string, plain non-JSON prose, and – importantly – two cases that should still pass despite cosmetic wrapping (a fenced block and JSON buried in prose), to prove extract_json recovers them rather than failing them.
# A handful of hand-crafted strings: two good, six broken in different ways.
CASES = [
("good, fenced", '```json\n{"answer": "429", "doc_id": "ratelimits", "confidence": 0.95}\n```'),
("good, null doc_id", '{"answer": "I don\'t have that in the docs.", "doc_id": null, "confidence": 0.4}'),
("missing field", '{"answer": "600 per minute", "doc_id": "ratelimits"}'),
("bad enum", '{"answer": "25 dollars", "doc_id": "pricing", "confidence": 0.8}'),
("confidence 1.5", '{"answer": "90 days", "doc_id": "backups", "confidence": 1.5}'),
("confidence as string",'{"answer": "24 hours", "doc_id": "deletion", "confidence": "high"}'),
("not JSON at all", "Sure! The Pro plan allows 600 requests per minute."),
("trailing prose", 'Here is the answer: {"answer": "MERIDIAN_API_KEY", "doc_id": "sdks", "confidence": 0.9} hope that helps!'),
]
print("Deterministic schema-check ladder")
print("-" * 66)
print(f"{'case':<22}{'pass':<7}{'stage':<9}detail")
print("-" * 66)
passes = 0
for name, raw in CASES:
ok, stage, detail = validate(raw)
passes += ok
print(f"{name:<22}{('PASS' if ok else 'FAIL'):<7}{stage:<9}{detail}")
print("-" * 66)
rate = passes / len(CASES)
print(f"structured-output pass rate: {passes}/{len(CASES)} = {rate:.0%}")Deterministic schema-check ladder
------------------------------------------------------------------
case pass stage detail
------------------------------------------------------------------
good, fenced PASS ok valid
good, null doc_id PASS ok valid
missing field FAIL schema confidence: Field required
bad enum FAIL schema doc_id: Value error, doc_id 'pricing' is not one of the 8 Meridian ids or null
confidence 1.5 FAIL schema confidence: Input should be less than or equal to 1
confidence as string FAIL schema confidence: Input should be a valid number, unable to parse string as a number
not JSON at all FAIL parse no JSON object found in the text
trailing prose PASS ok valid
------------------------------------------------------------------
structured-output pass rate: 3/8 = 38%Read the stage and detail columns – they’re the payoff. The not JSON at all case fails at the parse stage; every other broken case fails at schema with a precise reason. And the two cosmetically-wrapped cases (good, fenced and trailing prose) pass, because extract_json peeled off the fence and the surrounding prose before parsing. That’s the difference between a brittle check that punishes a model for adding backticks and a fair one that grades the actual content. Because no model is involved, running it again gives byte-identical output:
passes2 = sum(validate(raw)[0] for _, raw in CASES)
print(f"run 2 -> pass rate: {passes2}/{len(CASES)} = {passes2/len(CASES):.0%}")run 2 -> pass rate: 3/8 = 38%Passing the schema is necessary, not sufficient
A structured-output pass means the reply is shaped correctly – it does not mean the answer is right. The good, fenced case passes with "answer": "429", which happens to be correct, but a reply of {"answer": "500", "doc_id": "ratelimits", "confidence": 0.95} would also pass the schema check while being factually wrong. Schema validation is the gate you clear first: it confirms the output is usable, and only then do the content metrics from earlier in this module (exact match, F1) get to weigh in on whether it’s correct. Report both – a 100% pass rate with a 40% exact-match rate is a very different product than the reverse.
Running it live on Docent
Now the real thing. We reuse Docent’s corpus and retrieval verbatim, but change the prompt to demand JSON in exactly our shape, and validate whatever comes back. Live model output varies run to run, so treat the numbers below as a real recorded run, not a fixed constant – yours will be close but not byte-identical.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
# DOCS is the canonical 8-page Meridian corpus; retrieve() is Docent's keyword-overlap retriever.
def docent_json(question, docs, model="claude-haiku-4-5"):
ctx = "\n\n".join(f"[{d['id']}] {d['title']}\n{d['text']}" for d in retrieve(question, docs))
prompt = (
"Answer the question using ONLY the context. Respond with a single JSON object and nothing else, "
'with exactly these keys: "answer" (a short string), "doc_id" (the id of the doc you used, '
"one of " + ", ".join(sorted(DOC_IDS)) + ", or null if the context does not answer it), and "
'"confidence" (a number from 0 to 1).\n\n'
f"Context:\n{ctx}\n\nQuestion: {question}"
)
msg = client.messages.create(model=model, max_tokens=200,
messages=[{"role": "user", "content": prompt}])
return msg.content[0].text
questions = [
"What HTTP status code does Meridian return when you exceed the rate limit?",
"How much does the Pro plan cost per month?",
"How long are backups retained on the Scale plan?",
"Does Meridian support MongoDB-style aggregation pipelines?", # out-of-scope -> expect doc_id null
]
print("Live structured-output check over Docent (claude-haiku-4-5)")
print("-" * 70)
print(f"{'question':<46}{'pass':<7}stage")
print("-" * 70)
passes = 0
for q in questions:
raw = docent_json(q, DOCS)
ok, stage, detail = validate(raw)
passes += ok
short = (q[:43] + "...") if len(q) > 46 else q
print(f"{short:<46}{('PASS' if ok else 'FAIL'):<7}{stage}")
print("-" * 70)
print(f"structured-output pass rate: {passes}/{len(questions)} = {passes/len(questions):.0%}")Live structured-output check over Docent (claude-haiku-4-5)
----------------------------------------------------------------------
question pass stage
----------------------------------------------------------------------
What HTTP status code does Meridian return ...PASS ok
How much does the Pro plan cost per month? PASS ok
How long are backups retained on the Scale ...PASS ok
Does Meridian support MongoDB-style aggrega...PASS ok
----------------------------------------------------------------------
structured-output pass rate: 4/4 = 100%Four for four – on this run. But look at what a single raw reply actually contained, printed verbatim:
print(docent_json(questions[0], DOCS))```json
{
"answer": "HTTP 429",
"doc_id": "ratelimits",
"confidence": 0.95
}
The model wrapped its JSON in a ```` ```json ```` fence -- exactly the rung-1 hazard from the ladder. A naive `json.loads(raw)` on that string would have thrown, and a naive eval would have scored this *correct* reply as a format failure. Because `extract_json` strips the fence first, the reply is graded on its content and passes. That one detail is the difference between a live pass rate of 100% and a spuriously low one: the model was fine; only the wrapping was in the way. This is why you build the fence-stripping *before* you trust the pass rate -- and why a low structured-output score should always send you to look at raw replies, not straight to blaming the model.
Live outputs will shift from run to run: a different phrasing of `answer`, a `confidence` of `0.9` instead of `0.95`, occasionally a stray sentence outside the JSON. The validator's job is to give an honest verdict on whatever comes back, every time -- and to tell you, via the `stage`, whether a miss was a parse problem you can engineer around or a genuine schema violation the model produced.
---
## Practice Exercises
### Exercise 1: Add the cross-field (rung 5) constraint
The `DocentReply` model covers rungs 2 through 4, but not rung 5. Add a check that enforces the constraint *if `doc_id` is `null`, the `answer` must look like a refusal* (and, conversely, a non-null `doc_id` should not be a refusal). Then feed it an incoherent-but-well-typed reply like `{"answer": "The Pro plan costs 25 dollars per month.", "doc_id": null, "confidence": 0.95}` and confirm it now fails at the schema stage.
Hint
Cross-field rules need to see the whole object, so use a @model_validator(mode="after") rather than a single-field validator. Inside it, define a small refusal test – e.g. is_refusal = "don't have" in self.answer.lower() or "not in the docs" in self.answer.lower() – and raise ValueError("doc_id is null but answer is not a refusal") when self.doc_id is None and not is_refusal. Keep the refusal test simple here; a later module builds a proper refusal classifier instead of a substring check.
### Exercise 2: Break out the pass rate by rung
A single pass rate hides *where* replies fail. Modify the deterministic loop over `CASES` to also count how many failures happened at each stage (`parse` vs `schema`), and print those counts. On the eight hand-crafted cases, how many fail at `parse` and how many at `schema`? Explain why that split would change your fix if it came from live traffic instead.
Hint
A collections.Counter over the stage returned by validate for the failing cases gives the breakdown in two lines: stages = Counter(validate(raw)[1] for _, raw in CASES if not validate(raw)[0]). On CASES you’ll get one parse failure (the not JSON at all string) and four schema failures. In production, a spike in parse failures points at wrapping/formatting you can fix with extract_json or JSON mode; a spike in schema failures points at the model inventing fields, enum values, or out-of-range numbers – a prompt or model problem, not a parsing one.
### Exercise 3: Make Docent stumble, then catch it
The live prompt asks nicely for "a single JSON object and nothing else." Write a *deliberately weaker* prompt -- for instance, "Answer as JSON with an answer, the doc id, and your confidence," with no instruction to omit surrounding prose -- run it live on the four questions, and print the raw replies alongside the `validate` verdict. Does the weaker prompt produce more fenced blocks or trailing prose? Does `extract_json` still recover them, and does the pass rate hold?
Hint
Copy docent_json to docent_json_loose and swap only the instruction text; keep max_tokens small and reuse the same four questions to keep the call cost tiny. The interesting outcome isn’t a number, it’s the raw text: weaker prompts tend to add more prose and fences, which is exactly what extract_json is built to survive – so a well-built validator often keeps the pass rate high even when the prompt gets sloppy. If a reply ever fails, print its stage to see whether it was the prose (rung 1) or a genuine schema slip (rungs 2-4). Remember the answer varies run to run, so report what you actually saw, not what you expected.
---
## Summary
Structured output is its own category of evaluation, and one teams underrate: whenever an LLM feature emits machine-readable JSON, "correct" begins with "parses and validates," because a malformed reply breaks the program that consumes it before anyone asks whether the content was right. You grade it with a five-rung check ladder -- (1) parses as JSON, (2) required fields present, (3) correct types, (4) values in the allowed set or range, (5) cross-field constraints -- where each rung is a deterministic pass/fail and the rung that catches a reply names the defect. You built a `pydantic` validator for Docent's `{answer, doc_id, confidence}` contract, with `doc_id` constrained to Meridian's 8 pages or `null` and `confidence` in \([0, 1]\), plus a robust `extract_json()` that strips markdown fences and surrounding prose so a wrapped-but-valid reply is graded fairly. A deterministic run over eight hand-crafted strings gave a reproducible 3/8 pass rate -- catching a missing field, a bad enum, an out-of-range and a mistyped `confidence`, and plain non-JSON, while correctly passing two cosmetically-wrapped good cases. Live on four Docent questions the pass rate was 4/4, and a verbatim raw reply showed exactly why `extract_json` matters: the model fenced its JSON, which a naive parser would have failed.
### Key Concepts
- **Structured-output eval** -- grading outputs by *shape* (a schema) rather than by free-text quality; "correct" starts with "parses and validates."
- **The check ladder** -- parse → required fields → types → enums/ranges → cross-field constraints; an ordered sequence of deterministic gates, each catching a distinct failure.
- **Structured-output pass rate** -- the fraction of replies that clear all rungs; report it alongside the *distribution of failures by rung*, which tells you what to fix.
- **`extract_json`** -- a robust parse step that strips markdown fences and surrounding prose, so a structurally-valid reply isn't failed for cosmetic wrapping.
- **Schema as reference** -- a `pydantic` model (or JSON Schema) encodes required keys, types, enums, and bounds; validation is fully reproducible, unlike the model output it grades.
- **Necessary, not sufficient** -- passing the schema means *usable*, not *accurate*; pair the pass rate with content metrics (exact match, F1) to judge correctness.
### Why This Matters
Structured output is everywhere in production LLM systems -- routers, extractors, tool calls, function arguments, any feature whose result another service reads -- and a malformed reply there isn't a cosmetic blemish, it's an outage or a silent data-corruption bug. Yet format validity is the single most measurable thing about an LLM feature: a few lines of deterministic code give you a pass rate you can track on every deploy, alert on when it drops, and break down by rung to know whether the fix is a parser tweak or a prompt change. Teams that skip this ship "JSON mode" features whose real parse rate they've never measured and discover the gap in an incident. Teams that build the ladder catch a fenced-JSON regression in an eval run instead. You now have a validator that turns "does the output follow the structure?" into a number -- and the next lesson assembles this, exact match, F1, and ROUGE into a single reference-metric scorecard for Docent.
---
## Continue Building Your Skills
You can now grade an output nobody reads as prose -- the JSON another program consumes -- by walking it up a five-rung ladder and reporting a structured-output pass rate, with `extract_json` making the check fair to a model that likes to wrap its answer in fences. That's a deterministic, reproducible gate you can run on every deploy: 3/8 on the hand-crafted cases that must fail for specific reasons, 4/4 live on Docent when the wrapping is handled. The next lesson brings the whole module together. In the guided project you'll fold this schema check in with exact match, token-level F1, and ROUGE to build a single reference-metric scorecard -- one function that takes Docent's answers and a golden set and returns every deterministic number you've learned, in a form you can diff between two versions of the assistant.