The most expensive mistake in LLM testing is reaching for an LLM judge to check things a regex can check. Assertions form a hierarchy — deterministic, then semantic, then judge — and the discipline is always the same: use the cheapest layer that can express the requirement, and let each layer filter for the next.
Tier: Core · Workflow area: Testing AI systems — foundations
This playbook decomposes a real LLM feature's requirements into the three layers, implements each with concrete assertions, and wires them so a two-cent judge call never runs on an output a free schema check already condemned.
Decompose requirements into the three layers. Take each requirement and ask: could a program check this? Then: could similarity to a reference check it? Only what survives both questions earns a judge. Worked example for a support-reply generator:
Layer 1 — deterministic (free, instant, binary):
- Valid JSON matching ReplySchema; reply_text ≤ 800 chars
- Contains no email/phone/card patterns (PII regex)
- Never contains "guarantee", "refund approved" (banned-phrase list)
- Mentions the ticket ID it was given
Layer 2 — semantic (cheap, graded):
- reply_text is similar to the topic of the customer's issue
(embedding similarity vs the ticket text ≥ 0.78)
Layer 3 — judge (expensive, judgment):
- Tone is professional and empathetic; does not blame the customer
- Proposes a concrete next step consistent with policy context
Most teams discover 60–70% of their "AI quality" requirements land in layer 1. That discovery alone pays for this exercise.
Implement layer 1 as hard gates.
# promptfooconfig.yaml (excerpt)
defaultTest:
assert:
- type: is-json
value: { $ref: "./schemas/reply.json" }
- type: not-contains-any
value: ["guarantee", "refund approved"]
- type: javascript
value: "!/[\\w.+-]+@[\\w-]+\\.[a-z]{2,}/i.test(output) || 'PII: email leaked'"
These are binary and non-negotiable: a failure here fails the case with zero further spend. There is nothing to debate about invalid JSON.
Implement layer 2 where meaning matters but judgment does not.
assert:
- type: similar
value: "{{ticket_text}}"
threshold: 0.78
Calibrate the threshold empirically: run 20 known-good and 20 known-bad (off-topic) outputs, plot the two similarity distributions, and set the threshold in the gap. A threshold you did not calibrate is a random number with confidence.
Reserve the judge for genuine judgment — and constrain it.
assert:
- type: llm-rubric
provider: openai:gpt-5.2 # or your strongest available grader
value: |
Grade ONLY these two criteria, one verdict each:
1. TONE: professional and empathetic; never blames the customer.
2. ACTION: proposes a concrete next step a support agent could
actually take given: {{policy_context}}
Output JSON: {"tone": pass|fail, "action": pass|fail,
"evidence": "<quote from the reply for each verdict>"}
Constrained rubric, forced evidence quotes, structured verdict. A judge asked "is this a good reply?" is an opinion generator; a judge asked two specific questions with quote-backed answers is a measurement instrument (calibrating that instrument is its own playbook).
Order execution cheap-to-expensive and short-circuit. Promptfoo evaluates asserts per test; keep judge asserts in a separate, later-stage config (or use assert weights with a CI script that skips stage 2 on stage-1 failure) so malformed outputs never reach paid grading. In DeepEval/pytest, this is just test ordering with an early assert:
def test_reply(case):
out = generate_reply(case.ticket)
reply = ReplySchema.model_validate_json(out) # layer 1: raises = fail, stop
assert no_pii(reply.reply_text) # layer 1
assert topic_similarity(reply, case) >= 0.78 # layer 2
assert judge_tone_and_action(reply, case) # layer 3: only now
Review the hierarchy quarterly — demote aggressively. Every judge criterion is a candidate for demotion: once you notice the judge's "concrete next step" verdict correlates 0.95 with "reply contains one of 12 known action phrases," demote it to a deterministic list check and keep the judge for what still needs it. The hierarchy's health metric is judge-call share trending down while coverage grows.
Cheapest-layer-first is not (only) about the bill. Deterministic checks are trustworthy and debuggable; semantic checks are calibratable; judges are neither until carefully engineered. Pushing every requirement down the hierarchy maximizes the fraction of your suite that is beyond argument.
AnswerRelevancyMetric, layer 3 via GEval with the same constrained-criteria discipline; pytest ordering gives you short-circuiting for free.When a stakeholder asks "can the AI check X?", answer with the hierarchy question first: "Can a program check X?" You will be surprised how often the honest answer is yes — and every yes is a check that never flakes, never bills, and never argues.