Files
gbrain/test/eval-brainbench-e2e.test.ts
T
Garry Tan ab4e31e30a feat(eval): gbrain eval brainbench CLI + run-all wiring (EvalRunRecord v3)
No-DB cli.ts route (brings its own PGLite), --out as the canonical CI
artifact, explicit grace-tick process.exit(verdict) (PGLite exitCode-hijack
+ Bun stdout-discard guards), anti-vacuous-pass + seed-failure exit-2 paths,
_meta.metric_glossary block, --update-baseline canonical writer,
--compare with main-baseline governance. eval run-all's brainbench stub is
now wired in-process: one record per sweep, schema_version 3, mode 'n/a'.
Subprocess e2e pins literal exit codes 0/1/2 end to end.
2026-06-12 12:06:22 -07:00

215 lines
9.4 KiB
TypeScript

/**
* BrainBench CLI e2e — subprocess runs against a SMALL tmp corpus so the
* literal exit codes (the CI product, decision 9) are asserted end-to-end:
* 0 pass · 1 regression · 2 error/inconclusive. Also pins: the --out artifact
* is complete valid JSON with the _meta.metric_glossary block, --update-baseline
* is byte-deterministic across runs, anti-vacuous-pass, and the run-all wiring
* (full corpus, in-process).
*/
import { beforeAll, describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const REPO = process.cwd();
let root: string;
let fixtures: string;
let gold: string;
function run(args: string[], cwd = REPO): { exitCode: number; stdout: string; stderr: string } {
const proc = Bun.spawnSync(['bun', 'src/cli.ts', 'eval', 'brainbench', ...args], {
cwd,
env: { ...process.env, GBRAIN_QUIET: '1' },
stdout: 'pipe',
stderr: 'pipe',
});
return {
exitCode: proc.exitCode ?? -1,
stdout: proc.stdout.toString(),
stderr: proc.stderr.toString(),
};
}
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'bb-e2e-'));
fixtures = join(root, 'fixtures');
gold = join(root, 'gold');
mkdirSync(fixtures, { recursive: true });
mkdirSync(gold, { recursive: true });
for (const id of ['kta-001-deal-recall', 'kta-002-quiet-smalltalk']) {
cpSync(join(REPO, 'evals/brainbench/fixtures', `${id}.fixture.json`), join(fixtures, `${id}.fixture.json`));
cpSync(join(REPO, 'evals/brainbench/gold', `${id}.gold.json`), join(gold, `${id}.gold.json`));
}
});
describe('exit contract over a multi-brain run (PGLite exitCode-hijack guard)', () => {
test('clean run: exit 0, --out is complete valid JSON with the glossary block', () => {
const out = join(root, 'r1.json');
const r = run(['--fixtures', fixtures, '--gold', gold, '--harness', 'openclaw', '--out', out]);
expect(r.exitCode).toBe(0);
const doc = JSON.parse(readFileSync(out, 'utf-8'));
expect(doc.receipt.result_schema_version).toBe(1);
expect(doc.cells.length).toBeGreaterThan(0);
expect(doc._meta.metric_glossary.know_to_ask_failure_rate).toContain('thesis failure mode');
expect(doc.seed_failures).toEqual([]);
expect(r.stdout).toContain('# BrainBench scoreboard');
}, 30_000);
test('--update-baseline is byte-deterministic across two runs (decision 10)', () => {
const b1 = join(root, 'base1.json');
const b2 = join(root, 'base2.json');
expect(run(['--fixtures', fixtures, '--gold', gold, '--update-baseline', b1]).exitCode).toBe(0);
expect(run(['--fixtures', fixtures, '--gold', gold, '--update-baseline', b2]).exitCode).toBe(0);
expect(readFileSync(b1, 'utf-8')).toBe(readFileSync(b2, 'utf-8'));
}, 60_000);
test('--compare against own baseline: exit 0 PASS', () => {
const b = join(root, 'base1.json');
const r = run(['--fixtures', fixtures, '--gold', gold, '--compare', b]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('## Gate: PASS (same-hash)');
}, 30_000);
test('doctored main baseline (pretends fewer failures): exit 1 REGRESSION with named breach', () => {
const doctored = JSON.parse(readFileSync(join(root, 'base1.json'), 'utf-8'));
const cellKey = Object.keys(doctored.counts).find((k) => doctored.counts[k].gold_total > 0)!;
doctored.counts[cellKey].gold_failed = Math.max(0, doctored.counts[cellKey].gold_failed - 1);
// also make the run's count strictly greater by raising the bar impossibly
doctored.counts[cellKey].gold_failed = -1 as never;
const path = join(root, 'doctored.json');
writeFileSync(path, JSON.stringify(doctored, null, 2));
const r = run(['--fixtures', fixtures, '--gold', gold, '--compare', path]);
expect(r.exitCode).toBe(1);
expect(r.stdout).toContain('## Gate: REGRESSION');
expect(r.stdout).toContain('newly-failed');
}, 30_000);
test('--allow-regression flips the same comparison to exit 0 and records the reason', () => {
const r = run([
'--fixtures', fixtures, '--gold', gold,
'--compare', join(root, 'doctored.json'),
'--allow-regression', 'e2e test bless',
]);
expect(r.exitCode).toBe(0);
expect(r.stdout).toContain('regression allowed: e2e test bless');
}, 30_000);
test('fixtures_hash mismatch without a committed baseline: exit 2 INCONCLUSIVE', () => {
const foreign = JSON.parse(readFileSync(join(root, 'base1.json'), 'utf-8'));
foreign.fixtures_hash = 'f'.repeat(64);
const path = join(root, 'foreign.json');
writeFileSync(path, JSON.stringify(foreign, null, 2));
const r = run([
'--fixtures', fixtures, '--gold', gold,
'--compare', path,
'--committed-baseline', join(root, 'nonexistent.json'),
]);
expect(r.exitCode).toBe(2);
expect(r.stdout).toContain('corpus-bless');
}, 30_000);
});
describe('anti-vacuous-pass + error paths (always exit 2, never 0)', () => {
test('empty fixtures dir: exit 2', () => {
const empty = join(root, 'empty-fixtures');
const emptyGold = join(root, 'empty-gold');
mkdirSync(empty, { recursive: true });
mkdirSync(emptyGold, { recursive: true });
const r = run(['--fixtures', empty, '--gold', emptyGold]);
expect(r.exitCode).toBe(2);
expect(r.stderr).toContain('vacuous');
}, 30_000);
test('suite filter matching zero fixtures: exit 2', () => {
const r = run(['--fixtures', fixtures, '--gold', gold, '--suite', 'continuity']);
expect(r.exitCode).toBe(2);
expect(r.stderr).toContain('vacuous');
}, 30_000);
test('malformed fixture JSON: exit 2 with the validation error named', () => {
const badRoot = mkdtempSync(join(tmpdir(), 'bb-bad-'));
const badF = join(badRoot, 'fixtures');
const badG = join(badRoot, 'gold');
mkdirSync(badF);
mkdirSync(badG);
writeFileSync(join(badF, 'bad.fixture.json'), '{ not json');
const r = run(['--fixtures', badF, '--gold', badG]);
expect(r.exitCode).toBe(2);
expect(r.stderr).toContain('invalid JSON');
}, 30_000);
test('usage error (unknown flag): exit 2 with usage', () => {
const r = run(['--frobnicate']);
expect(r.exitCode).toBe(2);
expect(r.stderr).toContain('Usage: gbrain eval brainbench');
}, 30_000);
test('seed failure (duplicate slug across seed pages in one source): exit 2, fixture named', () => {
const dupRoot = mkdtempSync(join(tmpdir(), 'bb-dup-'));
const dupF = join(dupRoot, 'fixtures');
const dupG = join(dupRoot, 'gold');
mkdirSync(dupF);
mkdirSync(dupG);
// A page whose content exceeds importFromContent's size cap → status 'skipped' → SeedError.
const huge = 'x'.repeat(5_000_001);
writeFileSync(
join(dupF, 'seedfail-001.fixture.json'),
JSON.stringify({
schema_version: 1,
fixture_id: 'seedfail-001',
suites: ['know-to-ask'],
seed_pages: [{ slug: 'people/too-big', content: `---\ntitle: Too Big\n---\n${huge}` }],
turns: [{ turn_id: 1, role: 'user', text: 'Hello Too Big' }],
}),
);
writeFileSync(
join(dupG, 'seedfail-001.gold.json'),
JSON.stringify({ fixture_id: 'seedfail-001', turns: { '1': { should_retrieve: false } } }),
);
const r = run(['--fixtures', dupF, '--gold', dupG]);
expect(r.exitCode).toBe(2);
expect(r.stderr).toContain('SEED FAILURES');
expect(r.stderr).toContain('seedfail-001');
}, 30_000);
});
describe('holdout discipline (decision 22)', () => {
test('gate mode excludes holdout fixtures; --include-holdout scores them', async () => {
const { loadCorpus } = await import('../src/eval/brainbench/fixtures.ts');
const { runBrainBench } = await import('../src/eval/brainbench/harness.ts');
const corpus = await loadCorpus('evals/brainbench/fixtures', 'evals/brainbench/gold');
const holdoutIds = new Set(
corpus.fixtures.filter((f) => f.fixture.holdout).map((f) => f.fixture.fixture_id),
);
expect(holdoutIds.size).toBeGreaterThan(0);
const pick = corpus.fixtures
.filter((f) => f.fixture.category === 'kta-pos')
.filter((f, i, arr) => f.fixture.holdout || arr.findIndex((x) => !x.fixture.holdout) === i)
.slice(0, 4);
const sub = { ...corpus, fixtures: pick };
const gateRun = await runBrainBench(sub, {
harnesses: ['openclaw'], suites: ['know-to-ask'], includeHoldout: false, llm: false,
});
for (const r of gateRun.turn_rows) expect(holdoutIds.has(r.fixture_id)).toBe(false);
const pubRun = await runBrainBench(sub, {
harnesses: ['openclaw'], suites: ['know-to-ask'], includeHoldout: true, llm: false,
});
expect(pubRun.turn_rows.length).toBeGreaterThan(gateRun.turn_rows.length);
}, 60_000);
});
describe('run-all wiring (decision 16) — full corpus, in-process', () => {
test('runBrainBenchCore completes over the committed corpus with 12 cells', async () => {
const { runBrainBenchCore } = await import('../src/commands/eval-brainbench.ts');
const core = await runBrainBenchCore();
expect(core.status).toBe('completed');
expect(Object.keys(core.cells ?? {}).length).toBe(12);
expect(core.fixtures_hash).toBeDefined();
// committed baseline matches the committed corpus hash (drift guard)
if (existsSync('evals/brainbench/baselines/main.json')) {
const baseline = JSON.parse(readFileSync('evals/brainbench/baselines/main.json', 'utf-8'));
expect(baseline.fixtures_hash).toBe(core.fixtures_hash);
}
}, 120_000);
});