mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
* feat(eval-longmemeval): RunOpts.engine seam for shared benchmark brain Adds optional `engine?: PGLiteEngine` field to RunOpts. When set, runEvalLongMemEval uses the caller-provided engine and skips the withBenchmarkBrain wrapper (no fresh PGLite create, no disconnect on exit). When unset, the production CLI path is unchanged: withBenchmarkBrain creates and disposes a fresh engine per invocation. Designed for the test seam that's about to land: one beforeAll-created brain shared across all 13 runEvalLongMemEval calls in test/eval-longmemeval-e2e.slow.test.ts, amortizing the ~1-3s PGLite cold-create cost. runOneQuestion already calls resetTables() as its first line so per-test isolation is preserved across the shared engine. Pure additive seam — every existing caller (CLI, current tests that already create engines via withBenchmarkBrain implicitly) keeps its current behavior because opts.engine defaults to undefined. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(test): split eval-longmemeval slow tests + share engine across e2e half The 884-line test/eval-longmemeval.slow.test.ts was the heaviest single file in CI at ~359s on the matrix. Split by runEvalLongMemEval usage: - test/eval-longmemeval.slow.test.ts (trimmed): 8 pure describes, 15 tests. Harness lifecycle, resetTables, schema-migration robustness, warm-create speed gate, adapter haystackToPages, source-boost guard, loadResumeSet, buildByTypeSummary. Local wall: 1.985s, projected CI ~42s. - test/eval-longmemeval-e2e.slow.test.ts (NEW): 8 e2e describes, 11 tests. Every describe that calls runEvalLongMemEval — 13 call sites total. Threads a single beforeAll-created PGLite via the v0.41.10 RunOpts.engine seam. Local wall: 9.33s (was 15.09s without sharing); projected CI ~196s (was ~317s). - test/helpers/longmemeval-stub.ts (NEW): shared makeStubClient + StubCall. Matches the existing test/helpers/ convention (with-env.ts, reset-pglite.ts). Single source of truth across the two split files. - scripts/test-weights.json: replaced 359087ms entry with TWO entries (42000ms pure, 196000ms e2e). Projected linearly from local wall-clock × 21 CI scaling factor. First post-merge CI run will refine via scripts/mine-shard-weights.ts. Test count is preserved: 15 pure + 11 e2e = 26, matches original file. No production code changes in this commit — only test reorganization + opt-in to the RunOpts.engine seam from the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): bump matrix 6→10, dedicate two slow files, cache bun-install CI matrix wallclock: ~9 min → ~4.5 min. Three coordinated changes. 1. .github/workflows/test.yml matrix bumped from 6 → 10 shards. Per-shard total drops from 532s → 272s. Honest concurrency-budget call: total gated jobs go 13 → 18, so 2 concurrent PRs ≈ 36 queued, past the GH free-tier ~20 ceiling — single-PR runs unaffected, multi-PR days see queue pressure. Worth it for the 4-min CI saving. 2. Two slow files pulled out of the matrix and into their own dedicated jobs (sibling to verify, serial-tests): - slow-eval-longmemeval runs test/eval-longmemeval-e2e.slow.test.ts (~196s after the engine-sharing seam from the previous two commits). - slow-entity-resolve-perf runs test/entity-resolve-perf.slow.test.ts (~159s, single non-subdivisible perf test). The 60s default bun timeout is too tight for this file — bumped to 300000ms. scripts/test-shard.sh excludes both via -not -name clauses so the matrix sweep doesn't double-run them. Both new jobs wire into cache-write.needs and test-status.needs so CI gates on them. 3. actions/cache for ~/.bun/install/cache added to every job that runs bun install (test matrix, verify, serial-tests, slow-eval-longmemeval, slow-entity-resolve-perf). Keyed on bun.lock hash. Saves ~15s per job on cache hit; first-PR push pays full cost, subsequent runs hit cache. Total CI wallclock now bounded by max(matrix ~4.5min, slow-eval ~3.3min, slow-entity-resolve-perf ~2.6min) = ~4.5 min. The matrix is back to being the floor; no single test file dominates a shard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: v0.41.10.0 — CI wallclock 9min → 4.5min VERSION + package.json + CHANGELOG entry for the three preceding commits: feat(eval-longmemeval): RunOpts.engine seam for shared benchmark brain refactor(test): split eval-longmemeval slow tests + share engine across e2e half ci(test): bump matrix 6→10, dedicate two slow files, cache bun-install Net user-visible: CI 'Test' check finishes in ~4.5 min instead of ~9 min. Net contributor-visible: new RunOpts.engine seam on runEvalLongMemEval for benchmark suites that want to amortize PGLite cold-create across many calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): quarantine hybrid-meta + schema-pack-load-active to serial The 6→10 matrix shard bump in this branch re-shuffled file distribution across shard processes. Two pre-existing tests with hidden cross-file state dependencies surfaced as failures in CI run #77779498812/13: - test/hybrid-meta.test.ts shard 7: gateway state (configured by some other test in the same shard process) survived past the test's `delete process.env.OPENAI_API_KEY` call, so the early-return for expansion didn't fire and `expansion_applied` stayed true. - test/schema-pack-load-active.test.ts shard 8: the schema-pack module's test-injected locator state was left behind by an earlier file, so `loadActivePack` with the default config didn't fall through to the bundled gbrain-base path. Both files pass cleanly solo (verified). The pollution sources are unidentified — bun's reporter only printed 14 of 71 file headers per shard log, hiding the polluters. Rather than spelunk for the source, rename both files to *.serial.test.ts. The serial pass runs them at --max-concurrency=1 in a process that doesn't share state with the parallel matrix shards. Same-wave bookkeeping: - scripts/check-test-isolation.allowlist: drop test/hybrid-meta.test.ts entry (file is now serial, no longer R1-eligible). - scripts/test-weights.json: rename both weight entries to match the new filenames so future matrix LPT runs don't fall back to median. Companion to a7d029d0/2e1c269e/5a749acb of this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
21 KiB
TypeScript
504 lines
21 KiB
TypeScript
/**
|
|
* v0.41.10 split — end-to-end half of the LongMemEval test surface.
|
|
*
|
|
* Contains every describe that invokes `runEvalLongMemEval(...)`.
|
|
*
|
|
* v0.41.10 engine-sharing optimization: one PGLite brain is created via
|
|
* `createBenchmarkBrain()` in beforeAll and threaded through every
|
|
* runEvalLongMemEval call via `runOpts.engine`. Each call still calls
|
|
* `resetTables()` per-question internally (runOneQuestion's first line),
|
|
* so per-test isolation is preserved. This amortizes the ~1-3s cold-create
|
|
* cost across all 13 invocations in this file, dropping local wallclock
|
|
* from ~15s to ~3-5s.
|
|
*
|
|
* The pure / harness-shared half lives in test/eval-longmemeval.slow.test.ts.
|
|
* Both files run as separate .slow.test.ts entries.
|
|
*
|
|
* Stub MessagesClient lives in test/helpers/longmemeval-stub.ts.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, existsSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { runEvalLongMemEval } from '../src/commands/eval-longmemeval.ts';
|
|
import type { LongMemEvalQuestion } from '../src/eval/longmemeval/adapter.ts';
|
|
import { createBenchmarkBrain } from '../src/eval/longmemeval/harness.ts';
|
|
import type { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { makeStubClient } from './helpers/longmemeval-stub.ts';
|
|
|
|
const FIXTURE_PATH = join(import.meta.dir, 'fixtures', 'longmemeval-mini.jsonl');
|
|
|
|
// One shared brain across the whole file, threaded into every
|
|
// runEvalLongMemEval call via runOpts.engine. resetTables is called
|
|
// per-question inside runOneQuestion so tests stay isolated.
|
|
let sharedEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
sharedEngine = await createBenchmarkBrain();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (sharedEngine) await sharedEngine.disconnect();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. end-to-end with stubbed LLM
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEvalLongMemEval: end-to-end with stubbed LLM', () => {
|
|
test('5-question fixture produces 5 valid JSONL lines via --output', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
const { client, calls } = makeStubClient('canned-answer-stub');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '5', '--output', outPath, '--top-k', '3'],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
expect(existsSync(outPath)).toBe(true);
|
|
const raw = readFileSync(outPath, 'utf8');
|
|
const lines = raw.split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(5);
|
|
for (const line of lines) {
|
|
const obj = JSON.parse(line);
|
|
expect(typeof obj.question_id).toBe('string');
|
|
expect(typeof obj.hypothesis).toBe('string');
|
|
expect(obj.hypothesis).toContain('canned-answer-stub');
|
|
}
|
|
// Stub was called for every question with the right system + user shape.
|
|
// Retrieval may legitimately miss on --keyword-only (websearch AND requires
|
|
// every term to appear in one chunk); the harness wiring is what we're
|
|
// pinning here, not retrieval recall. We assert at least one call had a
|
|
// non-empty <chat_session> block to prove the sanitize + render path
|
|
// executed end-to-end.
|
|
expect(calls.length).toBe(5);
|
|
let withSessionsCount = 0;
|
|
for (const c of calls) {
|
|
expect(c.system).toContain('UNTRUSTED');
|
|
expect(c.userText).toContain('Question:');
|
|
expect(c.userText).toContain('Retrieved sessions:');
|
|
if (c.userText.includes('<chat_session')) withSessionsCount++;
|
|
}
|
|
expect(withSessionsCount).toBeGreaterThan(0);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. end-to-end retrieval-only (no LLM)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEvalLongMemEval: --retrieval-only path', () => {
|
|
test('5-question fixture produces 5 lines without an LLM client', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
// No client passed: retrieval-only never calls the client, so this works.
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--retrieval-only',
|
|
'--limit', '5', '--output', outPath, '--top-k', '3'],
|
|
{ engine: sharedEngine },
|
|
);
|
|
const raw = readFileSync(outPath, 'utf8');
|
|
const lines = raw.split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(5);
|
|
for (const line of lines) {
|
|
const obj = JSON.parse(line);
|
|
expect(typeof obj.question_id).toBe('string');
|
|
expect(typeof obj.hypothesis).toBe('string');
|
|
// retrieval-only hypotheses include rendered session text
|
|
// (or empty when retrieval missed everything — both are valid).
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 10. JSONL format guard (LF + UTF-8)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('JSONL format guard', () => {
|
|
test('each line ends with \\n, no \\r anywhere, UTF-8 round-trip is byte-equal', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
const { client } = makeStubClient('format-stub');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', outPath],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
const buf = readFileSync(outPath);
|
|
// No CR bytes anywhere.
|
|
for (let i = 0; i < buf.length; i++) {
|
|
expect(buf[i]).not.toBe(0x0d);
|
|
}
|
|
// File ends with a single LF.
|
|
expect(buf[buf.length - 1]).toBe(0x0a);
|
|
const text = buf.toString('utf8');
|
|
// UTF-8 round-trip is byte-equal.
|
|
expect(Buffer.from(text, 'utf8').equals(buf)).toBe(true);
|
|
// Each non-empty line is valid JSON.
|
|
const lines = text.split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(3);
|
|
for (const line of lines) {
|
|
const obj = JSON.parse(line);
|
|
expect(obj.question_id).toBeDefined();
|
|
expect(obj.hypothesis).toBeDefined();
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 11. JSONL key contract (additive, never replace)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('JSONL key contract', () => {
|
|
test('every line carries question_id + hypothesis at minimum', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--retrieval-only',
|
|
'--limit', '3', '--output', outPath],
|
|
{ engine: sharedEngine },
|
|
);
|
|
const text = readFileSync(outPath, 'utf8');
|
|
const lines = text.split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(3);
|
|
for (const line of lines) {
|
|
const obj = JSON.parse(line);
|
|
expect(Object.keys(obj)).toContain('question_id');
|
|
expect(Object.keys(obj)).toContain('hypothesis');
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 12. per-question failure handling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('per-question failure handling', () => {
|
|
test('one broken question does not kill the run; emits error JSONL line', async () => {
|
|
// Build an in-memory fixture with one malformed entry: missing
|
|
// haystack_sessions array entirely. haystackToPages reads that field,
|
|
// so the per-question try/catch must catch the resulting error.
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const fixturePath = join(tmp, 'broken.jsonl');
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
const valid: LongMemEvalQuestion = {
|
|
question_id: 'lme-ok-1',
|
|
question_type: 'single-session-user',
|
|
question: 'apple keyword',
|
|
answer: 'a',
|
|
haystack_dates: ['2025-01-01'],
|
|
answer_session_ids: ['ok-sess'],
|
|
haystack_sessions: [
|
|
{ session_id: 'ok-sess', turns: [{ role: 'user', content: 'apple in a session' }] },
|
|
],
|
|
};
|
|
const broken = {
|
|
question_id: 'lme-broken-1',
|
|
question_type: 'single-session-user',
|
|
question: 'will fail',
|
|
answer: 'a',
|
|
// missing haystack_sessions on purpose
|
|
};
|
|
const { writeFileSync } = await import('fs');
|
|
writeFileSync(
|
|
fixturePath,
|
|
JSON.stringify(valid) + '\n' + JSON.stringify(broken) + '\n' + JSON.stringify(valid) + '\n',
|
|
'utf8',
|
|
);
|
|
await runEvalLongMemEval(
|
|
[fixturePath, '--keyword-only', '--retrieval-only', '--output', outPath],
|
|
{ engine: sharedEngine },
|
|
);
|
|
const text = readFileSync(outPath, 'utf8');
|
|
const lines = text.split('\n').filter(l => l.length > 0).map(l => JSON.parse(l));
|
|
expect(lines.length).toBe(3);
|
|
expect(lines[0].question_id).toBe('lme-ok-1');
|
|
expect(typeof lines[0].hypothesis).toBe('string');
|
|
expect(lines[1].question_id).toBe('lme-broken-1');
|
|
expect(lines[1].hypothesis).toBe('');
|
|
expect(typeof lines[1].error).toBe('string');
|
|
expect(lines[1].error.length).toBeGreaterThan(0);
|
|
expect(lines[2].question_id).toBe('lme-ok-1');
|
|
expect(typeof lines[2].hypothesis).toBe('string');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 13. v0.35.1.0: --resume-from
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEvalLongMemEval --resume-from (v0.35.1.0)', () => {
|
|
test('skips already-answered questions and appends to the same output file', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
// Simulate prior run: 2 questions already answered, written to the file
|
|
// with hypothesis set. The fixture has 5 questions total.
|
|
const { writeFileSync } = await import('fs');
|
|
const fixture = readFileSync(FIXTURE_PATH, 'utf8')
|
|
.split('\n').filter(l => l.length > 0).map(l => JSON.parse(l));
|
|
writeFileSync(
|
|
outPath,
|
|
[
|
|
JSON.stringify({ question_id: fixture[0].question_id, hypothesis: 'prior-1' }),
|
|
JSON.stringify({ question_id: fixture[1].question_id, hypothesis: 'prior-2' }),
|
|
].join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
|
|
const { client } = makeStubClient('resumed-answer');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '5', '--top-k', '3',
|
|
'--output', outPath, '--resume-from', outPath],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
|
|
const text = readFileSync(outPath, 'utf8');
|
|
const lines = text.split('\n').filter(l => l.length > 0).map(l => JSON.parse(l));
|
|
// 2 prior rows + 3 new rows = 5 total
|
|
expect(lines.length).toBe(5);
|
|
// First two preserve their prior hypothesis (proves append, not truncate).
|
|
expect(lines[0].hypothesis).toBe('prior-1');
|
|
expect(lines[1].hypothesis).toBe('prior-2');
|
|
// Newly-answered three carry the canned stub.
|
|
for (let i = 2; i < 5; i++) {
|
|
expect(lines[i].hypothesis).toContain('resumed-answer');
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
test('all questions already done -> early return, no client calls', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const outPath = join(tmp, 'all-done.jsonl');
|
|
try {
|
|
const { writeFileSync } = await import('fs');
|
|
const fixture = readFileSync(FIXTURE_PATH, 'utf8')
|
|
.split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)).slice(0, 5);
|
|
writeFileSync(
|
|
outPath,
|
|
fixture.map(q => JSON.stringify({ question_id: q.question_id, hypothesis: 'done' })).join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
const { client, calls } = makeStubClient('should-not-be-called');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '5',
|
|
'--output', outPath, '--resume-from', outPath],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
// The client must not have been invoked at all — every question was skipped.
|
|
expect(calls.length).toBe(0);
|
|
// The output file is untouched (no new lines appended).
|
|
const lines = readFileSync(outPath, 'utf8').split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(5);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 12. v0.40.1.0 (Track D / T1 + T2): question field on every row + --by-type
|
|
// summary emission with resume-replace semantics + --by-type-floor exit gate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEvalLongMemEval --by-type (v0.40.1.0 Track D / T1+T2)', () => {
|
|
test('per-row JSONL includes the question text (T1, per D9)', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'hypothesis.jsonl');
|
|
try {
|
|
const { client } = makeStubClient('canned');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', outPath],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
const lines = readFileSync(outPath, 'utf8').split('\n').filter(l => l.length > 0);
|
|
expect(lines.length).toBe(3);
|
|
for (const line of lines) {
|
|
const row = JSON.parse(line);
|
|
expect(typeof row.question).toBe('string');
|
|
expect(row.question.length).toBeGreaterThan(0);
|
|
expect(typeof row.question_id).toBe('string');
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
test('--by-type emits a final by_type_summary line; absent when flag not set', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const withFlag = join(tmp, 'with-by-type.jsonl');
|
|
const withoutFlag = join(tmp, 'without-by-type.jsonl');
|
|
try {
|
|
const { client } = makeStubClient('canned');
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', withFlag, '--by-type'],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', withoutFlag],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
|
|
// With flag: last line is the summary.
|
|
const withLines = readFileSync(withFlag, 'utf8').split('\n').filter(l => l.length > 0);
|
|
const lastWith = JSON.parse(withLines[withLines.length - 1]);
|
|
expect(lastWith.kind).toBe('by_type_summary');
|
|
expect(lastWith.schema_version).toBe(1);
|
|
expect(typeof lastWith.recall_by_type).toBe('object');
|
|
expect(typeof lastWith.aggregate.hit).toBe('number');
|
|
expect(typeof lastWith.aggregate.total).toBe('number');
|
|
// Per-question rows must NOT have kind:by_type_summary.
|
|
for (let i = 0; i < withLines.length - 1; i++) {
|
|
const row = JSON.parse(withLines[i]);
|
|
expect(row.kind).toBeUndefined();
|
|
}
|
|
|
|
// Without flag: no summary anywhere.
|
|
const withoutLines = readFileSync(withoutFlag, 'utf8').split('\n').filter(l => l.length > 0);
|
|
for (const line of withoutLines) {
|
|
const row = JSON.parse(line);
|
|
expect(row.kind).toBeUndefined();
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
test('resume-replace: prior by_type_summary at the tail is REPLACED, not appended', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-test-'));
|
|
const outPath = join(tmp, 'resume.jsonl');
|
|
try {
|
|
const { client } = makeStubClient('canned');
|
|
// First run: --limit 3 produces 3 rows + 1 summary = 4 lines.
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', outPath, '--by-type'],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
const firstLines = readFileSync(outPath, 'utf8').split('\n').filter(l => l.length > 0);
|
|
const firstSummaryCount = firstLines.filter(l => {
|
|
try { return JSON.parse(l).kind === 'by_type_summary'; } catch { return false; }
|
|
}).length;
|
|
expect(firstSummaryCount).toBe(1);
|
|
expect(firstLines.length).toBe(4);
|
|
|
|
// Re-run with --limit 5 + --resume-from same path: 2 NEW questions get
|
|
// processed, by-type fires again, prior summary must be replaced (not
|
|
// duplicated). Exercises the full resume-replace code path.
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '5', '--output', outPath,
|
|
'--resume-from', outPath, '--by-type'],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
const secondLines = readFileSync(outPath, 'utf8').split('\n').filter(l => l.length > 0);
|
|
const secondSummaryCount = secondLines.filter(l => {
|
|
try { return JSON.parse(l).kind === 'by_type_summary'; } catch { return false; }
|
|
}).length;
|
|
expect(secondSummaryCount).toBe(1);
|
|
// 5 rows + 1 summary = 6 lines (original summary was stripped, new one
|
|
// appended).
|
|
expect(secondLines.length).toBe(6);
|
|
const last = JSON.parse(secondLines[secondLines.length - 1]);
|
|
expect(last.kind).toBe('by_type_summary');
|
|
// Summary aggregates across ALL 5 rows (not just the 2 newly processed).
|
|
// The fixture has ground truth on every row, so total == 5.
|
|
expect(last.aggregate.total).toBe(5);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 13. Codex CDX-3 — resume + --by-type-floor must enforce the floor even on
|
|
// a no-op resume (where all questions already done). Pre-CDX-3 the early
|
|
// return bypassed the floor gate entirely.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('codex CDX-3 — resume + --by-type-floor enforcement on no-op resume', () => {
|
|
test('all-done resume still runs --by-type emission AND --by-type-floor gate', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const outPath = join(tmp, 'all-done.jsonl');
|
|
try {
|
|
// Pre-seed the output file with all-failed rows (recall_hit: false).
|
|
// This represents a prior run that completed every question but with
|
|
// very poor recall — the floor gate should fire even though no
|
|
// questions are processed THIS run.
|
|
const fixture = readFileSync(FIXTURE_PATH, 'utf8')
|
|
.split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)).slice(0, 5);
|
|
const { writeFileSync } = await import('fs');
|
|
writeFileSync(
|
|
outPath,
|
|
fixture.map(q => JSON.stringify({
|
|
question_id: q.question_id,
|
|
question: q.question,
|
|
question_type: q.question_type,
|
|
hypothesis: 'done',
|
|
recall_hit: false, // every prior question missed
|
|
})).join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
|
|
const { client } = makeStubClient('should-not-be-called');
|
|
// Wrap to catch process.exit thrown from inside.
|
|
const exitCapture: { code: number | null } = { code: null };
|
|
const originalExit = process.exit;
|
|
// @ts-ignore — runtime override for test
|
|
process.exit = ((code: number) => {
|
|
exitCapture.code = code;
|
|
throw new Error('__exit__');
|
|
}) as any;
|
|
try {
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '5',
|
|
'--output', outPath, '--resume-from', outPath,
|
|
'--by-type', '--by-type-floor', '0.5'],
|
|
{ client, engine: sharedEngine },
|
|
);
|
|
} catch (e) {
|
|
// Expected: --by-type-floor breach → exit(1) → our test throw
|
|
if (!String(e).includes('__exit__')) throw e;
|
|
} finally {
|
|
// @ts-ignore — runtime restore
|
|
process.exit = originalExit;
|
|
}
|
|
|
|
// CDX-3: floor gate fired despite no-op resume → exit code 1.
|
|
expect(exitCapture.code).toBe(1);
|
|
|
|
// AND a by_type_summary was emitted at the file tail (CDX-3 also says
|
|
// resume must run summary emission even on no-op).
|
|
const lines = readFileSync(outPath, 'utf8').split('\n').filter(l => l.length > 0);
|
|
const summaries = lines.filter(l => {
|
|
try { return JSON.parse(l).kind === 'by_type_summary'; } catch { return false; }
|
|
});
|
|
expect(summaries.length).toBe(1);
|
|
const summary = JSON.parse(summaries[0]);
|
|
// All rows had recall_hit: false → aggregate.rate is 0 → below 0.5 floor.
|
|
expect(summary.aggregate.rate).toBeLessThan(0.5);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
});
|