Files
gbrain/test/eval-longmemeval.test.ts
T
f004a27429 v0.35.1.1: longmemeval fix wave (adapter + slug + gateway-wire) (#1056)
* docs(designs): 2026-05 embedder shootout eval plan

Adds docs/designs/2026_05_EVAL_PLAN.md — the approved plan + 6 Conductor session
briefs for the OpenAI vs Voyage vs ZeroEntropy embedder comparison.

Why: produce a publishable comparison report for v0.35.x release notes pinning
"which embedder wins, and does zerank-2 carry the win for ZeroEntropy" against
public LongMemEval + in-house BrainBench.

Each session brief is self-contained — repo, branch, commits, verify, ship,
deliverable, hand-off. Stewardable one section per Conductor session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pricing): add voyage-4-large + zembed-1 to EMBEDDING_PRICING

v0.35.0.0 shipped ZeroEntropy zembed-1 + zerank-2 reranker support and
expanded the Voyage allow-list to include voyage-4-large. The pricing
table missed both, so `gbrain upgrade`'s post-upgrade reembed prompt
silently fell back to "estimate unavailable" for users on these models.

- voyage:voyage-4-large @ $0.18/MTok (same as voyage-3-large)
- zeroentropyai:zembed-1 @ $0.05/MTok

New test file pins both entries plus the openai/voyage-3-large baselines,
case-insensitive provider matching, bare-model openai-default fallback,
table integrity (lowercase providers, finite non-negative prices), and
the estimateCostFromChars approximation. 11 cases, 46 expect() calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(exports): expose gbrain/ai/gateway with canary test

Adds ./ai/gateway to the package.json exports map so external eval
consumers (notably gbrain-evals, the sibling repo running the embedder
shootout in docs/designs/2026_05_EVAL_PLAN.md) can call configureGateway
directly to swap embedding providers per cell.

Why: pre-v0.35.1.0, gbrain-evals adapters hardcoded gbrain/embedding,
which means every retrieval adapter was OpenAI-only. The newly-exposed
gateway lets adapters route through Voyage and ZeroEntropy without
forking gbrain or duplicating the recipe wiring.

- package.json: add "./ai/gateway" -> "./src/core/ai/gateway.ts"
- scripts/check-exports-count.sh: bump expected count 17 -> 18
- test/public-exports.test.ts: add canary pinning configureGateway + embed,
  bump expected count assertion

Pre-existing import-resolution failures in this test file (16 on master)
are unrelated to this change — they're a longstanding Bun package
self-import behavior. The count + EXPECTED_EXPORTS list-match assertions
both pass cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): add --resume-from <jsonl> to gbrain eval longmemeval

Multi-cell embedder shootouts spend $50+/cell on the gpt-4o judge after
gbrain emits hypotheses. A mid-run abort (rate-limit, cost-cap, OS
interrupt, SIGKILL) previously meant re-paying the full cell. This flag
makes those aborts cheap: re-invoke with --resume-from pointed at the
partial JSONL and only the unanswered question_ids re-run.

Behavior:
- Read question_ids from the file; skip them on this run.
- Rows with non-empty hypothesis count as done.
- Rows with hypothesis="" AND an error field are NOT skipped (retry case
  for per-question failures recorded by the existing try/catch).
