mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +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>
438 lines
18 KiB
TypeScript
438 lines
18 KiB
TypeScript
/**
|
|
* v0.40.1.0 Track D / T3+T4 — Cross-modal --batch mode tests.
|
|
*
|
|
* Hermetic via the runEval DI seam (per D5). Tests the batch loop +
|
|
* semaphore + exit precedence + receipt suppression, NOT the underlying
|
|
* runEval orchestrator (which has its own coverage in existing tests).
|
|
*
|
|
* No env mutation, no mock.module — regular *.test.ts per CLAUDE.md
|
|
* test-isolation rules.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { runEvalCrossModal, runWithLimit, type BatchSummary } from '../src/commands/eval-cross-modal.ts';
|
|
import type { RunEvalResult } from '../src/core/cross-modal-eval/runner.ts';
|
|
import type { AggregateResult } from '../src/core/cross-modal-eval/aggregate.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function writeBatchFixture(rows: object[]): string {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'cm-batch-fixture-'));
|
|
const path = join(tmp, 'batch.jsonl');
|
|
writeFileSync(path, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8');
|
|
return path;
|
|
}
|
|
|
|
function makeStubRunEval(verdicts: Array<'pass' | 'fail' | 'inconclusive' | 'throw'>) {
|
|
let i = 0;
|
|
return async function stubRunEval(_opts: any): Promise<RunEvalResult> {
|
|
const idx = i++;
|
|
const v = verdicts[idx % verdicts.length];
|
|
if (v === 'throw') {
|
|
throw new Error(`stub error for question ${idx}`);
|
|
}
|
|
const aggregate: AggregateResult = {
|
|
verdict: v,
|
|
verdictMessage: `stub: ${v}`,
|
|
overall: v === 'pass' ? 8 : v === 'fail' ? 4 : 0,
|
|
perDimension: {},
|
|
successCount: 3,
|
|
modelCount: 3,
|
|
} as any;
|
|
return {
|
|
finalAggregate: aggregate,
|
|
cycles: [],
|
|
finalReceiptPath: '/tmp/fake-receipt.json',
|
|
};
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. runWithLimit semaphore primitive (T4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runWithLimit semaphore (v0.40.1.0 Track D / T4, per D6)', () => {
|
|
test('never exceeds the in-flight limit', async () => {
|
|
let inFlight = 0;
|
|
let maxObserved = 0;
|
|
const items = Array.from({ length: 20 }, (_, i) => i);
|
|
await runWithLimit(items, 3, async (_item) => {
|
|
inFlight++;
|
|
if (inFlight > maxObserved) maxObserved = inFlight;
|
|
await new Promise(r => setTimeout(r, 5));
|
|
inFlight--;
|
|
return 1;
|
|
});
|
|
expect(maxObserved).toBeLessThanOrEqual(3);
|
|
expect(maxObserved).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('per-item errors do not abort the whole batch', async () => {
|
|
const items = [0, 1, 2, 3, 4];
|
|
const results = await runWithLimit(items, 2, async (item) => {
|
|
if (item === 2) throw new Error(`fail-${item}`);
|
|
return item * 10;
|
|
});
|
|
expect(results.length).toBe(5);
|
|
expect(results[0]).toEqual({ ok: true, value: 0 });
|
|
expect(results[1]).toEqual({ ok: true, value: 10 });
|
|
expect(results[2].ok).toBe(false);
|
|
expect(results[3]).toEqual({ ok: true, value: 30 });
|
|
expect(results[4]).toEqual({ ok: true, value: 40 });
|
|
});
|
|
|
|
test('preserves input order in results array', async () => {
|
|
const items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
|
const results = await runWithLimit(items, 4, async (item) => {
|
|
// Intentional non-uniform sleep to scramble completion order.
|
|
await new Promise(r => setTimeout(r, (10 - item) * 2));
|
|
return item * item;
|
|
});
|
|
for (let i = 0; i < items.length; i++) {
|
|
expect(results[i]).toEqual({ ok: true, value: i * i });
|
|
}
|
|
});
|
|
|
|
test('limit=1 = serial', async () => {
|
|
const order: number[] = [];
|
|
const items = [0, 1, 2, 3];
|
|
await runWithLimit(items, 1, async (item) => {
|
|
const start = Date.now();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
order.push(item);
|
|
return Date.now() - start;
|
|
});
|
|
expect(order).toEqual([0, 1, 2, 3]);
|
|
});
|
|
|
|
test('limit > items.length still completes correctly', async () => {
|
|
const results = await runWithLimit([0, 1, 2], 100, async (x) => x + 1);
|
|
expect(results).toEqual([
|
|
{ ok: true, value: 1 },
|
|
{ ok: true, value: 2 },
|
|
{ ok: true, value: 3 },
|
|
]);
|
|
});
|
|
|
|
test('limit < 1 throws (defensive)', async () => {
|
|
await expect(runWithLimit([1, 2, 3], 0, async (x) => x)).rejects.toThrow(/limit must be >= 1/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. End-to-end batch via stubbed runEval (T3 + D5 + D10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEvalCrossModal --batch end-to-end (v0.40.1.0 Track D / T3, per D5+D10)', () => {
|
|
test('all-pass batch → exit 0; summary receipt has expected shape', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'what is X?', hypothesis: 'X is foo.' },
|
|
{ question_id: 'q2', question: 'what is Y?', hypothesis: 'Y is bar.' },
|
|
{ question_id: 'q3', question: 'what is Z?', hypothesis: 'Z is baz.' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '3',
|
|
'--cycles', '1', '--concurrent', '2', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'pass', 'pass']) },
|
|
);
|
|
expect(exit).toBe(0);
|
|
expect(existsSync(summaryPath)).toBe(true);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
expect(summary.kind).toBe('cross_modal_batch_summary');
|
|
expect(summary.schema_version).toBe(1);
|
|
expect(summary.verdict).toBe('pass');
|
|
expect(summary.pass_count).toBe(3);
|
|
expect(summary.fail_count).toBe(0);
|
|
expect(summary.error_count).toBe(0);
|
|
expect(summary.inconclusive_count).toBe(0);
|
|
expect(summary.per_question.length).toBe(3);
|
|
expect(summary.per_question[0].question_id).toBe('q1');
|
|
expect(summary.per_question[0].verdict).toBe('pass');
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('any FAIL → exit 1 (precedence: FAIL > INCONCLUSIVE > PASS)', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2', question: 'b', hypothesis: 'b-ans' },
|
|
{ question_id: 'q3', question: 'c', hypothesis: 'c-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '3',
|
|
'--cycles', '1', '--concurrent', '3', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'fail', 'pass']) },
|
|
);
|
|
expect(exit).toBe(1);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
expect(summary.verdict).toBe('fail');
|
|
expect(summary.fail_count).toBe(1);
|
|
expect(summary.pass_count).toBe(2);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('any per-question ERROR → exit 2 (precedence: ERROR > FAIL > INCONCLUSIVE > PASS)', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2', question: 'b', hypothesis: 'b-ans' },
|
|
{ question_id: 'q3', question: 'c', hypothesis: 'c-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '3',
|
|
'--cycles', '1', '--concurrent', '3', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'throw', 'fail']) },
|
|
);
|
|
// ERROR wins precedence over FAIL.
|
|
expect(exit).toBe(2);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
expect(summary.verdict).toBe('error');
|
|
expect(summary.error_count).toBe(1);
|
|
expect(summary.fail_count).toBe(1);
|
|
expect(summary.pass_count).toBe(1);
|
|
const q2 = summary.per_question.find(p => p.question_id === 'q2')!;
|
|
expect(q2.verdict).toBe('error');
|
|
expect(q2.error).toContain('stub error');
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('any INCONCLUSIVE (no error, no fail) → exit 2', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2', question: 'b', hypothesis: 'b-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '2',
|
|
'--cycles', '1', '--concurrent', '2', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'inconclusive']) },
|
|
);
|
|
expect(exit).toBe(2);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
expect(summary.verdict).toBe('inconclusive');
|
|
expect(summary.inconclusive_count).toBe(1);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('--batch + --task mutex → exit 1 with clear error', async () => {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', '/tmp/fake.jsonl', '--task', 'something', '--output', '/tmp/out.json'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).toBe(1);
|
|
});
|
|
|
|
test('--batch with non-existent file → exit 1', async () => {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', '/tmp/this-does-not-exist-' + Date.now() + '.jsonl', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).toBe(1);
|
|
});
|
|
|
|
test('--batch filters out by_type_summary rows (Codex #6)', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2', question: 'b', hypothesis: 'b-ans' },
|
|
// The summary row should be filtered before batch processing.
|
|
{ schema_version: 1, kind: 'by_type_summary', recall_by_type: {}, aggregate: { hit: 0, total: 0, rate: null } },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '10',
|
|
'--cycles', '1', '--concurrent', '2', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'pass']) },
|
|
);
|
|
expect(exit).toBe(0);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
// 2 rows, NOT 3 — the summary row was filtered.
|
|
expect(summary.total).toBe(2);
|
|
expect(summary.pass_count).toBe(2);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('--max-usd refusal without --yes', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
]);
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--limit', '1', '--cycles', '1', '--max-usd', '0.001'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).toBe(1);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--max-usd refusal bypassed by --yes', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath,
|
|
'--limit', '1', '--cycles', '1', '--max-usd', '0.001', '--yes'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).toBe(0);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Argument parser strictness (parseIntStrict / parseFloatStrict throw paths)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('argument parser strictness (v0.40.1.0 Track D / coverage gap)', () => {
|
|
test('--limit rejects non-integer with usage error → exit 1', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
]);
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--limit', 'not-a-number'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
// parseIntStrict throws synchronously inside parseArgs → caller catches in CLI
|
|
// dispatch and returns 1. Either path is acceptable; what matters is no PASS.
|
|
expect(exit).not.toBe(0);
|
|
} catch (err) {
|
|
// If parseArgs throws synchronously, that also counts as "rejected" — we
|
|
// assert the message rather than a specific exit code.
|
|
expect(String(err)).toMatch(/positive integer/);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('--max-usd rejects negative number', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
]);
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--max-usd', '-1'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).not.toBe(0);
|
|
} catch (err) {
|
|
expect(String(err)).toMatch(/non-negative number/);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Codex CDX-1 + CDX-2 — upstream error rows + --limit 0 + malformed rows
|
|
// must count in the denominator and fail-loud (no silent CI bypass).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('codex CDX-1 + CDX-2 — denominator-bypass defenses', () => {
|
|
test('upstream-error rows (longmemeval emitted {error:...}) count as upstream_error, NOT silently dropped', async () => {
|
|
// Mimics what eval-longmemeval emits when runOneQuestion throws:
|
|
// {question_id, question, question_type, hypothesis: '', error: '...'}
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2', question: 'b', question_type: 'temporal-reasoning', hypothesis: '', error: 'upstream OOM' },
|
|
{ question_id: 'q3', question: 'c', hypothesis: 'c-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '10',
|
|
'--cycles', '1', '--concurrent', '2', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'pass']) }, // only 2 calls (q1, q3) — q2 is upstream error
|
|
);
|
|
// Exit 2 because upstream_error_count > 0 (CDX-1).
|
|
expect(exit).toBe(2);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
// Denominator is 3 (not 2!) — upstream error counted.
|
|
expect(summary.total).toBe(3);
|
|
expect(summary.upstream_error_count).toBe(1);
|
|
expect(summary.error_count).toBe(0);
|
|
expect(summary.pass_count).toBe(2);
|
|
expect(summary.verdict).toBe('error');
|
|
// q2 surfaces in per_question with verdict 'upstream_error'.
|
|
const q2 = summary.per_question.find(p => p.question_id === 'q2');
|
|
expect(q2).toBeDefined();
|
|
expect(q2!.verdict).toBe('upstream_error');
|
|
expect(q2!.error).toBe('upstream OOM');
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('malformed rows (missing question or hypothesis) count toward malformed_count + exit 2', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
{ question_id: 'q2' /* missing question and hypothesis */ },
|
|
{ question_id: 'q3', question: 'c', hypothesis: 'c-ans' },
|
|
]);
|
|
const summaryPath = join(mkdtempSync(join(tmpdir(), 'cm-summary-')), 'summary.json');
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--output', summaryPath, '--limit', '10',
|
|
'--cycles', '1', '--concurrent', '2', '--max-usd', '1000'],
|
|
{ runEval: makeStubRunEval(['pass', 'pass']) },
|
|
);
|
|
expect(exit).toBe(2);
|
|
const summary = JSON.parse(readFileSync(summaryPath, 'utf8')) as BatchSummary;
|
|
expect(summary.malformed_count).toBe(1);
|
|
expect(summary.total).toBe(3); // 2 scored + 0 upstream + 1 malformed
|
|
expect(summary.verdict).toBe('error');
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
rmSync(summaryPath, { force: true });
|
|
}
|
|
});
|
|
|
|
test('--limit 0 is rejected (would bypass the gate with empty result → PASS)', async () => {
|
|
const fixturePath = writeBatchFixture([
|
|
{ question_id: 'q1', question: 'a', hypothesis: 'a-ans' },
|
|
]);
|
|
try {
|
|
const exit = await runEvalCrossModal(
|
|
['--batch', fixturePath, '--limit', '0'],
|
|
{ runEval: makeStubRunEval(['pass']) },
|
|
);
|
|
expect(exit).toBe(1);
|
|
} finally {
|
|
rmSync(fixturePath, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|