moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,311 @@
/**
* aggregate.ts unit tests (Sprint 9 Task 3).
*
* Brief: PM-Waggle-OS/briefs/2026-04-20-cc-sprint-9-tasks.md Task 3 §Acceptance
* Rubric: PM-Waggle-OS/strategy/2026-04-20-failure-mode-taxonomy.md §5
*
* Synthetic 12-instance JSONL fixture: 3 cells × 4 verdicts. Tests
* assert:
* - per-cell count table matches hand-computed rows
* - weighted score matches hand-computed number (see §2 below)
* - per-category rollup surfaces the hallucination flag at the right threshold
* - cross-cell delta matrix populates full-context raw direction
* - cost summary sums correctly + Week-1 projection flag fires at the threshold
* - markdown renderer produces parseable output + contains every row
*/
import { describe, it, expect } from 'vitest';
import {
buildReport,
perCellRollup,
perCategoryRollup,
crossCellDeltaMatrix,
costSummary,
projectVerdict6,
renderMarkdown,
WEIGHTS,
VERDICT6_VALUES,
type JudgedJsonlRecord,
} from '../../src/benchmarks/aggregate.ts';
// ── Fixture builder ─────────────────────────────────────────────────────
function mkRecord(
turnId: string,
cell: string,
verdict: 'correct' | 'incorrect' | 'unjudged',
failureMode: 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | null,
category: JudgedJsonlRecord['category'] = 'single-hop',
usd = 0.001,
): JudgedJsonlRecord {
const rec: JudgedJsonlRecord = {
turnId,
cell,
instance_id: `i_${turnId}`,
model: 'qwen3.6-35b-a3b',
seed: 42,
accuracy: verdict === 'correct' ? 1 : 0,
p50_latency_ms: 800,
p95_latency_ms: 1200,
usd_per_query: usd,
failure_mode: null,
category,
};
if (verdict !== 'unjudged') {
rec.judge_verdict = verdict;
rec.judge_failure_mode = failureMode;
rec.judge_rationale = 'test rationale';
rec.judge_model = 'claude-sonnet-4-6';
rec.judge_timestamp = '2026-04-21T12:00:00Z';
}
return rec;
}
/** 12-instance fixture — 4 each per cell, one of each verdict shape.
* Hand-computed expectations are commented inline so a future diff
* catches silent drift in the rubric. */
function fixture12(): JudgedJsonlRecord[] {
const recs: JudgedJsonlRecord[] = [];
const cells = ['raw', 'filtered', 'full-context'];
// Per cell: 1 correct + 1 F2 partial + 1 F3 incorrect + 1 F4 hallucinated.
for (const c of cells) {
recs.push(mkRecord(`${c}-A`, c, 'correct', null, 'single-hop'));
recs.push(mkRecord(`${c}-B`, c, 'incorrect', 'F2', 'multi-hop'));
recs.push(mkRecord(`${c}-C`, c, 'incorrect', 'F3', 'temporal'));
recs.push(mkRecord(`${c}-D`, c, 'incorrect', 'F4', 'open-ended'));
}
return recs;
}
// ── projectVerdict6 ─────────────────────────────────────────────────────
describe('projectVerdict6 — taxonomy §9 binary → brief §Task-1 6-value', () => {
it('maps correct → correct', () => {
expect(projectVerdict6(mkRecord('x', 'raw', 'correct', null))).toBe('correct');
});
it('maps incorrect + F1..F5 through their exact projections', () => {
const cases: Array<[typeof WEIGHTS extends Record<infer K, number> ? K : never, string]> = [
['F1_abstain', 'F1'],
['F2_partial', 'F2'],
['F3_incorrect', 'F3'],
['F4_hallucinated', 'F4'],
['F5_offtopic', 'F5'],
];
for (const [expected, mode] of cases) {
expect(projectVerdict6(
mkRecord('x', 'raw', 'incorrect', mode as 'F1' | 'F2' | 'F3' | 'F4' | 'F5'),
)).toBe(expected);
}
});
it('maps undefined judge_verdict → unjudged', () => {
expect(projectVerdict6(mkRecord('x', 'raw', 'unjudged', null))).toBe('unjudged');
});
});
// ── perCellRollup ───────────────────────────────────────────────────────
describe('perCellRollup', () => {
it('emits 3 rows, one per observed cell, in CELL_NAMES order', () => {
const rows = perCellRollup(fixture12());
expect(rows).toHaveLength(3);
expect(rows.map(r => r.cell)).toEqual(['raw', 'filtered', 'full-context']);
});
it('computes per-cell counts + weighted score correctly (hand-check)', () => {
const rows = perCellRollup(fixture12());
for (const row of rows) {
expect(row.total).toBe(4);
expect(row.counts.correct).toBe(1);
expect(row.counts.F2_partial).toBe(1);
expect(row.counts.F3_incorrect).toBe(1);
expect(row.counts.F4_hallucinated).toBe(1);
expect(row.counts.F1_abstain).toBe(0);
expect(row.counts.F5_offtopic).toBe(0);
expect(row.counts.unjudged).toBe(0);
// Weighted score = sum(percent × weight) over judged instances.
// judgedTotal=4. Each verdict is 1/4 = 0.25 of the cell.
// correct: 0.25 × 1.00 = 0.250
// F2_partial: 0.25 × 0.30 = 0.075
// F3_incorrect: 0.25 × -0.15 = -0.0375
// F4_hallucinated: 0.25 × -0.35 = -0.0875
// Total = 0.250 + 0.075 0.0375 0.0875 = 0.200
expect(row.weightedScore).toBeCloseTo(0.200, 4);
}
});
it('honors WEIGHTS table exactly', () => {
// Rebuild the hand-computed number from WEIGHTS by name so a rubric
// edit in aggregate.ts forces this test to recompute + update the
// expectation — no silent coefficient drift.
const expected =
0.25 * WEIGHTS.correct +
0.25 * WEIGHTS.F2_partial +
0.25 * WEIGHTS.F3_incorrect +
0.25 * WEIGHTS.F4_hallucinated;
const rows = perCellRollup(fixture12());
for (const row of rows) {
expect(row.weightedScore).toBeCloseTo(expected, 6);
}
});
it('treats unjudged rows as denominator-excluded for weightedScore', () => {
const mix = fixture12().slice(0, 4); // one cell worth
mix.push(mkRecord('unjudged-1', 'raw', 'unjudged', null));
mix.push(mkRecord('unjudged-2', 'raw', 'unjudged', null));
// Now raw has 4 judged + 2 unjudged. Weighted score denominator = 4,
// same as the baseline fixture — the 2 unjudged rows don't pull
// the score toward 0.
const rawRow = perCellRollup(mix).find(r => r.cell === 'raw')!;
expect(rawRow.total).toBe(6);
expect(rawRow.counts.unjudged).toBe(2);
expect(rawRow.weightedScore).toBeCloseTo(0.200, 4);
});
});
// ── perCategoryRollup ──────────────────────────────────────────────────
describe('perCategoryRollup', () => {
it('groups by category and computes percents per bucket', () => {
const rows = perCategoryRollup(fixture12());
// fixture assigns 3 rows per category (one per cell × one verdict shape)
const cats = rows.map(r => r.category);
expect(cats).toContain('single-hop');
expect(cats).toContain('multi-hop');
expect(cats).toContain('temporal');
expect(cats).toContain('open-ended');
for (const row of rows) {
expect(row.total).toBe(3);
}
// open-ended has all F4 (hallucinated) rows — flag must fire.
const openEnded = rows.find(r => r.category === 'open-ended')!;
expect(openEnded.counts.F4_hallucinated).toBe(3);
expect(openEnded.hallucinationFlag).toBe(true);
// single-hop has all correct — flag off.
const singleHop = rows.find(r => r.category === 'single-hop')!;
expect(singleHop.counts.correct).toBe(3);
expect(singleHop.hallucinationFlag).toBe(false);
});
it('does not include categories that have zero rows', () => {
const rows = perCategoryRollup(fixture12());
for (const row of rows) {
expect(row.total).toBeGreaterThan(0);
}
});
});
// ── crossCellDeltaMatrix ───────────────────────────────────────────────
describe('crossCellDeltaMatrix', () => {
it('returns delta = full-context raw for each verdict, preserving sign', () => {
// Build a case where full-context correct% > raw correct%.
const recs: JudgedJsonlRecord[] = [
// raw: 1 correct, 3 incorrect-F4 → 25% correct, 75% F4
mkRecord('r1', 'raw', 'correct', null, 'single-hop'),
mkRecord('r2', 'raw', 'incorrect', 'F4', 'single-hop'),
mkRecord('r3', 'raw', 'incorrect', 'F4', 'single-hop'),
mkRecord('r4', 'raw', 'incorrect', 'F4', 'single-hop'),
// full-context: 3 correct, 1 F4 → 75% correct, 25% F4
mkRecord('f1', 'full-context', 'correct', null, 'single-hop'),
mkRecord('f2', 'full-context', 'correct', null, 'single-hop'),
mkRecord('f3', 'full-context', 'correct', null, 'single-hop'),
mkRecord('f4', 'full-context', 'incorrect', 'F4', 'single-hop'),
];
const perCell = perCellRollup(recs);
const delta = crossCellDeltaMatrix(perCell);
expect(delta).not.toBeNull();
const correctDelta = delta!.find(d => d.verdict === 'correct')!;
expect(correctDelta.rawPercent).toBeCloseTo(0.25, 6);
expect(correctDelta.fullContextPercent).toBeCloseTo(0.75, 6);
expect(correctDelta.delta).toBeCloseTo(0.50, 6);
// F4 goes the other way.
const f4Delta = delta!.find(d => d.verdict === 'F4_hallucinated')!;
expect(f4Delta.delta).toBeCloseTo(-0.50, 6);
});
it('returns null when either raw or full-context is absent', () => {
const recs: JudgedJsonlRecord[] = [mkRecord('x', 'filtered', 'correct', null)];
const perCell = perCellRollup(recs);
expect(crossCellDeltaMatrix(perCell)).toBeNull();
});
});
// ── costSummary + Week-1 projection threshold ──────────────────────────
describe('costSummary', () => {
it('sums per-cell USD across records', () => {
const recs: JudgedJsonlRecord[] = [
mkRecord('a', 'raw', 'correct', null, 'single-hop', 0.010),
mkRecord('b', 'raw', 'correct', null, 'single-hop', 0.020),
mkRecord('c', 'full-context', 'correct', null, 'single-hop', 0.050),
];
const cost = costSummary(recs);
expect(cost.totalUsd).toBeCloseTo(0.08, 6);
expect(cost.perCellUsd['raw']).toBeCloseTo(0.03, 6);
expect(cost.perCellUsd['full-context']).toBeCloseTo(0.05, 6);
});
it('buildReport overlays an authoritative judgeTotalUsd and recomputes the Week-1 projection', () => {
const recs = fixture12();
// 12 records all judged; 12 × 4 cells × 50 instances scaling → 200
// instances. Set judgeTotalUsd = $1.50 across 12 → per-instance = 0.125
// → projected = 0.125 × 200 = $25 → above $20 → warning fires.
const report = buildReport(recs, { judgeTotalUsd: 1.50 });
expect(report.cost.judgeTotalUsd).toBeCloseTo(1.50, 6);
expect(report.cost.week1WarningProjectedUsd).toBeCloseTo(25.0, 6);
expect(report.cost.week1WarningFired).toBe(true);
});
it('Week-1 warning does not fire when projected stays under $20', () => {
const recs = fixture12();
const report = buildReport(recs, { judgeTotalUsd: 0.60 });
// per-instance = 0.05; projected = 0.05 × 200 = $10
expect(report.cost.week1WarningProjectedUsd).toBeCloseTo(10.0, 6);
expect(report.cost.week1WarningFired).toBe(false);
});
});
// ── Markdown renderer — smoke + snapshot-lite ──────────────────────────
describe('renderMarkdown', () => {
it('produces parseable markdown with every cell row + every verdict column', () => {
const report = buildReport(fixture12());
const md = renderMarkdown(report);
// Header structure
expect(md).toContain('# Benchmark Aggregate Report');
expect(md).toContain('## Per-cell verdict distribution');
expect(md).toContain('## Per-LoCoMo-category distribution');
expect(md).toContain('## Cost summary');
// Cells in table
for (const cell of ['raw', 'filtered', 'full-context']) {
expect(md).toContain(`| ${cell} |`);
}
// Verdict columns in the header row
for (const v of VERDICT6_VALUES) {
// VERDICT6_VALUES uses snake names that won't all appear literally
// in the header (e.g. "F1 abstain" vs "F1_abstain"). Assert on the
// base labels the renderer emits.
}
expect(md).toContain('Correct');
expect(md).toContain('F1 abstain');
expect(md).toContain('F4 hallucinated');
expect(md).toContain('Weighted score');
});
it('surfaces the hallucination-flag emoji on the flagged category', () => {
const md = renderMarkdown(buildReport(fixture12()));
// open-ended is flagged in the fixture (3/3 F4). Shape check via the
// brief's sentinel emoji + "PM review" string.
expect(md).toContain('⚠️ PM review');
});
it('omits the Week-1 warning line when threshold not crossed', () => {
const md = renderMarkdown(buildReport(fixture12(), { judgeTotalUsd: 0.40 }));
expect(md).not.toContain('Week-1 scale-up warning');
});
it('emits the Week-1 warning line when threshold crossed', () => {
const md = renderMarkdown(buildReport(fixture12(), { judgeTotalUsd: 5.0 }));
expect(md).toContain('Week-1 scale-up warning');
});
});

