Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Position bias, self-preference and panels

Position bias, self-preference and the same-family rule — how a judging setup stops a model from grading its own family's work.

ExplanationLast updated

On this page

Ask a model which of two answers is better and it answers. It will not tell you that it picked the one it saw first, or the one written in a house style it recognises as its own. Those are preferences about presentation rather than about quality, and they come back looking identical to a considered judgement.

Nothing downstream can separate the two. The verdict parses, the row is written, the pass rate moves, and no error is raised for a monitor to catch. Run that judge across four thousand rows and its preference is inside your quality figure, at full strength, carrying the figure’s authority.

Three things in a judging setup work against that, and this page is about what each one costs and where each one stops working. How a judge call happens is the mechanism underneath. There is no configuration on this page; the config surface is Configure a judge.

A judge that got 258 of 314 pairs wrong

Before any mechanism, here is the failure measured rather than described.

This build vendors LLMBar, a public 419-pair meta-evaluation corpus. Each pair carries a human preference between two answers to one instruction, and its four adversarial subsets are built so that the more attractive-looking answer is the wrong one. The product’s benchmark harness drives scripted judges over every pair, through the shipped judge transport, over HTTP to a listener on the same machine.

$ EVALPROG_BENCHMARK_REALRUN_DIR=./llmbar-evidence \
    go test -count=1 ./internal/evalprog/ -run TestRealRunLLMBarAgreement
ok  	github.com/tokentriage/platform/internal/evalprog	2.371s
$ cat ./llmbar-evidence/JG4-03-realrun-transcript.txt
JG4-03 REAL RUN — LLMBar meta-eval agreement, pointwise + pairwise
run clock            2026-08-22T13:00:00Z
corpus               vendored third_party/llmbar (MIT, LLMBar/LICENSE:1,3 read on disk 2026-08-22)
judge transport      REAL *shadow.Judge over REAL HTTP to a loopback httptest endpoint
model behind socket  a scripted RULE, not a vendor model — no egress, no credential
MODE                 FULL: every imported pair judged
[…5 corpus-import lines, 2 lines of ledger and body-store counts, and the 30-row per-subset table elided…]
HAND-DERIVABLE EXPECTATIONS (each a property of the RULE, not of the corpus):
  first-slot/pairwise        pairs=419 graded=  0 abstained=419 undecided=  0 calls= 838  agreement=undefined
  first-slot/pointwise-bool  pairs=419 graded=  0 abstained=  0 undecided=419 calls= 838  agreement=undefined
  gold-reading/pairwise      pairs=419 graded=419 abstained=  0 undecided=  0 calls= 838  agreement=1.0000 (419/419)
  gold-reading/pointwise-bool pairs=419 graded=419 abstained=  0 undecided=  0 calls= 838  agreement=1.0000 (419/419)
  length/pairwise            pairs=419 graded=409 abstained= 10 undecided=  0 calls= 838  agreement=0.2714 (111/409)
  length/pointwise-bool      pairs=419 graded=113 abstained=  0 undecided=306 calls= 838  agreement=0.2124 (24/113)
  first-slot/pairwise abstained on 419/419 — a flip is an abstention, never a coerced winner, and the reading's rate is UNDEFINED rather than 0.0
  first-slot/pointwise-bool undecided on 419/419 — two identical gradings are not a tie and are OUT of the denominator
  gold-reading/pairwise agreed on 419/419 — the plumbing loses nothing and inverts nothing
  length/pairwise natural=0.5789 (55/95) vs adversarial=0.1783 (56/314) — separation +0.4006

EVERY READING ABOVE IS `benchmark_agreement` AND LICENSES NOTHING.
  benchmark_agreement is a PRE-SCREEN and a prompt-regression check over a public meta-evaluation corpus. It is NOT domain validation: it licenses no promotion, no ceiling, no `measured` basis, and no verdict about this deployment's traffic. Leaderboard validity is not your-traffic validity.

The elision is marked and drops only per-subset detail that the totals below it already carry. Two things in that block belong to the run rather than to the corpus: the elapsed 2.371s, and the evidence directory named on the command line, which is where the harness wrote that transcript. Everything else is a constant in the harness or arithmetic over the vendored corpus, so the same two commands give you the same table twice. The whole corpus is scanned only when that directory is set; without it each subset is bounded to 20 pairs and every count is smaller.

Four things in there are worth reading slowly.

The judges are the fixture. The corpus is not. Three scripted rules answered from a loopback socket, so no vendor model was asked, no credential was used, and no rate above is a claim about anybody’s model. LLMBar’s pairs and their human preferences are the published corpus, and the transport, the ledger writes, the body store and the frame resolution are the product’s own.

