Skip to content

Commit bb55400

Browse files
feat(evals): preserve judge disagreement instead of averaging it away
The tri-judge panel existed to cancel single-judge self-preference, then collapsed its members to a mean. Scores of 0.9/0.9/0.9 and 0.2/0.9/1.6 average identically while describing opposite epistemic situations: one is agreement, the other an unresolved dispute. CI gated on that number treated both as equally trustworthy. consensus and averageScores are untouched, so the gate does not move. The dissent report is added alongside and says whether the number was worth gating on. Spread is the headline magnitude metric because it shares the scores' scale and bounds how far the mean misrepresents any single judge. Variance was rejected: squaring lets one distant judge dominate, conflating one judge out of step with a whole panel unsure. Rank information is kept separately as Kendall tau-b, since judges can agree on ordering while disagreeing on level and those are different failures. Spread cannot tell bimodal from uniform, so it is paired with a two-cluster split: equal clusters are a split with no majority, unequal ones are polarised. The dissenter is measured against the median, since the mean is dragged by the dissenter. A lone surviving judge reports as unverifiable, never as perfect agreement. Nobody disagreeing is not the same as agreement. detectDissentConvergence encodes the point that a scorer is itself a search artifact: a panel drifting toward agreement across runs is flagged as a possible overfit to what it cannot penalise, not as a win. Nothing calls it yet. Adopted for the argument, not for the source's numbers: that result is single-seed and short-side concentrated, and is not cited as support.
1 parent 54a7d63 commit bb55400

4 files changed

Lines changed: 875 additions & 9 deletions

File tree

src/infra/domain/evals/harness/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ export type { JudgeOptions, MockJudgeOptions } from "./trajectoryJudge.ts";
4949

5050
export { judgeTrajectoriesPanel, DEFAULT_PANEL } from "./panelJudge.ts";
5151
export type { PanelJudgeOptions } from "./panelJudge.ts";
52+
export type { PanelJudgeResultWithDissent } from "./panelJudge.ts";
53+
export { computePanelDissent, detectDissentConvergence } from "./panelDissent.ts";
54+
export type {
55+
JudgeScoreEntry,
56+
DissentPattern,
57+
PanelVerdict,
58+
AgreementMode,
59+
TrajectoryDissent,
60+
PanelDissentReport,
61+
PanelDissentOptions,
62+
DissentTrend,
63+
} from "./panelDissent.ts";
5264

