This commit is contained in:
311
packages/server/tests/benchmarks/aggregate.test.ts
Normal file
311
packages/server/tests/benchmarks/aggregate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
255
packages/server/tests/benchmarks/ensemble-tiebreak.test.ts
Normal file
255
packages/server/tests/benchmarks/ensemble-tiebreak.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
340
packages/server/tests/benchmarks/failure-mode-judge.test.ts
Normal file
340
packages/server/tests/benchmarks/failure-mode-judge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user