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>
389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
/**
|
|
* v0.28.1: LongMemEval benchmark harness tests.
|
|
*
|
|
* All tests run hermetically: in-memory PGLite, no DATABASE_URL, no API keys.
|
|
*
|
|
* v0.41.10 split: this file now holds the pure / harness-shared half of the
|
|
* surface — describes that don't call `runEvalLongMemEval` and so don't pay
|
|
* the per-call PGLite cold-create cost. End-to-end describes that DO call
|
|
* `runEvalLongMemEval` (and create their own benchmark brain via
|
|
* `withBenchmarkBrain`) live in test/eval-longmemeval-e2e.slow.test.ts.
|
|
* Both files run as separate .slow.test.ts entries so CI's LPT bin-packer
|
|
* (scripts/sharding.ts) can distribute them across different shards.
|
|
*
|
|
* 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, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
createBenchmarkBrain,
|
|
resetTables,
|
|
} from '../src/eval/longmemeval/harness.ts';
|
|
import { haystackToPages, type LongMemEvalQuestion } from '../src/eval/longmemeval/adapter.ts';
|
|
import { 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';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared engine for the harness/reset/speed cases
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let sharedEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
sharedEngine = await createBenchmarkBrain();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (sharedEngine) await sharedEngine.disconnect();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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', () => {
|
|
// v0.40.10 flake-hardening: mode-aware ceiling. Solo run on Apple Silicon
|
|
// shows p50 ~25ms; under 8-way shard CPU contention p50 reaches 600-1200ms;
|
|
// GitHub Actions Ubuntu runners are slower yet (CI run #77585655194 hit
|
|
// 17364ms total / ~1736ms/trial). Detect "loaded execution" via `$SHARD`
|
|
// (set by scripts/run-unit-parallel.sh) OR `$CI` (set by every major CI).
|
|
// Loaded ceiling 4000ms still catches >50x algorithmic regressions.
|
|
const LOADED = !!process.env.SHARD || !!process.env.CI;
|
|
const P50_CEILING_MS = LOADED ? 4000 : 1500;
|
|
test(`p50 < ${P50_CEILING_MS}ms 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}, ceiling=${P50_CEILING_MS}ms loaded=${LOADED})\n`,
|
|
);
|
|
expect(p50).toBeLessThan(P50_CEILING_MS);
|
|
if (p99 > P50_CEILING_MS * 2) {
|
|
process.stderr.write(`[speed] WARN: p99 above ${P50_CEILING_MS * 2}ms 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);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 13. v0.35.1.0: --resume-from helper (pure file I/O)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildByTypeSummary (pure function — no PGLite, no LLM)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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();
|
|
});
|
|
});
|