This commit is contained in:
270
benchmarks/harness/tests/a3-namespace-split.test.ts
Normal file
270
benchmarks/harness/tests/a3-namespace-split.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
328
benchmarks/harness/tests/agent-loop-exhaustion.test.ts
Normal file
328
benchmarks/harness/tests/agent-loop-exhaustion.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
174
benchmarks/harness/tests/b2-fold-in.test.ts
Normal file
174
benchmarks/harness/tests/b2-fold-in.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
588
benchmarks/harness/tests/cells-substrate.test.ts
Normal file
588
benchmarks/harness/tests/cells-substrate.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
70
benchmarks/harness/tests/cells.test.ts
Normal file
70
benchmarks/harness/tests/cells.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
133
benchmarks/harness/tests/cli-flags.test.ts
Normal file
133
benchmarks/harness/tests/cli-flags.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
benchmarks/harness/tests/dataset-loader.test.ts
Normal file
177
benchmarks/harness/tests/dataset-loader.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
benchmarks/harness/tests/failure-taxonomy/aggregate.test.ts
Normal file
123
benchmarks/harness/tests/failure-taxonomy/aggregate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
benchmarks/harness/tests/failure-taxonomy/codes.test.ts
Normal file
84
benchmarks/harness/tests/failure-taxonomy/codes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
benchmarks/harness/tests/failure-taxonomy/rubric.test.ts
Normal file
43
benchmarks/harness/tests/failure-taxonomy/rubric.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
123
benchmarks/harness/tests/failure-taxonomy/validator.test.ts
Normal file
123
benchmarks/harness/tests/failure-taxonomy/validator.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
186
benchmarks/harness/tests/health-check.test.ts
Normal file
186
benchmarks/harness/tests/health-check.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
275
benchmarks/harness/tests/ingest.test.ts
Normal file
275
benchmarks/harness/tests/ingest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
265
benchmarks/harness/tests/jsonl-record-schema.test.ts
Normal file
265
benchmarks/harness/tests/jsonl-record-schema.test.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
368
benchmarks/harness/tests/judge-wiring.test.ts
Normal file
368
benchmarks/harness/tests/judge-wiring.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
210
benchmarks/harness/tests/llm-retry.test.ts
Normal file
210
benchmarks/harness/tests/llm-retry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
benchmarks/harness/tests/models-config.test.ts
Normal file
186
benchmarks/harness/tests/models-config.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
342
benchmarks/harness/tests/preregistration.test.ts
Normal file
342
benchmarks/harness/tests/preregistration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
349
benchmarks/harness/tests/reasoning-capture.test.ts
Normal file
349
benchmarks/harness/tests/reasoning-capture.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
167
benchmarks/harness/tests/runner-lock.test.ts
Normal file
167
benchmarks/harness/tests/runner-lock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
403
benchmarks/harness/tests/smoke.test.ts
Normal file
403
benchmarks/harness/tests/smoke.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
benchmarks/harness/tests/smoke/outputs/.gitignore
vendored
Normal file
2
benchmarks/harness/tests/smoke/outputs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
323
benchmarks/harness/tests/smoke/smoke-run.test.ts
Normal file
323
benchmarks/harness/tests/smoke/smoke-run.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
229
benchmarks/harness/tests/stage2-config.test.ts
Normal file
229
benchmarks/harness/tests/stage2-config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
161
benchmarks/harness/tests/stats/cluster-bootstrap.test.ts
Normal file
161
benchmarks/harness/tests/stats/cluster-bootstrap.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
196
benchmarks/harness/tests/stats/fleiss-kappa.test.ts
Normal file
196
benchmarks/harness/tests/stats/fleiss-kappa.test.ts
Normal 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+1−3) / (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/);
|
||||
});
|
||||
});
|
||||
94
benchmarks/harness/tests/stats/wilson-ci.test.ts
Normal file
94
benchmarks/harness/tests/stats/wilson-ci.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
145
benchmarks/harness/tests/streak-tracker.test.ts
Normal file
145
benchmarks/harness/tests/streak-tracker.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
101
benchmarks/harness/tests/substrate.test.ts
Normal file
101
benchmarks/harness/tests/substrate.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
154
benchmarks/harness/tests/wrapper-v3-cells.test.ts
Normal file
154
benchmarks/harness/tests/wrapper-v3-cells.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user