Compiling the next screen…
Compiling the next screen…
Wed Aug 12 2026
David Bleeker, Founder

Most LLM teams have some kind of eval suite. Far fewer have an eval workflow that matches how failures actually appear in product code.
That gap matters because the failure surface is no longer just "did the model answer the benchmark correctly?" It is also: what context was assembled, in what order, under which truncation rules, inside which UI, and under what rendering conditions? A feature can look stable in a notebook and still fail after an innocent change to context packing or presentation.
Two small examples help make the point. ThinkingType tests whether font changes alter VLM judgments. Dbctx is about compiling PostgreSQL data into compact, queryable context. Different layers, same lesson: pre-ship testing has to cover both model behavior and the product machinery that shapes what the model sees.
My view is straightforward. Teams should stop treating eval as a single model-quality gate and start treating it as a release workflow for prompt construction, retrieval and context compilation, UI rendering, and policy checks. If a shipped feature depends on all four, the test plan should too.
What evaluation workflows should teams use to catch model and UI-dependent failures before shipping LLM features?
For most applied teams, the useful answer is not "more benchmarks." It is a release discipline:
That looks obvious on paper. In practice, many teams still evaluate only the final prompt text against a static golden set. That misses two ugly bug classes:
If you do not test those as first-class concerns, production will do it for you.
The workflow I recommend has five layers. It is stricter than ad hoc prompt eval, but still light enough to run in CI and before staged rollouts.
Use real tasks from logs, support escalations, and manual QA. Then label them by failure mode, not just by domain. I would want at least these buckets:
The practical point is that eval rows should map to code ownership. A failing example tagged "wrong context ordering" should have a plausible owner in the context assembly code, not just in "the model." That shortens debugging time more than another 200 generic samples.
Do not version only prompts. Version the full model input contract:
This is where a database-to-context compiler becomes relevant. Once you start compiling relational data into compact model context, you have created a new software artifact with its own bug surface: field omission, denormalization choices, priority inversions, summarization drift, and token-budget regressions. Treat that compiler like production code, because it is.
Absolute scores matter, but most release bugs are introduced by a change. Compare candidate versus baseline along the exact axis you changed.
Examples:
For each diff, compute:
Disagreement rate is often more informative than mean score. If 8% of outputs changed after a template refactor, inspect that 8%, even if the top-line score moved by only 0.3 points.
This is the part many teams skip.
An invariance suite asks: what changes should not alter the answer? Examples:
A sensitivity suite asks: what changes should alter the answer? Examples:
You need both. If the system is too sensitive, harmless product changes cause random regressions. If it is too invariant, real changes do not move behavior when they should, which usually means the model is ignoring important context.
Do not set one blunt threshold like "ship if accuracy > 85%." Use release gates tied to risk:
A ten-case regression in enterprise billing workflows is not canceled out by a hundred improved creative-writing cases. Aggregate metrics hide product risk very efficiently.
The cleanest architecture is to split evaluation into three layers that mirror the product pipeline.
This is the first architecture change I would make.
Define an artifact that represents exactly what the model will consume after retrieval, joins, packing, truncation, and templating. Store it as a versioned object in CI and in staged environments. Then evaluate both:
That boundary buys you something useful: you can tell whether a regression came from data assembly or model inference.
A simple release path looks like this:
This structure is boring in a good way. It makes failures reproducible.
For each task, generate transformed variants that preserve or intentionally perturb semantics. Store the transform type alongside the sample.
Examples:
font_swap_serif_to_sansrow_order_shuffledrop_low_priority_fieldtruncate_last_10_percentrename_column_humanizedscreenshot_contrast_reducedThis is how you move beyond static goldens. You are testing behavioral stability under controlled edits.
A common failure mode is spending labeling budget on average cases. Save human review for high-disagreement clusters, severe regressions, and examples where output changed but score did not. Those are often the cases where the rubric is too weak or the product risk is hidden.
If you use an LLM judge, use it as triage, not truth. Context-packing bugs can fool both the primary model and the judge in the same direction. A judge is most useful for narrowing the queue for human review and for checking structure or rubric dimensions that are easy to operationalize.
This workflow has costs.
Still, the alternative is worse: regressions caused by changes outside the "model" box that nobody saw before release.
Below is a compact pattern for differential evals with invariance tests. The point is not the exact framework. The point is to preserve a replayable compiled-input artifact and compare candidate behavior against a baseline at the failure-class level.
from dataclasses import dataclass
from typing import List, Dict, Any
import hashlib
import json
@dataclass
class EvalCase:
case_id: str
labels: Dict[str, Any]
raw_input: Dict[str, Any]
transform: str = "identity"
@dataclass
class CompiledInput:
case_id: str
compiler_version: str
prompt_version: str
model_input: Dict[str, Any]
fingerprint: str
def compile_context(raw_input: Dict[str, Any], compiler_version: str) -> Dict[str, Any]:
rows = raw_input["rows"]
# Example: stable sort by priority then id to avoid accidental row-order drift
rows = sorted(rows, key=lambda r: (-r.get("priority", 0), r["id"]))
packed = []
for r in rows:
packed.append({
"id": r["id"],
"title": r.get("title", ""),
"summary": r.get("summary", ""),
"status": r.get("status", "")
})
return {"context_rows": packed, "question": raw_input["question"]}
def build_compiled_input(case: EvalCase, compiler_version: str, prompt_version: str) -> CompiledInput:
model_input = compile_context(case.raw_input, compiler_version)
payload = json.dumps(model_input, sort_keys=True)
fp = hashlib.sha256(payload.encode()).hexdigest()
return CompiledInput(
case_id=case.case_id,
compiler_version=compiler_version,
prompt_version=prompt_version,
model_input=model_input,
fingerprint=fp,
)
def run_model(compiled: CompiledInput, model_name: str) -> Dict[str, Any]:
# Replace with actual inference call.
# The important part is that the compiled artifact is the replay boundary.
text = f"stub answer for {compiled.case_id}"
return {"answer": text, "model_name": model_name}
def score(output: Dict[str, Any], labels: Dict[str, Any]) -> Dict[str, Any]:
# Replace with task-specific scoring.
expected = labels.get("contains")
passed = expected in output["answer"] if expected else True
return {
"pass": passed,
"failure_class": labels.get("failure_class", "unknown")
}
def differential_eval(cases: List[EvalCase], baseline_cfg: Dict[str, str], candidate_cfg: Dict[str, str]):
regressions = []
for case in cases:
base_input = build_compiled_input(case, baseline_cfg["compiler_version"], baseline_cfg["prompt_version"])
cand_input = build_compiled_input(case, candidate_cfg["compiler_version"], candidate_cfg["prompt_version"])
base_out = run_model(base_input, baseline_cfg["model_name"])
cand_out = run_model(cand_input, candidate_cfg["model_name"])
base_score = score(base_out, case.labels)
cand_score = score(cand_out, case.labels)
if base_score["pass"] and not cand_score["pass"]:
regressions.append({
"case_id": case.case_id,
"transform": case.transform,
"failure_class": cand_score["failure_class"],
"baseline_fp": base_input.fingerprint,
"candidate_fp": cand_input.fingerprint
})
return regressions
And here is a tiny example of an invariance transform for context order. This catches accidental dependence on row order when your compiler claims the order is semantically irrelevant.
import random
def shuffled_case(case: EvalCase) -> EvalCase:
mutated = json.loads(json.dumps(case.raw_input))
rows = mutated["rows"]
random.Random(7).shuffle(rows)
mutated["rows"] = rows
return EvalCase(
case_id=f"{case.case_id}:row_order_shuffle",
labels=case.labels,
raw_input=mutated,
transform="row_order_shuffle"
)
A practical extension is to persist every CompiledInput as a JSON artifact in object storage and attach the fingerprint to traces in staging. When a regression appears, engineers can diff the exact model input instead of arguing about what the system probably sent.
Treat eval as a release workflow, not a benchmark report.
Version the whole input contract, especially retrieval and context compilation.
Add invariance tests for changes that should not matter and sensitivity tests for changes that should.
Create a compiled-input artifact boundary so you can localize regressions to the assembly layer or the model layer.
Gate releases on failure classes and blast radius, not one aggregate score.
Many LLM failures are software integration failures wearing a model-shaped mask. Teams that test only prompts will keep misdiagnosing them.