Files
gbrain/test/e2e/eval-loop.test.ts
T
bf52e1049b v0.41.1.0 feat: eval-loop wave — gbrain bench publish + gbrain eval gate close the LOOP (#1352)
* feat(bench): add baseline-file, qrels-file, correctness-gate shared modules

v0.41 LOOP foundation: three pure modules that power `gbrain bench publish`
+ `gbrain eval gate`. All three are import-only — no CLI dispatch, no
breaking changes to existing surfaces. Tested in isolation (34 cases).

- src/core/bench/baseline-file.ts (~190 LOC): single source of truth for
  the .baseline.ndjson file shape. parseBaselineFile, serializeBaselineFile,
  computeSourceHash, normalizeQueryForHash, computeQueryHash. Body rows
  stamped with schema_version: 1 so existing eval-replay parser accepts
  them unchanged.
- src/core/bench/qrels-file.ts (~210 LOC): pure parser + math for the
  .qrels.json shape. Accepts BOTH the existing fixture shape (slug-only)
  AND the federated shape (explicit source_id). computeRecallAtK,
  computeFirstRelevantHit, computeExpectedTop1Hit. Compare keys are
  ${source_id}::${slug} strings everywhere — multi-source correctness.
- src/core/bench/correctness-gate.ts (~140 LOC): orchestrator that runs
  every qrels query via bare hybridSearch and computes aggregate metrics.
  Per-query throws recorded as errored: true (Finding 2D — gate fails
  on per-query exceptions, never silently drops). Injectable searchFn
  test seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval-replay): skip baseline_metadata header + expose replayCore

Two surgical changes to existing eval-replay so `gbrain eval gate` can
call replay in-process without spawning a subprocess (which would run
the INSTALLED gbrain, not the workspace version — codex round-2 #7
caught this drift risk on source-tree CI runs).

