Files
gbrain/test/nightly-quality-probe.test.ts
T
94aaf7e396 v0.40.1.0 Track D — eval infrastructure (catch retrieval regressions, prove answer-quality wins) (#1298)
* 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>
2026-05-22 22:16:25 -07:00

360 lines
15 KiB
TypeScript

/**
* v0.40.1.0 Track D / T6+T7 — Nightly quality probe phase + doctor check.
*
* Hermetic: every external effect goes through the NightlyProbeDeps DI
* surface. No PGLite, no real LLM calls, no env mutation outside withEnv.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
runNightlyQualityProbe,
shouldRunNightly,
type NightlyProbeDeps,
type NightlyProbeResult,
} from '../src/core/cycle/nightly-quality-probe.ts';
import { withEnv } from './helpers/with-env.ts';
// ---------------------------------------------------------------------------
// Hermetic audit dir per test
// ---------------------------------------------------------------------------
let auditTmp: string;
beforeEach(() => {
auditTmp = mkdtempSync(join(tmpdir(), 'qprobe-audit-'));
});
afterEach(() => {
try { rmSync(auditTmp, { recursive: true, force: true }); } catch { /* best */ }
});
// ---------------------------------------------------------------------------
// 1. shouldRunNightly pure function
// ---------------------------------------------------------------------------
describe('shouldRunNightly (pure function, rate-limit logic)', () => {
test('empty history → run', () => {
expect(shouldRunNightly(new Date('2026-05-22T00:00:00Z'), [])).toEqual({ run: true });
});
test('last event > 24h ago → run', () => {
const r = shouldRunNightly(
new Date('2026-05-22T00:00:00Z'),
[{ ts: '2026-05-20T00:00:00Z' }],
);
expect(r).toEqual({ run: true });
});
test('last event within 24h → rate-limited', () => {
const r = shouldRunNightly(
new Date('2026-05-22T00:00:00Z'),
[{ ts: '2026-05-21T12:00:00Z' }],
);
expect(r).toEqual({ run: false, reason: 'rate_limited' });
});
test('one event old, one event recent → rate-limited (any recent fires it)', () => {
const r = shouldRunNightly(
new Date('2026-05-22T00:00:00Z'),
[
{ ts: '2026-05-01T00:00:00Z' },
{ ts: '2026-05-21T20:00:00Z' },
],
);
expect(r).toEqual({ run: false, reason: 'rate_limited' });
});
test('corrupt timestamp → ignored (does not rate-limit)', () => {
const r = shouldRunNightly(
new Date('2026-05-22T00:00:00Z'),
[{ ts: 'not a date' }],
);
expect(r).toEqual({ run: true });
});
test('configurable window respected', () => {
// 1-hour window: 6h ago counts as old.
const r = shouldRunNightly(
new Date('2026-05-22T00:00:00Z'),
[{ ts: '2026-05-21T18:00:00Z' }],
60 * 60 * 1000,
);
expect(r).toEqual({ run: true });
});
});
// ---------------------------------------------------------------------------
// 2. runNightlyQualityProbe via DI stubs
// ---------------------------------------------------------------------------
function makeDeps(overrides: Partial<NightlyProbeDeps> = {}): NightlyProbeDeps {
return {
isEnabled: async () => true,
hasEmbeddingProvider: async () => true,
resolveMaxUsd: async () => 5,
resolveRepoRoot: async () => process.cwd(),
runLongMemEval: async () => { /* stub */ },
runCrossModalBatch: async () => ({
exitCode: 0,
summary: {
pass_count: 5, fail_count: 0, inconclusive_count: 0, error_count: 0,
est_cost_usd: 0.35, verdict: 'pass',
},
}),
now: () => new Date(),
...overrides,
};
}
describe('runNightlyQualityProbe (DI stub harness)', () => {
test('disabled config → outcome: disabled, no audit row', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps({ isEnabled: async () => false }));
expect(r.outcome).toBe('disabled');
expect(r.exit_code).toBe(0);
// No audit row written.
const events = await readEvents();
expect(events.length).toBe(0);
});
});
test('enabled + no embedding key → outcome: no_embedding_key with audit row', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps({ hasEmbeddingProvider: async () => false }));
expect(r.outcome).toBe('no_embedding_key');
const events = await readEvents();
expect(events.length).toBe(1);
expect(events[0].outcome).toBe('no_embedding_key');
});
});
test('enabled + recent run within 24h → outcome: rate_limited', async () => {
// Pre-seed a recent audit event by running the probe once first.
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
// First run succeeds.
await runNightlyQualityProbe(makeDeps());
// Second run, same hour → rate_limited.
const r2 = await runNightlyQualityProbe(makeDeps());
expect(r2.outcome).toBe('rate_limited');
const events = await readEvents();
expect(events.length).toBe(2);
expect(events[1].outcome).toBe('rate_limited');
});
});
test('enabled + PASS summary → outcome: pass with audit row', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps());
expect(r.outcome).toBe('pass');
expect(r.exit_code).toBe(0);
const events = await readEvents();
expect(events.length).toBe(1);
expect(events[0].outcome).toBe('pass');
expect(events[0].pass_count).toBe(5);
expect(events[0].est_cost_usd).toBe(0.35);
});
});
test('enabled + FAIL summary → outcome: fail', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps({
runCrossModalBatch: async () => ({
exitCode: 1,
summary: {
pass_count: 7, fail_count: 3, inconclusive_count: 0, error_count: 0,
est_cost_usd: 0.42, verdict: 'fail',
},
}),
}));
expect(r.outcome).toBe('fail');
expect(r.exit_code).toBe(1);
const events = await readEvents();
expect(events[0].outcome).toBe('fail');
expect(events[0].fail_count).toBe(3);
});
});
test('runLongMemEval throws → outcome: error with audit row', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps({
runLongMemEval: async () => { throw new Error('longmemeval blew up'); },
}));
expect(r.outcome).toBe('error');
expect(r.exit_code).toBe(1);
const events = await readEvents();
expect(events[0].outcome).toBe('error');
expect(events[0].detail).toContain('longmemeval blew up');
});
});
test('missing fixture → outcome: error', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps({
resolveRepoRoot: async () => '/this/repo/root/does/not/exist',
}));
expect(r.outcome).toBe('error');
const events = await readEvents();
expect(events[0].outcome).toBe('error');
expect(events[0].detail).toContain('not found');
});
});
test('audit event records fixture_sha8 on successful runs', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
const r = await runNightlyQualityProbe(makeDeps());
expect(r.outcome).toBe('pass');
const events = await readEvents();
expect(events[0].fixture_sha8).toMatch(/^[0-9a-f]{8}$/);
});
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function readEvents(): Promise<any[]> {
// Re-import so it uses the override env var picked up at call time.
const { readRecentQualityProbeEvents } = await import('../src/core/audit-quality-probe.ts');
return readRecentQualityProbeEvents(2);
}
// ---------------------------------------------------------------------------
// 3. computeNightlyQualityProbeHealthCheck pure function (doctor.ts coverage)
// ---------------------------------------------------------------------------
describe('computeNightlyQualityProbeHealthCheck — pure doctor branch coverage', () => {
test('disabled + no events → ok with paste-ready enable hint', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const check = computeNightlyQualityProbeHealthCheck(false, []);
expect(check.name).toBe('nightly_quality_probe_health');
expect(check.status).toBe('ok');
expect(check.message).toMatch(/disabled \(opt-in\)/);
expect(check.message).toMatch(/gbrain config set autopilot\.nightly_quality_probe\.enabled true/);
});
test('enabled + no events → ok pending', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const check = computeNightlyQualityProbeHealthCheck(true, []);
expect(check.status).toBe('ok');
expect(check.message).toMatch(/enabled but no probe events/);
});
test('enabled + all-PASS events → ok with latest timestamp', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [
{ outcome: 'pass', ts: '2026-05-20T03:00:00Z' },
{ outcome: 'pass', ts: '2026-05-21T03:00:00Z' },
{ outcome: 'pass', ts: '2026-05-22T03:00:00Z' },
];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('ok');
expect(check.message).toMatch(/3 PASS runs/);
expect(check.message).toContain('2026-05-22T03:00:00Z');
});
test('enabled + ANY fail/error/budget_exceeded → warn with per-outcome counts', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [
{ outcome: 'pass', ts: '2026-05-19T03:00:00Z' },
{ outcome: 'fail', ts: '2026-05-20T03:00:00Z' },
{ outcome: 'error', ts: '2026-05-21T03:00:00Z', detail: 'longmemeval blew up' },
{ outcome: 'budget_exceeded', ts: '2026-05-22T03:00:00Z' },
];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/3 non-PASS runs/);
expect(check.message).toMatch(/pass=1/);
expect(check.message).toMatch(/fail=1/);
expect(check.message).toMatch(/error=1/);
expect(check.message).toMatch(/budget=1/);
// Latest in the list is what surfaces in the message.
expect(check.message).toContain('budget_exceeded');
expect(check.message).toContain('2026-05-22T03:00:00Z');
});
test('latest event with detail → detail surfaces in warn message', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [
{ outcome: 'error', ts: '2026-05-22T03:00:00Z', detail: 'no embedding provider' },
];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toContain('no embedding provider');
});
test('single non-PASS event uses singular grammar', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [{ outcome: 'fail', ts: '2026-05-22T03:00:00Z' }];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/1 non-PASS run /); // "run " not "runs "
});
test('single PASS event uses singular grammar', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [{ outcome: 'pass', ts: '2026-05-22T03:00:00Z' }];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('ok');
expect(check.message).toMatch(/1 PASS run /); // "run " not "runs "
});
});
// ---------------------------------------------------------------------------
// 4. Codex CDX-5 — doctor flags ALL non-PASS outcomes (no_embedding_key,
// rate_limited, inconclusive must trip warn, not get silently reported as PASS)
// ---------------------------------------------------------------------------
describe('codex CDX-5 — doctor health: every non-PASS outcome surfaces', () => {
test('no_embedding_key outcome → warn (was silently PASS before CDX-5 fix)', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [{ outcome: 'no_embedding_key', ts: '2026-05-22T03:00:00Z' }];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/no_embed_key=1/);
});
test('rate_limited outcome → warn', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [{ outcome: 'rate_limited', ts: '2026-05-22T03:00:00Z' }];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/rate_limited=1/);
});
test('inconclusive outcome → warn', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [{ outcome: 'inconclusive', ts: '2026-05-22T03:00:00Z' }];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/inconclusive=1/);
});
test('counts include the new outcome buckets when mixed with pass/fail/error', async () => {
const { computeNightlyQualityProbeHealthCheck } = await import('../src/commands/doctor.ts');
const events = [
{ outcome: 'pass', ts: '2026-05-16T03:00:00Z' },
{ outcome: 'fail', ts: '2026-05-17T03:00:00Z' },
{ outcome: 'error', ts: '2026-05-18T03:00:00Z' },
{ outcome: 'inconclusive', ts: '2026-05-19T03:00:00Z' },
{ outcome: 'budget_exceeded', ts: '2026-05-20T03:00:00Z' },
{ outcome: 'no_embedding_key', ts: '2026-05-21T03:00:00Z' },
{ outcome: 'rate_limited', ts: '2026-05-22T03:00:00Z' },
];
const check = computeNightlyQualityProbeHealthCheck(true, events);
expect(check.status).toBe('warn');
expect(check.message).toMatch(/6 non-PASS runs/); // 7 total, 1 pass, 6 bad
expect(check.message).toMatch(/pass=1/);
expect(check.message).toMatch(/fail=1/);
expect(check.message).toMatch(/error=1/);
expect(check.message).toMatch(/inconclusive=1/);
expect(check.message).toMatch(/budget=1/);
expect(check.message).toMatch(/no_embed_key=1/);
expect(check.message).toMatch(/rate_limited=1/);
});
});