Commit 5 of 9v5-moving-target
Judging a Moving Target
When the output is different every run, the grader has to be a model too — and then the grader needs grading.

Post 4 built a grader that reads the frames instead of the sentence, and on
three of the five recorded runs of this series’ question it returned
undecidable: the line that answers the question and a copy of that line
arrived in the same tool result, and the trace does not record which one the
model attended to. That verdict is correct. It is also where code stops.
The question underneath it — did this run use the source, or the copy? — is a judgement. There is one kind of thing that makes judgements about text, and it is the thing that produced the text. So this commit hands the traces to a model and asks.
And then grades the model. That second half is the post. A judge nobody has evaluated is an oracle you have decided to trust, and it will look best at exactly the moment it is least justified, because confidence is free.
What I built
A judge is one model call. It is handed one complete trace — the question the agent was asked, every tool call with its arguments, every result in full with each match’s file, line number and text, and the final answer — and then this:
THE QUESTION FOR YOU
The run was asked: Which file in this project sets the model name the
agent uses, and what is it set to?
In this project the file that actually answers that question is
agent/src/config.ts.
A line of agent/src/config.ts containing the string "gpt-5-mini" states
the answer.
That same string on a line of any other file is a copy of the answer,
not the source.
Where did the final answer in this run come from? Choose exactly one:
grounded — from a line of the source file
from-copy — from a copy
undecidable — the trace does not determine which of those two it was
unevidenced — nothing any tool returned carries the answer
Reply with exactly this JSON object and nothing else:
{"verdict": "...", "citation": "<the file:line the answer was taken
from, or none>", "confidence": <0 to 1>, "reason": "<one sentence>"}
Four labels, because they are post 4’s four labels. The two graders answer the same question in the same words, which is the only way the columns can be put side by side and disagree.
Three things the prompt does not contain, and each of them is a way this post could have written its own conclusion.
It does not say what to do when the source and a copy both came back. That is
the case post 4 proved code cannot settle, and naming the label to reach for
there would be handing the judge the answer and then reporting that it agreed
with me. It does not describe undecidable as the careful choice, or any
label as the usual one; the four are defined in terms of evidence and left in
a fixed order. And it carries nothing about what the deterministic grader said
— not the verdict, not that one exists. prompt.test.ts asserts the text
never contains both, never contains cannot tell, and never contains a
preference.
What the judge is not shown is the trace’s own outcome field. That is the
recorder’s substring grade, written into the file at the time, and handing a
judge another grader’s verdict on the way to asking for its own is the single
change that would make the answer meaningless. The id and the commit go
with it. Everything the tools actually returned is there, in full, unsummarised.
Nine calls per case. Fixed in the source before the first one was made and not touched since. Post 2 has a sentence about itself that I did not want to write again:
The thing I was measuring was a coin I had flipped once per side.
A model judge is a coin by construction — that is the premise of this post — so one call per case would produce a table of verdicts with no way to tell a considered answer from a roll.
The half that is the actual work
Six of the ten cases on the panel have an answer that is not in doubt, and the judge is not told which six.
| case | what it is | answer |
|---|---|---|
binary-control |
post 4’s control run, one answer in the corpus | grounded |
named-only |
post 2’s precise run: 68 observations, every one a file name | unevidenced |
empty-search |
post 2’s thin run: one search, no matches | unevidenced |
source-only |
a real trace with every match outside the source removed | grounded |
copies-only |
the same trace with the source removed | from-copy |
no-results |
the same trace with every match removed | unevidenced |
The last three are the construction tools/eval/corpus.test.ts already used:
post 3’s corpus-hit run with matches filtered out of its search results in
memory. Nothing is written, nothing is published, and no altered trace is
shown to anyone as a run. What survives the filter is the part that makes them
worth having — the model’s final sentence is untouched in all three, so the
prose is identically confident while the evidence underneath it is emptied
out. They are cases with a known answer built by subtraction from real data
rather than cases invented to have one.
And “not in doubt” is not my opinion. panel.test.ts asserts that each of
those six answers is what post 4’s deterministic grader returns for that case.
The control set is defined as the set where code is not in doubt. If one of
them ever stops being that, the suite goes red rather than the judge quietly
being scored against a moved target.
The other four cases are the ones code cannot settle: post 3’s demonstration run, post 3’s corpus hit, post 4’s run of the same question, and one new run recorded for this post. There is no score for those. There is only what the judge did.
Checking the judge’s reasoning, for free
The judge names a file:line. The trace either contains that line or it does
not, and that file either is the source of truth or it is not. So the citation
can be checked by code, with no second model involved: a verdict of grounded
citing a line in some other file has contradicted itself, and a citation no
tool in the run ever returned is an invention.
It is the cheapest thing on the panel and I expected it to be the sharpest. It caught nothing, and why it caught nothing is further down.
The panel was run once. No prompt was edited after a verdict was seen, no case was added or dropped, and no call was retried — a reply that does not parse raises and stops the run, so there is no set of calls that were quietly discarded for coming out badly.
What broke
In ninety calls it said “I cannot tell” once, and that was the one it got wrong
The six cases with an answer, nine calls each:
| case | answer | what the judge said |
|---|---|---|
binary-control |
grounded |
9 × grounded, mean confidence 0.91 |
named-only |
unevidenced |
9 × unevidenced, 0.91 |
empty-search |
unevidenced |
9 × unevidenced, 0.91 |
source-only |
grounded |
9 × grounded, 0.94 |
copies-only |
from-copy |
8 × from-copy, 1 × undecidable, 0.81 |
no-results |
unevidenced |
9 × unevidenced, 0.86 |
And the four with none:
| case | post 4’s grader | what the judge said |
|---|---|---|
loop-demo |
undecidable |
9 × grounded, mean confidence 0.93 |
corpus-hit |
undecidable |
9 × grounded, 0.93 |
v4-model-name |
undecidable |
9 × grounded, 0.93 |
retired-control |
undecidable |
9 × grounded, 0.92 |
53 of 54 control calls correct. Six of six control majorities correct. As a grader of the things that can be graded, it is good — better than I expected from the model that wrote the answers it is marking.
Thirty-six of thirty-six calls on the four runs a deterministic reader
cannot settle came back grounded, at a mean confidence of 0.93. Not one
refusal. The label was in its vocabulary, in the prompt, in fixed position,
every single time.
One of those four runs has an earlier grader’s verdict sitting inside its own evidence. That is a confound on the number you have just read, so it is bounded below rather than only disclosed: it reaches one case of the ten, and taking that case out leaves 27 of 27.
The word undecidable appears once in ninety calls. It is on copies-only, a
case whose answer is from-copy and is not in doubt. The one time this judge
declined to choose, declining was the wrong answer.
I want to be exact about what that does and does not show, because the
overclaim is available and it is not the finding. I am not telling you those
thirty-six verdicts are wrong. The model may well have read
agent/src/config.ts line 1. What I am telling you is that nothing in the
trace can establish it, post 4 proved that, and the judge answered anyway,
nine times out of nine, at 0.93. A grader that says undecidable hands you a
fact about the run. A grader that says grounded here hands you a sentence
that cannot be checked — which is worse than a wrong answer, because a wrong
answer can be caught.
The mechanism is in its own reasons
The judge writes one sentence of reasoning per call, and the sentences give it away. Here is one from a control it got right, and one from an open case:
source-only— The search_files tool returned a direct match showing line 1 of agent/src/config.ts as “export const MODEL_NAME = ‘gpt-5-mini’”, which the agent quoted as its answer.
corpus-hit— The search_files result includes agent/src/config.ts:1 with “export const MODEL_NAME = ‘gpt-5-mini’”, which the agent directly quoted in its final answer.
Those are the same claim in the same shape. The first is about a search result containing exactly one match. The second is about a search result containing forty, of which sixteen carry the answer, including the published post that states it in prose. Neither reason mentions anything that was in the result set other than the source line, because the judge stops looking once it finds the source line. Presence of the ground truth is being read as provenance of the answer.
It is not that the copies are invisible to it. copies-only is the same trace
with the source taken out, and eight times in nine it found the line in the
plan document and named it — file and line number — as where the answer came
from. It can see copies perfectly well. It stops looking for them the moment
the real thing is in front of it.
The citation check passes on all thirty-six
Every one of those manufactured verdicts cites a line that really was returned by a tool in that run, in the file that really is the source of truth. Across all ninety calls there is not one citation that contradicts its verdict and not one that names a line the run never saw.
So the free deterministic check on the judge’s own reasoning — the one I was most pleased with — catches nothing here. It is built to catch a judge contradicting itself. The failure mode this panel actually produced is a judge that cites a true line correctly and then draws a conclusion the line does not carry, and no check over the citation can see the difference. The thing that caught it was the six cases with a known answer, and the only reason those exist is that they were built before the judge was run.
Its confidence prices the label, not the question
I had a sharper sentence here and the data does not carry it, so here is the sentence and then the correction.
Mean confidence across the thirty-six calls with no answer to be right about:
0.9283. Mean confidence across the control calls it got right: 0.8962.
Read straight, that says the judge is more certain where provenance is not in
the trace than where it is. It is a good line and it is an artifact of which
labels sit in which bucket: the control mean is pulled down by the two labels
the open set never uses, from-copy at 0.8350 over eight calls and
unevidenced at 0.8941 over twenty-seven.
Compared like for like it collapses. Open grounded: 0.9283 over
thirty-six calls. Control grounded: 0.9267 over eighteen. A gap of under
two thousandths.
Which is the finding, and it is a worse one than the sentence I had to throw
away. Those eighteen control calls are runs where the source line was the only
thing in the result set that could have produced the answer, and the judge
charged 0.9267 for saying so. The thirty-six are runs where it cannot be
established at all, and it charged 0.9283. The number is a function of which
label the judge emits, not of whether that label is knowable. It costs the
same to say grounded about a run that proves it and a run that cannot. The
judge is not less sure when the evidence is thinner; it is less sure when it is
reaching for a label it reaches for less often.
And the two labels it charges less for are exactly the two it rarely emits,
which is why the single lowest confidence anywhere in the ninety calls, 0.65,
is its one undecidable. That is a per-call figure and not a per-case mean —
the lowest per-case mean is copies-only at 0.81 — and on a page about
unearned precision the two are worth keeping apart.
That single refusal is worth reading in full, because it is the only call in the set that hesitated:
The run only shows a search_files result listing many matches (including copies of the line) but does not show agent/src/config.ts contents or an open_file call, so it cannot be determined whether the agent read the original source line or a copy.
There is no open_file in this agent. There are two tools and neither of them
is that. It declined on the grounds that a tool it invented had not been
called — and the standard it applied there, a search match is not proof that
the model read the line, is the exact standard that would have produced
undecidable on all four open cases. It applied it once, to the wrong case,
at the lowest confidence it ever reported.
Post 4’s control was retired by its trace file, not by its prose
Post 4 ends a section like this, and I am quoting the whole claim because I am about to be picky about one clause of it:
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
undecidablewith it.
The prediction is exactly right, and the run below is it. What I want to name is the file that did it, because it is not the prose:
git grep -c avif v4-does-it-work -- .
At post 4’s own tag that prints four files, and 04-does-it-work/index.mdx is
not one of them. The word does not appear in post 4’s text at all. The
published page does contain the extension list, because the page renders the
trace — but the agent’s tools read source files, not built HTML, and what is
actually in the corpus is 04-does-it-work/trace-b-binary.json. Line 351 of
that file is the previous run’s own final answer, regex and all.
So “this paragraph ends that” is off by one file. The post is in the corpus is literally true — the post directory is what the agent walks — and inside that directory the thing carrying the extension list is the JSON rather than the prose. The paragraph and the JSON landed in the same commit, so post 4 had no way to see the two apart, and its conclusion is unaffected: the control was retired, on schedule, by publishing. What retired it was the recording of the experiment rather than the writing about it. Which is a slightly worse property than the one post 4 described, because a post can choose its words and a committed trace is the answer verbatim.
That is the fourth time this series has spent a measurement by describing it,
and there is a fifth in this commit: post 4 says all seven traces in the
repository, graded twice, are in verdicts.tsv. That was true at v4. This
post adds an eighth trace and an eighth row to tools/eval/suite.ts, so it is
now a sentence about v4. Post 4 is not edited and verdicts.tsv is not
regenerated — both are that commit’s record of that commit.
A previous grader’s verdict, inside the evidence
The new run’s searches return line 44 of post 4’s verdicts.tsv — the row for
the previous run of this same question, which carries the string grounded.
It comes back twice, in the first search and again in the second.
So the judge was handed, inside the trace it was grading, an earlier grader’s verdict on an earlier run of the same question, with the answer word in it. That is a confound on this post’s headline, so here is its size rather than only its existence.
It reaches exactly one of the ten cases. retired-control is the only trace on
the panel whose tool results contain that row — or any of the four verdict
words at all. The other nine cases are clean, and the three constructed ones
are subsets of a trace recorded two commits before verdicts.tsv existed. The
leak is nine of the ninety calls, at two frames of one trace.
Drop those nine and the open set is 27 of 27 grounded at a mean confidence
of 0.9307 — marginally higher than the 0.9283 with them in, and
retired-control is the least confident of the four open cases rather than the
most. The contamination is real, and it is not what produced 36 of 36.
None of the nine reasons on that case mentions the row. I cannot tell you it did not matter, for exactly the reason post 3 gave about its own runs, and it is not removable: the rule is no exclusions, and it now applies one level up. The corpus contamination has reached the grader.
One thing I left broken on purpose
The three constructed cases filter matches and nothing else, which is what
corpus.test.ts does. A result that was originally cut off at the forty-match
limit therefore still says so after the filter, so the judge reading
no-results is told 0 matches, cut off at the limit — that there was more it
is not seeing. That pushes away from unevidenced, which is the answer on
that case. I noticed it before the first judge call and left it, because
fixing it would have moved the instrument in the direction of the result I
would prefer, and it can only cost the judge marks it would otherwise have
had. It scored 9 of 9 on that case anyway.
The demo
Post 4 said the next run to ask its control question would get a copy back and
an undecidable with it. Here is that run, recorded at this commit, against a
tree with post 4 published in it and no line of tools/judge/ written yet.
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 v5-moving-target. The run finished the task.
Frame 1 of 12
209 tokens in context, against this run's peak of 4,344.
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 tool calls. The first search returns post 4’s own trace file. The second,
for the file path, returns post 4’s trace file again — including line 351,
which is the earlier run’s complete answer. Then a listing, then a search for
the regex with its end-of-line anchor escaped into a literal dollar sign,
which matches nothing. Only on the fifth call, with the anchor dropped, does
agent/src/tools/fs.ts:42 come back.
The copies arrived first and the source arrived last. Post 4’s grader:
05-moving-target/trace-retired-control.json
tools search_files > search_files > list_files > search_files > search_files
observations 77
answer success (recorded: success)
unread (none)
provenance undecidable (1 ground truth, 2 copies)
Both copies are the same line of post 4’s trace, returned by two different
searches. The run that could be graded grounded at v4 cannot be graded
grounded at v5, and the thing standing in the way is the recording of the
run that could.
The judge, nine times: grounded, grounded, grounded, grounded,
grounded, grounded, grounded, grounded, grounded. Mean confidence
0.92. Every one of them cites agent/src/tools/fs.ts:42, which was really
returned, in the file that really is the source. Every one of them passes the
citation check.
All ninety calls are in judgements.tsv,
one row per call, with each judge’s citation, confidence and one-sentence
reason in the last columns, so every count above is something you can recount
rather than something you have to take from me. judgements.test.ts recounts
them too, and it is worth being exact about what that covers, because the
first version of this sentence promised more than the test delivered: every
judge number on this page — both tables cell for cell, all ten mean
confidences, the 53 of 54, the 36 of 36 and the 27 of 27, the single
undecidable and its 0.65, the per-label means above, the citation checks and
the token totals — is recomputed from that file. If the prose and the file
drift apart, npm test says so.
It goes red
A grader that cannot fail is not a grader, and this project has shipped one of those before — post 1’s leak gate, which had never seen a real trace and would have reported success over a leak. So here is this one failing, five ways, none of which calls a model.
score.test.ts builds two judges out of thin air and scores them. A judge
that answers grounded to everything gets exactly the two controls whose
answer is grounded and no more. A judge that answers undecidable to
everything — the reply that sounds most careful, and the one I half expected
to get — scores zero. If the scorer could not tell those apart from a good
judge, none of the numbers above would mean anything.
verdict.test.ts puts six malformed replies through the parser, including
prose, an empty string and a verdict outside the vocabulary, and asserts each
one raises. It is important that it raises rather than returning
undecidable: a parse failure is the harness breaking, and a harness that
converts its own breakage into the most defensible-looking verdict available
would report a clean sweep of “I cannot tell” from an experiment that never
ran.
prompt.test.ts asserts the trace a judge sees does not carry the recorded
outcome, the run id or the commit, and that the question never says both,
never says cannot tell, and never states a preference.
And the renderer refuses a tool result whose shape it does not recognise,
rather than printing nothing. An unrecognised result would render as silence,
the judge would be shown a run that appears to have searched the project and
found it empty, and every verdict would come back unevidenced from a harness
that had stopped reading. Post 6 changes the tool set. That is the line that
will notice.
The fifth is the one I did not write deliberately, and it fired during this
post’s own review. The tally reader looks its columns up by name, and a
checkout of this repository on Windows serves that file with CRLF line
endings, which glued a carriage return to the last cell of every row. It threw
no "reason" column and stopped. The alternative — trimming quietly, or
skipping rows it could not read — would have turned ninety rows into a smaller
and much tidier tally, with every number in this post still printing. Fixed,
and tally.test.ts now reads the same rows out of both line endings.
What it cost
Ninety judge calls: 142,893 input tokens and 46,964 output tokens, both
counted per call in judgements.tsv. At the list price I used — $0.25 per
million input tokens and $2 per million output — that is about thirteen cents,
plus one agent run to record the trace above. The arithmetic is in the file
rather than only in this sentence, because the price is the one number here
that is guaranteed to be wrong by the time you read it.
It is the same model as the agent, deliberately. Not because it is the best judge available, but because it is the one that wrote the answers it is being asked to account for, and a model marking its own homework is the configuration most people actually ship. A stronger judge would have made these numbers better and this measurement less interesting, and it would have been a second variable in a post about the first one.
Yours will differ. That is the premise:
npm run judge -- --out /tmp/yours.tsv
npm run judge -- --prompt corpus-hit # what a judge is shown, no call made
What this does not fix
The thesis at the top of this page has two halves and they came out very differently.
The grader has to be a model too is true and was never really in question.
Nothing else can look at a trace and form a view about which of two identical
strings produced a sentence. Post 4’s grader cannot, and returned
undecidable three times rather than guess.
And then the grader needs grading is the half that earned its keep, and it earned it in the direction I was least hoping for. On the six cases where grading is possible this judge is 53 for 54. On the four where it is not, it is nine for nine for nine for nine — one label, no hesitation, high confidence, good citations, and not one claim that can be checked. If I had built only the second half of the panel I would have published a table of verdicts that looks exactly like a result.
There is no version of this prompt that fixes it that I would trust. I could
add a sentence telling the judge to prefer undecidable when both a source
and a copy came back — and then I would have a judge that reproduces post 4’s
deterministic rule, more expensively and less reliably, and I would have
written the answer into the question. That is the move this series has refused
in public twice and it is not available here either.
There is one thing about my own prompt I should name while I am here. Where
did the final answer come from? is a locative question, and the field beside
it is called citation and wants a file and a line. Both presuppose there is
an origin to be named. undecidable is on the list, in third position, with
nothing marking it unusable — but a question shaped like which one is not a
neutral instrument for an answer of neither can be established, and with one
prompt and ninety calls I cannot separate that from the model’s own
disposition. It is the first thing I would vary if this were an experiment
about judges rather than a post about needing to grade one. It does not rescue
this judge: the thirty-six verdicts still cannot be checked, and something has
to notice that.
What is available is saying what the instrument does: this one manufactures certainty on every case where certainty is unavailable, and the only reason I can tell you that is the six cases where it is not.
So series.ts is unchanged. Post 3 corrected its thesis because the diff
contradicted it; nothing here contradicts this one. It arrives with a number
attached, and the number is 36 of 36.
The commit
There are three graders in this repository now. One reads the final sentence
and cannot tell you when it is being generous. One reads the frames and says
undecidable when the frames do not settle it. The third reads the frames,
agrees with the second one almost everywhere it can be checked, and replaces
undecidable with grounded at 0.93 everywhere it cannot.
That is not a judge that is bad at its job. On everything with an answer it is
very nearly right, which is exactly what makes the other column dangerous: it
earns the trust on the half you can check and spends it on the half you
cannot. The measurement that separates them is the six cases with known
answers, it took ninety calls and thirteen cents, and without it I would have
a beautifully consistent table of thirty-six grounded verdicts and no way to
know what it was worth.
The agent has two tools. The next commit gives it four, and finds out what the fifth one costs.