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,270 @@
/**
* Sprint 12 Task 2 §2.1 — A3 failure taxonomy namespace split coverage.
*
* Decision doc: decisions/2026-04-23-jsonl-record-taxonomy-split-locked.md
*
* Covers the additive namespace split across four surfaces:
* 1. JudgePayload carries `a3_failure_code` + `a3_rationale` alongside the
* legacy `judge_failure_mode` + `judge_rationale` fields (Sprint 9
* read-only preservation contract).
* 2. `buildAggregate` emits `failure_distribution` when records carry the
* `a3_failure_code` column (legacy-only runs stay `undefined`).
* 3. `a3_failure_code` / `a3_rationale` grep-compatibility — A3 rows are
* greppable under the `a3_` prefix per Opcija C audit trail rationale.
* 4. Mapping from Sprint 9 5-value FailureMode into A3 § 6 8-value
* FailureCode preserves semantic equivalence (F1..F5 pass through,
* null → null, F6 / F_other remain unused until rubric upgrade).
*/
import { describe, expect, it } from 'vitest';
import { runJudge, type JudgeConfig, type JudgeTriple } from '../src/judge-runner.js';
import type { LlmClient } from '../src/judge-types.js';
import { buildAggregate } from '../src/metrics.js';
import type { FailureMode, JsonlRecord, RunConfig } from '../src/types.js';
function stubClient(
verdict: 'correct' | 'incorrect',
failureMode: FailureMode | null,
): LlmClient {
return {
async complete(_prompt: string) {
return JSON.stringify({
verdict,
failure_mode: failureMode,
rationale: `stub: ${verdict}/${failureMode ?? 'null'}`,
});
},
};
}
const TRIPLE: JudgeTriple = {
question: 'What is the capital of France?',
groundTruth: 'Paris',
contextExcerpt: 'France is a country in Europe. Its capital is Paris.',
modelAnswer: 'Paris',
};
describe('Sprint 12 Task 2 §2.1 — JudgePayload carries a3_* fields', () => {
it('single-judge correct verdict → a3_failure_code=null, a3_rationale=null, legacy preserved', async () => {
const client = stubClient('correct', null);
const payload = await runJudge(TRIPLE, { kind: 'single', model: 'claude-opus-4-7', client });
// A3 namespace columns.
expect(payload.a3_failure_code).toBeNull();
expect(payload.a3_rationale).toBeNull();
// Sprint 9 legacy preserved verbatim (contract: read-only, not erased).
expect(payload.judge_failure_mode).toBeNull();
expect(payload.judge_rationale).toContain('correct');
});
it('single-judge incorrect/F3 → a3_failure_code mirrors legacy 5-value code', async () => {
const client = stubClient('incorrect', 'F3');
const payload = await runJudge(TRIPLE, { kind: 'single', model: 'claude-opus-4-7', client });
expect(payload.a3_failure_code).toBe('F3');
expect(payload.a3_rationale).toBeNull();
expect(payload.judge_failure_mode).toBe('F3');
});
it('3-primary ensemble 2-1 majority → a3_failure_code mirrors majority code', async () => {
const opus = stubClient('incorrect', 'F1');
const gpt = stubClient('incorrect', 'F1');
const gemini = stubClient('correct', null);
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opus);
clients.set('gpt-5.4', gpt);
clients.set('gemini-3.1', gemini);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1'],
clients,
};
const payload = await runJudge(TRIPLE, config);
expect(payload.judge_failure_mode).toBe('F1');
expect(payload.a3_failure_code).toBe('F1');
expect(payload.a3_rationale).toBeNull();
});
it('3-primary + quadri-vendor tie-break → a3_failure_code mirrors resolved code', async () => {
const opus = stubClient('correct', null);
const gpt = stubClient('incorrect', 'F3');
const gemini = stubClient('incorrect', 'F4');
const grok = stubClient('correct', null);
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opus);
clients.set('gpt-5.4', gpt);
clients.set('gemini-3.1', gemini);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1'],
clients,
tieBreakerModel: 'xai/grok-4.20',
tieBreakerClient: grok,
};
const payload = await runJudge(TRIPLE, config);
expect(payload.tie_break_path).toBe('quadri-vendor');
expect(payload.judge_verdict).toBe('correct');
expect(payload.judge_failure_mode).toBeNull();
expect(payload.a3_failure_code).toBeNull();
expect(payload.a3_rationale).toBeNull();
});
it('3-primary pm-escalation 1-1-1-1 → a3_failure_code undefined (skipped-judge semantics)', async () => {
const opus = stubClient('correct', null);
const gpt = stubClient('incorrect', 'F1');
const gemini = stubClient('incorrect', 'F2');
const grok = stubClient('incorrect', 'F3'); // fourth bucket → 1-1-1-1
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opus);
clients.set('gpt-5.4', gpt);
clients.set('gemini-3.1', gemini);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1'],
clients,
tieBreakerModel: 'xai/grok-4.20',
tieBreakerClient: grok,
};
const payload = await runJudge(TRIPLE, config);
expect(payload.tie_break_path).toBe('pm-escalation');
expect(payload.judge_error).toBe('PM_ESCALATION');
// skipped-judge semantics: no A3 code assigned, aggregator will exclude.
expect(payload.a3_failure_code).toBeUndefined();
expect(payload.a3_rationale).toBeNull();
});
});
describe('Sprint 12 Task 2 §2.1 — buildAggregate failure_distribution', () => {
function makeConfig(): RunConfig {
return {
run: { kind: 'cell', name: 'raw' },
dataset: { id: 'synthetic', displayName: 'Synthetic', dataPath: 'synthetic', source: 'synthetic' },
model: {
id: 'qwen3.6-35b-a3b',
displayName: 'Qwen',
provider: 'alibaba',
litellmModel: 'dashscope/qwen3.6-35b-a3b',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
},
limit: 10,
seed: 42,
budgetUsd: Infinity,
outputPath: '/tmp/test.jsonl',
dryRun: true,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
};
}
function makeRecord(overrides: Partial<JsonlRecord> = {}): JsonlRecord {
return {
turnId: 't-1',
cell: 'raw',
instance_id: 'inst-1',
model: 'qwen',
seed: 42,
accuracy: 1,
p50_latency_ms: 10,
p95_latency_ms: 20,
usd_per_query: 0.001,
failure_mode: null,
...overrides,
};
}
it('emits failure_distribution when records carry a3_failure_code', () => {
const records: JsonlRecord[] = [
makeRecord({ instance_id: 'i1', a3_failure_code: null }),
makeRecord({ instance_id: 'i2', a3_failure_code: null }),
makeRecord({ instance_id: 'i3', a3_failure_code: 'F1' }),
makeRecord({
instance_id: 'i4',
a3_failure_code: 'F_other',
a3_rationale: 'model hallucinated an unrelated entity and drifted off topic entirely forever',
}),
];
const summary = buildAggregate(
makeConfig(),
records,
'2026-04-23T00:00:00.000Z',
'2026-04-23T00:00:01.000Z',
null,
);
expect(summary.failure_distribution).toBeDefined();
expect(summary.failure_distribution!.total).toBe(4);
expect(summary.failure_distribution!.counts.null).toBe(2);
expect(summary.failure_distribution!.counts.F1).toBe(1);
expect(summary.failure_distribution!.counts.F_other).toBe(1);
expect(summary.failure_distribution!.f_other_rate).toBeCloseTo(0.25, 10);
expect(summary.failure_distribution!.f_other_review_flag).toBe(true); // 25% > 10%
expect(summary.failure_distribution!.f_other_rationales_sample).toHaveLength(1);
});
it('leaves failure_distribution undefined when no records carry a3_failure_code', () => {
const records: JsonlRecord[] = [
makeRecord({ instance_id: 'i1', judge_failure_mode: 'F1' }), // legacy only
makeRecord({ instance_id: 'i2' }), // no judge at all
];
const summary = buildAggregate(
makeConfig(),
records,
'2026-04-23T00:00:00.000Z',
'2026-04-23T00:00:01.000Z',
null,
);
expect(summary.failure_distribution).toBeUndefined();
});
it('excludes rows with undefined a3_failure_code (PM_ESCALATION skipped-judge semantics)', () => {
const records: JsonlRecord[] = [
makeRecord({ instance_id: 'i1', a3_failure_code: null }),
makeRecord({ instance_id: 'i2', a3_failure_code: 'F1' }),
// PM escalated → a3 undefined, should be excluded from distribution.
makeRecord({ instance_id: 'i3' }),
];
const summary = buildAggregate(
makeConfig(),
records,
'2026-04-23T00:00:00.000Z',
'2026-04-23T00:00:01.000Z',
null,
);
expect(summary.failure_distribution).toBeDefined();
expect(summary.failure_distribution!.total).toBe(2);
});
});
describe('Sprint 12 Task 2 §2.1 — JsonlRecord grep compatibility', () => {
it('a3_failure_code + a3_rationale accepted at the type level as optional fields', () => {
// Compile-time contract check: the fields exist and accept the
// FailureCode union. The test body only asserts that the object
// structural-types correctly against JsonlRecord.
const rec: JsonlRecord = {
turnId: 't-1',
cell: 'raw',
instance_id: 'inst-1',
model: 'qwen',
seed: 42,
accuracy: 1,
p50_latency_ms: 10,
p95_latency_ms: 20,
usd_per_query: 0.001,
failure_mode: null,
a3_failure_code: 'F_other',
a3_rationale: 'ten or more token rationale satisfying the A3 LOCK validator invariant',
};
expect(rec.a3_failure_code).toBe('F_other');
expect(rec.a3_rationale?.split(/\s+/).filter(Boolean).length).toBeGreaterThanOrEqual(10);
});
});

View File

@@ -0,0 +1,328 @@
/**
* Task 2.5 Stage 2-Retry §1.4 — agent-loop tool-exhaustion fallback tests.
*
* Stage 2 N=20 showed 2/20 agentic instances reached maxTurns=3 with every
* turn spent on a search_memory call, leaving `resp.content` empty; the
* judge scored those as incorrect. Stage 2-Retry §1.4 adds a runtime-side
* forced-answer fallback in the agentic cell wrapper: on empty-content +
* non-empty toolsUsed, the cell makes ONE additional direct LLM call
* (no tools, SYSTEM_AGENTIC_FORCED_FALLBACK) with the accumulated search
* context and returns that answer.
*
* Test matrix per brief:
* (a) normal 1-call-1-answer → fallback NOT fired
* (b) 2-call-1-answer → fallback NOT fired
* (c) 3-call-1-answer → fallback NOT fired
* (d) 3 tools + empty content → fallback FIRED, returns forced answer
* Plus:
* (e) fallback preserves accumulated tool context in the user prompt
* (f) fallback counts its tokens into returned cost (no cost-leak)
*/
import { describe, expect, it } from 'vitest';
import type { AgentLoopConfig, AgentResponse } from '@waggle/agent';
import type { LlmCallInput, LlmCallResult, LlmClient } from '../src/llm.js';
import type { DatasetInstance, ModelSpec } from '../src/types.js';
import {
cells,
SYSTEM_AGENTIC,
SYSTEM_AGENTIC_FORCED_FALLBACK,
} from '../src/cells.js';
import { createSubstrate } from '../src/substrate.js';
import type { Embedder } from '@waggle/core';
const VEC_DIMS = 1024;
function createFakeEmbedder(): Embedder {
// Deterministic hash-seeded 1024-dim embedder — same pattern as other tests.
const fnv1a = (s: string): number => {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h || 1;
};
const embedOne = (text: string): Float32Array => {
let state = fnv1a(text);
const v = new Float32Array(VEC_DIMS);
for (let i = 0; i < VEC_DIMS; i++) {
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
v[i] = ((state >>> 0) / 0x100000000) * 2 - 1;
}
let mag = 0;
for (let i = 0; i < VEC_DIMS; i++) mag += v[i] * v[i];
mag = Math.sqrt(mag);
if (mag > 0) for (let i = 0; i < VEC_DIMS; i++) v[i] /= mag;
return v;
};
return {
dimensions: VEC_DIMS,
async embed(t) { return embedOne(t); },
async embedBatch(ts) { return ts.map(embedOne); },
};
}
const MODEL: ModelSpec = {
id: 'test-subject',
displayName: 'Test',
provider: 'alibaba',
litellmModel: 'test/model',
pricePerMillionInput: 0.1,
pricePerMillionOutput: 0.4,
contextWindow: 32_000,
};
const INSTANCE: DatasetInstance = {
instance_id: 'test_q001',
question: 'When did the event happen?',
context: 'irrelevant for this test',
expected: ['2023'],
conversation_id: 'conv-test',
};
/** Programmable mock runAgentLoop that simulates different tool-use and
* content patterns per-test. Also exercises the onToolResult callback so
* the cell wrapper's context capture is tested end-to-end. */
function makeScriptedAgentLoop(script: {
toolCallResults: string[]; // results the agent-loop would return to the agent
finalContent: string; // final resp.content
usageInput?: number;
usageOutput?: number;
}): (config: AgentLoopConfig) => Promise<AgentResponse> {
return async (config: AgentLoopConfig): Promise<AgentResponse> => {
// Fire onToolResult for each simulated tool call in order. Triggers the
// cell wrapper's capturedToolResults accumulator.
for (let i = 0; i < script.toolCallResults.length; i++) {
config.onToolResult?.('search_memory', { query: `simulated-${i}` }, script.toolCallResults[i]);
}
return {
content: script.finalContent,
toolsUsed: script.toolCallResults.map(() => 'search_memory'),
usage: {
inputTokens: script.usageInput ?? 200,
outputTokens: script.usageOutput ?? 10,
},
};
};
}
/** Capturing LlmClient — records every direct llm.call made by the cell
* wrapper. The forced-fallback pass hits this (not the mock agent-loop). */
function makeCapturingLlm(response: Partial<LlmCallResult> = {}): {
client: LlmClient;
calls: LlmCallInput[];
} {
const calls: LlmCallInput[] = [];
const client: LlmClient = {
async call(input) {
calls.push(input);
return {
text: response.text ?? 'FORCED_ANSWER',
inputTokens: response.inputTokens ?? 500,
outputTokens: response.outputTokens ?? 3,
latencyMs: response.latencyMs ?? 50,
costUsd: response.costUsd ?? 0.0002,
failureMode: response.failureMode ?? null,
};
},
};
return { client, calls };
}
describe('agent-loop tool-exhaustion fallback — Stage 2-Retry §1.4', () => {
it('case (a): 1 tool call + answer → fallback NOT fired', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: ['[1] Caroline: painted in 2023'],
finalContent: '2023',
});
const { client, calls } = makeCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(result.text).toBe('2023');
expect(result.failureMode).toBeNull();
// No fallback call.
expect(calls).toHaveLength(0);
} finally {
substrate.close();
}
});
it('case (b): 2 tool calls + answer → fallback NOT fired', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: [
'[1] some result',
'[2] refined result',
],
finalContent: '2023',
});
const { client, calls } = makeCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(result.text).toBe('2023');
expect(calls).toHaveLength(0);
} finally {
substrate.close();
}
});
it('case (c): 3 tool calls + answer (all turns used, but content present) → fallback NOT fired', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: ['r1', 'r2', 'r3'],
finalContent: 'best-effort-answer',
});
const { client, calls } = makeCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(result.text).toBe('best-effort-answer');
expect(calls).toHaveLength(0);
} finally {
substrate.close();
}
});
it('case (d): 3 tool calls + EMPTY content → fallback FIRED, forced answer returned', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: ['r1', 'r2', 'r3'],
finalContent: '', // agent exhausted turns, no answer
});
const { client, calls } = makeCapturingLlm({ text: 'FALLBACK-2023' });
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(result.text).toBe('FALLBACK-2023');
expect(result.failureMode).toBeNull();
// Exactly one fallback call.
expect(calls).toHaveLength(1);
expect(calls[0].systemPrompt).toBe(SYSTEM_AGENTIC_FORCED_FALLBACK);
} finally {
substrate.close();
}
});
it('case (e): fallback user prompt includes question + every captured tool result', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: [
'search_memory hit A',
'search_memory hit B',
'search_memory hit C',
],
finalContent: '',
});
const { client, calls } = makeCapturingLlm({ text: 'forced' });
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
const user = calls[0].userPrompt;
expect(user).toContain(INSTANCE.question);
expect(user).toContain('search_memory hit A');
expect(user).toContain('search_memory hit B');
expect(user).toContain('search_memory hit C');
// Each call result labelled with its call number.
expect(user).toContain('## search_memory call 1');
expect(user).toContain('## search_memory call 2');
expect(user).toContain('## search_memory call 3');
} finally {
substrate.close();
}
});
it('case (f): fallback token counts fold into the returned LlmCallResult cost', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const runFn = makeScriptedAgentLoop({
toolCallResults: ['r1', 'r2', 'r3'],
finalContent: '',
usageInput: 1_000_000,
usageOutput: 100_000,
});
// Fallback call accounts for another 500_000 input + 50_000 output.
const { client, calls } = makeCapturingLlm({
text: 'forced-final',
inputTokens: 500_000,
outputTokens: 50_000,
});
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
// Token sums: 1_500_000 input, 150_000 output.
expect(result.inputTokens).toBe(1_500_000);
expect(result.outputTokens).toBe(150_000);
// Cost = (1.5 × $0.1/M input) + (0.15 × $0.4/M output) = $0.15 + $0.06 = $0.21
expect(result.costUsd).toBeCloseTo(0.21, 5);
// And one fallback llm.call happened.
expect(calls).toHaveLength(1);
} finally {
substrate.close();
}
});
it('case (g): empty content + ZERO tool calls → fallback does NOT fire (honest abstain)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
// Agent answered directly without searching — and its content was empty.
// This is an honest abstain case, not tool-exhaustion; no fallback.
const runFn = makeScriptedAgentLoop({
toolCallResults: [],
finalContent: '',
});
const { client, calls } = makeCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(result.text).toBe('');
expect(calls).toHaveLength(0);
} finally {
substrate.close();
}
});
it('SYSTEM_AGENTIC_FORCED_FALLBACK is exported and non-empty', () => {
expect(SYSTEM_AGENTIC_FORCED_FALLBACK.length).toBeGreaterThan(50);
expect(SYSTEM_AGENTIC_FORCED_FALLBACK).toContain('commit to your best');
expect(SYSTEM_AGENTIC_FORCED_FALLBACK).toContain('Do not call tools');
});
it('SYSTEM_AGENTIC (softened Stage 2-Retry) is still the prompt agentic uses', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
let capturedSystem = '';
const runFn = async (config: AgentLoopConfig): Promise<AgentResponse> => {
capturedSystem = config.systemPrompt;
return { content: 'x', toolsUsed: [], usage: { inputTokens: 0, outputTokens: 0 } };
};
const { client } = makeCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: runFn,
});
expect(capturedSystem).toBe(SYSTEM_AGENTIC);
// Sanity check on the softened surface language that diverges from Stage 1.
expect(capturedSystem).toContain('Protocol (you SHOULD follow)');
expect(capturedSystem).not.toContain('Protocol (you MUST follow)');
expect(capturedSystem).toContain('general knowledge');
} finally {
substrate.close();
}
});
});

View File

@@ -0,0 +1,174 @@
/**
* Sprint 11 Task B2 fold-in integration test.
*
* Verifies judge-runner.ts wires resolveTieBreak into the Stage 2 judge
* runner on 3-primary splits per decisions/2026-04-22-tie-break-policy-locked.md.
*
* 1. 3-primary ensemble + 1-1-1 split → resolveTieBreak dispatched with
* the registered tieBreakerClient; fourth vote's verdict resolves to
* quadri-vendor plurality.
* 2. 3-primary ensemble + 1-1-1-1 (four-way after tie-break) → payload
* carries `tie_break_path: 'pm-escalation'` + `judge_error: 'PM_ESCALATION'`.
* 3. 3-primary ensemble + 2-1 majority → no tie-break call, legacy
* majority path taken, `tie_break_path` undefined.
* 4. 4-primary ensemble (not 3) → no tie-break fold-in even if 1-1-1-1;
* legacy `computeMajority` path preserved.
* 5. 3-primary ensemble WITHOUT tieBreakerClient → legacy path preserved
* even on 1-1-1 (back-compat).
*
* Uses stub LlmClients — no LLM spend.
*/
import { describe, it, expect } from 'vitest';
import { runJudge, type JudgeConfig, type JudgeTriple } from '../src/judge-runner.js';
import type { LlmClient } from '../src/judge-types.js';
function stubClient(verdict: 'correct' | 'incorrect', failureMode: null | 'F1' | 'F2' | 'F3' | 'F4' | 'F5'): LlmClient {
return {
async complete(_prompt: string) {
return JSON.stringify({
verdict,
failure_mode: failureMode,
rationale: `stub: ${verdict}/${failureMode ?? 'null'}`,
});
},
};
}
const TRIPLE: JudgeTriple = {
question: 'What is the capital of France?',
groundTruth: 'Paris',
contextExcerpt: 'France is a country in Europe. Its capital is Paris.',
modelAnswer: 'Paris',
};
describe('Sprint 11 B2 fold-in — judge-runner integration', () => {
it('3-primary 1-1-1 split dispatches resolveTieBreak with tieBreakerClient; resolves to quadri-vendor plurality', async () => {
// Three primary judges produce three distinct verdict keys (1-1-1).
const opusClient = stubClient('correct', null); // correct|NA
const gptClient = stubClient('incorrect', 'F3'); // incorrect|F3
const geminiClient = stubClient('incorrect', 'F4'); // incorrect|F4
// Tie-break client breaks the tie in favor of correct|NA.
const grokClient = stubClient('correct', null); // joins opus bucket
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opusClient);
clients.set('gpt-5.4-pro', gptClient);
clients.set('gemini-3.1-pro', geminiClient);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4-pro', 'gemini-3.1-pro'],
clients,
tieBreakerModel: 'xai/grok-4.20',
tieBreakerClient: grokClient,
};
const payload = await runJudge(TRIPLE, config);
expect(payload.tie_break_path).toBe('quadri-vendor');
expect(payload.tie_break_fourth_vendor).toBe('xai/grok-4.20');
expect(payload.judge_verdict).toBe('correct');
expect(payload.judge_failure_mode).toBeNull();
expect(payload.judge_model).toBe('ensemble_with_tiebreak');
expect(payload.judge_rationale).toContain('tie-break');
expect(payload.judge_rationale).toContain('quadri-vendor');
expect(payload.judge_ensemble).toHaveLength(4); // 3 primary + 1 fourth
expect(payload.judge_error).toBeUndefined();
});
it('3-primary 1-1-1 split where fourth vote is a fourth bucket → pm-escalation + judge_error', async () => {
const opusClient = stubClient('correct', null);
const gptClient = stubClient('incorrect', 'F2');
const geminiClient = stubClient('incorrect', 'F3');
const grokClient = stubClient('incorrect', 'F4'); // fourth bucket → 1-1-1-1
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opusClient);
clients.set('gpt-5.4-pro', gptClient);
clients.set('gemini-3.1-pro', geminiClient);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4-pro', 'gemini-3.1-pro'],
clients,
tieBreakerModel: 'xai/grok-4.20',
tieBreakerClient: grokClient,
};
const payload = await runJudge(TRIPLE, config);
expect(payload.tie_break_path).toBe('pm-escalation');
expect(payload.tie_break_fourth_vendor).toBe('xai/grok-4.20');
expect(payload.judge_error).toBe('PM_ESCALATION');
expect(payload.judge_verdict).toBeUndefined(); // no silent verdict
expect(payload.judge_failure_mode).toBeUndefined();
expect(payload.judge_ensemble).toHaveLength(4);
});
it('3-primary 2-1 majority → no tie-break dispatch; legacy path', async () => {
const opusClient = stubClient('correct', null);
const gptClient = stubClient('correct', null); // 2 for correct|NA
const geminiClient = stubClient('incorrect', 'F3'); // 1 for incorrect|F3
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opusClient);
clients.set('gpt-5.4-pro', gptClient);
clients.set('gemini-3.1-pro', geminiClient);
// Tie-breaker client is registered but should NOT be called.
let grokCalled = false;
const grokClient: LlmClient = {
async complete() {
grokCalled = true;
return JSON.stringify({ verdict: 'correct', failure_mode: null, rationale: 'should not be called' });
},
};
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4-pro', 'gemini-3.1-pro'],
clients,
tieBreakerModel: 'xai/grok-4.20',
tieBreakerClient: grokClient,
};
const payload = await runJudge(TRIPLE, config);
expect(grokCalled).toBe(false);
expect(payload.tie_break_path).toBeUndefined();
expect(payload.tie_break_fourth_vendor).toBeUndefined();
expect(payload.judge_verdict).toBe('correct');
expect(payload.judge_failure_mode).toBeNull();
expect(payload.judge_ensemble).toHaveLength(3); // 3 primary, no fourth
});
it('3-primary WITHOUT tieBreakerClient → legacy computeMajority path on 1-1-1 (back-compat preserved)', async () => {
const opusClient = stubClient('correct', null);
const gptClient = stubClient('incorrect', 'F3');
const geminiClient = stubClient('incorrect', 'F4');
const clients = new Map<string, LlmClient>();
clients.set('claude-opus-4-7', opusClient);
clients.set('gpt-5.4-pro', gptClient);
clients.set('gemini-3.1-pro', geminiClient);
const config: JudgeConfig = {
kind: 'ensemble',
models: ['claude-opus-4-7', 'gpt-5.4-pro', 'gemini-3.1-pro'],
clients,
// No tieBreakerModel / tieBreakerClient → legacy path.
};
const payload = await runJudge(TRIPLE, config);
expect(payload.tie_break_path).toBeUndefined();
// Legacy computeMajority 1-1-1 tie handling: returns the first-in-list
// judge's result verbatim (tie-breaker convention pre-dating B2).
// The judge_model field carries the tie-breaker's model id, NOT
// 'ensemble_majority' (that string only applies when a clear winner
// exists without tie).
expect(payload.judge_model).toBe('claude-opus-4-7');
expect(payload.judge_ensemble).toHaveLength(3);
});
});

View File

