genaipros← The path
Line C · Operate07 · Evaluation & Observability
Stage 05 / GenAI curriculum Observability  ×  Evaluation  for LLM systems
Stage overview & map

Two questions, one control room

This stage is two sibling disciplines that get bolted together in production. They ask different questions, they were invented by different people for different reasons, and confusing them is the single most common way an LLM team ends up with beautiful dashboards and furious users.

Observability

“Is the SYSTEM healthy?”

Latency, throughput, GPU telemetry, queue depth, traces, cost. Borrowed wholesale from SRE and bent to fit token-based workloads. The signals are numeric, continuous, and cheap to collect. There is always a right answer: the p99 latency is 4.2 s.

TTFTtokens/s KV cacheGPU util$/req
Evaluation

“Is the OUTPUT good?”

Correctness, faithfulness, format compliance, safety. Unique to AI. There is often no single ground truth — two different answers can both be right. The signals are sparse, expensive, contested, and produced by measuring instruments (judges, humans) that are themselves noisy and biased.

golden setjudge score schema validrefusal correctness
Where they converge — online evaluation

In production the two stop being separate. Online evaluation means sampling quality signals off live traffic, judging them continuously, and alerting on them the way you alert on any other service-level objective. The moment a faithfulness score becomes a Prometheus time series with a burn-rate alert on it, you are no longer doing “eval” or “monitoring” — you are doing one thing. Everything in this stage is building toward that.

MAP

The three-layer telemetry pyramid

Every session plugs into one of these layers. Volume falls and cost per signal rises as you climb. Click any layer.

Telemetry pyramid — the map all five sessions plug intoclick a layer or a side label
QUALITY TRACES spans · request path · attribution INFRA METRICS Prometheus counters · gauges · histograms · DCGM every request, every scrape, forever VOLUME ↑ ~10 pts/req 10⁴–10⁶ series $0.002 / signal ~$0 / signal COST PER SIGNAL ↑ S3 · infra + traces S1·S2·S4 · quality S5 · all three, wired
Select a layer to read what lives there, who emits it, and which session teaches it.
FLOW

One request, both disciplines

The same request produces both kinds of signal. This is the wiring you will build in S5.

A user asks your gateway to score an earnings excerpt

The FastAPI gateway assigns a request ID, tags the request with its slice labels (intent=risk_score, tier=high, locale=en-US) and starts a timer. Nothing has been measured yet — but the slice tags exist, and that decision, made here, determines every question you can ask later. You cannot retroactively slice data you never labelled.

The request becomes a tree of spans

The gateway opens a root span. Retrieval opens a child span. The call to your Gemma endpoint opens a gen_ai.chat span carrying model name, token counts, temperature. A tool call opens another. The tree is pushed to a collector as it completes. This is the attribution layer — the only layer that can tell you which step was slow or wrong.

Prometheus pulls counters and histograms

Every 15 s a scraper hits /metrics on the gateway and on vLLM. It gets cumulative counters (tokens generated) and histogram buckets (TTFT). No individual request is visible here — only the shape of the distribution. Cheap, permanent, aggregatable. Pull, not push.

A small slice of traffic is copied for judging

Deterministic checks (does the JSON parse? is risk_score in range?) run on 100% of responses because they cost microseconds. Then 1–5% of requests, stratified so that high-risk slices are over-sampled, are queued for a judge — asynchronously, off the user's critical path.

The judge emits a score, and the score is telemetry

A judge model scores faithfulness 1–5 against the source document. That score is attached back to the original trace and also exported as a metric. OpenTelemetry now has a dedicated shape for this: a gen_ai.evaluation.result event, parented to the span being evaluated. This is the join between the two disciplines, standardised.

The dashboard shows both, side by side, on one time axis

p99 TTFT and mean faithfulness on the same board, with a deploy annotation line running through both. That vertical line is the whole point: it lets you ask “did the thing that made us faster also make us wrong?”

An alert fires — and it is allowed to be a quality alert

Faithfulness on the compliance-flag slice drops below its objective for 30 minutes. That pages someone, exactly like a latency breach would, with a runbook attached. Quality became an SLO. That is the finish line for this stage.

GOAL

What you will be able to defend by the end

Evaluation side
  • Pick the right metric family for any LLM feature — and say why the other three are wrong for it.
  • Build a golden dataset and size it statistically so a 5-point delta means something.
  • Gate releases on it in CI with hard blockers, soft gates and canary gates.
  • Implement LLM-as-Judge with position, verbosity and self-preference bias mitigated, calibrated against human labels with a chance-corrected metric.
Observability side
  • Stand up the full pyramid on your Gemma service: Prometheus + DCGM, OTel/Langfuse tracing, quality signals.
  • Read GPU utilisation vs memory saturation correctly, and know why 98% util can still mean an idle GPU.
  • Choose histogram vs summary, control cardinality, and write PromQL you can explain.
  • Defend every dashboard panel, every alert threshold, and every burn-rate window you ship.
USE

Which discipline owns this problem?

The map is only useful if you can place a real problem on it. This is the triage question you will ask dozens of times, and getting it wrong sends you to the wrong tooling for a day.

Decision tree · someone says “the assistant is broken” — where do you look?
Follow “no ↓” until a “yes” exits right. The bottom-left box is the default.
Q1Did the request fail or time out — a non-200, a dropped stream, an empty body?
yes
Observability — S3Start at infra metrics: error rate, queue depth, GPU saturation, cold starts. This is an ordinary availability incident and SRE tooling answers it directly.
no ↓
Q2Did it return successfully but too slowly, or cost more than it should?
yes
Observability — S3Split latency into queue wait, prefill and decode before touching anything. TTFT and time-per-output-token point at different causes and different fixes.
no ↓
Q3Was the response structurally unusable — unparseable JSON, missing field, value outside the allowed set?
yes
Evaluation, deterministic tier — S2This is a contract failure, and contracts are checkable with code. Cheap, unambiguous, and it should have been caught by a validator before the user ever saw it.
no ↓
Q4Is it well-formed but wrong — unsupported claim, wrong risk band, invented citation, unhelpful refusal?
yes
Evaluation, judgement tier — S1 and S4The hard case, and the reason this stage exists. No error was raised and no threshold was crossed. You need a golden set to reproduce it and a calibrated judge or a human to score it.
no ↓
Q5Was it fine last week and is gradually getting worse, with no deploy to blame?
yes
Both, at the convergence point — S5Drift. Usually the environment moved, not the model: a refreshed index, a shifted traffic mix, an updated policy. It is only visible where quality signals and infra signals share a time axis.
no ↓
DefaultNo failure, no latency problem, no contract violation, no factual error, no trend. The complaint is about fit, not correctness — the output is right and still not what the user wanted. That is a product-requirements conversation, and the useful move is to turn it into a golden case with an explicit expected-behaviour checklist. Then it becomes measurable, and the next version can be held to it.
NAV

The five sessions

SRC

Source ledger & corrections to the plan

Sourcesection as assignedActual title in the materialCorrection
A
Practical LLM Evaluation for Production Systems — Mohanna, Kar & Ralte (, June 2026)
§1 Foundations of LLM Evaluation: Core Concepts and Primitives ✓ matches
§2Building Reliable Text-Only LLMs Through Training-Time Evaluation✓ matches
§3Controlling Text-Only LLM Behavior at Inference Time✓ matches
§4–6 “multimodal, out of scope” §4 Grounding and Reliability in Vision Language Models During Training; §5 Evaluating Visual Grounding and Reliability at Inference Time; §6 Evaluating Multimodal Conversational LLMs Across Training and Inference ✓ correctly scoped out. Note the material runs to §11 MoE routing computer-using agents document understanding reasoning models specialized systems. §8 and 10 are the natural follow-ons after your agents stage.
B
Generative AI on Kubernetes — applied guides
§5 “Model Observability” Model Observability (p.153) ✓ matches. Sections: Observability Stack (logs/metrics/tracing) → Model Server Metrics (TTFT, TPOT, throughput, latency, queues) → GPU Usage Monitoring → Quality Metrics → Responsible AI → Model Safety & Guardrails.
C
Generative AI Design Patterns — Lakshmanan & Hapke (, Oct 2025)
§6 “LLM-as-Judge, Reflection and adjacent patterns” §6. Improving Reliability Numbering correction: the section carries four patterns, not three — 17 LLM-as-Judge, 18 Reflection, 19 Dependency Injection, 20 Prompt Optimization. S4 teaches all four; 19 and 20 are the ones people skip and then regret.
How this stage reads the materials the source material and the source material name specific tools and models, and several of those claims have moved. Every tab carries an AGED callout where that happens, with the current replacement and an official-doc link.
→ start at S1, or jump anywhere; the tabs are independent.
Session 1 · the source material — Foundations of LLM Evaluation

Evaluation is a decision system, not a score

Everything in this session exists to answer one question: what evidence would change what you do? If a number can't change a decision, it isn't evaluation — it's reporting.

01

Why this session exists

10%
The failure this whole stage is built to prevent

A team ships an assistant after a demo that went beautifully. A week later a customer complains that the assistant confirmed a refund which was never processed. The reply sounded confident and correct. The model hadn't lied about anything it knew — it had simply skipped the billing lookup, and nothing in the pipeline checked whether the lookup had happened before the words “your refund has been issued” were allowed to appear.

Note what was not missing: the model was fine, the latency was fine, the service was up. What was missing was an evaluation process that could detect whether a required step had run, whether the final claim was supported by evidence, and whether high-risk cases should block a deploy.

That story sets up the first and most important reframe of the stage. You do not ship a model. You ship a system. The shipped behaviour is a function of the base model plus the prompt, the retrieved context, the tools, the policies, the output contract, and the decoding parameters. Change any one of them and the behaviour changes. Evaluate only the model and you are measuring a component that nobody uses.

What you actually shippedclick a component
THE SYSTEM UNDER EVALUATION · “the system tuple” base model gemma-4-e4b prompt v3.1.0 + hash context index snapshot tools+policy schema v7 decoding + contract any one of these changes → behaviour changes what a public benchmark measured the model alone, under someone else's conditions what production exercises all six, under your traffic distribution
Click any block to see what it means for versioning and attribution.
02

Core concepts, from zero

50%
Concept 1 — Evaluation as a decision system3 passes
Pass 1 · intuition

A smoke detector is not a smoke measurement device. It is a chain: a sensor that produces evidence (particles per m³), a known uncertainty (it goes off when you toast bread), a threshold committed to in advance, and an action that follows automatically (a very loud noise). Nobody wants a smoke detector with a nice display and no buzzer. That is what a dashboard without thresholds is.

Evaluation is the buzzer, not the display.

Pass 2 · mechanism

Four objects, chained. Each has a distinct owner and a distinct failure mode.

The decision chain — evidence becomes an actionclick an element
EVIDENCE what you measured UNCERTAINTY how much to trust it THRESHOLD committed in advance ACTION ship · block · roll back If you can remove the last box and nothing changes, you have reporting, not evaluation.
Click a box.

The operating model, in one line: primitives feed evaluators → evaluators produce metrics → metrics are compared to thresholds → thresholds trigger actions.

Pass 3 · trade-offs & nuance

Thresholds are product policy, not statistics. You set them from historical baselines, stakeholder risk tolerance, business SLOs, and legal constraints — then you revise them as evidence arrives. Getting them perfect on day one is not the goal; making the decision boundary explicit, reviewable and adjustable is.

Separate load-bearing from diagnostic metrics. Load-bearing metrics directly cause a ship/block/rollback. Diagnostic metrics explain why a load-bearing metric moved. A useful rule: two or three load-bearing metrics per production axis, everything else is debugging. When every metric is on the dashboard and none is tied to an action, the dashboard becomes noise and the team stops looking.

Where this breaks: thresholds set on aggregates hide concentrated failures. Overall violation rate 0.08% looks great until you notice all of it lives in the compliance-flag slice, which is 2% of traffic and 100% of your regulatory exposure. Gates must be slice-scoped or they are decorative.

Concept 2 — Why classical ML evaluation breaks here4 mechanisms
Pass 1 · intuition

Classical supervised evaluation assumes a stable map: one input, one correct output, compare, score. That works for a spam classifier. It breaks for a system whose job is to write a paragraph, because a paragraph has no single right answer, and it breaks again because the same input can produce different paragraphs on Tuesday.

Pass 2 · mechanism

“Single ground truth” stops existing

Two replies can share almost no wording and both be correct. Exact-match scoring punishes valid behaviour and rewards surface overlap. The fix is to stop asking “does this match the reference string?” and start asking “does this satisfy the task contract?” — did it avoid claiming an unverified action, did it ask for the missing identifier, did it hold the required format.

This is why behavioural checks, rubrics, and trace-based evaluators dominate over string similarity in production.

One run is not one measurement

Temperature, top-p, retries, tool responses and load all move the output. Run the same 50-case suite five times at temperature 0.7 and the mean helpfulness score wanders. A required tool call might fire in four runs out of five.

So the unit of evaluation shifts from an output to a distribution of outputs under a fixed configuration. Repeatability here does not mean identical text; it means bounded variance and stable decisions.

The model is not the biggest lever

A system-prompt edit routinely changes behaviour more than a model version bump. A retrieval index refresh changes factual accuracy with the model untouched. Behaviour depends on prompt × retrieval config × tool schema × policy version × decoding settings — a combinatorial space you cannot test exhaustively.

Consequences: risk-based prioritisation instead of full coverage, slice-based reporting instead of averages, and version everything — including data. A retrieval index refresh is a data change, not a config change, and if you don't version it you will blame the model.

The final text is the last visible step of a longer process

Even a lightly agentic system classifies, decides whether it needs external facts, calls a tool, applies a policy, then writes. The reply can look perfect while the process was wrong: wrong tool, invalid arguments, skipped check, swallowed timeout.

Score only the final text and every one of those stays invisible. Tool choice, argument validity, success rate, retry and fallback behaviour are all part of the evaluable output.

Pass 3 · trade-offs & nuance

Versioning gives you attribution hypotheses, not causal proof. Knowing the retrieval config changed on the same day accuracy dropped is a lead, not a verdict — you still need a targeted rerun or ablation to confirm it. Teams that skip this step spend weeks “fixing” prompts to compensate for an index regression.

Concept 3 — What “working” means: four axes
Pass 1 · intuition

A restaurant that serves brilliant food in ninety minutes at £400 a head, occasionally with a stone in it, is not “working” because the food is brilliant. Quality is one axis of four.

Pass 2 · mechanism
Quality

Helpful, correct, complete, right format for the task.

Safety

Policy, privacy, risk. Unsupported claims. Data exposure.

Cost

Tokens, retrieval, unnecessary tool calls, judge spend, GPU-hours.

Reliability

p95 latency, uptime, tool success rate, fallback behaviour, run-to-run stability.

Then map product goals onto measurable signals. “Don't claim a refund is issued unless verified” becomes unsupported_action_claim_rate with scope slice=refund and threshold = 0.

Pass 3 · trade-offs & nuance

Trade-offs are not the problem; implicit trade-offs are. Longer prompts and deeper retrieval buy quality with cost and latency. Tighter safety policy buys risk reduction with over-refusal on benign requests. Restricting tool calls buys latency with correctness. Decide the exchange rate before the release meeting, or the release meeting becomes an argument about vibes.

Risk tiers change the shape of the threshold. Two kinds:

KindDefinitionThreshold shapeFintech example
Zero-toleranceBlocks release regardless of frequency= 0, any occurrenceA compliance flag asserted with no supporting citation from the filing
Risk-budgetedTracked against an acceptable rate≤ 2%, per sliceSentiment label off by one adjacent class on low-materiality paragraphs
Concept 4 — Primitives, and the minimum instrumentation boundary
Pass 1 · intuition

A flight recorder doesn't store the pilot's thoughts. It stores the observable event sequence, precisely enough to reconstruct what happened. That's the standard here: log enough to replay and attribute, not enough to violate anyone's privacy.

Pass 2 · mechanism

Two layers. System primitives are the parts of the product that get received, used, produced or recorded. Evaluation constructs turn those into decisions.

Primitives → constructs → decisionsclick any node
SYSTEM PRIMITIVES · what the system does input prompt context tools output trace EVALUATION CONSTRUCTS · how behaviour becomes a decision evaluators metrics thresholds actions metadata+ versions Everything above the line describes behaviour. Everything below it describes measurement. Version both.
Click a primitive or construct for what to log and why it matters.

The minimum instrumentation boundary is the floor below which you can still make charts but can no longer run an evaluation programme: you will not be able to reproduce a failure, compare fairly, localise a regression, or turn an incident into a test.

Pass 3 · trade-offs & nuance

Evaluator versioning is the one people forget. If the judge model, judge prompt, rubric or scoring code changes, scores move even when the system didn't. A helpfulness score can rise because the assistant improved, or because the judge got lenient. Without evaluator versions in the run record you cannot tell those apart — and the trend line looks continuous either way.

Privacy shapes the log. Store the observable event sequence, not hidden model reasoning. To evaluate the refund workflow you need: was the tool called, with what arguments, did it succeed, did the reply make a verified claim. That's enough. Chain-of-thought storage buys little and costs a lot in retention and exposure.

Concept 5 — Test suites, golden datasets and slices
Pass 1 · intuition

A driving test isn't a random sample of roads. It deliberately contains a hill start, a three-point turn and an emergency stop, because those are where people fail. A good eval suite is designed the same way: not representative on average, but complete across the situations that matter.

Pass 2 · mechanism
test case
More than a prompt. Input + task label + slice tags + expected behaviour + risk tier + relevant tool/context conditions, and sometimes a reference output.
slice
A subgroup of cases sharing an important property — intent, user segment, risk tier, tool condition, input shape, locale. Slices exist because aggregate scores hide concentrated failures.
golden set (“gold set”)
In classical ML: one correct label per input. For generative tasks: inputs plus expected behaviour, with reference outputs used selectively — only where the target is genuinely well defined (a category, an extracted field, a required refusal).
adversarial set
Deliberate stress tests, and not only jailbreaks: ambiguous wording, missing identifiers, conflicting instructions, prompt injection, stale context, partial tool responses, misleading user claims. The goal is to pull likely production failures forward into offline testing.
synthetic set
Generated rather than collected. Good for phrasing diversity, rare intents, locales, long-context and format stress. Should expand a real-data core, never replace it.
regression set
The institutional memory of what broke before. Every incident becomes a case, plus a few paraphrase variants so the fix doesn't overfit one wording.

Then split suites by purpose, because a suite you iterate against cannot also be the suite you gate on:

SuiteUsed forChurnWho may edit
DevelopmentPrompt iteration, debuggingHighAnyone
Release-gatingFair version-to-version comparisonLow, versionedOwner + review
Hold-outMajor release decisions onlyFrozen; rarely inspectedOwner only
ExplorationFinding new failure modesVery highAnyone
Pass 3 · trade-offs & nuance

Synthetic data's self-reinforcing trap. If the same model family generates your cases and judges the responses, the generator produces cases the evaluator finds natural, and the system looks better than it is. Guardrails: keep a real-data core, sample synthetic cases from real traffic patterns, label synthetic cases separately, and report real and synthetic subsets as separate numbers.

Gold sets leak. Iterate against the same reference cases for three months and your prompt engineering has quietly optimised against those examples without improving general behaviour. This is the same phenomenon as benchmark contamination, applied to your own suite — S2 goes deeper.

Ownership or rot. A suite with no owner goes stale in a quarter: deprecated policies stay in, new intents never arrive, and the gate slowly stops measuring the product.

Concept 6 — The metric taxonomy (every term defined)reference · reference-free · benchmark · judge · human
Pass 1 · intuition

Four families of measuring instrument, in increasing order of cost and decreasing order of trustworthiness-per-dollar. The rule is simple: use the cheapest evaluator that reliably detects this failure mode, and escalate only when it can't.

Pass 2 · mechanism

Deterministic validators are rule-based checks on things that are objectively true or false: does the output parse as JSON, does a required field exist, is the tool argument the right type, did the policy-required tool call actually happen, does a forbidden claim appear without supporting evidence in the trace. Fast, cheap, stable, trivially debuggable. They are the backbone, and they stop you spending judge tokens on outputs that already violate a structural contract.

exact match
Output string equals reference string. Only sane for constrained targets: a category, a boolean, an extracted ID.
precision / recall / F1
For labelled classification. Precision = of the items you flagged, how many were right. Recall = of the items you should have flagged, how many you caught. F1 = their harmonic mean. Report both, not just F1 — the asymmetry usually matters (missing a compliance flag ≠ raising a spurious one).
BLEU
Bilingual Evaluation Understudy. Built for machine translation. Measures n-gram precision of the candidate against one or more references, with a brevity penalty. An n-gram is a run of n consecutive tokens.
ROUGE
Recall-Oriented Understudy for Gisting Evaluation. Built for summarisation. Measures n-gram overlap between output and reference — ROUGE-N for n-grams, ROUGE-L for longest common subsequence. the source material suggests it as a cheap grounding proxy for summarisation, which is a fair narrow use: if the summary shares little vocabulary with the source, something is off.
BERTScore
Instead of counting shared words, embed each token of candidate and reference with a pretrained encoder and match them by cosine similarity. Catches paraphrase that BLEU/ROUGE miss. Still measures similarity to a reference, so it inherits the core problem: it cannot tell you a fluent, similar-sounding answer is factually wrong.

The honest limit of the whole family: these metrics look rigorous and mostly measure surface overlap. They are a poor default for open-ended generation. Use them where the target is well defined, or where you have evidence the metric correlates with human judgement on your task.

Metrics computed without a gold answer, by relating the output to something else you have — usually the input, the retrieved context, or the model's own probabilities.

perplexity
The exponential of the average negative log-likelihood the model assigns to a text. Intuitively: “on average, how many equally likely next tokens was the model choosing between?” Perplexity 10 ≈ as uncertain as a fair 10-sided die at each step. Lower = the text is more predictable to that model. Covered properly in S2 — it is a training-time signal, not a quality metric.
faithfulness / groundedness
Are the claims in the output supported by the retrieved context? Reference-free because the context is the reference. The workhorse metric for RAG and for document-extraction pipelines like yours.
format / schema compliance
Reference-free and deterministic: validate against a JSON Schema or Pydantic model. Cheap enough to run on 100% of traffic, and it catches a whole class of production breakage before any semantic scoring runs.
self-consistency
Sample the same input k times and measure agreement on the decision fields. Disagreement is itself a signal — it flags cases where the system is unstable, without needing a ground truth.

These are the metrics that scale to live traffic, because live traffic has no labels.

Standardised public test sets with a fixed harness — MMLU, GSM8K, HumanEval, GPQA Diamond, SWE-bench Verified, and so on, usually run through a harness like lm-evaluation-harness or HELM.

contamination
Benchmark items, or close variants, appear in the model's training data. The score then partly reflects exposure rather than capability.
saturation
Top models cluster so tightly at the high end that score differences fall inside measurement noise. The benchmark stops discriminating.

The transfer rule: benchmarks are for model selection, never for shipping decisions. They can shortlist candidates and rule out models that can't do the basic task. They cannot tell you whether your configured product — with your prompt, retrieval, tools, policies, contracts, latency budget and traffic mix — is ready. Details and 2026 status in S2.

LLM-as-judge
A model scores another model's output against a written rubric. Scales subjective assessment far past human throughput and catches nuance that rules can't. It is a noisy, biased measuring instrument, not ground truth — S4 is entirely about making it trustworthy.
human evaluation
The escalation path for high-risk slices, ambiguous intent, nuanced policy, and UX judgements. Run as an operation: anchored rubric, pass/fail rules for critical violations, reviewer training, calibration rounds, double-scoring on a subset, inter-rater agreement checks. Without those controls it is expensive opinion collection.

Human labels have a second job beyond scoring: they are the calibration set that tells you whether your judge is any good. You cannot skip them entirely, even in a fully automated pipeline.