gold-reading is the control. A rule that answers from the corpus’s own gold agrees on 419 of 419 under both methods. The plumbing loses nothing and inverts nothing, which is what makes the other two rows readable as findings about the rules.

length is the failure, and it is quiet. Its rule is one line: prefer the longer response. On the natural subset it matches the human preference on 55 of 95 decided pairs. On the four adversarial subsets, where the longer answer is built to be the wrong one, it matches on 56 of 314, so 258 pairs came back with a confident winner a person would not have picked. Ten abstained and none failed. There is no error anywhere in that column.

first-slot is the same failure with the preference this page is named for. Its rule is to name whichever response is presented first. Under pairwise the build shows the pair twice, once each way, so the rule names one side the first time and the other side the second, on every pair: abstained=419. Presented once, it would have swept the first slot 419 times and read as a judge with firm opinions.

agreement=undefined on those two rows is the part most often misread. The rate travels as a pointer, and it comes back null when the denominator is zero.

{
  "reading": "benchmark_agreement",
  "benchmark": "llmbar",
  "subset": "natural",
  "method": "pairwise",
  "method_version": "3a35654",
  "model": "loopback-scripted-judge",
  "pairs_scanned": 100,
  "graded": 0,
  "agreed": 0,
  "disagreed": 0,
  "abstained": 100,
  "undecided": 0,
  "skipped_no_body": 0,
  "failed": 0,
  "judge_calls": 200,
  "rate": null
}

That is the first-slot judge’s reading over the natural subset, written by the same run, with the two trailing keys dropped: a long disclaimer string and the ran_at clock. Every key shown is the run’s own and no value was changed. rate: null records that no pair produced a preference, which is UNDEFINED and is not the claim 0.0. abstained: 100 beside it says why, and judge_calls: 200 says the judge was asked twice per pair to get there.

Two orderings, and a verdict derived from both

A positional preference has one property the others lack: it is a function of the order, so reversing the order reverses it. That is what the swap gate uses. The judge is not asked to be fair. It is asked twice, and the two answers are compared with each other.

The drawing has the whole method on it, including the leaf that turns it on.

One pairwise trial, run in both orderings, and the two ways it can endOne pair enters: side A is the chosen model's answer and side B is the duplicate's. The judge is asked twice inside a single grading call — once with A presented first, once with B presented first — and each call answers with a winner named in the same frame, so the two answers are directly comparable. The join compares them. When both orderings name the same side the trial has a winner, and two ties count as agreement. When they disagree the trial has no winner at all: it abstains with the reason swap_inconsistent, records agree as false, and the flip is counted into the swap set rather than averaged into a pass rate. A pair carrying only one ordering cannot be built — the constructor takes both winners, so a flip rate always has a denominator that describes the experiment. This trial runs on the shadow-duplicate plane, turned on with shadow.duplicate.pairwise; the same method name on an evals-plane judge transport is turned away at validate and again at boot. That plane dials a compiled-in provider address that no shadow.duplicate key moves, so it needs outbound traffic. At validation time the swap set gates promotion for pairwise use: thirty complete pairs, and a flip rate at or under nought point one.the shadow-duplicate plane — shadow.duplicate.pairwise: trueone pairA = the chosenmodel's answerB = the duplicate'scall 1 — A shown firstthe judge names a sidewinner_abcall 2 — B shown firstthe same pair, order reversedwinner_baBoth calls happen inside ONE grading call, and both winners namethe same two sides, so they compare directly. A one-ordering paircannot be built: the constructor takes both.The plane dials a compiled-in provider address that no shadow.duplicatekey moves, so this trial needs outbound traffic. The same method nameon an evals-plane judge transport is turned away at validate and boot.join — do the two orderings agree?the verdict is derived from both, never suppliedthey agree → a winnerwinner: A · B · TIE · agree: truetwo ties are agreement, not a missing answerthey flip → an abstentionabstain_reason: swap_inconsistentagree: false · no winner is writtencounted into the swap set, not averaged into a rateWhat the flips are forThe ledger row carries pairwise.prompt_sha, winner_ab, winner_ba and agree. At validation the swap set gatespromotion for pairwise use: SwapSetMinPairs 30 complete pairs, SwapFlipCeiling 0.10 flips per pair.The co-run is never wrapped in a k-sample vote — a voted flip rate would understate position bias.

Read it left to right. One pair goes in, side A being the answer the router served and side B the duplicate’s. Two calls go out inside one grading call, with the presentation order reversed between them, and both answers name the same two sides so they compare directly. The join is the method: the same side both times and the trial has a winner, with two ties counting as agreement; different sides and the trial has no winner at all, abstains with swap_inconsistent, and the flip is counted into the swap set rather than averaged into a rate. The label along the top names the plane this trial runs on, and the following section is about what that plane costs you.