@@ -0,0 +1,588 @@
/**
* Task 2.5 Stage 1 — retrieval + agentic cell behavior tests.
*
* These are the substrate-dependent cells. Tests use:
* - a real ephemeral MindDB + HybridSearch (via substrate factory) for
* retrieval, so we exercise the actual RRF fusion path end-to-end.
* - a mock `runAgentLoopFn` for agentic, so we verify the cell wires the
* tool allowlist + maxTurns + AbortSignal correctly without standing up
* a live LiteLLM proxy.
*/
import { describe, expect, it, vi } from 'vitest';
import type { Embedder } from '@waggle/core';
import type { AgentLoopConfig, AgentResponse } from '@waggle/agent';
import type { LlmCallInput, LlmCallResult, LlmClient } from '../src/llm.js';
import type { DatasetInstance, ModelSpec } from '../src/types.js';
import { cells, makeSearchMemoryTool, SYSTEM_AGENTIC } from '../src/cells.js';
import { createSubstrate } from '../src/substrate.js';
const VEC_DIMS = 1024;
function createFakeEmbedder(dims: number = VEC_DIMS): Embedder {
const fnv1a = (s: string): number => {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h || 1;
};
const embedOne = (text: string): Float32Array => {
let state = fnv1a(text);
const v = new Float32Array(dims);
for (let i = 0; i < dims; i++) {
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
v[i] = ((state >>> 0) / 0x100000000) * 2 - 1;
}
let mag = 0;
for (let i = 0; i < dims; i++) mag += v[i] * v[i];
mag = Math.sqrt(mag);
if (mag > 0) for (let i = 0; i < dims; i++) v[i] /= mag;
return v;
};
return {
dimensions: dims,
async embed(text) { return embedOne(text); },
async embedBatch(texts) { return texts.map(embedOne); },
};
}
const MODEL: ModelSpec = {
id: 'qwen3.6-35b-a3b-via-dashscope-direct',
displayName: 'Qwen3.6-35B-A3B (DashScope direct)',
provider: 'alibaba',
litellmModel: 'dashscope-direct/qwen3.6-35b-a3b',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
};
const INSTANCE: DatasetInstance = {
instance_id: 'locomo_conv-01_q000',
question: 'When did Caroline paint a sunrise?',
context: 'full conversation context would be here in reality',
expected: ['2022'],
conversation_id: 'conv-01',
};
/** Build an LlmClient that captures every call argument for assertion. */
function createCapturingLlm(response: Partial<LlmCallResult> = {}): {
client: LlmClient;
calls: LlmCallInput[];
} {
const calls: LlmCallInput[] = [];
const client: LlmClient = {
async call(input: LlmCallInput): Promise<LlmCallResult> {
calls.push(input);
return {
text: response.text ?? 'test-answer',
inputTokens: response.inputTokens ?? 100,
outputTokens: response.outputTokens ?? 10,
latencyMs: response.latencyMs ?? 42,
costUsd: response.costUsd ?? 0.0001,
failureMode: response.failureMode ?? null,
};
},
};
return { client, calls };
}
describe('no-context cell — Stage 2-Retry §1.1 true zero-memory baseline', () => {
it('sends question-only user prompt, no instance.context, no memory injection', async () => {
const { client, calls } = createCapturingLlm();
const result = await cells['no-context']({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
});
expect(result.text).toBe('test-answer');
expect(calls).toHaveLength(1);
expect(calls[0].userPrompt).toBe(`Question: ${INSTANCE.question}`);
expect(calls[0].userPrompt).not.toContain(INSTANCE.context);
expect(calls[0].userPrompt).not.toContain('# Recalled Memories');
expect(calls[0].userPrompt).not.toContain('Context:');
});
it('uses SYSTEM_BASELINE (not EVOLVED) for format consistency with raw/retrieval', async () => {
const { client, calls } = createCapturingLlm();
await cells['no-context']({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
});
// Phase 2.3 Option A refactor: SYSTEM_BASELINE deleted; baseline-style
// system prompt now comes from Phase 1.2 prompt-shapes via the cell's
// FACTOID_BASELINE_PERSONA. Assertions updated to fragment-based on the
// new persona phrasing + negative assertion preserves the original
// intent (no-context cell does NOT use evolved/strict-extraction framing).
expect(calls[0].systemPrompt).toContain('short-answer factoid');
expect(calls[0].systemPrompt).not.toContain('extracts the exact answer span');
});
it('does NOT require substrate or litellm (no dependencies beyond LlmClient)', async () => {
const { client } = createCapturingLlm();
// No substrate, no litellm — pure LLM call. Must not throw.
const result = await cells['no-context']({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
});
expect(result.failureMode).toBeNull();
expect(result.text).toBeTruthy();
});
});
describe('retrieval cell — real HybridSearch, Task 2.5 Stage 1', () => {
it('calls substrate search with the instance question and top-K', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
// memory_frames.gop_id FKs to sessions.gop_id — ensure sessions first.
substrate.sessions.ensure('conv-01');
substrate.sessions.ensure('conv-02');
const f1 = substrate.frames.createIFrame('conv-01', 'Caroline: I painted a sunrise in 2022', 'normal', 'import');
const f2 = substrate.frames.createIFrame('conv-01', 'Melanie: Nice painting', 'normal', 'import');
const f3 = substrate.frames.createIFrame('conv-02', 'Dan: unrelated turn', 'normal', 'import');
await substrate.search.indexFramesBatch([
{ id: f1.id, content: f1.content },
{ id: f2.id, content: f2.content },
{ id: f3.id, content: f3.content },
]);
const { client, calls } = createCapturingLlm();
const result = await cells.retrieval({
instance: INSTANCE,
model: MODEL,
llm: client,
turnId: 'turn-1',
substrate,
retrievalTopK: 5,
});
expect(result.text).toBe('test-answer');
expect(calls).toHaveLength(1);
const userPrompt = calls[0].userPrompt;
expect(userPrompt).toContain('# Recalled Memories');
expect(userPrompt).toContain('Caroline');
expect(userPrompt).toContain(INSTANCE.question);
} finally {
substrate.close();
}
});
it('uses baseline system prompt (not evolved)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
substrate.sessions.ensure('c');
const f = substrate.frames.createIFrame('c', 'Alice: hello', 'normal', 'import');
await substrate.search.indexFramesBatch([{ id: f.id, content: f.content }]);
const { client, calls } = createCapturingLlm();
await cells.retrieval({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't', substrate,
});
// Phase 2.3 Option A refactor: SYSTEM_BASELINE deleted; baseline-style
// system prompt now comes from Phase 1.2 prompt-shapes via the cell's
// FACTOID_BASELINE_PERSONA. Same intent as before — retrieval cell uses
// baseline (not strict-extraction) framing — expressed via fragment
// assertion on the new persona phrasing.
expect(calls[0].systemPrompt).toContain('short-answer factoid');
expect(calls[0].systemPrompt).not.toContain('extracts the exact answer span');
} finally {
substrate.close();
}
});
it('defaults retrievalTopK to 20 when unspecified (Stage 2-Retry §1.2)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search');
const { client } = createCapturingLlm();
const instanceNoConv: DatasetInstance = { ...INSTANCE };
delete instanceNoConv.conversation_id;
await cells.retrieval({
instance: instanceNoConv, model: MODEL, llm: client, turnId: 't', substrate,
});
expect(spy).toHaveBeenCalledWith(instanceNoConv.question, { limit: 20 });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('passes gopId filter when conversation_id is set on the instance (Stage 2-Retry §1.2)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const { client } = createCapturingLlm();
const instanceWithConv: DatasetInstance = { ...INSTANCE, conversation_id: 'conv-26' };
await cells.retrieval({
instance: instanceWithConv, model: MODEL, llm: client, turnId: 't', substrate,
});
expect(spy).toHaveBeenCalledWith(instanceWithConv.question, { limit: 20, gopId: 'conv-26' });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('omits gopId when conversation_id is absent (backward compat)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const { client } = createCapturingLlm();
const instanceNoConv: DatasetInstance = { ...INSTANCE };
delete instanceNoConv.conversation_id;
await cells.retrieval({
instance: instanceNoConv, model: MODEL, llm: client, turnId: 't', substrate,
retrievalTopK: 7,
});
// When no conversation_id, no gopId in call; only limit.
expect(spy).toHaveBeenCalledWith(instanceNoConv.question, { limit: 7 });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('emits (none) marker when no memories are retrieved', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { client, calls } = createCapturingLlm();
await cells.retrieval({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't', substrate,
});
expect(calls[0].userPrompt).toContain('(none)');
} finally {
substrate.close();
}
});
it('throws a clear error when substrate is missing', async () => {
const { client } = createCapturingLlm();
await expect(
cells.retrieval({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
}),
).rejects.toThrow(/requires a Substrate/);
});
});
describe('agentic cell — agent-loop plus search_memory, Task 2.5 Stage 1', () => {
function createMockRunAgentLoop(response: Partial<AgentResponse> = {}): {
fn: (config: AgentLoopConfig) => Promise<AgentResponse>;
configs: AgentLoopConfig[];
} {
const configs: AgentLoopConfig[] = [];
const fn = async (config: AgentLoopConfig): Promise<AgentResponse> => {
configs.push(config);
return {
content: response.content ?? '2022',
toolsUsed: response.toolsUsed ?? ['search_memory'],
usage: response.usage ?? { inputTokens: 250, outputTokens: 5 },
};
};
return { fn, configs };
}
it('invokes runAgentLoop with the search_memory tool only', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 'turn-1',
substrate,
litellm: { url: 'http://localhost:4000', apiKey: 'sk-test' },
runAgentLoopFn: fn,
});
expect(configs).toHaveLength(1);
expect(configs[0].tools.map(t => t.name)).toEqual(['search_memory']);
} finally {
substrate.close();
}
});
it('passes maxTurns=3 by default (GATE-S0 lock)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
});
expect(configs[0].maxTurns).toBe(3);
} finally {
substrate.close();
}
});
it('honours an agenticMaxTurns override', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
agenticMaxTurns: 5,
});
expect(configs[0].maxTurns).toBe(5);
} finally {
substrate.close();
}
});
it('threads an AbortSignal that can cancel after the timeout', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
agenticTimeoutMs: 50,
});
expect(configs[0].signal).toBeInstanceOf(AbortSignal);
} finally {
substrate.close();
}
});
it('uses SYSTEM_AGENTIC prompt verbatim', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
});
expect(configs[0].systemPrompt).toBe(SYSTEM_AGENTIC);
} finally {
substrate.close();
}
});
it('passes the question as the single user message', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn, configs } = createMockRunAgentLoop();
const { client } = createCapturingLlm();
await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
});
expect(configs[0].messages).toEqual([{ role: 'user', content: INSTANCE.question }]);
} finally {
substrate.close();
}
});
it('returns LlmCallResult shape with cost from model pricing and token usage', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { fn } = createMockRunAgentLoop({
content: 'final answer',
usage: { inputTokens: 1_000_000, outputTokens: 500_000 },
});
const { client } = createCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: fn,
});
expect(result.text).toBe('final answer');
expect(result.inputTokens).toBe(1_000_000);
expect(result.outputTokens).toBe(500_000);
// 1M input tokens x $0.2 + 500K output tokens x $0.8/M = 0.2 + 0.4 = 0.6
expect(result.costUsd).toBeCloseTo(0.6, 5);
expect(result.failureMode).toBeNull();
} finally {
substrate.close();
}
});
it('reports agentic_error_* failureMode on throw', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const throwingFn = async (): Promise<AgentResponse> => {
const e = new Error('transport blew up');
e.name = 'TypeError';
throw e;
};
const { client } = createCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: throwingFn,
});
expect(result.failureMode).toBe('agentic_error_TypeError');
expect(result.text).toBe('');
expect(result.costUsd).toBe(0);
} finally {
substrate.close();
}
});
it('reports timeout failureMode on AbortError', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const abortFn = async (): Promise<AgentResponse> => {
const e = new Error('aborted');
e.name = 'AbortError';
throw e;
};
const { client } = createCapturingLlm();
const result = await cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
substrate, litellm: { url: 'u', apiKey: 'k' }, runAgentLoopFn: abortFn,
});
expect(result.failureMode).toBe('timeout');
} finally {
substrate.close();
}
});
it('throws clear error when substrate missing', async () => {
const { client } = createCapturingLlm();
await expect(
cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't',
litellm: { url: 'u', apiKey: 'k' },
}),
).rejects.toThrow(/requires a Substrate/);
});
it('throws clear error when litellm config missing', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const { client } = createCapturingLlm();
await expect(
cells.agentic({
instance: INSTANCE, model: MODEL, llm: client, turnId: 't', substrate,
}),
).rejects.toThrow(/requires litellm/);
} finally {
substrate.close();
}
});
});
describe('makeSearchMemoryTool', () => {
it('returns a ToolDefinition with name=search_memory and a query param', () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const tool = makeSearchMemoryTool(substrate);
expect(tool.name).toBe('search_memory');
expect(tool.offlineCapable).toBe(true);
const params = tool.parameters as { required?: string[]; properties: Record<string, unknown> };
expect(params.required).toEqual(['query']);
expect(params.properties.query).toBeDefined();
expect(params.properties.limit).toBeDefined();
} finally {
substrate.close();
}
});
it('executes against substrate search and formats results', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
substrate.sessions.ensure('c');
const f = substrate.frames.createIFrame('c', 'Caroline: painted sunrise 2022', 'normal', 'import');
await substrate.search.indexFramesBatch([{ id: f.id, content: f.content }]);
const tool = makeSearchMemoryTool(substrate, 5);
const out = await tool.execute({ query: 'sunrise', limit: 3 });
expect(out).toContain('Caroline');
expect(out).toContain('sunrise');
} finally {
substrate.close();
}
});
it('returns a clear message on empty query', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const tool = makeSearchMemoryTool(substrate);
const out = await tool.execute({ query: ' ' });
expect(out).toMatch(/query is required/);
} finally {
substrate.close();
}
});
it('returns no-memories marker on empty corpus', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const tool = makeSearchMemoryTool(substrate);
const out = await tool.execute({ query: 'whatever' });
expect(out).toBe('(no memories found)');
} finally {
substrate.close();
}
});
it('clamps limit to 1..50 (Stage 2-Retry §1.2 upper bound relaxed 20→50)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const tool = makeSearchMemoryTool(substrate, 20);
await tool.execute({ query: 'x', limit: 999 });
expect(spy).toHaveBeenLastCalledWith('x', { limit: 50 });
await tool.execute({ query: 'x', limit: -5 });
expect(spy).toHaveBeenLastCalledWith('x', { limit: 1 });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('default limit is 20 (Stage 2-Retry §1.2 bump 10→20)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const tool = makeSearchMemoryTool(substrate); // no explicit default
await tool.execute({ query: 'x' });
expect(spy).toHaveBeenLastCalledWith('x', { limit: 20 });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('when boundToGopId is set, scopes every call to that gopId (Stage 2-Retry §1.2)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const tool = makeSearchMemoryTool(substrate, 20, 'conv-42');
await tool.execute({ query: 'anything' });
expect(spy).toHaveBeenLastCalledWith('anything', { limit: 20, gopId: 'conv-42' });
// Agent-side args.gopId must NOT override the bound scope (not part of
// the tool schema either way — silent drop).
await tool.execute({ query: 'still-scoped', gopId: 'conv-other' } as Record<string, unknown>);
expect(spy).toHaveBeenLastCalledWith('still-scoped', { limit: 20, gopId: 'conv-42' });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('when boundToGopId is NOT set, call has no gopId field (backward compat)', async () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const spy = vi.spyOn(substrate.search, 'search').mockResolvedValue([]);
const tool = makeSearchMemoryTool(substrate, 20);
await tool.execute({ query: 'x' });
expect(spy).toHaveBeenLastCalledWith('x', { limit: 20 });
spy.mockRestore();
} finally {
substrate.close();
}
});
it('description mentions auto-scope when bound to gopId', () => {
const substrate = createSubstrate({ embedder: createFakeEmbedder() });
try {
const bound = makeSearchMemoryTool(substrate, 20, 'conv-xyz');
const unbound = makeSearchMemoryTool(substrate, 20);
expect(bound.description).toContain('auto-restricted to the current conversation');
expect(unbound.description).not.toContain('auto-restricted');
} finally {
substrate.close();
}
});
});

View File

@@ -0,0 +1,70 @@
/**
* Sprint 12 Task 1 Blocker #2 — cell enum smoke tests.
*
* Acceptance criterion 6: cell parsing + dispatch tests pass with the A3
* LOCK nomenclature (`raw | filtered | compressed | full-context`).
*
* These tests are narrower than smoke.test.ts — the goal is to pin the
* cell enum surface at the type + value + dispatch layers so a future
* silent rename (or a half-rename) fails CI immediately rather than
* drifting downstream.
*/
import { describe, expect, it } from 'vitest';
import { cells, isCellName } from '../src/cells.js';
import type { CellName } from '../src/types.js';
// Sprint 12 Task 1 Blocker #2 shipped the first four names. Sprint 12 Task
// 2.5 Stage 1 (2026-04-23) added `retrieval` + `agentic` — backed by real
// HybridSearch + agent-loop respectively. Sprint 12 Task 2.5 Stage 2-Retry
// (2026-04-24) added `no-context` — the true zero-memory baseline used by
// the Stage 2-Retry memory-lift criterion. All new names are acceptable;
// legacy pre-Sprint-12 names must still be rejected.
const CANONICAL_NAMES: readonly CellName[] = [
'raw',
'filtered',
'compressed',
'full-context',
'retrieval',
'agentic',
'no-context',
];
const LEGACY_NAMES = ['memory-only', 'evolve-only', 'full-stack'] as const;
describe('CellName enum (Sprint 12 Task 1 Blocker #2 rename + Task 2.5 Stage 1 + Stage 2-Retry extensions)', () => {
it('exposes exactly the seven canonical cell names as object keys', () => {
const keys = Object.keys(cells).sort();
expect(keys).toEqual([...CANONICAL_NAMES].sort());
});
it('isCellName accepts every canonical name', () => {
for (const name of CANONICAL_NAMES) {
expect(isCellName(name)).toBe(true);
}
});
it('isCellName rejects the pre-Sprint-12 legacy names', () => {
for (const legacy of LEGACY_NAMES) {
expect(isCellName(legacy)).toBe(false);
}
});
it('isCellName rejects unrelated strings', () => {
for (const bad of ['', 'RAW', 'full_context', 'full-stack-v2', 'naive-rag', ' raw ']) {
expect(isCellName(bad)).toBe(false);
}
});
it('every cell key in the dispatch table is typed as a CellName', () => {
// Compile-time check: if a new cell is added to the type union but not
// to `cells`, TS fails the Record<CellName, CellFn> contract. If a cell
// is added to `cells` but not to the union, TS fails the `as CellName`
// narrowing below. Runtime shape check is redundant but documents the
// guarantee.
for (const key of Object.keys(cells)) {
expect(isCellName(key)).toBe(true);
const narrowed = key as CellName;
expect(typeof cells[narrowed]).toBe('function');
}
});
});

View File

@@ -0,0 +1,133 @@
/**
* Sprint 12 Task 1 Blocker #3 — CLI flag parsing tests.
*
* Exercises the 4 new flags added to runner.parseArgs() for the
* pre-registration surface: --manifest-hash, --emit-preregistration-event /
* --no-emit-preregistration-event, --per-cell, --judge-tiebreak.
*
* Per R1 verification: the CLI parser is an inline `switch`-based walker in
* runner.ts rather than commander/yargs. New flags were added alongside the
* existing Sprint 7/8/9 flags to keep scope minimal — this test file pins
* the new surface so accidental regressions in the switch cases fail fast.
*/
import { describe, expect, it } from 'vitest';
import { buildRuns, parseArgs } from '../src/runner.js';
describe('--manifest-hash', () => {
it('accepts a valid 64-char lowercase hex SHA-256', () => {
const hash = 'a'.repeat(64);
const args = parseArgs(['--manifest-hash', hash]);
expect(args.manifestHash).toBe(hash);
});
it('lowercases uppercase input for consistency', () => {
const hash = 'A'.repeat(64);
const args = parseArgs(['--manifest-hash', hash]);
expect(args.manifestHash).toBe('a'.repeat(64));
});
it('rejects a too-short hash', () => {
expect(() => parseArgs(['--manifest-hash', 'deadbeef'])).toThrow(/Invalid --manifest-hash/);
});
it('rejects a hash with non-hex characters', () => {
const bad = 'z'.repeat(64);
expect(() => parseArgs(['--manifest-hash', bad])).toThrow(/Invalid --manifest-hash/);
});
it('defaults to undefined when omitted', () => {
const args = parseArgs(['--cell', 'raw']);
expect(args.manifestHash).toBeUndefined();
});
});
describe('--emit-preregistration-event / --no-emit-preregistration-event', () => {
it('defaults to true when neither flag is supplied', () => {
const args = parseArgs(['--cell', 'raw']);
expect(args.emitPreregistrationEvent).toBe(true);
});
it('--emit-preregistration-event sets the flag to true explicitly', () => {
const args = parseArgs(['--emit-preregistration-event', '--cell', 'raw']);
expect(args.emitPreregistrationEvent).toBe(true);
});
it('--no-emit-preregistration-event sets the flag to false', () => {
const args = parseArgs(['--no-emit-preregistration-event', '--cell', 'raw']);
expect(args.emitPreregistrationEvent).toBe(false);
});
it('last flag wins when both are supplied', () => {
const args = parseArgs(['--emit-preregistration-event', '--no-emit-preregistration-event']);
expect(args.emitPreregistrationEvent).toBe(false);
});
});
describe('--per-cell', () => {
it('accumulates multiple values into an ordered list', () => {
const args = parseArgs(['--per-cell', 'raw', '--per-cell', 'filtered', '--per-cell', 'full-context']);
expect(args.perCell).toEqual(['raw', 'filtered', 'full-context']);
});
it('single --per-cell value yields a single-element list', () => {
const args = parseArgs(['--per-cell', 'raw']);
expect(args.perCell).toEqual(['raw']);
});
it('undefined perCell when flag is omitted', () => {
const args = parseArgs(['--cell', 'raw']);
expect(args.perCell).toBeUndefined();
});
it('rejects empty value', () => {
expect(() => parseArgs(['--per-cell', ''])).toThrow(/Invalid --per-cell/);
});
it('buildRuns honors --per-cell over --cell and --all-cells', () => {
const args = parseArgs([
'--all-cells',
'--cell', 'raw',
'--per-cell', 'filtered',
'--per-cell', 'compressed',
]);
const runs = buildRuns(args);
expect(runs).toHaveLength(2);
expect(runs.map(r => r.name)).toEqual(['filtered', 'compressed']);
});
it('buildRuns rejects unknown cell names in --per-cell', () => {
const args = parseArgs(['--per-cell', 'raw', '--per-cell', 'nonsense-cell']);
expect(() => buildRuns(args)).toThrow(/Unknown cell: nonsense-cell/);
});
});
describe('--judge-tiebreak', () => {
it('accepts quadri-vendor', () => {
const args = parseArgs(['--judge-tiebreak', 'quadri-vendor']);
expect(args.judgeTiebreak).toBe('quadri-vendor');
});
it('accepts pm-escalation', () => {
const args = parseArgs(['--judge-tiebreak', 'pm-escalation']);
expect(args.judgeTiebreak).toBe('pm-escalation');
});
it('accepts majority', () => {
const args = parseArgs(['--judge-tiebreak', 'majority']);
expect(args.judgeTiebreak).toBe('majority');
});
it('rejects unknown strategy', () => {
expect(() => parseArgs(['--judge-tiebreak', 'coin-flip'])).toThrow(/Invalid --judge-tiebreak/);
});
it('rejects missing value', () => {
expect(() => parseArgs(['--judge-tiebreak'])).toThrow(/Invalid --judge-tiebreak/);
});
it('defaults to undefined when omitted', () => {
const args = parseArgs(['--cell', 'raw']);
expect(args.judgeTiebreak).toBeUndefined();
});
});

View File

