RFC-005: Judge scorers and the judging pass¶
- Status: Completed (0.3.0); judge-scorer YAMLs hashed by content in 0.4.0 (RFC-001); judging modes (pick, pairwise) in 0.6.0
- Authors: Marko Dragoljevic
- Related PRD: PRD-003
- Related ADR: ADR-0001
- Related RFCs: RFC-002 (judged runs are derived runs)
- Code:
harness/scripts/judge_score.py(the pass),harness/scripts/judging.py(prompt, parser, judge client, rank statistics),harness/schemas/scorer.schema.json,judging-set.schema.json,verdict.schema.json; testharness/tests/test_judge_score.py
Abstract¶
A judge scorer re-measures frozen outputs with LLM judges, as a cross-run pass: one judging item is one source item with every arm's output for it, judged listwise and blind, and the verdicts are projected back to one score per arm per item, written as derived runs that every existing test reads. A gate refuses a judge until a committed run validates it against human ratings. This RFC holds the design that ADR-0001 recorded, and what its first uses found.
Problem Statement¶
The harness separates what a run produced from how it was measured, and rescore recomputes
code scorers for $0. In this repository, the summarisation eval ranks 29 arms by
coverage, its own overlap metric, while the SummEval eval measured that coverage agrees
with human experts at τ 0.1011, below plain ROUGE-1 (0.1487), and the best LLM judges at 0.5195,
93% of one expert's agreement with the other two. One eval had validated a better
instrument for another. A judge is not a code scorer, and four properties make rescore the wrong
home for it:
- it costs money and is not deterministic (hosted models at temperature 0 matched their own output 9.8% of the time across three repeats, summarisation report §3.3);
- it is a model, identified by a model id, a provider, a prompt and a protocol;
- its validity transfers only under the protocol it was validated with (SummEval: the article
and all its summaries in one call, 16 summaries, arms hidden), while
rescorecallsscore()once per output; - it can prefer its own family (when each hosted LLM wrote the reference summaries, 22 of 24 pushed their own family up the ranking, by 7.4 places on average).
Three kinds of re-measurement¶
| kind | what changes | cost | mechanism |
|---|---|---|---|
| new reference | what an output is scored against | $0 | evh rescore --reference-id … |
| new code scorer | the metric, as code | $0 | evh rescore with the new scorer |
| judge scorer | the metric, delivered by a model | billed | evh judge-score (this RFC) |
Design¶
The scorer artifact¶
data/scorers/<scorer_id>.yaml (scorer.schema.json): scorer_id (the file name),
kind: judge, dataset_id, judges (arm-style model specs, each with a vendor family;
at least two families), prompt, protocol, and optionally task, validation, arms_match
and cost_cap_usd. The task block names what an item is (source_label), what the arms wrote
(output_label), the dimensions and the scale; without it the scorer judges SummEval's
news-summary task (ARTICLE, SUMMARIES, coherence, consistency, fluency, relevance, 1–5). The
protocol sets outputs_per_call, blinding, order_seed, position_check_fraction,
noise_check_fraction and contested_threshold. The two check fractions are required, since the
controls are part of the pass (ADR-0001 §4): a scorer that omits either is refused, as is a
noise_check_fraction of 0, or a position_check_fraction of 0 when outputs_per_call is above 1
(a call that sees several outputs has an order that can move a verdict). Since 0.4.0 the YAML is
hashed by its parsed content, so a comment is not a new scorer.
The gate¶
A scorer runs only if its validation block names, for each judge, a committed run whose metric
equals the stated value (and is at least min_metric when set) and whose model, temperature,
reasoning, provider, max tokens, prompt file and prompt sha256 are the judge's. --unvalidated
runs it anyway and labels every number "unvalidated judge" (exit 3 without it). provider: fake
judges never pass. The gate checks that a validation exists and is current, not that it
transfers to the outputs being judged.
The pass (evh judge-score --dataset-id D --scorer S)¶
- Gate, as above.
- Judging set: one item is the source plus every arm's output for it (one run per arm, the
latest). Arm identities are hidden and their order shuffled per item with the seed. The arm
set, the outputs, the seed, the block size and a declared task are hashed into the set's id;
the manifest goes to
data/judging-sets/. A different arm set is a different set, never an append.--matchor--armschoose the arm set. - Blocks: with more arms than
outputs_per_call, an item is judged in the fewest blocks of at most that size, sizes as equal as possible (29 arms with 16 per call are 15 + 14). Arms are assigned to blocks by the per-item shuffle; the judge scores each output on an absolute scale per dimension, which is what lets blocks be averaged, an assumption the position check tests. - Verdicts: each judge reads each block and answers with JSON scores; each raw reply is
stored atomically under
data/judge-verdicts/, so a rerun skips what is stored (resume),--no-callsre-reads for $0, and--retry-unparsedcalls again for replies that did not parse. The cost cap (cost_cap_usd,EVAL_MAX_COST_USD) stops the pass mid-run with what was bought kept. - Controls: a second shuffle on
position_check_fractionof the items (it re-forms the blocks) measures position bias; a repeat onnoise_check_fractionmeasures the judge's own noise; each judge's agreement with the others is reported; an arm from a judge's own family is flagged (self_family_<judge>), with the raw own-minus-others gap, which is not a bias estimate since families differ in real quality. - Projection: one score per arm per item (the mean of the dimensions per judge;
judge_score, the primary, is the mean over judges), written as derived runs (derived.kind: judge) indata/runs-judged/<scorer_id>/, plus asummary.jsonwith the controls.EVAL_RUNS_DIR=data/runs-judged/<scorer_id>makes the leaderboard,pair-testandfamily-testread them.
--dry-run builds the judging set and prints calls, tokens and the estimated cost, calling
nothing. evh judge stays the older pointwise judge panel.
Judging modes (0.6.0)¶
What a judge is asked is the scorer's mode (owner, 2026-11-02). It lives in the scorer, not on the command line,
because a judge is validated in the mode it is used: a judge that grades like people says nothing about how it picks.
mode: grade(the default, everything above): each output gets its own score per dimension on the scale.mode: pick: the judge sees every arm's output for an item at once, blind and shuffled, and names the best ({"best": [3]}; several when they are equally best,{"best": [3, 7]}). An item is one call, so a pick scorer needsoutputs_per_callat least the number of arms; a pick made in blocks would name each block's winner, not the item's, so it is refused. Each arm's per-itemwonis 1 when it alone is best, 1/k when it shares the best with k − 1 others, else 0.mode: pairwise: the judge sees two outputs at a time and says which is better ({"better": 1},2or"tie"), for every pair of arms on every item (n(n − 1)/2 calls an item;--dry-runsays how many). Each arm's per-itemwonis its share of the item's pairs it won, a tie counting a half.
For pick and pairwise, won is the projected metric (judge_won, the mean over judges, is the primary), written as
judged runs exactly as grading's: goals, rounds, inspect and the paired tests read them unchanged. The controls
stay: the position check re-shuffles (pick) or swaps the two outputs (pairwise) and reports how often the verdict
holds; the noise check repeats calls; judges are compared with each other. The summary adds each arm's win rate
(its mean won) and Bradley-Terry strengths (Hunter 2004's MM fit over every pairwise outcome: a pick is the
winner beating each other output of its item), each with a 95% interval from resampling the items.
Validation in the mode. The scorer's validation.mode must be its mode. The SummEval example validates a pick
judge as it validates a grading one, as an arm on summeval_100: with mode: pick the judge picks the best of an
article's 16 summaries, scored top1 (1 when its pick is among the summaries the three experts rate best, by their
mean over the four dimensions) beside top1_chance (what a random pick gets: the share of best summaries); a pairwise
judge is scored pair_agreement on a seeded sample of pairs the experts rank apart. min_metric is the random arm's
value, so a judge no better than chance is refused.
What the first uses found¶
The first use was the summarisation eval, re-measured with judges validated on SummEval.
Validated on 2019-era summaries, the judges ranked every LLM above every BART model. The one human
check on these outputs went the other way (one non-expert reader, 20 pairs on truncated articles,
14 to 6 for BART, p = 0.115), which confirms neither. On three 2022 systems (GPT-3, BRIO and T0)
the judges matched the annotators' agreement with each other, and preferred the LLM summary to
BRIO's more often than the annotators did, within noise. Neither shows whether the validation
transfers to this eval's outputs; calibrate on comparable human data before a judged score
replaces a primary. Larger, dearer judges did not settle it either: the two highest-scoring
SummEval judges from vendors not in the first pair (judge-summ-v2, the same prompt, reasoning
off, at 41× the cost) reached the same verdict and the same 20-to-0 preference on the human-check
pairs. Checked against crowd ratings of LLM-era summaries (Zhang et al. 2024), all four judges
track the raters better than one rater does on coherence and relevance (they trail one on
faithfulness) and, by mean coherence and relevance, rank a BART-based fine-tune first or second of
19, so they show no blanket preference for LLM-written summaries; they still deviate from the
raters system by system in both directions. This eval's own outputs are still unchecked by
people beyond the one reader.
The second is SciFact: 227 relevance judgments cover 200 queries, so a relevant but unlabelled abstract ranked first counts against an arm. A judge can label those abstracts, but no judge here is validated on scientific relevance, so that run goes through the gate as "unvalidated" until a human-labelled sample validates it, the case the gate exists for.
Alternatives Considered¶
- Extend the judge panel. Pointwise on 1–5, not what SummEval validated; its verdicts are not frozen runs; it judges only finalists. Rejected as the vehicle; its cost abort and disagreement flag were reused.
- A judge inside
score(), run byrescore. Pointwise, which was not validated (SummEval's one pointwise scorer, UniEval, reached 64%), and it would hide paid, non-deterministic calls in a command documented as free. Rejected. - A new eval ("summarisation, judged"). Duplicates arms and outputs and loses the link to the runs that produced them. Rejected.
- One judge. Leaves self-preference unmeasured. Rejected.
- Human scoring of every output. The gold standard, with no budget for 200 × 29 summaries; a
20-pair human check was done (
examples/summarization-cnn-dailymail/human_check_judge.json), a larger one is open.
Consequences¶
- Listwise scores depend on the arm set; adding an arm needs a new judging pass.
- The validation transfers only partly (16 summaries per call from 2019-era systems against 29 current arms here, in blocks of 15 and 14), and a report must say so.
- Judging costs money and adds a noise source; it is capped and its noise is measured.
Testing¶
harness/tests/test_judge_score.py (the judging pass), evh validate V11 (judging sets), V12
(verdicts), V13 (judge scorers), and GUIDE step 10 offline with fake judges.