What it costs. Two provider calls for every judged pair, both charged against the one daily cap on shadow.duplicate. And a pair that flips yields no verdict, so a judge with a positional preference answers fewer pairs than you gave it. That is the trade the method makes: a smaller denominator you can describe, instead of a larger one you cannot.

A pair run in one ordering has no representation. The type carrying a pairwise verdict takes both winners or nothing, so a half-run pair cannot be built at all. The validation walk turns away a swap set carrying one-ordering pairs by name, before it looks at any rate, because a flip rate over half-run pairs has a denominator that does not describe the experiment.

What the flips are for. They are the swap set, and the validation walk gates pairwise promotion on it: at least 30 complete pairs, and a flip rate at or under 0.10. Above that ceiling the subject stays reported for pairwise use, and the record names the pairs that flipped rather than only printing the rate. The walk that promotes a judge covers the whole ordered check, and notes that a pointwise-bool judge prints those two floors and reaches neither.

How it fails. It measures position and nothing else. A judge that prefers its own family’s prose prefers it in both orderings, so it agrees with itself, its flip rate reads 0.0, and the gate passes it with the best result available on that axis. A clean swap set is evidence about position and about nothing else. Self-preference is handled somewhere else entirely, which is the section after next.

The plane pairwise runs on

That recommendation arrives with a condition attached, and the condition belongs here rather than in a note at the end.

judge.method: pairwise under evals.evaluators[].judge is turned away at tokentriage validate and again at boot, by name. Two configurations compose over that transport in this build: pointwise-bool, which is what an absent method: reads as, and panel at self_consistency_k: 1. The both-orderings trial reaches production through shadow.duplicate.pairwise, which is a different leaf on a different plane.

The shadow-duplicate judge dials a compiled-in provider URL and no shadow.duplicate.* key moves it, so that plane needs egress. The evals-plane judge takes evals.evaluators[].judge.endpoint_env and can be pointed at a loopback listener, which is the path to use for a rehearsal with no outbound traffic. So the plane that composes the swap trial is the one you cannot rehearse locally, and the plane you can rehearse locally is the one that turns the method away.

That has a consequence for the evidence behind this page, and it is worth stating plainly. runPairwise is called and fed from the both-orderings trials the shadow judge recorded on the ledger; what has been exercised is a planted ledger under test, and no capture drives a live pairwise judge end to end. The missing live pairwise capture is structural in this build rather than merely undone: the shadow plane is the only one that composes pairwise, and its judge transport cannot be pointed at a loopback host. Turning the swap gate on therefore costs provider traffic from the first pair, with no free rehearsal ahead of it.

The benchmark at the top of this page is the nearest thing to a rehearsal that runs with the network unplugged. It exercises the same method against the same transport type, and it runs inside the product’s own test binary rather than from a shipped verb.

The same-family rule

Symmetry cannot cancel a family preference, because a judge that likes its own family’s prose likes it in both orderings. So this one is settled before any traffic arrives, at configuration time.

A judge model’s family must differ from the family of every configured tier’s model. Every tier is a possible generator, because config cannot know which one the router will serve on the row this judge will later grade, so the judge has to differ from all of them. The family is the product line rather than the size, so claude-haiku-4-5 and claude-opus-4-8 are one family. The vendor path segment is stripped first, so anthropic/claude-haiku-4-5 and claude-haiku-4-5 land on the same answer and a spelling difference cannot open a hole.

Here is the shipped examples/evals/journey-2-validated-judge.yaml example with one leaf changed, the judge model moved from gemini-2.5-flash to claude-haiku-4-5, which is the family the frontier tier serves.

$ tokentriage validate --config W26-same-family.yaml
Error: configuration is invalid: config D:/tt-jg-scratch/cfg/W26-same-family.yaml: evals.evaluators[1].judge.model: names a model from the SAME FAMILY as tiers[1].model. B.7 refuses a same-family judge/generator pair — a model scoring its own family's output measures self-preference as well as quality (arXiv 2508.09724), and a judge that shares a generator's failure modes cannot witness them. Every configured tier is a possible generator for this judge, so its family must differ from ALL of them; the family is the product line (haiku and opus are one family), not the size. Name a judge from a different family. Neither value is repeated back here — read the two lines this message points at
Error: configuration is invalid: config D:/tt-jg-scratch/cfg/W26-same-family.yaml: evals.evaluators[1].judge.model: names a model from the SAME FAMILY as tiers[1].model. B.7 refuses a same-family judge/generator pair — a model scoring its own family's output measures self-preference as well as quality (arXiv 2508.09724), and a judge that shares a generator's failure modes cannot witness them. Every configured tier is a possible generator for this judge, so its family must differ from ALL of them; the family is the product line (haiku and opus are one family), not the size. Name a judge from a different family. Neither value is repeated back here — read the two lines this message points at
(exit 1)