@@ -0,0 +1,177 @@
/**
* Sprint 12 Task 1 Blocker #1 — dataset loader smoke tests.
*
* Acceptance criteria (per brief §1):
* 1. Canonical LoCoMo archive is present at the expected path, loads,
* and contains a stable number of instances. (Paper claim: 1540.
* Actual non-adversarial-with-evidence count: 1531. Delta documented
* in locomo-1540.meta.json.)
* 2. `getDatasetVersion` returns a deterministic SHA-256 hex across 3
* consecutive calls against the same archive.
* 3. `loadDataset` throws `DatasetMissingError` when the archive path
* is absent.
* 4. `BENCH_SYNTHETIC_DATASET=1` env flag re-enables the synthetic
* fallback (dev convenience only).
* 5. `getDatasetVersion` for synthetic specs returns the static
* `synthetic-scaffold-v1` string.
* 6. The computed hash matches the one written into
* `locomo-1540.meta.json` by the build script.
*
* Zero LLM calls. Pure loader + hash verification.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
DatasetMissingError,
SYNTHETIC_DATASET_VERSION,
getDatasetVersion,
loadDataset,
} from '../src/datasets.js';
import type { DatasetSpec } from '../src/types.js';
const HERE = url.fileURLToPath(import.meta.url);
const HARNESS_ROOT = path.resolve(path.dirname(HERE), '..');
const DATA_ROOT = path.resolve(HARNESS_ROOT, '..', 'data');
const LOCOMO_ARCHIVE = path.join(DATA_ROOT, 'locomo', 'locomo-1540.jsonl');
const LOCOMO_META = path.join(DATA_ROOT, 'locomo', 'locomo-1540.meta.json');
const LOCOMO_SPEC: DatasetSpec = {
id: 'locomo',
displayName: 'LoCoMo canonical',
dataPath: 'locomo/locomo-1540.jsonl',
source: 'external',
};
const SYNTHETIC_SPEC: DatasetSpec = {
id: 'synthetic',
displayName: 'Synthetic scaffold',
dataPath: 'synthetic/placeholder.jsonl',
source: 'synthetic',
};
describe('canonical LoCoMo archive', () => {
it('exists at the expected path', () => {
expect(fs.existsSync(LOCOMO_ARCHIVE)).toBe(true);
});
it('loads via loadDataset with a positive instance count', () => {
const instances = loadDataset(LOCOMO_SPEC, DATA_ROOT);
// Actual count is 1531 at build time (paper claim 1540 minus 9 edge
// cases with no resolvable evidence). Assert the known-good number so
// silent drift is caught; update deliberately if the upstream source
// is replaced.
expect(instances.length).toBe(1531);
for (const inst of instances) {
expect(inst.instance_id).toMatch(/^locomo_conv-\d+_q\d{3}$/);
expect(typeof inst.question).toBe('string');
expect(inst.question.length).toBeGreaterThan(0);
expect(Array.isArray(inst.expected)).toBe(true);
expect(inst.expected.length).toBeGreaterThan(0);
}
});
it('sidecar meta.json records the same count and a pinned hash', () => {
expect(fs.existsSync(LOCOMO_META)).toBe(true);
const meta = JSON.parse(fs.readFileSync(LOCOMO_META, 'utf-8')) as {
dataset_version: string;
instance_count: number;
paper_total_claim: number;
actual_count: number;
};
expect(meta.instance_count).toBe(1531);
expect(meta.actual_count).toBe(1531);
expect(meta.paper_total_claim).toBe(1540);
expect(meta.dataset_version).toMatch(/^[0-9a-f]{64}$/);
});
});
describe('getDatasetVersion determinism', () => {
it('returns identical SHA-256 hex across 3 consecutive calls', () => {
const a = getDatasetVersion(LOCOMO_SPEC, DATA_ROOT);
const b = getDatasetVersion(LOCOMO_SPEC, DATA_ROOT);
const c = getDatasetVersion(LOCOMO_SPEC, DATA_ROOT);
expect(a).toBe(b);
expect(b).toBe(c);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('matches the hash written to locomo-1540.meta.json', () => {
const version = getDatasetVersion(LOCOMO_SPEC, DATA_ROOT);
const meta = JSON.parse(fs.readFileSync(LOCOMO_META, 'utf-8')) as {
dataset_version: string;
};
expect(version).toBe(meta.dataset_version);
});
it('reproduces the hash when computed externally from the same bytes', () => {
const version = getDatasetVersion(LOCOMO_SPEC, DATA_ROOT);
const buf = fs.readFileSync(LOCOMO_ARCHIVE);
const manual = crypto.createHash('sha256').update(buf).digest('hex');
expect(version).toBe(manual);
});
it('returns the static string for synthetic specs', () => {
expect(getDatasetVersion(SYNTHETIC_SPEC, DATA_ROOT)).toBe(SYNTHETIC_DATASET_VERSION);
expect(SYNTHETIC_DATASET_VERSION).toBe('synthetic-scaffold-v1');
});
});
describe('missing-archive behaviour', () => {
let tmpRoot: string;
const previousEnv = process.env.BENCH_SYNTHETIC_DATASET;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-bench-missing-'));
delete process.env.BENCH_SYNTHETIC_DATASET;
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
if (previousEnv === undefined) {
delete process.env.BENCH_SYNTHETIC_DATASET;
} else {
process.env.BENCH_SYNTHETIC_DATASET = previousEnv;
}
});
it('loadDataset throws DatasetMissingError when archive is absent', () => {
expect(() => loadDataset(LOCOMO_SPEC, tmpRoot)).toThrow(DatasetMissingError);
});
it('getDatasetVersion throws DatasetMissingError when archive is absent', () => {
expect(() => getDatasetVersion(LOCOMO_SPEC, tmpRoot)).toThrow(DatasetMissingError);
});
it('DatasetMissingError exposes dataset id + resolved path', () => {
try {
loadDataset(LOCOMO_SPEC, tmpRoot);
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(DatasetMissingError);
const typed = err as DatasetMissingError;
expect(typed.datasetId).toBe('locomo');
expect(typed.resolvedPath.endsWith('locomo-1540.jsonl')).toBe(true);
expect(typed.message).toContain('build-locomo-canonical');
expect(typed.message).toContain('BENCH_SYNTHETIC_DATASET=1');
}
});
it('BENCH_SYNTHETIC_DATASET=1 re-enables synthetic fallback for loadDataset', () => {
process.env.BENCH_SYNTHETIC_DATASET = '1';
const instances = loadDataset(LOCOMO_SPEC, tmpRoot);
// Synthetic scaffold is 60 deterministic instances.
expect(instances.length).toBe(60);
for (const inst of instances) {
expect(inst.instance_id).toMatch(/^synthetic_\d{3}$/);
}
});
it('BENCH_SYNTHETIC_DATASET=1 returns the synthetic version string', () => {
process.env.BENCH_SYNTHETIC_DATASET = '1';
expect(getDatasetVersion(LOCOMO_SPEC, tmpRoot)).toBe(SYNTHETIC_DATASET_VERSION);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Sprint 12 Task 1 Blocker #6 — failure distribution aggregator tests.
*
* Acceptance (brief § 2.1 B):
* 1. counts sum equals total
* 2. f_other_rate computation correct
* 3. review_flag at 11% triggers
* 4. review_flag at 10% does NOT trigger (strict greater-than)
* 5. sample captures first 10 F_other rationales in order
* 6. zero-F_other input yields empty sample
*/
import { describe, expect, it } from 'vitest';
import {
computeFailureDistribution,
type FailureRow,
} from '../../src/failure-taxonomy/aggregate.js';
describe('computeFailureDistribution — structural invariants', () => {
it('counts sum equals total', () => {
const rows: FailureRow[] = [
{ failure_code: null },
{ failure_code: null },
{ failure_code: 'F1' },
{ failure_code: 'F3' },
{ failure_code: 'F6' },
];
const dist = computeFailureDistribution(rows);
expect(dist.total).toBe(5);
const summed =
dist.counts.null +
dist.counts.F1 + dist.counts.F2 + dist.counts.F3 +
dist.counts.F4 + dist.counts.F5 + dist.counts.F6 +
dist.counts.F_other;
expect(summed).toBe(dist.total);
});
it('f_other_rate = F_other count / total', () => {
const rows: FailureRow[] = [
{ failure_code: 'F_other', rationale: 'rationale one two three four five six seven eight nine' },
{ failure_code: 'F_other', rationale: 'another rationale two three four five six seven eight nine' },
{ failure_code: null },
{ failure_code: 'F1' },
{ failure_code: null },
];
const dist = computeFailureDistribution(rows);
expect(dist.counts.F_other).toBe(2);
expect(dist.total).toBe(5);
expect(dist.f_other_rate).toBeCloseTo(2 / 5, 10);
});
});
describe('computeFailureDistribution — F_other review flag threshold', () => {
function buildRows(fOtherCount: number, total: number): FailureRow[] {
const rows: FailureRow[] = [];
for (let i = 0; i < fOtherCount; i++) {
rows.push({
failure_code: 'F_other',
rationale: `rationale ${i} padded padded padded padded padded padded padded padded padded`,
});
}
for (let i = 0; i < total - fOtherCount; i++) {
rows.push({ failure_code: null });
}
return rows;
}
it('flag triggers at 11% (11/100 > 10%)', () => {
const dist = computeFailureDistribution(buildRows(11, 100));
expect(dist.f_other_rate).toBeCloseTo(0.11, 10);
expect(dist.f_other_review_flag).toBe(true);
});
it('flag does NOT trigger at 10% (strict greater-than: 10/100 not > 10%)', () => {
const dist = computeFailureDistribution(buildRows(10, 100));
expect(dist.f_other_rate).toBeCloseTo(0.10, 10);
expect(dist.f_other_review_flag).toBe(false);
});
it('flag does not trigger on empty input', () => {
const dist = computeFailureDistribution([]);
expect(dist.total).toBe(0);
expect(dist.f_other_rate).toBe(0);
expect(dist.f_other_review_flag).toBe(false);
});
});
describe('computeFailureDistribution — F_other rationale sample', () => {
it('captures first 10 F_other rationales in input order', () => {
const rows: FailureRow[] = [];
for (let i = 0; i < 15; i++) {
rows.push({
failure_code: 'F_other',
rationale: `rationale-${i} padded padded padded padded padded padded padded padded padded`,
});
}
const dist = computeFailureDistribution(rows);
expect(dist.f_other_rationales_sample).toHaveLength(10);
expect(dist.f_other_rationales_sample[0]).toMatch(/^rationale-0 /);
expect(dist.f_other_rationales_sample[9]).toMatch(/^rationale-9 /);
});
it('zero-F_other input yields empty sample array', () => {
const rows: FailureRow[] = [
{ failure_code: null },
{ failure_code: 'F1' },
{ failure_code: 'F2' },
];
const dist = computeFailureDistribution(rows);
expect(dist.counts.F_other).toBe(0);
expect(dist.f_other_rationales_sample).toEqual([]);
});
it('skips F_other rows without a rationale string in the sample (robustness)', () => {
const rows: FailureRow[] = [
{ failure_code: 'F_other', rationale: null },
{ failure_code: 'F_other', rationale: 'valid rationale one two three four five six seven eight' },
];
const dist = computeFailureDistribution(rows);
expect(dist.counts.F_other).toBe(2);
expect(dist.f_other_rationales_sample).toHaveLength(1);
});
});

View File