- Corrupt trailing lines (SIGKILL'd writer mid-line) are silently skipped
  with a stderr warn.
- When --resume-from path == --output path, the output emitter opens the
  file in append mode instead of truncating, so the existing rows survive.
- Empty resume case (all questions already done) returns immediately
  without spinning up the brain or calling the client.

New exported helper loadResumeSet() makes the parser unit-testable.

6 new test cases pinning:
- File-not-found returns empty set
- Well-formed JSONL load
- Error-row retry semantics (empty hypothesis + error -> not in set)
- Truncated final line recovery
- End-to-end resume against the 5-question mini fixture
- All-done early-return (stub client must NOT be invoked)

All 18 cases in test/eval-longmemeval.test.ts green; bun run typecheck
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: v0.35.1.0

Bumps VERSION + package.json + CHANGELOG entry for the embedder-shootout
prereq release. Three additive changes from the prior 4 commits:

- pricing: voyage-4-large + zembed-1 entries
- exports: gbrain/ai/gateway is now public
- eval: gbrain eval longmemeval --resume-from <jsonl>

Each commit on this branch is independently bisect-friendly and CI-green;
the CHANGELOG entry is the user-facing rollup. No migrations, no breaking
changes — the gateway export expands the surface, the resume-from flag is
additive, the pricing patch only changes "estimate unavailable" -> a real
dollar figure for two specific models.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(eval): longmemeval adapter handles _s split + sanitizes session_id slugs

Three tightly-coupled bugs blocked `gbrain eval longmemeval` against the
public LongMemEval _s split from HuggingFace (the dataset every shootout
cell needs):

1. HAYSTACK SHAPE: the _s split serializes haystack_sessions as
   LongMemEvalTurn[][] (each inner array is one session's turns directly)
   plus a parallel `haystack_session_ids: string[]` field. The
   pre-v0.35.1.1 adapter expected only the oracle `{session_id, turns}`
   shape and crashed with `session.turns is undefined` on every question.
   Fix: new `normalizeSessions` helper accepts both shapes, mirroring the
   proven `normalizeSessions` in gbrain-evals/eval/runner/longmemeval.ts.

2. SLUG VALIDATOR: the _s split's session_ids look like
   `sharegpt_yywfIrx_0` — underscored and mixed-case. The v0.32.7 CJK
   wave's `validatePageSlug` rejects both (allowed set is `[a-z0-9-]`
   case-insensitive, slash-separated). Fix: `sanitizeSessionIdForSlug`
   lowercases and replaces `_` + `.` + any other non-[a-z0-9-] character
   with `-`. The frontmatter `session_id:` keeps the original verbatim
   for downstream JSONL emit; only the SLUG is rewritten.

3. INTERFACE: `LongMemEvalQuestion.haystack_sessions` typed as a union
   of `LongMemEvalSession[] | LongMemEvalTurn[][]` so TypeScript callers
   see both shapes are accepted. New `haystack_session_ids?: string[]`
   field documented as parallel to the array-of-turns shape.

Pre-v0.35.1.1 caught by a fresh smoke pre-spend (3 questions × ZE @ 2560
→ 3 errors). Post-fix: 3/3 OK with non-empty hypotheses, single-session
recall measured (low on a 3-question sample but the pipeline runs).

2 new regression test cases pinning:
- _s split shape normalizes (slugs sanitized + frontmatter preserves
  original session_id + dates flow through)
- _s split with missing haystack_session_ids synthesizes
  `lme_<question_id>_<i>` ids

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): configure AI gateway before running gbrain eval longmemeval

v0.28.8 skipped connectEngine() for `gbrain eval longmemeval` so the
subcommand could run on machines without a configured brain. Side
effect (silent until v0.35.1.0 made it observable via the embedder
shootout): the gateway was never configureGateway()'d either, so the
first embed call inside importFromContent crashed with "AI gateway is
not configured. Call configureGateway() during engine connect."

Fix: call configureGateway() before runEvalLongMemEval, mirroring the
connectEngine() path. Reads `~/.gbrain/config.json` when present; falls
back to env vars (GBRAIN_EMBEDDING_MODEL, GBRAIN_EMBEDDING_DIMENSIONS,
OPENAI_API_KEY, etc.) when there's no config — preserving the v0.28.8
"runs on fresh machine" property.

Gated on the --help short-circuit so `gbrain eval longmemeval --help`
still works without spinning up the gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: v0.35.1.1

Bumps VERSION + package.json + CHANGELOG entry for the longmemeval fix
wave. Three commits this branch:

1. fix(eval): adapter handles _s split + sanitizes session_id slugs
2. fix(cli): configure AI gateway before running gbrain eval longmemeval
3. chore: v0.35.1.1

Each commit independently bisects; CHANGELOG entry is the user-facing
rollup. No schema migration; no breaking change.

Caught pre-spend by smoking Phase 1 of the embedder shootout — would
otherwise have wasted ~$476 in judge tokens across 7 cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: retrigger workflows

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:19:04 -07:00

669 lines
27 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);
});