The message really does arrive twice. tokentriage validate prints every error twice on stderr and every warning twice on stdout, on this build, and editing one copy out of a capture would be inventing an output the binary does not produce. The sibling verb splits them differently: tokentriage evals validate puts its config warnings on stderr (cmd/tokentriage/evals.go:250), so a pipeline that quiets one verb does not quiet the other.

The scratch path is this run’s, and evals.evaluators[1] is that config’s own index; yours will be your own. Nothing else in the message varies, and neither model id is echoed back, which is why a config carrying a pasted secret in model: can still be debugged from the error. tokentriage run stops the same file at boot with the same sentence, so there is no window where a config validates and then judges anyway.

Every panel seat is checked the same way, and an unreadable family stops the panel. The rule extends to each member of a panel rather than to the entry’s top-level judge model alone. A seat whose family matches a tier gets the same message with the member’s index in it. A seat whose family cannot be read gets a different one, and it goes the other way from the check above.

$ tokentriage validate --config W26-panel-unknown.yaml
Error: configuration is invalid: config D:/tt-jg-scratch/cfg/W26-panel-unknown.yaml: evals.evaluators[1].judge.panel.members[2].model: no readable model family, so this member can never be shown to differ from the generator's — and §2.1 refuses a panel it cannot prove is cross-family. UNKNOWN is refused, not admitted (JH7): "we could not tell" is not "it is fine". The composition site refuses the same member, so this is the boot-time twin of a refusal that would otherwise fire per request. The value you wrote is deliberately not repeated back in this message
[…the same line printed a second time, elided; `validate` doubles every error…]
(exit 1)

The member model in this probe was 4o, which has no leading alphabetic run to read a family from. That id is left out of the message, which is the same disclosure discipline as the capture before it, and it is why both of these can be published with the configs that produced them.

Two layers, and they answer “we could not tell” in opposite directions. Config validation is fail-open on a top-level judge model whose family cannot be read, because validation may not turn away a config the engine would accept. The per-row check that runs before each grading call is fail-closed on the same input: it decides one concrete pair, it cannot see the tier table, and it stops a row whose generator the ledger cannot name. Panel members are fail-closed at both layers, which is the capture above. Rows stopped that way are counted rather than dropped, under same_family_skips on the shadow plane’s periodic summary.

How it fails. It compares model ids as they are written. An upstream that resolves one id to a different model, a gateway alias, or a tier pointed at a router of its own all read as a different family to the config check, and the file cannot see past them. The per-row check is the stronger half, because it reads the generator the ledger recorded rather than the tier table, and it is the half that fires on the traffic you served.

Panels, and why a second family

The two controls above each catch one preference. A panel goes at the problem from the other side: if several judges from unrelated model families reach the same verdict, the errors they share are the ones that survive, and there are fewer of those than any single judge carries alone.

A panel is one instrument with several seats. It votes with all of its members or it does not vote, so a seat that cannot be reached stops the whole invocation rather than being quietly dropped from the count, and a split with no strict majority is an abstention carrying panel_split rather than a coin toss. The floors are three seats and at least two distinct families, and every seat pays its own provider call under the single daily cap the evaluator declares. Composing one, reading the vote vector, and pricing the multiplier before you turn it on are Run a cross-family panel.

What these controls do not cover

A preference that is neither position nor family. The length rows at the top of this page are a third kind, and no floor in the validation walk reads verbosity. What catches it is the same thing that caught it above: human labels on your own traffic, compared against the judge, which is the walk that promotes a judge.

A judge that is consistent and consistently wrong. A flip rate of 0.0 says the two orderings agreed, and a judge can agree with itself all day about the wrong answer. The swap gate is a precondition for a pairwise promotion, not a promotion.

Abstentions, and what a judging setup does with them. A flip produces an outcome with a named reason rather than a missing value, and swap_inconsistent is one of four such reasons with four different producers. When a judge can’t tell has the set, and says which of the four an operator can reach from a config file.

Anything about a judge after it is promoted. No shipped binary can fire a drift demotion, so a swap set that was clean at promotion time stays the last thing anybody measured about that judge’s position sensitivity until you run the check again. Judge drift, with no monitor between checks is that story.