@@ -0,0 +1,84 @@
/**
* Sprint 12 Task 1 Blocker #6 — failure-code enum + definitions tests.
*
* Acceptance (brief § 2.1 B):
* 1. FAILURE_CODES length === 7 (F1..F6 + F_other)
* 2. All definitions present (F1..F6 + F_other)
* 3. FailureCode type compiles as the 8-value union (compile-time
* proof via exhaustive switch)
* 4. No duplicate code entries
*/
import { describe, expect, it } from 'vitest';
import {
FAILURE_CODE_DEFINITIONS,
FAILURE_CODES,
FAILURE_TAXONOMY_VERSION,
F_OTHER_REVIEW_THRESHOLD,
type FailureCode,
} from '../../src/failure-taxonomy/codes.js';
describe('FAILURE_CODES constant', () => {
it('lists exactly 7 non-null codes in A3 LOCK §6 order', () => {
expect(FAILURE_CODES).toHaveLength(7);
expect(FAILURE_CODES).toEqual(['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F_other']);
});
it('has no duplicates', () => {
const unique = new Set(FAILURE_CODES);
expect(unique.size).toBe(FAILURE_CODES.length);
});
});
describe('FAILURE_CODE_DEFINITIONS record', () => {
it('provides a non-empty definition for every non-null code', () => {
for (const code of FAILURE_CODES) {
const def = FAILURE_CODE_DEFINITIONS[code];
expect(typeof def).toBe('string');
expect(def.length).toBeGreaterThan(10);
}
});
it('definitions match A3 LOCK §6 short-form taxonomy labels', () => {
expect(FAILURE_CODE_DEFINITIONS.F1).toContain('contradicts-ground-truth');
expect(FAILURE_CODE_DEFINITIONS.F2).toContain('partial-answer');
expect(FAILURE_CODE_DEFINITIONS.F3).toContain('off-topic');
expect(FAILURE_CODE_DEFINITIONS.F4).toContain('refusal');
expect(FAILURE_CODE_DEFINITIONS.F5).toContain('tool-use-error');
expect(FAILURE_CODE_DEFINITIONS.F6).toContain('format-violation');
expect(FAILURE_CODE_DEFINITIONS.F_other).toContain('F-other');
});
});
describe('FailureCode union shape', () => {
it('FailureCode is the exhaustive 8-value union (null + F1..F6 + F_other)', () => {
// Compile-time + runtime coverage: every case must be handled, else
// TS flags the `never` arm and the test fails at compile.
const all: FailureCode[] = [null, 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F_other'];
expect(all).toHaveLength(8);
for (const code of all) {
switch (code) {
case null:
case 'F1':
case 'F2':
case 'F3':
case 'F4':
case 'F5':
case 'F6':
case 'F_other':
// Exhaustive — no default needed.
break;
}
}
});
});
describe('taxonomy version + review threshold constants', () => {
it('FAILURE_TAXONOMY_VERSION pinned to "F1-F6+other v1"', () => {
expect(FAILURE_TAXONOMY_VERSION).toBe('F1-F6+other v1');
});
it('F_OTHER_REVIEW_THRESHOLD = 0.10 (A3 LOCK §6 strict-greater-than gate)', () => {
expect(F_OTHER_REVIEW_THRESHOLD).toBe(0.10);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Sprint 12 Task 1 Blocker #6 — judge rubric block tests.
*
* Acceptance (brief § 2.1 B):
* 1. Block contains verbatim "F1 — contradicts-ground-truth"
* 2. Block contains verbatim "F6 — format-violation"
* 3. Block contains "F-other" escape clause (≥10-word rationale directive)
* 4. Block contains taxonomy version tag "F1-F6+other v1"
*
* Plus: determinism (same bytes on two calls).
*/
import { describe, expect, it } from 'vitest';
import { buildJudgeRubricBlock } from '../../src/failure-taxonomy/rubric.js';
describe('buildJudgeRubricBlock', () => {
it('contains the F1 — contradicts-ground-truth label verbatim', () => {
const block = buildJudgeRubricBlock();
expect(block).toContain('F1 — contradicts-ground-truth');
});
it('contains the F6 — format-violation label verbatim', () => {
const block = buildJudgeRubricBlock();
expect(block).toContain('F6 — format-violation');
});
it('contains the F-other escape clause with the ≥10-word rationale directive', () => {
const block = buildJudgeRubricBlock();
expect(block).toContain('F-other');
expect(block).toContain('≥10-word rationale');
});
it('carries the taxonomy version tag "F1-F6+other v1"', () => {
const block = buildJudgeRubricBlock();
expect(block).toContain('F1-F6+other v1');
});
it('is deterministic — two successive calls return byte-identical strings', () => {
const a = buildJudgeRubricBlock();
const b = buildJudgeRubricBlock();
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Sprint 12 Task 1 Blocker #6 — failure-code entry validator tests.
*
* Acceptance (brief § 2.1 B, 10 tests):
* 1. null code + null rationale passes
* 2. null code + non-null rationale rejects
* 3. F1 + no rationale passes
* 4. F_other + 15-word rationale passes
* 5. F_other + 5-word rationale rejects (F_other_rationale_too_short)
* 6. F_other + null rationale rejects (F_other_rationale_missing)
* 7. F_other + whitespace-only rationale rejects
* 8. F_other + exactly-10-word rationale passes (boundary)
* 9. Invalid code enum rejects
* 10. F_other + newline-separated 10-word rationale passes
*/
import { describe, expect, it } from 'vitest';
import { validateFailureCodeEntry } from '../../src/failure-taxonomy/validator.js';
describe('validateFailureCodeEntry — null code (correct verdict)', () => {
it('null code + null rationale passes', () => {
const r = validateFailureCodeEntry({ failure_code: null, rationale: null });
expect(r.ok).toBe(true);
});
it('null code + undefined rationale passes', () => {
const r = validateFailureCodeEntry({ failure_code: null });
expect(r.ok).toBe(true);
});
it('null code + non-null rationale rejects (null_code_with_rationale)', () => {
const r = validateFailureCodeEntry({
failure_code: null,
rationale: 'model was correct but here is a comment',
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe('null_code_with_rationale');
}
});
});
describe('validateFailureCodeEntry — F1..F6 codes', () => {
it('F1 + no rationale passes (rationale optional for F1..F6)', () => {
const r = validateFailureCodeEntry({ failure_code: 'F1' });
expect(r.ok).toBe(true);
});
it('F3 + short rationale passes (no length constraint outside F_other)', () => {
const r = validateFailureCodeEntry({ failure_code: 'F3', rationale: 'bad' });
expect(r.ok).toBe(true);
});
it('F6 + null rationale passes', () => {
const r = validateFailureCodeEntry({ failure_code: 'F6', rationale: null });
expect(r.ok).toBe(true);
});
});
describe('validateFailureCodeEntry — F_other code rationale enforcement', () => {
it('F_other + 15-word rationale passes', () => {
const r = validateFailureCodeEntry({
failure_code: 'F_other',
rationale:
'the model produced a mostly-correct answer but reversed one subject pronoun in the middle which is confusing',
});
expect(r.ok).toBe(true);
});
it('F_other + 5-word rationale rejects (F_other_rationale_too_short)', () => {
const r = validateFailureCodeEntry({
failure_code: 'F_other',
rationale: 'model hallucinated extra facts wrong',
});
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe('F_other_rationale_too_short');
expect(r.message).toContain('10');
}
});
it('F_other + null rationale rejects (F_other_rationale_missing)', () => {
const r = validateFailureCodeEntry({ failure_code: 'F_other', rationale: null });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe('F_other_rationale_missing');
}
});
it('F_other + whitespace-only rationale rejects', () => {
const r = validateFailureCodeEntry({ failure_code: 'F_other', rationale: ' \t\n ' });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe('F_other_rationale_missing');
}
});
it('F_other + exactly-10-word rationale passes (boundary)', () => {
const r = validateFailureCodeEntry({
failure_code: 'F_other',
rationale: 'one two three four five six seven eight nine ten',
});
expect(r.ok).toBe(true);
});
it('F_other + newline-separated 10-word rationale passes (tokenize on any whitespace)', () => {
const r = validateFailureCodeEntry({
failure_code: 'F_other',
rationale: 'alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\niota\nkappa',
});
expect(r.ok).toBe(true);
});
});
describe('validateFailureCodeEntry — invalid input', () => {
it('rejects a code outside the enum', () => {
const r = validateFailureCodeEntry({ failure_code: 'F99' });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.code).toBe('invalid_failure_code');
}
});
});

View File

@@ -0,0 +1,186 @@
/**
* Task 2.5 Stage 1.5 §7.3 — preCellHealthCheck tests.
*
* Injects a stub fetchFn that returns a programmable sequence of Response /
* error so we can verify the liveness + model-ping matrix behaves correctly
* without a live LiteLLM proxy.
*/
import { describe, expect, it } from 'vitest';
import { preCellHealthCheck } from '../src/health-check.js';
const OK_JSON = JSON.stringify({
choices: [{ message: { content: 'pong' } }],
usage: { prompt_tokens: 3, completion_tokens: 1 },
});
function okResponse(body = OK_JSON): Response {
return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } });
}
function errorResponse(status: number): Response {
return new Response('', { status });
}
function seqFetch(responses: Array<Response | Error>): {
fn: typeof globalThis.fetch;
calls: Array<{ url: string; method: string }>;
} {
const calls: Array<{ url: string; method: string }> = [];
let idx = 0;
const fn: typeof globalThis.fetch = async (url, init) => {
calls.push({
url: typeof url === 'string' ? url : String(url),
method: (init?.method ?? 'GET').toUpperCase(),
});
const step = responses[idx++] ?? new Error('unexpected call');
if (step instanceof Error) throw step;
return step;
};
return { fn, calls };
}
const BASE_OPTS = {
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
subjectModel: 'qwen3.6-35b-a3b-via-dashscope-direct',
judgeModels: ['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1-pro-preview'],
};
describe('preCellHealthCheck — happy path', () => {
it('returns ok when every probe succeeds', async () => {
const { fn, calls } = seqFetch([
okResponse(), // /health/liveliness
okResponse(), // subject ping
okResponse(), // judge 1 ping
okResponse(), // judge 2 ping
okResponse(), // judge 3 ping
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(true);
expect(r.failures).toEqual([]);
// 1 liveness + 4 model probes.
expect(calls).toHaveLength(5);
expect(calls[0].url).toContain('/health/liveliness');
expect(calls[0].method).toBe('GET');
expect(calls[1].url).toContain('/v1/chat/completions');
expect(calls[1].method).toBe('POST');
});
it('skips liveness probe when includeLivenessProbe=false', async () => {
const { fn, calls } = seqFetch([
okResponse(), // subject
okResponse(), // judge 1
okResponse(), // judge 2
okResponse(), // judge 3
]);
const r = await preCellHealthCheck({
...BASE_OPTS,
fetchFn: fn,
includeLivenessProbe: false,
});
expect(r.ok).toBe(true);
expect(calls).toHaveLength(4);
expect(calls.every(c => c.url.includes('/v1/chat/completions'))).toBe(true);
});
it('works with subject only and no judges', async () => {
const { fn, calls } = seqFetch([
okResponse(), // liveness
okResponse(), // subject
]);
const r = await preCellHealthCheck({
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
subjectModel: 'qwen3.6-35b-a3b',
fetchFn: fn,
});
expect(r.ok).toBe(true);
expect(calls).toHaveLength(2);
});
});
describe('preCellHealthCheck — failure paths', () => {
it('flags liveness 5xx', async () => {
const { fn } = seqFetch([
errorResponse(503), // liveness fails
okResponse(),
okResponse(),
okResponse(),
okResponse(),
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(false);
expect(r.failures).toHaveLength(1);
expect(r.failures[0].endpoint).toContain('/health/liveliness');
expect(r.failures[0].error).toBe('http_503');
});
it('flags subject model 5xx', async () => {
const { fn } = seqFetch([
okResponse(), // liveness
errorResponse(500), // subject fails
okResponse(),
okResponse(),
okResponse(),
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(false);
expect(r.failures).toHaveLength(1);
expect(r.failures[0].endpoint).toContain('qwen3.6-35b-a3b-via-dashscope-direct');
expect(r.failures[0].error).toBe('http_500');
});
it('flags judge model 5xx', async () => {
const { fn } = seqFetch([
okResponse(), // liveness
okResponse(), // subject
okResponse(), // judge 1
errorResponse(502), // judge 2 fails
okResponse(), // judge 3
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(false);
expect(r.failures).toHaveLength(1);
expect(r.failures[0].endpoint).toContain('gpt-5.4');
expect(r.failures[0].error).toBe('http_502');
});
it('flags network/TypeError on any probe', async () => {
const err = new Error('fetch failed');
err.name = 'TypeError';
const { fn } = seqFetch([
okResponse(), // liveness
err, // subject throws
okResponse(),
okResponse(),
okResponse(),
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(false);
expect(r.failures).toHaveLength(1);
expect(r.failures[0].error).toBe('fetch_error_TypeError');
});
it('accumulates multiple failures across probes', async () => {
const { fn } = seqFetch([
errorResponse(503), // liveness fails
errorResponse(500), // subject fails
okResponse(),
errorResponse(502), // judge 2 fails
okResponse(),
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(r.ok).toBe(false);
expect(r.failures).toHaveLength(3);
});
it('returns probedAt ISO + positive durationMs', async () => {
const { fn } = seqFetch([
okResponse(), okResponse(), okResponse(), okResponse(), okResponse(),
]);
const r = await preCellHealthCheck({ ...BASE_OPTS, fetchFn: fn });
expect(new Date(r.probedAt).toString()).not.toBe('Invalid Date');
expect(r.durationMs).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,275 @@
/**
* Task 2.5 Stage 1 — ingest module tests.
*
* Covers extractTurnsFromLocomoRaw (raw JSON → flat turn stream) and
* ingestLoCoMoCorpus (turn stream → ephemeral MindDB + HybridSearch indices).
*
* Uses :memory: MindDB + a deterministic zero-dep 1024-dim fake embedder.
* Real runs use `createOllamaEmbedder` from @waggle/core, but that requires
* a live Ollama server — not appropriate for unit tests.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
FrameStore,
HybridSearch,
MindDB,
SessionStore,
type Embedder,
} from '@waggle/core';
import {
extractTurnsFromLocomoRaw,
ingestLoCoMoCorpus,
type LocomoRawSample,
} from '../src/ingest.js';
const VEC_DIMS = 1024; // matches VEC_TABLE_SQL `embedding float[1024]`
/** Deterministic hash-seeded 1024-dim embedder. Produces unit-norm vectors
* whose direction is entirely determined by the input string's bytes. Good
* enough for FTS5-agreement round-trip tests; not for semantic retrieval. */
function createFakeEmbedder(dims: number = VEC_DIMS): Embedder {
const fnv1a = (s: string): number => {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h || 1;
};
const embedOne = (text: string): Float32Array => {
let state = fnv1a(text);
const v = new Float32Array(dims);
for (let i = 0; i < dims; i++) {
// xorshift32 — cheap, deterministic, well-distributed
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
v[i] = ((state >>> 0) / 0x100000000) * 2 - 1;
}
// L2-normalize so vec0 cosine distance behaves sensibly
let mag = 0;
for (let i = 0; i < dims; i++) mag += v[i] * v[i];
mag = Math.sqrt(mag);
if (mag > 0) for (let i = 0; i < dims; i++) v[i] /= mag;
return v;
};
return {
dimensions: dims,
async embed(text: string): Promise<Float32Array> { return embedOne(text); },
async embedBatch(texts: string[]): Promise<Float32Array[]> { return texts.map(embedOne); },
};
}
/** Tiny LoCoMo-shaped fixture: 2 conversations, 3 turns each, 2 sessions in
* conv-01 (to verify multi-session ordering). */
const FIXTURE_SAMPLES: LocomoRawSample[] = [
{
sample_id: 'conv-01',
qa: [],
conversation: {
speaker_a: 'Alice',
speaker_b: 'Bob',
session_1_date_time: '1 January 2023',
session_1: [
{ speaker: 'Alice', dia_id: 'D1:1', text: 'Hello there' },
{ speaker: 'Bob', dia_id: 'D1:2', text: 'Hi Alice' },
],
session_2_date_time: '2 January 2023',
session_2: [
{ speaker: 'Alice', dia_id: 'D2:1', text: 'The sunrise painting is ready' },
],
},
},
{
sample_id: 'conv-02',
qa: [],
conversation: {
speaker_a: 'Carol',
speaker_b: 'Dan',
session_1_date_time: '10 February 2023',
session_1: [
{ speaker: 'Carol', dia_id: 'D1:1', text: 'Morning Dan' },
{ speaker: 'Dan', dia_id: 'D1:2', text: 'Morning' },
{ speaker: 'Carol', dia_id: 'D1:3', text: 'How is the weather today' },
],
},
},
];
let tmpFixturePath: string;
beforeEach(() => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'locomo-ingest-test-'));
tmpFixturePath = path.join(dir, 'locomo10.json');
fs.writeFileSync(tmpFixturePath, JSON.stringify(FIXTURE_SAMPLES), 'utf-8');
});
afterEach(() => {
try {
fs.rmSync(path.dirname(tmpFixturePath), { recursive: true, force: true });
} catch {
// best-effort cleanup — OS will reap on next tmp prune
}
});
describe('extractTurnsFromLocomoRaw', () => {
it('flattens every session_N turn across every conversation', () => {
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
expect(turns).toHaveLength(6); // 2 + 1 + 3 turns
});
it('preserves speaker + text + dia_id + conversation id per turn', () => {
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
const first = turns[0];
expect(first.gopId).toBe('conv-01');
expect(first.diaId).toBe('D1:1');
expect(first.speaker).toBe('Alice');
expect(first.text).toBe('Hello there');
expect(first.content).toBe('Alice: Hello there');
});
it('orders sessions numerically within a conversation', () => {
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
const conv01 = turns.filter(t => t.gopId === 'conv-01');
expect(conv01.map(t => t.diaId)).toEqual(['D1:1', 'D1:2', 'D2:1']);
});
it('maintains per-conversation grouping in output order', () => {
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
const ids = turns.map(t => t.gopId);
// conv-01 turns come before conv-02 turns (source order preserved)
const firstConv02 = ids.indexOf('conv-02');
const lastConv01 = ids.lastIndexOf('conv-01');
expect(lastConv01).toBeLessThan(firstConv02);
});
it('throws a clear error when the archive is missing', () => {
const missing = path.join(os.tmpdir(), `locomo-missing-${Date.now()}.json`);
expect(() => extractTurnsFromLocomoRaw(missing))
.toThrow(/LoCoMo raw archive not found/);
});
it('throws on invalid JSON', () => {
const bad = path.join(path.dirname(tmpFixturePath), 'bad.json');
fs.writeFileSync(bad, 'not json at all', 'utf-8');
expect(() => extractTurnsFromLocomoRaw(bad))
.toThrow(/not valid JSON/);
});
it('skips turns that are missing dia_id or text', () => {
const malformed = path.join(path.dirname(tmpFixturePath), 'malformed.json');
fs.writeFileSync(malformed, JSON.stringify([
{
sample_id: 'conv-x', qa: [],
conversation: {
speaker_a: 'A', speaker_b: 'B',
session_1: [
{ speaker: 'A', dia_id: 'D1:1', text: 'ok' },
{ speaker: 'A', text: 'no dia_id' } as unknown as { speaker: string; dia_id: string; text: string },
{ dia_id: 'D1:3', text: 'no speaker' } as unknown as { speaker: string; dia_id: string; text: string },
{ speaker: 'B', dia_id: 'D1:4' } as unknown as { speaker: string; dia_id: string; text: string },
],
},
},
]), 'utf-8');
const turns = extractTurnsFromLocomoRaw(malformed);
expect(turns).toHaveLength(1);
expect(turns[0].diaId).toBe('D1:1');
});
});
describe('ingestLoCoMoCorpus', () => {
it('creates one frame per turn and indexes them for vector search', async () => {
const db = new MindDB(':memory:');
try {
const frames = new FrameStore(db);
const sessions = new SessionStore(db);
const search = new HybridSearch(db, createFakeEmbedder());
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
const stats = await ingestLoCoMoCorpus(db, search, frames, sessions, turns);
expect(stats.count).toBe(6);
expect(stats.ingestMs).toBeGreaterThanOrEqual(0);
expect(stats.indexMs).toBeGreaterThanOrEqual(0);
// Confirm rows landed in memory_frames.
const raw = db.getDatabase();
const total = raw.prepare('SELECT COUNT(*) as c FROM memory_frames').get() as { c: number };
expect(total.c).toBe(6);
} finally {
db.close();
}
});
it('tags every frame with gop_id = conversation_id and source = import', async () => {
const db = new MindDB(':memory:');
try {
const frames = new FrameStore(db);
const sessions = new SessionStore(db);
const search = new HybridSearch(db, createFakeEmbedder());
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
await ingestLoCoMoCorpus(db, search, frames, sessions, turns);
const raw = db.getDatabase();
const rows = raw.prepare('SELECT gop_id, source FROM memory_frames').all() as Array<{ gop_id: string; source: string }>;
const gops = new Set(rows.map(r => r.gop_id));
expect(gops).toEqual(new Set(['conv-01', 'conv-02']));
expect(rows.every(r => r.source === 'import')).toBe(true);
} finally {
db.close();
}
});
it('round-trip: FTS5 keyword search finds the ingested turn', async () => {
const db = new MindDB(':memory:');
try {
const frames = new FrameStore(db);
const sessions = new SessionStore(db);
const search = new HybridSearch(db, createFakeEmbedder());
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
await ingestLoCoMoCorpus(db, search, frames, sessions, turns);
const results = await search.search('sunrise painting', { limit: 3 });
expect(results.length).toBeGreaterThan(0);
// Sunrise line lives in conv-01 / D2:1
const top = results[0];
expect(top.frame.content).toContain('sunrise');
expect(top.frame.gop_id).toBe('conv-01');
} finally {
db.close();
}
});
it('respects gopId scope: searching within one conversation excludes others', async () => {
const db = new MindDB(':memory:');
try {
const frames = new FrameStore(db);
const sessions = new SessionStore(db);
const search = new HybridSearch(db, createFakeEmbedder());
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
await ingestLoCoMoCorpus(db, search, frames, sessions, turns);
const scoped = await search.search('morning', { limit: 5, gopId: 'conv-02' });
expect(scoped.every(r => r.frame.gop_id === 'conv-02')).toBe(true);
} finally {
db.close();
}
});
it('close() frees the :memory: handle', async () => {
const db = new MindDB(':memory:');
const frames = new FrameStore(db);
const sessions = new SessionStore(db);
const search = new HybridSearch(db, createFakeEmbedder());
const turns = extractTurnsFromLocomoRaw(tmpFixturePath);
await ingestLoCoMoCorpus(db, search, frames, sessions, turns);
db.close();
// After close, further reads throw (better-sqlite3 behaviour).
expect(() => db.getDatabase().prepare('SELECT 1').get())
.toThrow();
});
});

View File

@@ -0,0 +1,265 @@
/**
* JsonlRecord extension tests (Sprint 9 Task 1).
*
* Extension spec: `PM-Waggle-OS/strategy/2026-04-20-failure-mode-taxonomy.md` §9
* Brief: `PM-Waggle-OS/briefs/2026-04-20-cc-sprint-9-tasks.md` Task 1
*
* These tests prove three acceptance criteria from the brief:
* (i) A pre-judge JSONL record (Sprint-7/8 shape, no judge fields)
* parses as a JsonlRecord without error — backward compatibility.
* (ii) A post-judge JSONL record (Task-1 extended shape) parses and
* preserves every judge field.
* (iii) The `JudgeVerdict` union is a closed enum — downstream code that
* exhaustive-switches on it caught at compile time. We enforce this
* with a compile-time never-check so TypeScript proves the coverage
* rather than depending on runtime validation (no Zod dep on the
* harness side yet; TypeScript is the check as per the brief:
* "Ako postoji schema validator (Zod ili sl.), dopuniti; ako ne,
* preskoči i ostavi TypeScript checking").
*/
import { describe, it, expect } from 'vitest';
import type {
JsonlRecord,
FailureMode,
JudgeVerdict,
JudgeEnsembleEntry,
PinningSurface,
} from '../src/types.js';
// Fixture: pre-judge record exactly as Sprint-7/8 runners emitted it.
const PRE_JUDGE_JSONL = JSON.stringify({
turnId: '5a02a79a-0a56-4e1d-a6e5-bc5a7b80b19f',
cell: 'raw',
instance_id: 'locomo_conv-26_q000',
model: 'qwen3.6-35b-a3b',
seed: 42,
accuracy: 1,
p50_latency_ms: 820,
p95_latency_ms: 1240,
usd_per_query: 0.000017,
failure_mode: null,
});
// Fixture: post-judge record with every new field populated, ensemble shape.
const POST_JUDGE_JSONL = JSON.stringify({
turnId: '9f16c4b2-e831-4a23-8a97-cd8b1e4c7210',
cell: 'full-context',
instance_id: 'locomo_conv-26_q001',
model: 'qwen3.6-35b-a3b',
seed: 42,
accuracy: 1,
p50_latency_ms: 1145,
p95_latency_ms: 1540,
usd_per_query: 0.000087,
failure_mode: null,
model_answer: '7 May 2023',
judge_verdict: 'correct',
judge_failure_mode: null,
judge_rationale: 'Answer matches ground truth date precisely.',
judge_model: 'claude-sonnet-4-6',
judge_timestamp: '2026-04-21T14:32:00.000Z',
judge_confidence: 0.98,
judge_ensemble: [
{ model: 'claude-sonnet-4-6', verdict: 'correct', failure_mode: null, latency_ms: 920 },
{ model: 'claude-haiku-4-5', verdict: 'correct', failure_mode: null, latency_ms: 410 },
],
});
// Fixture: incorrect verdict with a specific failure mode — asserts the
// binary verdict + failure_mode pairing that replaces the brief Task-1
// combined 6-value enum.
const INCORRECT_JSONL = JSON.stringify({
turnId: 'c7d54b11-a2e8-4c50-8f96-1a3b00c4ff70',
cell: 'raw',
instance_id: 'locomo_conv-26_q002',
model: 'qwen3.6-35b-a3b',
seed: 42,
accuracy: 0,
p50_latency_ms: 890,
p95_latency_ms: 1300,
usd_per_query: 0.000021,
failure_mode: null,
model_answer: 'The event took place on 12 December 2024.',
judge_verdict: 'incorrect',
judge_failure_mode: 'F3',
judge_rationale: 'Model states a date that contradicts the ground-truth context.',
judge_model: 'claude-sonnet-4-6',
judge_timestamp: '2026-04-21T14:33:05.000Z',
});
describe('JsonlRecord backward compatibility (Task 1 acceptance)', () => {
it('parses a pre-judge Sprint-7/8 record without error', () => {
const parsed = JSON.parse(PRE_JUDGE_JSONL) as JsonlRecord;
expect(parsed.turnId).toMatch(/^[0-9a-f-]{36}$/);
expect(parsed.cell).toBe('raw');
expect(parsed.failure_mode).toBeNull();
// All judge fields must be absent — treated as "not judged yet".
expect(parsed.judge_verdict).toBeUndefined();
expect(parsed.judge_failure_mode).toBeUndefined();
expect(parsed.judge_rationale).toBeUndefined();
expect(parsed.judge_model).toBeUndefined();
expect(parsed.judge_timestamp).toBeUndefined();
expect(parsed.judge_confidence).toBeUndefined();
expect(parsed.judge_ensemble).toBeUndefined();
expect(parsed.model_answer).toBeUndefined();
});
it('parses a post-judge record with ensemble and preserves every field', () => {
const parsed = JSON.parse(POST_JUDGE_JSONL) as JsonlRecord;
expect(parsed.model_answer).toBe('7 May 2023');
expect(parsed.judge_verdict).toBe('correct');
expect(parsed.judge_failure_mode).toBeNull();
expect(parsed.judge_rationale).toBe('Answer matches ground truth date precisely.');
expect(parsed.judge_model).toBe('claude-sonnet-4-6');
expect(parsed.judge_timestamp).toBe('2026-04-21T14:32:00.000Z');
expect(parsed.judge_confidence).toBeCloseTo(0.98, 3);
expect(parsed.judge_ensemble).toHaveLength(2);
expect(parsed.judge_ensemble?.[0].model).toBe('claude-sonnet-4-6');
expect(parsed.judge_ensemble?.[0].latency_ms).toBe(920);
});
it('parses an incorrect record with a populated failure_mode code', () => {
const parsed = JSON.parse(INCORRECT_JSONL) as JsonlRecord;
expect(parsed.judge_verdict).toBe('incorrect');
expect(parsed.judge_failure_mode).toBe('F3');
expect(parsed.judge_ensemble).toBeUndefined(); // single-judge run
});
});
describe('JsonlRecord judge-field type closedness (Task 1 acceptance)', () => {
// Compile-time never-check: any new value in the JudgeVerdict union
// will produce a TypeScript error here, forcing the author to update
// the aggregator and schema consumers. Serves as the "invalid string"
// gate the brief specified: TS catches at tsc time instead of at
// runtime via Zod.
it('JudgeVerdict is an exhaustive closed union', () => {
const verdicts: JudgeVerdict[] = ['correct', 'incorrect'];
for (const v of verdicts) {
switch (v) {
case 'correct':
expect(v).toBe('correct');
break;
case 'incorrect':
expect(v).toBe('incorrect');
break;
default: {
const _exhaustive: never = v;
throw new Error(`unreachable: ${_exhaustive as string}`);
}
}
}
});
it('FailureMode is exactly F1..F5 — no extras or aliases', () => {
const codes: FailureMode[] = ['F1', 'F2', 'F3', 'F4', 'F5'];
expect(codes).toHaveLength(5);
for (const code of codes) {
expect(code).toMatch(/^F[1-5]$/);
}
});
it('JudgeEnsembleEntry carries model + verdict + failure_mode at minimum', () => {
const entry: JudgeEnsembleEntry = {
model: 'claude-sonnet-4-6',
verdict: 'incorrect',
failure_mode: 'F4',
};
expect(entry.model).toBe('claude-sonnet-4-6');
expect(entry.verdict).toBe('incorrect');
expect(entry.failure_mode).toBe('F4');
// Optional fields are assignable without being required.
const withOptionals: JudgeEnsembleEntry = {
...entry,
rationale: 'hallucinated a name',
latency_ms: 540,
};
expect(withOptionals.rationale).toBeTruthy();
expect(withOptionals.latency_ms).toBe(540);
});
});
// ── Sprint 12 Task 1 / B3 addendum § 4 — pinning surface fields ──────────
describe('JsonlRecord B3 addendum pinning fields (Sub-deliverable C)', () => {
it('accepts an anthropic_immutable target row with null carve-out + null revision', () => {
const row: JsonlRecord = {
turnId: '11111111-1111-1111-1111-111111111111',
cell: 'raw',
instance_id: 'locomo_conv-26_q000',
model: 'claude-opus-4-7',
seed: 42,
accuracy: 1,
p50_latency_ms: 900,
p95_latency_ms: 1500,
usd_per_query: 0.0006,
failure_mode: null,
model_pinning_surface: 'anthropic_immutable',
model_pinning_carve_out_reason: null,
model_revision_hash: null,
};
expect(row.model_pinning_surface).toBe('anthropic_immutable');
expect(row.model_pinning_carve_out_reason).toBeNull();
expect(row.model_revision_hash).toBeNull();
});
it('accepts a floating_alias target row with non-null carve-out reason', () => {
const row: JsonlRecord = {
turnId: '22222222-2222-2222-2222-222222222222',
cell: 'filtered',
instance_id: 'locomo_conv-26_q001',
model: 'qwen3.6-35b-a3b-stage2',
seed: 42,
accuracy: 0,
p50_latency_ms: 1100,
p95_latency_ms: 1700,
usd_per_query: 0.0012,
failure_mode: null,
model_pinning_surface: 'floating_alias',
model_pinning_carve_out_reason:
'DashScope does not expose immutable model snapshots; floating alias mandated by B3 addendum § 5',
model_revision_hash: null,
};
expect(row.model_pinning_surface).toBe('floating_alias');
expect(typeof row.model_pinning_carve_out_reason).toBe('string');
expect((row.model_pinning_carve_out_reason as string).length).toBeGreaterThan(10);
});
it('parses a pre-Sprint-12 row (pinning fields absent) without error — backward compat', () => {
const raw = JSON.stringify({
turnId: '33333333-3333-3333-3333-333333333333',
cell: 'raw',
instance_id: 'locomo_conv-26_q002',
model: 'qwen3.6-35b-a3b',
seed: 42,
accuracy: 1,
p50_latency_ms: 800,
p95_latency_ms: 1200,
usd_per_query: 0.0008,
failure_mode: null,
});
const parsed = JSON.parse(raw) as JsonlRecord;
expect(parsed.model_pinning_surface).toBeUndefined();
expect(parsed.model_pinning_carve_out_reason).toBeUndefined();
expect(parsed.model_revision_hash).toBeUndefined();
expect(parsed.turnId).toMatch(/^[0-9a-f-]{36}$/);
});
it('PinningSurface union is exactly the B3 addendum § 4 three-value enum', () => {
const surfaces: PinningSurface[] = ['anthropic_immutable', 'floating_alias', 'revision_hash_pinned'];
expect(surfaces).toHaveLength(3);
for (const s of surfaces) {
switch (s) {
case 'anthropic_immutable':
case 'floating_alias':
case 'revision_hash_pinned':
expect(s).toBeTruthy();
break;
default: {
const _exhaustive: never = s;
throw new Error(`unreachable: ${_exhaustive as string}`);
}
}
}
});
});

View File

@@ -0,0 +1,368 @@
/**
* Judge wiring tests (Sprint 9 Task 2).
*
* Brief: PM-Waggle-OS/briefs/2026-04-20-cc-sprint-9-tasks.md Task 2 §Acceptance
* Scope: judge-client retry semantics + judge-runner payload assembly +
* ensemble aggregation. All mocked — zero real LLM calls.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createJudgeLlmClient, type JudgeClientCostEntry } from '../src/judge-client.js';
import { runJudge, type JudgeConfig } from '../src/judge-runner.js';
import { runOne } from '../src/runner.js';
import type { LlmClient } from '../src/judge-types.js';
import type { DatasetSpec, JsonlRecord, ModelSpec } from '../src/types.js';
// ── Fixtures ─────────────────────────────────────────────────────────────
const SYNTHETIC_DATASET: DatasetSpec = {
id: 'synthetic',
displayName: 'Synthetic',
dataPath: 'synthetic/placeholder.jsonl',
source: 'synthetic',
};
const QWEN_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,
};
/** Scripted LlmClient — enqueues responses or errors and returns them
* in order. Used for judge module unit tests. */
class ScriptedLlmClient implements LlmClient {
readonly calls: string[] = [];
private 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 readJsonl(file: string): JsonlRecord[] {
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf-8')
.split('\n')
.filter(l => l.trim().length > 0)
.map(l => JSON.parse(l) as JsonlRecord);
}
// ── Judge client — retry semantics ───────────────────────────────────────
describe('createJudgeLlmClient — transport retry semantics (brief §Failure-handling)', () => {
it('succeeds on first attempt without retry', async () => {
const fetchCalls: Array<{ url: string; body: unknown }> = [];
const fakeFetch: typeof fetch = async (url, init) => {
fetchCalls.push({ url: String(url), body: init?.body });
return new Response(
JSON.stringify({
choices: [{ message: { content: '{"verdict":"correct","failure_mode":null,"rationale":"ok"}' } }],
usage: { prompt_tokens: 100, completion_tokens: 20 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
};
const costs: JudgeClientCostEntry[] = [];
const client = createJudgeLlmClient({
litellmUrl: 'http://test',
litellmApiKey: 'sk-test',
model: 'claude-sonnet-4-6',
fetchImpl: fakeFetch,
backoffMs: [1, 1],
onCall: e => costs.push(e),
});
const text = await client.complete('hello judge');
expect(text).toContain('verdict');
expect(fetchCalls).toHaveLength(1);
expect(costs).toHaveLength(1);
expect(costs[0].ok).toBe(true);
expect(costs[0].promptTokens).toBe(100);
expect(costs[0].completionTokens).toBe(20);
expect(costs[0].usd).toBeGreaterThan(0);
});
it('retries twice on HTTP 500 then succeeds on the third attempt', async () => {
let calls = 0;
const fakeFetch: typeof fetch = async () => {
calls++;
if (calls <= 2) {
return new Response('upstream is down', { status: 500 });
}
return new Response(
JSON.stringify({
choices: [{ message: { content: '{"verdict":"incorrect","failure_mode":"F3","rationale":"wrong date"}' } }],
usage: { prompt_tokens: 100, completion_tokens: 22 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
};
const costs: JudgeClientCostEntry[] = [];
const client = createJudgeLlmClient({
litellmUrl: 'http://test',
litellmApiKey: 'sk-test',
model: 'claude-sonnet-4-6',
fetchImpl: fakeFetch,
backoffMs: [1, 1], // collapse backoff for tests
onCall: e => costs.push(e),
});
const text = await client.complete('hello');
expect(text).toContain('F3');
expect(calls).toBe(3);
// Single success entry — failures are absorbed by the retry loop
// and don't emit cost entries until the final outcome.
expect(costs).toHaveLength(1);
expect(costs[0].ok).toBe(true);
});
it('emits a final failed cost entry and throws when all retries are exhausted', async () => {
let calls = 0;
const fakeFetch: typeof fetch = async () => {
calls++;
return new Response('persistent 503', { status: 503 });
};
const costs: JudgeClientCostEntry[] = [];
const client = createJudgeLlmClient({
litellmUrl: 'http://test',
litellmApiKey: 'sk-test',
model: 'claude-sonnet-4-6',
fetchImpl: fakeFetch,
backoffMs: [1, 1],
onCall: e => costs.push(e),
});
await expect(client.complete('hello')).rejects.toThrow(/HTTP 503/);
// 1 initial + 2 retries = 3 attempts, all failing.
expect(calls).toBe(3);
expect(costs).toHaveLength(1);
expect(costs[0].ok).toBe(false);
});
it('falls back to reasoning_content when content is empty (thinking-mode provider)', async () => {
const fakeFetch: typeof fetch = async () =>
new Response(
JSON.stringify({
choices: [{
message: {
content: '',
reasoning_content: '{"verdict":"correct","failure_mode":null,"rationale":"parsed from reasoning"}',
},
}],
usage: { prompt_tokens: 80, completion_tokens: 200 },
}),
{ status: 200 },
);
const client = createJudgeLlmClient({
litellmUrl: 'http://test',
litellmApiKey: 'sk-test',
model: 'qwen3.6-35b-a3b-via-openrouter',
fetchImpl: fakeFetch,
backoffMs: [1, 1],
});
const text = await client.complete('hello');
expect(text).toContain('parsed from reasoning');
});
});
// ── runJudge — single-judge path ─────────────────────────────────────────
describe('runJudge — single judge produces a populated payload', () => {
it('maps judgeAnswer output onto the JudgePayload shape', async () => {
const client = new ScriptedLlmClient([
JSON.stringify({ verdict: 'correct', failure_mode: null, rationale: 'All facts match.' }),
]);
const payload = await runJudge(
{
question: 'Who painted the Mona Lisa?',
groundTruth: 'Leonardo da Vinci',
contextExcerpt: 'Leonardo da Vinci painted the Mona Lisa…',
modelAnswer: 'Leonardo da Vinci',
},
{ kind: 'single', model: 'claude-sonnet-4-6', client },
);
expect(payload.judge_verdict).toBe('correct');
expect(payload.judge_failure_mode).toBeNull();
expect(payload.judge_rationale).toBe('All facts match.');
expect(payload.judge_model).toBe('claude-sonnet-4-6');
expect(payload.judge_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(payload.model_answer).toBe('Leonardo da Vinci');
expect(payload.judge_error).toBeUndefined();
});
it('survives a JudgeParseError without aborting the batch', async () => {
// Judge returns garbage twice → module throws JudgeParseError.
const client = new ScriptedLlmClient(['not json', 'still not json']);
const payload = await runJudge(
{
question: 'q',
groundTruth: 'gt',
contextExcerpt: 'ctx',
modelAnswer: 'ma',
},
{ kind: 'single', model: 'gpt-5', client },
);
expect(payload.judge_verdict).toBeUndefined();
expect(payload.judge_failure_mode).toBeUndefined();
expect(payload.judge_error).toMatch(/^parse:/);
// model_answer still propagated — runner will store the raw answer
// even when judging failed, so re-judging later is possible.
expect(payload.model_answer).toBe('ma');
});
});
// ── runJudge — ensemble path + majority + tie-break ──────────────────────
describe('runJudge — ensemble aggregates per-judge verdicts + majority', () => {
const models = ['claude-sonnet-4-6', 'claude-haiku-4-5', 'gpt-5'];
const mkClient = (verdict: 'correct' | 'incorrect', mode: 'F3' | null = null, rationale = 'r'): LlmClient =>
new ScriptedLlmClient([JSON.stringify({ verdict, failure_mode: mode, rationale })]);
it('3-0 unanimous majority populates ensemble entries and picks the shared verdict', async () => {
const clients = new Map<string, LlmClient>([
['claude-sonnet-4-6', mkClient('correct', null, 'sonnet')],
['claude-haiku-4-5', mkClient('correct', null, 'haiku')],
['gpt-5', mkClient('correct', null, 'gpt-5')],
]);
const payload = await runJudge(
{ question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma' },
{ kind: 'ensemble', models, clients },
);
expect(payload.judge_verdict).toBe('correct');
expect(payload.judge_ensemble).toHaveLength(3);
// Ensemble entries carry the per-judge model id + its individual
// verdict so the aggregator can compute inter-judge agreement
// without re-reading per-judge calls.
expect(payload.judge_ensemble?.map(e => e.model).sort()).toEqual([...models].sort());
});
it('2-1 majority takes the majority verdict; minority preserved in ensemble', async () => {
const clients = new Map<string, LlmClient>([
['claude-sonnet-4-6', mkClient('incorrect', 'F3', 'wrong date')],
['claude-haiku-4-5', mkClient('incorrect', 'F3', 'wrong date')],
['gpt-5', mkClient('correct', null, 'actually looks fine')],
]);
const payload = await runJudge(
{ question: 'q', groundTruth: 'gt', contextExcerpt: 'ctx', modelAnswer: 'ma' },
{ kind: 'ensemble', models, clients },
);
expect(payload.judge_verdict).toBe('incorrect');
expect(payload.judge_failure_mode).toBe('F3');
// Minority verdict surfaced in the ensemble entries.
const gptEntry = payload.judge_ensemble?.find(e => e.model === 'gpt-5');
expect(gptEntry?.verdict).toBe('correct');
});
});
// ── Integration — runOne propagates judge fields into the JSONL ─────────
describe('runOne integration — judge fields land on every record when judgeConfig is set', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-judge-wire-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('single-judge mode — every row has judge_verdict + judge_failure_mode + judge_model', async () => {
const outputPath = path.join(tmpDir, 'judge.jsonl');
const queue = [
JSON.stringify({ verdict: 'correct', failure_mode: null, rationale: 'match' }),
JSON.stringify({ verdict: 'incorrect', failure_mode: 'F3', rationale: 'wrong date' }),
JSON.stringify({ verdict: 'correct', failure_mode: null, rationale: 'match' }),
];
const scripted = new ScriptedLlmClient(queue);
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 3,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
judgeConfig: { kind: 'single', model: 'claude-sonnet-4-6', client: scripted },
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(3);
for (const r of records) {
expect(r.judge_model).toBe('claude-sonnet-4-6');
expect(r.judge_verdict).toBeDefined();
expect(r.judge_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(r.model_answer).toBeDefined();
}
// Two correct + one incorrect, in the scripted order.
expect(records[0].judge_verdict).toBe('correct');
expect(records[1].judge_verdict).toBe('incorrect');
expect(records[1].judge_failure_mode).toBe('F3');
expect(records[2].judge_verdict).toBe('correct');
});
it('skips judging when judgeConfig is absent (existing behavior preserved)', async () => {
const outputPath = path.join(tmpDir, 'no-judge.jsonl');
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 2,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(2);
for (const r of records) {
expect(r.judge_verdict).toBeUndefined();
expect(r.judge_model).toBeUndefined();
expect(r.model_answer).toBeUndefined();
}
});
it('unjudgeable rows still populate model_answer + leave verdict undefined (no crash)', async () => {
const outputPath = path.join(tmpDir, 'unjudged.jsonl');
// Both attempts produce unparseable output → JudgeParseError → row
// keeps model_answer, judge_verdict stays undefined.
const scripted = new ScriptedLlmClient(['garbage', 'still garbage', 'garbage', 'still garbage']);
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 2,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
judgeConfig: { kind: 'single', model: 'gpt-5', client: scripted },
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(2);
for (const r of records) {
expect(r.judge_verdict).toBeUndefined();
expect(r.judge_failure_mode).toBeUndefined();
expect(r.model_answer).toBeDefined();
}
});
});

View File

@@ -0,0 +1,210 @@
/**
* Task 2.5 Stage 1.5 §7.1 — fetch-retry on TypeError tests.
*
* Exercises the retry branch in LiteLlmClient.call. Uses vi.stubGlobal to
* inject a fake fetch that returns a programmable sequence of responses or
* throws controllable error classes. 1-second wait between retries is
* accepted as per-test wall-clock cost; only 2-3 retry-path tests pay it.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createLlmClient } from '../src/llm.js';
import type { ModelSpec } from '../src/types.js';
const MODEL: ModelSpec = {
id: 'test-model',
displayName: 'Test',
provider: 'alibaba',
litellmModel: 'test/model',
pricePerMillionInput: 0.1,
pricePerMillionOutput: 0.5,
contextWindow: 16_000,
};
function buildInput() {
return {
model: MODEL,
systemPrompt: 'sys',
userPrompt: 'hello',
};
}
/** Mock that returns a JSON-body successful response. */
function mockSuccess(content = 'ok', usage?: { prompt_tokens: number; completion_tokens: number }): Response {
return new Response(
JSON.stringify({
choices: [{ message: { content } }],
usage: usage ?? { prompt_tokens: 10, completion_tokens: 2 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
/** Mock an HTTP error response. */
function mockHttpError(status: number): Response {
return new Response('', { status });
}
function throwTypeError(): never {
const e = new Error('fetch failed');
e.name = 'TypeError';
throw e;
}
function throwAbortError(): never {
const e = new Error('aborted');
e.name = 'AbortError';
throw e;
}
function throwRangeError(): never {
const e = new Error('range issue');
e.name = 'RangeError';
throw e;
}
let fetchCallCount = 0;
beforeEach(() => {
fetchCallCount = 0;
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('LiteLlmClient — §7.1 fetch-retry on TypeError', () => {
it('succeeds on first attempt with no retries (fast path)', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
return mockSuccess('hello-response');
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
expect(fetchCallCount).toBe(1);
expect(r.failureMode).toBeNull();
expect(r.text).toBe('hello-response');
});
it('retries once on TypeError and succeeds on the second attempt', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
if (fetchCallCount === 1) throwTypeError();
return mockSuccess('recovered');
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const started = Date.now();
const r = await client.call(buildInput());
const elapsed = Date.now() - started;
expect(fetchCallCount).toBe(2);
expect(r.failureMode).toBeNull();
expect(r.text).toBe('recovered');
// 1s backoff should be observable in the total latency.
expect(elapsed).toBeGreaterThanOrEqual(900);
expect(r.latencyMs).toBeGreaterThanOrEqual(900);
});
it('gives up after two TypeError attempts and returns failureMode', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
throwTypeError();
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
// 1 initial + 1 retry = 2 attempts total (FETCH_RETRY_MAX = 1).
expect(fetchCallCount).toBe(2);
expect(r.failureMode).toBe('fetch_error_TypeError');
expect(r.text).toBe('');
});
it('does NOT retry on AbortError (timeout) — returns immediately', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
throwAbortError();
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
expect(fetchCallCount).toBe(1);
expect(r.failureMode).toBe('timeout');
});
it('does NOT retry on http_5xx — returns immediately', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
return mockHttpError(502);
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
expect(fetchCallCount).toBe(1);
expect(r.failureMode).toBe('http_502');
});
it('does NOT retry on non-TypeError JS errors — returns immediately', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
throwRangeError();
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
expect(fetchCallCount).toBe(1);
expect(r.failureMode).toBe('fetch_error_RangeError');
});
it('latencyMs on successful retry reflects total wall-clock (including backoff)', async () => {
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
if (fetchCallCount === 1) throwTypeError();
return mockSuccess('ok');
});
const client = createLlmClient({
dryRun: false,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
// Total latency must include the 1000 ms backoff, NOT just the second
// attempt's round-trip. Otherwise budget accounting underestimates.
expect(r.latencyMs).toBeGreaterThanOrEqual(900);
});
it('DryRunClient path is unaffected by retry logic', async () => {
// Dry-run never touches fetch; retry loop shouldn't run.
vi.stubGlobal('fetch', async () => {
fetchCallCount++;
throwTypeError();
});
const client = createLlmClient({
dryRun: true,
litellmUrl: 'http://mock',
litellmApiKey: 'sk-test',
});
const r = await client.call(buildInput());
expect(fetchCallCount).toBe(0);
expect(r.failureMode).toBeNull();
expect(r.text.startsWith('DRY_RUN:')).toBe(true);
});
});

View File

@@ -0,0 +1,186 @@
/**
* Sprint 12 Task 1 Blocker #4 — judge model registry tests.
*
* Acceptance criteria (per brief § 4.2):
* 1. `config/models.json` parses clean via Node JSON.parse.
* 2. Every entry carries a `pinning_surface` field with a valid enum value.
* 3. Anthropic-direct entries (`provider: 'anthropic'`) have
* `pinning_surface_carve_out_reason: null`.
* 4. Non-Anthropic entries have non-null carve-out reason strings.
* 5. Entry `id` field matches the hash-key under which it is stored.
* 6. The 4 Sprint 11 judge models (Opus 4.7, GPT-5.4, Gemini 3.1,
* Grok 4.20) are all present with a valid `judge_role`.
*
* Additional coverage (bonus beyond brief's 6-test floor):
* - B2 LOCK quadri-vendor tie-break invariant: Grok 4.20 is judge_role
* `tertiary` (tie-break reserve, not primary).
* - PinningSurface + JudgeRole enum values line up with the TypeScript
* types in `types.ts`.
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { describe, expect, it } from 'vitest';
import type { JudgeRole, ModelSpec, PinningSurface } from '../src/types.js';
const HERE = url.fileURLToPath(import.meta.url);
const HARNESS_ROOT = path.resolve(path.dirname(HERE), '..');
const MODELS_PATH = path.join(HARNESS_ROOT, 'config', 'models.json');
const VALID_PINNING_SURFACES: readonly PinningSurface[] = [
'anthropic_immutable',
'floating_alias',
'revision_hash_pinned',
];
const VALID_JUDGE_ROLES: readonly JudgeRole[] = ['primary', 'secondary', 'tertiary', 'reserve'];
/** Sprint 11 Task 2.2 ratified judge ensemble — 3-primary + 1-reserve. */
const REQUIRED_JUDGE_IDS: readonly string[] = ['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1', 'grok-4.20'];
function loadModels(): Record<string, ModelSpec> {
const raw = fs.readFileSync(MODELS_PATH, 'utf-8');
return JSON.parse(raw) as Record<string, ModelSpec>;
}
describe('models.json — file integrity (criterion 1, 5)', () => {
it('exists at the expected path', () => {
expect(fs.existsSync(MODELS_PATH)).toBe(true);
});
it('parses cleanly as JSON', () => {
expect(() => loadModels()).not.toThrow();
});
it('every entry id matches its hash-key', () => {
const models = loadModels();
for (const [key, entry] of Object.entries(models)) {
expect(entry.id).toBe(key);
}
});
it('no duplicate ids', () => {
const models = loadModels();
const ids = Object.values(models).map(m => m.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('pinning_surface field (criteria 2, 3, 4)', () => {
it('every entry has a pinning_surface field', () => {
const models = loadModels();
for (const [key, entry] of Object.entries(models)) {
expect(entry.pinning_surface, `model ${key} missing pinning_surface`).toBeDefined();
}
});
it('every pinning_surface value is in the B3 addendum § 4 enum', () => {
const models = loadModels();
for (const [key, entry] of Object.entries(models)) {
expect(
VALID_PINNING_SURFACES.includes(entry.pinning_surface as PinningSurface),
`model ${key} has invalid pinning_surface: ${entry.pinning_surface}`,
).toBe(true);
}
});
it('anthropic_immutable entries have null carve_out_reason', () => {
const models = loadModels();
const anthropicImmutable = Object.entries(models).filter(
([, entry]) => entry.pinning_surface === 'anthropic_immutable',
);
expect(anthropicImmutable.length).toBeGreaterThan(0);
for (const [key, entry] of anthropicImmutable) {
expect(
entry.pinning_surface_carve_out_reason,
`anthropic_immutable model ${key} must have null carve_out_reason`,
).toBeNull();
}
});
it('floating_alias entries have non-null carve_out_reason with B3 addendum rationale', () => {
const models = loadModels();
const floatingAlias = Object.entries(models).filter(
([, entry]) => entry.pinning_surface === 'floating_alias',
);
expect(floatingAlias.length).toBeGreaterThan(0);
for (const [key, entry] of floatingAlias) {
const reason = entry.pinning_surface_carve_out_reason;
expect(reason, `floating_alias model ${key} must have non-null carve_out_reason`).not.toBeNull();
expect(typeof reason).toBe('string');
expect((reason as string).length).toBeGreaterThan(10);
// B3 addendum § 5 requires the reason to reference the addendum so an
// audit grep surfaces every carve-out in one query.
expect(reason as string).toMatch(/B3 addendum/);
}
});
});
describe('Sprint 11 Task 2.2 judge ensemble (criterion 6)', () => {
it('all four required judge entries are present', () => {
const models = loadModels();
for (const id of REQUIRED_JUDGE_IDS) {
expect(models[id], `required judge ${id} missing from registry`).toBeDefined();
}
});
it('every judge entry has a judge_role in the valid enum', () => {
const models = loadModels();
for (const id of REQUIRED_JUDGE_IDS) {
const entry = models[id];
expect(entry.judge_role).toBeDefined();
expect(VALID_JUDGE_ROLES.includes(entry.judge_role as JudgeRole)).toBe(true);
}
});
it('Opus 4.7 is anthropic_immutable primary (A3 LOCK § 4 consistency)', () => {
const models = loadModels();
const opus = models['claude-opus-4-7'];
expect(opus.pinning_surface).toBe('anthropic_immutable');
expect(opus.pinning_surface_carve_out_reason).toBeNull();
expect(opus.judge_role).toBe('primary');
expect(opus.provider).toBe('anthropic');
});
it('Grok 4.20 is the reserve tie-break (B2 LOCK § 1 quadri-vendor invariant)', () => {
const models = loadModels();
const grok = models['grok-4.20'];
expect(grok).toBeDefined();
expect(grok.judge_role).toBe('reserve');
expect(grok.pinning_surface).toBe('floating_alias');
expect(grok.provider).toBe('xai_via_openrouter');
});
it('GPT-5.4 + Gemini 3.1 are primary judges (B2 LOCK § 1 3-vendor primary ensemble)', () => {
const models = loadModels();
const gpt = models['gpt-5.4'];
const gemini = models['gemini-3.1'];
expect(gpt.judge_role).toBe('primary');
expect(gpt.pinning_surface).toBe('floating_alias');
expect(gemini.judge_role).toBe('primary');
expect(gemini.pinning_surface).toBe('floating_alias');
});
it('target models (not judges) leave judge_role undefined', () => {
const models = loadModels();
const qwenTarget = models['qwen3.6-35b-a3b-stage2'];
expect(qwenTarget).toBeDefined();
expect(qwenTarget.judge_role).toBeUndefined();
});
});
describe('ModelSpec shape invariants', () => {
it('every entry has the Sprint 7 baseline fields (id/displayName/provider/litellmModel/pricing/contextWindow)', () => {
const models = loadModels();
for (const [key, entry] of Object.entries(models)) {
expect(typeof entry.id, `${key}.id`).toBe('string');
expect(typeof entry.displayName, `${key}.displayName`).toBe('string');
expect(typeof entry.provider, `${key}.provider`).toBe('string');
expect(typeof entry.litellmModel, `${key}.litellmModel`).toBe('string');
expect(typeof entry.pricePerMillionInput, `${key}.pricePerMillionInput`).toBe('number');
expect(typeof entry.pricePerMillionOutput, `${key}.pricePerMillionOutput`).toBe('number');
expect(typeof entry.contextWindow, `${key}.contextWindow`).toBe('number');
}
});
});

View File

@@ -0,0 +1,342 @@
/**
* Sprint 12 Task 1 Blocker #3 — pre-registration emitter tests.
*
* Acceptance criteria (per brief § 4.1):
* 1. Payload schema valid — every required field present and typed right.
* 2. Emitter fires exactly once when called.
* 3. Payload includes canonical dataset SHA-256.
* 4. Payload includes manifest hash (CLI override OR auto-computed).
* 5. Manifest hash deterministic across 3 reads.
* 6. `resolveManifestPath` honors `BENCH_SPEC_MANIFEST_PATH` env var.
* 7. `ManifestNotFoundError` thrown when path absent + no override.
* 8. `readManifestLockedDate` normalises YAML `locked_date: YYYY-MM-DD`
* to ISO-8601 `YYYY-MM-DDT00:00:00Z`.
* 9. `sanitizeArgv` redacts API-key-shaped arguments.
* 10. Event name matches the canonical `bench.preregistration.manifest_hash`.
*
* No LLM calls. No real manifest — uses tmp-dir fixture YAML.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import {
CANONICAL_MANIFEST_PATH,
ManifestNotFoundError,
PREREGISTRATION_EVENT_NAME,
RUNNER_VERSION_FALLBACK,
computeBenchSpecManifestHash,
emitPreregistrationManifest,
getRunnerVersion,
readManifestLockedDate,
resolveManifestPath,
sanitizeArgv,
type PreregistrationManifestPayload,
} from '../src/preregistration.js';
// Fixture YAML that mirrors the A3 LOCK v1 `locked_date:` line exactly
// so the regex extractor + hash functions get real-shape input. Kept
// minimal — tests don't need the full 250-line canonical doc.
const FIXTURE_YAML = `# Bench-Spec LOCK v1 — machine-readable twin (test fixture)
manifest_version: v1.0.0
manifest_type: bench_spec_lock_parent
locked_date: 2026-04-22
authority: PM (Marko Marković) — A3 interview 7/7 closed 2026-04-22
sprint: 11
track: A
task: A3
`;
// Known-good SHA-256 of FIXTURE_YAML bytes. Computed once here and
// asserted in determinism tests — any accidental fixture mutation
// surfaces as a test break, not silent drift.
const FIXTURE_HASH = crypto.createHash('sha256').update(FIXTURE_YAML, 'utf-8').digest('hex');
function makeValidPayload(overrides: Partial<PreregistrationManifestPayload> = {}): PreregistrationManifestPayload {
return {
manifest_hash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
manifest_path: CANONICAL_MANIFEST_PATH,
manifest_locked_at: '2026-04-22T00:00:00Z',
dataset_version: '39e415e2f3a0fa1bd3cb1804a58d0b440b50d3070b2100698437e4ec402a5b24',
dataset_path: 'locomo/locomo-1540.jsonl',
dataset_instance_count: 1531,
per_cell: ['raw', 'filtered', 'compressed', 'full-context'],
judge_tiebreak: 'quadri-vendor',
judge_models: [],
emitted_at: '2026-04-22T12:00:00.000Z',
runner_version: 'abc1234',
runner_invocation: { argv: ['node', 'runner.ts'], cwd: '/tmp/test' },
...overrides,
};
}
describe('PreregistrationManifestPayload schema (criterion 1)', () => {
it('accepts a fully-populated payload', () => {
const payload = makeValidPayload();
// Compile-time proof: TS picks up the interface. Runtime proof: every
// required field is a string/number/array of the right shape.
expect(typeof payload.manifest_hash).toBe('string');
expect(payload.manifest_hash).toMatch(/^[0-9a-f]{64}$/);
expect(typeof payload.manifest_path).toBe('string');
expect(typeof payload.manifest_locked_at).toBe('string');
expect(typeof payload.dataset_version).toBe('string');
expect(typeof payload.dataset_path).toBe('string');
expect(typeof payload.dataset_instance_count).toBe('number');
expect(Array.isArray(payload.per_cell)).toBe(true);
expect(typeof payload.judge_tiebreak).toBe('string');
expect(Array.isArray(payload.judge_models)).toBe(true);
expect(typeof payload.emitted_at).toBe('string');
expect(typeof payload.runner_version).toBe('string');
expect(typeof payload.runner_invocation.cwd).toBe('string');
expect(Array.isArray(payload.runner_invocation.argv)).toBe(true);
});
it('carries the canonical manifest path constant', () => {
expect(CANONICAL_MANIFEST_PATH).toBe('decisions/2026-04-22-bench-spec-locked.manifest.yaml');
});
});
describe('emitPreregistrationManifest (criteria 2, 10)', () => {
let infoSpy: MockInstance;
beforeEach(() => {
// createCoreLogger(...).info() routes to console.error (stderr) so library
// log lines never corrupt stdout machine-consumers (hive-mind-core/src/logger.ts).
infoSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
infoSpy.mockRestore();
});
it('emits exactly once per call with the canonical event name', () => {
emitPreregistrationManifest(makeValidPayload());
expect(infoSpy).toHaveBeenCalledTimes(1);
const call = infoSpy.mock.calls[0];
// createCoreLogger('bench.preregistration') emits
// `[waggle:bench.preregistration] <msg>` as first arg, then payload.
expect(String(call[0])).toContain('[waggle:bench.preregistration]');
expect(String(call[0])).toContain(PREREGISTRATION_EVENT_NAME);
const payload = call[1] as Record<string, unknown>;
expect(payload.event).toBe(PREREGISTRATION_EVENT_NAME);
expect(PREREGISTRATION_EVENT_NAME).toBe('bench.preregistration.manifest_hash');
});
it('payload carries canonical dataset SHA + instance count verbatim', () => {
emitPreregistrationManifest(makeValidPayload({
dataset_version: 'abc123',
dataset_instance_count: 1531,
}));
const payload = infoSpy.mock.calls[0][1] as Record<string, unknown>;
expect(payload.dataset_version).toBe('abc123');
expect(payload.dataset_instance_count).toBe(1531);
});
});
describe('resolveManifestPath (criteria 6, 7)', () => {
let tmp: string;
const savedEnv = process.env.BENCH_SPEC_MANIFEST_PATH;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-prereg-'));
delete process.env.BENCH_SPEC_MANIFEST_PATH;
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
if (savedEnv === undefined) {
delete process.env.BENCH_SPEC_MANIFEST_PATH;
} else {
process.env.BENCH_SPEC_MANIFEST_PATH = savedEnv;
}
});
it('resolves explicit override path when it exists', () => {
const fixturePath = path.join(tmp, 'bench-spec.manifest.yaml');
fs.writeFileSync(fixturePath, FIXTURE_YAML, 'utf-8');
expect(resolveManifestPath(fixturePath)).toBe(fixturePath);
});
it('throws ManifestNotFoundError when explicit override is absent', () => {
const missing = path.join(tmp, 'does-not-exist.yaml');
expect(() => resolveManifestPath(missing)).toThrow(ManifestNotFoundError);
});
it('honors BENCH_SPEC_MANIFEST_PATH env var when set', () => {
const fixturePath = path.join(tmp, 'env-driven.yaml');
fs.writeFileSync(fixturePath, FIXTURE_YAML, 'utf-8');
process.env.BENCH_SPEC_MANIFEST_PATH = fixturePath;
expect(resolveManifestPath()).toBe(fixturePath);
});
it('ManifestNotFoundError exposes the attempted paths', () => {
const missing = path.join(tmp, 'missing.yaml');
try {
resolveManifestPath(missing);
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(ManifestNotFoundError);
const typed = err as ManifestNotFoundError;
expect(typed.attemptedPaths.length).toBeGreaterThan(0);
expect(typed.message).toContain('BENCH_SPEC_MANIFEST_PATH');
expect(typed.message).toContain('--manifest-hash');
}
});
});
describe('computeBenchSpecManifestHash (criteria 4, 5)', () => {
let tmp: string;
let fixturePath: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-hash-'));
fixturePath = path.join(tmp, 'manifest.yaml');
fs.writeFileSync(fixturePath, FIXTURE_YAML, 'utf-8');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('returns deterministic SHA-256 hex (matches byte hash)', () => {
const h = computeBenchSpecManifestHash(fixturePath);
expect(h).toBe(FIXTURE_HASH);
expect(h).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic across 3 consecutive calls', () => {
const a = computeBenchSpecManifestHash(fixturePath);
const b = computeBenchSpecManifestHash(fixturePath);
const c = computeBenchSpecManifestHash(fixturePath);
expect(a).toBe(b);
expect(b).toBe(c);
});
it('changes when the manifest YAML bytes change', () => {
const h1 = computeBenchSpecManifestHash(fixturePath);
fs.writeFileSync(fixturePath, FIXTURE_YAML + '# a single-byte change\n', 'utf-8');
const h2 = computeBenchSpecManifestHash(fixturePath);
expect(h1).not.toBe(h2);
});
});
describe('readManifestLockedDate (criterion 8)', () => {
let tmp: string;
let fixturePath: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-locked-date-'));
fixturePath = path.join(tmp, 'manifest.yaml');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('normalises YYYY-MM-DD to ISO-8601 midnight UTC', () => {
fs.writeFileSync(fixturePath, FIXTURE_YAML, 'utf-8');
expect(readManifestLockedDate(fixturePath)).toBe('2026-04-22T00:00:00Z');
});
it('passes through full ISO-8601 timestamps unchanged', () => {
fs.writeFileSync(fixturePath, 'locked_date: 2026-04-22T14:30:00Z\n', 'utf-8');
expect(readManifestLockedDate(fixturePath)).toBe('2026-04-22T14:30:00Z');
});
it('returns "unknown" when the field is absent', () => {
fs.writeFileSync(fixturePath, 'other_field: value\n', 'utf-8');
expect(readManifestLockedDate(fixturePath)).toBe('unknown');
});
});
describe('sanitizeArgv (criterion 9)', () => {
it('redacts values after --api-key / --bearer / --token / --key', () => {
const argv = ['node', 'runner.ts', '--api-key', 'secret-key-123', '--token', 'bearer-abc'];
const result = sanitizeArgv(argv);
expect(result).toEqual([
'node', 'runner.ts', '--api-key', '[REDACTED]', '--token', '[REDACTED]',
]);
});
it('redacts sk-* and Bearer * tokens inline', () => {
const argv = ['node', 'runner.ts', 'sk-proj-abcdef0123', 'Bearer xyz789'];
const result = sanitizeArgv(argv);
expect(result).toEqual(['node', 'runner.ts', '[REDACTED]', '[REDACTED]']);
});
it('passes through normal arguments unchanged', () => {
const argv = ['node', 'runner.ts', '--cell', 'raw', '--seed', '42'];
expect(sanitizeArgv(argv)).toEqual(argv);
});
});
describe('getRunnerVersion', () => {
it('returns a string — either git short SHA or the fallback', () => {
const v = getRunnerVersion();
expect(typeof v).toBe('string');
expect(v.length).toBeGreaterThan(0);
// Either looks like a short SHA (7-40 hex chars) or is the fallback.
expect(/^[0-9a-f]{7,40}$/.test(v) || v === RUNNER_VERSION_FALLBACK).toBe(true);
});
});
// ── Sub-deliverable C — per-judge pinning in manifest payload ────────────
describe('judge_models B3 addendum pinning (Sub-deliverable C)', () => {
let infoSpy: MockInstance;
beforeEach(() => {
// createCoreLogger(...).info() routes to console.error (stderr) so library
// log lines never corrupt stdout machine-consumers (hive-mind-core/src/logger.ts).
infoSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
infoSpy.mockRestore();
});
it('payload.judge_models carries per-model pinning_surface + carve_out_reason', () => {
const payload = makeValidPayload({
judge_models: [
{
model_id: 'claude-opus-4-7',
provider: 'anthropic',
judge_role: 'primary',
pinning_surface: 'anthropic_immutable',
pinning_surface_carve_out_reason: null,
},
{
model_id: 'gemini-3.1',
provider: 'google_via_openrouter',
judge_role: 'secondary',
pinning_surface: 'floating_alias',
pinning_surface_carve_out_reason:
'Google does not expose immutable model snapshots through OpenRouter routing layer; floating alias mandated by B3 addendum § 5',
},
],
});
emitPreregistrationManifest(payload);
expect(infoSpy).toHaveBeenCalledTimes(1);
const emitted = infoSpy.mock.calls[0][1] as { judge_models: Array<Record<string, unknown>> };
expect(emitted.judge_models).toHaveLength(2);
expect(emitted.judge_models[0].model_id).toBe('claude-opus-4-7');
expect(emitted.judge_models[0].pinning_surface).toBe('anthropic_immutable');
expect(emitted.judge_models[0].pinning_surface_carve_out_reason).toBeNull();
expect(emitted.judge_models[1].model_id).toBe('gemini-3.1');
expect(emitted.judge_models[1].pinning_surface).toBe('floating_alias');
expect((emitted.judge_models[1].pinning_surface_carve_out_reason as string)).toMatch(/B3 addendum/);
});
it('payload.judge_models[] is empty when judging is disabled (schema-valid)', () => {
const payload = makeValidPayload({ judge_models: [] });
emitPreregistrationManifest(payload);
const emitted = infoSpy.mock.calls[0][1] as { judge_models: unknown[] };
expect(Array.isArray(emitted.judge_models)).toBe(true);
expect(emitted.judge_models).toHaveLength(0);
});
});

View File

@@ -0,0 +1,349 @@
/**
* Sprint 11 Task A2 — reasoning_content capture tests.
*
* Authority:
* - docs/plans/H-AUDIT-1-DESIGN-DOC-2026-04-22.md §3 (test scenarios) + §6 (implementation plan)
* - PM-Waggle-OS/decisions/2026-04-22-h-audit-1-design-ratified.md (PM ratification — all 5 open questions answered)
*
* Two canonical acceptance tests + ratification-specific coverage:
*
* 1. reasoning_content round-trip at the transport layer — parser extracts
* the three supported shapes in the ratified precedence order
* (`message.reasoning_content` > `message.reasoning` > `body.reasoning_content`).
* 2. Full turn-graph reconstruction from a single turnId — after a harness
* turn runs, filtering JSONL by turnId yields one row carrying answer,
* reasoning, cost, latency, and (when judged) judge payload.
*
* Plus:
* 3. `reasoningShape='unknown'` signal when thinking=on but no reasoning field.
* 4. `readJsonl(path, { includeReasoning: false })` strips content but keeps
* `reasoning_content_chars` + `reasoning_shape` observability fields.
* 5. Exclusion verification: `judge-runner.ts` does NOT pass reasoning to judges.
* 6. metrics.ts aggregates reasoning_content chars + shape distribution when
* any record has reasoning data.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createLlmClient } from '../src/llm.js';
import { JsonlWriter, readJsonl, buildAggregate } from '../src/metrics.js';
import type { JsonlRecord, ModelSpec, DatasetSpec, RunConfig } from '../src/types.js';
// ── Shared fixtures ────────────────────────────────────────────────────────
const stage2Model: ModelSpec = {
id: 'qwen3.6-35b-a3b-stage2',
displayName: 'Stage 2 LOCKED',
provider: 'alibaba',
litellmModel: 'qwen3.6-35b-a3b-via-openrouter',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
stage2Config: {
thinking: true,
maxTokens: 64000,
reasoningShape: 'openrouter-unified',
},
};
const syntheticDataset: DatasetSpec = {
id: 'synthetic',
displayName: 'Synthetic',
dataPath: 'synthetic/placeholder.jsonl',
source: 'synthetic',
};
function respondWith(body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
// ── Test 1: parser precedence + round-trip ────────────────────────────────
describe('Sprint 11 A2 — reasoning_content parser precedence (ratification §Q3)', () => {
let originalFetch: typeof global.fetch;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
originalFetch = global.fetch;
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('primary: DashScope native `message.reasoning_content` is preferred over `message.reasoning` when both are present', async () => {
fetchMock.mockResolvedValueOnce(respondWith({
choices: [{ message: {
content: 'Paris',
reasoning: 'OR-unified chain', // secondary shape
reasoning_content: 'DashScope native chain', // primary shape
} }],
usage: { prompt_tokens: 10, completion_tokens: 1 },
}));
const llm = createLlmClient({ dryRun: false, litellmUrl: 'http://unused', litellmApiKey: 'sk-t' });
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.text).toBe('Paris');
expect(result.reasoningContent).toBe('DashScope native chain');
expect(result.reasoningShape).toBe('message.reasoning_content');
});
it('secondary: OpenRouter unified `message.reasoning` when primary is absent', async () => {
fetchMock.mockResolvedValueOnce(respondWith({
choices: [{ message: { content: 'Paris', reasoning: 'OR-unified chain' } }],
usage: { prompt_tokens: 10, completion_tokens: 1 },
}));
const llm = createLlmClient({ dryRun: false, litellmUrl: 'http://unused', litellmApiKey: 'sk-t' });
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.reasoningContent).toBe('OR-unified chain');
expect(result.reasoningShape).toBe('message.reasoning');
});
it('tertiary: legacy top-level `body.reasoning_content` when both primary and secondary absent', async () => {
fetchMock.mockResolvedValueOnce(respondWith({
choices: [{ message: { content: 'Paris' } }],
reasoning_content: 'legacy top-level chain',
usage: { prompt_tokens: 10, completion_tokens: 1 },
}));
const llm = createLlmClient({ dryRun: false, litellmUrl: 'http://unused', litellmApiKey: 'sk-t' });
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.reasoningContent).toBe('legacy top-level chain');
expect(result.reasoningShape).toBe('body.reasoning_content');
});
it('unknown: thinking=on requested but no reasoning field present — signal drift without throwing', async () => {
fetchMock.mockResolvedValueOnce(respondWith({
choices: [{ message: { content: 'Paris' } }],
usage: { prompt_tokens: 10, completion_tokens: 1 },
}));
const llm = createLlmClient({ dryRun: false, litellmUrl: 'http://unused', litellmApiKey: 'sk-t' });
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.text).toBe('Paris');
expect(result.reasoningContent).toBeUndefined();
expect(result.reasoningShape).toBe('unknown');
});
it('thinking=off: reasoningShape stays undefined (no drift signal for legitimate no-reasoning routes)', async () => {
fetchMock.mockResolvedValueOnce(respondWith({
choices: [{ message: { content: 'Paris' } }],
usage: { prompt_tokens: 10, completion_tokens: 1 },
}));
const baseline: ModelSpec = { ...stage2Model, stage2Config: undefined };
const llm = createLlmClient({ dryRun: false, litellmUrl: 'http://unused', litellmApiKey: 'sk-t' });
const result = await llm.call({ model: baseline, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.reasoningContent).toBeUndefined();
expect(result.reasoningShape).toBeUndefined();
});
});
// ── Test 2: full turn-graph reconstruction + JSONL round-trip ─────────────
describe('Sprint 11 A2 — JSONL persistence + turn-graph reconstruction (design doc §3 test 2)', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-reasoning-capture-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('reconstructs full turn graph from single turnId (answer + reasoning + cost + latency)', async () => {
// Write one record with the full reasoning payload — models what runner.ts
// persists after a live call.
const outputPath = path.join(tmpDir, 'reconstruct.jsonl');
const writer = new JsonlWriter(outputPath);
const turnId = '11111111-2222-3333-4444-555555555555';
const record: JsonlRecord = {
turnId,
cell: 'raw',
instance_id: 'synth_001',
model: 'qwen3.6-35b-a3b-stage2',
seed: 42,
accuracy: 1,
p50_latency_ms: 3040,
p95_latency_ms: 3040,
usd_per_query: 0.000122,
failure_mode: null,
reasoning_content: 'Thinking Process: 2+2=4. Answer: 4.',
reasoning_content_chars: 35,
reasoning_shape: 'message.reasoning',
};
writer.write(record);
await writer.close();
// Read-path: opt in to reasoning for the reconstruct consumer.
const rows = readJsonl(outputPath, { includeReasoning: true });
const filtered = rows.filter(r => r.turnId === turnId);
expect(filtered).toHaveLength(1);
expect(filtered[0].reasoning_content).toBe('Thinking Process: 2+2=4. Answer: 4.');
expect(filtered[0].reasoning_content_chars).toBe(35);
expect(filtered[0].reasoning_shape).toBe('message.reasoning');
expect(filtered[0].p50_latency_ms).toBe(3040);
expect(filtered[0].usd_per_query).toBeCloseTo(0.000122, 6);
});
it('readJsonl default strips reasoning_content but keeps chars + shape (ratification §Q4 read-path pruning)', async () => {
const outputPath = path.join(tmpDir, 'pruned.jsonl');
const writer = new JsonlWriter(outputPath);
writer.write({
turnId: 'abc',
cell: 'raw',
instance_id: 'synth_001',
model: 'qwen3.6-35b-a3b-stage2',
seed: 42,
accuracy: 1,
p50_latency_ms: 100,
p95_latency_ms: 100,
usd_per_query: 0.001,
failure_mode: null,
reasoning_content: 'secret chain-of-thought',
reasoning_content_chars: 24,
reasoning_shape: 'message.reasoning',
});
await writer.close();
const pruned = readJsonl(outputPath); // default: includeReasoning: false
expect(pruned).toHaveLength(1);
expect(pruned[0].reasoning_content).toBeUndefined(); // stripped
expect(pruned[0].reasoning_content_chars).toBe(24); // kept
expect(pruned[0].reasoning_shape).toBe('message.reasoning'); // kept
});
it('readJsonl { includeReasoning: true } preserves everything (archive + audit path)', async () => {
const outputPath = path.join(tmpDir, 'full.jsonl');
const writer = new JsonlWriter(outputPath);
writer.write({
turnId: 'xyz',
cell: 'raw',
instance_id: 's',
model: 'm',
seed: 42,
accuracy: 1,
p50_latency_ms: 1,
p95_latency_ms: 1,
usd_per_query: 0,
failure_mode: null,
reasoning_content: 'full chain here',
reasoning_content_chars: 15,
reasoning_shape: 'message.reasoning_content',
});
await writer.close();
const full = readJsonl(outputPath, { includeReasoning: true });
expect(full[0].reasoning_content).toBe('full chain here');
expect(full[0].reasoning_content_chars).toBe(15);
expect(full[0].reasoning_shape).toBe('message.reasoning_content');
});
});
// ── Test 3: aggregate surface ─────────────────────────────────────────────
describe('Sprint 11 A2 — metrics aggregate (design doc §6.3)', () => {
it('computes reasoning_content sum/p50/p95 + shape distribution when any record carries reasoning', () => {
const records: JsonlRecord[] = [
{ turnId: 'a', cell: 'raw', instance_id: 's', model: 'm', seed: 42, accuracy: 1, p50_latency_ms: 10, p95_latency_ms: 10, usd_per_query: 0.001, failure_mode: null,
reasoning_content_chars: 100, reasoning_shape: 'message.reasoning_content' },
{ turnId: 'b', cell: 'raw', instance_id: 's', model: 'm', seed: 42, accuracy: 1, p50_latency_ms: 10, p95_latency_ms: 10, usd_per_query: 0.001, failure_mode: null,
reasoning_content_chars: 200, reasoning_shape: 'message.reasoning' },
{ turnId: 'c', cell: 'raw', instance_id: 's', model: 'm', seed: 42, accuracy: 1, p50_latency_ms: 10, p95_latency_ms: 10, usd_per_query: 0.001, failure_mode: null,
reasoning_content_chars: 500, reasoning_shape: 'message.reasoning' },
];
const config: RunConfig = {
run: { kind: 'cell', name: 'raw' },
dataset: syntheticDataset,
model: stage2Model,
limit: 3,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath: 'unused',
dryRun: true,
litellmUrl: 'unused',
litellmApiKey: 'unused',
};
const summary = buildAggregate(config, records, '2026-04-22T00:00:00Z', '2026-04-22T00:00:01Z', null);
expect(summary.reasoningContent).toBeDefined();
expect(summary.reasoningContent!.count).toBe(3);
expect(summary.reasoningContent!.sumChars).toBe(800);
expect(summary.reasoningContent!.shapeDistribution).toEqual({
'message.reasoning_content': 1,
'message.reasoning': 2,
});
});
it('omits reasoningContent aggregate when NO records carry reasoning (thinking=off runs stay compact)', () => {
const records: JsonlRecord[] = [
{ turnId: 'a', cell: 'raw', instance_id: 's', model: 'm', seed: 42, accuracy: 1, p50_latency_ms: 10, p95_latency_ms: 10, usd_per_query: 0.001, failure_mode: null },
];
const config: RunConfig = {
run: { kind: 'cell', name: 'raw' },
dataset: syntheticDataset,
model: { ...stage2Model, stage2Config: undefined },
limit: 1,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath: 'unused',
dryRun: true,
litellmUrl: 'unused',
litellmApiKey: 'unused',
};
const summary = buildAggregate(config, records, '2026-04-22T00:00:00Z', '2026-04-22T00:00:01Z', null);
expect(summary.reasoningContent).toBeUndefined();
});
it('counts shape=unknown in the shape distribution (observable drift signal reaches aggregates)', () => {
const records: JsonlRecord[] = [
{ turnId: 'a', cell: 'raw', instance_id: 's', model: 'm', seed: 42, accuracy: 1, p50_latency_ms: 10, p95_latency_ms: 10, usd_per_query: 0.001, failure_mode: null,
reasoning_shape: 'unknown' },
];
const config: RunConfig = {
run: { kind: 'cell', name: 'raw' },
dataset: syntheticDataset,
model: stage2Model,
limit: 1,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath: 'unused',
dryRun: true,
litellmUrl: 'unused',
litellmApiKey: 'unused',
};
const summary = buildAggregate(config, records, '2026-04-22T00:00:00Z', '2026-04-22T00:00:01Z', null);
expect(summary.reasoningContent).toBeDefined();
expect(summary.reasoningContent!.shapeDistribution.unknown).toBe(1);
});
});
// ── Test 4: exclusion-rule verification (§2.4 rule 2) ─────────────────────
describe('Sprint 11 A2 — exclusion contract (design doc §2.4)', () => {
it('judge-runner.ts does NOT reference reasoning_content anywhere (static guard against future regressions)', () => {
const judgeRunnerPath = path.resolve(__dirname, '../src/judge-runner.ts');
const source = fs.readFileSync(judgeRunnerPath, 'utf-8');
// The judge input surface is `{ question, groundTruth, contextExcerpt, modelAnswer }` —
// any occurrence of `reasoning_content` or `.reasoning` inside judge-runner would
// mean a regression opening the exclusion loophole.
expect(source.includes('reasoning_content')).toBe(false);
// `.reasoning` naked match is too broad (e.g. variable names), so guard
// on the specific key access patterns instead:
expect(source.match(/\.reasoning(?![_\w])/g)).toBeNull();
});
});

View File

@@ -0,0 +1,167 @@
/**
* Task 2.5 Stage 1.5 §7.4 — acquireRunnerLock tests.
*
* Uses a fresh per-test tmpdir so concurrent vitest workers don't collide,
* and passes `skipSignalHandlers: true` so the vitest runner's SIGINT path
* stays untouched.
*
* Windows PID-check note (PM §6): these tests assert the mtime-heartbeat
* behaviour, which is the primary cross-platform signal. `process.kill(pid,
* 0)` is not exercised here — it's used nowhere in the production code
* path (see src/runner-lock.ts module header for rationale).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { acquireRunnerLock } from '../src/runner-lock.js';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bench-lock-test-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('acquireRunnerLock — basic acquire/release', () => {
it('writes the lock file with payload {pid, hostname, startedAt}', () => {
const output = path.join(tmpDir, 'run.jsonl');
const h = acquireRunnerLock(output, { skipSignalHandlers: true });
try {
expect(fs.existsSync(h.lockPath)).toBe(true);
const payload = JSON.parse(fs.readFileSync(h.lockPath, 'utf-8'));
expect(payload.pid).toBe(process.pid);
expect(typeof payload.hostname).toBe('string');
expect(new Date(payload.startedAt).toString()).not.toBe('Invalid Date');
} finally {
h.release();
}
});
it('release() deletes the lock file', () => {
const output = path.join(tmpDir, 'run.jsonl');
const h = acquireRunnerLock(output, { skipSignalHandlers: true });
expect(fs.existsSync(h.lockPath)).toBe(true);
h.release();
expect(fs.existsSync(h.lockPath)).toBe(false);
});
it('release() is idempotent', () => {
const output = path.join(tmpDir, 'run.jsonl');
const h = acquireRunnerLock(output, { skipSignalHandlers: true });
h.release();
expect(() => h.release()).not.toThrow();
});
it('creates parent directories as needed', () => {
const output = path.join(tmpDir, 'nested', 'dir', 'run.jsonl');
const h = acquireRunnerLock(output, { skipSignalHandlers: true });
try {
expect(fs.existsSync(h.lockPath)).toBe(true);
} finally {
h.release();
}
});
});
describe('acquireRunnerLock — contention + staleness', () => {
it('refuses to acquire when a fresh lock exists', () => {
const output = path.join(tmpDir, 'run.jsonl');
const h1 = acquireRunnerLock(output, { skipSignalHandlers: true });
try {
expect(() =>
acquireRunnerLock(output, { skipSignalHandlers: true }),
).toThrow(/active runner lock/);
} finally {
h1.release();
}
});
it('takes over a stale lock (mtime beyond staleMs)', () => {
const output = path.join(tmpDir, 'run.jsonl');
const h1 = acquireRunnerLock(output, { skipSignalHandlers: true });
// Backdate the lock file mtime to simulate a crashed owner.
const oldTime = new Date(Date.now() - 10 * 60_000);
fs.utimesSync(h1.lockPath, oldTime, oldTime);
// Second acquire should succeed — stale owner, take over.
const h2 = acquireRunnerLock(output, { skipSignalHandlers: true, staleMs: 60_000 });
try {
// Lock is the same file path but now owned by h2 — verify by reading
// the payload's startedAt (h2 wrote fresh, so it's recent).
const payload = JSON.parse(fs.readFileSync(h2.lockPath, 'utf-8'));
const age = Date.now() - new Date(payload.startedAt).getTime();
expect(age).toBeLessThan(2000); // written within last 2s
} finally {
h2.release();
// h1.release() is a no-op since h2 deleted the shared lock file; still safe.
h1.release();
}
});
it('tolerates a corrupt lock file (treats as stale when mtime allows)', () => {
const output = path.join(tmpDir, 'run.jsonl');
const lockPath = `${output}.lock`;
// Manually write garbage + backdate so it counts as stale.
fs.writeFileSync(lockPath, '{not valid json', 'utf-8');
const oldTime = new Date(Date.now() - 10 * 60_000);
fs.utimesSync(lockPath, oldTime, oldTime);
const h = acquireRunnerLock(output, { skipSignalHandlers: true, staleMs: 60_000 });
try {
const payload = JSON.parse(fs.readFileSync(h.lockPath, 'utf-8'));
expect(payload.pid).toBe(process.pid);
} finally {
h.release();
}
});
it('custom staleMs controls when a lock is considered stale', () => {
const output = path.join(tmpDir, 'run.jsonl');
const lockPath = `${output}.lock`;
fs.writeFileSync(lockPath, JSON.stringify({ pid: 99999, hostname: 'ghost', startedAt: '2000-01-01' }));
// Lock is fresh by default 60s window, but expired under staleMs: 1 (1ms).
// Wait 10ms to ensure age > 1ms:
const start = Date.now();
while (Date.now() - start < 15) { /* busy wait */ }
const h = acquireRunnerLock(output, { skipSignalHandlers: true, staleMs: 1 });
try {
expect(h.lockPath).toBe(lockPath);
} finally {
h.release();
}
});
});
describe('acquireRunnerLock — heartbeat refresh', () => {
it('refreshes lock mtime at the configured interval', async () => {
const output = path.join(tmpDir, 'run.jsonl');
const h = acquireRunnerLock(output, {
skipSignalHandlers: true,
heartbeatIntervalMs: 50, // aggressive for test speed
});
try {
const mtime1 = fs.statSync(h.lockPath).mtimeMs;
await new Promise<void>(resolve => setTimeout(resolve, 120)); // let heartbeat fire
const mtime2 = fs.statSync(h.lockPath).mtimeMs;
expect(mtime2).toBeGreaterThanOrEqual(mtime1);
} finally {
h.release();
}
});
it('stops refreshing after release()', async () => {
const output = path.join(tmpDir, 'run.jsonl');
const h = acquireRunnerLock(output, {
skipSignalHandlers: true,
heartbeatIntervalMs: 50,
});
h.release();
// Lock file is gone — subsequent heartbeat attempts silently fail (wrapped
// in try/catch). Wait and confirm no error bubbles up.
await new Promise<void>(resolve => setTimeout(resolve, 150));
expect(fs.existsSync(h.lockPath)).toBe(false);
});
});

View File

@@ -0,0 +1,403 @@
/**
* Four-cell ablation harness — smoke tests.
*
* Covers the brief's acceptance criteria:
* - `--cell raw --dataset locomo --limit 1` produces a JSONL record with all
* required fields (turnId, cell, instance_id, model, seed, accuracy,
* p50/p95 latency, usd_per_query, failure_mode).
* - `--control verbose-fixed --dataset locomo --limit 50` runs 50 instances
* without crashing and writes the aggregate summary.
* - Cost capture active on every record (all four cost fields present).
* - Seed reproducibility: same seed → identical instance order.
* - All four cells produce valid records when run via --all-cells.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseArgs, buildRuns, runOne } from '../src/runner.js';
import {
loadDataset,
sampleInstances,
loadPreflightSampleLock,
PREFLIGHT_LOCOMO_50_DISTRIBUTION,
} from '../src/datasets.js';
import type { JsonlRecord } from '../src/types.js';
const HERE = url.fileURLToPath(import.meta.url);
const HARNESS_ROOT = path.resolve(path.dirname(HERE), '..');
const DATA_DIR = path.resolve(HARNESS_ROOT, '..', 'data');
const STAGE_2_LOCK = path.join(DATA_DIR, 'preflight-locomo-50.json');
const CALIBRATION_LOCK = path.join(DATA_DIR, 'failure-mode-calibration-10.jsonl');
const SYNTHETIC_DATASET = {
id: 'synthetic' as const,
displayName: 'Synthetic',
dataPath: 'synthetic/placeholder.jsonl',
source: 'synthetic' as const,
};
const QWEN_MODEL = {
id: 'qwen3.6-35b-a3b',
displayName: 'Qwen3.6-35B-A3B',
provider: 'alibaba' as const,
litellmModel: 'dashscope/qwen3.6-35b-a3b',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
};
function readJsonl(file: string): JsonlRecord[] {
if (!fs.existsSync(file)) return [];
return fs.readFileSync(file, 'utf-8')
.split('\n')
.filter(l => l.trim().length > 0)
.map(l => JSON.parse(l) as JsonlRecord);
}
describe('arg parsing', () => {
it('parses a single-cell invocation', () => {
const args = parseArgs(['--cell', 'raw', '--dataset', 'locomo', '--limit', '1', '--model', 'qwen3.6-35b-a3b']);
expect(args.cell).toBe('raw');
expect(args.dataset).toBe('locomo');
expect(args.limit).toBe(1);
expect(args.model).toBe('qwen3.6-35b-a3b');
});
it('parses --all-cells', () => {
const args = parseArgs(['--all-cells', '--dataset', 'synthetic', '--limit', '5']);
expect(args.allCells).toBe(true);
expect(buildRuns(args)).toHaveLength(4);
});
it('parses --full as Infinity', () => {
const args = parseArgs(['--cell', 'raw', '--full']);
expect(args.limit).toBe(Number.POSITIVE_INFINITY);
});
it('rejects unknown cell names', () => {
const args = parseArgs(['--cell', 'nonsense']);
expect(() => buildRuns(args)).toThrow(/Unknown cell/);
});
it('rejects unknown control names', () => {
const args = parseArgs(['--control', 'nonsense']);
expect(() => buildRuns(args)).toThrow(/Unknown control/);
});
});
describe('dataset sampling (reproducibility)', () => {
it('produces identical instance order for the same seed', () => {
const all = loadDataset(SYNTHETIC_DATASET, '/nonexistent');
const a = sampleInstances(all, 42, 10);
const b = sampleInstances(all, 42, 10);
expect(a.map(i => i.instance_id)).toEqual(b.map(i => i.instance_id));
});
it('produces different order for different seeds', () => {
const all = loadDataset(SYNTHETIC_DATASET, '/nonexistent');
const a = sampleInstances(all, 42, 10);
const b = sampleInstances(all, 7, 10);
expect(a.map(i => i.instance_id)).not.toEqual(b.map(i => i.instance_id));
});
});
describe('preflight-locomo-50 sample lock (Task 1 acceptance)', () => {
it('lock file exists at the canonical path and parses', () => {
expect(fs.existsSync(STAGE_2_LOCK)).toBe(true);
});
it('loads 50 instances with the required 13/13/12/12 distribution', () => {
const instances = loadPreflightSampleLock(STAGE_2_LOCK);
expect(instances).toHaveLength(50);
const raw = JSON.parse(fs.readFileSync(STAGE_2_LOCK, 'utf-8')) as {
instances: { category: string; id: string }[];
};
const dist: Record<string, number> = {};
for (const i of raw.instances) dist[i.category] = (dist[i.category] ?? 0) + 1;
expect(dist['single-hop']).toBe(PREFLIGHT_LOCOMO_50_DISTRIBUTION['single-hop']);
expect(dist['multi-hop']).toBe(PREFLIGHT_LOCOMO_50_DISTRIBUTION['multi-hop']);
expect(dist['temporal']).toBe(PREFLIGHT_LOCOMO_50_DISTRIBUTION['temporal']);
expect(dist['open-ended']).toBe(PREFLIGHT_LOCOMO_50_DISTRIBUTION['open-ended']);
});
it('has no duplicate instance ids', () => {
const raw = JSON.parse(fs.readFileSync(STAGE_2_LOCK, 'utf-8')) as {
instances: { id: string }[];
};
const ids = new Set(raw.instances.map(i => i.id));
expect(ids.size).toBe(raw.instances.length);
});
it('throws the Task-1 error message on a tampered lock', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-tampered-'));
const tamperedPath = path.join(tmp, 'tampered.json');
const raw = JSON.parse(fs.readFileSync(STAGE_2_LOCK, 'utf-8')) as {
_meta: unknown;
instances: { category: string }[];
};
// Drop a single single-hop to force a 12/13/12/12 mismatch.
const tampered = {
_meta: raw._meta,
instances: [
...raw.instances.filter(i => i.category !== 'single-hop'),
...raw.instances.filter(i => i.category === 'single-hop').slice(1),
],
};
fs.writeFileSync(tamperedPath, JSON.stringify(tampered), 'utf-8');
expect(() => loadPreflightSampleLock(tamperedPath)).toThrow(
/Pre-flight sample distribution mismatch: expected 13\/13\/12\/12/,
);
fs.rmSync(tmp, { recursive: true, force: true });
});
});
describe('failure-mode-calibration-10 (Task 2 acceptance)', () => {
it('lock file exists and parses as JSONL', () => {
expect(fs.existsSync(CALIBRATION_LOCK)).toBe(true);
});
it('has 10 instances with the 3/3/2/2 distribution and null human_label fields', () => {
const raw = fs.readFileSync(CALIBRATION_LOCK, 'utf-8');
const lines = raw
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('#'));
const records = lines.map(l => JSON.parse(l) as {
id: string;
category: string;
human_label: { verdict: null | string; failure_mode: null | string; rationale: null | string };
});
expect(records).toHaveLength(10);
const dist: Record<string, number> = {};
for (const r of records) dist[r.category] = (dist[r.category] ?? 0) + 1;
expect(dist['single-hop']).toBe(3);
expect(dist['multi-hop']).toBe(3);
expect(dist['temporal']).toBe(2);
expect(dist['open-ended']).toBe(2);
for (const r of records) {
expect(r.human_label.verdict).toBeNull();
expect(r.human_label.failure_mode).toBeNull();
expect(r.human_label.rationale).toBeNull();
}
});
it('does not overlap with preflight-locomo-50 instance ids', () => {
const calRaw = fs.readFileSync(CALIBRATION_LOCK, 'utf-8');
const calLines = calRaw
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('#'));
const calIds = new Set(calLines.map(l => (JSON.parse(l) as { id: string }).id));
const stageRaw = JSON.parse(fs.readFileSync(STAGE_2_LOCK, 'utf-8')) as {
instances: { id: string }[];
};
const stageIds = new Set(stageRaw.instances.map(i => i.id));
for (const id of calIds) expect(stageIds.has(id)).toBe(false);
// And stage-2 ids should not leak into calibration either.
for (const id of stageIds) expect(calIds.has(id)).toBe(false);
});
});
describe('runOne — acceptance criteria', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-bench-smoke-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('--cell raw --limit 1 produces a JSONL record with all required fields', async () => {
const outputPath = path.join(tmpDir, 'raw.jsonl');
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 1,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(1);
const r = records[0];
expect(r.turnId).toMatch(/^[0-9a-f-]{36}$/);
expect(r.cell).toBe('raw');
expect(r.instance_id).toBeTruthy();
expect(r.model).toBe('qwen3.6-35b-a3b');
expect(r.seed).toBe(42);
expect(typeof r.accuracy).toBe('number');
expect(typeof r.p50_latency_ms).toBe('number');
expect(typeof r.p95_latency_ms).toBe('number');
expect(typeof r.usd_per_query).toBe('number');
expect(r.failure_mode).toBeNull();
// Summary file is written alongside.
const summaryPath = outputPath.replace(/\.jsonl$/, '.summary.json');
expect(fs.existsSync(summaryPath)).toBe(true);
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
expect(summary.counts.total).toBe(1);
expect(summary.metrics).toHaveProperty('meanAccuracy');
expect(summary.metrics).toHaveProperty('totalUsd');
});
it('--control verbose-fixed --limit 50 executes 50 instances without crashing', async () => {
const outputPath = path.join(tmpDir, 'verbose-fixed.jsonl');
await runOne({
run: { kind: 'control', name: 'verbose-fixed' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 50,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(50);
for (const r of records) {
expect(r.cell).toBe('verbose-fixed');
expect(r.turnId).toMatch(/^[0-9a-f-]{36}$/);
expect(typeof r.usd_per_query).toBe('number');
expect(typeof r.p50_latency_ms).toBe('number');
expect(typeof r.p95_latency_ms).toBe('number');
}
// All turnIds must be unique (one per instance).
const turnIds = new Set(records.map(r => r.turnId));
expect(turnIds.size).toBe(50);
});
it('every record carries all four cost-capture fields (accuracy, p50, p95, usd_per_query)', async () => {
const outputPath = path.join(tmpDir, 'cost.jsonl');
await runOne({
run: { kind: 'cell', name: 'filtered' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 5,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records.length).toBeGreaterThan(0);
for (const r of records) {
// All four cost fields — brief acceptance requirement.
expect(r).toHaveProperty('accuracy');
expect(r).toHaveProperty('p50_latency_ms');
expect(r).toHaveProperty('p95_latency_ms');
expect(r).toHaveProperty('usd_per_query');
}
});
it('budget cap stops the run early', async () => {
const outputPath = path.join(tmpDir, 'budgeted.jsonl');
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 20,
seed: 42,
// Budget is tiny — even dry-run cost (roughly a few cents per call)
// will stop well before 20 instances complete if the budget guard
// works. We accept anywhere from 0 to a partial count here; the
// important invariant is that `<= 20` always.
budgetUsd: 0.000001,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records.length).toBeLessThan(20);
const summary = JSON.parse(
fs.readFileSync(outputPath.replace(/\.jsonl$/, '.summary.json'), 'utf-8'),
);
expect(summary.counts.budgetStoppedAt).not.toBeNull();
});
it('sample-lock path loads preflight-locomo-50.json with the correct distribution', async () => {
const outputPath = path.join(tmpDir, 'sample-lock.jsonl');
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET, // ignored when sampleLockPath is set
model: QWEN_MODEL,
limit: Number.POSITIVE_INFINITY,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
sampleLockPath: STAGE_2_LOCK,
// This test only exercises lock loading + deterministic ordering; skip
// preregistration emission (needs a manifest in the sibling PM-Waggle-OS
// repo, absent on CI — covered separately by preregistration.test.ts).
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(50);
// instance_ids must be the stable locomo_<sample_id>_q<NNN> form.
for (const r of records) {
expect(r.instance_id).toMatch(/^locomo_conv-\d+_q\d{3}$/);
}
// Ordering invariant: when the lock drives the run, re-running must
// produce the identical instance sequence (no shuffle applied).
const outputPath2 = path.join(tmpDir, 'sample-lock-2.jsonl');
await runOne({
run: { kind: 'cell', name: 'raw' },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: Number.POSITIVE_INFINITY,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath: outputPath2,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
sampleLockPath: STAGE_2_LOCK,
emitPreregistrationEvent: false,
});
const records2 = readJsonl(outputPath2);
expect(records2.map(r => r.instance_id)).toEqual(records.map(r => r.instance_id));
});
it('all four cells produce records with the correct `cell` tag', async () => {
const cellNames = ['raw', 'filtered', 'compressed', 'full-context'] as const;
for (const name of cellNames) {
const outputPath = path.join(tmpDir, `${name}.jsonl`);
await runOne({
run: { kind: 'cell', name },
dataset: SYNTHETIC_DATASET,
model: QWEN_MODEL,
limit: 2,
seed: 42,
budgetUsd: Number.POSITIVE_INFINITY,
outputPath,
dryRun: true,
litellmUrl: 'http://unused',
litellmApiKey: 'unused',
emitPreregistrationEvent: false,
});
const records = readJsonl(outputPath);
expect(records).toHaveLength(2);
for (const r of records) {
expect(r.cell).toBe(name);
}
}
});
});

View File

@@ -0,0 +1,119 @@
{
"_meta": {
"description": "Sprint 12 Task 1 Session 3 smoke test — mock judge responses for the 3-primary ensemble (Opus 4.7, GPT-5.4, Gemini 3.1) across 10 mock instances. One entry per (instance_id, judge_model) pair. Tie-break fourth-vendor (Grok 4.20) reserve vote is captured as a top-level `grok_reserve_vote` field on the single instance that triggers the 1-1 code split among incorrect judges (mock-q-08) — avoids pulling in the runtime resolveTieBreak module since smoke test is pipeline proof, not tie-break unit test.",
"kappa_target_band": "[0.60, 0.70]",
"kappa_predicted": 0.682,
"correctness_target": "7 of 10 final verdicts correct",
"brief": "PM-Waggle-OS/briefs/2026-04-22-cc-sprint-12-task1-session3-brief.md §2.1 C"
},
"judges": ["claude-opus-4-7", "gpt-5.4", "gemini-3.1"],
"tie_break_reserve": "grok-4.20",
"responses": [
{
"instance_id": "mock-q-01",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-02",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-03",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-04",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-05",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-06",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "correct", "failure_code": null, "rationale": null }
]
},
{
"instance_id": "mock-q-07",
"note": "(2,1) majority correct — 1 dissenting judge picks F3 off-topic. Final verdict = correct, no tie-break needed.",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gemini-3.1", "verdict": "incorrect", "failure_code": "F3", "rationale": null }
]
},
{
"instance_id": "mock-q-08",
"note": "(1,2) majority incorrect — 1 correct + 2 incorrect judges split codes (F1 vs F_other). Tie-break reserve (Grok 4.20) votes F1 → final verdict = incorrect, failure_code = F1.",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "correct", "failure_code": null, "rationale": null },
{ "judge": "gpt-5.4", "verdict": "incorrect", "failure_code": "F1", "rationale": null },
{
"judge": "gemini-3.1",
"verdict": "incorrect",
"failure_code": "F_other",
"rationale": "model answered with a completely different year and also attributed the move to the wrong city entirely"
}
],
"grok_reserve_vote": { "verdict": "incorrect", "failure_code": "F1", "rationale": null },
"final_failure_code": "F1"
},
{
"instance_id": "mock-q-09",
"note": "(0,3) unanimous F_other — all three judges agree the failure does not fit F1..F6 and each provides a ≥10-word rationale. Takes the sole F_other slot in the fixture so aggregate f_other_rate = 1/10 = 10% (not > 10%, review_flag stays off per A3 LOCK §6 strict-gt semantic).",
"judge_votes": [
{
"judge": "claude-opus-4-7",
"verdict": "incorrect",
"failure_code": "F_other",
"rationale": "model returned a long musical digression about unrelated string instruments rather than naming the one played"
},
{
"judge": "gpt-5.4",
"verdict": "incorrect",
"failure_code": "F_other",
"rationale": "the answer drifts into a tangential essay about orchestra sections and never actually states the instrument"
},
{
"judge": "gemini-3.1",
"verdict": "incorrect",
"failure_code": "F_other",
"rationale": "response compares cello and viola tonal range but fails to commit to a single instrument name"
}
]
},
{
"instance_id": "mock-q-10",
"note": "(0,3) unanimous F6 format-violation — all three judges agree the content is correct but formatted wrong (e.g. JSON envelope violated).",
"judge_votes": [
{ "judge": "claude-opus-4-7", "verdict": "incorrect", "failure_code": "F6", "rationale": null },
{ "judge": "gpt-5.4", "verdict": "incorrect", "failure_code": "F6", "rationale": null },
{ "judge": "gemini-3.1", "verdict": "incorrect", "failure_code": "F6", "rationale": null }
]
}
]
}

View File

@@ -0,0 +1,75 @@
{
"_meta": {
"description": "Sprint 12 Task 1 Session 3 smoke test — 10-instance synthetic LoCoMo-shaped fixture. Content is synthetic, structure mirrors the real locomo-1540.jsonl row shape at the fields the smoke pipeline exercises (instance_id, conversation_id, question, reference_answer). Prefix 'mock-conv-*' + 'mock-q-*' makes the synthetic-ness explicit so the fixture cannot be confused for a real LoCoMo slice.",
"conversation_distribution": {
"mock-conv-A": 3,
"mock-conv-B": 3,
"mock-conv-C": 2,
"mock-conv-D": 2
},
"total_instances": 10,
"brief": "PM-Waggle-OS/briefs/2026-04-22-cc-sprint-12-task1-session3-brief.md §2.1 C"
},
"instances": [
{
"instance_id": "mock-q-01",
"conversation_id": "mock-conv-A",
"question": "What day did Alice meet Bob?",
"reference_answer": "Tuesday"
},
{
"instance_id": "mock-q-02",
"conversation_id": "mock-conv-A",
"question": "Where did they go on the weekend?",
"reference_answer": "the lake"
},
{
"instance_id": "mock-q-03",
"conversation_id": "mock-conv-A",
"question": "What did Carol bring to the picnic?",
"reference_answer": "potato salad"
},
{
"instance_id": "mock-q-04",
"conversation_id": "mock-conv-B",
"question": "How many guests came to the party?",
"reference_answer": "twelve"
},
{
"instance_id": "mock-q-05",
"conversation_id": "mock-conv-B",
"question": "What year did Dave move to Berlin?",
"reference_answer": "2021"
},
{
"instance_id": "mock-q-06",
"conversation_id": "mock-conv-B",
"question": "Who hosted the book club?",
"reference_answer": "Emma"
},
{
"instance_id": "mock-q-07",
"conversation_id": "mock-conv-C",
"question": "What kind of car does Frank drive?",
"reference_answer": "a blue Subaru"
},
{
"instance_id": "mock-q-08",
"conversation_id": "mock-conv-C",
"question": "When did Grace finish her PhD?",
"reference_answer": "May 2022"
},
{
"instance_id": "mock-q-09",
"conversation_id": "mock-conv-D",
"question": "What instrument does Henry play?",
"reference_answer": "cello"
},
{
"instance_id": "mock-q-10",
"conversation_id": "mock-conv-D",
"question": "Where does Ivy's sister live?",
"reference_answer": "Lisbon"
}
]
}

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,323 @@
/**
* Sprint 12 Task 1 Session 3 — smoke test suite.
*
* End-to-end offline integration test that exercises the Session 1+2+3
* substrate pipeline on deterministic fixtures (no real LLM calls). Task
* 1 closure gate: this test PASS = substrate ready for Task 2 (C3 mini).
*
* Pipeline exercised per brief § 2.1 C:
* 1. Load mock-locomo-instances.json + mock-judge-responses.json
* 2. Derive majority verdict per item from the 3-primary ensemble votes
* 3. Resolve failure_code per item (unanimous | majority | tie-break-
* reserved via pre-computed grok_reserve_vote in fixture)
* 4. Build pre-tie-break vote matrix → Fleiss κ
* 5. Build CorrectnessRow[] → Wilson 95% CI + cluster-bootstrap 95% CI
* 6. Build FailureRow[] → failure distribution + F_other review flag
* 7. Emit `bench.smoke.completed` structured log event with aggregate
* 8. Assert expected invariants (κ range, sum-to-total, F_other gate,
* CI containment, ci_lower ≤ ci_upper)
*
* Brief § 7 reuse guidance: the pre-tie-break Fleiss κ + post-tie-break
* correctness derivation sits inline here. The real tie-break module
* (`resolveTieBreak` in packages/server/src/benchmarks/judge/ensemble-
* tiebreak.ts) is unit-tested in Sprint 11; smoke intentionally pre-
* encodes the tie-break outcome via `grok_reserve_vote` + `final_failure_code`
* fields in the fixture, avoiding a cross-package runtime import just to
* prove the pipeline shape. Flagged in the exit ping as a non-blocking
* surprise (ACCEPT — scoped per brief § 5 surprises policy).
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { describe, expect, it } from 'vitest';
import { createCoreLogger } from '@waggle/core';
import {
computeClusterBootstrapCI,
computeFleissKappa,
computeWilsonCI,
type CorrectnessRow,
type VoteMatrix,
} from '../../src/stats/index.js';
import {
FAILURE_TAXONOMY_VERSION,
computeFailureDistribution,
type FailureCode,
type FailureRow,
} from '../../src/failure-taxonomy/index.js';
// ── Fixture loading ──────────────────────────────────────────────────────
const HERE = url.fileURLToPath(import.meta.url);
const FIXTURES_DIR = path.resolve(path.dirname(HERE), 'fixtures');
interface MockInstance {
instance_id: string;
conversation_id: string;
question: string;
reference_answer: string;
}
interface MockJudgeVote {
judge: string;
verdict: 'correct' | 'incorrect';
failure_code: FailureCode;
rationale: string | null;
}
interface MockJudgeEntry {
instance_id: string;
judge_votes: MockJudgeVote[];
grok_reserve_vote?: MockJudgeVote;
final_failure_code?: FailureCode;
note?: string;
}
interface MockFixtures {
instances: MockInstance[];
judges: string[];
tie_break_reserve: string;
responses: MockJudgeEntry[];
}
function loadFixtures(): MockFixtures {
const instancesRaw = JSON.parse(
fs.readFileSync(path.join(FIXTURES_DIR, 'mock-locomo-instances.json'), 'utf-8'),
) as { instances: MockInstance[] };
const judgeRaw = JSON.parse(
fs.readFileSync(path.join(FIXTURES_DIR, 'mock-judge-responses.json'), 'utf-8'),
) as {
judges: string[];
tie_break_reserve: string;
responses: MockJudgeEntry[];
};
return {
instances: instancesRaw.instances,
judges: judgeRaw.judges,
tie_break_reserve: judgeRaw.tie_break_reserve,
responses: judgeRaw.responses,
};
}
// ── Pipeline helpers ─────────────────────────────────────────────────────
/**
* Build the K=2 (correct / incorrect) pre-tie-break vote matrix from the
* 3-primary ensemble. Fleiss κ per A3 LOCK § 4 is computed over this
* verdict-level matrix (not the K=8 failure-code matrix) — matches the
* HALT threshold semantics documented in §4.
*/
function buildVerdictVoteMatrix(
responses: readonly MockJudgeEntry[],
): VoteMatrix {
const counts: number[][] = [];
for (const entry of responses) {
let correct = 0;
let incorrect = 0;
for (const vote of entry.judge_votes) {
if (vote.verdict === 'correct') correct += 1;
else incorrect += 1;
}
counts.push([correct, incorrect]);
}
return {
n_judges: 3,
counts,
categories: ['correct', 'incorrect'],
};
}
/**
* Derive the final post-tie-break verdict + failure_code per item.
*
* Rules mirror B2 LOCK § 1 runtime:
* - Verdict = majority of the 3 primary judges (K=2 always has a winner).
* - Failure code on correct verdict = null.
* - Failure code on incorrect verdict = majority among the incorrect-
* voting judges' code picks; ties break to `grok_reserve_vote` if the
* fixture provides one (the audit-expected path).
*/
function resolveFinalVerdict(
entry: MockJudgeEntry,
): { correct: 0 | 1; failure_code: FailureCode; rationale: string | null } {
let correctCount = 0;
for (const v of entry.judge_votes) {
if (v.verdict === 'correct') correctCount += 1;
}
if (correctCount >= 2) {
return { correct: 1, failure_code: null, rationale: null };
}
// Majority incorrect — resolve code.
const incorrectVotes = entry.judge_votes.filter(v => v.verdict === 'incorrect');
const codeCounts = new Map<string, number>();
for (const v of incorrectVotes) {
if (v.failure_code !== null) {
codeCounts.set(v.failure_code, (codeCounts.get(v.failure_code) ?? 0) + 1);
}
}
// Pick the code with strictly-majority count. On a tie, fall through to
// the tie-break reserve vote carried in the fixture.
let topCode: FailureCode = null;
let topCount = 0;
let tied = false;
for (const [code, count] of codeCounts.entries()) {
if (count > topCount) {
topCode = code as FailureCode;
topCount = count;
tied = false;
} else if (count === topCount) {
tied = true;
}
}
if (tied && entry.grok_reserve_vote) {
topCode = entry.grok_reserve_vote.failure_code;
}
// Pick the first matching rationale from the incorrect votes for the
// chosen code — used by the F_other sampler downstream.
const chosen = incorrectVotes.find(v => v.failure_code === topCode);
return {
correct: 0,
failure_code: topCode,
rationale: chosen?.rationale ?? null,
};
}
// ── The smoke test ───────────────────────────────────────────────────────
describe('Sprint 12 Task 1 Session 3 smoke suite — end-to-end substrate', () => {
it('runs the full pipeline on 10-instance mock fixtures and produces expected aggregate', () => {
const fixtures = loadFixtures();
expect(fixtures.instances).toHaveLength(10);
expect(fixtures.responses).toHaveLength(10);
expect(fixtures.judges).toEqual(['claude-opus-4-7', 'gpt-5.4', 'gemini-3.1']);
expect(fixtures.tie_break_reserve).toBe('grok-4.20');
// 1. Pre-tie-break vote matrix + Fleiss κ.
const voteMatrix = buildVerdictVoteMatrix(fixtures.responses);
const kappa = computeFleissKappa(voteMatrix);
expect(Number.isNaN(kappa.kappa)).toBe(false);
expect(kappa.kappa).toBeGreaterThanOrEqual(0.5);
expect(kappa.kappa).toBeLessThanOrEqual(0.95);
expect(kappa.n_items).toBe(10);
expect(kappa.n_judges).toBe(3);
expect(kappa.n_categories).toBe(2);
// 2. Post-tie-break correctness rows + Wilson / bootstrap CIs.
const instanceById = new Map<string, MockInstance>();
for (const inst of fixtures.instances) instanceById.set(inst.instance_id, inst);
const correctnessRows: CorrectnessRow[] = [];
const failureRows: FailureRow[] = [];
let tieBreakActivations = 0;
for (const entry of fixtures.responses) {
const instance = instanceById.get(entry.instance_id);
if (!instance) throw new Error(`instance not found: ${entry.instance_id}`);
const resolved = resolveFinalVerdict(entry);
correctnessRows.push({ conversation_id: instance.conversation_id, correct: resolved.correct });
failureRows.push({ failure_code: resolved.failure_code, rationale: resolved.rationale });
if (entry.grok_reserve_vote) tieBreakActivations += 1;
}
const successes = correctnessRows.reduce((acc, r) => acc + r.correct, 0);
expect(successes).toBe(7); // fixture design
expect(correctnessRows).toHaveLength(10);
const wilson = computeWilsonCI({ successes, trials: correctnessRows.length });
expect(wilson.point_estimate).toBeCloseTo(0.7, 10);
expect(wilson.point_estimate).toBeGreaterThanOrEqual(0.5);
expect(wilson.point_estimate).toBeLessThanOrEqual(0.9);
expect(wilson.ci_lower).toBeLessThanOrEqual(wilson.point_estimate);
expect(wilson.ci_upper).toBeGreaterThanOrEqual(wilson.point_estimate);
const bootstrap = computeClusterBootstrapCI({ rows: correctnessRows });
expect(bootstrap.point_estimate).toBeCloseTo(0.7, 10);
expect(bootstrap.ci_lower).toBeLessThanOrEqual(bootstrap.point_estimate);
expect(bootstrap.ci_upper).toBeGreaterThanOrEqual(bootstrap.point_estimate);
expect(bootstrap.n_bootstrap).toBe(10000);
expect(bootstrap.seed).toBe(42);
expect(bootstrap.n_clusters).toBe(4);
expect(bootstrap.n_rows).toBe(10);
// 3. Failure distribution + F_other review flag.
const distribution = computeFailureDistribution(failureRows);
expect(distribution.total).toBe(10);
const summed =
distribution.counts.null +
distribution.counts.F1 + distribution.counts.F2 + distribution.counts.F3 +
distribution.counts.F4 + distribution.counts.F5 + distribution.counts.F6 +
distribution.counts.F_other;
expect(summed).toBe(10);
expect(distribution.counts.null).toBe(7);
expect(distribution.counts.F1).toBe(1);
expect(distribution.counts.F_other).toBe(1);
expect(distribution.counts.F6).toBe(1);
expect(distribution.f_other_rate).toBeCloseTo(0.1, 10);
// Strict greater-than: 10% exactly should NOT trip the flag.
expect(distribution.f_other_review_flag).toBe(false);
expect(distribution.f_other_rationales_sample).toHaveLength(1);
// Tie-break activation sanity — fixture has exactly one instance
// carrying a grok_reserve_vote field (mock-q-08).
expect(tieBreakActivations).toBe(1);
// 4. Emit the completion event on a scoped logger so downstream CI
// can tail it. Payload carries the smoke gate's observable state.
const log = createCoreLogger('bench.smoke');
const aggregate = {
event: 'bench.smoke.completed',
taxonomy_version: FAILURE_TAXONOMY_VERSION,
n_instances: 10,
n_judges: 3,
tie_break_reserve: fixtures.tie_break_reserve,
tie_break_activations: tieBreakActivations,
kappa: kappa.kappa,
kappa_P_bar: kappa.P_bar,
kappa_P_e: kappa.P_e,
wilson_ci: {
point_estimate: wilson.point_estimate,
ci_lower: wilson.ci_lower,
ci_upper: wilson.ci_upper,
half_width: wilson.half_width,
},
bootstrap_ci: {
point_estimate: bootstrap.point_estimate,
ci_lower: bootstrap.ci_lower,
ci_upper: bootstrap.ci_upper,
n_bootstrap: bootstrap.n_bootstrap,
seed: bootstrap.seed,
n_clusters: bootstrap.n_clusters,
},
failure_distribution: {
counts: distribution.counts,
f_other_rate: distribution.f_other_rate,
f_other_review_flag: distribution.f_other_review_flag,
},
};
log.info('bench.smoke.completed', aggregate);
// 5. Determinism gate — re-running the same pipeline must produce a
// bit-identical bootstrap CI (Wilson + Fleiss are closed-form so
// determinism there is definitional). Sorted-key stringify so
// downstream consumers comparing via JSON.stringify get stable
// output independent of property insertion order.
const bootstrap2 = computeClusterBootstrapCI({ rows: correctnessRows });
expect(bootstrap2.ci_lower).toBe(bootstrap.ci_lower);
expect(bootstrap2.ci_upper).toBe(bootstrap.ci_upper);
});
it('fixture κ lands in the target band (≈0.68, inside user-specified [0.60, 0.70])', () => {
const fixtures = loadFixtures();
const kappa = computeFleissKappa(buildVerdictVoteMatrix(fixtures.responses));
// Pre-computed from the fixture design:
// 6× (3,0) + 1× (2,1) + 1× (1,2) + 2× (0,3)
// P_e = 0.49 + 0.09 = 0.58
// P_bar = (8·1 + 2·(1/3)) / 10 = 0.8667
// κ = (0.8667 0.58) / 0.42 = 0.6825
expect(kappa.kappa).toBeGreaterThan(0.60);
expect(kappa.kappa).toBeLessThan(0.75);
expect(kappa.kappa).toBeCloseTo(0.6825, 3);
});
});

View File

@@ -0,0 +1,229 @@
/**
* Sprint 11 Task B1 — Stage 2 config threading tests.
*
* Acceptance per brief §3 Track B B1:
* "Verifikuj da C2 i C3 harness koristi taj config eksplicitno,
* ne nasleđeno iz drugog lokala."
*
* These tests assert the LOCKED Stage 2 config (thinking=on, max_tokens=64000,
* route=qwen3.6-35b-a3b-via-openrouter per decision doc 2026-04-22) flows
* from models.json → ModelSpec.stage2Config → LlmCallInput → request body,
* and reasoning is parsed back from the response.
*
* No real API calls — fetch is mocked. Live end-to-end smoke lives in
* `scripts/sprint-11-b1-smoke.mjs` and runs independently.
*/
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createLlmClient } from '../src/llm.js';
import type { ModelSpec } from '../src/types.js';
const HERE = url.fileURLToPath(import.meta.url);
const HARNESS_ROOT = path.resolve(path.dirname(HERE), '..');
const MODELS_JSON = path.join(HARNESS_ROOT, 'config', 'models.json');
// Typed helper — models.json contains optional stage2Config so inline type.
type ModelsRegistry = Record<string, ModelSpec>;
function loadModels(): ModelsRegistry {
return JSON.parse(fs.readFileSync(MODELS_JSON, 'utf-8')) as ModelsRegistry;
}
describe('Sprint 11 B1 — models.json Stage 2 entry', () => {
it('exposes qwen3.6-35b-a3b-stage2 with the LOCKED config', () => {
const models = loadModels();
const stage2 = models['qwen3.6-35b-a3b-stage2'];
expect(stage2).toBeDefined();
expect(stage2.litellmModel).toBe('qwen3.6-35b-a3b-via-openrouter');
expect(stage2.stage2Config).toBeDefined();
expect(stage2.stage2Config?.thinking).toBe(true);
expect(stage2.stage2Config?.maxTokens).toBe(64000);
expect(stage2.stage2Config?.reasoningShape).toBe('openrouter-unified');
});
it('leaves the baseline qwen3.6-35b-a3b entry without stage2Config (no side-effect on non-Stage-2 harness runs)', () => {
const models = loadModels();
const baseline = models['qwen3.6-35b-a3b'];
expect(baseline).toBeDefined();
expect(baseline.stage2Config).toBeUndefined();
});
});
describe('Sprint 11 B1 — LiteLlmClient threads stage2Config into request body', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
const stage2Model: ModelSpec = {
id: 'qwen3.6-35b-a3b-stage2',
displayName: 'Stage 2 LOCKED',
provider: 'alibaba',
litellmModel: 'qwen3.6-35b-a3b-via-openrouter',
pricePerMillionInput: 0.2,
pricePerMillionOutput: 0.8,
contextWindow: 262144,
stage2Config: {
thinking: true,
maxTokens: 64000,
reasoningShape: 'openrouter-unified',
},
};
it('sends max_tokens=64000 and reasoning:{enabled:true} when stage2Config is present', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: '4', reasoning: '2 + 2 = 4' } }],
usage: { prompt_tokens: 20, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
await llm.call({
model: stage2Model,
systemPrompt: 'sys',
userPrompt: 'q',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse((init as RequestInit).body as string);
expect(payload.model).toBe('qwen3.6-35b-a3b-via-openrouter');
expect(payload.max_tokens).toBe(64000);
expect(payload.reasoning).toEqual({ enabled: true });
});
it('captures reasoning from OpenRouter unified response shape', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: '4', reasoning: 'thought chain here' } }],
usage: { prompt_tokens: 20, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.text).toBe('4');
expect(result.reasoningContent).toBe('thought chain here');
});
it('captures reasoning from DashScope native response shape', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: '4', reasoning_content: 'dashscope chain' } }],
usage: { prompt_tokens: 20, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
const result = await llm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.reasoningContent).toBe('dashscope chain');
});
it('omits reasoningContent when provider did not emit it (back-compat for non-thinking routes)', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: 'hi' } }],
usage: { prompt_tokens: 5, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const baseline: ModelSpec = { ...stage2Model, stage2Config: undefined };
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
const result = await llm.call({ model: baseline, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.reasoningContent).toBeUndefined();
});
it('per-call override (input.thinking / maxTokensOverride) wins over model.stage2Config', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: 'hi' } }],
usage: { prompt_tokens: 5, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
await llm.call({
model: stage2Model, // has thinking=true, 64000
systemPrompt: 'sys',
userPrompt: 'q',
thinking: false,
maxTokensOverride: 256,
});
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse((init as RequestInit).body as string);
expect(payload.max_tokens).toBe(256);
expect(payload.reasoning).toBeUndefined();
});
it('back-compat: models without stage2Config still send legacy max_tokens=600 and no reasoning', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
choices: [{ message: { content: 'hi' } }],
usage: { prompt_tokens: 5, completion_tokens: 1 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const legacy: ModelSpec = { ...stage2Model, stage2Config: undefined };
const llm = createLlmClient({
dryRun: false,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'sk-test',
});
await llm.call({ model: legacy, systemPrompt: 'sys', userPrompt: 'q' });
const [, init] = fetchMock.mock.calls[0];
const payload = JSON.parse((init as RequestInit).body as string);
expect(payload.max_tokens).toBe(600);
expect(payload.reasoning).toBeUndefined();
});
});

View File

@@ -0,0 +1,161 @@
/**
* Sprint 12 Task 1 Blocker #5 — cluster-bootstrap tests.
*
* Acceptance (brief § 2.1 A):
* 1. Deterministic re-run (same input + seed → same output)
* 2. Default n_bootstrap = 10 000
* 3. Default seed = 42
* 4. Rejects empty rows
* 5. CI ⊇ point_estimate property
* 6. ci_lower ≤ ci_upper invariant
* 7. Cluster structure affects CI vs. instance-level Wilson
* 8. NaN guard on malformed `correct` field
*/
import { describe, expect, it } from 'vitest';
import {
computeClusterBootstrapCI,
type CorrectnessRow,
} from '../../src/stats/cluster-bootstrap.js';
import { computeWilsonCI } from '../../src/stats/wilson-ci.js';
function buildClusteredRows(
clusterCount: number,
rowsPerCluster: number,
correctRate: number,
): CorrectnessRow[] {
// Deterministic construction: first ⌊rate × rowsPerCluster⌋ rows in each
// cluster are correct. Keeps tests independent of PRNG state.
const rows: CorrectnessRow[] = [];
const correctPerCluster = Math.round(correctRate * rowsPerCluster);
for (let c = 0; c < clusterCount; c++) {
const conversation_id = `conv-${c}`;
for (let r = 0; r < rowsPerCluster; r++) {
rows.push({ conversation_id, correct: r < correctPerCluster ? 1 : 0 });
}
}
return rows;
}
describe('computeClusterBootstrapCI — determinism + defaults', () => {
it('produces bit-identical output on two calls with same input', () => {
const rows = buildClusteredRows(5, 4, 0.75);
const a = computeClusterBootstrapCI({ rows, n_bootstrap: 500, seed: 42 });
const b = computeClusterBootstrapCI({ rows, n_bootstrap: 500, seed: 42 });
expect(a.ci_lower).toBe(b.ci_lower);
expect(a.ci_upper).toBe(b.ci_upper);
expect(a.point_estimate).toBe(b.point_estimate);
});
it('different seeds produce different bootstrap CIs (seed sensitivity sanity)', () => {
// 20 singleton clusters with an irregular correct/wrong pattern so that
// bootstrap resample means span a dense set of values. At n=20 and
// n_bootstrap=2000, the 2.5th/97.5th percentile indices (50 and 1950)
// are far from the extremes, so different seeds produce materially
// different CI bounds.
const rows: CorrectnessRow[] = [];
for (let i = 0; i < 20; i++) {
rows.push({ conversation_id: `c-${i}`, correct: (i % 3 === 0 ? 1 : 0) });
}
const a = computeClusterBootstrapCI({ rows, n_bootstrap: 2000, seed: 42 });
const b = computeClusterBootstrapCI({ rows, n_bootstrap: 2000, seed: 123 });
expect(a.point_estimate).toBe(b.point_estimate);
const sameBounds = a.ci_lower === b.ci_lower && a.ci_upper === b.ci_upper;
expect(sameBounds).toBe(false);
});
it('defaults n_bootstrap=10000 and seed=42 per A3 LOCK § 2', () => {
const rows = buildClusteredRows(3, 4, 0.5);
const r = computeClusterBootstrapCI({ rows });
expect(r.n_bootstrap).toBe(10000);
expect(r.seed).toBe(42);
});
});
describe('computeClusterBootstrapCI — structural invariants', () => {
it('CI contains the point estimate (point ∈ [ci_lower, ci_upper])', () => {
const rows = buildClusteredRows(8, 4, 0.75);
const r = computeClusterBootstrapCI({ rows, n_bootstrap: 2000, seed: 42 });
expect(r.point_estimate).toBeGreaterThanOrEqual(r.ci_lower);
expect(r.point_estimate).toBeLessThanOrEqual(r.ci_upper);
});
it('ci_lower ≤ ci_upper always', () => {
const rows = buildClusteredRows(5, 3, 0.333);
const r = computeClusterBootstrapCI({ rows, n_bootstrap: 1000, seed: 42 });
expect(r.ci_lower).toBeLessThanOrEqual(r.ci_upper);
});
it('reports n_clusters = distinct conversation_ids', () => {
const rows = [
{ conversation_id: 'a', correct: 1 as const },
{ conversation_id: 'a', correct: 1 as const },
{ conversation_id: 'b', correct: 0 as const },
{ conversation_id: 'c', correct: 1 as const },
];
const r = computeClusterBootstrapCI({ rows, n_bootstrap: 100, seed: 42 });
expect(r.n_clusters).toBe(3);
expect(r.n_rows).toBe(4);
});
it('produces wider CI than instance-level Wilson when intra-cluster correlation is high', () => {
// 6 clusters × 4 rows, all-or-nothing correctness within each cluster:
// 4 clusters all-correct (4×4=16 successes) + 2 clusters all-wrong (0).
// Intra-cluster correlation is max (1.0) — clusters are homogeneous.
// Bootstrap should reflect that cluster-level variance is huge (some
// samples pick all-correct clusters → near 1.0; others pick all-wrong
// → near 0.0), producing a much wider CI than instance-level Wilson
// which assumes independent 16/24 successes.
const rows: CorrectnessRow[] = [];
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
rows.push({ conversation_id: `correct-${c}`, correct: 1 });
}
}
for (let c = 0; c < 2; c++) {
for (let r = 0; r < 4; r++) {
rows.push({ conversation_id: `wrong-${c}`, correct: 0 });
}
}
const bootstrap = computeClusterBootstrapCI({ rows, n_bootstrap: 2000, seed: 42 });
const wilson = computeWilsonCI({ successes: 16, trials: 24 });
const bootstrapWidth = bootstrap.ci_upper - bootstrap.ci_lower;
const wilsonWidth = wilson.ci_upper - wilson.ci_lower;
expect(bootstrapWidth).toBeGreaterThan(wilsonWidth);
});
});
describe('computeClusterBootstrapCI — input validation', () => {
it('rejects empty rows', () => {
expect(() => computeClusterBootstrapCI({ rows: [] })).toThrow(/non-empty rows/);
});
it('rejects n_bootstrap < 1', () => {
const rows = buildClusteredRows(2, 2, 0.5);
expect(() => computeClusterBootstrapCI({ rows, n_bootstrap: 0 })).toThrow(
/n_bootstrap ≥ 1/,
);
});
it('rejects non-integer seed', () => {
const rows = buildClusteredRows(2, 2, 0.5);
expect(() => computeClusterBootstrapCI({ rows, seed: 1.5 })).toThrow(/integer seed/);
});
it('rejects rows with correct ∉ {0, 1}', () => {
const rows = [
{ conversation_id: 'a', correct: 1 as 0 | 1 },
{ conversation_id: 'a', correct: 2 as unknown as 0 | 1 },
];
expect(() => computeClusterBootstrapCI({ rows, n_bootstrap: 10, seed: 42 })).toThrow(
/correct ∈ \{0, 1\}/,
);
});
it('rejects confidence ≠ 0.95', () => {
const rows = buildClusteredRows(2, 2, 0.5);
expect(() => computeClusterBootstrapCI({ rows, confidence: 0.99 })).toThrow(
/confidence=0\.95/,
);
});
});

View File

@@ -0,0 +1,196 @@
/**
* Sprint 12 Task 1 Blocker #5 — Fleiss κ tests.
*
* Acceptance (brief § 2.1 A, criterion-by-criterion):
* 1. K=2 case reduction sanity
* 2. K=6 (F1-F6 taxonomy) happy path
* 3. Perfect agreement → κ=1.0
* 4. Zero-above-chance agreement → κ=0
* 5. Pre-tie-break input only (no post-tie-break leakage)
* 6. NaN guard when P_e = 1 (uniform assignment)
* 7. Reject mismatched row widths
* 8. Reject row sums ≠ n_judges
*/
import { describe, expect, it } from 'vitest';
import { computeFleissKappa, type VoteMatrix } from '../../src/stats/fleiss-kappa.js';
describe('computeFleissKappa — structural invariants', () => {
it('returns κ=1.0 under perfect agreement (all judges pick same category per item)', () => {
// 4 items, 3 judges, 2 categories. Every judge on every item → same category.
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0],
[0, 3],
[3, 0],
[0, 3],
],
};
const result = computeFleissKappa(matrix);
expect(result.kappa).toBeCloseTo(1.0, 10);
expect(result.P_bar).toBeCloseTo(1.0, 10);
expect(result.n_items).toBe(4);
expect(result.n_judges).toBe(3);
expect(result.n_categories).toBe(2);
});
it('returns κ=NaN when P_e=1 (all judges always pick the single category)', () => {
// 3 items, 3 judges — uniform assignment into category 0.
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0],
[3, 0],
[3, 0],
],
};
const result = computeFleissKappa(matrix);
expect(Number.isNaN(result.kappa)).toBe(true);
expect(result.P_e).toBe(1);
});
it('reduces cleanly to a binary-agreement measure (K=2 case)', () => {
// 5 items, 3 judges. Mixed disagreement. κ should land in (0, 1).
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0], // unanimous correct
[2, 1], // majority correct
[3, 0], // unanimous correct
[1, 2], // majority incorrect
[0, 3], // unanimous incorrect
],
};
const result = computeFleissKappa(matrix);
expect(result.n_categories).toBe(2);
expect(result.kappa).toBeGreaterThan(0);
expect(result.kappa).toBeLessThanOrEqual(1);
// Category marginals should sum to 1 (modulo float).
const marginalSum = result.category_marginals.reduce((a, b) => a + b, 0);
expect(marginalSum).toBeCloseTo(1.0, 10);
});
it('handles K=6 failure taxonomy shape (F1-F6 + null encoded as 7-column matrix)', () => {
// 6 items, 3 judges, 7 categories (null + F1..F6). Simulates A3 LOCK §6
// shape with moderate disagreement.
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0, 0, 0, 0, 0, 0], // all correct (null)
[2, 1, 0, 0, 0, 0, 0], // 2 correct, 1 F1
[0, 3, 0, 0, 0, 0, 0], // unanimous F1
[0, 0, 2, 1, 0, 0, 0], // 2 F2, 1 F3
[3, 0, 0, 0, 0, 0, 0], // all correct
[0, 0, 0, 0, 0, 0, 3], // unanimous F6
],
categories: ['correct', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6'],
};
const result = computeFleissKappa(matrix);
expect(result.n_categories).toBe(7);
expect(Number.isFinite(result.kappa)).toBe(true);
expect(result.kappa).toBeGreaterThan(0);
expect(result.category_marginals).toHaveLength(7);
});
it('returns κ near 0 when item agreement matches chance (no systematic signal)', () => {
// Large symmetric input where P_bar ≈ P_e. Constructed so that judges'
// marginals are 50/50 and per-item agreement is exactly what chance gives.
// 4 items with (2,1) counts at n=3 → P_i = (4+13) / (3·2) = 1/3 each.
// Marginals after symmetry: p_0 = p_1 = 0.5 → P_e = 0.5.
// So κ = (1/3 0.5) / (1 0.5) = (1/6) / 0.5 = 1/3. Near-zero / negative.
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[2, 1],
[1, 2],
[2, 1],
[1, 2],
],
};
const result = computeFleissKappa(matrix);
expect(result.P_e).toBeCloseTo(0.5, 10);
expect(result.P_bar).toBeCloseTo(1 / 3, 10);
expect(result.kappa).toBeCloseTo(-1 / 3, 10);
});
it('accepts the 3-primary ensemble shape (Opus + GPT + Gemini pre-tie-break)', () => {
// Mirrors the benchmark runner's real input: 3 judges, N items, K=2.
// No dependency on tie-break state — Fleiss consumes pre-tie-break
// counts directly per A3 LOCK § 4.
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0],
[3, 0],
[2, 1],
[1, 2], // 1-2 split — tie-break would fire at runtime, but κ input is pre
[1, 1], // ← would error: row sum 2 ≠ 3 (invalid; see rejection test)
],
};
// The 5th row violates row-sum invariant; replace with valid row.
matrix.counts = matrix.counts.slice(0, 4);
const result = computeFleissKappa(matrix);
expect(result.n_items).toBe(4);
expect(result.kappa).toBeGreaterThan(0);
});
});
describe('computeFleissKappa — input validation', () => {
it('throws on empty counts array', () => {
expect(() => computeFleissKappa({ n_judges: 3, counts: [] })).toThrow(
/non-empty counts matrix/,
);
});
it('throws on n_judges < 2', () => {
expect(() =>
computeFleissKappa({ n_judges: 1, counts: [[1, 0]] }),
).toThrow(/n_judges ≥ 2/);
});
it('throws on K < 2 (single column)', () => {
expect(() =>
computeFleissKappa({ n_judges: 3, counts: [[3]] }),
).toThrow(/K ≥ 2 categories/);
});
it('throws when row width differs from first row (non-rectangular)', () => {
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0],
[1, 2, 0], // ← extra column
],
};
expect(() => computeFleissKappa(matrix)).toThrow(/rectangular/);
});
it('throws when row sum ≠ n_judges', () => {
const matrix: VoteMatrix = {
n_judges: 3,
counts: [
[3, 0],
[1, 1], // sum = 2 ≠ 3
],
};
expect(() => computeFleissKappa(matrix)).toThrow(/row sum must equal n_judges/);
});
it('throws when categories length does not match K', () => {
const matrix: VoteMatrix = {
n_judges: 3,
counts: [[3, 0]],
categories: ['correct', 'wrong', 'extra'],
};
expect(() => computeFleissKappa(matrix)).toThrow(/categories length/);
});
it('throws on non-integer / negative counts', () => {
const matrix: VoteMatrix = {
n_judges: 3,
counts: [[2.5, 0.5]], // fractional counts
};
expect(() => computeFleissKappa(matrix)).toThrow(/non-negative integers/);
});
});

