mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(eval-longmemeval): --by-type flag + question field + resume-replace
Per-question JSONL row gains `question`, `question_type`, and (when
ground truth is available) `recall_hit` — additive fields that existing
consumers (LongMemEval's `evaluate_qa.py`) ignore. New `--by-type` flag
emits a `{kind:"by_type_summary", recall_by_type, aggregate}` line at
the end of the output, resume-safe: rebuilt from existing rows so the
final aggregate covers cumulative resumed questions, prior summary at
the tail replaced rather than appended. New `--by-type-floor F` exits
non-zero per breached question_type. Empty-bucket guard emits null rate
not NaN. Exports `buildByTypeSummary` + `emitByTypeSummary` +
`seedRecallByTypeFromFile` for unit testing.
* feat(eval-cross-modal): --batch flag + semaphore + DI seam
Adds `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT]
[--yes]` to the existing eval cross-modal command. Mutually exclusive
with --task. Reads LongMemEval-shape JSONL output, filters by_type_summary
rows automatically, fans out via a new `runWithLimit<T>` semaphore
primitive (default --concurrent 3 x 3 model slots = 9 simultaneous calls;
below tier-1 rate limits on all 3 providers). Pre-flight cost estimate
refuses past --max-usd (default $5) unless --yes. Per-question receipts
written to a per-batch tempdir + deleted at end of run so
~/.gbrain/eval-receipts/ stays clean; summary receipt inlines verdicts.
Exit precedence (new batch-level policy, not inherited from aggregate.ts):
ERROR > FAIL > INCONCLUSIVE > PASS — any per-question runtime error exits 2.
New `runEvalCrossModal(args, opts?: {runEval?})` DI seam mirrors the
existing eval-longmemeval pattern. Tests pass a stub runEval so unit tests
don't need API keys; gateway availability check is also skipped when
opts.runEval is provided. Pinned by 17 cases.
* test: hermetic qrels retrieval gate against synthetic basis-vector corpus
Adds test/eval-replay-gate.test.ts as a unit-shard test (NOT under
test/e2e/ — the unit-shard CI matrix runs every PR via bun test;
test/e2e/ is fixed-file). Seeds a PGLite engine with synthetic
placeholder-name pages whose embeddings are basis vectors (same pattern
as test/e2e/search-quality.test.ts:23-28) so retrieval is hermetic — no
API keys, no DATABASE_URL, fully deterministic.
The qrels fixture at test/fixtures/eval-baselines/qrels-search.json has
12 hand-curated queries; each maps to a ranked list of relevant slugs +
`first_relevant_slug` (expected top-1). For each query, the gate asserts
`top1_match_rate >= 0.80` AND `recall_at_10 >= 0.85`. Env-overridable
floors via GBRAIN_REPLAY_GATE_TOP1_FLOOR / GBRAIN_REPLAY_GATE_RECALL_FLOOR
through withEnv(). Gate-fire prints per-query HIT/miss + recall to stderr.
When ranking changes intentionally move expected slugs, edit
qrels-search.json directly with a 'Why:' line in the commit body —
documented in docs/eval-bench.md.
scripts/check-test-real-names.sh allowlist gains 6 entries for the
privacy-grep regression guard inside the test, which must literally
spell the names it forbids to assert they're NOT in the fixture (same
meta-rule exception as skillpack-harvest privacy tests).
* feat(autopilot): opt-in nightly cross-modal quality probe + doctor check
Composes `gbrain eval longmemeval --by-type` + `gbrain eval cross-modal
--batch` into a 24h-cadenced quality check. Default DISABLED — opt-in via
`gbrain config set autopilot.nightly_quality_probe.enabled true` so new
users don't discover background API spend.
src/core/cycle/nightly-quality-probe.ts ships the phase implementation
with a full NightlyProbeDeps DI surface (isEnabled, hasEmbeddingProvider,
resolveMaxUsd, resolveRepoRoot, runLongMemEval, runCrossModalBatch, now)
so tests stub every external effect — no PGLite, no real LLM calls.
Pure `shouldRunNightly(now, recentEvents, windowMs?)` rate-limit fn.
src/core/audit-quality-probe.ts is the ISO-week-rotated JSONL writer
(mirrors audit-slug-fallback.ts; honors GBRAIN_AUDIT_DIR). One event per
run: outcome (pass/fail/inconclusive/error/budget_exceeded/rate_limited/
no_embedding_key), exit code, pass/fail/error counts, est_cost_usd,
fixture_sha8.
src/commands/doctor.ts gains a `nightly_quality_probe_health` check:
SKIPPED with paste-ready enable command when disabled; OK with timestamp
when all PASS in last 7 days; WARN with per-outcome counts when any
FAIL/ERROR/BUDGET_EXCEEDED. Extracted as pure
`computeNightlyQualityProbeHealthCheck(probeEnabled, events)` for
unit testing.
test/fixtures/longmemeval-nightly.jsonl is a 10-question placeholder
dataset (synthetic names only) distinct from the existing 5-question
mini fixture so the probe has consistent regression signal.
Real expected cost: ~$0.35/night = ~$10.50/month. Worst-case at
default $5 cap: $150/month.
Pinned by 21 cases in test/nightly-quality-probe.test.ts covering the
rate-limit pure function, every outcome branch, and all 7 branches of
the doctor check.
Autopilot scheduler wiring deferred to v0.41+ — the phase is callable
in isolation today (via the DI surface); cycle-loop dispatcher
integration filed in TODOS.md as a follow-up.
* docs: document Track D eval surfaces + file v0.41+ follow-up TODOs
docs/eval-bench.md gains a 'v0.40.1.0 Track D — Eval infrastructure'
section covering: --by-type usage + resume-replace semantics, the
hermetic qrels gate workflow + 'Why:' commit-body refresh convention,
--batch end-to-end with cost-bound + concurrency knobs, and the opt-in
nightly probe enable workflow + cost ceiling.
TODOS.md files two follow-ups:
- v0.41+: contributor-mode CI capture for BrainBench-Real replay gate
(the deferred original Task 2 design — replay against real captured
queries is more valuable than synthetic qrels long-term, but needs CI
secret + nightly capture pipeline + commit automation; deferred to a
dedicated wave)
- v0.41+: wire the nightly quality probe into autopilot scheduling
(phase callable in isolation today; cycle-loop dispatcher integration
is a ~3-hour follow-up)
CLAUDE.md Key Files annotations extended for the four lanes:
eval-longmemeval gains the --by-type description, eval-cross-modal
gains the --batch + DI seam description, new entries for the qrels
gate test + the nightly probe + audit-quality-probe writer.
* chore: bump version and changelog (v0.40.1.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(eval): close 4 codex-flagged eval-integrity bugs
Codex adversarial review on the Track D wave found 4 real ways the new
eval-gate code could silently bypass its gates. Each fix below either
counts what was previously dropped, fails fast on a parser edge case,
or enforces a gate that was previously skipped on an early-return path.
CDX-1: cross-modal --batch silently dropped failed/corrupt LongMemEval
rows. `gbrain eval longmemeval` emits {error:..., hypothesis:''} when
runOneQuestion throws; the batch reader's missing-field skip threw those
rows away, shrinking the denominator. A green eval on a subset is now
impossible:
- eval-longmemeval.ts: error rows now carry `question` + `question_type`
so the batch consumer can identify them as upstream failures, not
skip them as malformed.
- eval-cross-modal.ts: readBatchRows now returns {rows, upstream_errors,
malformed_count}. Upstream errors fold into per_question with verdict
'upstream_error'. BatchSummary gains `upstream_error_count` and
`malformed_count`. ERROR exit precedence widens to include both, so
any upstream failure exits 2.
CDX-2: --limit 0 was a direct CI bypass — zero-row check fired before
slicing, then the empty result fell through to verdict='pass'. Fixed
with a hard `limit >= 1` check.
CDX-3: --resume-from + --by-type-floor was a real gate skip. When a
prior run had every question answered, the early "nothing to do" return
fired BEFORE summary emission and floor enforcement. Now the no-op
resume path still seeds recallByType from the existing file, emits the
by_type_summary at the tail, and runs the floor gate.
CDX-5: doctor nightly_quality_probe_health only flagged fail / error /
budget_exceeded as warn. no_embedding_key / rate_limited / inconclusive
were silently reported as PASS — hiding misconfigurations and queue
backpressure. The bad-event filter is now `outcome !== 'pass'`, and the
counts string surfaces every bucket so the operator sees exactly what
went wrong.
scripts/check-privacy.sh: adds test/eval-replay-gate.test.ts to the
allowlist (the qrels test's privacy-grep regression guard literally
names what it forbids, same meta-rule exception as the existing
test/recency-decay.test.ts + skillpack-harvest allowlist entries).
Pinned by 8 new regression cases across eval-longmemeval (CDX-3),
eval-cross-modal-batch (CDX-1 + CDX-2), and nightly-quality-probe
(CDX-5). 76 Track D tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
/**
|
|
* v0.40.1.0 Track D / T5 — Hermetic retrieval qrels gate.
|
|
*
|
|
* Gates PRs touching src/core/search/** against a hand-curated qrels fixture
|
|
* (test/fixtures/eval-baselines/qrels-search.json). Fully hermetic via basis-
|
|
* vector embeddings — no API keys, no DATABASE_URL, no network.
|
|
*
|
|
* This is the structural-fix replacement for the original Task 2 design
|
|
* (eval replay against captured eval_candidates), which Codex caught as
|
|
* non-functional in CI (eval-export bypasses op-layer capture; replay
|
|
* re-embeds queries via gateway which needs an API key). The qrels approach
|
|
* tests retrieval QUALITY directly, not stability.
|
|
*
|
|
* Refresh procedure: when ranking changes are intentional, edit qrels-search.json
|
|
* with a `Why:` line in the commit body (D4 convention). Do NOT silently
|
|
* rubber-stamp baseline drift.
|
|
*
|
|
* Env overrides (via withEnv() per CLAUDE.md R1):
|
|
* GBRAIN_REPLAY_GATE_TOP1_FLOOR (default 0.80)
|
|
* GBRAIN_REPLAY_GATE_RECALL_FLOOR (default 0.85)
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import type { ChunkInput } from '../src/core/types.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Canonical PGLite block (CLAUDE.md R3+R4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture loader + helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface QrelQuery {
|
|
query_id: string;
|
|
query: string;
|
|
/** Basis-vector dimension this query embeds at — same dim as first_relevant_slug. */
|
|
embedding_dim: number;
|
|
relevant_slugs: string[];
|
|
first_relevant_slug: string;
|
|
}
|
|
|
|
interface QrelFixture {
|
|
schema_version: 1;
|
|
queries: QrelQuery[];
|
|
}
|
|
|
|
function loadFixture(): QrelFixture {
|
|
const path = join(import.meta.dir, 'fixtures', 'eval-baselines', 'qrels-search.json');
|
|
const fix = JSON.parse(readFileSync(path, 'utf8')) as QrelFixture;
|
|
return fix;
|
|
}
|
|
|
|
/** Basis vector with 1.0 at `idx` and 0.0 elsewhere. Mirrors search-quality.test.ts. */
|
|
function basisEmbedding(idx: number, dim = 1536): Float32Array {
|
|
const emb = new Float32Array(dim);
|
|
emb[idx % dim] = 1.0;
|
|
return emb;
|
|
}
|
|
|
|
/**
|
|
* Seed each relevant slug with a chunk whose embedding aligns with the
|
|
* query's basis dimension. The FIRST relevant slug gets a stronger signal
|
|
* (compiled_truth) so it ranks top-1; subsequent relevant slugs get timeline
|
|
* chunks at the same direction so they appear in top-K recall but not top-1.
|
|
*
|
|
* Non-relevant slugs from OTHER queries serve as noise — they're at
|
|
* different basis dimensions so they orthogonally don't match.
|
|
*/
|
|
async function seedCorpus(fix: QrelFixture): Promise<void> {
|
|
const seenSlugs = new Set<string>();
|
|
for (const q of fix.queries) {
|
|
for (let i = 0; i < q.relevant_slugs.length; i++) {
|
|
const slug = q.relevant_slugs[i];
|
|
if (seenSlugs.has(slug)) continue;
|
|
seenSlugs.add(slug);
|
|
// First relevant: top-1 should win, so embed at THIS query's dim with
|
|
// chunk_source=compiled_truth (gets the source boost). Subsequent
|
|
// relevant slugs use timeline source so they outrank pure noise but
|
|
// don't beat the first relevant.
|
|
const isFirst = slug === q.first_relevant_slug;
|
|
const isPersonOrCompany = slug.startsWith('people/') || slug.startsWith('companies/');
|
|
await engine.putPage(slug, {
|
|
type: isPersonOrCompany ? (slug.startsWith('people/') ? 'person' : 'company') : 'note',
|
|
title: slug.split('/').pop() ?? slug,
|
|
compiled_truth: isFirst ? `Primary content about ${q.query}` : '',
|
|
timeline: !isFirst ? `Mentioned in context of ${q.query}` : '',
|
|
});
|
|
const chunk: ChunkInput = {
|
|
chunk_index: 0,
|
|
chunk_text: isFirst
|
|
? `Primary content about ${q.query}`
|
|
: `Mentioned in context of ${q.query}`,
|
|
chunk_source: isFirst ? 'compiled_truth' : 'timeline',
|
|
embedding: basisEmbedding(q.embedding_dim),
|
|
token_count: 10,
|
|
};
|
|
await engine.upsertChunks(slug, [chunk]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run all qrels queries against the seeded engine, compute top-1 match rate
|
|
* and recall@10. Pure data: returns the two metrics so the gate logic stays
|
|
* separate from the measurement logic.
|
|
*/
|
|
async function measureGate(
|
|
fix: QrelFixture,
|
|
): Promise<{ top1Rate: number; recallAt10: number; perQuery: Array<{ id: string; top1Match: boolean; recall: number }> }> {
|
|
let top1Hits = 0;
|
|
let recallSum = 0;
|
|
const perQuery: Array<{ id: string; top1Match: boolean; recall: number }> = [];
|
|
for (const q of fix.queries) {
|
|
const results = await engine.searchVector(basisEmbedding(q.embedding_dim), { limit: 10 });
|
|
const top1Slug = results[0]?.slug;
|
|
const top1Match = top1Slug === q.first_relevant_slug;
|
|
if (top1Match) top1Hits++;
|
|
const retrievedSlugs = new Set(results.map(r => r.slug));
|
|
const relevantInTop10 = q.relevant_slugs.filter(s => retrievedSlugs.has(s)).length;
|
|
const recall = q.relevant_slugs.length === 0 ? 0 : relevantInTop10 / q.relevant_slugs.length;
|
|
recallSum += recall;
|
|
perQuery.push({ id: q.query_id, top1Match, recall });
|
|
}
|
|
return {
|
|
top1Rate: top1Hits / fix.queries.length,
|
|
recallAt10: recallSum / fix.queries.length,
|
|
perQuery,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants (env-overridable)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_TOP1_FLOOR = 0.80;
|
|
const DEFAULT_RECALL_FLOOR = 0.85;
|
|
|
|
function resolveFloors(): { top1: number; recall: number } {
|
|
const t = process.env.GBRAIN_REPLAY_GATE_TOP1_FLOOR;
|
|
const r = process.env.GBRAIN_REPLAY_GATE_RECALL_FLOOR;
|
|
return {
|
|
top1: t !== undefined ? Number(t) : DEFAULT_TOP1_FLOOR,
|
|
recall: r !== undefined ? Number(r) : DEFAULT_RECALL_FLOOR,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('eval replay gate — hermetic retrieval qrels (v0.40.1.0 Track D / T5)', () => {
|
|
test('current ranking meets default floors (top1 >= 0.80 AND recall@10 >= 0.85)', async () => {
|
|
const fix = loadFixture();
|
|
await seedCorpus(fix);
|
|
const { top1Rate, recallAt10, perQuery } = await measureGate(fix);
|
|
const { top1, recall } = resolveFloors();
|
|
|
|
// Surface per-query results when the gate trips so the operator sees which
|
|
// queries regressed without re-running with --verbose.
|
|
if (top1Rate < top1 || recallAt10 < recall) {
|
|
process.stderr.write(`[eval replay gate] BREACH:\n`);
|
|
process.stderr.write(` top1=${top1Rate.toFixed(3)} (floor ${top1.toFixed(3)})\n`);
|
|
process.stderr.write(` recall@10=${recallAt10.toFixed(3)} (floor ${recall.toFixed(3)})\n`);
|
|
for (const q of perQuery) {
|
|
process.stderr.write(` ${q.id}: top1=${q.top1Match ? 'HIT' : 'miss'} recall=${q.recall.toFixed(2)}\n`);
|
|
}
|
|
}
|
|
|
|
expect(top1Rate).toBeGreaterThanOrEqual(top1);
|
|
expect(recallAt10).toBeGreaterThanOrEqual(recall);
|
|
});
|
|
|
|
test('env-overridable floors via GBRAIN_REPLAY_GATE_TOP1_FLOOR / GBRAIN_REPLAY_GATE_RECALL_FLOOR', async () => {
|
|
const fix = loadFixture();
|
|
await seedCorpus(fix);
|
|
|
|
// Set an impossible-to-meet floor and verify the resolver picks it up.
|
|
// We don't ASSERT failure here (that would make the test flaky against
|
|
// any future ranking improvement); we just assert the resolver respects
|
|
// env vars.
|
|
await withEnv({ GBRAIN_REPLAY_GATE_TOP1_FLOOR: '0.999', GBRAIN_REPLAY_GATE_RECALL_FLOOR: '0.999' }, async () => {
|
|
const { top1, recall } = resolveFloors();
|
|
expect(top1).toBeCloseTo(0.999, 3);
|
|
expect(recall).toBeCloseTo(0.999, 3);
|
|
});
|
|
|
|
// After exit, defaults restored.
|
|
const { top1, recall } = resolveFloors();
|
|
expect(top1).toBeCloseTo(DEFAULT_TOP1_FLOOR, 3);
|
|
expect(recall).toBeCloseTo(DEFAULT_RECALL_FLOOR, 3);
|
|
});
|
|
|
|
test('seeded corpus produces deterministic top-1 results (gate sanity)', async () => {
|
|
const fix = loadFixture();
|
|
await seedCorpus(fix);
|
|
// Run twice; same fixture must produce same per-query top-1.
|
|
const a = await measureGate(fix);
|
|
const b = await measureGate(fix);
|
|
expect(a.top1Rate).toBe(b.top1Rate);
|
|
expect(a.recallAt10).toBe(b.recallAt10);
|
|
for (let i = 0; i < a.perQuery.length; i++) {
|
|
expect(a.perQuery[i]).toEqual(b.perQuery[i]);
|
|
}
|
|
});
|
|
|
|
test('fixture schema sanity — every query has required fields', () => {
|
|
const fix = loadFixture();
|
|
expect(fix.schema_version).toBe(1);
|
|
expect(fix.queries.length).toBeGreaterThanOrEqual(10);
|
|
for (const q of fix.queries) {
|
|
expect(typeof q.query_id).toBe('string');
|
|
expect(typeof q.query).toBe('string');
|
|
expect(typeof q.embedding_dim).toBe('number');
|
|
expect(Array.isArray(q.relevant_slugs)).toBe(true);
|
|
expect(q.relevant_slugs.length).toBeGreaterThan(0);
|
|
expect(q.relevant_slugs).toContain(q.first_relevant_slug);
|
|
}
|
|
});
|
|
|
|
test('no real names in qrels fixture (CLAUDE.md privacy rule)', () => {
|
|
// Smoke-grep: scan the fixture for known-real-name patterns. This is a
|
|
// belt-and-suspenders check beyond scripts/check-privacy.sh.
|
|
const raw = readFileSync(
|
|
join(import.meta.dir, 'fixtures', 'eval-baselines', 'qrels-search.json'),
|
|
'utf8',
|
|
);
|
|
// Block list — names that appeared in older E2E fixtures and were
|
|
// explicitly called out by Codex (#9) as privacy-violating.
|
|
const blockList = ['Pedro Franceschi', 'Brex', 'Wintermute', 'Garry Tan', 'Y Combinator', 'YC'];
|
|
for (const name of blockList) {
|
|
expect(raw).not.toContain(name);
|
|
}
|
|
});
|
|
});
|