Pass 3 · trade-offs & nuance
FamilyCost / 1k itemsLatencyStabilityCatchesBlind to
Deterministic validator~$0<1 msPerfectContract breaks, missing evidence, forbidden claimsAnything requiring judgement
Reference metric~$0msPerfectLabel errors, extraction errorsValid answers phrased differently
Reference-free (faithfulness by rule/NLI)$0–$210–100 msHighUnsupported claims vs contextStyle, tone, completeness
LLM judge$2–$200.5–5 sMedium, biasedHelpfulness, tone, nuanced policyIts own biases; fluent-but-wrong
Human review$200–$2 000hours–daysMedium, driftsEverything, in principleScale; consistency without calibration
Decision tree · which evaluator for this output?
Follow “no ↓” until a “yes” exits right. The bottom-left box is the default.
Q1Can the requirement be written as a rule that is objectively true or false about the output, the schema, or the trace?
yes
Deterministic validatorRun it on 100% of traffic. Schema check, required-field check, evidence-binding check. Never pay a judge for this.
no ↓
Q2Does a single unambiguous correct answer exist and is it already labelled — a category, a numeric field, an extracted ID, a required refusal?
yes
Reference-based metricExact match / precision / recall / F1, reported per slice. Stable, trends well, easy to debug.
no ↓
Q3Is the property a relationship between the output and something you already have — the source document, the retrieved context, the input constraints?
yes
Reference-free metricFaithfulness / groundedness / citation correctness against the context. Works on unlabelled live traffic, which is the point.
no ↓
Q4Is this a high-risk slice, a policy edge case, or a case where being wrong is expensive and irreversible?
yes
Human reviewStratified sample, anchored rubric, double-scoring, inter-rater agreement. Also becomes your judge calibration set.
no ↓
Q5Have you already got ≥30 human-labelled examples of this dimension to calibrate against?
yes
Calibrated LLM judgeFixed rubric, coarse scale, versioned prompt, order-swapped, agreement measured. See S4.
no ↓
DefaultLabel 30–50 real failures by hand first. An uncalibrated judge on a dimension you have never scored yourself produces a number nobody should act on — and you will not know it is wrong.
Concept 7 — Repeatability and statistical sanity
Pass 1 · intuition

Weigh yourself on a bathroom scale five times and you get five numbers. Nobody concludes they gained 300 g between readings. But teams do exactly that with eval scores — 8.10 → 8.18 becomes “the new prompt is better” and ships.

Pass 2 · mechanism
repeatability
Same harness, config, suite, environment → comparable results across reruns. For stochastic systems this means bounded variance and stable decisions, not identical text.
reproducibility
Broader: another team or environment follows the same protocol and reaches comparable conclusions. You need both; repeatability first.
confidence interval (CI)
A range that would contain the true value in, say, 95% of repeated experiments. Report “8.1 (95% CI 7.9–8.3)”, not “8.1”. If the gate is 8.0, that interval is the entire decision.
bootstrap resampling
Resample your eval cases with replacement, recompute the metric, repeat ~1 000 times, and read the interval off the resulting distribution. No distributional assumptions needed, works for any metric. Note the cost trap: bootstrapping the metric is free; re-running the judge 1 000 times is not — resample the stored scores, don't re-score.
minimum meaningful change (MMC)
The smallest movement that would alter a product decision. Declared before the experiment. 0.2 points of helpfulness might be invisible to users; 0.2 percentage points of unsupported compliance claims might be a regulatory incident.
minimum detectable effect (MDE)
The smallest true difference your design has enough samples to reliably detect. If MDE > MMC, your experiment cannot answer your question and no amount of staring at the chart will fix it.
sampling policy
How many samples per input, at what temperature. Once chosen it is part of the config: one release evaluated at k=1 and the next at k=5 are not comparable unless you say so loudly.
evaluator drift
The measuring instrument changed, not the system. New judge model, edited rubric, reworded judge prompt, or a rotated human reviewer pool. Produces beautifully smooth false trends.

A regression is not “the metric went down”. It is four questions, all of which must be checked: is the change bigger than run-to-run variation? does it exceed the declared MMC? does it concentrate in a critical slice? does it cross a gate threshold?

Pass 3 · trade-offs & nuance

Variance you can't remove, measure separately. Different sources need different fixes. Reply quality moving because the model sampled differently → decoding policy or a stronger prompt contract. Quality moving because a tool timed out → a reliability problem wearing a quality costume. Scores moving because the judge prompt changed → evaluator drift. Track tool failure rate, judge-score variance, fallback rate and timeout rate as their own signals, or all three failures look identical on the quality chart.

Controlling drift costs discipline, not money. Keep a small calibration set. When the judge changes, run old and new judge on the same calibration set before treating the new scores as comparable. For humans, run periodic calibration rounds and double-score a subset.

Concept 8 — Pipelines and gates: offline, online, regression
Pass 1 · intuition

Offline evaluation is the pre-flight checklist. Canary gates are the first ten minutes after takeoff, watching the instruments. Online evaluation is the whole rest of the flight. All three run on the same aircraft, and findings from the last one rewrite the first one's checklist.

Pass 2 · mechanism
Golden set → harness → scores → gate → rollout → back to golden setclick a stage
golden setfrozen snapshot harnessfrozen config scores + diffper slice, with CI CI gateblock / warn / pass shadow→canary→ full ramp ONLINE EVALUATION sampled judging · drift · incidents · slow-burn trends every incident becomes a new case + variants
Click a stage.

Gates come in three strengths. Each combines a metric, a scope, a threshold and an action:

GateFires onActionTuning failure
Hard blockerZero-tolerance / catastrophic classes, slice-scopedRelease does not ship. No override without a written exception.Too broad → the gate gets disabled “temporarily”, forever
Soft gateMeaningful regressions that aren't catastrophicForces an explicit decision: accept the trade-off, mitigate, narrow the rollout, or blockNo decision recorded → becomes a warning nobody reads
Canary gateLive signals during staged rolloutContinue / pause / investigate / roll back / reduce exposureToo sensitive → rollback fatigue; too loose → misses what it existed for
Pass 3 · trade-offs & nuance

Offline can't see production, and that's structural. Real users invent new intents, traffic mixes shift, tools degrade, policies change, indexes refresh, external services time out. Online evaluation has to catch two very different shapes: acute incidents (a visible spike — tool outage, violation jump) and slow-burn regressions (helpfulness down 2%/week; latency creeping as a backend degrades; refusals drifting up after a policy edit). Slow burns evade simple threshold alerts because no single day looks bad. You need trend monitoring over longer windows to see them at all.

The drift usually isn't the model. A new product tier creates a slice that fails badly while overall accuracy holds. A policy update changes what the right answer is. A tool schema change produces failures that look exactly like hallucinations. In all three cases the correct response is to update the suite and the slices — not to patch the prompt.

Concept 9 — Granularity: choosing the unit of measurement

Too coarse and failures hide; too fine and evaluation gets expensive and noisy. Move up the ladder for product relevance, down for diagnosis.

LevelEvaluatesYour fintech pipelineNeeds logged
SpanExtracted fields, entitiesExtracted ticker, fiscal period, risk_score valueStructured output fields
StepIntermediate decisions and actionsRouter complexity classification; retrieval call; tool argumentsTrace events
TurnOne request, one responseIs this single analysis helpful, correct, safe, well-formedInput/output pairs + rubric
TaskEnd-to-end completion of one jobWhole 10-K section → sentiment + risk + signal + flagsTask grouping ID
WorkflowA sequence of related tasksFiling ingested → analysed → flagged → escalated to a humanSession/case ID across turns
SystemAggregate behaviour over many tasksp95 latency, $/filing, cache hit rate, slice trends, incident rateProduction telemetry

Granularity is a logging decision made in advance. You cannot evaluate tool-call correctness later if tool events were never recorded. You cannot evaluate workflow success if turns were never linked into sessions. You cannot debug drift if slice tags were never written. Choosing the level and choosing the log are the same choice.

03

Reality check

25%

Worked example · how many eval samples do you need to detect a 5-point quality delta?

This is the calculation that decides whether your entire eval programme produces signal or theatre. Pass-rate evals are binomial: each case is a Bernoulli trial, pass or fail, and the score is the sample proportion .

Step 1 — how noisy is a single run? The standard error of a proportion is SE = √(p(1−p)/n).

// your current golden set: 100 cases, observed pass rate 80% SE = sqrt(0.80 × 0.20 / 100) = 0.040 95% CI ≈ p̂ ± 1.96 × SE = 0.80 ± 0.078 = [72.2% , 87.8%]

A 5-point delta is smaller than half the width of the noise band on one arm. With n=100 you cannot distinguish an 80% system from an 85% system. You will still see the number move between runs, which is worse than seeing nothing, because you will act on it.

Step 2 — size for the delta you care about. For comparing two proportions at 95% confidence and 80% power, the standard approximation is n ≈ 2 × (zα/2+zβ)² × p̄(1−p̄) / Δ², with (1.96+0.84)² = 7.85.

// detect Δ = 5 points around p̄ ≈ 0.82, per arm n = 7.85 × 2 × (0.82 × 0.18) / (0.05²) = 7.85 × 2 × 0.1476 / 0.0025 = 927 cases per arm // the same maths at other deltas — note the 1/Δ² blow-up Δ = 10 pts -> ~232 per arm Δ = 5 pts -> ~927 per arm Δ = 2 pts -> ~5 790 per arm

Step 3 — the move that actually rescues you. Nobody hand-labels 927 fintech cases. Three legitimate escapes:

  • Make the metric less noisy. A binary pass/fail on a rubric with an anchored definition has far lower variance than a 1–10 subjective score. Coarse scales are not a simplification, they are a variance reduction.
  • Use paired comparison. Run both variants on the same cases and test only the cases where they disagree (a McNemar-style paired test). Correlated arms remove most of the between-case variance, and required n drops by roughly an order of magnitude for highly-correlated systems.
  • Stop trying to detect 5 points globally. Set your MMC per slice by consequence. Zero-tolerance slices don't need a power calculation at all — you need one violation to block, so the question becomes coverage, not sensitivity.
Verdict for your golden set: a 150–250 case suite with binary per-criterion checks, paired runs against the previous approved version, and per-slice reporting is the right shape. It reliably catches the ≥10-point regressions that matter, it will not resolve 2-point wobbles — and you should say so out loud in the release notes rather than pretend otherwise.
Fresh example 1 · the sizing mistake is the industry default

A May 2026 write-up on eval-set sizing runs exactly the calculation above and lands on the same shape of answer: detecting a 2-point delta around the 80% range needs roughly 6 300 examples per arm, a 4-point delta about 1 580, and a 7-point delta about 480. Its blunt framing — that 100 examples is a coin flip — matches the arithmetic above. The practical prescription is to put a confidence interval next to every number in a deploy note.

Fresh example 2 · sizing from the defect-rate side

Eugene Yan frames the same problem as a release requirement rather than an A/B test: if your product requirement is a defect rate below 5%, and you observe 3% on 200 samples, the interval is roughly 3% ± 2.4% — upper bound 5.4%, which exceeds the requirement, so you cannot claim you met it. Double to 400 samples and the interval tightens to about 3% ± 1.7%, upper bound 4.7%, and now you can. This is the version of the argument that product managers actually accept, because it is phrased as “can we claim this yet”.

Fresh example 3 · the gate now has an off-the-shelf implementation

When the source material describes “regression gates: turning metrics into deployment decisions”, it describes a capability, not a product. As of 2026 that capability ships: Langfuse released a GitHub Actions integration (langfuse/experiment-action) in May 2026 that fails a workflow when experiment scores fall below a threshold, which converts evaluation from a post-release review into a deploy gate. In the same launch week Langfuse shipped Code Evaluators — Python/TypeScript functions written in the UI that run deterministic checks (JSON parseability, schema validation, required tool arguments) with no judge call and no token cost. That is the “cheapest evaluator first” principle, productised.

Worth noting for your own build: Langfuse also shipped Score Analytics for measuring evaluator alignment (precision, recall, F1, accuracy) and baseline comparison for flagging a reference run. Those are the drift-control primitives from Concept 7, as product features.

Where the source material has aged — barely the source material published in June 2026 and is deliberately tool-agnostic, so almost nothing in §1 has moved. Two small additions from the last few months: (1) the gate and evaluator alignment concepts now have first-class open-source implementations rather than being build-it-yourself, and (2) OpenTelemetry has standardised a shape for exporting eval scores as telemetry (gen_ai.evaluation.result), which §1's “trace storage” section predates. S3 covers that in detail. Neither changes the framework; both reduce how much of it you write yourself.
04

Apply to my stack — lab

10%

Target: your FastAPI gateway in front of Gemma E4B on Modal, scoring earnings and SEC documents. Deliverable: a golden set schema and a harness skeleton that produces a frozen, replayable run record.

4.1 · The golden fintech case format

Note what this schema encodes: slice tags, risk tier, expected behaviour rather than an expected string, and a per-case list of which evaluators apply. That last field is what stops you paying judge tokens for schema checks.

evals/golden/fin_risk_0007.yaml
id: fin_risk_0007
source: {doc: "MSFT_10K_FY25", section: "Item 1A Risk Factors", span: [4120, 5230]}
slice: {task: risk_score, sector: tech, doc_type: 10-K, length: long, locale: en-US}
risk_tier: high            # drives sampling depth, decoding policy, gate strictness
input:
  excerpt: "<verbatim filing text>"
expected:
  # reference-based: only where a single answer genuinely exists
  fields:
    risk_score: {min: 6, max: 8}          # banded, not exact — the honest target
    compliance_flags: ["concentration_risk", "fx_exposure"]
  # behavioural: the contract, checked by rules not strings
  must:
    - every compliance_flag carries a char-offset citation into the excerpt
    - trading_signal is one of [buy, hold, sell, no_signal]
    - sentiment justification quotes <= 25 words from the source
  must_not:
    - assert a numeric figure absent from the excerpt
    - emit a trading_signal when doc_type == 10-K and section == "Item 1A"
evaluators: [schema_v3, citation_binding, field_band, judge_faithfulness_v2]
owner: you
origin: incident-2026-06-14   # why this case exists

4.2 · Harness structure that survives contact with a real release

Four properties matter: frozen suite snapshot, explicit config (never hidden defaults), pluggable evaluators, and artifacts you can re-read six months later.

evals/harness/run.py
from dataclasses import dataclass, asdict
import hashlib, json, time, uuid

@dataclass(frozen=True)
class SystemTuple:
    """Everything that can change behaviour. If it isn't here, you can't attribute."""
    model: str            # "gemma-4-e4b"
    model_endpoint: str   # the Modal web URL, pinned per run
    serving_profile: str  # your PROFILE switch: quant | prefix | best ...
    prompt_id: str
    prompt_version: str
    prompt_hash: str      # sha256 of the rendered template
    retrieval_cfg: str
    index_snapshot: str   # a data version, not a config version
    tool_schema_v: str
    policy_v: str
    decoding: dict        # {"temperature":0.0,"top_p":1.0,"enable_thinking":false}
    samples_per_case: int
    suite_id: str
    suite_snapshot: str   # content hash of the frozen case set
    evaluator_versions: dict
    code_commit: str

class Evaluator:
    name = "base"; version = "0"
    kind  = "deterministic"          # deterministic | reference | judge | human
    def score(self, case, output, trace) -> dict:
        """-> {value, label, explanation, cost_usd}. Never raises; failures are results."""
        raise NotImplementedError

def run_suite(cases, tup: SystemTuple, evaluators, baseline=None):
    run_id = f"run_{uuid.uuid4.hex[:10]}"
    records = []
    for case in cases:
        for k in range(tup.samples_per_case):
            out, trace = invoke(case, tup)      # your gateway, not the raw model
            # cheap first: if the contract breaks, don't pay a judge for the semantics
            gate = [e for e in evaluators if e.kind == "deterministic"]
            res  = {e.name: e.score(case, out, trace) for e in gate}
            if all(r["value"] for r in res.values):
                for e in evaluators:
                    if e.kind != "deterministic" and e.name in case["evaluators"]:
                        res[e.name] = e.score(case, out, trace)
            records.append({"case": case["id"], "k": k, "slice": case["slice"],
                            "risk_tier": case["risk_tier"], "scores": res,
                            "output": out, "trace": trace})
    report = aggregate(records, bootstrap=1000)   # per-slice means + 95% CI
    return {"run_id": run_id, "config": asdict(tup), "records": records,
            "report": report, "diff": diff_vs(baseline, report) if baseline else None}
What this upgrades in your existing toy code Your QualityMonitor currently scores format compliance and collects user feedback — that is one deterministic evaluator plus one human signal, with no suite, no slices and no run record. Three concrete upgrades: (1) promote format-compliance scoring into an Evaluator with a version field, so a change to it is visible in the run record; (2) attach the slice dict to every request at the gateway edge so every later metric can be sliced; (3) add bootstrap=1000 to whatever currently reports a mean, and print the interval next to it. That third one takes about twenty lines and changes more arguments than any other change in this stage.
Optional exercise

Take your E2E baseline — 100% success, 91% cache hit, 3-model routing distribution across 11 queries — and answer one question honestly: what is the 95% confidence interval on a 100% success rate observed on n=11? Use the Wilson interval rather than the normal approximation, because the normal approximation degenerates at p̂ = 1. You should land near [74%, 100%]. Then write the one sentence you would put in a README to describe that result truthfully. That sentence is the deliverable.

Bridge → S2 You now have the vocabulary and the decision framework, but only one place to apply it: a finished system. S2 splits the timeline — the gates you can run before a model ever sees a user (loss, perplexity, contamination, checkpoint selection) versus the controls that run on every single request in production (contracts, decoding policy, evidence binding, drift).
Session 2 · the source material — Training-time evaluation & inference-time control

Two clocks: before the model ships, and every request after

The same operating model from S1 — suites, evaluators, thresholds, actions — applied at two very different moments. Training-time gates stop bad behaviour hardening into weights. Inference-time controls stop everything else, and they run on every request forever.

01

Why this session exists

10%

Two failure stories, one on each clock.

The training-clock failure

A fine-tune improves your risk-scoring accuracy from 78% to 91%. Everyone celebrates. Three weeks after deploy it's back at 74% in production. The eval set overlapped the training data at the paraphrase level — same filings, reworded. You measured memorisation and called it learning. Loss went down the entire time.

The inference-clock failure

Nothing about the model changed. Someone shortened the system prompt to save tokens, and the phrase enforcing “cite the offset for every compliance flag” went with it. Flags keep appearing. They look identical in the response body. There is no longer anything behind them. Cost went down; the dashboards look great.

Neither failure is a model quality problem in the sense a benchmark would recognise. The first is a data hygiene failure that made your evidence worthless. The second is a missing control — a rule that should have been enforced by code, delegated to a sentence in a prompt.

The lifecycle, and what each stage can breakclick a stage
TRAINING CLOCK · runs occasionally, gates progression pretrainloss stability domain adaptmemorisation risk instruction tuneformat contracts preference tuneover-refusal, verbosity task fine-tunebrittle narrowing RELEASE GATE + model / dataset card INFERENCE CLOCK · runs on every single request intake prompt gov decoding contract evidence bind budgets+rollout
Click a stage for its dominant failure mode, its signal, and the action when the signal fails.
02

Core concepts, from zero

50%
Concept 1 — What loss and perplexity can and cannot tell youtraining clock
Pass 1 · intuition

Imagine reading a sentence aloud with one word covered, and betting on what's underneath. Perplexity is the average number of equally-plausible words you were choosing between at each step. Perplexity 2 means you were basically flipping a coin. Perplexity 50 means you were as lost as if picking from a 50-sided die.

Now the crucial part: this measures how surprised the model was by text. It says nothing about whether the model will follow your JSON schema, refuse an unsafe request, or refrain from inventing a revenue figure. A model can be beautifully unsurprised by financial prose and still confidently fabricate numbers in it.

Pass 2 · mechanism

Training loss for a language model is cross-entropy: the average negative log-probability the model assigned to the tokens that actually came next. Perplexity is just that number exponentiated, which puts it back on a “number of choices” scale humans can reason about.

loss = −(1/N) · Σi log p(tokeni | context<i) // natural log, nats/token perplexity = exp(loss) // so: loss 2.30 -> ppl 10.0 loss 2.08 -> ppl 8.0 loss 1.61 -> ppl 5.0

Two consequences fall straight out of that formula, and both bite people:

  • Perplexity is tokenizer-dependent. It is per-token, so two models with different tokenizers are not comparable on it. A tokenizer that splits words more finely gets a flattering number for free.
  • Perplexity is corpus-dependent. “Perplexity 8” is meaningless without naming the held-out set. On your own domain corpus it is a genuinely useful relative signal; across papers it is nearly noise.

So the training-time practice is: treat loss monitoring as necessary but incomplete, and add cheap behavioural smoke tests the moment the model can produce coherent text — a handful of routing-style classification prompts, a structured-output adherence check, a couple of refusal-correctness probes. Those catch things loss curves are structurally blind to.

Pass 3 · trade-offs & nuance

Checkpoint selection is where this gets expensive. The lowest-validation-loss checkpoint is frequently not the best product checkpoint. Loss keeps improving while instruction-following degrades, or while the model gets more verbose (which costs you money at inference forever). Select on a small behavioural scorecard, with validation loss as a sanity-check rather than the objective.

What loss is good for: detecting divergence, detecting a data-mix shift mid-run, and confirming the tokenizer and formatting assumptions are what you think they are. A sudden loss discontinuity almost always means a data problem, not a learning-rate problem. The right action is to pause the run and inspect the mix, not to lower the LR and hope.

Where it breaks entirely: loss on data the model has memorised is meaninglessly low. Contamination and perplexity interact badly — which is the next concept, and it is the one that invalidates evidence rather than merely being unhelpful.

Concept 2 — Data quality gates, before a single step of trainingtraining clock
Pass 1 · intuition

You cannot inspect a cake for flour quality. Everything you failed to check about the ingredients is now distributed irreversibly through the result. Data gates run before mixing because afterwards the only remedy is to start over.

Pass 2 · mechanism

Where did this come from, and does it belong here?

Provenance is a licence question, a privacy question and an attribution question at once. Its practical payoff at eval time is that duplication and toxicity metrics computed per source are actionable, while the same metrics computed globally are not. “Overall toxicity 0.4%” tells you nothing; “source 7 contributes 80% of it” tells you what to drop.

Exact duplicates aggressively, near-duplicates carefully

Repetitive content inflates apparent capability while increasing memorisation and reducing generalisation. Exact duplicates should just go. Near-duplicates are subtler and more dangerous in instruction data, because templated examples look diverse on the surface — same skeleton, different slot values.

Symptom in a deployed assistant: it always opens with the same sentence, or always reaches for the same category phrasing even when the intent shifted slightly. That's template memorisation, and it traces back to here. Compute duplication per source and per slice so the fix stays targeted instead of a blunt filter that throws away long-tail coverage.

Screening is slice-aware and source-aware, or it's decoration

The question is not “how toxic is the corpus” but “where does unsafe language concentrate, and does that overlap with my high-risk slices?” For a fintech assistant the analogue of the classic account_access danger zone is anything that normalises unsupported financial assertions or “helpful” regulatory workarounds. Training content that models confident unhedged claims will surface later as confident unhedged claims.

Format noise in training becomes format failure in production

If your structured-output examples are inconsistent — sometimes fenced, sometimes not, sometimes with a trailing comment, field order shuffled — the model inherits that instability and emits almost-valid JSON at 2 a.m.

Two cheap checks that eliminate a whole class of production breakage: parse-validity rate and required-field-presence rate across the training examples themselves. Run them on the dataset, not just the model.

The quiet one: instructions that fight themselves

Conflicting instructions inside a single example, unclear constraints, ambiguous success criteria, hidden goals competing with the visible task. The useful artifact is a short rubric that categorises instruction-quality failures, plus a sampling plan that over-samples high-risk instruction subsets. You are not auditing everything; you are auditing where a bad instruction becomes an incident.

Pass 3 · trade-offs & nuance

