mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(worker-pool): shared sliding pool + bounded semaphore + PGLite-clamp wrapper T1 + T2 of the v0.41.16.0 workers cathedral. New src/core/worker-pool.ts is the canonical primitive every --workers N bulk command in this wave (and future bulk commands) builds on. Atomic-claim invariant enforced by scripts/check-worker-pool-atomicity.sh (wired into bun run verify). BudgetExhausted bypass + AbortSignal composition baked into the helper so budget caps are a structural ceiling under concurrency, not a per-caller convention. The new resolveWorkersWithClamp wrapper composes existing autoConcurrency with PGLite-clamp + per-(command, requested) stderr dedup. Deliberately NOT a modification to shared autoConcurrency (silent today, used by sync + import); embed.ts keeps GBRAIN_EMBED_CONCURRENCY || 20 default per codex #13. 23 + 12 + 9 = 44 hermetic tests pin every contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: structural + dim-check regression suites for v0.41.16.0 wave - test/embed-helper-migration.test.ts (T3): asserts embed.ts's two sliding-pool sites are migrated to runSlidingPool, pre-migration shapes (let nextIdx = 0, Promise.all(Array.from(...))) are gone, GBRAIN_EMBED_CONCURRENCY || 20 default preserved, failureLabel threads page.slug. Per codex #16/#17 these are invariant assertions, not byte-equality on progress event ORDERING. - test/embedding-dim-check-facts.test.ts (T6): readFactsEmbeddingDim covers vector(N) + halfvec(N), halfvec-before-vector regex ordering pinned (codex #19), buildFactsAlterRecipe emits DROP INDEX + ALTER USING + CREATE INDEX (codex #18, not bare REINDEX), FactsEmbeddingDimMismatchError tagged class shape, assertFactsEmbeddingDimMatchesConfig PGLite skip + Postgres absent- column skip, doctor check + insert-cast wiring assertions. - test/extract-conversation-facts-workers.test.ts (T5): helper exports (extractConversationFactsLockId, PER_PAGE_LOCK_TTL_MINUTES), structural wiring (runSlidingPool, resolveWorkersWithClamp, withRefreshingLock, LockUnavailableError, delete-orphans-first before segment loop, preflight before pool, exit 3 when lock_skipped > 0), Minion handler round-trip. - test/extract-workers.test.ts (T7): --workers wiring on all 3 inner fs-walk loops (extractForSlugs, extractLinksFromDir, extractTimelineFromDir) + CLI parse + opts threading through runExtractCore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump v0.41.16.0 → v0.41.17.0 (queue collision with PR #1510) PR #1510 (garrytan/dynamic-regex-conversation-formats) claimed v0.41.16.0 on master in parallel. Advancing this wave to v0.41.17.0 so both can land cleanly. Pure mechanical version bump: - VERSION + package.json → 0.41.17.0 - CHANGELOG.md header + "To take advantage of v0.41.17.0" block - TODOS.md section header + v0.41.18+ forward references - CLAUDE.md inline version tags - Regenerated llms-full.txt / llms.txt No code changes. The actual workers cathedral feature set is unchanged from the two prior commits in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): search-image-column probes column dim at runtime CI shard 5 failed on `searchVector column routing (v0.27.1)` with: error: expected 1280 dimensions, not 1536 The test had a hardcoded `fakeText1536` helper that seeded chunks at 1536-d vectors. Master's default embedding model switched from OpenAI text-embedding-3-large (1536) to ZeroEntropy zembed-1 (1280) so a fresh PGLite brain on CI now sizes content_chunks.embedding at 1280; the test's 1536-d INSERT trips pgvector's CheckExpectedDim. Fix: probe `content_chunks.embedding` width via `readContentChunksEmbeddingDim(engine)` in `beforeAll`, store in `TEXT_DIM`, and build `fakeTextDefault(seed)` at that width. The test now passes regardless of which default ships (the model has flipped twice and may flip again). Local dev (1536 from older config) and CI fresh-install (1280 from new default) both pass. Image-side vectors stay at 1024 (matches Voyage multimodal-3 + the column's fixed width on the image side). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): bump PGLite hook timeout for shard-4 deep-process files facts-anti-loop.test.ts and ingest-capture.test.ts were timing out in CI shard 4 with "beforeEach/afterEach hook timed out" after the v0.41.16.0 master merge brought migration count to 99. When these files run deep in a shard process that has already created ~20 PGLite engines, the WASM cold-start + 95-migration replay legitimately exceeds bun's 5s default hook timeout (observed 5.6s and 7.3s locally when reproducing). Bun's --timeout=60000 from scripts/test-shard.sh covers TEST timeouts but NOT hook timeouts; those default to 5s and must be set per-hook via the optional 2nd arg to beforeAll/afterAll. Reproduced locally by running the first 21 shard-4 files via head -21 /tmp/shard4-list.txt | xargs bun test → 179 pass, 2 fail (both with hook-timeout error) After fix: → 198 pass, 0 fail (the 4 anti-loop + 15 ingest-capture tests recover) Full shard 4 with fix: 955 pass, 0 fail. Full shard 5 with fix: 1261 pass, 0 fail. Also added a defensive diagnostic to the two put_page tests: if facts_backstop is missing in the response payload, throw with the full payload + isError so future failures surface the actual handler error instead of a bare "expected {...} got undefined" assertion. No-op when the test passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
473 lines
19 KiB
TypeScript
473 lines
19 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.41.15.0 — migrated to shared helper)', () => {
|
|
// v0.41.15.0 T4 (codex #15): migrated to opts-object API from
|
|
// src/core/worker-pool.ts. Result shape gains an additive `idx` field;
|
|
// error field widened from `Error` to `unknown`. The `limit < 1`
|
|
// throw is no longer raised — the helper clamps to 1 silently,
|
|
// matching the permissive contract of runSlidingPool.
|
|
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,
|
|
limit: 3,
|
|
fn: 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,
|
|
limit: 2,
|
|
fn: async (item) => {
|
|
if (item === 2) throw new Error(`fail-${item}`);
|
|
return item * 10;
|
|
},
|
|
});
|
|
expect(results.length).toBe(5);
|
|
expect(results[0]).toMatchObject({ ok: true, value: 0, idx: 0 });
|
|
expect(results[1]).toMatchObject({ ok: true, value: 10, idx: 1 });
|
|
expect(results[2].ok).toBe(false);
|
|
expect(results[2].idx).toBe(2);
|
|
expect(results[3]).toMatchObject({ ok: true, value: 30, idx: 3 });
|
|
expect(results[4]).toMatchObject({ ok: true, value: 40, idx: 4 });
|
|
});
|
|
|
|
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,
|
|
limit: 4,
|
|
fn: async (item) => {
|
|
await new Promise(r => setTimeout(r, (10 - item) * 2));
|
|
return item * item;
|
|
},
|
|
});
|
|
for (let i = 0; i < items.length; i++) {
|
|
expect(results[i]).toMatchObject({ ok: true, value: i * i, idx: i });
|
|
}
|
|
});
|
|
|
|
test('limit=1 = serial', async () => {
|
|
const order: number[] = [];
|
|
const items = [0, 1, 2, 3];
|
|
await runWithLimit({
|
|
items,
|
|
limit: 1,
|
|
fn: 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({
|
|
items: [0, 1, 2],
|
|
limit: 100,
|
|
fn: async (x) => x + 1,
|
|
});
|
|
expect(results.length).toBe(3);
|
|
expect(results[0]).toMatchObject({ ok: true, value: 1, idx: 0 });
|
|
expect(results[1]).toMatchObject({ ok: true, value: 2, idx: 1 });
|
|
expect(results[2]).toMatchObject({ ok: true, value: 3, idx: 2 });
|
|
});
|
|
|
|
test('limit < 1 silently clamps to 1 worker (v0.41.15.0 permissive contract)', async () => {
|
|
// Prior behavior threw "limit must be >= 1". The shared helper
|
|
// clamps via Math.max(1, ...) to match runSlidingPool's permissive
|
|
// shape. Callers passing 0 get serial execution, not an exception.
|
|
const results = await runWithLimit({
|
|
items: [1, 2, 3],
|
|
limit: 0,
|
|
fn: async (x) => x,
|
|
});
|
|
expect(results.length).toBe(3);
|
|
for (let i = 0; i < 3; i++) {
|
|
expect(results[i]).toMatchObject({ ok: true, value: i + 1, idx: i });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 });
|
|
}
|
|
});
|
|
});
|