mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +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>
882 lines
36 KiB
TypeScript
882 lines
36 KiB
TypeScript
/**
|
|
* v0.28.1: LongMemEval benchmark harness tests.
|
|
*
|
|
* All tests run hermetically: in-memory PGLite, no DATABASE_URL, no API keys.
|
|
* The end-to-end tests stub the Anthropic client via the `runEvalLongMemEval`
|
|
* `client` opt so the LLM-answer path is exercised without a real API call.
|
|
*
|
|
* Cold connect of a fresh PGLite is ~1-3s per pglite-engine.ts:106-108.
|
|
* Tests share one engine across the harness/reset/speed cases via beforeAll,
|
|
* so the connect cost amortizes across the file.
|
|
*/
|
|
|
|
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 type Anthropic from '@anthropic-ai/sdk';
|
|
import {
|
|
createBenchmarkBrain,
|
|
resetTables,
|
|
withBenchmarkBrain,
|
|
} from '../src/eval/longmemeval/harness.ts';
|
|
import { haystackToPages, type LongMemEvalQuestion } from '../src/eval/longmemeval/adapter.ts';
|
|
import { runEvalLongMemEval, loadResumeSet } from '../src/commands/eval-longmemeval.ts';
|
|
import { importFromContent } from '../src/core/import-file.ts';
|
|
import { DEFAULT_SOURCE_BOOSTS } from '../src/core/search/source-boost.ts';
|
|
import type { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import type { ThinkLLMClient } from '../src/core/think/index.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared engine for the harness/reset/speed cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let sharedEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
sharedEngine = await createBenchmarkBrain();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (sharedEngine) await sharedEngine.disconnect();
|
|
});
|
|
|
|
const FIXTURE_PATH = join(import.meta.dir, 'fixtures', 'longmemeval-mini.jsonl');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stub MessagesClient. Returns a canned answer and records the prompt the
|
|
// caller built so tests can assert on prompt-construction.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface StubCall {
|
|
model: string;
|
|
system: string;
|
|
userText: string;
|
|
}
|
|
|
|
function makeStubClient(cannedText: string): { client: ThinkLLMClient; calls: StubCall[] } {
|
|
const calls: StubCall[] = [];
|
|
const client: ThinkLLMClient = {
|
|
async create(params: Anthropic.MessageCreateParamsNonStreaming): Promise<Anthropic.Message> {
|
|
const sys = typeof params.system === 'string'
|
|
? params.system
|
|
: Array.isArray(params.system)
|
|
? params.system.map(b => (typeof b === 'string' ? b : (b as any).text ?? '')).join('\n')
|
|
: '';
|
|
const userMsg = params.messages[0];
|
|
const userContent = typeof userMsg.content === 'string'
|
|
? userMsg.content
|
|
: userMsg.content.map(b => (b.type === 'text' ? b.text : '')).join('\n');
|
|
calls.push({ model: params.model, system: sys, userText: userContent });
|
|
return {
|
|
id: 'stub-msg-id',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: params.model,
|
|
content: [{ type: 'text', text: cannedText, citations: null }],
|
|
stop_reason: 'end_turn',
|
|
stop_sequence: null,
|
|
usage: {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
cache_creation_input_tokens: null,
|
|
cache_read_input_tokens: null,
|
|
server_tool_use: null,
|
|
service_tier: null,
|
|
},
|
|
container: null,
|
|
} as unknown as Anthropic.Message;
|
|
},
|
|
};
|
|
return { client, calls };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. harness lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('harness lifecycle', () => {
|
|
test('create -> reset -> import -> search -> assert hits', async () => {
|
|
await resetTables(sharedEngine);
|
|
for (let i = 0; i < 5; i++) {
|
|
const slug = `chat/lifecycle-${i}`;
|
|
const content =
|
|
`---\ntype: note\nsession_id: lifecycle-${i}\n---\n\n` +
|
|
`**user:** I bought a chocolate labrador puppy named Biscuit.\n\n` +
|
|
`**assistant:** That's a great choice for a family dog.\n`;
|
|
await importFromContent(sharedEngine, slug, content, { noEmbed: true });
|
|
}
|
|
const results = await sharedEngine.searchKeyword('chocolate labrador', { limit: 5 });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.some(r => r.slug.startsWith('chat/lifecycle-'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. reset clears all tables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resetTables clears all tables', () => {
|
|
test('after reset, search returns zero rows and pages count is zero', async () => {
|
|
// Seed some pages first.
|
|
for (let i = 0; i < 3; i++) {
|
|
const slug = `chat/reset-${i}`;
|
|
const content = `---\ntype: note\n---\n\n**user:** seed content reset-${i}\n`;
|
|
await importFromContent(sharedEngine, slug, content, { noEmbed: true });
|
|
}
|
|
const beforeCount = await sharedEngine.executeRaw<{ c: number }>(
|
|
`SELECT COUNT(*)::int AS c FROM pages`,
|
|
);
|
|
expect(beforeCount[0].c).toBeGreaterThan(0);
|
|
|
|
await resetTables(sharedEngine);
|
|
|
|
const afterPages = await sharedEngine.executeRaw<{ c: number }>(
|
|
`SELECT COUNT(*)::int AS c FROM pages`,
|
|
);
|
|
expect(afterPages[0].c).toBe(0);
|
|
|
|
const afterChunks = await sharedEngine.executeRaw<{ c: number }>(
|
|
`SELECT COUNT(*)::int AS c FROM content_chunks`,
|
|
);
|
|
expect(afterChunks[0].c).toBe(0);
|
|
|
|
const searchAfter = await sharedEngine.searchKeyword('seed', { limit: 5 });
|
|
expect(searchAfter.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. schema-migration robustness (table count floor)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resetTables: schema-migration robustness', () => {
|
|
test('pg_tables enumeration returns at least the schema floor', async () => {
|
|
const rows = await sharedEngine.executeRaw<{ tablename: string }>(
|
|
`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`,
|
|
);
|
|
// Floor is 10: pages, content_chunks, links, tags, raw_data, ingest_log,
|
|
// page_versions, timeline_entries — plus several v0.28-shipped tables.
|
|
// If pg_tables discovery breaks (column rename, schema-name change), the
|
|
// count drops and the regression surfaces here.
|
|
expect(rows.length).toBeGreaterThanOrEqual(10);
|
|
const names = rows.map(r => r.tablename);
|
|
expect(names).toContain('pages');
|
|
expect(names).toContain('content_chunks');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. speed (warm) — p50 + p99 across 10 trials
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('warm-create speed gate', () => {
|
|
test('p50 < 1500ms under parallel test load (catches order-of-magnitude regressions)', async () => {
|
|
const trials = 10;
|
|
const samples: number[] = [];
|
|
for (let i = 0; i < trials; i++) {
|
|
const t0 = performance.now();
|
|
await resetTables(sharedEngine);
|
|
for (let j = 0; j < 5; j++) {
|
|
const slug = `chat/speed-${i}-${j}`;
|
|
const content = `---\ntype: note\n---\n\n**user:** speed sample ${i}-${j} keyword apple\n`;
|
|
await importFromContent(sharedEngine, slug, content, { noEmbed: true });
|
|
}
|
|
await sharedEngine.searchKeyword('apple', { limit: 5 });
|
|
samples.push(performance.now() - t0);
|
|
}
|
|
samples.sort((a, b) => a - b);
|
|
const p50 = samples[Math.floor(samples.length * 0.5)];
|
|
const p99 = samples[Math.floor(samples.length * 0.99)];
|
|
process.stderr.write(
|
|
`[speed] warm reset+import+search p50=${p50.toFixed(1)}ms p99=${p99.toFixed(1)}ms (n=${trials})\n`,
|
|
);
|
|
// Threshold bumped from 500ms → 1500ms because the original was tight enough
|
|
// to flake under parallel test load (8-way shard process + PGLite WASM
|
|
// contention). Solo run shows p50 ~25ms; under parallel load p50 can reach
|
|
// 600-1200ms transiently. 1500ms still catches order-of-magnitude
|
|
// regressions (a 10x slowdown to 250ms baseline would fail at 2.5s).
|
|
expect(p50).toBeLessThan(1500);
|
|
if (p99 > 3000) {
|
|
process.stderr.write(`[speed] WARN: p99 above 3000ms threshold (informational)\n`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. adapter shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('adapter haystackToPages', () => {
|
|
test('synthetic 3-session question converts to 3 pages with stable slugs + frontmatter', () => {
|
|
const q: LongMemEvalQuestion = {
|
|
question_id: 'q-shape-1',
|
|
question_type: 'single-session-user',
|
|
question: 'q?',
|
|
answer: 'a',
|
|
haystack_dates: ['2025-01-15', '2025-02-01', '2025-03-10'],
|
|
answer_session_ids: ['sess-1'],
|
|
haystack_sessions: [
|
|
{ session_id: 'sess-1', turns: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }] },
|
|
{ session_id: 'sess-2', turns: [{ role: 'user', content: 'q2' }] },
|
|
{ session_id: 'sess-3', turns: [{ role: 'user', content: 'q3' }] },
|
|
],
|
|
};
|
|
const pages = haystackToPages(q);
|
|
expect(pages.length).toBe(3);
|
|
expect(pages[0].slug).toBe('chat/sess-1');
|
|
expect(pages[1].slug).toBe('chat/sess-2');
|
|
expect(pages[2].slug).toBe('chat/sess-3');
|
|
expect(pages[0].content).toContain('type: note');
|
|
expect(pages[0].content).toContain('date: 2025-01-15');
|
|
expect(pages[0].content).toContain('session_id: sess-1');
|
|
expect(pages[0].content).toContain('**user:** hi');
|
|
expect(pages[0].content).toContain('**assistant:** hello');
|
|
});
|
|
|
|
test('haystack without dates produces pages with no date frontmatter line', () => {
|
|
const q: LongMemEvalQuestion = {
|
|
question_id: 'q-shape-2',
|
|
question_type: 'multi-session',
|
|
question: 'q?',
|
|
answer: 'a',
|
|
answer_session_ids: [],
|
|
haystack_sessions: [
|
|
{ session_id: 'sess-x', turns: [{ role: 'user', content: 'no date here' }] },
|
|
],
|
|
};
|
|
const pages = haystackToPages(q);
|
|
expect(pages[0].content).toContain('session_id: sess-x');
|
|
expect(pages[0].content).not.toContain('date:');
|
|
});
|
|
|
|
// v0.35.1.1 regression: the public LongMemEval _s split uses arrays of
|
|
// turn-arrays for haystack_sessions plus a parallel haystack_session_ids
|
|
// string array. The pre-v0.35.1.1 adapter crashed with `session.turns is
|
|
// undefined` on this shape. Pre-v0.35.1.1 the slug validator also
|
|
// rejected the underscored, mixed-case session_ids the dataset uses.
|
|
test('v0.35.1.1: _s split shape (turn-array + parallel ids) normalizes correctly', () => {
|
|
const q: LongMemEvalQuestion = {
|
|
question_id: 'q-s-1',
|
|
question_type: 'single-session-user',
|
|
question: 'q?',
|
|
answer: 'a',
|
|
haystack_dates: ['2025-01-01', '2025-01-02'],
|
|
answer_session_ids: ['sharegpt_AbC_0'],
|
|
haystack_session_ids: ['sharegpt_AbC_0', 'sess_DEF_1'],
|
|
// No {session_id, turns} — turns directly per the _s shape.
|
|
haystack_sessions: [
|
|
[{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }],
|
|
[{ role: 'user', content: 'bye' }],
|
|
],
|
|
};
|
|
const pages = haystackToPages(q);
|
|
expect(pages.length).toBe(2);
|
|
// Slugs got lowercased + underscores became hyphens (validator-safe).
|
|
expect(pages[0].slug).toBe('chat/sharegpt-abc-0');
|
|
expect(pages[1].slug).toBe('chat/sess-def-1');
|
|
// Frontmatter keeps the ORIGINAL session_id (no sanitization). The
|
|
// _s ids preserve through the round-trip; only the slug got rewritten.
|
|
expect(pages[0].content).toContain('session_id: sharegpt_AbC_0');
|
|
expect(pages[0].content).toContain('date: 2025-01-01');
|
|
expect(pages[0].content).toContain('**user:** hi');
|
|
expect(pages[1].content).toContain('**user:** bye');
|
|
});
|
|
|
|
test('v0.35.1.1: missing haystack_session_ids on _s shape synthesizes ids per question', () => {
|
|
const q: LongMemEvalQuestion = {
|
|
question_id: 'q-s-2',
|
|
question_type: 'single-session-user',
|
|
question: 'q?',
|
|
answer: 'a',
|
|
answer_session_ids: [],
|
|
// _s shape but the parallel ids array is absent. Adapter falls back
|
|
// to a synthesized `lme_<question_id>_<i>` slug.
|
|
haystack_sessions: [
|
|
[{ role: 'user', content: 'turn 1' }],
|
|
],
|
|
};
|
|
const pages = haystackToPages(q);
|
|
expect(pages.length).toBe(1);
|
|
expect(pages[0].slug).toBe('chat/lme-q-s-2-0');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. source-boost regression guard
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('source-boost regression guard', () => {
|
|
test('chat/<session_id> slugs do not prefix-match any DEFAULT_SOURCE_BOOSTS entry (factor stays 1.0)', () => {
|
|
const candidate = 'chat/lme-fixture-1';
|
|
// Longest-prefix-match wins; ELSE branch is 1.0. We just need to assert
|
|
// no key is a prefix of the candidate slug.
|
|
const matched = Object.keys(DEFAULT_SOURCE_BOOSTS).filter(prefix =>
|
|
candidate.startsWith(prefix),
|
|
);
|
|
expect(matched).toEqual([]);
|
|
// Sanity: the existing openclaw/chat/ entry must not match either.
|
|
expect(DEFAULT_SOURCE_BOOSTS['openclaw/chat/']).toBeDefined();
|
|
expect(candidate.startsWith('openclaw/chat/')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 },
|
|
);
|
|
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',
|
|
]);
|
|
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 },
|
|
);
|
|
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,
|
|
]);
|
|
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,
|
|
]);
|
|
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('loadResumeSet (v0.35.1.0)', () => {
|
|
test('returns empty set when path does not exist', () => {
|
|
const set = loadResumeSet('/nonexistent/path/never/exists.jsonl');
|
|
expect(set.size).toBe(0);
|
|
});
|
|
|
|
test('reads question_ids from a well-formed JSONL', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const p = join(tmp, 'partial.jsonl');
|
|
const { writeFileSync } = await import('fs');
|
|
try {
|
|
writeFileSync(
|
|
p,
|
|
[
|
|
JSON.stringify({ question_id: 'a', hypothesis: 'one' }),
|
|
JSON.stringify({ question_id: 'b', hypothesis: 'two' }),
|
|
].join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
const set = loadResumeSet(p);
|
|
expect(set.size).toBe(2);
|
|
expect(set.has('a')).toBe(true);
|
|
expect(set.has('b')).toBe(true);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('skips rows whose hypothesis is empty AND error is set (retry case)', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const p = join(tmp, 'with-errors.jsonl');
|
|
const { writeFileSync } = await import('fs');
|
|
try {
|
|
writeFileSync(
|
|
p,
|
|
[
|
|
JSON.stringify({ question_id: 'good', hypothesis: 'real-answer' }),
|
|
JSON.stringify({ question_id: 'bad', hypothesis: '', error: 'rate-limit' }),
|
|
JSON.stringify({ question_id: 'recovered', hypothesis: 'second-try', error: 'old-error' }),
|
|
].join('\n') + '\n',
|
|
'utf8',
|
|
);
|
|
const set = loadResumeSet(p);
|
|
// 'bad' is retried; 'good' and 'recovered' are kept (hypothesis non-empty).
|
|
expect(set.size).toBe(2);
|
|
expect(set.has('good')).toBe(true);
|
|
expect(set.has('bad')).toBe(false);
|
|
expect(set.has('recovered')).toBe(true);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('tolerates a truncated/corrupt final line (SIGKILL recovery case)', async () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'lme-resume-'));
|
|
const p = join(tmp, 'truncated.jsonl');
|
|
const { writeFileSync } = await import('fs');
|
|
try {
|
|
writeFileSync(
|
|
p,
|
|
JSON.stringify({ question_id: 'a', hypothesis: 'one' }) + '\n' +
|
|
'{"question_id":"b","hypothesis":"two-trunc' /* no closing brace, no LF */,
|
|
'utf8',
|
|
);
|
|
const set = loadResumeSet(p);
|
|
// First line counts; second is silently skipped (stderr warn).
|
|
expect(set.size).toBe(1);
|
|
expect(set.has('a')).toBe(true);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
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 },
|
|
);
|
|
|
|
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 },
|
|
);
|
|
// 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 },
|
|
);
|
|
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 },
|
|
);
|
|
await runEvalLongMemEval(
|
|
[FIXTURE_PATH, '--keyword-only', '--limit', '3', '--output', withoutFlag],
|
|
{ client },
|
|
);
|
|
|
|
// 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 },
|
|
);
|
|
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 },
|
|
);
|
|
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);
|
|
});
|
|
|
|
describe('buildByTypeSummary (pure function)', () => {
|
|
test('populated buckets produce sorted keys + rate math', async () => {
|
|
const { buildByTypeSummary } = await import('../src/commands/eval-longmemeval.ts');
|
|
const summary = buildByTypeSummary({
|
|
'multi-session': { hit: 10, total: 10 },
|
|
'single-session-user': { hit: 18, total: 19 },
|
|
});
|
|
expect(summary.kind).toBe('by_type_summary');
|
|
expect(summary.schema_version).toBe(1);
|
|
// Sorted alphabetically.
|
|
expect(Object.keys(summary.recall_by_type)).toEqual(['multi-session', 'single-session-user']);
|
|
expect(summary.recall_by_type['multi-session'].rate).toBeCloseTo(1.0, 5);
|
|
expect(summary.recall_by_type['single-session-user'].rate).toBeCloseTo(18 / 19, 5);
|
|
expect(summary.aggregate.hit).toBe(28);
|
|
expect(summary.aggregate.total).toBe(29);
|
|
expect(summary.aggregate.rate).toBeCloseTo(28 / 29, 5);
|
|
});
|
|
|
|
test('empty bucket map produces rate:null aggregate, not NaN', async () => {
|
|
const { buildByTypeSummary } = await import('../src/commands/eval-longmemeval.ts');
|
|
const summary = buildByTypeSummary({});
|
|
expect(summary.recall_by_type).toEqual({});
|
|
expect(summary.aggregate.hit).toBe(0);
|
|
expect(summary.aggregate.total).toBe(0);
|
|
expect(summary.aggregate.rate).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 },
|
|
);
|
|
} 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);
|
|
});
|