View File

@@ -0,0 +1,94 @@
/**
* Sprint 12 Task 1 Blocker #5 — Wilson CI tests.
*
* Acceptance (brief § 2.1 A):
* 1. Published tabular value at p̂=0.5 / n=100
* 2. Edge at p̂=1.0 (ci_upper=1, ci_lower<1)
* 3. Edge at p̂=0.0 (mirror)
* 4. Half-width ≈ 0.85pp at p̂=0.916 / n=1540 (A3 LOCK § 5)
* 5. Monotonicity (n↑ → half_width↓)
* 6. Rejects n≤0
*/
import { describe, expect, it } from 'vitest';
import { computeWilsonCI, Z_95_TWO_SIDED } from '../../src/stats/wilson-ci.js';
describe('computeWilsonCI — numerical correctness', () => {
it('matches published tabular value at p̂=0.5, n=100 (ci ≈ [0.404, 0.596])', () => {
const r = computeWilsonCI({ successes: 50, trials: 100 });
expect(r.point_estimate).toBeCloseTo(0.5, 10);
// Published Wilson bounds: 0.40383 … 0.59616 (matches e.g.
// Agresti-Coull-style reference tables with z=1.959964).
expect(r.ci_lower).toBeCloseTo(0.40383, 3);
expect(r.ci_upper).toBeCloseTo(0.59616, 3);
});
it('handles p̂=1.0 edge: upper ≈ 1, lower < 1 (no nonsensical >1 bound)', () => {
const r = computeWilsonCI({ successes: 10, trials: 10 });
expect(r.point_estimate).toBe(1);
// Wilson at p̂=1 asymptotically approaches ci_upper=1; fp arithmetic
// may leave it at 1 ε. Clamp in impl covers >1; we tolerate ~εlevel
// underflow.
expect(r.ci_upper).toBeCloseTo(1, 10);
expect(r.ci_upper).toBeLessThanOrEqual(1);
expect(r.ci_lower).toBeLessThan(1);
expect(r.ci_lower).toBeGreaterThan(0.6);
});
it('handles p̂=0.0 edge (mirror of p̂=1.0): lower ≈ 0, upper > 0', () => {
const r = computeWilsonCI({ successes: 0, trials: 10 });
expect(r.point_estimate).toBe(0);
expect(r.ci_lower).toBeCloseTo(0, 10);
expect(r.ci_lower).toBeGreaterThanOrEqual(0);
expect(r.ci_upper).toBeGreaterThan(0);
expect(r.ci_upper).toBeLessThan(0.4);
});
it('A3 LOCK § 5 sanity: half-width ≈ 0.85pp at p̂=0.916 / n=1540', () => {
// p̂ · n = 1411.64 — round to nearest integer that still gives p̂ ≈ 0.916.
const successes = Math.round(0.916 * 1540);
const r = computeWilsonCI({ successes, trials: 1540 });
// Brief § 2.1 A expectation: ~0.85pp. Allow ±0.1pp tolerance for
// rounding (actual value is around 1.4% half-width for Wilson; the
// brief's 0.85pp is an approximation from the normal-approx Wald
// interval, which is consistently narrower for mid-range p̂). Wilson
// is the primary per A3 LOCK; document this as tolerance band.
expect(r.half_width).toBeGreaterThan(0.010);
expect(r.half_width).toBeLessThan(0.020);
expect(r.point_estimate).toBeCloseTo(0.916, 2);
});
it('half-width shrinks as n grows (monotonicity at fixed p̂=0.5)', () => {
const small = computeWilsonCI({ successes: 5, trials: 10 });
const medium = computeWilsonCI({ successes: 50, trials: 100 });
const large = computeWilsonCI({ successes: 500, trials: 1000 });
expect(small.half_width).toBeGreaterThan(medium.half_width);
expect(medium.half_width).toBeGreaterThan(large.half_width);
// z is a shared module constant, not a Wilson internal — reused
// elsewhere (future narrower CI tiers). Assert the pinned value.
expect(Z_95_TWO_SIDED).toBeCloseTo(1.959964, 6);
});
});
describe('computeWilsonCI — input validation', () => {
it('rejects trials <= 0', () => {
expect(() => computeWilsonCI({ successes: 0, trials: 0 })).toThrow(/trials ≥ 1/);
expect(() => computeWilsonCI({ successes: 0, trials: -5 })).toThrow(/trials ≥ 1/);
});
it('rejects successes outside [0, trials]', () => {
expect(() => computeWilsonCI({ successes: -1, trials: 10 })).toThrow(/successes/);
expect(() => computeWilsonCI({ successes: 11, trials: 10 })).toThrow(/successes/);
});
it('rejects non-integer successes / trials', () => {
expect(() => computeWilsonCI({ successes: 5.5, trials: 10 })).toThrow(/successes/);
expect(() => computeWilsonCI({ successes: 5, trials: 10.5 })).toThrow(/trials/);
});
it('rejects confidence ≠ 0.95 (hardcoded z)', () => {
expect(() => computeWilsonCI({ successes: 5, trials: 10, confidence: 0.99 })).toThrow(
/confidence=0\.95/,
);
});
});

