Files
gbrain/eval/runner/types.ts
T
Garry TanandClaude Opus 4.7 629ba853ff feat(eval): Phase 2 adapter interface + EXT-1 ripgrep+BM25 baseline
Phase 2 credibility unlock: BrainBench now compares gbrain to external
baselines on the same corpus and queries. Transforms the benchmark from
internal ablation ("gbrain-graph beats gbrain-grep") to category comparison
("gbrain-graph beats classic BM25 by 32 pts P@5"). This is the #1 fix
from the 4-review arc — addresses Codex's core critique that v1's
before/after was self-referential.

Added:
  eval/runner/types.ts                      — Adapter interface (v1.1 spec)
  eval/runner/adapters/ripgrep-bm25.ts      — EXT-1 classic IR baseline
  eval/runner/adapters/ripgrep-bm25.test.ts — 11 unit tests, all pass
  eval/runner/multi-adapter.ts              — side-by-side scorer

Adapter interface (eng pass 2 spec):
  - Thin 3-method Strategy: init(rawPages, config), query(q, state), snapshot(state)
  - BrainState is opaque to runner (never inspected)
  - Raw pages passed in-memory; gold/ never crosses adapter boundary
    (structural ingestion-boundary enforcement)
  - PoisonDisposition enum reserved for future poison-resistance scoring

EXT-1 ripgrep+BM25:
  - Classic Lucene-variant IDF + k1/b tuned at standard 1.5/0.75
  - Title tokens double-weighted for entity-page slug-match bias
  - Stopword filter, alphanumeric tokenization, stable lexicographic tie-break
  - Pure in-memory inverted index — no external deps, ~100 LOC core

First side-by-side results on 240-page rich-prose corpus, 145 relational queries:

| Adapter       | P@5    | R@5    | Correct top-5 |
|---------------|--------|--------|---------------|
| gbrain-after  | 49.1%  | 97.9%  | 248/261       |
| ripgrep-bm25  | 17.1%  | 62.4%  | 124/261       |
| Delta         | +32.0  | +35.5  | +124          |

gbrain-after is the hybrid graph+grep config from PR #188. Ripgrep+BM25 is
a genuinely strong classic-IR baseline (BM25 is what Lucene/Elasticsearch
ship). gbrain's ~+32-point lead on relational queries reflects real work
by the knowledge graph layer: typed links + traversePaths surface the
correct answers in top-K that BM25 only pulls in via partial-text overlap.

Next in Phase 2: EXT-2 vector-only RAG + EXT-3 hybrid-without-graph
adapters. Both plug into the same Adapter interface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:04 +08:00