View File

@@ -0,0 +1,255 @@
/**
* Sprint 11 Task B2 — Tie-break policy unit tests.
*
* Authority: decisions/2026-04-22-tie-break-policy-locked.md (LOCKED)
*
* The four core scenarios from the brief §3 B2 + LOCK §0:
*
* 1. 3-0 consensus → path=none, no fourth-vendor call.
* 2. 2-1 majority → path=majority, no fourth-vendor call.
* 3. 1-1-1 split resolves → path=quadri-vendor, plurality verdict,
* fourth-vendor called once with correct payload.
* 4. 1-1-1 stays unresolved → path=pm-escalation (1-1-1-1 four-way),
* fourth-vendor called once; verdict is the
* PM_ESCALATION_VERDICT sentinel.
*
* Tests use a mocked CallFourthVendor function and an in-memory logger —
* no network, no API spend. The LIVE grok-4.20 smoke lives in the B2 exit
* ping's companion script and runs independently.
*/
import { describe, it, expect, vi } from 'vitest';
import {
resolveTieBreak,
DEFAULT_FOURTH_VENDOR,
PM_ESCALATION_VERDICT,
type Vote,
type TieBreakLogger,
type CallFourthVendor,
} from '../../src/benchmarks/judge/ensemble-tiebreak.js';
function vote(verdict: 'correct' | 'incorrect', failure_mode: 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | null, model: string): Vote {
return {
verdict,
failure_mode,
rationale: `rationale from ${model}`,
judge_model: model,
};
}
function makeLogger(): { logger: TieBreakLogger; events: Array<{ event: string; fields: Record<string, unknown> }> } {
const events: Array<{ event: string; fields: Record<string, unknown> }> = [];
const logger: TieBreakLogger = {
info: (event, fields) => {
events.push({ event, fields });
},
warn: (event, fields) => {
events.push({ event, fields });
},
};
return { logger, events };
}
describe('Sprint 11 B2 — resolveTieBreak', () => {
it('3-0 consensus returns path=none and does not call the fourth vendor', async () => {
const votes: Vote[] = [
vote('correct', null, 'claude-opus-4-7'),
vote('correct', null, 'gpt-5.4-pro'),
vote('correct', null, 'gemini-3.1-pro'),
];
const callFourthVendor = vi.fn<CallFourthVendor>();
const { logger, events } = makeLogger();
const result = await resolveTieBreak(votes, { callFourthVendor, logger });
expect(result.path).toBe('none');
expect(result.verdict).toBe('correct|NA');
expect(result.votes).toHaveLength(3);
expect(result.fourthVendorVote).toBeUndefined();
expect(result.fourthVendorSlug).toBeUndefined();
expect(callFourthVendor).not.toHaveBeenCalled();
expect(events).toContainEqual(
expect.objectContaining({ event: 'tie_break', fields: expect.objectContaining({ path: 'none' }) }),
);
});
it('2-1 majority returns path=majority and does not call the fourth vendor', async () => {
const votes: Vote[] = [
vote('correct', null, 'claude-opus-4-7'),
vote('correct', null, 'gpt-5.4-pro'),
vote('incorrect', 'F3', 'gemini-3.1-pro'),
];
const callFourthVendor = vi.fn<CallFourthVendor>();
const { logger, events } = makeLogger();
const result = await resolveTieBreak(votes, { callFourthVendor, logger });
expect(result.path).toBe('majority');
expect(result.verdict).toBe('correct|NA');
expect(result.votes).toHaveLength(3);
expect(callFourthVendor).not.toHaveBeenCalled();
expect(events).toContainEqual(
expect.objectContaining({ event: 'tie_break', fields: expect.objectContaining({ path: 'majority' }) }),
);
});
it('1-1-1 split triggers quadri-vendor call on xai/grok-4.20 and resolves via plurality', async () => {
const primaryVotes: Vote[] = [
vote('correct', null, 'claude-opus-4-7'), // bucket A: correct|NA
vote('incorrect', 'F3', 'gpt-5.4-pro'), // bucket B: incorrect|F3
vote('incorrect', 'F4', 'gemini-3.1-pro'), // bucket C: incorrect|F4
];
const grokVote = vote('correct', null, 'xai/grok-4.20'); // joins bucket A → plurality
const callFourthVendor = vi.fn<CallFourthVendor>().mockResolvedValue(grokVote);
const { logger, events } = makeLogger();
const result = await resolveTieBreak(primaryVotes, { callFourthVendor, logger });
expect(result.path).toBe('quadri-vendor');
expect(result.verdict).toBe('correct|NA');
expect(result.votes).toHaveLength(4);
expect(result.fourthVendorVote).toEqual(grokVote);
expect(result.fourthVendorSlug).toBe(DEFAULT_FOURTH_VENDOR);
// Exactly one fourth-vendor call with the correct payload.
expect(callFourthVendor).toHaveBeenCalledTimes(1);
expect(callFourthVendor).toHaveBeenCalledWith({
primaryVotes,
model: 'xai/grok-4.20',
});
// pino-shaped events: invoke + resolved.
const invokeEvent = events.find(e => e.event === 'tie_break.quadri-vendor.invoke');
const resolvedEvent = events.find(e => e.event === 'tie_break.quadri-vendor.resolved');
expect(invokeEvent).toBeDefined();
expect(invokeEvent!.fields).toMatchObject({
path: 'quadri-vendor',
fourth_vendor_slug: 'xai/grok-4.20',
});
expect(resolvedEvent).toBeDefined();
expect(resolvedEvent!.fields).toMatchObject({
path: 'quadri-vendor',
fourth_vendor_slug: 'xai/grok-4.20',
verdict: 'correct|NA',
});
});
it('1-1-1 split where the fourth vote is a fourth distinct bucket escalates to PM', async () => {
const primaryVotes: Vote[] = [
vote('correct', null, 'claude-opus-4-7'), // A
vote('incorrect', 'F2', 'gpt-5.4-pro'), // B
vote('incorrect', 'F3', 'gemini-3.1-pro'), // C
];
// Fourth vote takes a fourth distinct failure mode → 1-1-1-1.
const grokVote = vote('incorrect', 'F4', 'xai/grok-4.20');
const callFourthVendor = vi.fn<CallFourthVendor>().mockResolvedValue(grokVote);
const { logger, events } = makeLogger();
const result = await resolveTieBreak(primaryVotes, { callFourthVendor, logger });
expect(result.path).toBe('pm-escalation');
expect(result.verdict).toBe(PM_ESCALATION_VERDICT);
expect(result.votes).toHaveLength(4);
expect(result.fourthVendorVote).toEqual(grokVote);
expect(result.fourthVendorSlug).toBe(DEFAULT_FOURTH_VENDOR);
expect(callFourthVendor).toHaveBeenCalledTimes(1);
// Logger emits a pm-escalation path event on the 4-vote recursive call.
const escalationEvent = events.find(e =>
e.event === 'tie_break' && e.fields.path === 'pm-escalation',
);
expect(escalationEvent).toBeDefined();
expect(escalationEvent!.fields).toMatchObject({
path: 'pm-escalation',
verdict: PM_ESCALATION_VERDICT,
});
});
});
describe('Sprint 11 B2 — resolveTieBreak defensive invariants', () => {
it('throws on invalid vote length (2 votes)', async () => {
const votes: Vote[] = [
vote('correct', null, 'a'),
vote('correct', null, 'b'),
];
await expect(resolveTieBreak(votes)).rejects.toThrow(/must be 3 .* or 4/);
});
it('throws on invalid vote length (5 votes)', async () => {
const votes: Vote[] = [
vote('correct', null, 'a'),
vote('correct', null, 'b'),
vote('correct', null, 'c'),
vote('correct', null, 'd'),
vote('correct', null, 'e'),
];
await expect(resolveTieBreak(votes)).rejects.toThrow(/must be 3 .* or 4/);
});
it('1-1-1 without a callFourthVendor dep throws explicitly', async () => {
const votes: Vote[] = [
vote('correct', null, 'a'),
vote('incorrect', 'F3', 'b'),
vote('incorrect', 'F4', 'c'),
];
await expect(resolveTieBreak(votes)).rejects.toThrow(/requires a callFourthVendor/);
});
it('caller-provided 4-vote vector resolves to plurality without extra calls', async () => {
// Test the caller-driven 4-vote shape — harness may pre-construct this.
const votes: Vote[] = [
vote('correct', null, 'claude-opus-4-7'),
vote('correct', null, 'gpt-5.4-pro'),
vote('incorrect', 'F3', 'gemini-3.1-pro'),
vote('incorrect', 'F4', 'xai/grok-4.20'),
];
const callFourthVendor = vi.fn<CallFourthVendor>();
const { logger, events } = makeLogger();
const result = await resolveTieBreak(votes, { callFourthVendor, logger });
expect(result.path).toBe('majority');
expect(result.verdict).toBe('correct|NA');
expect(callFourthVendor).not.toHaveBeenCalled();
expect(events.some(e => e.fields.path === 'majority')).toBe(true);
});
it('caller-provided 2-2 tie on 4 votes escalates (defensive, never silent coin-flip)', async () => {
const votes: Vote[] = [
vote('correct', null, 'a'),
vote('correct', null, 'b'),
vote('incorrect', 'F3', 'c'),
vote('incorrect', 'F3', 'd'),
];
const { logger, events } = makeLogger();
const result = await resolveTieBreak(votes, { logger });
expect(result.path).toBe('pm-escalation');
expect(result.verdict).toBe(PM_ESCALATION_VERDICT);
expect(events.some(e => e.event === 'tie_break.two-two-tie')).toBe(true);
});
it('fourthVendorModel override is respected', async () => {
const votes: Vote[] = [
vote('correct', null, 'a'),
vote('incorrect', 'F3', 'b'),
vote('incorrect', 'F4', 'c'),
];
const callFourthVendor = vi.fn<CallFourthVendor>().mockResolvedValue(
vote('correct', null, 'custom-slug/vendor-x'),
);
const result = await resolveTieBreak(votes, {
callFourthVendor,
fourthVendorModel: 'custom-slug/vendor-x',
});
expect(result.fourthVendorSlug).toBe('custom-slug/vendor-x');
expect(callFourthVendor).toHaveBeenCalledWith({
primaryVotes: votes,
model: 'custom-slug/vendor-x',
});
});
});

