mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
New Category 2 in BrainBench: per-link-type accuracy measured directly on the
240-page rich-prose world-v1 corpus. Distinct from Cat 1's retrieval metrics,
this measures whether inferLinkType() correctly classifies extracted edges
when the prose varies (the 58% works_at and 41% advises residuals that v0.10.5
regexes targeted).
How it works:
1. Loads all pages from eval/data/world-v1/
2. Derives GOLD expected edges from each page's _facts metadata
(founders → founded, investors → invested_in, advisors → advises,
employees → works_at, attendees → attended, primary_affiliation +
role drives person-page outbound type)
3. Runs extractPageLinks() on each page → INFERRED edges
4. Per (from, to) pair, compares inferred type vs gold type
5. Emits per-link-type table: correct / mistyped / missed / spurious +
type accuracy + recall + precision + strict F1 (triple match)
6. Full confusion matrix rows=gold, cols=inferred
v0.10.5 validation on 240-page corpus (up from pre-v0.10.5 baselines):
- works_at: 58% → 100.0% (+42 pts) — 10/10 correct, 0 mistyped
- advises: 41% → 88.2% (+47 pts) — 15/17 correct
- attended: — → 100.0% 131/134 recall
- founded: 100% → 100.0% 40/40
- invested_in: 89% → 92.0% 69/75
- Overall: 88.5% → 95.7% type accuracy (conditional on edge found)
Strict F1 overall: 53.7%. Lower because the _facts-based gold set only
captures core relationships; rich prose extracts many peripheral mentions
(190 spurious "mentions" edges) that aren't bugs but are correctly-typed
prose references without a _facts counterpart. Spurious counts are signal
for future type-precision tuning, not failure.
Wired into eval/runner/all.ts as Cat 2 so every full benchmark run includes
the rich-prose type accuracy table alongside retrieval metrics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
/**
|
|
* BrainBench — per-link-type accuracy on the 240-page rich-prose corpus.
|
|
*
|
|
* This is the measurement tool for v0.10.5+ extraction work. It:
|
|
* 1. Loads all pages from eval/data/world-v1/
|
|
* 2. Derives GOLD expected links per page from `_facts` metadata
|
|
* (founders → founded, investors → invested_in, advisors → advises,
|
|
* employees → works_at, attendees → attended, primary_affiliation →
|
|
* works_at or founded based on role)
|
|
* 3. Runs extractPageLinks on each page → INFERRED links
|
|
* 4. Compares gold vs inferred per link type:
|
|
* - correctly_typed: gold (src, tgt) exists AND inferred type matches
|
|
* - mistyped: gold (src, tgt) exists AND inferred type differs
|
|
* - missed: gold (src, tgt) exists AND no inferred edge
|
|
* - spurious: inferred (src, tgt) with no gold edge at all
|
|
*
|
|
* Emits a per-link-type table with type accuracy per type + overall.
|
|
* Headline metric: TYPE ACCURACY = correctly_typed / (correctly_typed + mistyped)
|
|
* conditional on the edge being found at all (excludes missed).
|
|
*
|
|
* Also emits a COMBINED metric: F1 per link type treating type as part of
|
|
* the identity — (src, tgt, type) triple must match. Catches both the
|
|
* extraction-recall problem and the type-accuracy problem in one number.
|
|
*
|
|
* Usage: bun eval/runner/type-accuracy.ts [--json]
|
|
*/
|
|
|
|
import { readdirSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { extractPageLinks } from '../../src/core/link-extraction.ts';
|
|
import type { PageType } from '../../src/core/types.ts';
|
|
|
|
interface RichPage {
|
|
slug: string;
|
|
type: 'person' | 'company' | 'meeting' | 'concept';
|
|
title: string;
|
|
compiled_truth: string;
|
|
timeline: string;
|
|
_facts: {
|
|
type: string;
|
|
name?: string;
|
|
role?: string;
|
|
industry?: string;
|
|
primary_affiliation?: string;
|
|
secondary_affiliations?: string[];
|
|
founders?: string[];
|
|
employees?: string[];
|
|
investors?: string[];
|
|
advisors?: string[];
|
|
attendees?: string[];
|
|
related_companies?: string[];
|
|
};
|
|
}
|
|
|
|
interface GoldEdge {
|
|
from: string;
|
|
to: string;
|
|
type: string;
|
|
}
|
|
|
|
/** Load all rich-prose pages from the world-v1 shard directory. */
|
|
function loadCorpus(dir: string): RichPage[] {
|
|
const files = readdirSync(dir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
|
|
const out: RichPage[] = [];
|
|
for (const f of files) {
|
|
const p = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
if (Array.isArray(p.timeline)) p.timeline = p.timeline.join('\n');
|
|
if (Array.isArray(p.compiled_truth)) p.compiled_truth = p.compiled_truth.join('\n\n');
|
|
p.title = String(p.title ?? '');
|
|
p.compiled_truth = String(p.compiled_truth ?? '');
|
|
p.timeline = String(p.timeline ?? '');
|
|
out.push(p as RichPage);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Derive the gold edge set from `_facts` metadata. Only edges where both
|
|
* endpoints are real pages in the corpus count (FK-constraint style).
|
|
*
|
|
* Rules:
|
|
* company.founders -> founded (person -> company)
|
|
* company.employees -> works_at (person -> company)
|
|
* company.investors -> invested_in (person -> company)
|
|
* company.advisors -> advises (person -> company)
|
|
* meeting.attendees -> attended (person -> meeting)
|
|
* person.primary_affiliation + role=founder -> founded
|
|
* person.primary_affiliation + role∈{engineer,...}-> works_at
|
|
* person.primary_affiliation + role=advisor -> advises
|
|
* person.primary_affiliation + role=investor/partner -> invested_in
|
|
* person.secondary_affiliations + role=advisor -> advises
|
|
*/
|
|
function buildGoldEdges(pages: RichPage[]): GoldEdge[] {
|
|
const existing = new Set(pages.map(p => p.slug));
|
|
const edges: GoldEdge[] = [];
|
|
const push = (from: string, to: string, type: string) => {
|
|
if (!existing.has(from) || !existing.has(to)) return;
|
|
if (from === to) return;
|
|
edges.push({ from, to, type });
|
|
};
|
|
|
|
// Company-page -> incoming edges from people referenced in _facts arrays.
|
|
for (const p of pages) {
|
|
if (p._facts.type === 'company') {
|
|
for (const f of p._facts.founders ?? []) push(f, p.slug, 'founded');
|
|
for (const e of p._facts.employees ?? []) {
|
|
// Avoid double-labeling: if e is also a founder, prefer founded (more specific).
|
|
if ((p._facts.founders ?? []).includes(e)) continue;
|
|
push(e, p.slug, 'works_at');
|
|
}
|
|
for (const i of p._facts.investors ?? []) push(i, p.slug, 'invested_in');
|
|
for (const a of p._facts.advisors ?? []) push(a, p.slug, 'advises');
|
|
}
|
|
if (p._facts.type === 'meeting') {
|
|
// Direction: extractPageLinks on a meeting page produces
|
|
// (meeting_slug, person_slug, 'attended') because the person slugs
|
|
// appear as entity refs inside the meeting page's content. Match that
|
|
// direction in the gold so (from, to) pairs align with the inferred set.
|
|
for (const a of p._facts.attendees ?? []) push(p.slug, a, 'attended');
|
|
}
|
|
}
|
|
|
|
// Person-page -> outgoing primary_affiliation + secondaries.
|
|
for (const p of pages) {
|
|
if (p._facts.type !== 'person') continue;
|
|
const role = (p._facts.role ?? '').toLowerCase();
|
|
const primary = p._facts.primary_affiliation;
|
|
if (primary && existing.has(primary)) {
|
|
if (['founder', 'co-founder'].includes(role)) push(p.slug, primary, 'founded');
|
|
else if (role === 'advisor') push(p.slug, primary, 'advises');
|
|
else if (['partner', 'investor', 'vc'].includes(role)) push(p.slug, primary, 'invested_in');
|
|
else push(p.slug, primary, 'works_at');
|
|
}
|
|
for (const sec of p._facts.secondary_affiliations ?? []) {
|
|
if (!existing.has(sec)) continue;
|
|
// Secondary affiliations are typically advisory / board work.
|
|
if (role === 'advisor') push(p.slug, sec, 'advises');
|
|
else if (['partner', 'investor', 'vc'].includes(role)) push(p.slug, sec, 'invested_in');
|
|
else push(p.slug, sec, 'mentions');
|
|
}
|
|
}
|
|
|
|
// Dedup (same from/to/type edge could be added via multiple rules).
|
|
const seen = new Set<string>();
|
|
const dedup: GoldEdge[] = [];
|
|
for (const e of edges) {
|
|
const k = `${e.from}\u0000${e.to}\u0000${e.type}`;
|
|
if (seen.has(k)) continue;
|
|
seen.add(k);
|
|
dedup.push(e);
|
|
}
|
|
return dedup;
|
|
}
|
|
|
|
/** Run extractPageLinks on every page; return flat list of inferred edges. */
|
|
function inferAllEdges(pages: RichPage[]): GoldEdge[] {
|
|
const edges: GoldEdge[] = [];
|
|
for (const p of pages) {
|
|
const content = `${p.title}\n\n${p.compiled_truth}\n\n${p.timeline}`;
|
|
const candidates = extractPageLinks(content, {}, p.type as PageType);
|
|
for (const c of candidates) {
|
|
edges.push({ from: p.slug, to: c.targetSlug, type: c.linkType });
|
|
}
|
|
}
|
|
return edges;
|
|
}
|
|
|
|
interface PerTypeResult {
|
|
linkType: string;
|
|
gold: number;
|
|
correctly_typed: number; // gold edge present AND inferred type matches
|
|
mistyped: number; // gold edge present AND inferred type differs
|
|
missed: number; // gold edge present AND no inferred edge
|
|
spurious: number; // inferred edge (this type) with no matching gold (any type) for (from, to)
|
|
type_accuracy: number; // correctly_typed / (correctly_typed + mistyped) [conditional on finding the edge]
|
|
recall: number; // correctly_typed / gold
|
|
precision: number; // correctly_typed / (correctly_typed + spurious_this_type_and_mistyped_from_other_types_into_this_type)
|
|
f1_strict: number; // F1 where (from, to, type) triple must match exactly
|
|
}
|
|
|
|
interface ConfusionMatrix {
|
|
// matrix[goldType][inferredType] = count
|
|
[goldType: string]: Record<string, number>;
|
|
}
|
|
|
|
function score(gold: GoldEdge[], inferred: GoldEdge[]): {
|
|
perType: PerTypeResult[];
|
|
confusion: ConfusionMatrix;
|
|
overallTypeAccuracy: number;
|
|
overallStrictF1: number;
|
|
} {
|
|
// Index gold by (from, to) pair — regardless of type.
|
|
const goldByPair = new Map<string, string>(); // key: from\u0000to → type
|
|
for (const g of gold) {
|
|
goldByPair.set(`${g.from}\u0000${g.to}`, g.type);
|
|
}
|
|
|
|
// Index inferred by (from, to, type).
|
|
const inferredByPair = new Map<string, string>();
|
|
for (const i of inferred) {
|
|
const key = `${i.from}\u0000${i.to}`;
|
|
// Keep the first inferred type for each pair; extractPageLinks already dedupes by (targetSlug, linkType).
|
|
if (!inferredByPair.has(key)) {
|
|
inferredByPair.set(key, i.type);
|
|
}
|
|
}
|
|
|
|
const linkTypes = new Set<string>();
|
|
for (const g of gold) linkTypes.add(g.type);
|
|
for (const i of inferred) linkTypes.add(i.type);
|
|
|
|
// Build confusion matrix: gold type → inferred type counts.
|
|
const confusion: ConfusionMatrix = {};
|
|
for (const t of linkTypes) confusion[t] = {};
|
|
|
|
for (const [pair, goldType] of goldByPair) {
|
|
const inferredType = inferredByPair.get(pair) ?? '(missing)';
|
|
confusion[goldType][inferredType] = (confusion[goldType][inferredType] ?? 0) + 1;
|
|
}
|
|
// Spurious edges (inferred without gold) tracked under '(no-gold)' rows.
|
|
confusion['(no-gold)'] = {};
|
|
for (const [pair, inferredType] of inferredByPair) {
|
|
if (!goldByPair.has(pair)) {
|
|
confusion['(no-gold)'][inferredType] = (confusion['(no-gold)'][inferredType] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
const perType: PerTypeResult[] = [];
|
|
let overallCorrectlyTyped = 0;
|
|
let overallFound = 0;
|
|
let overallGold = 0;
|
|
let overallInferredThisTypeOrMistyped = 0;
|
|
// For overall strict F1:
|
|
let overallStrictTP = 0;
|
|
let overallStrictFP = 0;
|
|
let overallStrictFN = 0;
|
|
|
|
for (const t of linkTypes) {
|
|
if (t === '(no-gold)' || t === '(missing)') continue;
|
|
const goldCount = Object.values(confusion[t] ?? {}).reduce((a, b) => a + b, 0);
|
|
const correctly_typed = confusion[t]?.[t] ?? 0;
|
|
const missed = confusion[t]?.['(missing)'] ?? 0;
|
|
const mistyped = goldCount - correctly_typed - missed;
|
|
|
|
// Spurious for this type: inferred as this type, but gold had a different type OR no gold edge.
|
|
let spurious = 0;
|
|
// (a) inferred as t where gold was a DIFFERENT type: sum column t across other goldTypes
|
|
for (const gt of Object.keys(confusion)) {
|
|
if (gt === t || gt === '(missing)' || gt === '(no-gold)') continue;
|
|
spurious += confusion[gt]?.[t] ?? 0;
|
|
}
|
|
// (b) inferred as t with no gold edge at all
|
|
spurious += confusion['(no-gold)']?.[t] ?? 0;
|
|
|
|
const found = correctly_typed + mistyped; // edge found (any type) where gold existed
|
|
const type_accuracy = found > 0 ? correctly_typed / found : 0;
|
|
const recall = goldCount > 0 ? correctly_typed / goldCount : 0;
|
|
const precisionDenom = correctly_typed + spurious;
|
|
const precision = precisionDenom > 0 ? correctly_typed / precisionDenom : 0;
|
|
const f1_strict =
|
|
precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
|
|
perType.push({
|
|
linkType: t,
|
|
gold: goldCount,
|
|
correctly_typed,
|
|
mistyped,
|
|
missed,
|
|
spurious,
|
|
type_accuracy,
|
|
recall,
|
|
precision,
|
|
f1_strict,
|
|
});
|
|
|
|
overallCorrectlyTyped += correctly_typed;
|
|
overallFound += found;
|
|
overallGold += goldCount;
|
|
overallInferredThisTypeOrMistyped += correctly_typed + spurious;
|
|
overallStrictTP += correctly_typed;
|
|
overallStrictFN += mistyped + missed; // gold edges not correctly-typed
|
|
overallStrictFP += spurious;
|
|
}
|
|
|
|
const overallTypeAccuracy = overallFound > 0 ? overallCorrectlyTyped / overallFound : 0;
|
|
const overallStrictPrecision =
|
|
overallStrictTP + overallStrictFP > 0
|
|
? overallStrictTP / (overallStrictTP + overallStrictFP)
|
|
: 0;
|
|
const overallStrictRecall =
|
|
overallStrictTP + overallStrictFN > 0
|
|
? overallStrictTP / (overallStrictTP + overallStrictFN)
|
|
: 0;
|
|
const overallStrictF1 =
|
|
overallStrictPrecision + overallStrictRecall > 0
|
|
? (2 * overallStrictPrecision * overallStrictRecall) /
|
|
(overallStrictPrecision + overallStrictRecall)
|
|
: 0;
|
|
|
|
// Sort perType by gold count descending (most common first).
|
|
perType.sort((a, b) => b.gold - a.gold);
|
|
|
|
return { perType, confusion, overallTypeAccuracy, overallStrictF1 };
|
|
}
|
|
|
|
function pct(n: number, digits = 1): string {
|
|
return `${(n * 100).toFixed(digits)}%`;
|
|
}
|
|
|
|
async function main() {
|
|
const json = process.argv.includes('--json');
|
|
const dir = process.argv.find(a => a.startsWith('--dir='))?.slice('--dir='.length) ??
|
|
'eval/data/world-v1';
|
|
const log = json ? () => {} : console.log;
|
|
|
|
log('# BrainBench — type accuracy on rich-prose corpus\n');
|
|
log(`Generated: ${new Date().toISOString().slice(0, 19)}`);
|
|
log(`Corpus: ${dir}/`);
|
|
|
|
const pages = loadCorpus(dir);
|
|
log(`Loaded ${pages.length} pages.\n`);
|
|
|
|
const gold = buildGoldEdges(pages);
|
|
const inferred = inferAllEdges(pages);
|
|
|
|
log(`Gold edges (from _facts): ${gold.length}`);
|
|
log(`Inferred edges (extractPageLinks): ${inferred.length}\n`);
|
|
|
|
const { perType, confusion, overallTypeAccuracy, overallStrictF1 } = score(gold, inferred);
|
|
|
|
log('## Per-link-type results\n');
|
|
log('| Link type | Gold | Correct | Mistyped | Missed | Spurious | Type acc | Recall | Prec | F1 (strict) |');
|
|
log('|--------------|------|---------|----------|--------|----------|----------|--------|--------|-------------|');
|
|
for (const r of perType) {
|
|
log(
|
|
`| ${r.linkType.padEnd(12)} | ${String(r.gold).padStart(4)} | ${String(r.correctly_typed).padStart(7)} | ${String(r.mistyped).padStart(8)} | ${String(r.missed).padStart(6)} | ${String(r.spurious).padStart(8)} | ${pct(r.type_accuracy).padStart(8)} | ${pct(r.recall).padStart(6)} | ${pct(r.precision).padStart(6)} | ${pct(r.f1_strict).padStart(11)} |`,
|
|
);
|
|
}
|
|
log('');
|
|
log('**Columns:**');
|
|
log('- *Type acc*: given the edge was found at all, was it typed correctly? `correct / (correct + mistyped)`.');
|
|
log('- *Recall*: of gold edges, how many did we correctly find AND type? `correct / gold`.');
|
|
log('- *Precision*: of edges we inferred as this type, how many were actually this type? `correct / (correct + spurious)`.');
|
|
log('- *F1 (strict)*: strict `(from, to, type)` triple match. Catches both extraction-recall and type-accuracy misses in one number.\n');
|
|
|
|
log('## Overall\n');
|
|
log(`- Overall type accuracy (conditional on finding the edge): **${pct(overallTypeAccuracy)}**`);
|
|
log(`- Overall strict F1 (triple match): **${pct(overallStrictF1)}**\n`);
|
|
|
|
log('## Confusion matrix (rows = gold type, cols = inferred type)\n');
|
|
const inferredCols = Array.from(
|
|
new Set(
|
|
Object.values(confusion).flatMap(row => Object.keys(row)),
|
|
),
|
|
).sort();
|
|
const rowKeys = Object.keys(confusion).filter(k => k !== '(no-gold)').sort();
|
|
rowKeys.push('(no-gold)');
|
|
|
|
const header = ['gold \\ inferred', ...inferredCols];
|
|
log('| ' + header.map(h => h.padEnd(14)).join(' | ') + ' |');
|
|
log('|' + header.map(() => '----------------').join('|') + '|');
|
|
for (const g of rowKeys) {
|
|
if (!confusion[g]) continue;
|
|
const row = [g, ...inferredCols.map(ic => String(confusion[g][ic] ?? 0))];
|
|
log('| ' + row.map(v => v.padEnd(14)).join(' | ') + ' |');
|
|
}
|
|
log('');
|
|
|
|
if (json) {
|
|
console.log(JSON.stringify({
|
|
overallTypeAccuracy,
|
|
overallStrictF1,
|
|
perType,
|
|
confusion,
|
|
goldTotal: gold.length,
|
|
inferredTotal: inferred.length,
|
|
}, null, 2));
|
|
}
|
|
}
|
|
|
|
main().catch(e => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|