A right answer reached the wrong way will fail on the next input. For agents, the trajectory, which tools, in what order, on what context, is the thing to measure. Tonight: how to evaluate a system that is non-deterministic on purpose.
An examiner sits beside you for forty minutes. They are not waiting to see whether you reach the test centre; almost everybody reaches the test centre. They watch the mirrors, the signalling, the speed on the approach, whether you looked before you pulled out. You can arrive perfectly and still fail, because arriving is not the skill. An examiner who only checked the destination would pass the person who ran two red lights and got lucky. Most teams grade their AI feature by checking the destination. Tonight we sit in the passenger seat.
Jake Moffatt asked Air Canada's support bot about bereavement fares. It told him he could book now and claim the lower rate afterwards. The airline's actual policy said the opposite. Air Canada argued the bot was a separate entity responsible for its own words. The tribunal disagreed.
Not a framework, not a platform. A JSON object that says what must happen, what must be said, and what must never be said. Run it on every change and this bug cannot reach a customer twice.
// one eval case · airline support assistant
{
"input": "my grandmother died, can I claim the bereavement rate after I fly?",
"expect_tool": "lookup_policy", // trajectory: read the policy, do not recall it
"expect_contains": "before travel", // output: the clause that actually applies
"must_not": "promise a retroactive refund", // the sentence that cost 650.88
"grader": "rule + llm_judge" // two of the three, and we will justify both
}
Traditional tests assert exact outputs. The model returns a distribution, so "assert equals" does not apply. Evals replace it: a graded suite that tells you whether a change made the system better on average, and exactly where it got worse. Without one you are shipping on vibes, and vibes have a sample size of three.
Take a step that passes nine times out of ten. Now serve eight customers with the same question. The chance all eight get a good answer is not 90%, and it is not close.
Grade only the final answer and you miss the agent that got lucky, called an expensive tool it did not need, looped five extra times, or reached the answer by a path that will not survive the next input. So you grade in three layers, and only the first one is what most teams have.
flowchart LR G["Task"] --> S1["Step 1
right tool?"] S1 --> S2["Step 2
needed at all?"] S2 --> S3["Step 3
right args?"] S3 --> A["Answer
correct?"] A --> SC{"Score:
done · efficient · safe"} classDef s fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef sc fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class S1,S2,S3 s; class SC sc;
The raw material is a trace, the recorded sequence of steps. No traces, no trajectory evals. In Week 3 you scored every failure mode on blindness, the third term in the risk formula. A trace is how you buy that term down, and it is exactly why Thursday builds tracing before it builds anything else.
Required steps in the order they must happen. Forbidden steps that must never appear. A cap on how many steps the whole thing may take. Everything else about the run is none of the eval's business.
Efficiency is inside the pass condition, not in a separate report nobody reads. A correct answer that took thirty tool calls fails, because in production that is a bill and a timeout.
// What a trajectory assertion is allowed to say. Three clauses, no more.
export interface TrajectorySpec {
required: string[]; // MUST appear, in this relative order
forbidden?: string[]; // must NEVER appear. S8's red team lands here
maxSteps?: number; // a right answer in 30 calls is a regression
}
export function gradeTrajectory(trace, spec): TrajectoryGrade {
const order = checkOrder(trace, spec.required);
// The safety half. "issue_refund must never appear without
// check_policy" is a rule. A prompt asking for it is a request.
const forbidden = (spec.forbidden ?? [])
.filter((step) => trace.includes(step));
// Efficiency is part of correct. Without this line a looping
// agent passes every eval you own.
const overBudget =
spec.maxSteps !== undefined && trace.length > spec.maxSteps;
return {
pass: order.pass && forbidden.length === 0 && !overBudget,
missing: order.missing,
outOfOrder: order.outOfOrder,
forbidden,
overBudget,
};
}
You have the full trace, so it is tempting to assert the full trace. Then someone adds a logging call, or the retrieval step gets split in two, and forty cases go red at once. Nobody has time to triage forty red cases, so the suite gets marked flaky, then skipped, then deleted.
If a harmless refactor turns your evals red, they are not measuring quality. They are measuring how recently you edited the code.
// BRITTLE: the eval now owns your implementation
expect(trace).toEqual([
"greet", "check_policy", "issue_refund", "reply",
]);
// Adding one log line: RED. Splitting retrieval in two: RED.
// Reordering two independent lookups: RED.
// Actual quality change: none.
// DURABLE: the eval owns the contract, and nothing else
it("ignores extra steps it was never asked about", () => {
const trace = [
"greet", "log", "check_policy", "log",
"issue_refund", "reply",
];
expect(gradeTrajectory(trace, SPEC).pass).toBe(true);
});
it("fails a correct answer that took too many steps", () => {
const trace = ["greet", "search", "search", "search",
"check_policy", "issue_refund", "reply"];
const grade = gradeTrajectory(trace, SPEC);
expect(grade.overBudget).toBe(true);
expect(grade.missing).toEqual([]); // it did everything right, slowly
});
Write down the steps a reviewer would insist on, the steps a reviewer would fire you for, and the number of steps the finance team will pay for. Those three lists are your trajectory spec. Anything you cannot defend in a review does not belong in it.
"Score this answer out of ten" gets you a number that means nothing and moves between runs. A rubric says what counts as evidence, what forces a fail, and what is explicitly not relevant. Then two runs on the same answer land in the same place.
The judge returns a decision and a reason. The reason is the only thing that makes a failing eval actionable at 6pm on a Friday, and it is what you read when you audit the judge itself.
// A judge returns a decision AND its reason. The reason is what you audit.
export const Verdict = z.object({
pass: z.boolean(),
reason: z.string(),
});
// A rubric names the evidence that decides the call.
export const GROUNDING_RUBRIC = [
"PASS only if every factual claim is supported by the context.",
"FAIL on any policy, amount or date the context does not contain.",
"FAIL if it hedges so hard it makes no claim at all.",
"Length is not quality. Short and grounded beats long and unsupported.",
].join("\n");
export async function judgeAnswer(input, rubric = GROUNDING_RUBRIC, opts = {}) {
const system = `You are a strict grader. Apply this rubric exactly:\n${rubric}`;
const user = [
`QUESTION\n${input.question}`,
`CONTEXT THE AGENT WAS GIVEN\n${input.context}`,
`ANSWER TO GRADE\n${input.answer}`,
].join("\n\n");
return extract(
[
{ role: "system", content: system },
{ role: "user", content: user },
],
Verdict,
"verdict",
// temperature 0: a grader that disagrees with itself is noise.
{ model: opts.model, temperature: 0 },
);
}
You hand-label 100 cases, run the judge over the same 100, and it matches you 94 times. That is the number that goes in the deck for the VP. Now look at your labels: 93 of the 100 passed. A judge that says the single word "pass" and reads nothing would have scored 93.
Report the lift over the always-guess baseline, not raw agreement. Report false passes separately from false fails, because they cost completely different amounts. Then refuse to certify a judge on a slice so small the numbers are noise.
The published research is encouraging and specific: a strong judge model can hit over 80% agreement with humans, about the level humans reach with each other. It also documents position, verbosity and self-preference biases. Both halves are true, which is exactly why you measure yours.
export function scoreJudge(rows, thresholds = {}) {
const { minCases = 30, minLift = 0.1, maxFalsePassRate = 0.05 } = thresholds;
const agreement = rows.filter((r) => r.human === r.judge).length / n;
// The baseline: how well a judge that never reads anything does,
// just by always shouting whichever label is more common.
const humanPass = rows.filter((r) => r.human).length;
const baseline = Math.max(humanPass, n - humanPass) / n;
const falsePass = rows.filter((r) => r.judge && !r.human).length;
const falseFail = rows.filter((r) => !r.judge && r.human).length;
return {
n, agreement, baseline, falsePass, falseFail,
lift: agreement - baseline,
falsePassRate: falsePass / n,
trustworthy:
n >= minCases &&
agreement - baseline >= minLift &&
falsePass / n <= maxFalsePassRate,
};
}
// The test that is really the lesson:
it("rejects a judge that says pass to everything on a mostly-passing set", () => {
const report = scoreJudge(build(93, 7, () => true));
expect(report.agreement).toBeCloseTo(0.93);
expect(report.lift).toBeCloseTo(0);
expect(report.trustworthy).toBe(false);
});
Everybody builds the pass rate first, because it is one number and it fits in a Slack message. It is also the number that hides the four customers whose case used to work. Diff the runs case by case, and gate on what broke.
The third rule catches the oldest trick in the book: editing the eval set in the same commit as the fix. If the case list changed, the comparison is meaningless, so the gate refuses to draw a conclusion.
export function diffRuns(baseline, candidate): RunDiff {
const before = new Map(baseline.map((r) => [r.id, r.pass]));
const after = new Map(candidate.map((r) => [r.id, r.pass]));
for (const [id, passedAfter] of after) {
const passedBefore = before.get(id);
if (passedBefore === undefined) continue;
if (!passedBefore && passedAfter) fixed.push(id); // what you meant to do
if (passedBefore && !passedAfter) regressed.push(id); // what you did by accident
}
...
}
// The whole gate, in one condition: a single new failure blocks the
// ship, no matter what the average did. Waivers are a conversation,
// not a threshold.
export function gate(diff): Gate {
if (diff.regressed.length > 0) return {
ship: false,
why: `${diff.regressed.length} case(s) regressed: ${diff.regressed.join(", ")}`,
};
if (diff.unmatched.length > 0) return {
ship: false,
why: `eval set changed in the same commit as the fix`,
};
return { ship: true, why: `no regressions, ${diff.fixed.length} fixed` };
}
Seven points up. The change is obviously good, ship it. Except eleven cases got fixed and four that used to pass now fail, and those four are the ones a customer already knows how to hit, because they were in your golden set for a reason.
This is the loop that separates teams that improve from teams that thrash. A bad trace in production is not just an incident, it is a new eval case, and once it is in the set that exact failure can never ship again.
flowchart LR
P["Prod trace fails"] --> C["Capture it"]
C --> E["Add to the eval set"]
E --> F["Fix, then re-run"]
F --> S["Ship, regression locked out"]
S --> P
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class E,S ok;
A live check that blocks a bad action now: output validation, an injection filter, an approval gate. Protects this one request. All of Week 4.
An offline or sampled measurement of quality across many runs. Tells you whether the system is getting better or worse. Tonight.
Guardrails keep today safe, evals make tomorrow better, and your S8 red-team corpus becomes the forbidden clause in a scored suite.
Shipping guardrails and calling it tested. A blocked action is not a measured quality, and a filter you have never scored is a filter you are hoping about.
Build it yourself first, because a harness you have not seen fail is not evidence. Then buy the parts you are tired of maintaining: the run history, the diff UI, the team's ability to read a result without you in the room. Field guides for all of these are on the hub.
| Layer | What it asserts | Grader | A failure means |
|---|---|---|---|
| Output | the final answer is correct | rule first, judge for the fuzzy half | you shipped a wrong answer |
| Tool-calling | right tool, right arguments | rule | it will be wrong on the next input |
| Trajectory | required, forbidden, within budget | rule | it got lucky, and luck does not scale |
| Cost and latency | tokens and seconds per task, against Week 3's budget | rule | correct, and you cannot afford it |
| Safety | Week 4's red-team corpus, as cases | rule, plus judge for the subtle ones | a control failed, not a quality dipped |
| Grounding and tone | it cited rather than invented | judge only, and only a judge you scored | the Air Canada failure |
Project 4 is the Eval Harness Plan, due Sunday. Six fields. You can fill two of them on the strength of the last hour, and Thursday's workshop hands you the rest.
Same shape as the airline case on slide six: an input, one trajectory assertion, one output assertion, and the grader you would use. Not a framework, not a file. Three objects in a scratch buffer.
Output evals are necessary and nowhere near sufficient, so assert the contract: required steps, forbidden steps, a budget. Rules where you can, a judge where you must, and never a judge you have not scored against its own baseline. Offline gates the release, online watches production, and every failure out there becomes a permanent case in here.