Coverage is slice completeness, not volume. More tokens do not fix a missing slice. The artifact worth building is a slice matrix — intent × risk tier × locale — with counts in the cells, owned and maintained. Then define an edge-case budget: an explicit tolerance for failures in rare slices, set by severity. Low-severity long-tail failures can be tolerated at some rate. High-severity ones in your compliance slice get near-zero tolerance, and that decision is what stops “average helpfulness improved” from being an acceptable answer when unsafe compliance also rose.

Concept 3 — Leakage and contamination: when your evidence stops being evidencethe one that invalidates everything else
Pass 1 · intuition

A student who has seen the exam paper scores brilliantly. The score is real; the inference you want to draw from it is not. Contamination doesn't make your model worse — it makes your measurement worthless, silently, while every number improves.

Pass 2 · mechanism

Leakage is a spectrum, not an event. Three levels, each needing a different detector and a different response.

LevelWhat it isDetectionWhy it's dangerousResponse
Exact overlap Same document / prompt / QA pair in both train and eval Hash match on normalised text, at document and example level Model can memorise surface form outright Always high severity. Remove, regenerate the affected eval slices, rerun gates. If it touched a gate-driving set, block progression until the rerun passes.
Near-duplicate Structurally identical, small substitutions — the templated case Fuzzy match on normalised text (lowercase, whitespace, boilerplate stripped) Inflates perceived robustness. The model learned the template, not the skill. Severity depends on slice. Concentrated in a critical slice → dedupe or reweight the source, refresh that slice, rerun.
Paraphrase overlap A rewrite: different wording, same intent and structure Embedding-similarity scan — expensive, so run it only on high-risk slices and gate-driving sets Hardest to detect, and it destroys exactly the property you needed: stability under rephrasing Treat as a reliability risk. Strengthen the eval set with fresh paraphrases and holdouts, add robustness gates, rerun before progression.

The gating shape, as pseudocode — this is the artifact, not the algorithm:

exact = hash_match(train, eval) near_dupes = fuzzy_match(train, eval, threshold=0.90) para = similarity_scan(high_risk_slices, train, threshold=0.85) if exact or near_dupes.high or para.high_risk: block_progression

Treat leakage as a property of a slice, not of a dataset. Small overlap in a low-risk slice during early development is survivable. Overlap in your compliance slice is release-blocking, because that is the slice where you were relying on the measurement most.

Pass 3 · trade-offs & nuance

Your internal suites are benchmarks too. Benchmark contamination isn't only about public leaderboards. If a training or synthetic-generation dataset ever includes prior versions of your regression suite, your gates weaken every cycle. The model memorises the test and you gradually lose the ability to detect real regressions — while every dashboard says things are improving. Contamination control is not research hygiene; it is a release-readiness requirement.

The output is a versioned artifact. The point isn't which similarity method you pick. It's that the process produces a contamination report, attached to a release, that can block progression. Without the artifact, detection is a one-off analysis someone did once and nobody can find.

Concept 4 — Instruction-following is three separate thingstraining clock
Pass 1 · intuition

“Did it follow instructions?” is like asking whether a contractor did a good job. Did they build the right thing, did they follow building regulations, and did they hand over the paperwork in the required format? Three questions, three failure modes, three different fixes — and they fail independently.

Pass 2 · mechanism
task intent adherence
Did it do the thing that was asked? Failure → rebalance the instruction mixture, reduce ambiguous prompts.
constraint adherence
Did it respect the rules and policy? Failure → revise policy data, add counterexamples.
format adherence
Did it meet the output contract? Failure → fix templates and exemplars, tighten validators.

Measured separately, a regression tells you which layer to repair. Merged into one “quality” number, it tells you to rewrite everything. A starter micro-suite of 10–15 cases across your core intents is enough to expose brittle instruction-following before anyone runs a large review cycle.

Structured output deserves its own axis because it predicts operational reliability. Track parse-success rate, required-field-presence rate, field-type validity, allowed enum values, and constraint-violation flags — all deterministic, all cheap, all before any judge runs. This is the single highest-leverage set of checks in the whole training-time programme.

Pass 3 · trade-offs & nuance

Not every constraint reduces to a rule. For the ones that don't, use a short anchored rubric — pass / borderline / fail — plus a severity field, rather than a numeric score. “Borderline” is not indecision; it is a genuine category meaning “met the intent and the format but ambiguous on a constraint”, and it routes to human review instead of being rounded into a mean.

Concept 5 — Preference tuning: the regressions that only appear after deploytraining clock
Pass 1 · intuition

Optimise a customer service team purely on “no complaints” and you will eventually get a team that refuses to do anything interesting, at length, very politely. Preference tuning shifts the whole output distribution, and the three things it shifts by accident are refusal behaviour, diversity, and length.

Pass 2 · mechanism

Over-refusal is a slice problem, not a global one. A global refusal rate hides the signal, because what you care about is correctness of refusal. The taxonomy that makes it measurable:

OutcomeMeaningWhere you want it
Correct refuseRequest is disallowed; model refuses and gives a safe alternativeHigh in high-risk slices
Should complyRequest is allowed; model refuses anywayNear zero in low-risk slices — this is the over-refusal metric
Partial complyHelps but omits key steps, or bolts on unnecessary refusal languageLow everywhere; it's the polite failure
EvasiveAvoids without being helpful or clearZero. This is the one users hate most.
mode collapse
The model settles into a narrow, repetitive band of safe generic outputs. Detect via: percentage of replies reusing the same opening template, percentage containing no actionable step, and cautious use of structural diversity measures. In an analysis product this can be worse than an occasional wrong answer, because it destroys the reason the product exists.
verbosity drift
Output length creeps up. Track p95 output length, not mean — the tail is what drives tail latency and cost. This is an early-warning signal for a production cost problem that hasn't happened yet.
helpfulness under constraints
The metric that separates “safe” from “safe and useless”: can the model stay inside policy and still move the user forward? Refusing an unsafe action while offering a legitimate path is a pass. Refusing and stopping is a fail even though nothing unsafe happened.

Gate the stage on all four together, or improving one silently pays for it with another.

Pass 3 · trade-offs & nuance

Extra tokens need a justification. If verbosity rises without improving constrained helpfulness, safety recovery, or high-risk correctness, it is drift, not quality — and it will show up on your infrastructure bill and your p99 latency chart months later, at which point nobody will connect it to a preference-tuning run.

Flag for your upcoming fine-tuning stage

Five things from the training clock that will pay off directly when you fine-tune Gemma, ranked by return:

  1. Contamination scanning against your golden set — build this first. The moment you have both a fine-tune corpus and an eval set, run exact + near-dupe across all of it and a paraphrase scan on the high-risk slices. Everything downstream depends on this evidence being clean.
  2. Format-consistency checks on the training data itself. Your pipeline emits structured fintech objects. Inconsistent exemplars are the single most common cause of almost-valid JSON in production, and it is trivially preventable here.
  3. p95 output length as a first-class gate. You are on an L4 with a KV-cache budget you already had to tune down to 0.92 utilisation. Verbosity drift is not abstract for you; it costs you concurrency.
  4. Checkpoint selection on a behavioural scorecard, not validation loss. Build the scorecard before you start the run, not after.
  5. Model card + dataset card + contamination report as release artifacts. Not bureaucracy — this is what lets you reconstruct, six months into an incident, why the model shipped and what evidence supported it.
Concept 6 — Inference time is a control system, not an analysisinference clock
Pass 1 · intuition

A factory doesn't inspect finished products and write reports. It puts gauges, interlocks and shutoffs in the line, so the expensive failure is prevented by design rather than discovered afterwards. At inference time you are not evaluating a model call — you are evaluating a pipeline, and failures live in the seams between steps.

Pass 2 · mechanism

Split the request into two coupled threads. The user-facing flow runs from input to rendered answer. The execution flow records retrieval hits, tool calls, failures, retries and verification flags. The rule that prevents the whole class of confident-lie failures is: the user-facing reply may only assert what the execution thread proved.

Inference control surface — every control emits a signal that triggers an actionclick a control
USER-FACING FLOW intakeschema+truncate promptid+ver+hash decodingby risk tier contractreject–repair claim gateevidence-bound only EXECUTION FLOW · produces the evidence flags retrievaldoc ids · ranks · coverage tool execargs valid? ok? timeout? evidence flagscitation_ok = true the claim gate reads executed evidence, never model self-report budgets5 dials fallback Each control produces a measurable signal. A control with no signal is a hope.
Click a control.

Production assistants fail on structure constantly: almost-valid JSON, a missing field, a category outside the allowed set. These break downstream automation and pollute your evaluation, because a judge will happily score an unusable reply as helpful.

A contract is a control layer, not a quality metric. Three rules:

  • Validate structure, not semantics — is it parseable, are required keys present, are types right, are enums legal.
  • Repair only when strictly syntactic and safe. A trailing comma, yes. Guessing a missing risk_score, absolutely not.
  • Fall back when meaning is ambiguous, and cap the retries, because every retry buys structure with tail latency and cost.

What passing guarantees: the output is structurally usable. What it does not guarantee: the category is correct, the reply is safe, the tool calls were appropriate. Keeping those separate is what makes debugging fast.

Decoding parameters amplify or damp variance. In high-risk slices variance is a risk multiplier — it can flip a refusal or a verification claim. So make decoding an explicit, auditable policy rather than a default someone set once:

DECODING_BY_RISK = { "high": {"temperature": 0.0, "top_p": 1.0}, "medium": {"temperature": 0.2, "top_p": 0.9}, "low": {"temperature": 0.4, "top_p": 0.95}, }

Then measure stability the right way. Not string similarity across repeated runs — that penalises harmless rewording. Measure decision agreement on the fields that matter: did the category stay the same, did the refusal type stay the same, did a verification-sensitive claim appear or not. High-risk slices must show high decision agreement; low-risk slices can tolerate less, as long as safety and cost hold.

Bind claim classes to evidence flags. If the flag is false or absent, the claim language is not permitted to appear — full stop, enforced in code:

# the fintech version of the refund rule def unsupported_flag(resp, citations_ok: bool) -> bool: asserts = any(f["type"] in HIGH_IMPACT for f in resp["compliance_flags"]) return asserts and not citations_ok

The power comes from pairing it with the trace: the flag is recorded by the execution thread, so the gate does not depend on the model's self-report. When the rate is non-zero, the action is not “tune temperature”. It is: block the response, serve a safe template, and open an incident that becomes a regression test.

retrieval coverage
Was the relevant policy or passage retrieved at all? A retrieval problem. No amount of prompt editing fixes it, and editing the prompt is exactly what teams do.
faithfulness
Are the claims supported by what was retrieved? A generation problem.
citation correctness
Do the citations point at the right evidence? A distinct failure — a response can be faithful and still cite the wrong paragraph, which destroys auditability even though the content is right.

Log document IDs and ranks; watch rank instability and coverage drops per slice. Misattributing a retrieval regression to the model is one of the most expensive debugging mistakes available.

BudgetBoundsSymptom when it's missing
Token budgetGeneration lengthp99 latency owned by a handful of runaway generations
Retry budgetContract repairs + tool retriesTail latency triples during a partial outage; nobody knows why
Timeout budgetTool callsA slow dependency silently becomes your latency
Judge budgetOnline grading spendEval costs scale with traffic and surprise you monthly
Error budgetReliability degradation over timeNo principled way to say “stop shipping and fix things”

Once budgets exist, performance analysis becomes causal: p95 rose because retries rose, and retries are a quantity you control.

Four drifts, four different mitigations. Naming them correctly is most of the work:

DriftSignalAction
Traffic driftIntent-mix divergence vs baselineInspect new traffic, add slices, add cases — your offline suite is no longer representative
Tool driftRising tool failure rate or latencyReliability work, not prompt work
Retrieval driftCoverage drop, rank instabilityIndex / ranking debugging; expand the retrieval eval set
Policy driftBehaviour no longer matches updated policyUpdate the expected behaviour in the suite — the model may be fine and the test wrong

A simple, honest traffic-drift detector: compare the intent distribution of recent traffic against a baseline and flag when total variation distance exceeds a threshold.

delta = Σk |p_baseline(k) − p_current(k)| flag if delta > 0.20 // an operational alert, not a research metric

And the promotion ladder: shadow (candidate runs on real traffic, output discarded, behaviour compared — zero user impact) → canary (small share of live traffic, hard blockers and canary gates armed) → full ramp only when the gates hold. Rollback criteria and their owner are written down before the rollout starts, precisely so nobody debates them during an incident.

Pass 3 · trade-offs & nuance

Prompts are the highest-frequency lever and the least governed. A small edit can change routing, refusal behaviour, verbosity and tool calling. Three things make prompt changes safe: log PROMPT_ID, PROMPT_VERSION and a template hash on every request; categorise changes by risk so you know what regression scope they demand; keep diffs reviewable and reversible. The dangerous edits are not the ones that change wording — they're the ones that change constraints, output contracts or tool instructions, and those should automatically trigger full high-risk-slice regression.

Every control costs something. Contract validation costs microseconds. Bounded repair costs a retry. A guardrail model costs a second full inference on the whole conversation, because safety assessment can't be done token by token. Low-temperature decoding on high-risk slices costs you response diversity users sometimes like. The design job is picking which controls earn their price per slice, not applying all of them everywhere.

Decision tree · a failure just landed — where does it get fixed?
The most expensive mistake in this stage is fixing a failure on the wrong clock. Follow “no ↓”.
Q1Can a deterministic rule detect this failure from the output or the trace, right now, on every request?
yes
Inference controlAdd the validator or the claim gate. Ship it today. Do not retrain to fix something a five-line check enforces perfectly.
no ↓
Q2Does the failure disappear when you re-run the same input at temperature 0?
yes
Decoding policyIt's variance, not incapability. Move the slice to a lower-temperature tier and add a decision-agreement check to the suite.
no ↓
Q3Was the evidence the model needed actually present in the context it received?
no — it was missing
Retrieval fixA coverage problem. Debug index, chunking and ranking. Editing the prompt here just teaches the model to bluff more convincingly.
yes, the evidence was there ↓
Q4Does the behaviour flip when you rephrase the same request three different ways?
yes
Training-time / data fixParaphrase instability means the model learned a template, not the skill. Check for paraphrase-level contamination first, then add counterexamples and retrain.
no ↓
Q5Is the expected behaviour in your suite still correct — or did the policy, the product or the world change underneath it?
the suite is stale
Fix the testPolicy drift. Update the expected behaviour, version the change, and note it in the changelog. The model was right and the gate was wrong.
no, the suite is correct ↓
DefaultA genuine capability gap. Add the case plus paraphrase variants to the regression suite, decide between a stronger base model and a fine-tune on the evidence (high-risk slice performance first, cost last), and in the meantime add an inference-time guardrail so the failure is contained while you work.
03

Reality check

25%

Worked example A · what a perplexity improvement is actually worth

A domain-adaptation run on SEC filings takes held-out perplexity from 12.4 to 10.1. The slide says “18.5% improvement”. Convert it to something meaningful.

// back to loss, where the arithmetic is honest loss_before = ln(12.4) = 2.518 nats/token loss_after = ln(10.1) = 2.313 nats/token Δloss = 0.205 nats/token (≈ 0.296 bits/token) // what that means per token, on average The model went from ~12.4 to ~10.1 effective choices per token. Roughly one fewer plausible continuation in every four tokens. // what it does NOT tell you — check each one separately parse_valid_rate : unmeasured citation_binding_rate : unmeasured refusal_correctness : unmeasured p95_output_length : unmeasured // and this one may have got worse
Verdict: a real signal that the model absorbed the domain's statistics, and a legitimate reason to keep the checkpoint in contention. It is not a reason to ship. The behavioural smoke tests are what decide, and if p95 output length rose 15% at the same time, the cheaper checkpoint may still be the better product.

Worked example B · verbosity drift is a cost regression in disguise

Suppose a tuning run raises mean output from 220 to 265 tokens and p95 from 480 to 620. Your workload: 10 000 requests/day on your L4 profile.

// throughput side — decode is the bottleneck, so tokens ≈ time mean tokens/req 220 -> 265 = +20.5% decode work daily output tok 2.20M -> 2.65M = +450 000 tokens/day // GPU-time side, at a measured ~900 output tok/s aggregate on L4 decode seconds/day 2 444 -> 2 944 = +500 s/day (+8.3 GPU-min/day) at $0.45/GPU-hour = +$0.0625/day = ~$1.90/month // tail side — where it actually hurts p95 output 480 -> 620 tokens p95 generation time @ ~28 tok/s/seq = 17.1s -> 22.1s (+29%) concurrency at fixed max_num_seqs=256 -> each slot held 29% longer -> effective QPS ceiling falls ~22%
Verdict: the dollar figure is trivial and the capacity figure is not. The cost of verbosity on a single small GPU is almost never the electricity — it is queue depth, p99 latency, and the moment your autoscaler adds a second replica. That's why p95 output length is a release gate, not a nice-to-have chart.
Fresh example 1 · the benchmarks in the materials are saturated

the source material recommends a language model evaluation harness for pre-deployment capability assessment — still correct as tooling. What has moved is which benchmarks carry signal. By 2026 MMLU sits around 88–93% for frontier models, a spread narrow enough that differences fall inside measurement noise; HellaSwag is above 95%; original GSM8K is effectively solved at the top tier. Analyses through 2026 converge on the same advice: keep the classics only for continuity and regression checking, and use contamination-resistant sets — GPQA Diamond, Humanity's Last Exam, SWE-bench Verified, LiveCodeBench, and fresh annual exams like AIME — when you need a benchmark to actually discriminate.

Note also the harness-dependence problem: the same model weights can score materially differently depending on the evaluation harness, which is one more reason a benchmark number in a vendor table is not a measurement of your system.

Fresh example 2 · contamination is measurable, and the results are uncomfortable

the source material's contamination section is conceptual. There is now a formal statistical definition to point at: ConStat reframes contamination as an unusual performance lift on a benchmark relative to a related reference benchmark, measured against a panel of reference models — producing a p-value and an estimated effect size in accuracy points, rather than a hand-wave. Published detections included widely-used base models on HellaSwag, GSM8K and ARC-Challenge, with effects in the 3–8 point range, plus the top three models on a major open leaderboard at submission time.

The operational takeaway for you: “this model is popular and scores well” is not evidence of cleanliness. Base-model contamination propagates into every fine-tune built on it.

Fresh example 3 · guardrails as inference controls have grown up, and now emit telemetry

the source material's runtime-guardrails section describes composing a guardian model into the request flow with custom orchestration code, and notes ongoing work to fold it into gateway components. That work landed. NVIDIA's NeMo Guardrails now ships both as an open-source Python library and as a production microservice sharing the same configuration model, with input / dialog / retrieval / execution / output rails, deployable via the NIM Operator on Kubernetes. The piece that matters for this stage: it emits OpenTelemetry — end-to-end distributed tracing with context propagated across guardrails, inference and downstream services, plus metrics for policy evaluations.

That is the S2 → S3 handoff made concrete: a control that produces a signal, in a standard format, that your observability stack can alert on.

Where the materials have aged
  • the source material, “Quality Metrics”: names OpenAI Evals, LangSmith and Arize as the structured-evaluation options. The current open-source shortlist is broader and more specialised — lm-evaluation-harness for base-model benchmarking, Ragas for RAG-specific metrics, DeepEval for pytest-style CI gates, promptfoo for red-teaming and multi-model comparison, and Phoenix or Langfuse when you want tracing and evaluation in one stack. Most mature programmes run two in parallel: a lightweight framework that blocks bad deploys, plus a platform for monitoring and human review.
  • the source material's judge example names a specific frontier model as the judge. Treat all named models in both sources as illustrative — the lineup has turned over. Judge choice is a versioned config value in your harness, not a fact about the world.
  • the source material on guardian model sizes ("biggest is about 7B or 8B") is a moving target and shouldn't be planned against.
  • is June 2026 and holds up; its contamination taxonomy predates none of the above. The one thing it doesn't cover is that eval scores now have a standard telemetry shape — S3.
04

Apply to my stack — lab

10%

4.1 · Risk-tiered decoding, wired to your enable_thinking switch

Your Modal deployment exposes a per-request thinking toggle. That is a decoding-policy dial with a quality/latency price, so it belongs in the risk-tier table rather than being set ad hoc by callers.

gateway/policy.py
# One table. Auditable. Logged on every request as part of the system tuple.
DECODING_BY_RISK = {
    # compliance flags / risk scores: stability beats creativity, always
    "high":   {"temperature": 0.0, "top_p": 1.0,  "enable_thinking": True,  "max_tokens": 900},
    # risk scoring on medium-materiality sections
    "medium": {"temperature": 0.2, "top_p": 0.9,  "enable_thinking": False, "max_tokens": 700},
    # sentiment / summarisation: variance is cheap here
    "low":    {"temperature": 0.4, "top_p": 0.95, "enable_thinking": False, "max_tokens": 400},
}

def decision_agreement(samples: list[dict], fields=("risk_score_band", "trading_signal",
                                              "compliance_flag_set")) -> dict:
    """Stability measured on DECISIONS, not on string similarity.
    Gate: high-risk slices must hold >= 0.95 agreement at k=5."""
    out = {}
    for f in fields:
        vals = [tuple(s[f]) if isinstance(s[f], list) else s[f] for s in samples]
        modal = max(set(vals), key=vals.count)
        out[f] = vals.count(modal) / len(vals)
    return out

4.2 · The contamination scan you run before your first fine-tune

Two cheap tiers on everything, one expensive tier on the slices that gate releases. The output is a signed report attached to the run, not a notebook cell.

evals/contamination/scan.py
import hashlib, json, re
from datasketch import MinHash, MinHashLSH   # tier 2: near-dupes at scale

def norm(t: str) -> str:
    t = t.lower
    t = re.sub(r"\s+", " ", t)
    for boiler in BOILERPLATE:            # SEC headers, page furniture, disclaimers
        t = t.replace(boiler, "")
    return t.strip

def tier1_exact(train, evalset):
    h = {hashlib.sha256(norm(x["text"]).encode).hexdigest for x in train}
    return [e["id"] for e in evalset
            if hashlib.sha256(norm(e["input"]["excerpt"]).encode).hexdigest in h]

def tier2_near(train, evalset, thr=0.90):
    lsh = MinHashLSH(threshold=thr, num_perm=128)
    for i, x in enumerate(train): lsh.insert(f"t{i}", mh(norm(x["text"])))
    return [e["id"] for e in evalset
            if lsh.query(mh(norm(e["input"]["excerpt"])))]

def tier3_paraphrase(train, evalset, thr=0.85):
    # expensive: embeddings. Run ONLY on high-risk + gate-driving slices.
    hi = [e for e in evalset if e["risk_tier"] == "high"]
    return [e["id"] for e in hi if max_cosine(e, train) > thr]

def report(train, evalset) -> dict:
    r = {"exact": tier1_exact(train, evalset),
         "near":  tier2_near(train, evalset),
         "para":  tier3_paraphrase(train, evalset),
         "train_snapshot": snapshot_hash(train),
         "eval_snapshot":  snapshot_hash(evalset)}
    r["blocking"] = bool(r["exact"]) or bool(r["para"]) \
                    or len(r["near"]) > 0.02 * len(evalset)
    return r   # commit this next to the model card. It is release evidence.

4.3 · The prompt-governance three-liner

The cheapest high-value change in this whole session. Without it, every quality movement is unattributable.

gateway/prompts.py
PROMPT_ID       = "fin_analyst_v4"
PROMPT_VERSION  = "4.2.0"
PROMPT_TEMPLATE = (...)
PROMPT_HASH     = hashlib.sha256(PROMPT_TEMPLATE.encode).hexdigest[:16]

# Emit all three on every span AND every metric exemplar.
# Then a change in faithfulness has a suspect list of length one.
What this upgrades in your existing toy code Your gateway already exposes /metrics and your ObservabilityManager tracks per-request cost. Three additions turn it into a control surface: (1) a Pydantic output contract with bounded repair — validate, repair only syntactic damage, cap at one retry, fall back to a safe template and record which path was taken as a labelled counter; (2) the citations_ok evidence flag computed in the execution thread and read by a claim gate before the response is released; (3) retry and timeout budgets as explicit counters so a latency spike is attributable to a controlled quantity rather than a mystery.
Optional exercise

