mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
/**
|
|
* v0.31.2 — notability gate eval harness.
|
|
*
|
|
* Two responsibilities:
|
|
*
|
|
* 1. Mining + JSONL utilities (pure functions in
|
|
* src/commands/notability-eval.ts) — covered with deterministic
|
|
* no-LLM tests.
|
|
*
|
|
* 2. Eval harness — given a fixture of (paragraph, confirmed_tier)
|
|
* and the runtime extractor, computes precision@HIGH, recall@HIGH,
|
|
* F1, and the full confusion matrix. Soft gate: warn if
|
|
* precision@HIGH < 0.75; fail PR if < 0.50.
|
|
*
|
|
* The harness runs against the public-anonymized fixture (40 cases) by
|
|
* default. Operators with a private fixture (`~/.gbrain/eval/
|
|
* notability-real.jsonl`, mined + hand-confirmed via `gbrain notability-
|
|
* eval`) can opt into the larger run by setting GBRAIN_NOTABILITY_EVAL_REAL=1.
|
|
*
|
|
* Sample size justification: the public fixture has 14 HIGH cases.
|
|
* For precision@HIGH = 0.75 with a 95% CI ±10pp, n=14 gives roughly
|
|
* the right floor for a "is the gate dramatically wrong" check;
|
|
* tighter measurements need the private fixture (50 cases).
|
|
*
|
|
* The actual LLM call is stubbed via __setChatTransportForTests so this
|
|
* file is parallel-safe and fast — it's a CONTRACT test for the harness
|
|
* shape, not a quality measurement of any specific model. A real
|
|
* quality run uses --real (against a configured Sonnet).
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, afterEach } from 'bun:test';
|
|
import { join } from 'path';
|
|
import {
|
|
mineNotabilityCandidates,
|
|
splitParagraphs,
|
|
walkMarkdownFiles,
|
|
loadJsonlCases,
|
|
writeJsonlCases,
|
|
defaultMiningOutPath,
|
|
defaultReviewOutPath,
|
|
type ConfirmedCase,
|
|
} from '../src/commands/notability-eval.ts';
|
|
import { extractFactsFromTurn } from '../src/core/facts/extract.ts';
|
|
import {
|
|
__setChatTransportForTests,
|
|
resetGateway,
|
|
type ChatResult,
|
|
} from '../src/core/ai/gateway.ts';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
|
|
let tmp: string;
|
|
|
|
beforeAll(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'notability-eval-test-'));
|
|
});
|
|
|
|
afterAll(() => {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
__setChatTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
describe('splitParagraphs', () => {
|
|
test('splits on blank lines and filters by length', () => {
|
|
const body = [
|
|
'short',
|
|
'',
|
|
'this is a long enough paragraph to clear the 80-char minimum threshold for inclusion as a candidate.',
|
|
'',
|
|
'another long paragraph that meets the minimum length cutoff should also be included in the output.',
|
|
].join('\n');
|
|
const ps = splitParagraphs(body);
|
|
expect(ps.length).toBe(2);
|
|
expect(ps[0]).toContain('long enough paragraph');
|
|
expect(ps[1]).toContain('another long paragraph');
|
|
});
|
|
|
|
test('drops paragraphs over the max-char ceiling', () => {
|
|
const huge = 'x'.repeat(900);
|
|
const ps = splitParagraphs(huge);
|
|
expect(ps.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('walkMarkdownFiles', () => {
|
|
test('walks a directory tree for .md files only', () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'walk-md-'));
|
|
try {
|
|
mkdirSync(join(root, 'meetings'), { recursive: true });
|
|
mkdirSync(join(root, 'personal'), { recursive: true });
|
|
writeFileSync(join(root, 'meetings', 'one.md'), 'x'.repeat(100));
|
|
writeFileSync(join(root, 'meetings', 'two.txt'), 'not md');
|
|
writeFileSync(join(root, 'personal', 'three.md'), 'x'.repeat(100));
|
|
const out = walkMarkdownFiles(root);
|
|
const sorted = out.slice().sort();
|
|
expect(sorted.length).toBe(2);
|
|
// walkMarkdownFiles builds its relative paths with join(), which emits
|
|
// '\' on win32 — build the expectations the same way.
|
|
expect(sorted[0]).toBe(join('meetings', 'one.md'));
|
|
expect(sorted[1]).toBe(join('personal', 'three.md'));
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('mineNotabilityCandidates — round-robin (skipLlm)', () => {
|
|
test('returns up to target counts per tier', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'mine-rr-'));
|
|
try {
|
|
// Seed enough paragraphs across two corpus dirs to fill all three tiers.
|
|
mkdirSync(join(root, 'meetings'), { recursive: true });
|
|
mkdirSync(join(root, 'personal'), { recursive: true });
|
|
const para = (n: number) =>
|
|
Array.from({ length: 6 }, (_, i) =>
|
|
`Paragraph ${n}.${i} with enough characters to clear the eighty character minimum threshold for the candidate filter to accept it as input.`,
|
|
).join('\n\n');
|
|
for (let i = 0; i < 4; i++) {
|
|
writeFileSync(join(root, 'meetings', `m${i}.md`), para(i));
|
|
writeFileSync(join(root, 'personal', `p${i}.md`), para(i + 100));
|
|
}
|
|
|
|
const candidates = await mineNotabilityCandidates(root, {
|
|
targetHigh: 5,
|
|
targetMedium: 5,
|
|
targetLow: 3,
|
|
skipLlm: true,
|
|
});
|
|
|
|
// We can't guarantee EXACTLY 5/5/3 because round-robin into bucket
|
|
// sizes depends on candidate count modulo 3, but every tier should
|
|
// be reached AND total ≤ 13.
|
|
const tiers = new Set(candidates.map(c => c.predicted_tier));
|
|
expect(tiers.size).toBeGreaterThan(0);
|
|
expect(candidates.length).toBeLessThanOrEqual(13);
|
|
expect(candidates.length).toBeGreaterThan(0);
|
|
// Every candidate has a real path + paragraph.
|
|
for (const c of candidates) {
|
|
expect(c.path).toMatch(/\.(md)$/);
|
|
expect(c.paragraph.length).toBeGreaterThanOrEqual(80);
|
|
expect(['high', 'medium', 'low']).toContain(c.predicted_tier);
|
|
}
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('returns empty when corpus dirs are empty', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'mine-empty-'));
|
|
try {
|
|
const candidates = await mineNotabilityCandidates(root, { skipLlm: true });
|
|
expect(candidates).toEqual([]);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('JSONL utilities', () => {
|
|
test('writeJsonlCases + loadJsonlCases round-trip', () => {
|
|
const path = join(tmp, 'roundtrip.jsonl');
|
|
const cases = [
|
|
{ path: 'a.md', paragraph: 'p1', predicted_tier: 'high' as const, predicted_at: 'now' },
|
|
{ path: 'b.md', paragraph: 'p2', predicted_tier: 'low' as const, predicted_at: 'now' },
|
|
];
|
|
writeJsonlCases(path, cases);
|
|
const out = loadJsonlCases<typeof cases[0]>(path);
|
|
expect(out).toEqual(cases);
|
|
});
|
|
|
|
test('loadJsonlCases skips malformed lines', () => {
|
|
const path = join(tmp, 'malformed.jsonl');
|
|
writeFileSync(path, '{"valid":1}\n{this is not json}\n{"valid":2}\n');
|
|
const out = loadJsonlCases<{ valid: number }>(path);
|
|
expect(out.length).toBe(2);
|
|
expect(out[0]?.valid).toBe(1);
|
|
expect(out[1]?.valid).toBe(2);
|
|
});
|
|
|
|
test('default paths resolve under ~/.gbrain/eval/', () => {
|
|
// Both defaults are join()-built, so the substring must carry native
|
|
// separators too ('\.gbrain\eval\…' on win32).
|
|
expect(defaultMiningOutPath()).toContain(
|
|
join('.gbrain', 'eval', 'notability-mining-candidates.jsonl'),
|
|
);
|
|
expect(defaultReviewOutPath()).toContain(join('.gbrain', 'eval', 'notability-real.jsonl'));
|
|
});
|
|
});
|
|
|
|
describe('public-anonymized fixture — shape contract', () => {
|
|
test('40 cases, ~14 HIGH + ~13 MEDIUM + ~13 LOW', () => {
|
|
const cases = loadJsonlCases<ConfirmedCase>(
|
|
'test/fixtures/notability-eval-public.jsonl',
|
|
);
|
|
expect(cases.length).toBe(40);
|
|
const counts = { high: 0, medium: 0, low: 0 };
|
|
for (const c of cases) {
|
|
counts[c.confirmed_tier] += 1;
|
|
}
|
|
// Allow some slack for future fixture tuning.
|
|
expect(counts.high).toBeGreaterThanOrEqual(10);
|
|
expect(counts.medium).toBeGreaterThanOrEqual(10);
|
|
expect(counts.low).toBeGreaterThanOrEqual(10);
|
|
expect(counts.high + counts.medium + counts.low).toBe(40);
|
|
});
|
|
|
|
test('every fixture case has a paragraph >= 80 chars and a rationale', () => {
|
|
const cases = loadJsonlCases<ConfirmedCase & { tier_rationale?: string }>(
|
|
'test/fixtures/notability-eval-public.jsonl',
|
|
);
|
|
for (const c of cases) {
|
|
expect(c.paragraph.length).toBeGreaterThanOrEqual(80);
|
|
// Rationale isn't required by the helper but the public fixture
|
|
// documents tier choices for reviewer transparency.
|
|
expect(typeof c.tier_rationale === 'string' && c.tier_rationale.length > 0).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Eval harness contract: given a confirmed-tier fixture and a stubbed
|
|
* extractor, compute precision@HIGH and assert the harness shape works.
|
|
*
|
|
* The `tierOf` function below extracts the dominant notability from a
|
|
* paragraph by stubbing the LLM to return the confirmed tier. This is
|
|
* a CONTRACT test for the harness shape, not a quality measurement.
|
|
* (A quality run uses --real against a configured Sonnet.)
|
|
*/
|
|
describe('eval harness — precision@HIGH contract', () => {
|
|
function makeChatStub(predictedTier: 'high' | 'medium' | 'low') {
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => ({
|
|
text: JSON.stringify({
|
|
facts: [{
|
|
fact: 'eval-stub',
|
|
kind: 'fact',
|
|
entity: null,
|
|
confidence: 1.0,
|
|
notability: predictedTier,
|
|
}],
|
|
}),
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'test:stub',
|
|
providerId: 'test',
|
|
}));
|
|
}
|
|
|
|
test('100% perfect predictor → precision@HIGH = 1, recall@HIGH = 1, F1 = 1', async () => {
|
|
const fixture = loadJsonlCases<ConfirmedCase>('test/fixtures/notability-eval-public.jsonl');
|
|
let tp = 0;
|
|
let fp = 0;
|
|
let fn = 0;
|
|
for (const c of fixture) {
|
|
makeChatStub(c.confirmed_tier);
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: c.paragraph,
|
|
source: 'eval-test',
|
|
});
|
|
const predicted = facts[0]?.notability ?? 'medium';
|
|
if (c.confirmed_tier === 'high' && predicted === 'high') tp += 1;
|
|
else if (c.confirmed_tier !== 'high' && predicted === 'high') fp += 1;
|
|
else if (c.confirmed_tier === 'high' && predicted !== 'high') fn += 1;
|
|
}
|
|
const precision = tp / Math.max(1, tp + fp);
|
|
const recall = tp / Math.max(1, tp + fn);
|
|
expect(precision).toBe(1);
|
|
expect(recall).toBe(1);
|
|
});
|
|
|
|
test('always-medium predictor → precision@HIGH = 0 (no HIGH predictions)', async () => {
|
|
const fixture = loadJsonlCases<ConfirmedCase>('test/fixtures/notability-eval-public.jsonl');
|
|
let tp = 0;
|
|
let fp = 0;
|
|
for (const c of fixture) {
|
|
makeChatStub('medium'); // always-medium model
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: c.paragraph,
|
|
source: 'eval-test',
|
|
});
|
|
const predicted = facts[0]?.notability ?? 'medium';
|
|
if (c.confirmed_tier === 'high' && predicted === 'high') tp += 1;
|
|
else if (c.confirmed_tier !== 'high' && predicted === 'high') fp += 1;
|
|
}
|
|
const precision = tp + fp === 0 ? 0 : tp / (tp + fp);
|
|
expect(precision).toBe(0); // no HIGH predictions → no precision metric
|
|
});
|
|
|
|
test('over-predicts HIGH → precision drops below the warn threshold', async () => {
|
|
const fixture = loadJsonlCases<ConfirmedCase>('test/fixtures/notability-eval-public.jsonl');
|
|
let tp = 0;
|
|
let fp = 0;
|
|
for (const c of fixture) {
|
|
makeChatStub('high'); // always-high model (max false-positive rate)
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: c.paragraph,
|
|
source: 'eval-test',
|
|
});
|
|
const predicted = facts[0]?.notability ?? 'medium';
|
|
if (c.confirmed_tier === 'high' && predicted === 'high') tp += 1;
|
|
else if (c.confirmed_tier !== 'high' && predicted === 'high') fp += 1;
|
|
}
|
|
const precision = tp / Math.max(1, tp + fp);
|
|
// 14 TP / 40 predictions = 0.35 → below the 0.75 warn threshold
|
|
// AND below the 0.50 PR-fail threshold. This is what a misaligned
|
|
// model looks like; the harness CORRECTLY flags it.
|
|
expect(precision).toBeLessThan(0.75);
|
|
expect(precision).toBeLessThan(0.50);
|
|
});
|
|
});
|