View File

@@ -0,0 +1,145 @@
/**
* Task 2.5 Stage 1.5 §7.2 — StreakTracker tests.
*
* Verifies the consecutive-fetch-transport-failure counter, reset semantics,
* and observability surface (getRecentWindow, summary).
*/
import { describe, expect, it } from 'vitest';
import { StreakTracker, isFetchTransportFailure } from '../src/streak-tracker.js';
describe('isFetchTransportFailure', () => {
it('matches fetch_error_* patterns', () => {
expect(isFetchTransportFailure('fetch_error_TypeError')).toBe(true);
expect(isFetchTransportFailure('fetch_error_RangeError')).toBe(true);
expect(isFetchTransportFailure('fetch_error_SyntaxError')).toBe(true);
});
it('does NOT match non-fetch patterns', () => {
expect(isFetchTransportFailure(null)).toBe(false);
expect(isFetchTransportFailure(undefined)).toBe(false);
expect(isFetchTransportFailure('timeout')).toBe(false);
expect(isFetchTransportFailure('http_500')).toBe(false);
expect(isFetchTransportFailure('http_404')).toBe(false);
expect(isFetchTransportFailure('')).toBe(false);
expect(isFetchTransportFailure('FETCH_ERROR_TypeError')).toBe(false); // case-sensitive
});
});
describe('StreakTracker — halt trigger', () => {
it('does not halt on 4 consecutive fetch_error_TypeError', () => {
const t = new StreakTracker();
for (let i = 0; i < 4; i++) {
expect(t.record('fetch_error_TypeError')).toBe(false);
}
expect(t.getConsecutiveFailures()).toBe(4);
});
it('halts on the 5th consecutive fetch_error_TypeError', () => {
const t = new StreakTracker();
for (let i = 0; i < 4; i++) t.record('fetch_error_TypeError');
expect(t.record('fetch_error_TypeError')).toBe(true);
expect(t.getConsecutiveFailures()).toBe(5);
});
it('resets counter on a successful call (null failureMode)', () => {
const t = new StreakTracker();
for (let i = 0; i < 4; i++) t.record('fetch_error_TypeError');
t.record(null);
expect(t.getConsecutiveFailures()).toBe(0);
for (let i = 0; i < 4; i++) {
expect(t.record('fetch_error_TypeError')).toBe(false);
}
});
it('resets counter on timeout (AbortError)', () => {
const t = new StreakTracker();
t.record('fetch_error_TypeError');
t.record('fetch_error_TypeError');
t.record('timeout');
expect(t.getConsecutiveFailures()).toBe(0);
});
it('resets counter on http_5xx', () => {
const t = new StreakTracker();
t.record('fetch_error_TypeError');
t.record('fetch_error_TypeError');
t.record('http_502');
expect(t.getConsecutiveFailures()).toBe(0);
});
it('mixes fetch_error_* subtypes and counts them all', () => {
const t = new StreakTracker();
t.record('fetch_error_TypeError');
t.record('fetch_error_RangeError');
t.record('fetch_error_TypeError');
t.record('fetch_error_SyntaxError');
expect(t.record('fetch_error_TypeError')).toBe(true); // 5th consecutive
});
});
describe('StreakTracker — configuration', () => {
it('honours a custom threshold', () => {
const t = new StreakTracker({ threshold: 3 });
t.record('fetch_error_TypeError');
t.record('fetch_error_TypeError');
expect(t.record('fetch_error_TypeError')).toBe(true);
});
it('clamps threshold to minimum of 1', () => {
const t = new StreakTracker({ threshold: 0 });
expect(t.record('fetch_error_TypeError')).toBe(true); // threshold clamped to 1
});
it('honours a custom window size', () => {
const t = new StreakTracker({ windowSize: 3 });
t.record('fetch_error_TypeError');
t.record(null);
t.record('fetch_error_TypeError');
t.record(null); // oldest entry slides out
const w = t.getRecentWindow();
expect(w).toHaveLength(3);
});
});
describe('StreakTracker — observability', () => {
it('getRecentWindow returns snapshot of last N outcomes', () => {
const t = new StreakTracker({ windowSize: 5 });
t.record('fetch_error_TypeError');
t.record(null);
t.record('fetch_error_TypeError');
t.record('timeout');
t.record('fetch_error_TypeError');
expect(t.getRecentWindow()).toEqual([true, false, true, false, true]);
});
it('caps window at windowSize', () => {
const t = new StreakTracker({ windowSize: 3 });
for (let i = 0; i < 7; i++) {
t.record(i % 2 === 0 ? 'fetch_error_TypeError' : null);
}
expect(t.getRecentWindow()).toHaveLength(3);
});
it('summary() returns a human-readable status line', () => {
const t = new StreakTracker({ threshold: 5, windowSize: 5 });
t.record('fetch_error_TypeError');
t.record('fetch_error_TypeError');
t.record(null);
const s = t.summary();
expect(s).toContain('consecutive=0');
expect(s).toContain('threshold=5');
expect(s).toContain('[XX.]');
});
});
describe('StreakTracker — reset', () => {
it('reset clears both counter and window', () => {
const t = new StreakTracker();
t.record('fetch_error_TypeError');
t.record('fetch_error_TypeError');
t.reset();
expect(t.getConsecutiveFailures()).toBe(0);
expect(t.getRecentWindow()).toEqual([]);
});
});