Take your PROFILE switch (worst / baseline / best / prefix / quant) and run your golden set against two of them — prefix (bf16) and quant (FP8 weights and FP8 KV cache). You already know the throughput delta. The question this session asks is different: did FP8 quantisation move any decision field? Compute decision agreement between the two profiles on risk_score_band, trading_signal and compliance_flag_set, per risk tier. If high-risk agreement is below 0.95, you have bought throughput with correctness and you now have the number to prove it — which is exactly the kind of trade-off S1 said must be explicit rather than implicit.

Bridge → S3 Every control you just added emits a signal, and right now those signals go nowhere. S3 builds the plumbing: what a counter, gauge and histogram actually are, what vLLM and DCGM expose, how a trace gets stitched together across your gateway and your Modal endpoint, and how to turn all of it into alerts that are worth waking up for.
Session 3 · the source material — Model Observability

Instrumenting a token machine

Kubernetes will happily tell you the pod is Running, the GPU is busy and the API is answering health checks, while every user waits eleven seconds for a first token. This session is about the signals that actually describe an LLM's behaviour, and why the ones you inherited from web services don't.

01

Why this session exists

10%

Three things make LLM serving different from every microservice you've monitored before, and each one breaks a standard assumption:

Why the microservice playbook mismeasures an LLMclick a row
assumption: CPU + memory describe load the work happens on the GPU, and CPU tells you approximately nothing about saturation fix: DCGM exporter + KV-cache utilisation and know that prefill is compute-bound while decode is memory-bandwidth-bound assumption: requests/sec measures throughput one request can be 30 tokens or 30 000; counting requests measures almost nothing about the work fix: tokens are the unit of computation prompt tokens, generation tokens, and the two-phase latency split TTFT / TPOT assumption: broken = error rate goes up a normal app crashes on unknown input; a model calmly returns 200 OK with a wrong answer fix: quality signals become telemetry the fourth pillar — everything in S1/S2/S4 exported alongside latency and cost
Click a row.

You have built toy versions of all of this. What follows is not “here is Prometheus” — it is what your toys got wrong, and what changes when the thing is real.

02

Core concepts, from zero

50%
Concept 1 — Four signal types, and why they are not interchangeable
Pass 1 · intuition

A hospital has a vitals monitor (continuous numbers, cheap, no story), a patient chart tracing one person through admission → theatre → recovery (expensive, complete, one person), a nurse's notes (unstructured, searchable, verbose), and a diagnosis (a judgement about whether the outcome was good). Metrics, traces, logs, quality. Asking a vitals monitor which surgeon was slow is a category error.

Pass 2 · mechanism
SignalShapeTransportCardinalityAnswersCannot answer
MetricsAggregated numbers over timePull — a scraper hits /metricsMust stay low“Is it slow in general? Is it getting worse?”“Why was this request slow?”
TracesTree of timed spans per requestPush — the app exports to a collectorSampled, high detail“Which step consumed the 4 seconds?”“What's the 99th percentile over 30 days?”
LogsTimestamped text eventsWritten to stdout, shippedUnbounded“What exactly did the engine say when it broke?”Anything aggregate, cheaply
QualityScores attached to outputsBoth — event on the span, plus a metricSampled“Was the answer any good?”“Is the service up?”

The pull/push distinction is not trivia. Metrics are pulled, which means the scraper controls the rate and a dead target is visible as a failed scrape. Traces are pushed, which means the application controls the rate and a dead application is simply silent. That difference determines what you can build an alert on — and it is exactly the property that a scale-to-zero serverless endpoint breaks. Hold that thought for the lab.

Pass 3 · trade-offs & nuance

Kubernetes gives you neither metrics nor tracing natively; both are conventions layered on top. For metrics the de-facto standard is Prometheus with the OpenMetrics exposition format — a CNCF project that standardised and extended the original Prometheus text format while keeping backward compatibility. For tracing it's OpenTelemetry. Logs in Kubernetes go to stdout/stderr and land in a node file, which makes kubectl logs easy and long-term retention someone else's problem — you add Loki or equivalent, or you lose them on pod churn.

Concept 2 — Metric types: counter, gauge, histogram, summarythe choice your toy got wrong
Pass 1 · intuition

A car has an odometer (only goes up, resets when you replace it), a speedometer (goes up and down, reading now), and — if you wanted p95 speed — you'd need to have been recording how long you spent in each speed band. That last one is the histogram, and it is the one people get wrong.

Pass 2 · mechanism
counter
Monotonically increasing. Resets to zero on process restart. You never read the raw value — you read rate over it, and the query engine handles the reset. Tokens generated, requests completed, errors.
gauge
Goes up and down; the value right now. Requests currently running, KV-cache utilisation, GPU temperature. A gauge scraped every 15 s misses everything that happened between scrapes — a 3-second queue spike is invisible.
histogram
Pre-defined buckets, each a counter of “how many observations were ≤ this value”, plus _sum and _count. Quantiles are computed at query time by interpolating within a bucket.
summary
Quantiles computed client-side, in the process, and exported as final numbers.

Histogram vs summary is the decision, and there is a right answer for LLM serving.

HistogramSummary
Quantile computedAt query time, from bucketsIn the process, at observation time
Aggregatable across replicas?Yes — buckets addNo — you cannot average two p95s and get a p95
AccuracyBounded by bucket widthExact-ish, per instance
CostOne time series per bucketCheap on storage, CPU in-process
Arbitrary quantiles later?Yes — ask for p99.9 next yearNo — only what you pre-configured

With two Modal replicas, a summary gives you two p95s and no legal way to combine them. That alone settles it: histograms for anything you will alert on. vLLM agrees — TTFT, time-per-output-token and end-to-end request latency are all exported as histograms.

The query, and the trap inside it:

# p95 TTFT over 5 minutes, across all replicas of one model histogram_quantile(0.95, sum by (le, model_name) (rate(vllm:time_to_first_token_seconds_bucket[5m]))) # TRAP: sum by (le, ...) must come INSIDE. Aggregate the buckets, then # take the quantile. histogram_quantile(0.95, avg(...)) is meaningless.

Bucket boundaries are a design decision you make once. If your TTFT buckets top out at 2.5 s and reality is 9 s, everything above 2.5 s lands in +Inf and histogram_quantile extrapolates — your p99 becomes fiction that looks like data. Pick buckets that straddle your SLO threshold with resolution on both sides.

Pass 3 · trade-offs & nuance

Cardinality is the thing that takes your monitoring down. Every unique combination of label values is a separate time series, stored and indexed independently. The multiplication is silent until it isn't:

// a plausible, well-intentioned label set model(3) × slice(6) × risk_tier(3) × status(4) × le_buckets(14) = 3 024 series for one histogram // fine // now someone adds user_id for "better attribution" × user_id(50 000) = 151 200 000 series // this is an outage

The rule: labels are for values from a small, bounded, slowly-changing set. Anything unbounded — user ID, request ID, prompt text, document ID, error message — belongs in a trace or a log, never a metric label. If you need to jump from a metric spike to the individual requests behind it, that's what exemplars are for: a trace ID attached to a sampled histogram observation, giving you one click from the chart into a trace, without putting the ID in the label set.

Concept 3 — The metrics that actually describe an LLM
Pass 1 · intuition

Two phases, two completely different bottlenecks, and users feel them differently. Prefill reads the whole prompt and produces the first token — that's the pause before anything appears, and it is compute-bound. Decode produces every subsequent token one at a time — that's the speed of the text scrolling, and it is bound by memory bandwidth. A single “latency” number averages these two unrelated things into a number that describes neither.

Pass 2 · mechanism
One streaming request, decomposedclick a segment
QUEUE PREFILL · compute-bound DECODE · memory-bandwidth-bound · one token per step TTFT — what the user calls "did it freeze" TPOT / ITL — what the user calls "reading speed" e2e request latency — the only one a plain HTTP monitor sees t=0 request arrives last token
Click a segment.

The metric names, in both vocabularies. You will see both in the wild, so know the mapping:

WhatTypevLLM nameOTel GenAI conventionRead it as
Time to first tokenHistogram (s)vllm:time_to_first_token_secondsgen_ai.server.time_to_first_tokenPerceived wait. Dominated by queue + prefill. The metric for interactive UX.
Time per output token (a.k.a. inter-token latency)Histogram (s)vllm:time_per_output_token_secondsgen_ai.server.time_per_output_tokenStreaming smoothness. Humans read ~3 words/s, so roughly 4–5 tok/s is the floor for “no perceived delay”.
End-to-end latencyHistogram (s)vllm:e2e_request_latency_secondsgen_ai.server.request.durationTotal. Correlated with the two above but useful for trends and for non-streaming callers.
ThroughputCountersvllm:prompt_tokens_total, vllm:generation_tokens_total— no recommendationReal system load. Generation tokens/s alone is usually a good enough load indicator, since decode dominates wall-clock.
Queue pressureGaugesvllm:num_requests_waiting, vllm:num_requests_runningWaiting > 0 sustained means you are at capacity. This is your scale-out signal and your leading indicator for TTFT.
KV cacheGauge + counterscache-usage gauge; prefix-cache queries and hits countersCache near 100% means preemption is imminent. Hit rate is your prefix-caching payoff, measured rather than assumed.

Note the shape of that last row: vLLM V1 moved from exporting a prefix-cache hit-rate gauge to exporting queries and hits counters. That is strictly better — you can compute a rate over any window, instead of being stuck with whatever window the server chose.

Pass 3 · trade-offs & nuance
Aged — the source material's alert example and vLLM V1 the source material's PrometheusRule example alerts on max_over_time(time_per_output_token_seconds[5m]) >= 0.3. Two problems today: the metric name is missing the vllm: prefix, and max_over_time on a histogram metric name isn't a valid selector — you want histogram_quantile over the _bucket series. vLLM's V1 engine also deprecated or removed several legacy metrics, including the swap-based preemption metrics (the --swap-space flag is gone) and the hit-rate gauge mentioned above. The material's structure is right — expression, severity labels, a linked runbook URL — and that runbook link is the part most teams skip and most regret at 3 a.m.

Verify names against your build. Metric names and semantics change between vLLM releases; you are on 0.21.0. Before copying any query into production, curl your own /metrics and grep. This is a thirty-second check that prevents a dashboard that renders beautifully and shows nothing.

Concept 4 — Getting the metrics out: scraping, and where it breaks
Pass 1 · intuition

Prometheus is a meter reader that walks a fixed round every fifteen seconds. It needs a stable address and a target that's home. Both of those assumptions get interesting in Kubernetes, and one of them is simply false on serverless.

Pass 2 · mechanism