208 lines
8.8 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.
/**
* BrainBench adapter interface (v1.1 Phase 2).
*
* Adapters are configs-under-test. Each one ingests the same raw pages,
* answers the same queries, and emits ranked results. The runner treats
* BrainState as opaque — it never inspects adapter internals.
*
* Ingestion boundary: the runner passes `rawPages: Page[]` in memory.
* Adapters NEVER receive the `gold/` directory path. Gold is consumed only
* by scorers, which the runner calls separately on adapter output. This is
* structural enforcement of the "system-under-test gets only raw pages"
* contract (eng pass 2 requirement).
*
* Precedence of adapter implementations (ships across Phase 2):
* GbrainAdapter (configs AF — existing gbrain wrapped in the interface)
* RipgrepBm25Adapter (EXT-1 — strong grep-based baseline)
* VectorOnlyAdapter (EXT-2 — commodity vector RAG, same embedder as gbrain)
* HybridNoGraphAdapter(EXT-3 — gbrain hybrid with graph features disabled)
*/
// ─── Page ────────────────────────────────────────────────────────────
/**
* A raw page as the adapter sees it. Slug is the stable ID;
* compiled_truth + timeline are the prose the adapter indexes.
* Frontmatter carries loosely-typed metadata (type, title, etc.).
*/
export interface Page {
slug: string;
type: 'person' | 'company' | 'meeting' | 'concept' | 'deal' | 'project' | 'source' | 'media';
title: string;
compiled_truth: string;
timeline: string;
/** Optional additional metadata. Adapters should NOT rely on _facts —
* that field is the gold canonical source, reserved for scorers. */
frontmatter?: Record<string, unknown>;
}
// ─── Query ───────────────────────────────────────────────────────────
export type Tier =
| 'easy' // T1: single-page lookup
| 'medium' // T2: relational, graph-required
| 'hard' // T3: multi-hop + temporal
| 'adversarial' // T4: identity collisions, contradictions
| 'fuzzy' // T5: vague recall, "I know I mentioned it somewhere"
| 'externally-authored'; // T5.5: outside-researcher queries
export type ExpectedOutputType =
| 'answer-string'
| 'canonical-entity-id'
| 'cited-source-pages'
| 'time-qualified-answer'
| 'abstention'
| 'contradiction-explanation'
| 'poison-flag'
| 'confidence-score';
/**
* Gold shape varies by tier. Kept as an open-ended record; scorers
* validate the tier-specific shape. Canonical gold for relational queries
* lives under `relevant` (list of page slugs expected in top-K).
*/
export interface Gold {
relevant?: string[];
grades?: Record<string, number>;
expected_answer?: string;
expected_entity_id?: string;
expected_citations?: string[];
expected_abstention?: boolean;
expected_as_of?: string;
[key: string]: unknown;
}
export interface Query {
id: string; // q-0001 … q-0350
tier: Tier;
text: string; // natural-language query
expected_output_type: ExpectedOutputType;
gold: Gold;
/** Required for temporal queries — see eng pass 2 validator spec. */
as_of_date?: string | 'corpus-end' | 'per-source';
acceptable_variants?: string[]; // for LLM-judged outputs
known_failure_modes?: string[];
author?: string; // set for Tier 5.5 externally-authored
tags?: string[]; // 'identity-collision', 'contradiction', etc.
}
// ─── RankedDoc ──────────────────────────────────────────────────────
/**
* Adapter output per query: a ranked list of pages the adapter believes
* are relevant. Score semantics are adapter-specific; only RANK matters
* for top-K metrics. Scorers never compare scores across adapters.
*/
export interface RankedDoc {
page_id: string; // page slug
score: number; // adapter-internal relevance score (not comparable across adapters)
rank: number; // 1-based rank within this query's result list
/** Optional snippet of supporting text. Useful for citation scoring. */
snippet?: string;
}
// ─── PoisonDisposition ──────────────────────────────────────────────
/**
* Per eng pass 2 spec. Each poison item in the corpus is tagged with an
* `expected_behavior` in gold; the adapter reports which behavior it
* chose via getPoisonDisposition(). Scorer matches adapter disposition
* against expected to compute poison_resistance.
*/
export type PoisonDisposition =
| 'exclude' // never ingested; page not in index
| 'quarantine' // ingested but tagged; not returned in normal queries
| 'warn' // retrievable with a warning flag on results
| 'ignore' // indexed but not used for factual answers
| 'mark-untrusted';// provenance metadata flags source as untrusted
// ─── AdapterConfig ──────────────────────────────────────────────────
/**
* Per-adapter configuration knobs. Adapter implementations extend this
* with their own fields (e.g. GbrainAdapter takes `config: 'A' | ... | 'F'`).
*/
export interface AdapterConfig {
/** Human-readable adapter name (shown in scorecards). */
name: string;
/** Top-K truncation the scorer uses (adapter is free to return more). */
k?: number;
/** Adapter-specific options. */
[key: string]: unknown;
}
// ─── BrainState ─────────────────────────────────────────────────────
/**
* OPAQUE to the runner. Each adapter internally defines its own shape:
* RipgrepBm25Adapter BrainState = an in-memory inverted index + doc-length table
* GbrainAdapter BrainState = a PGLite engine handle + .db file path
* VectorOnlyAdapter BrainState = embedding index + cached vectors
*
* The runner only uses it as an adapter-internal state handle. Never
* inspected, serialized, or cross-type-checked. This is the structural
* boundary that prevents a runner bug from leaking implementation details
* across adapters.
*/
export type BrainState = unknown;
// ─── Adapter interface ──────────────────────────────────────────────
export interface Adapter {
/** The registered adapter name, e.g. "gbrain-a" | "ripgrep-bm25". */
readonly name: string;
/**
* Ingest the raw pages and build internal state. Called ONCE per
* benchmark run. Adapters that need warming (embeddings, indexes) do
* that work here.
*/
init(rawPages: Page[], config: AdapterConfig): Promise<BrainState>;
/**
* Answer a single query. Adapters return their top results in rank
* order. The scorer applies the query's `k` cutoff; adapters are free
* to return fewer than k (with a shorter list).
*/
query(q: Query, state: BrainState): Promise<RankedDoc[]>;
/**
* Persist the brain state to a filesystem path (for reproducibility +
* cross-task state sharing). Returns the path. Optional — adapters
* that don't support snapshotting can return an empty string.
*/
snapshot?(state: BrainState): Promise<string>;
/**
* Per-page poison disposition: what did the adapter do with each
* poison item? Scorer compares to gold's `expected_behavior`.
* Adapters that don't have a poison path return an empty map.
*/
getPoisonDisposition?(state: BrainState): Record<string, PoisonDisposition>;
}
// ─── Scorer helpers ─────────────────────────────────────────────────
/** Standard top-K slice; helper since every scorer needs it. */
export function topK(docs: RankedDoc[], k: number): RankedDoc[] {
return docs.slice(0, k);
}
/** Precision@k: fraction of top-k that are in relevant set. */
export function precisionAtK(docs: RankedDoc[], relevant: Set<string>, k: number): number {
const topDocs = topK(docs, k);
if (topDocs.length === 0) return 0;
let hits = 0;
for (const d of topDocs) if (relevant.has(d.page_id)) hits++;
return hits / topDocs.length;
}
/** Recall@k: fraction of relevant found in top-k. */
export function recallAtK(docs: RankedDoc[], relevant: Set<string>, k: number): number {
if (relevant.size === 0) return 0;
const topDocs = topK(docs, k);
let hits = 0;
for (const d of topDocs) if (relevant.has(d.page_id)) hits++;
return hits / relevant.size;
}