Files
gbrain/test/eval/cat6.test.ts
T
Garry TanandClaude Opus 4.6 839258dc42 feat(eval): Day 6 — adversarial-injections + Cat 6 prose-scale + Cat 11 multi-modal
Three modules that together cover BrainBench v1 Cat 6 (prose-scale
extraction fidelity) and Cat 11 (multi-modal ingest fidelity).

**eval/runner/adversarial-injections.ts** — 6 deterministic content
transforms shared by Cat 10 (adversarial.ts, 22 hand-crafted cases) and
Cat 6 (prose-scale variants). Each injection produces a modified content
string + a structured GoldDelta describing what the extractor MUST and
MUST NOT produce. Kinds:
  - code_fence_leak — fake [X](people/fake) inside ``` fence, must NOT extract
  - inline_code_slug — `people/fake` in backticks, must NOT extract
  - substring_collision — "SamAI" near real `people/sam`, exactly one link
  - ambiguous_role — "works with" vs "works at", downgrade type to mentions
  - prose_only_mention — strip markdown link syntax, bare name → mentions only
  - multi_entity_sentence — pack 4+ entities into one clause, extract all

Mulberry32 PRNG keeps variant generation deterministic under fixed seed.
Codex flagged the original plan's wording ("extract injection engine from
adversarial.ts") as overstated — adversarial.ts is a static case list,
not a reusable engine. This module is NEW code.

**eval/runner/cat6-prose-scale.ts** — Runner. Loads world-v1, applies all
6 injection kinds to sampled base pages (default 50 variants per kind ×
6 kinds = 300 variants), runs extractPageLinks on each, compares to gold
delta. Emits per-kind + overall metrics (precision, recall, F1,
code_fence_leak_rate, substring_fp_rate, pages_with_links_coverage,
mean_links_per_page). **v1 verdict is always "baseline_only"** — no
gating threshold per codex fix #9 (current extractor residuals make
>0.80 unreachable; v1 records a baseline, regression guard triggers on
drop below it).

**eval/runner/cat11-multimodal.ts** — PDF + HTML + audio runners.
Fixtures load from eval/data/multimodal/<modality>/fixtures.json
manifests; each modality skips gracefully when manifest missing or
(audio) when neither GROQ_API_KEY nor OPENAI_API_KEY is set. Metrics:
  - PDF: char-level similarity via Levenshtein + optional entity_recall
  - HTML: word-recall over normalized tokens (multiset semantics)
  - Audio: WER (word error rate) via Levenshtein on word sequences
Fixtures are NOT committed; a future eval:fetch-multimodal script will
download them hash-verified from public sources (arXiv CC-licensed
papers, Wikipedia CC-BY-SA, Common Voice CC0).

Injectable audio transcriber (`opts.transcribe`) means tests don't need
GROQ/OpenAI keys — stubbed transcriptions exercise the WER math path
directly.

**Tests (60 new):** adversarial-injections (19) — per-kind assertions +
dispatcher coverage + slug regex conformance; cat6 (12) — variant
determinism, scoreVariant shape, aggregate per-kind + overall metrics,
corpus resolver slug rules; cat11 (29) — charSimilarity / wordRecall /
wer math, htmlToText strips scripts + decodes entities, HTML modality
with real fixtures, audio modality gracefully skips without key + uses
stub transcriber correctly.

All 60 tests pass in 48ms + 41ms.
Total eval suite now: 227 pass, 0 fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 21:59:30 +08:00

203 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* cat6-prose-scale.ts tests — Day 6 of BrainBench v1 Complete.
*
* Uses a tiny synthetic corpus (5 pages) so tests run in <200ms, not the
* full 240-page world-v1 which is expensive to traverse per-test.
*
* Covers:
* - generateVariants produces deterministic variantIds under fixed seed
* - Variants cover all 6 injection kinds
* - scoreVariant computes matched/missed/false_positives correctly
* - aggregate produces all per-kind metrics + overall numbers
* - Verdict is always 'baseline_only' in v1 (no gating threshold)
*/
import { describe, test, expect } from 'bun:test';
import {
generateVariants,
aggregate,
scoreVariant,
makeCorpusResolver,
type BasePage,
} from '../../eval/runner/cat6-prose-scale.ts';
import { ALL_INJECTION_KINDS } from '../../eval/runner/adversarial-injections.ts';
const TINY_CORPUS: BasePage[] = [
{
slug: 'people/amara',
type: 'person',
title: 'Amara Okafor',
content: 'Amara is a partner at [Halfway](companies/halfway).',
},
{
slug: 'people/jordan',
type: 'person',
title: 'Jordan Park',
content: 'Jordan founded [NovaMind](companies/novamind) in 2023.',
},
{
slug: 'people/mina',
type: 'person',
title: 'Mina Kapoor',
content: 'Mina runs [Threshold](companies/threshold).',
},
{
slug: 'people/sarah',
type: 'person',
title: 'Sarah Chen',
content: 'Sarah advises several seed-stage founders.',
},
{
slug: 'companies/halfway',
type: 'company',
title: 'Halfway Capital',
content: 'VC firm focused on climate + AI infrastructure.',
},
{
slug: 'companies/novamind',
type: 'company',
title: 'NovaMind',
content: 'AI infrastructure startup.',
},
{
slug: 'companies/threshold',
type: 'company',
title: 'Threshold',
content: 'Venture firm.',
},
];
// ─── Variant generation ───────────────────────────────────────────────
describe('generateVariants', () => {
test('produces exactly perKind × 6 variants', () => {
const variants = generateVariants(TINY_CORPUS, { perKind: 3 });
expect(variants.length).toBe(3 * ALL_INJECTION_KINDS.length);
});
test('variants are distributed across all kinds', () => {
const variants = generateVariants(TINY_CORPUS, { perKind: 2 });
const kinds = new Set(variants.map(v => v.kind));
expect(kinds.size).toBe(ALL_INJECTION_KINDS.length);
});
test('deterministic under fixed seed', () => {
const a = generateVariants(TINY_CORPUS, { perKind: 2, baseSeed: 42 });
const b = generateVariants(TINY_CORPUS, { perKind: 2, baseSeed: 42 });
expect(a.length).toBe(b.length);
for (let i = 0; i < a.length; i++) {
expect(a[i].variantId).toBe(b[i].variantId);
expect(a[i].content).toBe(b[i].content);
}
});
test('different seeds produce different content', () => {
const a = generateVariants(TINY_CORPUS, { perKind: 2, baseSeed: 42 });
const b = generateVariants(TINY_CORPUS, { perKind: 2, baseSeed: 99 });
// code_fence_leak's fake slug depends on seed; content will differ.
const aCodeFences = a.filter(v => v.kind === 'code_fence_leak');
const bCodeFences = b.filter(v => v.kind === 'code_fence_leak');
expect(aCodeFences[0].content).not.toBe(bCodeFences[0].content);
});
test('variantId follows "<slug>-v<idx>-<kind>" pattern', () => {
const variants = generateVariants(TINY_CORPUS, { perKind: 1 });
for (const v of variants) {
expect(v.variantId).toMatch(/^.+-v\d+-[a-z_]+$/);
expect(v.variantId.endsWith(`-${v.kind}`)).toBe(true);
}
});
});
// ─── scoreVariant ─────────────────────────────────────────────────────
describe('scoreVariant', () => {
test('returns VariantResult with extracted, missed, false_positives, matched', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 1 });
const result = await scoreVariant(variants[0], resolver);
expect(result.variantId).toBe(variants[0].variantId);
expect(Array.isArray(result.extracted)).toBe(true);
expect(Array.isArray(result.false_positives)).toBe(true);
expect(Array.isArray(result.missed)).toBe(true);
expect(typeof result.matched).toBe('number');
});
test('counts a must_extract slug as matched when extractor produces it', async () => {
// Multi-entity sentences pack ≥4 real refs and the resolver accepts all known slugs.
// The extractor should produce those links.
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 5 });
const multi = variants.find(v => v.kind === 'multi_entity_sentence' && v.goldDelta.must_extract.length >= 4);
if (!multi) {
// Skip gracefully if TINY_CORPUS is too small
return;
}
const result = await scoreVariant(multi, resolver);
expect(result.matched).toBeGreaterThan(0);
});
});
// ─── aggregate ────────────────────────────────────────────────────────
describe('aggregate', () => {
test('emits per_kind for every kind present', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 2 });
const results = await Promise.all(variants.map(v => scoreVariant(v, resolver)));
const report = aggregate(variants, results);
expect(report.per_kind.length).toBe(ALL_INJECTION_KINDS.length);
expect(report.variants).toBe(variants.length);
});
test('overall metrics are computed (precision/recall/f1 in 0-1 range)', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 2 });
const results = await Promise.all(variants.map(v => scoreVariant(v, resolver)));
const report = aggregate(variants, results);
expect(report.overall.link_precision).toBeGreaterThanOrEqual(0);
expect(report.overall.link_precision).toBeLessThanOrEqual(1);
expect(report.overall.link_recall).toBeGreaterThanOrEqual(0);
expect(report.overall.link_recall).toBeLessThanOrEqual(1);
expect(report.overall.link_f1).toBeGreaterThanOrEqual(0);
expect(report.overall.link_f1).toBeLessThanOrEqual(1);
});
test('verdict is always baseline_only in v1', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 1 });
const results = await Promise.all(variants.map(v => scoreVariant(v, resolver)));
const report = aggregate(variants, results);
expect(report.verdict).toBe('baseline_only');
});
test('mean_links_per_page matches totalExtracted / variants', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
const variants = generateVariants(TINY_CORPUS, { perKind: 1 });
const results = await Promise.all(variants.map(v => scoreVariant(v, resolver)));
const report = aggregate(variants, results);
const totalExtracted = results.reduce((sum, r) => sum + r.extracted.length, 0);
expect(report.overall.mean_links_per_page).toBeCloseTo(totalExtracted / variants.length, 6);
});
});
// ─── Corpus resolver ──────────────────────────────────────────────────
describe('makeCorpusResolver', () => {
test('returns slug unchanged when slug is known', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
expect(await resolver.resolve('people/amara')).toBe('people/amara');
});
test('returns null when slug is unknown', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
expect(await resolver.resolve('people/ghost')).toBeNull();
});
test('does not resolve bare names in v1', async () => {
const resolver = makeCorpusResolver(TINY_CORPUS);
// Cat 6 scoring semantics: bare-name resolution is out of scope.
expect(await resolver.resolve('Amara Okafor')).toBeNull();
});
});