|
| 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