View File

@@ -0,0 +1,101 @@
/**
* Task 2.5 Stage 1 — substrate factory tests.
*
* Covers the lifecycle contract: construct with `:memory:` + an injected
* fake embedder, verify the FTS5 + vec0 tables exist, ingest a handful of
* frames, round-trip-search, tear down.
*
* No Ollama / network dependency — tests inject a deterministic fake embedder.
*/
import { describe, expect, it } from 'vitest';
import type { Embedder } from '@waggle/core';
import { createSubstrate } from '../src/substrate.js';
const VEC_DIMS = 1024;
function createFakeEmbedder(dims: number = VEC_DIMS): Embedder {
const fnv1a = (s: string): number => {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h || 1;
};
const embedOne = (text: string): Float32Array => {
let state = fnv1a(text);
const v = new Float32Array(dims);
for (let i = 0; i < dims; i++) {
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
v[i] = ((state >>> 0) / 0x100000000) * 2 - 1;
}
let mag = 0;
for (let i = 0; i < dims; i++) mag += v[i] * v[i];
mag = Math.sqrt(mag);
if (mag > 0) for (let i = 0; i < dims; i++) v[i] /= mag;
return v;
};
return {
dimensions: dims,
async embed(text) { return embedOne(text); },
async embedBatch(texts) { return texts.map(embedOne); },
};
}
describe('createSubstrate', () => {
it('constructs an ephemeral :memory: substrate with injected embedder', () => {
const sub = createSubstrate({ embedder: createFakeEmbedder() });
try {
expect(sub.db).toBeDefined();
expect(sub.frames).toBeDefined();
expect(sub.search).toBeDefined();
expect(sub.embedder.dimensions).toBe(VEC_DIMS);
// Verify schema bootstrap: memory_frames + vec table exist.
const raw = sub.db.getDatabase();
const row = raw.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memory_frames'",
).get();
expect(row).toBeDefined();
} finally {
sub.close();
}
});
it('createIFrame + indexFramesBatch round-trip via the substrate', async () => {
const sub = createSubstrate({ embedder: createFakeEmbedder() });
try {
// memory_frames.gop_id FKs to sessions.gop_id — ensure rows first.
sub.sessions.ensure('gop-a');
sub.sessions.ensure('gop-b');
const f1 = sub.frames.createIFrame('gop-a', 'Alice: The sunrise painting', 'normal', 'import');
const f2 = sub.frames.createIFrame('gop-a', 'Bob: Nice painting Alice', 'normal', 'import');
const f3 = sub.frames.createIFrame('gop-b', 'Carol: Morning Dan', 'normal', 'import');
await sub.search.indexFramesBatch([
{ id: f1.id, content: f1.content },
{ id: f2.id, content: f2.content },
{ id: f3.id, content: f3.content },
]);
const results = await sub.search.search('sunrise painting', { limit: 3 });
expect(results.length).toBeGreaterThan(0);
expect(results[0].frame.content).toContain('sunrise');
} finally {
sub.close();
}
});
it('close() is idempotent', () => {
const sub = createSubstrate({ embedder: createFakeEmbedder() });
sub.close();
expect(() => sub.close()).not.toThrow();
});
it('after close(), DB access throws', () => {
const sub = createSubstrate({ embedder: createFakeEmbedder() });
sub.close();
expect(() => sub.db.getDatabase().prepare('SELECT 1').get()).toThrow();
});
});

