Commit 4 of 9v4-does-it-work
But Does It Work?
Single-turn evals test the decision the model made, not the prose it wrapped around it.

Post 3 finished with an agent that answered its question forty-one times out
of forty-two, and a grader that could not tell you why. The check was a
case-insensitive substring: put agent/src/config.ts and gpt-5-mini in the
final sentence and the run is a success. Twenty-one runs passed it. All
twenty-one also had the ground truth and a published post about the ground
truth in the same set of search results, and a sentence naming the file and
the value is consistent with either.
So the last post said, in public, that it was handing the grading problem here. This is that.
What I built
The trace already knows. Every recorded run in this repository is a list of frames, and the union is short enough to read in one go — a question, a tool call with its arguments, the result that came back, a step of text, and the two frame types the later commits will need. Nothing in it is prose about what happened. It is what happened, including the parts the model did not mention afterwards.
An eval that reads a trace is therefore already an eval that reads a decision. What it needed was one distinction the schema does not draw, because the schema does not care and the grader has to:
export type Observation =
| { frame: number; tool: string; kind: 'path'; file: string }
| { frame: number; tool: string; kind: 'line';
file: string; line: number; text: string }
list_files returns names. search_files returns lines. A name tells the
model that a file exists; a line tells it what is written inside one. By the
time the answer is written both are just tokens in the context, which is
exactly why a check over the answer cannot separate them — and separating them
turns out to be most of the value.
On top of that, three grades per run instead of one.
The answer grade is the recorder’s own rule, recomputed from the committed
file rather than from the live run: all the expected facts present is a
success, some is partial, none is a failure. It is here to be compared
against, not to be believed. A test asserts that it agrees with the outcome
each trace was written with, on all six runs there are facts to check — post
1’s question has no fact a substring could check, so it grades ungraded and
the test skips it — so that when the two columns diverge it is the run that
moved and not the reimplementation.
Unread claims is the first thing the frames can say that the sentence
cannot. For each fact the answer states, did any tool actually return it? A
fact that appears in a line is read. A fact that appears only as a file name
in a listing is named-only — the model inferred it. A fact that appears in
nothing at all is unsupported; it came out of the model. Anything in the
answer that is not read is an unread claim, and the substring grader gives
it full marks.
Provenance is the axis post 3 asked for, and the useful part of it is the verdict that admits defeat:
export type Provenance =
| 'grounded' // the ground-truth line was observed, no copy of it was
| 'undecidable' // both were observed; the trace cannot say which was used
| 'from-copy' // a copy was observed, the ground truth was not
| 'unevidenced' // neither was observed
| 'ungradable' // the question has no single line for an answer
A copy is any line outside the source file carrying the value the answer turns on. That rule over-counts on purpose: it flags a line that restates the value without naming the file, which could not by itself have produced the answer. A grader whose mistakes all run toward I cannot tell is one you can act on. One whose mistakes run toward grounded is the thing this post exists to complain about.
Why this is vitest and not an eval framework
The ai SDK’s ecosystem has an eval story and the course this series follows
uses Laminar, and I used neither, because the thing being graded here is a
pure function from a committed JSON file to a verdict. No model is called. No
score is averaged. Nothing is non-deterministic, nothing needs a dashboard,
and nothing needs to be uploaded anywhere to be looked at later. An eval
framework earns its keep when runs are live and scores are noisy — which is
post 5, and post 5 can have one. Here the unit under test is a function, the
repository already runs vitest in CI, and adding a service dependency to a
check whose entire virtue is that it runs offline from a clean checkout would
be paying for the opposite of what I want.
So the suite is tools/eval/, the gate rides in npm test, and there is a
report:
npm run eval # one block per run
npm run eval -- --tsv # the same thing as a table
Each committed trace has a row in tools/eval/suite.ts saying which question
it was answering and what this eval says about it. Those expectations were
written down as predictions, from reading the traces, before the grader was
run over them; all seven matched on the first run and none has been changed
since. That ordering matters, because the other ordering — run it, then write
down whatever it said — produces a suite that agrees with everything.
What broke
What post 2’s partial was actually worth
Post 2’s precise run is the one that called list_files, got the whole
project back, and then said it could not read the project. The recorder graded
it partial, and post 2 defines a partial as a run whose answer carried one
of the two facts and not the other. The fact it carried was the file.
This eval counts 68 observations in that trace and every one of them is a file name. Not one line of file contents was ever returned to the model. So the fact it was credited with is an unread claim, and here is the sentence it was credited for:
The file is likely
agent/src/config.tsoragent/src/run.ts— try openingagent/src/config.ts(cat agent/src/config.ts) to see the model name declaration.
That is two guesses and an instruction to go and check. The substring grader
sees the first guess, finds one of its two strings, and awards half. Post 2
called its own check “the crudest possible” and handed grading here, so this
is a prediction coming true rather than a claim being overturned — no number
in post 2’s tables changes. What changes is what partial is worth in the
column it was counted in.
Not one run of this series’ question can be shown to have answered from the source
Five recorded runs across posts 2, 3 and this one ask the same question,
character for character. The grader can return grounded. It returns it for
none of them:
| run | answer | what the frames say |
|---|---|---|
2 · trace-a-precise |
partial | unevidenced, 1 unread claim |
2 · trace-b-thin |
failure | unevidenced |
3 · trace |
success | undecidable, 1 copy |
3 · trace2-corpus-hit |
success | undecidable, 16 copies |
4 · trace-a-model-name |
success | undecidable, 1 copy |
Three of those runs end with this sentence:
The model name is set in
agent/src/config.ts— it exportsMODEL_NAME = 'gpt-5-mini'.
Not approximately. The same string, three times, from three separate runs across two commits:
for t in site/src/content/posts/03-the-loop/trace.json \
site/src/content/posts/03-the-loop/trace2-corpus-hit.json \
site/src/content/posts/04-does-it-work/trace-a-model-name.json; do
node -e "console.log(require('./$t').frames.filter(f=>f.type==='assistant').at(-1).content)"
done | sort -u | wc -l
That prints 1. What it prints is 86 bytes of UTF-8 — eighty-four
characters, one of them an em dash. Behind those three identical sentences are
three different
result sets, with one, sixteen and one copies of the answer in them. A grader
reading the sentence has nothing to work with. It is looking at the same bytes
each time, and it is right to give the same grade, and the grade is worthless.
A contrast post 3 drew is weaker than it reads
Post 3 puts two runs on the page: the demonstration of the loop at the top,
and tally-06 further down as the run that was handed the answer key. Of the
first it says it never saw any of the prose — and I want to be exact about
what that sentence claims, because I drafted a correction to it that it does
not deserve.
It sits under the heading The corpus now contains posts about the answer,
four lines after post 3 works out that the run’s search hit the forty-match
cap fourteen files in and — post 3’s words — never got as far as site/.
That is luck, not hygiene. So “the prose” there means the published posts,
and on its own terms the claim is true: 0 of that run’s 40 matches are under
site/. Post 3 also reaches this post’s conclusion before this post does,
in the same section: I cannot tell you it did not happen, because
config.ts was in the same result set every single time … The recorder’s
substring check cannot tell “read the source” from “read the post about the
source”, and neither can I.
The eval does not disagree with any of that. It agrees and puts a number on
it. What it adds is one thing the framing of those two runs leaves standing.
The featured run’s results are not free of the answer: they contain
agent/src/cli.ts line 48, which post 3 quotes from that very trace two
sections earlier — step to frame five and read the third match — and
describes as naming both graded facts in one string. One line is enough. So
the distance between the two runs is sixteen copies against one, which is a
difference of degree, and both verdicts come out undecidable. The contrast
is real as contamination. It is not a contrast of provenance, which is what a
reader flipping between the two players might take it for.
Two counts of the same trace, and they do not conflict. Post 3 counts the
TRACE_EXPECT-shaped pair — path and value on one line with a comma between
them — and gets five lines in four files, which is exact; I checked it. This
eval counts any line outside config.ts carrying the value at all, and gets
sixteen in eight, because it deliberately includes lines that hand over the
value without naming the file. Two rules, one trace, both printed.
Nothing in post 3 needs editing. This is a reading of it, and it goes here because that is where this series puts its readings.
A fixture caught a bug the whole corpus would have hidden
The first version of the support check asked whether a fact appeared in the
text of a line. But a search match arrives as a file, a line number and the
line, all at once, so a run that reads agent/src/config.ts:1 has read the
path as surely as it has read the assignment. The first version called that
file fact unsupported.
All seven committed traces graded identically before and after the fix, because in this repository the path is also spelled out inside other lines, so the wrong rule reached the right answer for the wrong reason on every real run I have. The thing that caught it was a fixture about a fictional project with a port number in it, written deliberately against a question this project does not ask. An eval tested only against the corpus it was built for cannot tell you whether it is measuring that corpus or remembering it.
The control retires the moment this page is published
The second run below asks about something this repository stated in exactly
one place at v3-the-loop, which is the tree both runs below were recorded
against and the only reason one of them can be graded grounded. This
paragraph ends that. The extension list is now in a published post, the post
is in the corpus, and the next run to ask that question gets a copy back
alongside the source and an undecidable with it.
That is the third time this series has spent a measurement by describing it, and it is beginning to look less like an accident than like a property. An experiment run against the repository that documents it has a half-life.
The demo
Same question as posts 2 and 3. Both runs below carry v4-does-it-work as
their commit, which is the tag this post is bound to, but the tree they
actually walked was v3-the-loop — they were recorded before a line of
tools/eval/ existed, so nothing the grader says about them can have been
arranged by writing the grader.
Recorded run
Which file in this project sets the model name the agent uses, and what is it set to?
Recorded from gpt-5-mini at commit v4-does-it-work. The run finished the task.
Frame 1 of 4
204 tokens in context, against this run's peak of 1,908.
You
Which file in this project sets the model name the agent uses, and what is it set to?
One search, forty matches, the answer in the first sweep, and the sentence
again. agent/src/config.ts:1 is in there. So is agent/src/cli.ts:48.
Verdict: undecidable. The loop works, the answer is correct, and the run
cannot prove it.
Then the control — a question this repository answers in exactly one line:
Recorded run
Which file in this project decides that a file is binary and should not be searched, and which file extensions does it list?
Recorded from gpt-5-mini at commit v4-does-it-work. The run finished the task.
Frame 1 of 12
209 tokens in context, against this run's peak of 3,094.
You
Which file in this project decides that a file is binary and should not be searched, and which file extensions does it list?
Five searches for one question. The first sweep already contained
agent/src/tools/fs.ts line 42, which is the whole answer; the model then
searched for the matched line verbatim and got nothing back twice, because it
was searching for a regular expression using its own text as the pattern.
Then it searched for the doc comment above it, got one hit, searched for the
file path, and answered. Four of its five calls were the model checking a
thing it had already been told, and two of those four came back with nothing.
None of that is visible in the final sentence. All of it is in the trace.
Verdict: grounded — the source line observed, no copy of it anywhere in what
came back.
Which is the finding under the finding. The model-name question is undecidable in this corpus and no grader fixes that, because the trace does not record which of the forty matches the model attended to and the API does not expose it. What changed the verdict was not a better grader. It was a question the corpus answers once.
All seven traces in the repository, graded twice, are in
verdicts.tsv, generated by
npm run eval -- --tsv, with each run’s final answer in the last column so
the identical-sentence claim above is something you can check rather than
something you have to take from me.
It goes red
An eval suite that cannot fail is the defect this project has already shipped once: post 1’s leak gate had never seen a real trace and would have reported success over a leak, because a gate that finds nothing and a gate that is not looking print the same line.
So, three ways this one fails.
Claim that post 3’s demonstration run answered from the source — change its
one expectation in tools/eval/suite.ts from undecidable to grounded —
and the gate says so, and exits 1:
03-the-loop/trace.json
tools list_files > search_files
observations 117
answer success (recorded: success)
unread (none)
provenance undecidable (1 ground truth, 1 copies)
MISMATCH provenance: expected grounded, got undecidable
1 of 7 case(s) no longer match their recorded verdict:
03-the-loop/trace.json: provenance: expected grounded, got undecidable
Second, tools/eval/corpus.test.ts takes the real committed corpus-hit trace
and removes matches from its search result in memory — nothing is written and
no altered trace is shown to anyone as a run — and asserts that the verdict
moves to grounded with the copies gone, to from-copy with the source gone,
and to unevidenced with both gone, while the answer grade stays success
through all four, because the sentence never changed. That is post 3’s
complaint, reproduced on demand.
Third, and this is the one I would actually rely on: a tool result whose shape the eval does not recognise raises rather than returning nothing. An unrecognised result would contribute zero observations, every claim would grade unsupported, and the suite would look most rigorous at the exact moment it had stopped reading. Post 6 changes the tool set; this is the line that will notice.
What this does not fix
The thesis at the top of this page says a single-turn eval tests the decision
rather than the prose, and it survives, with a condition it did not come with:
a decision eval can only distinguish what the run recorded. Where the
ground truth and a copy of it arrive in the same tool result, the decision the
model made is genuinely not in the trace, and a grader that reported
grounded there would be inventing. The three ways out are a question the
corpus answers once, which is the run above and which publishing retires; an
API that reports which part of a result the model attended to, which does not
exist; and deleting the copies, which this series has forbidden itself twice
in writing.
So I have left series.ts alone. Post 3 changed its thesis because the diff
contradicted it. Nothing here contradicts this one — it just arrives with a
limit printed on the side.
The commit
There are now two graders in this repository. One reads the sentence, and on four of the seven traces in the tree it has been cheerfully, publicly overgenerous. The other reads the frames and says that three of those four are undecidable and the fourth was credited for a guess. On the other three — post 1’s chatbot, post 2’s thin run, and the control above — the substring check is not overgenerous about anything, which is worth saying too. A crude grader is not a grader that is always wrong. It is a grader that cannot tell you when it is.
Neither of them is judging quality. Both are checking a run against facts I wrote down before it started, which works precisely as long as the answer is a string I already know. The next question does not have one of those, and then the grader has to be a model too.
The next commit is about judging a moving target.