Files
waggle-os/packages/server/tests/benchmarks/failure-mode-judge.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

341 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Failure-mode judge — unit tests.
*
* Brief: PM-Waggle-OS/briefs/2026-04-20-cc-preflight-prep-tasks.md Task 4
* Module: packages/server/src/benchmarks/judge/failure-mode-judge.ts
*
* Coverage:
* - Valid JSON parse for all 5 failure modes (F1..F5) + the correct verdict.
* - Invalid-JSON → retry succeeds.
* - Invalid-JSON on both attempts → JudgeParseError.
* - 4-judge ensemble: 4-0 unanimous, 3-1 majority, 2-2 tie broken by
* the first model in `judgeModels` (Sonnet by convention).
* - Fleiss' kappa on hand-crafted 4×10 matrices with values computed by
* hand and verified against the formula (κ=1 for unanimous two-cluster,
* κ≈0.1111 for a known mixed matrix — both within ±0.01 tolerance).
*
* No network. No LLM calls. Pure unit tests against mock LlmClients.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
buildJudgePrompt,
computeFleissKappa,
extractJsonBody,
judgeAnswer,
judgeEnsemble,
JudgeParseError,
RETRY_REMINDER,
type FailureMode,
type JudgeResult,
type LlmClient,
type Verdict,
} from '../../src/benchmarks/judge/failure-mode-judge.js';
// ── Test helpers ───────────────────────────────────────────────────────
class ScriptedLlmClient implements LlmClient {
readonly calls: string[] = [];
private readonly queue: Array<string | Error>;
constructor(responses: Array<string | Error>) {
this.queue = [...responses];
}
async complete(prompt: string): Promise<string> {
this.calls.push(prompt);
if (this.queue.length === 0) throw new Error('ScriptedLlmClient out of responses');
const next = this.queue.shift()!;
if (next instanceof Error) throw next;
return next;
}
}
function mkResult(verdict: Verdict, failure_mode: FailureMode | null, rationale: string, judge_model: string): JudgeResult {
return { verdict, failure_mode, rationale, judge_model };
}
// ── Prompt shape ───────────────────────────────────────────────────────
describe('buildJudgePrompt', () => {
it('interpolates all four required variables verbatim', () => {
const prompt = buildJudgePrompt({
question: 'When did Caroline go to the support group?',
groundTruth: '7 May 2023',
contextExcerpt: 'Caroline: I went to a LGBTQ support group yesterday.',
modelAnswer: 'Unclear.',
});
expect(prompt).toContain('## Question\nWhen did Caroline go to the support group?');
expect(prompt).toContain('## Ground-truth answer\n7 May 2023');
expect(prompt).toContain('## Ground-truth supporting context');
expect(prompt).toContain("Caroline: I went to a LGBTQ support group yesterday.");
expect(prompt).toContain("## Model's answer\nUnclear.");
// Decision tree sanity — verifies the exact §4 text didn't drift.
expect(prompt).toContain('→ F1 (ABSTAIN)');
expect(prompt).toContain('→ F5 (OFF-TOPIC)');
expect(prompt).toContain('→ F4 (HALLUCINATED)');
expect(prompt).toContain('→ F2 (PARTIAL)');
expect(prompt).toContain('→ F3 (INCORRECT)');
});
});
// ── JSON extraction ────────────────────────────────────────────────────
describe('extractJsonBody', () => {
it('returns bare JSON unchanged', () => {
expect(extractJsonBody('{"verdict":"correct"}')).toBe('{"verdict":"correct"}');
});
it('strips markdown fences with `json` hint', () => {
const raw = '```json\n{"verdict":"correct"}\n```';
expect(extractJsonBody(raw)).toBe('{"verdict":"correct"}');
});
it('strips bare markdown fences', () => {
const raw = '```\n{"verdict":"incorrect","failure_mode":"F3"}\n```';
expect(extractJsonBody(raw)).toBe('{"verdict":"incorrect","failure_mode":"F3"}');
});
it('extracts the JSON object from prose', () => {
const raw = 'Here is my verdict: {"verdict":"correct","failure_mode":null,"rationale":"ok"}';
expect(extractJsonBody(raw)).toBe('{"verdict":"correct","failure_mode":null,"rationale":"ok"}');
});
it('returns null when no object is present', () => {
expect(extractJsonBody('I cannot comply.')).toBeNull();
});
});
// ── judgeAnswer — happy path + failure modes ───────────────────────────
describe('judgeAnswer — valid JSON parse for all 5 failure modes + correct', () => {
const cases: Array<{ name: string; verdict: Verdict; failure_mode: FailureMode | null; rationale: string }> = [
{ name: 'correct', verdict: 'correct', failure_mode: null, rationale: 'All facts match the ground truth.' },
{ name: 'F1 abstain', verdict: 'incorrect', failure_mode: 'F1', rationale: 'Model explicitly refused to answer.' },
{ name: 'F2 partial', verdict: 'incorrect', failure_mode: 'F2', rationale: 'Model stated 2 of 3 required facts.' },
{ name: 'F3 incorrect', verdict: 'incorrect', failure_mode: 'F3', rationale: 'Model stated a wrong date derived from context.' },
{ name: 'F4 hallucinated', verdict: 'incorrect', failure_mode: 'F4', rationale: 'Model named a person not in the context.' },
{ name: 'F5 off-topic', verdict: 'incorrect', failure_mode: 'F5', rationale: 'Model answered a different question.' },
];
for (const c of cases) {
it(`parses ${c.name} and stamps the judge_model`, async () => {
const payload = JSON.stringify({ verdict: c.verdict, failure_mode: c.failure_mode, rationale: c.rationale });
const client = new ScriptedLlmClient([payload]);
const result = await judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'claude-sonnet-4-6', llmClient: client,
});
expect(result.verdict).toBe(c.verdict);
expect(result.failure_mode).toBe(c.failure_mode);
expect(result.rationale).toBe(c.rationale);
expect(result.judge_model).toBe('claude-sonnet-4-6');
expect(client.calls).toHaveLength(1);
});
}
});
describe('judgeAnswer — retry semantics', () => {
const validPayload = JSON.stringify({ verdict: 'correct', failure_mode: null, rationale: 'All facts match.' });
it('on invalid JSON, retries once with the reminder and returns the retry result', async () => {
const client = new ScriptedLlmClient([
'Sorry, I cannot produce structured output — here is a paragraph.',
validPayload,
]);
const result = await judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'claude-sonnet-4-6', llmClient: client,
});
expect(result.verdict).toBe('correct');
expect(client.calls).toHaveLength(2);
// Retry prompt must begin with the exact reminder text from the spec.
expect(client.calls[1].startsWith(RETRY_REMINDER)).toBe(true);
});
it('on invalid JSON twice, throws JudgeParseError with the raw response attached', async () => {
const client = new ScriptedLlmClient([
'Still refusing to produce JSON.',
'Nope, same here.',
]);
await expect(
judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'gpt-5', llmClient: client,
}),
).rejects.toBeInstanceOf(JudgeParseError);
try {
await judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'gpt-5', llmClient: new ScriptedLlmClient(['bad1', 'bad2']),
});
} catch (e) {
expect(e).toBeInstanceOf(JudgeParseError);
const err = e as JudgeParseError;
expect(err.judgeModel).toBe('gpt-5');
expect(err.lastResponse).toBe('bad2');
expect(err.lastParseError).toBeTruthy();
}
});
it('rejects schema-valid JSON that violates the verdict/failure_mode invariant', async () => {
// verdict=correct with a non-null failure_mode — Step-3 contract violation.
const bad = JSON.stringify({ verdict: 'correct', failure_mode: 'F4', rationale: 'contradictory' });
// Both attempts return the same bad shape — should throw.
const client = new ScriptedLlmClient([bad, bad]);
await expect(
judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'gemini-pro', llmClient: client,
}),
).rejects.toBeInstanceOf(JudgeParseError);
});
it('accepts fenced JSON in the first attempt (no retry)', async () => {
const fenced = '```json\n' + JSON.stringify({ verdict: 'incorrect', failure_mode: 'F3', rationale: 'Wrong date.' }) + '\n```';
const client = new ScriptedLlmClient([fenced]);
const result = await judgeAnswer({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModel: 'haiku', llmClient: client,
});
expect(result.failure_mode).toBe('F3');
expect(client.calls).toHaveLength(1);
});
});
// ── judgeEnsemble — 4-judge aggregation ────────────────────────────────
describe('judgeEnsemble — 4-judge aggregation', () => {
function mkClientWith(verdict: Verdict, failure_mode: FailureMode | null, rationale: string): LlmClient {
return new ScriptedLlmClient([JSON.stringify({ verdict, failure_mode, rationale })]);
}
const models = ['claude-sonnet-4-6', 'claude-haiku-4-5', 'gpt-5', 'gemini-pro'];
it('4-0 unanimous → majority matches the unanimous verdict', async () => {
const clients = new Map<string, LlmClient>();
for (const m of models) clients.set(m, mkClientWith('correct', null, 'match'));
const result = await judgeEnsemble({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModels: models, llmClients: clients,
});
expect(result.ensemble).toHaveLength(4);
expect(result.majority.verdict).toBe('correct');
expect(result.majority.failure_mode).toBeNull();
// κ = 1 when every rater agrees on the same class (and all 6 classes
// contribute 0 or 1 to the marginals → expected = observed = 1).
expect(result.fleissKappa).toBeCloseTo(1, 6);
});
it('3-1 majority → majority verdict wins, minority is recorded in ensemble', async () => {
const clients = new Map<string, LlmClient>();
clients.set(models[0], mkClientWith('incorrect', 'F3', 'A'));
clients.set(models[1], mkClientWith('incorrect', 'F3', 'B'));
clients.set(models[2], mkClientWith('incorrect', 'F3', 'C'));
clients.set(models[3], mkClientWith('incorrect', 'F4', 'D')); // minority — says hallucination
const result = await judgeEnsemble({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModels: models, llmClients: clients,
});
expect(result.majority.verdict).toBe('incorrect');
expect(result.majority.failure_mode).toBe('F3');
const failureModes = result.ensemble.map(r => r.failure_mode);
expect(failureModes.filter(m => m === 'F3')).toHaveLength(3);
expect(failureModes.filter(m => m === 'F4')).toHaveLength(1);
});
it('2-2 tie is broken by the first model in judgeModels (Sonnet by convention)', async () => {
const clients = new Map<string, LlmClient>();
// Sonnet + Haiku say F2; GPT-5 + Gemini say F3.
clients.set(models[0], mkClientWith('incorrect', 'F2', 'sonnet'));
clients.set(models[1], mkClientWith('incorrect', 'F2', 'haiku'));
clients.set(models[2], mkClientWith('incorrect', 'F3', 'gpt-5'));
clients.set(models[3], mkClientWith('incorrect', 'F3', 'gemini'));
const result = await judgeEnsemble({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModels: models, llmClients: clients,
});
// Sonnet wins the tie → F2.
expect(result.majority.failure_mode).toBe('F2');
expect(result.majority.rationale).toBe('sonnet');
expect(result.majority.judge_model).toBe('claude-sonnet-4-6');
});
it('refuses when no client is registered for a judge model', async () => {
const clients = new Map<string, LlmClient>();
clients.set(models[0], mkClientWith('correct', null, 'ok'));
// Missing models[1..3]
await expect(
judgeEnsemble({
question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma',
judgeModels: models, llmClients: clients,
}),
).rejects.toThrow(/no LlmClient registered/);
});
});
// ── Fleiss' kappa ──────────────────────────────────────────────────────
describe("computeFleissKappa on hand-crafted 4-judge × 10-subject matrices", () => {
function row(n: number, verdict: Verdict, failure_mode: FailureMode | null): JudgeResult[] {
return Array.from({ length: n }, (_, i) => mkResult(verdict, failure_mode, 'r', `j${i}`));
}
it('κ = 1 for two-cluster unanimous agreement (5 × correct / 5 × F3)', () => {
// 5 subjects: 4 raters all say correct.
// 5 subjects: 4 raters all say F3.
// Pbar = 1 (every subject unanimous). Pj(correct) = 0.5, Pj(F3) = 0.5.
// Pebar = 0.5 + 0.5 = 0.5 (treating the other 4 classes as 0).
// κ = (1 - 0.5) / (1 - 0.5) = 1.
const matrix: JudgeResult[][] = [];
for (let i = 0; i < 5; i++) matrix.push(row(4, 'correct', null));
for (let i = 0; i < 5; i++) matrix.push(row(4, 'incorrect', 'F3'));
const kappa = computeFleissKappa(matrix);
expect(kappa).toBeCloseTo(1, 6);
});
it('κ ≈ 0.1111 for 5 unanimous-correct + 5 split-2/2-correct/F3 subjects', () => {
// Hand-computed: n_correct = 5*4 + 5*2 = 30, n_F3 = 5*2 = 10. Total = 40.
// Pj(correct) = 30/40 = 0.75 → 0.5625
// Pj(F3) = 10/40 = 0.25 → 0.0625
// Pebar = 0.5625 + 0.0625 = 0.625
// Per-subject Pi:
// unanimous correct: (16+0-4)/(4*3) = 12/12 = 1
// split 2/2: (4+4-4)/12 = 4/12 ≈ 0.33333
// Pbar = (5*1 + 5*0.33333) / 10 = 6.66667 / 10 = 0.66667
// κ = (0.66667 - 0.625) / (1 - 0.625) = 0.04167 / 0.375 = 0.11111
const matrix: JudgeResult[][] = [];
for (let i = 0; i < 5; i++) matrix.push(row(4, 'correct', null));
for (let i = 0; i < 5; i++) {
matrix.push([
mkResult('correct', null, 'r', 'j0'),
mkResult('correct', null, 'r', 'j1'),
mkResult('incorrect', 'F3', 'r', 'j2'),
mkResult('incorrect', 'F3', 'r', 'j3'),
]);
}
const kappa = computeFleissKappa(matrix);
expect(kappa).toBeCloseTo(0.1111, 2); // tolerance ±0.01 per the brief
});
it('κ = 1 when every rating falls in a single category (expected = observed = 1)', () => {
// Degenerate edge case: all 40 ratings are `correct`. Pebar = 1.
// Implementation clamps to 1 (the (1-1)/(1-1) limit).
const matrix: JudgeResult[][] = [];
for (let i = 0; i < 10; i++) matrix.push(row(4, 'correct', null));
expect(computeFleissKappa(matrix)).toBe(1);
});
it('throws when rater counts are inconsistent across subjects', () => {
const matrix: JudgeResult[][] = [
row(4, 'correct', null),
row(3, 'correct', null), // wrong rater count
];
expect(() => computeFleissKappa(matrix)).toThrow(/constant rater count/);
});
it('returns 0 for a single-rater input (kappa undefined, convention 0)', () => {
const matrix: JudgeResult[][] = Array.from({ length: 10 }, () => row(1, 'correct', null));
expect(computeFleissKappa(matrix)).toBe(0);
});
it('returns 0 for an empty input', () => {
expect(computeFleissKappa([])).toBe(0);
});
});