In Kubernetes the wiring is declarative. A ServiceMonitor (or PodMonitoring on Google's managed service) selects targets by label and sets the scrape interval; the container just exposes /metrics. For KServe you don't write the annotations by hand — the controller does it, from annotations on the ServingRuntime and InferenceService:

# ServingRuntime prometheus.kserve.io/port: '8080' prometheus.kserve.io/path: "/metrics" # InferenceService serving.kserve.io/enable-prometheus-scraping: "true"
The multi-container gotcha worth knowing before you hit it Prometheus assumes one endpoint per target. In KServe's Knative mode the pod runs several containers — the model server plus Knative and Istio sidecars — so a single scrape silently misses signals from the others. KServe ships a metric aggregator component (qpext) that scrapes every container and exposes one merged endpoint; enable it with serving.kserve.io/enable-metric-aggregation. In Standard mode the pod is a single container and you don't need it. If you move your Gemma service to KServe on GKE, this is the setting that decides whether your queue metrics exist.

Managed collection follows the same shape with the operational burden removed. Google Cloud Managed Service for Prometheus runs collectors as a DaemonSet that scrapes only co-located targets and pushes to Google's backend — you keep PromQL and your existing instrumentation, and you stop running Prometheus servers. There are even predefined dashboards for the LLM serving path: Google documents managed-collection exporters for GKE Inference Gateway, vLLM and llm-d.

Pass 3 · trade-offs & nuance
Your specific problem: scraping a scale-to-zero serverless endpoint

Your Gemma service runs on Modal with min_containers=0, max_containers=2 and a 5-minute scaledown window. vLLM does expose /metrics on the same port Modal publishes, so the URL is reachable. Three things then go wrong, and none of them are Modal's fault — they are what pull-based metrics assume:

  • Scraping keeps the container alive. A 15-second scrape means the scaledown window never elapses. You have just converted a scale-to-zero deployment into an always-on GPU bill, using your monitoring.
  • Counters reset on every cold start. rate handles resets, but the gaps between them are real data loss, and cumulative panels will sawtooth.
  • Two replicas behind one URL. Modal load-balances, so successive scrapes hit different containers. The series are interleaved with no distinguishing label — gauges become nonsense and histogram buckets are a blend of two engines.

The design that works: make the gateway the source of truth for everything you alert on. Your FastAPI layer sees every request, never scales to zero, and can attribute correctly. It measures TTFT, e2e latency, tokens, cost, error class and quality — all with proper labels. Treat vLLM's /metrics as best-effort per-replica engine telemetry, scraped opportunistically for capacity work (KV-cache pressure, queue depth, prefix-cache hits), and never as an SLO source. The lab implements this.

Concept 5 — GPU telemetry: utilisation is not saturation
Pass 1 · intuition

A motorway can be “100% occupied” with cars crawling at 8 km/h. Occupancy and throughput are different measurements, and the first one is the one that's easy to collect. DCGM_FI_DEV_GPU_UTIL tells you the GPU was busy during the sample window. It does not tell you it was doing useful work efficiently, and during decode it is routinely near 100% while the compute units are mostly idle, waiting on memory.

Pass 2 · mechanism

Every vendor uses the same architecture: a management component collects from the device, an exporter presents it on /metrics in Prometheus format. NVIDIA's is DCGM with dcgm-exporter, deployed as a DaemonSet on GPU nodes (port 9400), or provisioned automatically by the GPU Operator. AMD and Intel ship equivalents; there is no common naming convention across vendors, which matters if you ever run mixed fleets.

The metrics that matter, grouped by the question they answer:

QuestionMetricHow to read it on an L4 running Gemma
Is the GPU busy?DCGM_FI_DEV_GPU_UTILOccupancy, not efficiency. High during decode almost by definition. Weak signal alone. (Also known to be a heavier metric to collect than most.)
Is it doing real compute?Profiling metrics — SM activity / occupancy, tensor pipe activeThe honest efficiency signal. Low SM activity with high util = memory-bound, i.e. normal decode.
Is memory the bottleneck?DCGM_FI_DEV_MEM_COPY_UTILMemory-controller utilisation. During decode this is the number that's actually pinned.
Am I about to OOM?DCGM_FI_DEV_FB_USED / FB_FREE / FB_TOTALFramebuffer (VRAM). You already learned this the hard way: 0.95 gpu-memory-utilization OOM'd the KV cache on your L4 and 0.92 left headroom. This is the metric that would have told you first.
Is the hardware sick?DCGM_FI_DEV_XID_ERRORS, ECC single/double-bit, retired pagesXID codes and uncorrectable ECC precede real failures. These are the silent-failure detectors nvidia-smi won't show you.
Is it throttling?DCGM_FI_DEV_GPU_TEMP, MEMORY_TEMP, POWER_USAGEThermal or power throttling shows up as unexplained TPOT degradation with no change in load. Correlate before blaming the model.

The diagnostic combination, and the reason you collect more than one: GPU util high + memory-copy util high + SM activity low = memory-bound decode, which is the expected steady state and means you should be looking at batch size and KV cache, not at the model. GPU util high + SM activity high = compute-bound, which for an LLM means you are prefill-dominated and should look at prompt lengths and chunked prefill.

Pass 3 · trade-offs & nuance

On Modal you do not run a DaemonSet, so DCGM is not available to you today — GPU telemetry is one of the concrete things you gain by moving to KServe on GKE, alongside the GPU Operator provisioning the exporter for you. Design the dashboard so the GPU row degrades gracefully: build the panels against DCGM metric names now, accept that they're empty on Modal, and they light up the day you move. A dashboard you have to rebuild at migration time is a dashboard nobody migrates.

Concept 6 — Tracing, and the GenAI semantic conventions
Pass 1 · intuition

A trace is a delivery tracking number for one request. Each handoff — gateway, retrieval, model, tool — stamps it with a start time, an end time and some attributes. The stamps only assemble into one journey because everyone agreed to pass the same identifier along. That agreement is context propagation, and it is the entire trick.

Pass 2 · mechanism
span
One timed operation: a name, start and end timestamps, a status, key/value attributes, and a parent. The unit of a trace.
trace
The tree of spans sharing a trace ID. Rendered as a waterfall, which is why the slow step is visually obvious in a way it never is in metrics.
context propagation
Passing trace ID and parent span ID across process boundaries, usually in HTTP headers. Every component in the path must participate — a component that doesn't breaks the tree there.
semantic conventions
Agreed names for attributes, so “which model was called” is the same key regardless of who instrumented it. Without them, every framework invents its own and you spend your debugging time translating.
sampling
You cannot afford to keep every trace. Head sampling decides at the start (cheap, dumb); tail sampling decides after seeing the whole trace (expensive, keeps the interesting ones — errors and slow requests).

vLLM speaks OpenTelemetry natively — one flag on the server:

--otlp-traces-endpoint $OTEL_ENDPOINT # gRPC or HTTP env: OTEL_SERVICE_NAME=vllm-server

A GenAI span carries a standard attribute set. Names you'll actually see:

# span: gen_ai.chat gen_ai.operation.name = "chat" gen_ai.provider.name = "openai" # OpenAI-compatible server gen_ai.request.model = "gemma-4-e4b" gen_ai.request.temperature = 0.0 gen_ai.usage.input_tokens = 3480 gen_ai.usage.output_tokens = 412 gen_ai.response.finish_reasons = ["stop"] gen_ai.response.time_to_first_chunk = 0.84 # streaming gen_ai.prompt.name / .version = "fin_analyst_v4" / "4.2.0"

Prompt and completion content is opt-in and off by default — a deliberate PII decision. Capturing it means setting an explicit environment variable, and the content attributes carry warnings about sensitive data. Decide that per-slice, not globally.

Pass 3 · trade-offs & nuance
The convergence point: eval scores are now a standard telemetry signal

This is the single most important thing in S3 for this stage, and it post-dates the source material. OpenTelemetry defines a gen_ai.evaluation.result event whose job is to carry the result of evaluating a GenAI output for quality — parented to the span being evaluated, or carrying gen_ai.response.id when the span ID isn't available. Its attributes are exactly the S1 vocabulary:

# event: gen_ai.evaluation.result gen_ai.evaluation.name # required — "Faithfulness", "CitationBinding" gen_ai.evaluation.score.value # the number gen_ai.evaluation.score.label # low-cardinality: pass | fail | relevant | ... gen_ai.evaluation.explanation # the judge's reasoning gen_ai.response.id # when you can't parent to a span error.type # when the EVALUATOR itself failed

Two details worth internalising. First, score.label is explicitly meant to be low cardinality — the spec's own note is that a raw score of 1 means “relevant” in one system and “not relevant” in another, so the label is the interpretation and you must document your possible values. Second, error.type is conditionally required when the evaluation ends in an error: a judge that timed out is a distinct outcome from a judge that scored zero, and conflating them is how you get a quality alert during an unrelated API outage.

Aged — the source material on the state of the conventions the source material says an experimental specification “already defines a core set of semantic conventions” for LLM observability, under a general generative-AI subproject, and correctly predicts that predicting adoption is hard. Current status, and it matters for how you pin versions:
  • All gen_ai.* content was moved out of the main semantic-conventions repository into a dedicated GenAI conventions repo in v1.42.0 (June 2026). That is an organisational change giving the fast-moving work its own release cadence — not a graduation to stable.
  • As of mid-2026, no GenAI-specific span, metric, event or attribute is marked Stable. They remain in Development, with no committed stabilisation timeline. Shared core attributes referenced alongside them — error.type, server.addressare stable.
  • Frameworks emit several generations of attribute names at once. The spec provides OTEL_SEMCONV_STABILITY_OPT_IN for dual-emission during transitions.
The engineering posture: adopt them now — the shape is good and widely emitted — but pin the convention version, and inspect a stored trace to see what your exact package actually emits rather than trusting a blog example.
Concept 7 — LLM-native tracing: Langfuse and Phoenix
Pass 1 · intuition

A generic APM tool sees an HTTPS call to a hostname taking 4.2 seconds. An LLM-native tool sees which prompt version was used, what came back, how many tokens it cost, which retrieved chunks were in context, and what a judge scored it. Same underlying trace, radically different question-answering power.

Pass 2 · mechanism
Langfuse — the data model you already touched

Five objects, and understanding them is most of the tool. A trace is one end-to-end request. Observations are the nodes inside it; a generation is a specialised span wrapping a model call. A session groups traces belonging to one conversation or case. A score is a numeric, categorical or boolean quality signal attached to a trace, observation or session — scores are what power evaluation and analytics. Everything carries metadata, tags and a user ID, so you can slice by customer or feature.

What your toy version missed: scores attached at the wrong level. Attach a faithfulness score to the generation, not the trace, or you can't tell which of three model calls was unfaithful.

Phoenix — OTel all the way down

Apache-2.0, self-hostable in one command. Its setup centres on arize-phoenix-otel and phoenix.otel.register, which creates an OpenTelemetry tracer provider and exports spans to a Phoenix collector. Instrumentation comes from OpenInference — a separate open specification of LLM semantic conventions plus per-library instrumentors, model-provider agnostic.

The distinction worth holding: Phoenix is the workbench; OpenInference is the schema. If you already run OpenTelemetry elsewhere the mental model is identical and there's no proprietary SDK to learn. It also runs phoenix.evals over captured traces and logs the results back onto spans — the same convergence idea as the OTel event.

Choosing: both are open-source, both self-host, both do tracing + evaluation + datasets + experiments. Langfuse leans toward prompt management, human annotation queues and CI-gating workflows; Phoenix leans toward OTel-purity and span-level analysis. For your stack the tiebreaker is that Langfuse's score model maps cleanly onto the S1 evaluator/metric vocabulary, and you already have Langfuse examples running.

Pass 3 · trade-offs & nuance

Do not run two tracing systems on the same request path. Pick one collection path, and if you need the data in two places, fan out at the collector — the OTel Collector exists precisely so that instrumentation is decided once and destinations are decided later. Double-instrumenting produces two incomplete trees and a lot of confident wrong conclusions.

Concept 8 — SLI, SLO, error budget, and alerts worth waking up for
Pass 1 · intuition

A monthly reliability target isn't a wish, it's a budget. 99.9% over 30 days means you are allowed 43 minutes of failure. Spend it slowly and nobody needs to wake up. Spend a third of it in an hour and someone does. That framing — rate of spend, not absolute level — is what stops alert fatigue.

Pass 2 · mechanism
SLI — service-level indicator
A metric chosen because it has direct user impact. For an LLM, TPOT is a good one: it measures the wait between tokens the user actually experiences. “CPU utilisation” is not an SLI.
SLO — service-level objective
The promise you make about an SLI: e.g. TPOT under a threshold for 99.9% of requests over a rolling 30 days. Internal, and it should be achievable — an SLO you always breach is just a lie with a dashboard.
SLA — service-level agreement
The contractual version, usually coarser (monthly availability) and with money attached. Breaking SLOs erodes the margin that keeps you SLA-compliant.
error budget
100% minus the SLO, expressed as allowed bad events. 99.9% over 30 days = 0.1% = ~43 min. It is a budget: it can be spent deliberately on risky launches.
burn rate
How fast you're consuming the budget relative to steady state. Burn rate 1 = you'll exhaust it exactly at period end. Burn rate 14.4 = you'll exhaust a 30-day budget in about 2 days.

Multi-window multi-burn-rate alerting is the technique that makes this usable. One alert per severity, each requiring a fast window and a slow window to agree — the slow window prevents a 30-second blip from paging, the fast window makes recovery detection quick:

# PAGE: burning 14.4x -> 2% of a 30-day budget in 1 hour ( slo:ttft_error_ratio5m > 14.4 * 0.001 ) and ( slo:ttft_error_ratio1h > 14.4 * 0.001 ) # TICKET: burning 3x -> slow leak, fix it this week, don't wake anyone ( slo:ttft_error_ratio30m > 3 * 0.001 ) and ( slo:ttft_error_ratio6h > 3 * 0.001 )

Both alerts are on the same SLO. What differs is urgency, and therefore the destination: one pages, one files a ticket. Everything else goes on a dashboard and pages nobody.

Pass 3 · trade-offs & nuance

Quality SLOs need longer windows than latency SLOs, for a statistical reason. If you sample 2% of 10 000 daily requests for judging, that's 200 scores/day — about 8 per hour. A one-hour window on a quality metric is nearly pure noise; the confidence interval on 8 samples is enormous. Quality burn-rate alerts want 6h/24h windows, or a much higher sampling rate on the specific slice you're gating. This is the S1 sample-size arithmetic showing up again, now as an alerting design constraint.

Every alert needs a runbook URL. the source material's example includes one and it's the most underrated line in the section. An alert without a documented response procedure is a notification that someone should feel bad.

Decision tree · metric, trace, or log — which layer answers this question?
Asking the wrong layer is the most common time-waster in an incident. Follow “no ↓”.
Q1Is the question about one specific request — this user, this trace ID, this failure?
yes
TraceOpen the waterfall. Metrics fundamentally cannot answer this; they threw the individual away at aggregation time.
no ↓
Q2Is it about a trend, a distribution, or a rate over time — “is it getting worse”, “what's the p99”?
yes
MetricHistogram over a rate, aggregated by slice. Cheap and permanent. Do not try to compute this by counting traces; traces are sampled.
no ↓
Q3Do you need the exact text a component emitted — a stack trace, an engine startup line, a raw error body?
yes
LogSearch the aggregated logs. On K8s, remember pod churn deletes the node file — if it isn't shipped, it's gone.
no ↓
Q4Is it about whether the content was correct, faithful, safe or well-formed?
yes
Quality signalA score on the span, plus a metric for the trend. If you don't have one, no amount of infra telemetry will ever tell you — go build S1's harness.
no ↓
Q5Is it “which deploy caused this?”
yes
Metric + version labelAnswerable only if the system-tuple versions are labels or exemplars. If they aren't, this is a logging-design bug, not an incident-response problem.
no ↓
DefaultStart at the metric, narrow by slice, then jump to a trace via an exemplar, then read the logs for that pod and window. Metric → trace → log, in that order. Going the other way means reading a lot of text before you know what you're looking for.
Decision tree · alert, dashboard, or ignore?
Applied to every signal before it earns a place in your alerting config.
Q1If this fires at 03:00, is there an action a human can take right now that materially helps?
no
Dashboard, not an alertAn alert nobody can act on trains people to ignore alerts, which costs you the ones that matter.
yes, there's an action ↓
Q2Does it map to an SLI with a user-visible SLO, and is the budget burning fast?
yes
PageMulti-window burn-rate alert, severity critical, runbook URL attached, owner named.
no ↓
Q3Is it a slow leak — real degradation, but hours or days from hurting anyone?
yes
Ticket alertLower burn-rate thresholds on longer windows. Routes to a queue, not a phone.
no ↓
Q4Is it a leading indicator that reliably precedes a real breach — queue depth rising, KV cache above 90%, retry budget half spent?
yes
Warning alertLow severity, routed to a channel. Useful precisely because it fires before the SLO does.
no ↓
DefaultDashboard panel only. Most signals are diagnostic — they exist to explain a load-bearing metric once you're already looking. Adding them to alerting is how a healthy on-call rota becomes an unhealthy one.
03

Reality check

25%

Worked example A · why p99 tells a different story from the mean

A single hour of TTFT observations on your Gemma endpoint. 1 000 requests, two populations mixed together — short queries hitting a warm prefix cache, and long 10-K sections arriving during a burst.

// the distribution, as bucket counts 0.0–0.5s : 720 requests // prefix cache hits, warm container 0.5–1.0s : 210 requests 1.0–2.5s : 50 requests 2.5–5.0s : 12 requests 5.0– 30s : 8 requests // cold starts + long prefill under queue // the mean, which is what a naive dashboard shows mean ≈ (720×0.25 + 210×0.75 + 50×1.75 + 12×3.75 + 8×15) / 1000 = (180 + 157.5 + 87.5 + 45 + 120) / 1000 = 0.59 s -> "TTFT is under 600ms" -> everyone relaxes // the percentiles, which are what users experience p50 ≈ 0.33 s // the typical user: delighted p95 ≈ 1.6 s // 50 users/hour: noticeable pause p99 ≈ 4.2 s // 10 users/hour: is it broken? p99.9≈ ~20 s // 1 user/hour: definitely broken

Now the part that matters operationally. Those 8 slowest requests are 0.8% of traffic — but look at what they do to the mean: they contribute 120 of the 589 total milliseconds, or 20% of the mean. So the mean is simultaneously (a) hiding the tail and (b) being distorted by it. It is the worst of both worlds and it is the default panel in most dashboards.

And per-user exposure compounds. If a single analyst processes 40 sections in a session, the chance they hit at least one p99 event is:

P(at least one ≥4.2s) = 1 − (0.99)⁴⁰ = 33% P(at least one ≥20s) = 1 − (0.999)⁴⁰ = 3.9%
Verdict: a third of your sessions contain a visible stall, on a service whose mean TTFT is 590 ms. Alert on p95 and p99 of a histogram, never on a mean, and never on a summary you can't aggregate across replicas. Then split the two populations with a cache_hit label — because these are not one distribution with a tail, they are two distributions, and the fix for each is different.

Worked example B · reading GPU utilisation vs memory saturation on your L4

Three panels during a steady decode-heavy workload. What do you conclude?

DCGM_FI_DEV_GPU_UTIL = 97% // "the GPU is maxed out!" DCGM_FI_DEV_MEM_COPY_UTIL = 88% // memory controller nearly pinned SM activity (profiling) = 21% // compute units mostly idle DCGM_FI_DEV_FB_USED / TOTAL = 21.1 / 23.0 GiB = 92% vllm:num_requests_waiting = 0 generation tokens/s = ~900

Reading it: util 97% with SM activity 21% is the signature of memory-bound decode — the GPU is busy waiting on memory, which is the expected steady state for autoregressive generation. Buying a faster-compute GPU would change almost nothing. What would help is anything that improves arithmetic intensity: larger batches (more sequences sharing each weight read), FP8 weights and FP8 KV cache (fewer bytes moved per token — which is exactly what your quant profile does), or prefix caching (skip the prefill entirely).

And the 92% framebuffer number is the real warning. That's your gpu-memory-utilization: 0.92 setting doing what you told it to. There is no headroom for a longer context or a bigger batch:

// what happens at the boundary KV cache full -> scheduler preempts running sequences -> preempted requests re-enter the queue -> num_requests_waiting spikes -> TTFT p99 blows out while GPU_UTIL still reads 97% // which is why the alert is on the cache, not on utilisation alert: KVCachePressure expr: vllm_kv_cache_usage_perc > 0.90 // verify the exact name on YOUR build for: 10m
Verdict: GPU_UTIL is the least informative GPU panel on the dashboard and the one everyone puts first. The load-bearing pair for an LLM is framebuffer used % (are you about to preempt) and requests waiting (are you already over capacity). Put those two at the top; keep util as a diagnostic below the fold.
Fresh example 1 · vLLM ships the dashboards now

the source material tells you to configure scraping and then build your own Grafana views. As of 2026 you don't have to start blank: vLLM's docs publish importable dashboard JSON — performance_statistics.json for latency and throughput and query_statistics.json for request volume and KPIs — and the vLLM production-stack project ships a fuller vllm-dashboard.json organised into four collapsible rows: system overview, quality of service, engine load, resource usage. Its observability bundle is built on kube-prometheus-stack and includes a Prometheus Adapter so vLLM metrics reach the Kubernetes Metrics API for custom autoscaling — which is the bridge from your monitoring to KEDA-style scaling on queue depth.

Use these as a starting point and a naming reference, then delete two-thirds of the panels. A dashboard that answers a question is worth ten that display metrics.

Fresh example 2 · the NVIDIA stack, since that's your career direction

Dynamo — NVIDIA's distributed serving framework — is instructive because of how conventionally it behaves. Runtime metrics prefixed dynamo_* appear on the same /metrics endpoint alongside backend metrics (vLLM's vllm:*, TensorRT-LLM's trtllm_*). On Kubernetes the Dynamo operator creates PodMonitor resources automatically and labels pods for discovery; GPU metrics come from dcgm-exporter and node metrics from node-exporter, both via kube-prometheus-stack. It ships pre-provisioned Grafana dashboards and a Tempo instance for traces.

The lesson: NVIDIA's serving stack is not a parallel universe. It is Prometheus + Grafana + OTel + DCGM with sensible defaults. Everything you learn here transfers directly, which is the strongest argument for learning the open-source layer properly rather than a vendor console.

Fresh example 3 · the honest limit of OpenTelemetry for this stage

Worth stating plainly, because it's the boundary between S3 and everything else in this stage: the GenAI semantic conventions standardise model attributes, token usage and latency. They do not tell you how to produce output evaluation, safety scoring or content quality assessment — the gen_ai.evaluation.result event standardises the shape of the answer, not the evaluator that computes it. OpenTelemetry is the data plane; you still need an evaluation layer on top, and that layer is S1, S2 and S4.

The corollary is a useful architectural rule: choose your evaluator freely, but make it emit OTel events. Then swapping judges or eval frameworks doesn't invalidate your dashboards, your alerts or your history.

04

Apply to my stack — lab

10%

4.1 · Gateway-side metrics, correctly typed and correctly labelled

This replaces your QuickMetricsCollector. Note what's a histogram, what's a counter, and — importantly — what is not a label.

gateway/metrics.py
from prometheus_client import Counter, Gauge, Histogram

# Bounded label set. slice/tier/model are small enums. NEVER user_id, doc_id, prompt.
LBL = ["model", "task", "risk_tier", "cache"]

# Buckets straddle the SLO (1.5s) with resolution on BOTH sides, and a long
# tail because Modal cold starts are real and must not all land in +Inf.
TTFT = Histogram("gw_ttft_seconds", "Time to first token, measured at the gateway",
                 LBL, buckets=(.1,.25,.5,.75,1.0,1.5,2.5,4.0,7.0,15.0,30.0,60.0))

TPOT = Histogram("gw_time_per_output_token_seconds", "Inter-token latency", LBL,
                 buckets=(.005,.01,.02,.035,.05,.08,.12,.2,.4))

E2E  = Histogram("gw_request_duration_seconds", "End to end", LBL,
                 buckets=(.5,1,2,4,8,15,30,60,120))

TOK  = Counter("gw_tokens_total", "Tokens", LBL + ["direction"])   # in|out
COST = Counter("gw_cost_usd_total", "Attributed cost", LBL)
REQ  = Counter("gw_requests_total", "Requests", LBL + ["outcome"])
# outcome: ok | contract_repair | contract_fallback | claim_blocked | upstream_error

# Controlled quantities from S2 — these make latency spikes ATTRIBUTABLE
RETRY   = Counter("gw_retries_total", "Retries", LBL + ["reason"])
INFLIGHT= Gauge("gw_inflight_requests", "In flight", ["model"])

# THE FOURTH PILLAR. Quality is a metric like any other.
QUALITY = Histogram("gw_eval_score", "Evaluation scores from online judging",
                    LBL + ["eval_name"], buckets=(0,1,2,3,4,5))
QFAIL   = Counter("gw_eval_errors_total", "Evaluator itself failed",
                  LBL + ["eval_name", "error_type"])   # NOT the same as a low score

4.2 · The Modal scrape problem, solved

Do not point Prometheus at your Modal URL on a 15-second interval. Two options, and the second is the one to ship.

OptionHowVerdict
Direct scrape of the Modal endpointPrometheus static_configs → the Modal web URL /metricsDon't. Defeats scale-to-zero, mixes replicas, sawtooths on cold start.
Gateway as SLO source + opportunistic engine pullGateway exposes /metrics (always up, correct labels). A separate low-frequency job pulls vLLM /metrics only when the gateway has seen traffic in the last 60s, and writes it into a Pushgateway or as recording rules under a source="engine" label.Ship this. SLOs and alerts read gateway series only. Engine series are capacity diagnostics with an explicit “best effort” note on the panel.
monitoring/engine_pull.py — runs in the gateway process
import httpx, asyncio, time

_last_traffic = 0.0        # updated by the request middleware

async def pull_engine_metrics(modal_url: str, interval: int = 60):
    """Only pull while the container is ALREADY warm. Never resurrect it.
    Everything landed here is diagnostic; nothing here backs an SLO."""
    async with httpx.AsyncClient(timeout=5) as c:
        while True:
            await asyncio.sleep(interval)
            if time.time - _last_traffic > 45:
                continue                      # likely cold or scaling down: leave it alone
            try:
                r = await c.get(f"{modal_url}/metrics")
                # re-expose under source="engine"; note replica identity is UNKNOWN
                ingest_openmetrics(r.text, extra_labels={"source": "engine"})
            except Exception as e:
                ENGINE_PULL_FAIL.labels(reason=type(e).__name__).inc

4.3 · Dashboard: six panels, in this order

Your Grafana JSON in monitoring/grafana/dashboards/ becomes this. Rule: every panel answers a question you can name out loud, and the top row is load-bearing only.

#PanelQueryQuestion it answers
1TTFT p50/p95/p99 by risk tierhistogram_quantile(0.99, sum by (le,risk_tier) (rate(gw_ttft_seconds_bucket[5m])))Are users waiting, and is the high-risk path worse?
2Error-budget burn, TTFT SLOrecording rule slo:ttft_error_ratio over 1h / 6hDo we need to stop shipping?
3Faithfulness p50 + sample counthistogram_quantile(0.5, sum by (le,task) (rate(gw_eval_score_bucket{eval_name="faithfulness"}[6h])))Is the output still good? Always plot n next to it.
4Outcome mix (stacked)sum by (outcome) (rate(gw_requests_total[5m]))Are contracts repairing / falling back / claim-gating more than usual?
5Engine load — waiting vs running, KV cache %vllm:num_requests_waiting, cache usage gaugeAre we at capacity? (marked “best effort, engine-pull”)
6Cost per 1k requests by taskrate(gw_cost_usd_total[1h]) / rate(gw_requests_total[1h]) * 1000Did that quality change cost us money?
Two dashboard rules worth more than any panel (1) Annotate deploys. Push a Grafana annotation on every release carrying prompt_version, model and serving_profile. A vertical line through all six panels turns “something changed on Tuesday” into a one-glance answer. (2) Panel 3 must show its sample count. A faithfulness line computed from 8 samples per hour looks exactly like one computed from 800. Plot n on the right axis, and your future self will not act on noise.

4.4 · Alert rules that survive review

monitoring/prometheus/alert_rules.yml
groups:
- name: gemma-slo
  rules:
  # --- recording rule: "bad" = TTFT over 1.5s. Defined once, reused everywhere.
  - record: slo:ttft_error_ratio5m
    expr: |
      sum(rate(gw_ttft_seconds_bucket{le="+Inf"}[5m]))
      - sum(rate(gw_ttft_seconds_bucket{le="1.5"}[5m]))
      / sum(rate(gw_ttft_seconds_count[5m]))

  # --- PAGE: fast burn (14.4x of a 99.9% / 30d budget)
  - alert: TTFTBudgetBurnFast
    expr: slo:ttft_error_ratio5m > 0.0144 and slo:ttft_error_ratio1h > 0.0144
    for: 2m
    labels:   {severity: critical, team: llm-platform}
    annotations:
      summary: "TTFT budget burning 14.4x — 2% of the month in 1 hour"
      runbook_url: "https://runbooks/gemma/ttft-burn"

  # --- WARNING: leading indicator, fires BEFORE the SLO does
  - alert: KVCachePressure
    expr: vllm_kv_cache_usage_perc{source="engine"} > 0.90
    for: 10m
    labels:   {severity: warning}
    annotations:
      summary: "KV cache >90% — preemption imminent, TTFT p99 will follow"
      runbook_url: "https://runbooks/gemma/kv-pressure"

  # --- QUALITY SLO. Long window: only ~8 judged samples/hour at 2% sampling.
  - alert: FaithfulnessDegraded
    expr: |
      histogram_quantile(0.5, sum by (le,task) (
        rate(gw_eval_score_bucket{eval_name="faithfulness"}[6h]))) < 4.0
      and sum by (task) (rate(gw_eval_score_count[6h])) * 21600 > 100
    for: 30m
    labels:   {severity: critical, team: llm-quality}
    annotations:
      summary: "Faithfulness median below 4.0 on {{ $labels.task }}"
      runbook_url: "https://runbooks/gemma/faithfulness-drop"

  # --- The one people forget: is the JUDGE broken, or is the MODEL bad?
  - alert: EvaluatorFailing
    expr: rate(gw_eval_errors_total[15m]) / rate(gw_eval_score_count[15m]) > 0.1
    for: 15m
    labels:   {severity: warning}
    annotations:
      summary: ">10% of judge calls erroring — quality panels are unreliable right now"

Note the second clause on FaithfulnessDegraded: it refuses to fire unless there are more than 100 judged samples in the window. That single line is the S1 sample-size lesson, encoded as an alerting guard, and it will save you from three false pages a month.

4.5 · The span layout for one fintech request

gateway/tracing.py
with tracer.start_as_current_span("analyze_filing_section") as root:
    root.set_attribute("task", task); root.set_attribute("risk_tier", tier)
    root.set_attribute("gen_ai.prompt.name", PROMPT_ID)
    root.set_attribute("gen_ai.prompt.version", PROMPT_VERSION)

    with tracer.start_as_current_span("retrieval") as s:
        s.set_attribute("retrieval.doc_ids", ids); s.set_attribute("retrieval.index_snapshot", snap)

    with tracer.start_as_current_span("gen_ai.chat") as s:      # the OTel-conventional one
        s.set_attribute("gen_ai.request.model", "gemma-4-e4b")
        s.set_attribute("gen_ai.request.temperature", dec["temperature"])
        s.set_attribute("gen_ai.usage.input_tokens", usage.prompt)
        s.set_attribute("gen_ai.usage.output_tokens", usage.completion)
        s.set_attribute("gen_ai.response.time_to_first_chunk", ttft)

    with tracer.start_as_current_span("contract_validate") as s:
        s.set_attribute("contract.outcome", outcome)   # ok|repair|fallback

# LATER, asynchronously, off the critical path — the convergence:
def emit_eval(span_ctx, name, value, label, explanation):
    logger.emit("gen_ai.evaluation.result", context=span_ctx, attributes={
        "gen_ai.evaluation.name": name,            # "Faithfulness"
        "gen_ai.evaluation.score.value": value,    # 4.0
        "gen_ai.evaluation.score.label": label,    # "pass" — LOW cardinality
        "gen_ai.evaluation.explanation": explanation,
    })
    QUALITY.labels(**lbl, eval_name=name).observe(value)   # and as a metric
Optional exercise

Recreate your E2E baseline on real dashboards, then find where it lies. Run your 11-query test against the live Modal endpoint with the gateway metrics above wired up, and reproduce the three headline numbers — 100% success, 91% cache hit, 3-model routing split — as Grafana panels. Then answer two questions the test report couldn't: (a) what was the p99 TTFT, and how much of it was the cold start on request one? (b) is “91% cache hit” a prefix-cache hit measured by vLLM's queries/hits counters, or your gateway's own response cache? Those are different metrics with the same name in your README, and knowing which one you published is the whole exercise.

Bridge → S4 Panel 3 on that dashboard — the faithfulness line — is currently a promise. It assumes you have a judge, and that its scores mean something. S4 earns that assumption: the bias catalogue, rubric design, calibration against human labels, and the latency-and-cost price every reliability pattern charges for its quality gain.
Session 4 · the source material — Improving Reliability (Patterns 17–20)

The judge is an instrument, and instruments need calibrating

Four patterns that buy reliability, each with a price tag in latency and money. The first one — LLM-as-Judge — is the load-bearing one, because every automated quality number in the previous three sessions eventually rests on it.

01

Why this session exists

10%
The failure: a judge that gives everyone an A

Two logo designs get scored against the same five-criterion rubric. A genuinely excellent, professionally-made logo scores 0.90. A generic first draft the model produced thirty seconds ago also scores 0.90. The rubric was reasonable. The criteria were sensible. The scores are useless, because the instrument does not discriminate.

That is the default behaviour of an uncalibrated LLM judge, and it is the reason “we added LLM-as-Judge” is not the same sentence as “we can measure quality”.

Evaluating open-ended output is hard because the three classical approaches each fail in a different direction:

Why LLM-as-Judge exists at allclick a box
OUTCOME MEASUREMENT the gold standard… …but confounded by everything and only available post-deploy HUMAN EVALUATION works pre-deploy, high fidelity… …expensive, slow, biased, bounded by expert availability AUTOMATED METRICS systematic, instant, free… …n-gram overlap can't see meaning, nuance or factual correctness LLM-AS-JUDGE · the middle ground outcome-proxy · pre-deploy · systematic · fast · customisable
Click a box.

The pattern's promise is real. The catch is that it inherits none of the three approaches' trustworthiness So do we.

02

Core concepts, from zero

50%
Pattern 17 — LLM-as-Judge: three implementationsprompting → ML → fine-tuning
Pass 1 · intuition

You want to know whether marketing copy will sell. You can't run the sale (too slow, too confounded), and you can't hire a focus group per draft. So you write down what good copy looks like, and hand that checklist to a competent reader. LLM-as-Judge is the checklist plus a reader that never gets tired and costs a fraction of a cent.

Pass 2 · mechanism

Three escalating options. Most teams start at 1 and only move when they have data.

Write a rubric, ask a model to apply it

A custom scoring rubric for your problem, applied by an LLM at temperature zero. The skeleton is small:

Given an article and a summary, score 1–5 on each criterion. For each score, provide a brief justification. - Factual accuracy - Completeness of key points - Conciseness - Clarity

Two things make it consistent: temperature zero plus caching, and self-contained inputs — pre-process so the judge sees everything it needs, e.g. include enough conversation turns and summarise long agent outputs rather than truncating them arbitrarily.

Learn the mapping from rubric scores to the outcome you care about

The prompting judge gives you a vector of criterion scores. Whether that vector predicts anything is a separate question — so answer it empirically. Three steps: (1) build the rubric; (2) collect historical cases where you know the real outcome; (3) train a classifier on rubric scores → outcome.

The elegant property: the model learns to discount criteria that don't matter, or on which the judge is inconsistent. You get outcome alignment and noise-robustness from the same fit — provided you don't overfit, which with a handful of criteria and a few hundred outcomes is a real risk you must hold out against.

When you cannot write down what “good” means

Some criteria resist articulation. What exactly makes financial commentary appropriately hedged? If experts can recognise it but not specify it, have them annotate against the same rubric, then adapter-tune a model on those input→score pairs so it reproduces expert judgement.

This is the right shape when you need conformance to a professional standard — applying a diagnostic checklist the way a clinician would, or a materiality assessment the way an auditor would. It's also the most expensive option and the one that needs the most labelled data, so it is genuinely a last resort.

Fourth design axis, orthogonal to all three: what shape is the output? A single piece scored absolutely (pointwise), a head-to-head against a reference (pairwise), or a ranked list. Choose based on how the score gets used: if a decision is made from the output, binary is best, because it ties the score directly to the correctness of the decision.

Pass 3 · trade-offs & nuance

The counter-intuitive caveat about explanations. Asking the judge to justify each score is standard advice, and it buys real interpretability. But there is evidence that requiring self-explanation degrades evaluation performance and bias mitigation — plausibly because the act of generating a justification entrenches a bias rather than exposing it. If you genuinely don't need explanations, an alternative is to take the probability-weighted mean over the score distribution rather than the single most-likely score. Be careful combining that with chain-of-thought prompting; the two interact oddly.

Practical read for you: keep explanations on. You need them for calibration and for debugging your rubric, and you are nowhere near the regime where squeezing the last few points of judge accuracy matters. Just know the trade-off is real and revisit it once your calibration numbers plateau.

Concept 2 — Rubric design: the four levers that control consistency
Pass 1 · intuition

Ask ten people to rate a film out of 100 and you get chaos. Ask them “would you recommend it — yes or no” and you get a signal. Every lever below is a variation on that: reduce the number of ways the instrument can wobble.

Pass 2 · mechanism
Lever 1 · coarse scales

1–5 is usually fine. 1–10 is worse. 1–100 is noise with decimal places. The logical endpoint is binary, which gives the best consistency of all — and, conveniently, is also what a gate needs. Prefer several binary questions over one graded score.

Lever 2 · multiple criteria, not one aggregate

Splitting one “quality” score into four named criteria is a form of chain-of-thought — it forces the judge to reason through components before committing. It also makes regressions diagnosable: you learn which dimension moved.

Lever 3 · anchored criteria

Don't say “factual accuracy”. Say what 1 and 5 mean: 1 if any statement misrepresents the source; 5 if every statement is grounded in it. Anchors are the difference between a rubric and a mood.

Lever 4 · a reference to compare against

Supplying a reference output — even just the previous approved version — calibrates the model and stabilises scores across invocations. This is also the fix for leniency (below), because relative judgement is far more reliable than absolute.

A fifth option when stakes justify the cost: LLM-as-jury — several models evaluate, each optionally adopting a different stakeholder's perspective. Combine it with binary questions and you get nuanced scoring out of individually-reliable judgements; that combination is sometimes called polling.

Pass 3 · trade-offs & nuance

Make the criteria a proxy for the outcome or the KPI. Measuring outcomes is best but usually confounded; the next-best thing is judging against business-aligned indicators. Two warnings come with that: watch for adversarial actors gaming the metric, and remember that a criterion you can't connect to any downstream consequence is a criterion you're measuring because it was easy to write.

Concept 3 — The bias catalogue, and where to intervenethe core of this session
Pass 1 · intuition

Every measuring instrument has systematic error. A cheap kitchen scale reads 3 g heavy — that's fine if you know it, because you subtract 3. The danger is an unknown bias, and the specific danger with judges is that several of their biases push in the same direction as the thing you're trying to measure.

Pass 2 · mechanism
The judge pipeline, with a bias checkpoint at each stageclick a checkpoint
1 · inputs assembled candidate(s) + context 2 · rubric applied criteria + anchors 3 · scores emitted values + justification 4 · aggregated → metric → gate position bias favours whichever candidate came first lost-in-the-middle misses information buried mid-context rubric wobble unanchored criteria drift between runs authority · bandwagon swayed by citations or stated majorities verbosity / length longer scores higher at equal content self-preference favours its own output, and low-perplexity text leniency everyone gets an A; no discrimination shared bias ensembling does not remove common error Intervene at the stage, not at the end. A correction applied after aggregation cannot recover information the judge never used.
Click any bias for its mechanism and its mitigation.

The three that will actually bite you, with what the literature says and what to do:

BiasMechanismMitigationResidual risk
Position In pairwise comparison, judges systematically favour one slot. Widely reproduced; the standard finding is a preference for the first answer, though in some domains — safety in particular — it can favour either side. Run both orders and average, or count only cases where the verdict survives the swap. Cheap and effective. Measure your own swap-disagreement rate and report it. Doubles judge cost. Ties need an explicit policy rather than being silently broken.
Verbosity / length Longer answers score higher even when they contain the same relevant content. Quantified by looking at error rates on long-vs-short options. Anchor conciseness as an explicit scored criterion; normalise or cap length before judging; penalise length in the aggregation function. Over-penalising length hurts genuinely thorough answers. Anchor it, don't just subtract.
Self-preference Models score their own outputs higher. The proposed mechanism is familiarity: judges prefer text they assign lower perplexity to — which means they prefer well-written text over badly-written-but-accurate text, regardless of authorship. Use a different model family as judge than the one that generated the output. Ensemble across models. One suggested refinement: down-weight a model's vote on samples where it shows unusually low perplexity. The perplexity mechanism means this is not only about self-recognition — a judge from a different family can still over-reward fluent nonsense.
Pass 3 · trade-offs & nuance

Leniency, and the three ways out. Judges behave like a professor who gives every student an A and B. Combined with non-determinism, that means you should not compare two absolute scores to establish a ranking. Instead: (1) compare the two pieces directly in one pairwise call; (2) normalise within a group — generate several responses, score them together, and express each score relative to the group average (the group-relative approach used in GRPO-style training); (3) lower your expectations about what the judge is for — use it to identify problems rather than to rank near-equivalents. A technical RAG failure like total context loss scores near zero while normal operation scores 0.95, and that gap is detectable even by a lenient instrument.

The uncomfortable 2026 finding about ensembling. The standard defences — ensembling across judge models, reporting inter-judge agreement, reversing presentation order — reduce variance within the judge population. They do not remove systematic bias shared across it. Recent work identifies exactly this: shared biases that survive all three defences. The practical implication is blunt — agreement between judges is not evidence of correctness. Only agreement with humans is, which is the next concept.

When to stop using a frontier judge. Given the cost and bias profile, fine-tuned small judge models are worth considering for specific dimensions, and specialised evaluator models exist for particular tasks and industry-specific metrics. For your pipeline the relevant version of this is: your own Gemma E4B, running on hardware you already pay for, is a legitimate judge candidate for the deterministic-adjacent dimensions — once calibrated.

Concept 4 — Calibration: proving the judge agrees with humans
Pass 1 · intuition

Before a new thermometer goes in a hospital, someone puts it in ice water and boiling water and checks it reads 0 and 100. Nobody argues about whether that's necessary. Calibrating a judge is the same procedure: a small set of cases where you already know the answer, and a number describing how well the instrument reproduces it.

Pass 2 · mechanism

Treat the judge as a classifier against a human reference, and report what you'd report for any classifier.

the reference
Built by adjudication or true majority vote over multiple human annotators — not one person's opinion. Preserve ties and low-consensus cases rather than forcing an arbitrary label; those cases are telling you the task is ambiguous.
raw agreement
The percentage of cases where judge and human match. Necessary to report, wildly misleading alone — see Pass 3.
Cohen's kappa (κ)
Agreement corrected for chance, for two raters on nominal labels. κ = (pobs − pchance) / (1 − pchance). 1.0 is perfect, 0 is chance-level, negative is worse than chance.
Krippendorff's alpha
The flexible generalisation: any number of raters, nominal/ordinal/interval scales, tolerates missing observations. For two raters on nominal data with no missing values it's mathematically equivalent to Cohen's kappa, so the choice is toolchain convenience. Fleiss' kappa is the ≥3-rater case but needs equal ratings per item.
TPR / TNR
True-positive rate (of the real failures, how many did the judge catch) and true-negative rate (of the good outputs, how many did it correctly pass). Report these alongside agreement, always — they are the numbers that tell you whether the judge is useful for gating.
human–human agreement
Report it on the same examples with the same metric, as context for the judge's number — not as a hard ceiling. If your humans only agree at κ = 0.5, expecting κ = 0.8 from a judge is incoherent.

The loop: label 30–200 real examples with domain experts (domain knowledge matters here, and the gap between experts and generalist annotators is large) → baseline the full metric ensemble → rewrite the judge prompt against the worst-performing examples → repeat. In practice alignment plateaus in five to ten iterations.

Pass 3 · trade-offs & nuance
The base-rate trap — the single most important number in this session

A content-safety judge reviews 100 outputs. 95 are fine, 5 are violations. The judge says “pass” to everything.

raw agreement = 95/100 = 95% // ship it! …no. p_chance ≈ 0.905 kappa = (0.95 − 0.905) / (1 − 0.905) ≈ 0.47 // and with a slightly different marginal split it goes NEGATIVE: // a judge that always says the majority class can score kappa ≈ −0.05 TPR (caught real violations) = 0 / 5 = 0% TNR (correctly passed good) = 95 / 95 = 100%

The judge has 95% agreement and catches literally nothing. This is why agreement is called a trap metric on imbalanced data, and why TPR and TNR must be reported next to it. Your compliance-flag slice is exactly this shape: rare, high-consequence positives in a sea of clean cases.

Three legitimate places to stop calibrating:

  1. Alignment above ~0.80 with iterations yielding under ~0.02 each — you are now fitting variance in the golden set, not real failure modes.
  2. Human inter-rater agreement below ~0.6 κ on the same set — the task is genuinely ambiguous and further calibration optimises noise. Redefine the criterion instead.
  3. A product decision: “catches 6 of 9 hallucinations” may be fine for a developer tool and unacceptable for a regulatory flag. Define that threshold before you start.

Report per-dimension, never as one average. An aggregate alignment of 0.80 can hide a faithfulness alignment of 0.55 — close to chance on a binary task. And two specific pathologies to watch for: high accuracy with low κ means the judge is right by chance and would score similarly on a shuffled dataset; high precision with low recall means it's conservative — right when it flags, but missing most real failures. Neither is trustworthy.

Pattern 18 — Reflection: critique, apply, regenerate
Pass 1 · intuition

In a chat UI, when the answer is wrong you say so and it fixes it. API calls are stateless, so that loop doesn't exist unless you build it. Reflection is that loop, automated: generate, critique, apply the critique, regenerate.

Pass 2 · mechanism

Instead of one call, you make two or more. The output goes to an evaluator — which may be an LLM judge, an external tool, or a human — and crucially the evaluator returns a critique explaining how the response falls short, not just a score. That critique becomes part of a modified prompt, and the cycle repeats until the response clears a bar.

The Reflection loopclick an element
generate evaluatedifferent model critiquehow it falls short apply → regeneratemodified prompt cap the retries — usually at one
Click a stage of the Reflection loop to see what it costs and where it goes wrong.

The one-round special case is the pragmatic default, and for a specific reason: with exactly one retry, you never have to define what “good enough” means. No threshold, no infinite-loop guard, no leniency problem. Given how hard it is to pick a threshold on a lenient judge's scores, that is a bigger simplification than it looks.

Pass 3 · trade-offs & nuance

Reflection multiplies calls, so it multiplies tail latency. That's the whole trade. Where the cost of being wrong is high relative to an extra inference — code generation against a compiler, product listings that must be complete and correct — multiple reflection stages are clearly worth it, and the evaluator can be an external tool rather than a model. Where latency is the product — chatbots, real-time interfaces — reflection can push tail latency past acceptable limits, especially since you often already need a retry strategy just to get the first call to succeed.

Get the evaluator right or the loop is worse than nothing. The evaluation stage is the important part: a bad critique produces confident, expensive wrongness. And use a different model for critique than for generation — the self-preference bias above is exactly what you're defending against.

A middle path worth knowing: generate several drafts up front, critique all of them, prune the weak candidates, and continue — a beam-search shape rather than a linear loop. Better quality per round, higher cost per round. Develop a heuristic that sets reflection depth from problem characteristics, available time, and the business impact of the expected quality gain, rather than picking one depth for everything.

Pattern 19 — Dependency Injectionthe one people skip
Pass 1 · intuition

You cannot test the second half of a pipeline if running it requires paying for and waiting on the first half — and getting a slightly different first half each time.

Pass 2 · mechanism

Three properties make GenAI chains hard to test: they're non-deterministic, the foundation models change on someone else's schedule, and prompts are not portable across providers even when the API is.

The fix is old and boring: define the chain so any step can be replaced by an implementation with the same signature, and inject a mock when developing or testing the others. Pair it with assertions between steps — assert the critique has ≥4 improvements, assert the chosen change came from the critique's own list, assert the diff touched 1–5 lines. In Python, assert is disabled by -O, so you get assertions on in development and off in production without changing the code.

Pass 3 · trade-offs

Hardcoded mock values get harder to maintain as coupling between steps increases — if step 2's behaviour depends closely on step 1's specifics, choosing the right mock becomes its own problem. Mock external tools too, not just LLM calls, so your tests aren't hostage to network latency and third-party availability.

Pattern 20 — Prompt Optimizationindirection
Pass 1 · intuition

Every prompt you hand-tune is a hardcoded constant fitted to one model version. When the model changes, all your trials are invalidated at once. The fix is the oldest one in computing: another level of indirection.

Pass 2 · mechanism

Four components. A pipeline of steps declared by input/output signature rather than by prompt text; a dataset of examples (can be as small as one); an evaluator that scores a run automatically — comparing to a reference, or an LLM-as-Judge fitness score when you have no references; and an optimizer that generates prompt variations, runs them, and returns the pipeline with the best-performing prompts injected.

In DSPy the two entry-level optimizers are best-of-N (generate N prompt variants, keep the best on your one example — costs N inferences every time) and bootstrapped few-shot (pick k examples as demonstrations, evaluate on the rest, rotate, keep the best-performing selection — costs once, then runs a single inference). Your code ends up containing no prompts at all.

Pass 3 · trade-offs

A prompt library in a config file is not a substitute — it makes prompts easier to edit but you still re-experiment by hand on every dependency change. That said, prompt management and versioning remain good practice independently (and S2 requires them for attribution). The payoff compounds: once you record all prompts plus human feedback, you can use the same infrastructure to build a judge, and eventually a dataset for post-training a task-specific model.

The price each pattern charges for its quality gain
PatternExtra inferencesLatency impactWhere it paysWhere it doesn't
17 · Judge (offline)+1 per case, batchNone on usersAlways. This is the cheapest quality signal you will ever buy.
17 · Judge (online, sampled)+1 per sampled reqZero if asyncContinuous quality SLOs. Sample 1–10%.Synchronously on the request path — never do this
17 · Judge (jury / order-swap)+2 to +5None if offlineRelease gates and calibration runsPer-request online sampling; the cost multiplies by traffic
18 · Reflection (1 round)+2 (critique + regen)≈3× tailCode gen, compliance output, anything with an expensive failureInteractive chat, streaming UX, real-time
18 · Reflection (beam, 3 drafts)+7 or moreLargeOffline batch generation where quality dominatesAnything a user is waiting for
19 · Dependency Injection−N in devNegative — it's fasterEvery multi-step chain. Pure win.Single-call systems
20 · Prompt OptimizationFront-loadedNone at inference (bootstrapped few-shot); N× (best-of-N)Surviving model upgrades without a re-tuning projectOne-off prompts you'll never revisit
Decision tree · how should this judge be shaped?
Assumes you already decided a judge is the right evaluator (S1's tree). Follow “no ↓”.
Q1Will a machine act on this score automatically — a gate, a route, a block?
yes
Binary, several criteriaPose it as yes/no questions. Best consistency, and it ties the score directly to the correctness of the decision. Report TPR/TNR, not a mean.
no — a human reads it ↓
Q2Are you comparing two candidates — a new version against the approved one?
yes
Pairwise, order-swappedNever compare two absolute scores; leniency compresses them. Judge them head-to-head in one call, run both orders, and count only verdicts that survive the swap.
no — a single output, absolute ↓
Q3Is the dimension high-stakes, and do you have ≥30 expert-labelled examples of it?
no labels yet
Stop. Label first.Thirty labels is one afternoon. Shipping an uncalibrated judge on a high-stakes dimension means you will not find out it's broken until it matters.
yes, labelled ↓
Q4Does the judge's κ against humans clear your pre-declared bar, with acceptable TPR on the rare positive class?
no
Iterate or escalateRewrite the rubric against the worst examples (5–10 iterations). If human–human κ is itself below ~0.6, the criterion is ambiguous — redefine it rather than tuning against noise. Otherwise route this dimension to humans.
yes, it clears ↓
Q5Did the same model family generate the output being judged?
yes
Swap the judge familySelf-preference is well documented and the familiarity mechanism means it rewards fluency over accuracy. Use a different family, or accept a measured, stated bias.
no ↓
DefaultPointwise, 1–5, three to five anchored criteria, temperature 0, explanations on, judge model and rubric versioned, re-calibrated against a fixed human set whenever any of those change. Report per-dimension with a confidence interval, and never as a single average.
03

Reality check

25%

Worked example A · what does it cost to judge 1% of 10 000 requests/day?

Your workload, three judge options. Assume a judged case is ~3 500 input tokens (the source excerpt plus the response plus the rubric) and ~250 output tokens of scores and justification.

// volume 10 000 req/day × 1% = 100 judged/day = 3 000/month tokens/month: in 10.5M · out 0.75M // OPTION A — hosted frontier judge, ~$2.50/M in, ~$10/M out in 10.5M × $2.50/M = $26.25 out 0.75M × $10.00/M = $7.50 ≈ $34 / month // OPTION B — hosted small judge, ~$0.15/M in, ~$0.60/M out ≈ $2.03 / month // OPTION C — your own Gemma E4B on the same L4 prefill 10.5M tok @ ~3 000 tok/s = 3 500 s decode 0.75M tok @ ~900 tok/s = 833 s total GPU time = 4 333 s = 1.20 h/month at $0.45/GPU-hour = ≈ $0.54 / month (+ it keeps the container warm, which has its own cost — see below)

Now scale the sampling rate, because 1% is a starting point, not an answer:

sample judged/mo frontier small self-hosted GPU-h/mo 1% 3 000 $34 $2.03 1.2 5% 15 000 $170 $10.15 6.0 10% 30 000 $339 $20.30 12.0 // and with order-swapping for pairwise gates, double every figure
Verdict: the money is not the constraint at your scale — even 10% on a frontier judge is $339/month. The real constraints are (1) self-preference, which argues against using your own Gemma to judge your own Gemma's output on subjective dimensions, and (2) statistical power: at 1% sampling you get ~4 judged cases per hour, which as S3 showed is far too few to alert on. The right split: a cheap hosted small judge for continuous online sampling at 5% (≈$10/month, ~20 cases/hour, alertable on a 6-hour window), and a frontier judge reserved for offline release gates and calibration runs where cost per case is irrelevant. Self-hosted Gemma-as-judge is the right choice for deterministic-adjacent dimensions like citation binding, where self-preference doesn't apply because the answer is checkable.

Worked example B · the tail-latency price of one round of Reflection

Your compliance-flag path. Suppose the single-call profile is TTFT p95 = 1.4 s and total p95 = 6.2 s.

// single call today p95 total = 6.2 s // with 1 round of reflection: generate -> critique -> regenerate // naive assumption (WRONG): 3 × 6.2 = 18.6s // correct: percentiles of a SUM are not the sum of percentiles, // but they're also not independent — shared queue, shared GPU. median path ≈ 3.1 + 2.2 + 3.1 = 8.4 s // critique is shorter p95 path ≈ 6.2 + 4.0 + 6.2 = 16.4 s // correlated: same congested engine p99 path ≈ 30 s // three chances to hit a cold start // and the capacity cost, which is the one that surprises people 3 calls per request at fixed max_num_seqs=256 -> effective request throughput ceiling falls to ~1/3 -> at constant arrival rate, queue depth rises, which raises every latency above, including for requests NOT using reflection
Verdict: reflection is not a 3× latency cost, it's a 3× latency cost plus a 3× capacity cost that degrades your other traffic. Apply it per-slice: on the high-risk compliance path where a wrong flag is expensive and the caller is a batch pipeline rather than a person, one round is clearly worth it. On the sentiment path, which is high-volume and low-consequence, it is not. That per-slice decision is the whole discipline — and it's why the risk tier from S2 keeps reappearing.
Fresh example 1 · the bias literature has professionalised

the source material's considerations section names inconsistency, leniency, self-bias, length bias and positional bias — and that list is still the right one. What's changed is that each has been quantified and given mitigations. Self-preference now has a proposed mechanism (judges prefer lower-perplexity, more familiar text — which is why they favour well-written inaccuracy over badly-written accuracy) and a proposed mitigation (ensemble, and down-weight a model's vote where it shows unusually low perplexity on the sample). Position bias has systematic studies across pointwise and pairwise protocols. Broader surveys catalogue a dozen more — authority bias, bandwagon effects, distraction, compassion-fade from model names.

And a caution that generalises badly in the other direction: in the safety domain specifically, one study found position bias could favour either side and verbosity had minimal effect — so measure your own biases on your own task rather than importing coefficients.

Fresh example 2 · ensembling does not fix shared bias

The most consequential 2026 result for anyone building a judge pipeline. The standard defences — ensembling across judge models, reporting inter-judge agreement, reversing presentation order — address variance within the judge population but not systematic biases shared across it. Recent work identifies a concrete example: LLM judges' ratings of a model's behaviour converge with that model's own self-report in a way that human ratings of the same samples do not.

The design implication is the one thing to take from this whole session: your judge-agreement number is a measure of precision, not accuracy. A human-labelled calibration set is not an optional nicety you graduate out of — it is the only thing standing between you and a confidently wrong quality metric, and it has to be refreshed.

Fresh example 3 · calibration is now an operational cadence, not a one-off

The practitioner consensus that solidified through 2026 is that calibration decays and needs a schedule: monthly human spot-checks on stratified production samples, full calibration cycles quarterly or whenever a red-flag signal fires. Stratified sampling specifically — random sampling under-represents the tail behaviours that matter most: high-confidence wrong answers, low-frequency edge cases, and outputs from recently-deployed prompts. That's the same argument S1 made for slice-based suites, applied to the calibration set.

And the reason it decays is precisely S1's evaluator drift: your judge model gets updated, your rubric gets edited, your traffic mix shifts. Every one of those invalidates the last calibration. Put the recalibration trigger in the same place you version the judge.

Where the source material has aged
  • Named models are dated. The section's worked examples use specific 2025-era frontier models as generator and judge. The architectural point — use a different model for evaluation than for generation — is more durable than any model name. Treat judge choice as a versioned config value.
  • “Dependency Injection is not supported natively by any GenAI framework.” Still broadly true as a first-class feature, and still worth implementing yourself: it's twenty lines of function-parameter defaults, and the material's own example shows the whole pattern.
  • The Prompt Optimization framework list (DSPy, plus AdalFlow and PromptWizard as alternatives) has grown, and the material explicitly hoped it would. Verify the current optimizer names in DSPy's own docs before copying API calls — that layer moves quickly.
  • What has not aged: the bias catalogue, the leniency problem, the advice to prefer binary questions and coarse scales, and the warning that explanations may hurt evaluation performance. Those are the load-bearing parts of the section and they've all been reinforced by 2026 work rather than overturned.
04

Apply to my stack — lab

10%

4.1 · A judge for citation binding, on your own Gemma

Chosen deliberately: citation binding is checkable, so self-preference doesn't apply, and it's the dimension your compliance path depends on. enable_thinking is on because the task is verification, not generation, and the reasoning trace is separable via reasoning_content.

evals/judges/citation_binding.py
JUDGE_ID, JUDGE_VERSION = "citation_binding", "2.1.0"

RUBRIC = """You verify whether each compliance flag is supported by the cited span.
For EACH flag, answer three questions. Answer only yes or no.

Q1 SPAN_EXISTS  : do the character offsets fall inside the excerpt?
Q2 SPAN_SUPPORTS: does the cited text state or clearly imply the flagged risk?
                  no  = the span is topically related but does not establish it
                  yes = a reader of ONLY this span would agree the flag applies
Q3 NO_EXTRAPOLATION: is the flag free of figures/claims absent from the span?

Return JSON: {"flags":[{"id":..,"q1":bool,"q2":bool,"q3":bool,"why":"<=25 words"}]}
"""
# Binary questions (best consistency). Anchored (q2 states what no/yes mean).
# Multiple criteria (acts as CoT). Explanations capped so verbosity can't inflate.

async def judge(excerpt: str, response: dict, client) -> dict:
    r = await client.chat.completions.create(
        model="gemma-4-e4b",
        messages=[{"role": "system", "content": RUBRIC},
                  {"role": "user", "content": render(excerpt, response)}],
        temperature=0.0, top_p=1.0,
        extra_body={"chat_template_kwargs": {"enable_thinking": True}},
    )
    out = parse(r)
    # The judge is a measuring instrument. Version it like one.
    out["_meta"] = {"judge_id": JUDGE_ID, "judge_version": JUDGE_VERSION,
                    "rubric_hash": sha256(RUBRIC)[:12], "model": "gemma-4-e4b"}
    return out

4.2 · The position-swap harness for pairwise release gates

Two calls, opposite orders. Only verdicts that survive the swap count; the rest are ties, and the disagreement rate is your position-bias measurement.

evals/judges/pairwise.py
async def pairwise_swapped(case, cand_a, cand_b, judge) -> dict:
    """cand_a = new candidate, cand_b = current approved baseline."""
    fwd = await judge(case, first=cand_a, second=cand_b)   # A in slot 1
    rev = await judge(case, first=cand_b, second=cand_a)   # A in slot 2

    fwd_a_wins = (fwd["winner"] == "first")
    rev_a_wins = (rev["winner"] == "second")

    if fwd_a_wins == rev_a_wins:
        return {"verdict": "A" if fwd_a_wins else "B", "robust": True}
    # Disagreed under swap -> the judge preferred a POSITION, not an answer.
    return {"verdict": "tie", "robust": False, "position_flip": True}

def gate(results, min_robust=0.80):
    flip_rate = mean(r["position_flip"] for r in results)
    if 1 - flip_rate < min_robust:
        # More than 20% of verdicts are position artefacts: the JUDGE is broken,
        # and no conclusion about the candidate is available. Fix the rubric.
        raise JudgeUnreliable(f"position flip rate {flip_rate:.0%}")
    wins = sum(r["verdict"] == "A" for r in results if r["robust"])
    return wilson_interval(wins, sum(r["robust"] for r in results))

4.3 · The calibration script — run this before you trust any of the above

evals/calibration/align.py
from sklearn.metrics import cohen_kappa_score, confusion_matrix

def align(human_labels, judge_labels, positive="fail") -> dict:
    """Report the ENSEMBLE. Any single number here can be gamed by a
    judge that always predicts the majority class."""
    tn, fp, fn, tp = confusion_matrix(
        human_labels, judge_labels, labels=["pass", positive]).ravel
    return {
        "n": len(human_labels),
        "prevalence": (tp + fn) / len(human_labels),   # the base rate. print it FIRST.
        "raw_agreement": (tp + tn) / len(human_labels),
        "kappa": cohen_kappa_score(human_labels, judge_labels),
        "tpr": tp / (tp + fn) if (tp + fn) else None,   # caught real failures
        "tnr": tn / (tn + fp) if (tn + fp) else None,
        "precision": tp / (tp + fp) if (tp + fp) else None,
    }

# Stopping rule, declared BEFORE you start iterating on the rubric.
GATE = {"kappa": 0.70, "tpr": 0.85}     # TPR bar is high: missing a real
                                    # compliance failure is the expensive error

def ship_judge(m, human_human_kappa):
    if human_human_kappa < 0.60:
        return "ABORT: humans don't agree. The criterion is ambiguous, not the judge."
    if m["kappa"] >= GATE["kappa"] and m["tpr"] >= GATE["tpr"]:
        return "SHIP"
    return "ITERATE: rewrite rubric against the worst-scoring examples"

4.4 · Wiring scores back to Langfuse and to Prometheus

gateway/quality.py
# Attach the score to the GENERATION, not the trace — otherwise you can't tell
# which of three model calls in the chain was the unfaithful one.
langfuse.score(
    trace_id=trace_id, observation_id=generation_id,
    name="citation_binding", value=1.0 if passed else 0.0,
    data_type="BOOLEAN", comment=why,
)
# Same score, second destination: the S3 metric that backs the SLO.
QUALITY.labels(model="gemma-4-e4b", task=task, risk_tier=tier, cache=ch,
               eval_name="citation_binding").observe(1.0 if passed else 0.0)
# Third: the OTel event, so the score lives on the span for debugging.
emit_eval(span_ctx, "CitationBinding", 1.0 if passed else 0.0,
          "pass" if passed else "fail", why)
What this upgrades in your existing toy code Your Langfuse examples already emit quality scores, and your QualityMonitor already collects user feedback. Four upgrades: (1) attach scores to observations rather than traces, so multi-step attribution works; (2) add judge_id/judge_version/rubric_hash to every score's metadata — without them a score trend is uninterpretable across a rubric edit; (3) turn your user feedback into the seed of a calibration set rather than a standalone metric — thumbs are human labels, and 30 of them beat any amount of judge tuning; (4) add Dependency Injection to your router chain so you can test the analysis step against a mocked classifier output.
Optional exercise

Measure your own judge's leniency and position bias in one afternoon. Take 30 fintech analyses. For 15 of them, deliberately corrupt one compliance flag — change the cited offsets so the span no longer supports the claim. Now: (a) run the pointwise judge on all 30 and plot the score distribution — if the corrupted and clean sets overlap substantially, you've measured leniency and pointwise scoring is not usable for gating this dimension; (b) run pairwise clean-vs-corrupted in both orders and compute the position-flip rate; (c) compute κ, TPR and TNR against the ground truth you constructed. You now have three numbers that let you say exactly what your judge can and cannot be trusted to decide — which is the entire deliverable of this session.

Bridge → S5 You now have every component: a sized golden set, lifecycle gates, a telemetry pyramid, and a calibrated judge. S5 assembles them into one operating system for your Gemma service — the control tower, with a CI harness that blocks releases, online judging on a traffic slice, alert rules with defensible thresholds, and a phased plan to get there from what you have today.
Session 5 · Capstone — synthesis across all three sources, applied to your stack

The quality control tower

Four sessions gave you four separate capabilities. This one is about the wiring — turning a golden set, a telemetry pyramid, a calibrated judge and a set of gates into one operating system for your Gemma E4B service, where a quality regression is caught in CI, a live quality drop pages someone, and every threshold on the board has a written reason.

01

Why this session exists

10%

You can build every piece in this stage and still not have a system. The failure mode is specific and common: the pieces exist but nothing connects them, so no piece can act. The golden set lives in a notebook someone runs by hand. Grafana shows latency. Langfuse shows traces. A judge script exists but its output goes to a CSV. Nothing blocks a deploy. Nothing pages anyone. Every artefact is real and the system is still ungoverned.

The control tower is the opposite arrangement: a single loop where evidence produced anywhere reaches a threshold that has an owner and an action attached. the source material calls this the operating model — define the system, collect evidence, compare evidence to thresholds, connect the result to an action. Everything below is that sentence, made concrete for your stack.

Four artefacts vs one loopclick either side
what most teams have golden set (notebook) Grafana (latency) Langfuse (traces) judge script → CSV no edges between the boxes nothing blocks · nothing pages · nothing learns the control tower CI gate canary online eval incident every edge is an action
Click either arrangement.
02

Designing the tower

50%
Concept 1 · The system tuple — what exactly are you evaluating? must be written down first
Pass 1 · intuition

A clinical trial does not report “the drug worked”. It reports what dose, of what compound, in whom, against what comparator. Change any of those and the result no longer transfers. The system tuple is your dose sheet. It is the exhaustive list of things that, if changed, could change your results — so that when a number moves, the set of candidate explanations is finite and written down.

Without it, every regression investigation starts with an argument about what changed.

Pass 2 · mechanism

Your tuple is not abstract — it's the concrete inventory of your Gemma stack. Every row below gets a version string, and every evaluation run and every production trace carries all of them.

ComponentYour value todayVersion keyWho can change it
Modelgoogle/gemma-4-E4B-it (or the FP8 checkpoint under the fp8-ckpt profile)model_id + HF revision hashyou, via MODEL_REVISION
Serving configPROFILE — quant / prefix / best / tunedprofile + resolved vLLM argv hashyou, via modal deploy
Enginevllm==0.21.0, L4, max_model_len=10000, gpu_mem_util=0.92engine_verimage rebuild
Decodingtemperature / top_p, enable_thinking per requestdecoding_policy_vergateway config
Promptsystem prompt per intentprompt_idprompt_ver + SHA-256anyone with repo access ← highest-frequency lever
Routercomplexity classifier + 3-model routing tablerouter_vergateway config
RetrievalSEC / earnings indexindex_snapshot + corpus dateingestion job ← changes without a deploy
Contractfintech output JSON schemaschema_verrepo
Evaluatorsvalidators + judge model + rubricevaluator_ver, judge_id, rubric_verrepo ← silently rescales your scores
Suitegolden fintech setsuite_ver + snapshot hashrepo, with an owner per case

Two rows deserve the italics. The retrieval index changes on a cron, not on a deploy — so a quality drop can have no corresponding commit. And evaluator versions change the measuring instrument, not the thing measured: if the judge rubric changes, your whole history recalibrates and the trend line lies.

Pass 3 · trade-offs & nuance

Versioning is cheap; the discipline is not. The tuple only pays off if it's emitted automatically. A tuple you have to remember to fill in is a tuple that is wrong within two weeks. Compute it once at gateway startup, attach it to every span as resource attributes and to every eval run as a frozen JSON blob.

Correlation is not attribution. You still need a targeted rerun — pin the old index snapshot, rerun the affected slice, compare. The tuple's job is to make that rerun possible, not to prove anything by itself.

Where it breaks: serverless. On Modal with min_containers=0 and max_containers=2, two replicas can be running different container images during a rolling deploy. If the tuple is captured gateway-side only, you will attribute a mixed-version window to a single version. Have the gateway read the engine's reported version from the response and record it per request, not per deploy.

Concept 2 · The golden fintech set — structure, sizing, ownership the asset everything else leans on
Pass 1 · intuition

Think of it less as a test set and more as an institutional memory of what has gone wrong. Every case earns its place by representing either a behaviour you must guarantee or a failure you have already survived. A set assembled that way grows in a specific direction — toward the things that actually break — instead of growing toward whatever was easy to write.

Pass 2 · mechanism

Your pipeline produces four output types plus a routing decision. Each needs a different evaluator, so each becomes its own slice with its own sizing and its own gate.

SliceOutput shapeGround truth?Primary evaluatorRisk tierTarget n
sentimentenum: bullish / neutral / bearishyes — human labelreference metric (macro-F1)low150
risk_scoreint 1–10 + justificationpartial — banded labelbanded exact-match + judge on the justificationmedium200
trading_signalenum + confidence + rationaleno single truthassertions on shape; pairwise judge on rationalehigh200
compliance_flagbool + cited spanyes — expert labelassertion: cited span must exist verbatim in sourcehigh / zero-tolerance250
router_classenum: simple / moderate / complexyes — labelreference metric + cost-weighted confusionmedium150
adversarialmixedexpected behaviour onlyassertions + refusal taxonomyhigh80

The compliance-flag slice is the one to design first, because it is the only one where the correct evaluator is free. A compliance flag must cite a span from the source document. Whether that span appears verbatim in the source is a string operation, not a judgement. That converts your highest-risk output into a deterministic check — the single biggest win available in this whole eval set.

the check that makes the highest-risk slice free
def citation_is_bound(flag, source_text):
    # the model claims a compliance issue and cites a span
    if not flag["raised"]: return True
    span = norm(flag.get("cited_span", ""))
    return len(span) >= 24 and span in norm(source_text)

Three more structural rules, straight from the source material: keep a development set you iterate against, a release-gating set that stays stable enough for fair version-to-version comparison, and a hold-out set you look at rarely — because a set you tune against stops measuring generalisation and starts measuring how hard you tuned.

Pass 3 · trade-offs & nuance

Sizing is per-slice, not global. The n column above is not decoration — a 1,030-case suite that is 80% sentiment tells you almost nothing about compliance flags, because the slice that matters has 40 cases and a confidence interval ±15 points wide. Size each slice for the delta you need to detect on that slice. The maths is in the reality check below.

Synthetic data expands, it does not replace. You can generate 500 paraphrases of a risk-score prompt in an afternoon, and it will make the suite look robust while measuring nothing new. the source material's warning is sharper than the usual one: if you use the same model family to generate cases and to judge them, the generator produces cases the judge finds natural, and the system self-congratulates. Keep a real-data core, label synthetic cases as synthetic, and report the two subsets separately.

Every case needs an owner and a reason. An unowned suite goes stale in a quarter — policies change, the taxonomy drifts, and nobody knows whether a failing case is a real regression or an out-of-date expectation. One line of provenance per case (owner, added_because) is what keeps the suite alive.

Concept 3 · The gate ladder — from commit to full ramp where evidence becomes an action
Pass 1 · intuition

Airport security has layers with deliberately different sensitivities: a metal detector that everyone passes through, a swab test for a sample, a full search for the flagged. Nobody argues that the metal detector should catch everything — it's tuned to be fast and cheap, and the later layers exist precisely because it isn't sufficient.

Your gates work the same way. Each layer is cheaper and blunter than the one after it, and each has a different job. The mistake is trying to make one layer do everything — usually by putting the expensive judge in CI on every commit, which makes CI slow enough that people start skipping it.

Pass 2 · mechanism

Five layers, each with its own trigger, cost and action.

L1 · Pre-commit — deterministic only, seconds

Runs on
every commit, locally and in CI
Suite
25-case smoke slice, one per intent + the five worst historical failures
Evaluators
schema validation, enum membership, range checks, citation binding — zero LLM calls
Cost
~$0.004 in Gemma tokens, under 30 s wall clock
Action on fail
block the commit

This layer exists to keep the expensive layers from being wasted on structurally broken output. the source material's phrasing is the right instinct: never spend a judge call on a response that already violates its contract.

L2 · PR gate — reference metrics + a cheap judge sample

Runs on
every pull request that touches prompts, router, schema or serving config
Suite
development set, ~350 cases, all slices, 1 sample per case
Evaluators
everything from L1, plus macro-F1 on the labelled slices, plus judge on a 20% subsample of the open-ended slices
Cost
~$0.09 per run; roughly 6 minutes
Action on fail
red check + a slice-diff comment on the PR. Overridable with a written reason.

The output that matters here is not the aggregate — it's the per-slice diff against the base branch. “Overall 91.2% → 90.8%” is noise. “compliance_flag 98% → 91%, everything else flat” is a finding.

L3 · Release gate — the full suite, statistically sized, hard blockers armed

Runs on
release candidates only, on a frozen tuple
Suite
full release-gating set (1,030 cases) + hold-out; 3 samples per case on high-risk slices
Evaluators
full stack including calibrated judge with position swapping
Cost
~$1.80 per run; ~35 minutes
Action on fail
hard blockers stop the release outright; soft gates require a named person to accept the trade-off in writing

This is the only layer that reports confidence intervals, because it's the only layer where the decision is expensive enough to justify the sample count.

L4 · Canary — real traffic, small blast radius, short window

Runs on
5% of live traffic for 60 minutes, then 25%, then full
Signals
contract-failure rate, unsupported-claim rate, refusal rate, p95 TTFT, tool failure rate, cost per request, judge score on an elevated 10% sample
Comparison
canary cohort vs the stable cohort over the same wall-clock window — not vs yesterday
Action on fail
automatic rollback for hard signals, page-and-hold for soft ones

Same-window comparison is the detail people get wrong. Traffic mix varies by hour; comparing a 10 a.m. canary against a 24-hour stable baseline manufactures differences that have nothing to do with the release.

L5 · Online — permanent, and the only layer that learns

Runs on
100% deterministic checks, 2% stratified judge sample, ~40 human reviews/week
Signals
everything from L4, plus drift on the intent distribution and slow-burn trend monitoring over 7 and 28-day windows
Action
alerts with runbooks; and critically — every incident becomes a new golden case

L5 is where the loop closes. The other four layers only test what you already thought of. This is the layer that discovers new failure modes and feeds them backwards into L1–L3. A control tower without this edge is an open loop with extra steps.

Pass 3 · trade-offs & nuance

Gate sensitivity has a failure mode in both directions, and they're asymmetric. Too loose and you ship regressions — bad, but visible and correctable. Too tight and you get rollback fatigue: the gate fires on noise, people learn to override it, and within a month the gate is decorative while still appearing green in your compliance story. That second failure is worse because it's invisible. Review every gate after each rollout and ask whether it produced signal or noise; retire gates that only ever produce noise.

Hard blockers must be slice-scoped or they don't work. A global unsupported-claim rate of 0.05% looks excellent and can still hide a 4% rate inside the compliance slice, because the compliance slice is 6% of traffic. the source material's rule holds: zero-tolerance gates are evaluated within the high-risk slice, never on the aggregate.

Judge budget is a gate design parameter. Putting the judge at L2 at 100% coverage would cost roughly 5× more per PR and add 20 minutes. The subsample at L2 and the full run at L3 is a deliberate purchase of speed at the cost of sensitivity — L2 will miss small quality deltas, and that is fine, because L3 exists.

Concept 4 · Online judge sampling — stratification, async, and the feedback edge the convergence point of the whole stage
Pass 1 · intuition

A factory does not weigh every biscuit. It weighs a sample — but not a random one. It deliberately over-samples the line that has been drifting, and the shift right after a machine change. Stratified sampling buys you statistical power exactly where you need it, which is never uniformly distributed.

Uniform random sampling of your traffic would spend most of its judge budget on the sentiment slice, because that's most of your volume — and sentiment is the slice you already measure for free with a reference metric. That's the whole argument for stratification in one sentence.

Pass 2 · mechanism

Three properties make online judging safe: it is asynchronous (never on the user's critical path), stratified (sample rate varies by slice), and budgeted (a hard daily ceiling on judge spend, so a traffic spike can't produce a cost spike).

Online evaluation path — the async forkclick a stage
gateway request → Gemma → reply validators · 100% inline, ~0.4 ms user gets reply fork: copy trace to queue stratified sampler compliance 15% · signal 8% · risk 4% · sentiment 0.5% judge worker off critical path · budgeted score → telemetry span event + metric human review queue · ~40/wk disagreements + all zero-tolerance hits → golden set recalibrate + new cases nothing below the dashed fork can add latency to the reply above it
Click a stage of the online evaluation path.

The sample rates in the sampler box are not arbitrary — they're derived from how many judged samples each slice needs per day to make its weekly trend readable. That derivation is in the reality check.

Pass 3 · trade-offs & nuance

Sampling bias is the quiet killer. If you sample only requests that completed, you systematically exclude timeouts, truncations and fallbacks — which is to say you exclude your worst outputs, and your quality metric improves precisely when your system degrades. Sample at the point the request is accepted, not the point it succeeds, and record an outcome label so failures stay in the denominator.

Judge scores need their own health metrics. A judge is a service: it has an error rate, a latency, and a refusal rate of its own. Track judge_call_failures_total separately, and never let a judge failure silently count as a passing score. Default a failed judge call to “unscored”, not to “pass” — the difference decides whether an outage looks like perfect quality or like missing data.

Retention and privacy. Traces of earnings and SEC analysis carry customer-identifying context. Store what you need to replay the evaluation — document IDs, retrieval ranks, tool outcomes, verification flags — and redact free text you don't need. the source material's phrasing is a good default: capture the observable event sequence, not the model's private reasoning.

Decision tree · a gate just went red — what do you actually do?
Follow “no ↓” until a “yes” exits right. The bottom-left box is the default.
Q1Did a zero-tolerance check fail — unsupported compliance claim, cited span not present in source, or PII in the output?
yes
Block / roll back nowFrequency is irrelevant. One occurrence blocks. Open an incident, capture the trace, add the case to the golden set before the fix ships.
no ↓
Q2Is the change larger than run-to-run variance on this slice — i.e. do the candidate and baseline confidence intervals fail to overlap?
no
Do nothingIt's noise. Record the run, don't touch the prompt. If this happens repeatedly the slice is under-sized — fix the sizing, not the model.
yes ↓ (it's real)
Q3Is the regression concentrated in a high-risk slice, even if the aggregate is flat or improved?
yes
Hard blockAn aggregate that improves while a critical slice degrades is the exact failure this whole ladder exists to catch. Do not average it away.
no ↓
Q4Did the evaluator change in this run — judge model, rubric version, validator logic, or suite snapshot?
yes
Not a regression yet — rescoreYou changed the instrument. Run old and new evaluators over the calibration set, establish the offset, then re-ask the question.
no ↓
Q5Does the drop coincide with an infrastructure signal — tool failure rate, retrieval coverage, p95 latency, timeout rate?
yes
Fix the pipeline, not the promptThis is a reliability incident wearing a quality costume. Editing the prompt here papers over an index or tool problem and adds debt.
no ↓
DefaultReal, above threshold, in a non-critical slice, same evaluator, no infra correlate. Soft gate: a named owner accepts or rejects the trade-off in writing, and the reason goes in the release changelog. This is the case where evaluation stops being automatic and becomes a product decision — which is correct, and should not be automated away.
Decision tree · offline eval, online eval, or an A/B test?
You have a change and you want to know if it is better. Three instruments, very different costs. Follow “no ↓” until a “yes” exits right.
Q1Could this change plausibly cause a zero-tolerance failure — a fabricated citation, a policy breach, leaked data?
yes
Offline first, alwaysNever let real users be the detector for a catastrophic failure mode. Run the adversarial and high-risk slices before a single request is routed to it. Online comes after, not instead.
no ↓
Q2Is the effect you are looking for large and mechanical — schema compliance, refusal correctness, routing accuracy, format stability?
yes
Offline is sufficient and 100× cheaperDeterministic effects show up clearly on a few hundred frozen cases. Spending live traffic to learn something a fixture can tell you is pure waste, and slower besides.
no ↓
Q3Does the change depend on things your suite structurally cannot contain — real traffic mix, live retrieval freshness, tool latency under load, genuine user phrasing?
yes
Online evaluation on a canaryYour suite is a snapshot of the past. When the question is about the present, sample the present. Compare canary against stable over the same window, never against yesterday.
no ↓
Q4Is the metric you actually care about a user behaviour — acceptance rate, escalation rate, follow-up questions, task abandonment?
yes
A/B test, and budget weeks for itOnly a randomised split can attribute a behavioural outcome to a change. But check the arithmetic before committing: detecting a 2-point shift in a behavioural rate typically needs thousands of users per arm, which at 10k requests/day is a multi-week experiment.
no ↓
DefaultNot catastrophic, not mechanical, not traffic-dependent, not behavioural. Offline on the release-gating suite, then ship behind a canary and watch. This is the overwhelmingly common case, and the reason the gate ladder is shaped the way it is: offline answers most questions cheaply, canary catches what the suite could not represent, and A/B is reserved for the rare question where nothing else can substitute for randomisation.
03

Reality check

25%

A · Sizing the compliance slice, and what it costs to run it

The compliance slice is zero-tolerance, so the question isn't “detect a 5-point delta” — it's “can I demonstrate the unsupported-claim rate is below 0.5%?” That's a precision question, and it needs a one-sided upper bound.

# Goal: show unsupported-claim rate p < 0.005 with 95% confidence. # If we observe ZERO failures in n cases, the "rule of three" gives the # approximate one-sided 95% upper bound: upper_bound ≈ 3 / n n = 250 → 3/250 = 0.0120 → 1.20% ✗ above 0.5% n = 600 → 3/600 = 0.0050 → 0.50% ~ exactly at the line n = 1000 → 3/1000 = 0.0030 → 0.30% ✓ clears it # So a clean run on 250 offline cases CANNOT establish a 0.5% claim. # It can only say "below 1.2%".

This is the number that reframes the whole design. You are not going to hand-label 1,000 compliance cases. So the offline suite stops being the place that certifies the rate, and its job changes: offline proves the mechanism works; production accumulates the sample size.

# Production accumulates the n you can't hand-build. # 10,000 req/day × 6% compliance-flag intent = 600 compliance requests/day # The citation-binding check is DETERMINISTIC → runs on 100% of them, free. n per day = 600 n per week = 4,200 upper bound (0 failures, 1 week) = 3/4200 = 0.00071 → 0.07% # One week of free deterministic checking gives a bound 17x tighter than # the entire hand-labelled offline suite.
Verdict: the highest-value engineering decision in this capstone is not the judge — it's converting the highest-risk output into a deterministic check. Deterministic checks run at 100% coverage for free, and coverage is what buys statistical confidence. The 250 offline compliance cases still earn their place: they test the mechanism against known-hard cases before release. They just aren't the thing that certifies the rate.

B · The full monthly bill for the control tower

Baseline: 10,000 requests/day, average ~100 output tokens, your L4 at roughly $0.45/hour. Judging uses Gemma E4B as its own judge on a second Modal deployment — which is the interesting choice, so let's price it honestly against an API judge.

ComponentVolumeUnit$/monthNotes
Serving (existing)10k req/dayL4 @ $0.45/h~$45your measured figure with all optimisations on
Deterministic validators300k/monthCPU only$0runs in the gateway process
Online judge — self-hosted Gemma2% of 300k = 6,000 judgments~700 tok in / 120 out each~$11scale-to-zero judge replica, batched, ~4.5 GPU-hours/month
Online judge — frontier API alternativesame 6,000~$0.004/judgment~$24higher agreement, no infra, data leaves your boundary
CI: L2 PR gate~80 PRs/month$0.09/run~$7the dominant CI cost is wall-clock, not tokens
CI: L3 release gate~8 releases/month$1.80/run~$143 samples/case on high-risk slices
Prometheus + Grafana~9k active seriesself-hosted or GMP~$0–18free self-hosted; GMP charges per sample ingested
Langfuse (self-hosted)300k traces/monthClickHouse-backed~$12small VM + object storage
Human review~160 reviews/month~4 min each ≈ 11 htime, not $the real constraint, and the reason stratification matters
Total marginal cost of the control tower~$44–62/moi.e. roughly the cost of the serving itself
Verdict: quality infrastructure roughly doubles your bill and consumes about 11 hours of expert attention a month. That is the honest price, and it is worth stating out loud when you propose this — because the alternative framing (“evals are cheap”) sets an expectation that collapses the first time someone reads the invoice. The one line that would genuinely blow the budget is judging 100% of traffic instead of 2%: that's 300,000 judgments, roughly $550–1,200/month, for a trend line that 6,000 samples already resolves.

C · Deriving the sample rates in the sampler box

Where did “compliance 15%, signal 8%, risk 4%, sentiment 0.5%” come from? Each slice needs enough judged samples per week that its weekly mean has a usable interval.

# For a 1-5 judge score, observed SD across cases is typically ~0.9. # Standard error of the weekly mean: SE = SD / sqrt(n) # Target: SE ≤ 0.10 → a weekly mean readable to ±0.2 (95% CI) n_needed = (0.9 / 0.10)² = 81 judged samples / week / slice # Now work backwards from traffic. 10k/day × 7 = 70,000 req/week. trading_signal : 9% of traffic = 6,300/wk → 81/6300 = 1.3% → set 8% compliance : 6% of traffic = 4,200/wk → 81/4200 = 1.9% → set 15% risk_score : 22% of traffic = 15,400/wk → 81/15400 = 0.5% → set 4% sentiment : 48% of traffic = 33,600/wk → 81/33600 = 0.24% → set 0.5%

The set rates are all above the bare minimum, deliberately. Two reasons: you want the interval to stay usable when you slice within a slice (by locale, by document type), and you want enough samples that a mid-week problem is visible before Friday.

Verdict: compliance gets 30× the sample rate of sentiment despite being 8× smaller in volume — the two effects compound into a 15%-vs-0.5% spread. Notice the shape of the answer: sample rate should be roughly inversely proportional to slice volume, then multiplied up by risk. Uniform sampling gets both factors wrong at once.

Three current sources this design leans on

The wiring in this session is not bespoke any more. OpenTelemetry's GenAI conventions define a gen_ai.evaluation.result event carrying gen_ai.evaluation.name, gen_ai.evaluation.score.value, an optional low-cardinality gen_ai.evaluation.score.label (pass/fail, correct/incorrect) and a free-form gen_ai.evaluation.explanation. The spec is explicit that the event should be parented to the GenAI span being evaluated, or carry gen_ai.response.id when the span ID isn't available — which is exactly the async case in your sampler, where the judge runs minutes after the span closed.

Two caveats to design around. The GenAI conventions moved to their own repository in the v1.42.0 release (June 2026) and remain in Development status — no signal, attribute or metric under gen_ai.* is marked Stable, and names can still change. Pin the convention version you emit and record it in your tuple. Second, the conventions standardise the transport of an evaluation result, not the evaluation itself; the rubric, the calibration and the threshold are still entirely yours.

The L2/L3 gates in this design used to be custom scaffolding. As of May 2026 Langfuse ships a GitHub Actions integration (langfuse/experiment-action) that fails a workflow when experiment scores fall below a threshold — turning evaluation into a deploy gate rather than a post-release review, which is precisely the L3 semantics above. The same release wave added Code Evaluators: Python or TypeScript evaluate functions written in the Langfuse UI that run deterministic checks — JSON parseability, schema validation, required tool arguments — with no judge call and no token cost, landing as native scores alongside judge scores.

That maps almost one-to-one onto the L1/L2 split in the gate ladder: code evaluators for the contract layer, LLM-as-judge for the semantic layer, both producing the same score object. Earlier additions worth knowing: Score Analytics for measuring evaluator alignment across precision, recall and F1, and baseline comparison for flagging a specific run as the reference point.

Since your direction is the NVIDIA stack on AWS and GCP, it's worth knowing the vendor-shaped version of what you're building by hand. NeMo Evaluator runs custom and industry benchmarks behind a small API surface; NeMo Guardrails is the runtime rails layer (input, dialog, retrieval, execution, output) available both as an open-source Python library and as a production microservice, with configurations portable between the two. As of the 25.10 microservices release, Guardrails ships end-to-end distributed tracing with context propagating across guardrails, inference and downstream services, plus Kubernetes ConfigMap-based configuration — meaning the guardrail decisions land in the same trace tree as your gateway spans rather than in a separate log.

On the serving side, Dynamo exposes runtime metrics under a dynamo_* prefix at the same /metrics endpoint as the backend engine's own metrics, and its Kubernetes operator creates PodMonitor resources automatically so kube-prometheus-stack discovers them without manual scrape config. GPU metrics still come from dcgm-exporter underneath.

The trade is the usual one, and it's worth being clear-eyed: the managed path gets you tracing, rails and evaluation in days instead of weeks, at the cost of an NVIDIA AI Enterprise subscription and a stack that is harder to reason about when it misbehaves. Building it yourself first — which is what this capstone does — is the thing that makes you able to evaluate that trade rather than accept it.

04

Apply to my stack — the build

10%

Everything below assumes the components from S1–S4 exist. This section is the assembly: the repo layout, the three files that hold the whole thing together, and a phased plan that never leaves you with a half-wired system.

4.1 · Repo layout

the shape the control tower takes on disk
evals/
  golden/                    # the asset. one YAML per case, owned, versioned
    fin_sentiment_*.yaml     #   150 cases
    fin_risk_*.yaml          #   200
    fin_signal_*.yaml        #   200
    fin_compliance_*.yaml    #   250  ← zero-tolerance slice
    fin_router_*.yaml        #   150
    adversarial_*.yaml       #    80
    _suite.lock              # snapshot hash — frozen per release
  validators/                # deterministic. L1 + 100% of production traffic
    schema.py  ranges.py  citation_binding.py  refusal.py
  judges/                    # from S4: rubric + position swap + calibration
    faithfulness_v3.yaml  pairwise.py  align.py
  harness/
    run.py                   # the single entry point for L1/L2/L3
    tuple.py                 # freeze + emit the system tuple
    stats.py                 # bootstrap CIs, Wilson bounds, MDE
    gates.yaml               # ← the file below. thresholds live in ONE place
gateway/
  quality.py                 # inline validators + the async sampler fork
  tracing.py  metrics.py  policy.py  prompts.py
monitoring/
  prometheus/alert_rules.yml
  grafana/control_tower.json
.github/workflows/
  eval-pr.yml                # L2
  eval-release.yml           # L3

4.2 gates.yaml — every threshold in one auditable file

This is the single most important file in the capstone. Not because it's clever, but because thresholds scattered across code and dashboards are thresholds nobody can review. Every entry carries a metric, a scope, a threshold, an owner and an action — the source material's four-part gate, made into config.

evals/harness/gates.yaml
meta:
  suite_ver: "2026.07.3"
  reviewed:  "2026-07-20"      # stale review = stale gate. re-review quarterly.

hard_blockers:                  # release does not ship. no override path.
  - id: unsupported_compliance_claim
    metric: citation_binding_failure_rate
    scope:  "slice=compliance_flag"   # ← slice-scoped, never aggregate
    threshold: "== 0"
    owner: "you"
    action: block_release
    why: "a cited span that is not in the source is a fabricated citation"

  - id: schema_validity
    metric: parse_valid_rate
    scope: "all"
    threshold: ">= 0.995"
    owner: "you"
    action: block_release
    why: "downstream automation consumes these fields directly"

  - id: unsafe_advice
    metric: refusal_correctness
    scope: "slice=adversarial, risk=high"
    threshold: ">= 0.98"
    owner: "you"
    action: block_release

soft_gates:                     # require a written, named acceptance
  - id: risk_score_band_accuracy
    metric: banded_exact_match
    scope: "slice=risk_score"
    threshold: ">= baseline - 0.02"   # relative to last approved release
    ci: required                        # must not fire on overlapping intervals
    owner: "you"
    action: review_and_accept

  - id: faithfulness
    metric: judge_faithfulness_mean
    scope: "slice=trading_signal"
    threshold: ">= 4.10"
    judge: "gemma-4-e4b @ rubric faithfulness_v3"   # evaluator is part of the gate
    ci: required
    owner: "you"
    action: review_and_accept

  - id: cost_per_request
    metric: usd_per_request_p50
    scope: "all"
    threshold: "<= baseline * 1.15"
    owner: "you"
    action: review_and_accept

canary_gates:                   # live traffic, same-window comparison
  - id: canary_contract_failures
    metric: contract_failure_rate
    compare: "canary vs stable, same window"
    threshold: "<= stable * 1.5"
    window: 15m
    action: auto_rollback

  - id: canary_ttft
    metric: ttft_p95_seconds
    compare: "canary vs stable, same window"
    threshold: "<= stable + 0.4"
    window: 15m
    action: pause_and_page
Why ci: required is on the soft gates and not the hard blockers A hard blocker on a zero-tolerance event fires on a single occurrence — there is no distribution to put an interval around, and demanding statistical significance before blocking a fabricated citation would be absurd. Soft gates compare two noisy means, so they must not fire on overlapping confidence intervals or you have built a rollback-fatigue machine. Same file, opposite statistical treatment, for a defensible reason.

4.3 · The async sampler fork in your gateway

This is the one piece of production code with a hard latency constraint: it must add no measurable time to the user's response. The pattern is fire-and-forget onto a bounded queue, with the bounded part doing the important work — if the judge backs up, you drop samples rather than grow memory or block requests.

gateway/quality.py — the fork below the dashed line in the diagram
import asyncio, random, time

# sample rates derived in Reality Check C — inversely ∝ volume, × risk
SAMPLE_RATE = {
    "compliance_flag": 0.15,
    "trading_signal":  0.08,
    "risk_score":      0.04,
    "sentiment":       0.005,
}

class QualityFork:
    def __init__(self, judge, metrics, maxsize=2000, daily_budget_usd=0.60):
        self.q = asyncio.Queue(maxsize=maxsize)   # bounded: backpressure, not OOM
        self.judge, self.metrics = judge, metrics
        self.budget = daily_budget_usd
        self.spent  = 0.0

    def offer(self, record):
        # called INLINE on the response path. must never await, never raise.
        if random.random >= SAMPLE_RATE.get(record.slice, 0.0):
            return
        try:
            self.q.put_nowait(record)
        except asyncio.QueueFull:
            # dropping is CORRECT here. record it so the gap is visible.
            self.metrics.judge_dropped.inc

    async def worker(self):
        while True:
            rec = await self.q.get
            if self.spent >= self.budget:
                self.metrics.judge_budget_exhausted.inc; self.q.task_done; continue
            try:
                score = await self.judge.score(rec)          # position-swapped, S4
                self.spent += score.cost_usd
                # emit BOTH: a span event for the trace, a metric for the SLO
                emit_eval_event(rec.trace_id, rec.response_id, score)
                self.metrics.judge_score.labels(
                    slice=rec.slice, metric=score.name).observe(score.value)
            except Exception as e:
                # NEVER default a failed judge call to "pass"
                self.metrics.judge_failures.labels(err=type(e).__name__).inc
            finally:
                self.q.task_done
gateway/quality.py — emitting the score in the OTel shape
from opentelemetry import trace
_log = get_logger_provider.get_logger("gemma.eval")

def emit_eval_event(trace_id, response_id, score):
    # The judge runs minutes after the span closed, so we cannot parent to a
    # live span. The spec covers exactly this: set gen_ai.response.id instead.
    _log.emit(LogRecord(
        event_name="gen_ai.evaluation.result",
        attributes={
            "gen_ai.evaluation.name":        score.name,          # required
            "gen_ai.evaluation.score.value": score.value,
            "gen_ai.evaluation.score.label": score.label,         # low cardinality!
            "gen_ai.evaluation.explanation": score.explanation,
            "gen_ai.response.id":            response_id,
            # not in the spec — your own tuple, so scores stay comparable
            "eval.judge_id":   score.judge_id,
            "eval.rubric_ver": score.rubric_ver,
            "eval.slice":      score.slice,
        }))
Aged — the source material, and one live constraint on your Modal deployment

the source material's quality-metrics section suggests OpenAI Evals, LangSmith and Arize as the framework options and says an experimental OTel spec defines a core set of conventions. Both statements have moved. The conventions now live in their own repository with their own release cadence and include the evaluation-result event above — which did not exist when the section was written, and which is what makes “export judge scores as Prometheus metrics” a portable pattern rather than a bespoke one. On the tooling side, the 2026 open-source shortlist for what this capstone needs is narrower and more specialised: lm-evaluation-harness for base-model benchmarking, Ragas for retrieval-specific metrics, DeepEval for pytest-style CI gates, promptfoo for red-teaming and multi-model comparison, and Langfuse or Phoenix for the production trace-and-score layer.

And a constraint the materials can't know about: your Gemma runs on Modal with min_containers=0 and max_containers=2. vLLM's /metrics is served on the same port Modal exposes, so it is reachable — but scraping it means (a) counters reset to zero on every cold start, (b) two replicas behind one URL produce interleaved, non-additive series, and (c) the scrape itself keeps a GPU warm and bills you. The resolution: gateway-side metrics are the source of truth for every SLO and every gate in gates.yaml; vLLM /metrics is pulled opportunistically for engine diagnostics only, labelled with a replica ID, and nothing in the alerting path depends on it.

4.4 · Alert rules you can defend

monitoring/prometheus/alert_rules.yml — the quality alerts
groups:
- name: gemma.quality
  rules:
  # ── zero-tolerance: any occurrence, fast window, page immediately ──
  - alert: UnsupportedComplianceClaim
    expr: increase(citation_binding_failures_total[5m]) > 0
    for: 0m
    labels: {severity: critical}
    annotations:
      summary: "Compliance flag cited a span not present in the source"
      runbook_url: "https://.../runbooks/unsupported-claim"
      # the runbook says: capture trace, disable compliance_flag intent,
      # add the case to evals/golden/, THEN debug.

  # ── quality SLO: slow-burn, two windows, so one bad hour doesn't page ──
  - alert: FaithfulnessSLOBurn
    expr: |
      (avg_over_time(judge_score{metric="faithfulness",slice="trading_signal"}[1h]) < 4.1)
      and
      (avg_over_time(judge_score{metric="faithfulness",slice="trading_signal"}[6h]) < 4.1)
    for: 30m
    labels: {severity: warning}
    annotations:
      summary: "Faithfulness below objective on trading_signal for 30m+"
      runbook_url: "https://.../runbooks/faithfulness-drop"

  # ── the measuring instrument itself can fail. treat it as a service. ──
  - alert: JudgeCoverageLost
    expr: rate(judge_scores_total[30m]) < 0.5 * rate(judge_scores_total[7d] offset 7d)
    for: 20m
    labels: {severity: warning}
    annotations:
      summary: "Judged sample rate halved — quality metrics are now unreliable"
      # WITHOUT this, a judge outage looks like flat, healthy quality.

  # ── drift: the offline suite may no longer represent production ──
  - alert: IntentDistributionDrift
    expr: intent_distribution_l1_distance > 0.20
    for: 6h
    labels: {severity: info}
    annotations:
      summary: "Traffic mix shifted >20% vs baseline — review golden set coverage"
The third rule is the one people leave out JudgeCoverageLost exists because of an asymmetry that catches teams badly: when your judge pipeline breaks, the quality dashboard doesn't go red — it goes flat, holding its last good value, and flat reads as healthy. Every quality signal in a control tower needs a paired liveness signal, or your most reassuring dashboard is the one that fails silent.

4.5 · What this upgrades in your existing toy code

Your component todayWhat it got rightThe upgrade
ObservabilityManagerTraceContext You already think in terms of a request-scoped context object — that's the correct shape. Replace the home-grown context with an OTel tracer and let the SDK do propagation. Your TraceContext becomes a thin helper that attaches the system tuple as span attributes. You keep the ergonomics and gain portability across Jaeger, Tempo, Langfuse and Phoenix without rewriting instrumentation.
QualityMonitor (user feedback + format-compliance scoring) Format-compliance scoring inline on every request is exactly right, and most teams don't do it. Split it in two. The format-compliance half becomes validators/ — runs at 100%, feeds L1 and the canary gate. The subjective half becomes the sampled judge path. Right now they're one class with one sample rate, which forces you to choose between “cheap and shallow” and “expensive and complete”; splitting lets you have both.
CostTracker Per-request cost attribution — the hard part is already done. Add a second dimension: cost_source ∈ {serving, judge, retry, reflection}. Without it, the day judge spend doubles you'll see “cost went up” with no way to attribute it, and reflection retries are invisible inside serving cost.
PerformanceTracker (GPU stats) You knew GPU telemetry mattered enough to build it. Replace with dcgm-exporter on any K8s deployment. On Modal you can't run a DaemonSet, so keep a minimal in-process reader — but stop treating utilisation as the headline. KV-cache utilisation and queue depth predict user-visible latency; GPU utilisation percentage mostly doesn't.
QuickMetricsCollector (percentiles, per-model distribution) Per-model distribution is the right slice, and percentiles beat means. Client-side percentiles can't be aggregated across replicas — averaging two p95s is not a p95. Export histogram buckets and compute quantiles in PromQL. With max_containers=2 this already matters today.
Plotly dashboards Fine for analysis notebooks. Not a monitoring surface — no alerting, no shared state, no deploy annotations. Move operational views to Grafana; keep Plotly for the calibration and sizing analyses in align.py, where it's genuinely better.
Existing alert rules + Grafana JSON The scaffolding exists, which is most of the friction. Audit every rule against the alert-vs-dashboard decision tree above. Expect to delete more than you add — and add the quality and judge-liveness rules, which nothing in a pre-eval stack would have.
E2E test: 100% success, 91% cache hit, 3-model routing A real end-to-end assertion with real numbers. These become panels with baselines, not one-off test output. And note the framing shift: 100% success rate over 11 queries has a 95% lower bound near 72% — it's a smoke test, not evidence. Recreate all three on live dashboards where the sample size does the work.

4.6 · Phased plan — six weeks, never half-wired

Week 1 · Instrument before you evaluate

Ship: system tuple emitted on every request; OTel tracing in the gateway with gen_ai.* attributes; gateway-side histograms for TTFT, e2e latency, tokens, cost; slice tags on every request.

Why first: every later phase needs slice tags, and you cannot retroactively slice data you never labelled. A week of tagging bought here saves a month of “we can't tell which slice regressed” later.

Done when: you can answer “what was the p95 TTFT for compliance-flag requests on prompt version 3.1 last Tuesday?” without writing new code.

Week 2 · Build the golden set, compliance slice first

Ship: 250 compliance cases with citation-binding assertions; 150 sentiment cases with labels; the _suite.lock mechanism; one owner per case file.

Why this order: compliance is your highest-risk slice and its evaluator is free. Building it first gets you a real, blocking gate in week two, which is what earns the project the runway to continue.

Done when: the citation-binding check runs against production traffic at 100% and you know the current failure rate — the first genuinely new fact this project produces.

Week 3 · Harness and the two cheap gates

Ship: harness/run.py with frozen-tuple runs and per-slice reports; bootstrap confidence intervals in stats.py; gates.yaml; the L1 pre-commit smoke slice and the L2 PR workflow with a slice-diff comment.

Deliberately no judge yet. Everything so far is deterministic and free, which means the harness gets debugged without judge noise confusing you about whether a failure is real.

Done when: a PR that breaks the output schema goes red automatically, with a comment naming the slice.

Week 4 · Add the judge, and prove it's worth trusting

Ship: the S4 judge with position swapping; 60 human-labelled calibration cases on the trading-signal slice; align.py reporting precision, recall, TPR/TNR and a chance-corrected agreement statistic; the remaining golden slices.

Gate on yourself: do not wire the judge into gates.yaml until it clears your alignment bar on its own calibration set. An uncalibrated judge in a gate is worse than no gate — it produces confident, wrong blocking decisions and burns the team's trust in the whole apparatus.

Done when: you can state the judge's true-positive rate on the failures that matter, not just its agreement percentage.

Week 5 · Online sampling and the alerting path

Ship: the async QualityFork with stratified rates and a daily budget; gen_ai.evaluation.result emission; the control-tower Grafana board with deploy annotations; the four alert rules including judge liveness; L4 canary gates on the deploy pipeline.

The measurement to take: confirm the fork adds no measurable p99 latency. Run a load test with the fork enabled and disabled and compare — if there's a delta, the fork is doing work it shouldn't.

Done when: a deliberately broken prompt deployed to canary rolls itself back within 15 minutes, without a human.

Week 6 · Close the loop, then leave it alone for a month

Ship: the human review queue with disagreement routing; the incident → golden-case path as a documented, boring procedure; the weekly trend review; L3 release gate with the full sized suite and confidence intervals.

Then stop building and operate it. A month of running the tower teaches you which gates are noisy, which alerts nobody acted on, and which slices are under-sized — and nothing in a design document can tell you those things. the source material's cadence is a good default: gate every release, review slice dashboards weekly, add regression cases from incidents weekly.

Done when: a real incident produces a new golden case without anyone deciding to make that happen.

4.7 · Optional exercise

Evaluate the evaluator: audit your own router

Your complexity classifier is the most evaluable component you own and almost certainly the least evaluated. It makes a discrete, labellable decision, and its errors have directly measurable cost. Treat it as a first-class eval target:

  1. Pull 150 real routed requests from your traces and hand-label the correct tier.
  2. Build the confusion matrix — but weight it by cost, not by count. Routing a simple query to the expensive model wastes money; routing a complex compliance query to the cheap model produces a wrong answer in your highest-risk slice. Those two errors are not equal, and unweighted accuracy treats them as if they were.
  3. Compute the cost-weighted error in dollars and in high-risk misroutes per 1,000 requests.
  4. Now the interesting question: is your router better than always using the mid-tier model? Compute that baseline. It is surprisingly often competitive, and a router that loses to a constant is a component you can delete.

What you'll take from it: asymmetric error costs are the norm in production and the exception in benchmarks. Once you've built one cost-weighted confusion matrix, unweighted accuracy stops looking like a metric and starts looking like a missing assumption.

Bridge · what this stage hands to the next one You now have the thing fine-tuning actually requires: a sized, owned golden set and a contamination scanner, so when you fine-tune Gemma you can prove the gain is real rather than memorised. Carry three artefacts forward — the contamination report, the calibrated judge, and gates.yaml — because a fine-tune is just another release candidate, and it goes through the exact same ladder.
← 06The path
Next stage · 08 →genaipros · 07 · Evaluation & ObservabilityAI for Everyone ↗