mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
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.
215 lines
9.4 KiB
TypeScript
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);
|
|
});
|