The practitioner hierarchy is strict: assertions before judges. Anything checkable by code — schema validity, exact answers, SQL that executes, numbers that reconcile — should be checked by code: deterministic, free, instant, and immune to judge bias.
Tier: Advanced · Workflow area: Evaluation Engineering
This playbook builds that deterministic layer as a pytest-style harness, then wires it (plus your pb-35/pb-36 metrics) into CI so a prompt tweak, model bump, or retriever change that drops faithfulness blocks the merge — the "eval harness in the pipeline" pattern employers now name verbatim in postings.
Every LLM system has some code-checkable surface — if you believe yours has none, you have not decomposed the output yet (a free-text answer still has: length bounds, required citations present, no leaked system-prompt text, valid markdown).
Inventory the deterministic checks. For each output type, list assertions in four families: structural (parses as JSON, validates against the Pydantic model — track schema-validity rate, not just pass/fail), exact/set match (labels, entities, IDs against gold), execution-based (generated SQL runs against a fixture DB and result rows match; generated code passes its tests; extracted totals reconcile: sum of line items == stated total), property-based (dates within range, citations point at real chunk IDs, no PII patterns, answer length within bounds).
Build the harness as parametrized tests.
import pytest, json
from mymodels import InvoiceExtract # Pydantic
from harness import run_pipeline, load_golden
CASES = load_golden("evals/golden/v3/extraction.jsonl")
@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
def test_schema_valid(case, cached_outputs):
out = cached_outputs[case["id"]]
InvoiceExtract.model_validate_json(out) # structural
def test_totals_reconcile(cached_outputs):
fails = [cid for cid, o in cached_outputs.items()
if not reconciles(json.loads(o))]
assert len(fails) / len(cached_outputs) <= 0.02, fails
Key design: generate outputs once per run into a cache fixture, then run all assertions against the cache — N assertions must not mean N × API calls. Rate-based thresholds (<= 0.02) for flaky-tolerant checks; hard asserts only for never-acceptable failures (PII leak, prompt-injection echo).
Layer the statistical metrics on top. Add a second suite that runs Ragas faithfulness/recall (pb-35) and the calibrated judge (pb-36) over the golden set and asserts against thresholds — e.g., faithfulness_mean >= 0.75 and faithfulness_p10 >= 0.55 (guard the tail, not just the mean). Judge-based tests inherit judge noise: assert with a tolerance band derived from observed run-to-run variance (measure it: run the suite 5x on identical code; the spread defines your minimum detectable regression).
Wire the CI gate.
# .github/workflows/llm-evals.yml
name: llm-evals
on:
pull_request:
paths: ["prompts/**", "pipeline/**", "evals/**", "config/model.yaml"]
jobs:
evals:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: "3.12"}
- run: pip install -r evals/requirements.txt
- name: Deterministic suite (hard gate)
run: pytest evals/deterministic -q --junitxml=det.xml
env: {LLM_API_KEY: "${{ secrets.LLM_API_KEY }}"}
- name: Metric suite (thresholded gate)
run: pytest evals/metrics -q --junitxml=met.xml
- name: Post diff report
if: always()
run: python evals/report.py --against origin/main --out pr_comment.md
Path-filter so the suite runs only when prompts, pipeline code, model config, or the evals themselves change. Make the deterministic suite a required check (hard gate); the metric suite starts as a soft gate (warn) for two weeks of variance observation, then hardens.
Make failures diagnosable in the PR. The report step posts: metric deltas vs main (with the tolerance band), the specific failing case IDs, and per-case before/after outputs for the worst 5. A gate that says "faithfulness dropped" without showing which cases teaches developers to re-run until green; a gate that shows the broken cases teaches them to read traces (pb-34's whole point).
Handle the three classic gate-failure disputes with policy, not argument. (a) "The eval is wrong, not my change" → golden-set cases have provenance (pb-34); disputing a case means a PR against the dataset with reviewer sign-off. (b) "It's judge noise" → the tolerance band from step 3 is the arbiter; inside the band, soft-warn only. (c) "Emergency, must ship" → an explicit labeled bypass (eval-override label + named approver) that pages the eval owner — visible bypasses beat silent gate deletion.
Keep the suite alive. New production failures become new golden cases (pb-34's growth rule) — each incident should add the test that would have caught it. Review runtime and cost monthly; prune or sample cases when the suite exceeds ~10 minutes, keeping full coverage on a nightly scheduled run.
Deterministic checks are your unit tests, metric thresholds are your integration tests, and the PR diff report is the reviewer — a prompt change is a code change and deserves exactly the same pipeline.
In services delivery, the eval suite is a contract artifact — "faithfulness ≥ 0.75 on the agreed golden set, enforced in CI" is an SLA clause you can actually operationalize.