View File

@@ -0,0 +1,154 @@
/**
* Task 2.5 Stage 2-Retry §1.5 — wrapper tests for --v3-cells + JSONL
* cell-field rewrite.
*
* The wrapper lives at `scripts/run-mini-locomo.ts`, outside the harness
* package tree. It does NOT have a separate vitest config, so these tests
* live alongside the harness suite and import from the wrapper via a
* workspace-relative path. Re-exports of `parseArgs` and
* `rewriteJsonlCellField` were added in §1.5 to enable these assertions.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// Workspace-relative import. The wrapper file is at repo_root/scripts/.
// harness/tests/ -> harness/ -> ../ (benchmarks/) -> ../ (repo root) -> scripts/
const here = url.fileURLToPath(import.meta.url);
const wrapperPath = path.resolve(path.dirname(here), '..', '..', '..', 'scripts', 'run-mini-locomo.ts');
// Dynamic import so the test discovers the wrapper at the workspace-root
// location rather than a transpiled dist. Import resolved at setup.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapper: any = await import(url.pathToFileURL(wrapperPath).href);
describe('wrapper — parseArgs --v3-cells flag (Stage 2-Retry §1.5)', () => {
it('--v3-cells expands cells to the 5-cell v3 roster', () => {
const args = wrapper.parseArgs(['--v3-cells']);
expect(args.v3Cells).toBe(true);
expect(args.cells).toEqual([
'no-context',
'oracle-context',
'full-context',
'retrieval',
'agentic',
]);
});
it('--v3-cells default (not passed) leaves cells at the legacy 4-cell default', () => {
const args = wrapper.parseArgs([]);
expect(args.v3Cells).toBe(false);
expect(args.cells).toEqual(['raw', 'context', 'retrieval', 'agentic']);
});
it('--cells <csv> WITH --v3-cells lets --cells win', () => {
const args = wrapper.parseArgs([
'--v3-cells',
'--cells', 'raw,retrieval',
]);
expect(args.v3Cells).toBe(true); // flag stays set for observability
expect(args.cells).toEqual(['raw', 'retrieval']); // but --cells wins
});
it('--v3-cells BEFORE an explicit --cells still yields --cells', () => {
const args = wrapper.parseArgs([
'--v3-cells',
'--cells', 'no-context,agentic',
]);
expect(args.cells).toEqual(['no-context', 'agentic']);
});
});
describe('wrapper — rewriteJsonlCellField (Stage 2-Retry §1.5 JSONL emit contract)', () => {
let tmpDir: string;
let jsonlPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-rewrite-test-'));
jsonlPath = path.join(tmpDir, 'sample.jsonl');
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ }
});
it('rewrites every row cell field and returns the count', () => {
const rows = [
{ turnId: 'a', cell: 'raw', instance_id: 'i1', model: 'm', accuracy: 1 },
{ turnId: 'b', cell: 'raw', instance_id: 'i2', model: 'm', accuracy: 0 },
{ turnId: 'c', cell: 'raw', instance_id: 'i3', model: 'm', accuracy: 1 },
];
fs.writeFileSync(jsonlPath, rows.map(r => JSON.stringify(r)).join('\n'), 'utf-8');
const count = wrapper.rewriteJsonlCellField(jsonlPath, 'oracle-context');
expect(count).toBe(3);
const rewrittenRows = fs.readFileSync(jsonlPath, 'utf-8')
.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
expect(rewrittenRows.every(r => r.cell === 'oracle-context')).toBe(true);
// Non-cell fields preserved.
expect(rewrittenRows.map(r => r.turnId)).toEqual(['a', 'b', 'c']);
expect(rewrittenRows.map(r => r.accuracy)).toEqual([1, 0, 1]);
});
it('is idempotent — running twice produces the same output', () => {
const rows = [{ turnId: 'a', cell: 'raw', instance_id: 'i1' }];
fs.writeFileSync(jsonlPath, JSON.stringify(rows[0]), 'utf-8');
wrapper.rewriteJsonlCellField(jsonlPath, 'oracle-context');
const pass1 = fs.readFileSync(jsonlPath, 'utf-8');
wrapper.rewriteJsonlCellField(jsonlPath, 'oracle-context');
const pass2 = fs.readFileSync(jsonlPath, 'utf-8');
expect(pass2).toBe(pass1);
});
it('preserves empty lines (trailing newline) verbatim', () => {
const content = '{"cell":"raw","turnId":"x"}\n'; // trailing newline
fs.writeFileSync(jsonlPath, content, 'utf-8');
wrapper.rewriteJsonlCellField(jsonlPath, 'oracle-context');
const after = fs.readFileSync(jsonlPath, 'utf-8');
expect(after.endsWith('\n')).toBe(true);
expect(after.split('\n').filter(l => l.trim())).toHaveLength(1);
});
it('tolerates malformed lines (preserves them, counts only valid ones)', () => {
const content = [
JSON.stringify({ cell: 'raw', ok: 1 }),
'not valid json',
JSON.stringify({ cell: 'raw', ok: 2 }),
].join('\n');
fs.writeFileSync(jsonlPath, content, 'utf-8');
const count = wrapper.rewriteJsonlCellField(jsonlPath, 'oracle-context');
expect(count).toBe(2);
const after = fs.readFileSync(jsonlPath, 'utf-8');
expect(after).toContain('not valid json');
expect(after).toContain('"cell":"oracle-context"');
});
it('returns 0 when the file does not exist', () => {
const missing = path.join(tmpDir, 'does-not-exist.jsonl');
const count = wrapper.rewriteJsonlCellField(missing, 'oracle-context');
expect(count).toBe(0);
});
});
describe('wrapper — V3_TO_V1_CELLS map (Stage 2-Retry aliases)', () => {
// V3_TO_V1_CELLS is module-private but mapCell is reachable indirectly via
// parseArgs' acceptance + execution path; test by driving parseArgs with
// known v3 names and cross-checking the cells list is accepted downstream.
// The map structure is also covered by the --v3-cells expansion test above.
it('--cells oracle-context is accepted', () => {
const args = wrapper.parseArgs(['--cells', 'oracle-context']);
expect(args.cells).toEqual(['oracle-context']);
});
it('--cells no-context is accepted', () => {
const args = wrapper.parseArgs(['--cells', 'no-context']);
expect(args.cells).toEqual(['no-context']);
});
it('--cells full-context (as a v3 name) is accepted', () => {
const args = wrapper.parseArgs(['--cells', 'full-context']);
expect(args.cells).toEqual(['full-context']);
});
});