View File

@@ -0,0 +1,340 @@
/**
* 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);
});
});

View File

@@ -0,0 +1,216 @@
/**
* Verbose-fixed cell isolation — defense-in-depth unit test.
*
* Brief: PM-Waggle-OS/briefs/2026-04-20-cc-preflight-prep-tasks.md Task 3
* LOCKED: decisions/2026-04-20-verbose-fixed-oq-resolutions-locked.md §OQ-VF-3
*
* The verbose-fixed control MUST NOT invoke retrieval, the wiki compiler, or
* the memory reader. Per the LOCKED spec, its purpose is to eliminate the
* "prompt bogatstvo" confounder — a verbose prompt that *simulates* memory
* access without ever *actually* hitting the memory stack. Any call to those
* layers collapses the control into naive-RAG or full-context and voids the
* Week-2 results.
*
* This test enforces that invariant by construction:
* 1. Invoke `controls['verbose-fixed']` from the harness — the same entry
* point the runner uses in scored runs.
* 2. Spy on every server-side retrieval/wiki/memory surface that could
* plausibly be reached. The harness currently reaches none of these,
* but a future wiring mistake would.
* 3. Assert zero invocations on each spy.
*
* Failure modes guarded:
* - Someone wires HybridSearch or CombinedRetrieval into the harness cell
* (e.g. accidentally merging filtered logic into verbose-fixed).
* - Someone calls WikiCompiler.compile* (or the `compile` method) from the
* cell to "enrich" the prompt.
* - Someone swaps the static system prompt for a dynamic assembler that
* invokes any of the above under the hood.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HybridSearch, FrameStore, KnowledgeGraph, MindDB } from '@waggle/core';
import { WikiCompiler } from '@waggle/wiki-compiler';
// CombinedRetrieval isn't re-exported from @waggle/agent (yet) — import from
// the source path so prototype spies attach to the canonical class the
// harness would reach through if a future refactor wired it in.
import { CombinedRetrieval } from '../../../agent/src/combined-retrieval.js';
import { controls } from '../../../../benchmarks/harness/src/controls.js';
import type { LlmClient, LlmCallInput, LlmCallResult } from '../../../../benchmarks/harness/src/llm.js';
import type { DatasetInstance, ModelSpec } from '../../../../benchmarks/harness/src/types.js';
// ── Fixtures ─────────────────────────────────────────────────────────────
const MODEL: ModelSpec = {
id: 'qwen3.6-35b-a3b',
displayName: 'Qwen3.6-35B-A3B',
provider: 'alibaba',
litellmModel: 'dashscope/qwen3.6-35b-a3b',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
};
const INSTANCE: DatasetInstance = {
instance_id: 'iso_test_001',
question: 'Who painted the Mona Lisa?',
context: 'Leonardo da Vinci painted the Mona Lisa during the Italian Renaissance.',
expected: ['Leonardo da Vinci'],
};
/** A recording LlmClient that counts calls and returns a canned response.
* We do NOT use the harness's own DryRunClient here because we want full
* visibility into what the cell passed through — call args, system prompt,
* user prompt — without any DryRun transformation that could mask a leak. */
class RecordingLlmClient implements LlmClient {
readonly calls: LlmCallInput[] = [];
async call(input: LlmCallInput): Promise<LlmCallResult> {
this.calls.push(input);
return {
text: 'Leonardo da Vinci',
inputTokens: 32,
outputTokens: 4,
latencyMs: 1,
costUsd: 0.000005,
failureMode: null,
};
}
}
interface Spies {
combinedSearch: ReturnType<typeof vi.spyOn>;
hybridSearch: ReturnType<typeof vi.spyOn>;
wikiCompile: ReturnType<typeof vi.spyOn>;
wikiEntity: ReturnType<typeof vi.spyOn>;
wikiConcept: ReturnType<typeof vi.spyOn>;
wikiSynthesis: ReturnType<typeof vi.spyOn>;
wikiIndex: ReturnType<typeof vi.spyOn>;
wikiHealth: ReturnType<typeof vi.spyOn>;
}
function installSpies(): Spies {
// Prototype-level spies fire no matter which instance the harness might
// construct. They throw if actually invoked so a single escaped call
// becomes a loud test failure rather than a subtle accumulating count.
const trap = (layer: string) => () => {
throw new Error(`verbose-fixed cell illegally invoked ${layer}`);
};
return {
combinedSearch: vi
.spyOn(CombinedRetrieval.prototype as unknown as { search: (...args: unknown[]) => unknown }, 'search')
.mockImplementation(trap('CombinedRetrieval.search')),
hybridSearch: vi
.spyOn(HybridSearch.prototype as unknown as { search: (...args: unknown[]) => unknown }, 'search')
.mockImplementation(trap('HybridSearch.search')),
wikiCompile: vi
.spyOn(WikiCompiler.prototype as unknown as { compile: (...args: unknown[]) => unknown }, 'compile')
.mockImplementation(trap('WikiCompiler.compile')),
wikiEntity: vi
.spyOn(WikiCompiler.prototype as unknown as { compileEntityPage: (...args: unknown[]) => unknown }, 'compileEntityPage')
.mockImplementation(trap('WikiCompiler.compileEntityPage')),
wikiConcept: vi
.spyOn(WikiCompiler.prototype as unknown as { compileConceptPage: (...args: unknown[]) => unknown }, 'compileConceptPage')
.mockImplementation(trap('WikiCompiler.compileConceptPage')),
wikiSynthesis: vi
.spyOn(WikiCompiler.prototype as unknown as { compileSynthesisPage: (...args: unknown[]) => unknown }, 'compileSynthesisPage')
.mockImplementation(trap('WikiCompiler.compileSynthesisPage')),
wikiIndex: vi
.spyOn(WikiCompiler.prototype as unknown as { compileIndex: (...args: unknown[]) => unknown }, 'compileIndex')
.mockImplementation(trap('WikiCompiler.compileIndex')),
wikiHealth: vi
.spyOn(WikiCompiler.prototype as unknown as { compileHealth: (...args: unknown[]) => unknown }, 'compileHealth')
.mockImplementation(trap('WikiCompiler.compileHealth')),
};
}
describe('verbose-fixed cell isolation (OQ-VF-3 invariant)', () => {
let spies: Spies;
let llm: RecordingLlmClient;
beforeEach(() => {
spies = installSpies();
llm = new RecordingLlmClient();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('verbose-fixed cell invokes zero retrieval calls', async () => {
await controls['verbose-fixed']({
instance: INSTANCE,
model: MODEL,
llm,
turnId: 'iso-retrieval-0001',
});
// Both the workspace-level combined-retrieval and the lower-level
// hybrid memory search must stay at zero — brief Task 3 case 1 +
// case 3 combined (memory reader + retriever).
expect(spies.combinedSearch).not.toHaveBeenCalled();
expect(spies.hybridSearch).not.toHaveBeenCalled();
// Sanity: the cell MUST have called the LLM exactly once. Otherwise
// the invariant is met only because the cell did nothing — not a
// healthy pass.
expect(llm.calls).toHaveLength(1);
});
it('verbose-fixed cell invokes zero wiki compiler calls', async () => {
await controls['verbose-fixed']({
instance: INSTANCE,
model: MODEL,
llm,
turnId: 'iso-wiki-0002',
});
// All five compile entry points on WikiCompiler — every public compile
// surface that could inject wiki content into the prompt.
expect(spies.wikiCompile).not.toHaveBeenCalled();
expect(spies.wikiEntity).not.toHaveBeenCalled();
expect(spies.wikiConcept).not.toHaveBeenCalled();
expect(spies.wikiSynthesis).not.toHaveBeenCalled();
expect(spies.wikiIndex).not.toHaveBeenCalled();
expect(spies.wikiHealth).not.toHaveBeenCalled();
expect(llm.calls).toHaveLength(1);
});
it('verbose-fixed cell invokes zero memory read calls', async () => {
await controls['verbose-fixed']({
instance: INSTANCE,
model: MODEL,
llm,
turnId: 'iso-memory-0003',
});
// The memory reader — HybridSearch.search is the canonical read path
// on personal.mind and workspace mind databases. CombinedRetrieval
// wraps it but we spy on both layers to catch either-or entry points.
expect(spies.hybridSearch).not.toHaveBeenCalled();
expect(spies.combinedSearch).not.toHaveBeenCalled();
expect(llm.calls).toHaveLength(1);
});
it('verbose-fixed cell passes a long-form system prompt through to the LLM', async () => {
// Belt-and-braces: the whole point of the control is a VERBOSE prompt.
// If future refactor strips the verbose instructions, the control's
// diagnostic value vanishes even if the zero-call invariants still hold.
await controls['verbose-fixed']({
instance: INSTANCE,
model: MODEL,
llm,
turnId: 'iso-shape-0004',
});
expect(llm.calls).toHaveLength(1);
const call = llm.calls[0];
expect(call.systemPrompt.length).toBeGreaterThan(80);
expect(call.systemPrompt.toLowerCase()).toMatch(/step by step|full sentences|careful/);
expect(call.userPrompt).toContain(INSTANCE.question);
expect(call.userPrompt).toContain(INSTANCE.context);
});
});
// Ensure the suppressed-logic imports are not tree-shaken away — referencing
// the Mind / KG constructors keeps bundler eye on them so spies attach to the
// *real* prototype methods. (If the test ever compiles without these imports,
// they'd still be inert at runtime since we never instantiate them.)
void MindDB;
void FrameStore;
void KnowledgeGraph;