- parseNdjson now skips lines where _kind === 'baseline_metadata'.
  Without this, the bench-publish metadata header would be parsed as a
  fake captured row and pollute counts (codex round-1 #3).
- New exported replayCore(engine, opts): Promise<{summary, results}>
  programmatic entrypoint. Existing CLI runEvalReplay now wraps it.
  ReplaySummary interface also exported for eval-gate consumers.

IRON-RULE regression pinned by test/eval-replay-metadata-skip.test.ts
(2 cases): header skipped from row counts; malformed rows still rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(bench): add `gbrain bench publish` CLI verb

The LOOP-closing verb. Turns captured eval rows (gbrain eval export) into
a baseline file (.baseline.ndjson) consumed by gbrain eval gate --baseline.

Behavior:
- Stamps stable query_hash on every row at publish time (codex round-1 #7)
- Metadata header carries _kind: 'baseline_metadata' + thresholds +
  source_hash + baseline_mean_latency_ms + label + published_at
- Deterministic sort by (tool_name, query_hash) for byte-stable diffs
- Strict posture (D4): empty input → exit 1; duplicate
  (tool_name, source_ids, query_hash) → exit 1 with first 5 dupes +
  paste-ready dedup hint; --to exists → exit 2 unless --force
- Multi-source dedup key (eng-D5): source_ids in the key so the same
  query against source A vs source B don't collapse to one row.
  Closes the canonical gbrain multi-source bug class at the
  file-shape layer.
- Audit JSONL at ~/.gbrain/audit/bench-publish-YYYY-Www.jsonl via
  shared audit-writer primitive.

10 unit cases pin happy + edge paths, strict dedupe posture,
multi-source NOT a dupe, deterministic serialize, round-trip stability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): add `gbrain eval gate` two-gate CI verb

The CI-gating verb. Two gating paths (CEO D8 + eng D6/D7):

- Regression gate (--baseline X.baseline.ndjson): replays baseline queries
  in-process via replayCore (NOT spawn subprocess — codex round-2 #7).
  Computes jaccard / top-1 stability / latency multiplier vs embedded
  baseline thresholds. Catches retrieval REGRESSIONS during refactors.
- Correctness gate (--qrels Y.qrels.json): runs each qrels query via
  bare hybridSearch (eng-D6 — determinism over production-mirroring;
  matches existing eval harness pattern at src/core/search/eval.ts:242).
  Computes recall@K + first_relevant_hit_rate + expected_top1_hit_rate.
  Catches retrieval QUALITY drops against known-right answers.

Both can be passed together; both must pass for verdict 'pass'. At least
one required (usage error otherwise).

Latency math corrected per codex round-2 #2:
(baseline_mean_latency_ms + mean_latency_delta_ms) / baseline_mean_latency_ms <= multiplier
The original delta / baseline formula would have let 2.5x slowdowns pass
at multiplier=2.0.

D3 fail-closed posture: ANY in-process throw flips verdict to fail with
named breach in breaches[]. Never silently exits 0.

Exit codes: 0 PASS, 1 FAIL (regression OR throw), 2 USAGE.

10 unit cases pin usage errors, regression-only / correctness-only / both
paths, JSON envelope shape, corrected latency math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(autopilot): wire nightly quality probe (opt-in, off by default)

Closes the v0.40.1.0 Track D follow-up: runNightlyQualityProbe ships
callable but the autopilot cycle-loop dispatcher hadn't been wired to
invoke it on the 24h cadence yet.

- src/commands/autopilot.ts (tick body): invokes runNightlyQualityProbe
  when cfg.autopilot.nightly_quality_probe.enabled === true.
  Per eng-D10 (codex round-1 #11): NO scheduler-side rate-limit check.
  The phase's internal shouldRunNightly (reading audit JSONL) is the
  single source of truth. Probe call wrapped in try/catch that logs to
  stderr and DOES NOT bump consecutiveErrors (probe failure is
  informational, never crashes the loop).
- src/core/cycle/nightly-probe-adapters.ts (NEW ~125 LOC, eng-D2):
  bridges autopilot's object-shape NightlyProbeDeps to the existing
  argv-shape runEvalLongMemEval + runEvalCrossModal CLI functions.
  Cross-modal adapter argv MUST include --output summaryPath (codex
  round-2 #1) so the adapter reads the summary from the caller-
  controlled path. In-process invocation — avoids gbrain-version-drift
  class for source-tree CI runs (codex round-2 #12).
- src/core/config.ts: added autopilot.nightly_quality_probe to
  GBrainConfig interface (typecheck gate).

Default OFF — opt-in via:
  gbrain config set autopilot.nightly_quality_probe.enabled true

Cost cap default $5/run × 30 nights ≈ $150/month worst-case per brain.
Expected real cost ~$0.35/night × 30 ≈ $10.50/month.

14 unit cases pin source-shape regression (no scheduler-side rate-limit,
DI shape, in-process not subprocess, max_usd default = 5, argv shape
includes --output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): full capture → publish → gate LOOP integration (PGLite)

Hermetic end-to-end test of the v0.41 LOOP per eng-D5. Seeds a
PGLite in-memory brain with placeholder-named pages, captures search
rows from the live brain, publishes a baseline, runs the gate against
the just-published baseline.

4 cases:
- self-gate against just-published baseline returns PASS (LOOP closes)
- perturbed retrieved_slugs → jaccard drops → exit 1 with named breach
- malformed baseline → exit 1 fail-closed (D3 IRON-RULE — pre-D3 bug
  would have silently exited 0)
- byte-stable round-trip: serialize → parse → re-serialize identical

Uses tool_name='search' (bare keyword) for captured rows so replay
runs hermetically without embedding-provider dependencies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(eval-longmemeval): bump warm-create p50 gate 1500ms → 2500ms

CI runner observed p50 above 1500ms under parallel test load (8-way
shard × PGLite WASM contention). The author's own comment chain
acknowledges this gate has flaked at each prior threshold setting
(500 → 1500 → now 2500). 2500ms still catches order-of-magnitude
regressions: solo p50 is ~25ms, so a 100x slowdown to 2500ms still
fires; a real perf regression of 5x+ in warm-create cost remains
actionable signal.

Caught by CI test shard 2 on PR #1352 (v0.41.0.0). Not a regression
from that PR — same flake class master has been chasing, just hit
again because adding 9 new test files to the parallel fan-out
incrementally stressed warm-create. Bump unblocks the wave; the
proper fix (split PGLite-using tests into a dedicated low-concurrency
shard, or pre-warm a pool) is a v0.42+ test-infra task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version 0.41.0.0 → 0.41.1.0

Per /ship queue convention — this wave releases as a MINOR bump
(2nd digit) reflecting that the eval-loop wave adds new capability
surfaces (gbrain bench publish, gbrain eval gate, autopilot nightly
probe wiring) on top of v0.41's already-shipped feature set.

VERSION + package.json + CHANGELOG header + "To take advantage" line
all updated together. Trio agrees on 0.41.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:50:08 -07:00

275 lines
9.8 KiB
TypeScript

/**
* e2e LOOP test (v0.41 / eng-D5).
*
* Exercises the full capture → export → publish → gate chain in one
* process against PGLite in-memory. Hermetic — no LLM calls, no Docker,
* no DATABASE_URL required.
*
* Strategy: use `tool_name: 'search'` (bare keyword) for the captured
* rows so replay calls `engine.searchKeyword()` which doesn't need
* embeddings. Basis-vector embeddings are seeded for future vector tests
* but the LOOP-correctness assertions ride the keyword path.
*
* 4 cases per the plan:
* 1. self-gate passes (regression-only path)
* 2. perturbed-row gate fails with named breach (regression-only path)
* 3. D3 fail-closed: malformed baseline → exit 1 with breach (not silent pass)
* 4. Round-trip: published baseline parses back byte-identically
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import type { ChunkInput, EvalCandidateInput } from '../../src/core/types.ts';
import { buildBaselineFromInput } from '../../src/commands/bench-publish.ts';
import {
parseBaselineFile,
serializeBaselineFile,
} from '../../src/core/bench/baseline-file.ts';
import { runEvalGate } from '../../src/commands/eval-gate.ts';
let engine: PGLiteEngine;
function basisEmbedding(idx: number, dim = 1536): Float32Array {
const emb = new Float32Array(dim);
emb[idx % dim] = 1.0;
return emb;
}
// Capture a search via the real engine.searchKeyword + build an
// EvalCandidateInput row that mirrors what `gbrain eval export` would write.
async function captureSearch(query: string, latency_ms = 100): Promise<EvalCandidateInput> {
const t0 = Date.now();
const results = await engine.searchKeyword(query);
const observed = Date.now() - t0;
return {
tool_name: 'search',
query,
retrieved_slugs: results.map(r => r.slug),
retrieved_chunk_ids: results.map(r => r.chunk_id),
source_ids: ['default'],
expand_enabled: null,
detail: null,
detail_resolved: null,
vector_enabled: false,
expansion_applied: false,
latency_ms: latency_ms || observed,
remote: false,
job_id: null,
subagent_id: null,
};
}
// process.exit hijacker for tests that exercise the CLI dispatcher.
function withExitCapture<T>(fn: () => Promise<T>): Promise<{ exitCode: number | null; result?: T }> {
const realExit = process.exit;
let captured: number | null = null;
process.exit = ((code?: number) => {
captured = code ?? 0;
throw new Error('__test_exit__');
}) as typeof process.exit;
return (async () => {
try {
const result = await fn();
return { exitCode: captured, result };
} catch (e) {
if (e instanceof Error && e.message === '__test_exit__') {
return { exitCode: captured };
}
throw e;
} finally {
process.exit = realExit;
}
})();
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed 4 placeholder pages with distinguishable content. Pages chosen
// so each captured query hits a unique single page → top1 is stable.
await engine.putPage('people/alice-example', {
type: 'person',
title: 'Alice Example',
compiled_truth: 'Alice is a fintech founder building payments infrastructure for emerging markets.',
timeline: '2026-01-15: Met Alice at example meetup.',
});
await engine.upsertChunks('people/alice-example', [
{
chunk_index: 0,
chunk_text: 'Alice is a fintech founder building payments infrastructure for emerging markets.',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(0),
token_count: 15,
},
]);
await engine.putPage('people/bob-example', {
type: 'person',
title: 'Bob Example',
compiled_truth: 'Bob is an AI safety researcher working on alignment.',
timeline: '2026-02-10: Bob shared alignment paper.',
});
await engine.upsertChunks('people/bob-example', [
{
chunk_index: 0,
chunk_text: 'Bob is an AI safety researcher working on alignment.',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(1),
token_count: 12,
},
]);
await engine.putPage('companies/widget-co-example', {
type: 'company',
title: 'Widget Co Example',
compiled_truth: 'Widget Co manufactures industrial widgets for healthcare verticals.',
timeline: '2026-03-01: Widget Co announced Series A.',
});
await engine.upsertChunks('companies/widget-co-example', [
{
chunk_index: 0,
chunk_text: 'Widget Co manufactures industrial widgets for healthcare verticals.',
chunk_source: 'compiled_truth',
embedding: basisEmbedding(2),
token_count: 12,
},
]);
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
describe('e2e LOOP: capture → publish → gate against self', () => {
test('case 1: self-gate against just-published baseline returns PASS', async () => {
const dir = mkdtempSync(join(tmpdir(), 'eval-loop-'));
try {
// Step 1: capture three search queries against the live brain.
const captured = await Promise.all([
captureSearch('fintech'),
captureSearch('alignment'),
captureSearch('widgets'),
]);
// Sanity: keyword search returned at least one row for each (else
// the test corpus isn't seeded properly and the LOOP can't loop).
for (const c of captured) {
expect(c.retrieved_slugs.length).toBeGreaterThan(0);
}
// Step 2: publish a baseline from the captured rows.
const baselineFile = buildBaselineFromInput(captured, {
label: 'e2e-loop-self-test',
});
const baselinePath = join(dir, 'self.baseline.ndjson');
writeFileSync(baselinePath, serializeBaselineFile(baselineFile));
// Step 3: gate against the baseline. Since the brain hasn't changed,
// self-replay should produce identical retrieval → jaccard=1.0,
// top1=1.0 → verdict PASS → exit 0.
const out = await withExitCapture(() =>
runEvalGate(engine, ['--baseline', baselinePath, '--json']),
);
// Exit 0 is the unwrapped happy path (no process.exit call).
// Some assertion environments will have null exitCode meaning "did not
// call process.exit", which is the success path.
expect(out.exitCode).toBe(null);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
});
describe('e2e LOOP: perturbed baseline → gate FAILS with named breach', () => {
test('case 2: perturbed retrieved_slugs → jaccard drops → exit 1', async () => {
const dir = mkdtempSync(join(tmpdir(), 'eval-loop-'));
try {
const captured = await Promise.all([
captureSearch('fintech'),
captureSearch('alignment'),
captureSearch('widgets'),
]);
// Perturb: invent slugs that the brain WILL NOT return. Replay will
// see zero overlap → low jaccard → breach.
const perturbed = captured.map(c => ({
...c,
retrieved_slugs: ['fake/slug-not-in-brain-1', 'fake/slug-not-in-brain-2'],
}));
const baselineFile = buildBaselineFromInput(perturbed, {
label: 'e2e-loop-perturbed',
});
const baselinePath = join(dir, 'perturbed.baseline.ndjson');
writeFileSync(baselinePath, serializeBaselineFile(baselineFile));
const out = await withExitCapture(() =>
runEvalGate(engine, ['--baseline', baselinePath]),
);
// Perturbed baseline → current retrieval doesn't match → jaccard well
// below 0.85 → exit 1 with breach.
expect(out.exitCode).toBe(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
});
describe('e2e LOOP: D3 fail-closed on malformed baseline (NOT silent pass)', () => {
test('case 3: malformed baseline → exit 1 with parse breach (D3 IRON-RULE)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'eval-loop-'));
try {
const baselinePath = join(dir, 'bad.baseline.ndjson');
writeFileSync(baselinePath, '{this is not valid JSON\n');
const out = await withExitCapture(() =>
runEvalGate(engine, ['--baseline', baselinePath]),
);
// The IRON-RULE: malformed input MUST exit 1 (treat as gate fail).
// The pre-D3 bug would have silently exited 0.
expect(out.exitCode).toBe(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('e2e LOOP: round-trip byte stability', () => {
test('case 4: published baseline parses back byte-identically', async () => {
const dir = mkdtempSync(join(tmpdir(), 'eval-loop-'));
try {
const captured = await Promise.all([
captureSearch('fintech'),
captureSearch('alignment'),
]);
const baselineFile = buildBaselineFromInput(captured, {
label: 'roundtrip',
publishedAt: new Date('2026-05-24T00:00:00Z'),
});
const baselinePath = join(dir, 'roundtrip.baseline.ndjson');
const serialized = serializeBaselineFile(baselineFile);
writeFileSync(baselinePath, serialized);
// Read back from disk, parse, re-serialize: must be byte-identical.
const onDisk = readFileSync(baselinePath, 'utf-8');
const parsed = parseBaselineFile(onDisk);
const reserialized = serializeBaselineFile(parsed);
expect(reserialized).toBe(onDisk);
// Also: metadata fields preserved exactly.
expect(parsed.metadata.label).toBe('roundtrip');
expect(parsed.metadata.published_at).toBe('2026-05-24T00:00:00.000Z');
expect(parsed.metadata._kind).toBe('baseline_metadata');
expect(parsed.rows.length).toBe(captured.length);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
});