5365
export {
5466
CATEGORY_RUBRICS,
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { describe, it, expect } from "bun:test";
2+
import {
3+
computePanelDissent,
4+
detectDissentConvergence,
5+
type PanelDissentReport,
6+
} from "./panelDissent.ts";
7+
import { judgeTrajectoriesPanel } from "./panelJudge.ts";
8+
import { buildMockJudgeClient } from "./trajectoryJudge.ts";
9+
import type { EvalScenario, EvalTrajectory, PanelJudgeEntry } from "./types.ts";
10+
11+
function traj(id: string): EvalTrajectory {
12+
return { id, messages: [{ role: "assistant", content: id }] };
13+
}
14+
15+
/** One judge's verdicts: [trajectoryId, score, explanation?] triples. */
16+
function judge(
17+
judgeModel: string,
18+
scores: ReadonlyArray<[string, number, string?]>,
19+
): PanelJudgeEntry {
20+
return {
21+
judgeModel,
22+
durationMs: 1,
23+
scored: scores.map(([id, score, explanation], i) => ({
24+
id,
25+
score,
26+
explanation: explanation ?? `${judgeModel} on ${id}`,
27+
rank: i + 1,
28+
})),
29+
};
30+
}
31+
32+
const SCENARIO: EvalScenario = {
33+
id: "dissent-test",
34+
tags: [],
35+
systemPrompt: "be helpful",
36+
userInput: "hello",
37+
};
38+
39+
describe("computePanelDissent", () => {
40+
it("gives the same mean for a unanimous and a split panel but a different verdict", () => {
41+
const unanimous = computePanelDissent(
42+
"s",
43+
[
44+
judge("anthropic/j", [["a", 0.9]]),
45+
judge("openai/j", [["a", 0.9]]),
46+
judge("google/j", [["a", 0.9]]),
47+
],
48+
[traj("a")],
49+
);
50+
const disputed = computePanelDissent(
51+
"s",
52+
[
53+
judge("anthropic/j", [["a", 0.2]]),
54+
judge("openai/j", [["a", 0.9]]),
55+
judge("google/j", [["a", 1.6]]),
56+
],
57+
[traj("a")],
58+
);
59+
60+
const u = unanimous.perTrajectory[0]!;
61+
const d = disputed.perTrajectory[0]!;
62+
expect(u.meanScore).toBe(0.9);
63+
expect(d.meanScore).toBe(0.9);
64+
65+
expect(u.pattern).toBe("consensus");
66+
expect(unanimous.verdict).toBe("safe_to_gate");
67+
expect(d.pattern).not.toBe("consensus");
68+
expect(disputed.verdict).toBe("unsafe_to_gate");
69+
expect(d.spread).toBeGreaterThan(u.spread);
70+
});
71+
72+
it("separates agreement on ordering from agreement on level", () => {
73+
// Same ordering (a > b > c), judges offset by a constant.
74+
const levelOnly = computePanelDissent(
75+
"s",
76+
[
77+
judge("anthropic/j", [["a", 0.9], ["b", 0.6], ["c", 0.3]]),
78+
judge("openai/j", [["a", 0.6], ["b", 0.3], ["c", 0.05]]),
79+
judge("google/j", [["a", 0.45], ["b", 0.2], ["c", 0.02]]),
80+
],
81+
[traj("a"), traj("b"), traj("c")],
82+
);
83+
expect(levelOnly.rankAgreement).toBe(1);
84+
expect(levelOnly.agreementMode).toBe("level_disagreement");
85+
86+
// Similar levels, contradictory orderings.
87+
const rankOnly = computePanelDissent(
88+
"s",
89+
[
90+
judge("anthropic/j", [["a", 0.9], ["b", 0.6], ["c", 0.3]]),
91+
judge("openai/j", [["a", 0.3], ["b", 0.6], ["c", 0.9]]),
92+
judge("google/j", [["a", 0.6], ["b", 0.9], ["c", 0.3]]),
93+
],
94+
[traj("a"), traj("b"), traj("c")],
95+
);
96+
expect(rankOnly.rankAgreement).toBeLessThan(0.5);
97+
expect(rankOnly.agreementMode).toBe("rank_disagreement");
98+
});
99+
100+
it("distinguishes a two-cluster panel from an evenly spread one at equal spread", () => {
101+
const polarised = computePanelDissent(
102+
"s",
103+
[
104+
judge("anthropic/j", [["a", 0.2]]),
105+
judge("openai/j", [["a", 0.25]]),
106+
judge("google/j", [["a", 0.9]]),
107+
],
108+
[traj("a")],
109+
);
110+
const dispersed = computePanelDissent(
111+
"s",
112+
[
113+
judge("anthropic/j", [["a", 0.2]]),
114+
judge("openai/j", [["a", 0.55]]),
115+
judge("google/j", [["a", 0.9]]),
116+
],
117+
[traj("a")],
118+
);
119+
120+
const p = polarised.perTrajectory[0]!;
121+
const q = dispersed.perTrajectory[0]!;
122+
expect(p.spread).toBe(q.spread);
123+
expect(p.pattern).toBe("polarised");
124+
expect(q.pattern).toBe("dispersed");
125+
expect(p.polarisation).toBeGreaterThan(q.polarisation);
126+
});
127+
128+
it("names the out-of-step judge and which way it leans", () => {
129+
const low = computePanelDissent(
130+
"s",
131+
[
132+
judge("anthropic/j", [["a", 0.85]]),
133+
judge("openai/j", [["a", 0.8]]),
134+
judge("google/j", [["a", 0.1]]),
135+
],
136+
[traj("a")],
137+
);
138+
expect(low.perTrajectory[0]!.dissenter).toEqual({
139+
judgeModel: "google/j",
140+
score: 0.1,
141+
direction: "below",
142+
deviation: 0.7,
143+
});
144+
145+
const high = computePanelDissent(
146+
"s",
147+
[
148+
judge("anthropic/j", [["a", 0.1]]),
149+
judge("openai/j", [["a", 0.15]]),
150+
judge("google/j", [["a", 0.9]]),
151+
],
152+
[traj("a")],
153+
);
154+
expect(high.perTrajectory[0]!.dissenter?.judgeModel).toBe("google/j");
155+
expect(high.perTrajectory[0]!.dissenter?.direction).toBe("above");
156+
});
157+
158+
it("keeps the losing position readable instead of discarding it", () => {
159+
const report = computePanelDissent(
160+
"s",
161+
[
162+
judge("anthropic/j", [["a", 0.85, "sizing is within budget"]]),
163+
judge("openai/j", [["a", 0.8, "acceptable plan"]]),
164+
judge("google/j", [["a", 0.1, "breaches the position limit"]]),
165+
],
166+
[traj("a")],
167+
);
168+
const d = report.perTrajectory[0]!;
169+
expect(d.minorityView.map((m) => m.judgeModel)).toEqual(["google/j"]);
170+
expect(d.minorityView[0]!.explanation).toBe("breaches the position limit");
171+
expect(d.majorityView.map((m) => m.judgeModel).sort()).toEqual(["anthropic/j", "openai/j"]);
172+
});
173+
174+
it("reports a lone surviving judge as unverifiable rather than perfect agreement", () => {
175+
const report = computePanelDissent(
176+
"s",
177+
[
178+
judge("anthropic/j", [["a", 0.9]]),
179+
{ judgeModel: "openai/j", scored: [], durationMs: 0, failed: { reason: "boom" } },
180+
{ judgeModel: "google/j", scored: [], durationMs: 0, failed: { reason: "boom" } },
181+
],
182+
[traj("a")],
183+
);
184+
expect(report.judgeCount).toBe(1);
185+
expect(report.verdict).toBe("unverifiable");
186+
expect(report.safeToGate).toBe(false);
187+
expect(report.agreementMode).toBe("unverifiable");
188+
expect(report.perTrajectory[0]!.pattern).toBe("unverifiable");
189+
expect(report.reasons.join(" ")).toContain("anthropic/j");
190+
});
191+
192+
it("reports an empty panel as unverifiable", () => {
193+
const report = computePanelDissent("s", [], []);
194+
expect(report.judgeCount).toBe(0);
195+
expect(report.verdict).toBe("unverifiable");
196+
expect(report.safeToGate).toBe(false);
197+
});
198+
199+
it("does not call two judges far apart a consensus just because the mean sits between them", () => {
200+
const report = computePanelDissent(
201+
"s",
202+
[judge("anthropic/j", [["a", 0.1]]), judge("openai/j", [["a", 0.9]])],
203+
[traj("a")],
204+
);
205+
const d = report.perTrajectory[0]!;
206+
expect(d.meanScore).toBe(0.5);
207+
expect(d.pattern).toBe("split");
208+
expect(report.verdict).toBe("unsafe_to_gate");
209+
expect(report.safeToGate).toBe(false);
210+
});
211+
212+
it("produces identical output for identical input on every call", () => {
213+
const build = () =>
214+
computePanelDissent(
215+
"s",
216+
[
217+
judge("anthropic/j", [["a", 0.2], ["b", 0.7]]),
218+
judge("openai/j", [["a", 0.9], ["b", 0.1]]),
219+
judge("google/j", [["a", 0.55], ["b", 0.4]]),
220+
],
221+
[traj("a"), traj("b")],
222+
);
223+
const first = JSON.stringify(build());
224+
// A clock-dependent or randomised statistic would drift across repeats.
225+
for (let i = 0; i < 25; i += 1) {
226+
expect(JSON.stringify(build())).toBe(first);
227+
}
228+
});
229+
});
230+
231+
describe("detectDissentConvergence", () => {
232+
it("flags a panel drifting toward agreement over successive runs", () => {
233+
const history = [0.6, 0.5, 0.4, 0.2].map(
234+
(maxSpread) => ({ maxSpread }) as PanelDissentReport,
235+
);
236+
const trend = detectDissentConvergence(history);
237+
expect(trend.n).toBe(4);
238+
expect(trend.slope).toBeLessThan(0);
239+
expect(trend.converging).toBe(true);
240+
});
241+
242+
it("does not flag a stable panel", () => {
243+
const history = [0.3, 0.31, 0.29, 0.3].map(
244+
(maxSpread) => ({ maxSpread }) as PanelDissentReport,
245+
);
246+
expect(detectDissentConvergence(history).converging).toBe(false);
247+
});
248+
});
249+
250+
describe("judgeTrajectoriesPanel dissent field", () => {
251+
it("leaves the consensus scores untouched while adding the dissent report", async () => {
252+
const client = buildMockJudgeClient({
253+
responses: { "dissent-test": [{ id: "a", score: 0.5 }, { id: "b", score: 0.5 }] },
254+
byModel: {
255+
"anthropic/test": {
256+
"dissent-test": [{ id: "a", score: 0.9 }, { id: "b", score: 0.2 }],
257+
},
258+
"openai/test": {
259+
"dissent-test": [{ id: "a", score: 0.8 }, { id: "b", score: 0.3 }],
260+
},
261+
"google/test": {
262+
"dissent-test": [{ id: "a", score: 0.7 }, { id: "b", score: 0.4 }],
263+
},
264+
},
265+
});
266+
const result = await judgeTrajectoriesPanel(
267+
{ scenario: SCENARIO, trajectories: [traj("a"), traj("b")] },
268+
{ client, panel: ["anthropic/test", "openai/test", "google/test"] },
269+
);
270+
271+
// Pinned to the values the pre-dissent averaging produced, so a change to
272+
// the CI gate's number cannot slip through this module.
273+
expect(result.consensus.map((c) => ({ id: c.id, score: c.score, rank: c.rank }))).toEqual([
274+
{ id: "a", score: 0.8, rank: 1 },
275+
{ id: "b", score: 0.3, rank: 2 },
276+
]);
277+
expect(result.dissent.judgeCount).toBe(3);
278+
expect(result.dissent.rankAgreement).toBe(1);
279+
expect(result.dissent.perTrajectory.map((d) => d.trajectoryId)).toEqual(["a", "b"]);
280+
for (const d of result.dissent.perTrajectory) {
281+
expect(d.meanScore).toBe(result.consensus.find((c) => c.id === d.trajectoryId)!.score);
282+
}
283+
});
284+
285+
it("reports a dropped-judge panel with the survivors it actually had", async () => {
286+
const client = buildMockJudgeClient({
287+
responses: { "dissent-test": [{ id: "a", score: 0.7 }, { id: "b", score: 0.3 }] },
288+
throwForModel: "openai/test",
289+
});
290+
const result = await judgeTrajectoriesPanel(
291+
{ scenario: SCENARIO, trajectories: [traj("a"), traj("b")] },
292+
{ client, panel: ["anthropic/test", "openai/test", "google/test"] },
293+
);
294+
expect(result.quorum).toBe(2);
295+
expect(result.dissent.judgeCount).toBe(2);
296+
expect(result.dissent.perTrajectory[0]!.perJudge.map((j) => j.judgeModel)).toEqual([
297+
"anthropic/test",
298+
"google/test",
299+
]);
300+
expect(result.dissent.verdict).toBe("safe_to_gate");
301+
});
302+
303+
it("reports no judges as unverifiable when the whole panel fails", async () => {
304+
const client = buildMockJudgeClient({ responses: {}, throwOnCall: true });
305+
const result = await judgeTrajectoriesPanel(
306+
{ scenario: SCENARIO, trajectories: [traj("a"), traj("b")] },
307+
{ client, panel: ["x/1", "x/2", "x/3"] },
308+
);
309+
expect(result.quorum).toBe(0);
310+
expect(result.dissent.verdict).toBe("unverifiable");
311+
});
312+
});

0 commit comments

Comments
 (0)