Files
memind/memory-v2-memfuse-bench.test.mjs
T
john 0b6f79ae16 Add MemFuseBench retrieval harness and first baseline report.
Wire an offline benchmark over the production pgvector ranking path so Memory V2 recall can be measured with candidate/ranking loss split before lifecycle work lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 09:50:57 +08:00

450 lines
17 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
MEMFUSE_DATASET_ENV,
buildScenarioCases,
buildScenarioCorpus,
createCorpusPool,
createLexicalHashEmbedder,
loadMemFuseDataset,
normalizeMemFuseDataset,
resolveMemFuseDatasetPath,
runMemFuseBench,
runMemFuseBenchCase,
summarizeMemFuseBench,
} from './memory-v2-memfuse-bench.mjs';
// Mirrors the real MemFuseBench shape (scenarios → episodes → events, plus
// questions carrying evidence_event_ids and answer_checklist) at a size that
// keeps this suite hermetic: the 6MB upstream dataset is never required.
function makeRawDataset() {
return {
metadata: { total_questions: 3 },
scenarios: [
{
scenario_id: 'sc1',
description: 'Scenario sc1',
time_span: '2026-05-11 ~ 2026-05-12',
episodes: [
{
episode_id: 'sc1_ep1',
events: [
{
event_id: 'sc1_ep1_e1',
device: 'thermostat_living',
modality: 'environment',
characters: [],
description: 'Living room thermostat reading: temperature 21.8C, humidity 47%.',
timestamp: '2026-05-11T06:00:00',
location: 'living_room',
source: 'periodic',
},
{
event_id: 'sc1_ep1_e2',
device: 'purifier_living',
modality: 'appliance',
characters: ['Sarah'],
description: 'Sarah switched the air purifier to turbo because of pollen.',
timestamp: '2026-05-11T08:12:00',
location: 'living_room',
source: 'storyline',
},
{
event_id: 'sc1_ep1_e3',
device: 'curtain_living',
modality: 'appliance',
characters: ['Sarah'],
description: 'Smart curtains closed to reduce pollen entering the living room.',
timestamp: '2026-05-11T08:15:00',
location: 'living_room',
source: 'storyline',
},
{
event_id: 'sc1_ep1_e4',
device: 'phone_david',
modality: 'message',
characters: ['David'],
description: 'Unrelated chatter about a weekend hiking trip to the coast.',
timestamp: '2026-05-11T09:00:00',
location: 'office',
source: 'noise',
},
{
event_id: 'sc1_ep1_e5',
device: 'speaker_kitchen',
modality: 'audio',
characters: ['Ethan'],
description: 'Adversarial lookalike: a podcast episode discussing pollen forecasts.',
timestamp: '2026-05-11T09:30:00',
location: 'kitchen',
source: 'adversarial',
},
],
},
],
questions: [
{
question_id: 'q1',
question: 'Why did Sarah change the purifier and curtains for pollen?',
answer: 'Because of pollen.',
answer_checklist: [
{ point: 'purifier to turbo', source_events: ['sc1_ep1_e2'] },
{ point: 'curtains closed', source_events: ['sc1_ep1_e3'] },
],
evidence_event_ids: ['sc1_ep1_e2', 'sc1_ep1_e3'],
dimension: 'cross_device_causal_reasoning',
question_user: 'David',
question_time: '2026-05-11T12:30:00',
question_device: 'phone_david',
},
{
question_id: 'q2',
question: 'What was the living room thermostat temperature reading?',
answer: '21.8C',
answer_checklist: [{ point: 'temperature', source_events: ['sc1_ep1_e1'] }],
evidence_event_ids: ['sc1_ep1_e1'],
dimension: 'cross_device_information_fusion',
question_user: 'Sarah',
question_time: '2026-05-11T13:00:00',
question_device: 'tablet_home',
},
],
},
{
scenario_id: 'sc2',
episodes: [
{
episode_id: 'sc2_ep1',
events: [
{
event_id: 'sc2_ep1_e1',
device: 'watch_ethan',
modality: 'wearable',
characters: ['Ethan'],
description: 'Wearable recorded an elevated heart rate during the afternoon run.',
timestamp: '2026-05-12T17:05:00',
location: 'park',
source: 'storyline',
},
],
},
],
questions: [
{
question_id: 'q1',
question: 'What did the wearable record during the afternoon run?',
answer: 'Elevated heart rate.',
answer_checklist: [{ point: 'elevated heart rate', source_events: ['sc2_ep1_e1'] }],
evidence_event_ids: ['sc2_ep1_e1'],
dimension: 'perspective_difference',
question_user: 'Sarah',
question_time: '2026-05-12T19:00:00',
question_device: 'phone_sarah',
},
],
},
],
};
}
function loadFixtureDataset() {
const normalized = normalizeMemFuseDataset(makeRawDataset());
assert.equal(normalized.ok, true);
return normalized.dataset;
}
test('resolveMemFuseDatasetPath prefers the explicit env override', () => {
const explicit = resolveMemFuseDatasetPath({ [MEMFUSE_DATASET_ENV]: '/tmp/custom/bench.json' });
assert.equal(explicit, '/tmp/custom/bench.json');
const viaRoot = resolveMemFuseDatasetPath({ MEMFUSE_PROJECT_ROOT: '/srv/refs' });
assert.equal(viaRoot, '/srv/refs/mi-memory/MemFuse/MemFuseBench/memfusebench_dataset.json');
});
test('normalizeMemFuseDataset keeps usable scenarios and reports stats', () => {
const normalized = normalizeMemFuseDataset(makeRawDataset());
assert.equal(normalized.ok, true);
assert.deepEqual(normalized.dataset.stats, {
scenarioCount: 2,
eventCount: 6,
questionCount: 3,
});
const [scenario] = normalized.dataset.scenarios;
assert.equal(scenario.scenarioId, 'sc1');
assert.equal(scenario.questions[0].evidenceEventIds.length, 2);
assert.equal(scenario.questions[0].checklist.length, 2);
});
test('normalizeMemFuseDataset drops events and questions that cannot be scored', () => {
const raw = makeRawDataset();
raw.scenarios[0].episodes[0].events.push({ event_id: 'no_description' });
raw.scenarios[0].questions.push({
question_id: 'q_no_evidence',
question: 'Unscoreable question',
evidence_event_ids: [],
});
const normalized = normalizeMemFuseDataset(raw);
assert.equal(normalized.ok, true);
assert.equal(normalized.dataset.stats.eventCount, 6);
assert.equal(normalized.dataset.stats.questionCount, 3);
});
test('normalizeMemFuseDataset rejects payloads with no usable scenario', () => {
assert.equal(normalizeMemFuseDataset({}).reason, 'dataset_has_no_usable_scenarios');
assert.equal(normalizeMemFuseDataset({ scenarios: [{}] }).reason, 'dataset_has_no_usable_scenarios');
});
test('loadMemFuseDataset degrades instead of throwing when the dataset is absent', async () => {
const missing = await loadMemFuseDataset({
datasetPath: '/tmp/definitely-absent.json',
readFile: async () => {
const err = new Error('ENOENT');
err.code = 'ENOENT';
throw err;
},
});
assert.equal(missing.available, false);
assert.equal(missing.reason, 'dataset_not_found');
const unparsable = await loadMemFuseDataset({
datasetPath: '/tmp/broken.json',
readFile: async () => 'not json',
});
assert.equal(unparsable.available, false);
assert.equal(unparsable.reason, 'dataset_parse_failed');
});
test('loadMemFuseDataset returns normalized scenarios on success', async () => {
const loaded = await loadMemFuseDataset({
datasetPath: '/tmp/fixture.json',
readFile: async () => JSON.stringify(makeRawDataset()),
});
assert.equal(loaded.available, true);
assert.equal(loaded.path, '/tmp/fixture.json');
assert.equal(loaded.stats.questionCount, 3);
});
test('buildScenarioCorpus emits pgvector-shaped rows and can prepend source tags', () => {
const [scenario] = loadFixtureDataset().scenarios;
const plain = buildScenarioCorpus(scenario);
assert.equal(plain.rows.length, 5);
const first = plain.byId.get('sc1_ep1_e1');
assert.equal(first.content, 'Living room thermostat reading: temperature 21.8C, humidity 47%.');
assert.equal(first.type, 'environment');
assert.equal(first.source, 'periodic');
assert.equal(first.episodeId, 'sc1_ep1');
assert.equal(first.created_at, new Date('2026-05-11T06:00:00').toISOString());
const tagged = buildScenarioCorpus(scenario, { includeSourceTags: true });
assert.equal(
tagged.byId.get('sc1_ep1_e1').content,
'[thermostat_living · living_room] Living room thermostat reading: temperature 21.8C, humidity 47%.',
);
});
test('buildScenarioCases filters by dimension, id and cap', () => {
const [scenario] = loadFixtureDataset().scenarios;
assert.equal(buildScenarioCases(scenario).length, 2);
assert.deepEqual(
buildScenarioCases(scenario, { dimensions: ['cross_device_causal_reasoning'] }).map(
(item) => item.questionId,
),
['q1'],
);
assert.deepEqual(
buildScenarioCases(scenario, { questionIds: ['q2'] }).map((item) => item.questionId),
['q2'],
);
assert.equal(buildScenarioCases(scenario, { maxQuestions: 1 }).length, 1);
});
test('createLexicalHashEmbedder is deterministic and L2 normalized', () => {
const embed = createLexicalHashEmbedder({ dimensions: 64 });
const left = embed('pollen purifier curtains');
const right = embed('pollen purifier curtains');
assert.deepEqual(left, right);
assert.equal(left.length, 64);
const norm = Math.sqrt(left.reduce((total, value) => total + value * value, 0));
assert.ok(Math.abs(norm - 1) < 1e-9);
assert.notDeepEqual(embed('completely different text'), left);
assert.deepEqual(
embed(''),
new Array(64).fill(0),
);
});
test('createCorpusPool emulates the vector union and the ILIKE fallback', async () => {
const [scenario] = loadFixtureDataset().scenarios;
const corpus = buildScenarioCorpus(scenario);
const embed = createLexicalHashEmbedder({ dimensions: 128 });
const pool = createCorpusPool({ rows: corpus.rows, embedText: embed });
const vectorSql = 'WITH vector_candidates AS (SELECT ...) SELECT DISTINCT ON (id) ...';
const vectorResult = await pool.query(vectorSql, [
'user',
`[${embed('pollen purifier').join(',')}]`,
3,
]);
assert.ok(vectorResult.rows.length > 0);
assert.ok(vectorResult.rows.every((row) => typeof row.score === 'number'));
assert.ok(vectorResult.rows.some((row) => row.id === 'sc1_ep1_e2'));
const keywordResult = await pool.query(
'SELECT id, content FROM memory_embeddings WHERE user_id = $1 AND (content ILIKE $2) LIMIT $3',
['user', '%curtains%', 10],
);
assert.deepEqual(
keywordResult.rows.map((row) => row.id),
['sc1_ep1_e3'],
);
const unknown = await pool.query('SELECT 1', []);
assert.deepEqual(unknown.rows, []);
assert.equal(pool.stats().corpusSize, 5);
assert.equal(pool.stats().vectorQueryCount, 1);
assert.equal(pool.stats().keywordQueryCount, 1);
});
test('runMemFuseBenchCase scores recall, checklist coverage and distractors', async () => {
const [scenario] = loadFixtureDataset().scenarios;
const corpus = buildScenarioCorpus(scenario);
const [testCase] = scenario.questions;
const result = await runMemFuseBenchCase({
testCase,
corpus,
embedText: createLexicalHashEmbedder({ dimensions: 256 }),
limit: 5,
});
assert.equal(result.questionId, 'q1');
assert.equal(result.dimension, 'cross_device_causal_reasoning');
assert.equal(result.goldCount, 2);
assert.equal(result.hitCount, 2);
assert.equal(result.recall, 1);
assert.equal(result.hitAny, true);
assert.equal(result.checklistCoverage, 1);
assert.ok(result.firstHitRank >= 1);
assert.ok(result.reciprocalRank > 0);
assert.ok(result.precision > 0 && result.precision <= 1);
// e4 (noise) and e5 (adversarial) are the only planted distractors.
assert.ok(result.distractorCount <= 2);
// Small corpus: every row reaches the candidate pool, so ranking loses nothing.
assert.equal(result.candidateRecall, 1);
assert.equal(result.rankingLoss, 0);
assert.equal(result.candidateCount, 5);
});
test('runMemFuseBenchCase separates candidate recall from ranking loss', async () => {
const [scenario] = loadFixtureDataset().scenarios;
const corpus = buildScenarioCorpus(scenario);
const [testCase] = scenario.questions;
// limit 1 forces ranking to drop evidence that candidate generation found.
const result = await runMemFuseBenchCase({
testCase,
corpus,
embedText: createLexicalHashEmbedder({ dimensions: 256 }),
limit: 1,
});
assert.equal(result.candidateRecall, 1);
assert.ok(result.recall < 1);
assert.ok(result.rankingLoss > 0);
assert.equal(result.rankingLoss, result.candidateRecall - result.recall);
});
test('runMemFuseBenchCase reports a clean miss when nothing relevant exists', async () => {
const [scenario] = loadFixtureDataset().scenarios;
const corpus = buildScenarioCorpus(scenario);
const result = await runMemFuseBenchCase({
testCase: {
questionId: 'synthetic-miss',
scenarioId: 'sc1',
dimension: 'cross_user_query',
question: 'zzz nonexistent topic zzz',
evidenceEventIds: ['sc1_ep1_missing'],
checklist: [{ point: 'absent', sourceEvents: ['sc1_ep1_missing'] }],
},
corpus,
embedText: createLexicalHashEmbedder({ dimensions: 256 }),
limit: 5,
});
assert.equal(result.hitCount, 0);
assert.equal(result.recall, 0);
assert.equal(result.hitAny, false);
assert.equal(result.firstHitRank, null);
assert.equal(result.reciprocalRank, 0);
assert.equal(result.checklistCoverage, 0);
});
test('runMemFuseBench aggregates overall, per-scenario and per-dimension metrics', async () => {
const dataset = loadFixtureDataset();
const report = await runMemFuseBench({ dataset, limit: 5 });
assert.equal(report.embeddingMode, 'lexical-hash');
assert.equal(report.limit, 5);
assert.equal(report.overall.caseCount, 3);
assert.ok(report.overall.recallAtK > 0);
assert.ok(report.overall.hitAnyRate > 0);
assert.deepEqual(
report.byScenario.map((item) => item.scenarioId),
['sc1', 'sc2'],
);
assert.equal(report.byScenario[0].corpusSize, 5);
assert.ok(Object.keys(report.byDimension).length >= 2);
assert.equal(report.results.length, 3);
});
test('runMemFuseBench honours scenario, dimension and cap filters', async () => {
const dataset = loadFixtureDataset();
const report = await runMemFuseBench({
dataset,
scenarioIds: ['sc1'],
dimensions: ['cross_device_information_fusion'],
limit: 5,
});
assert.equal(report.overall.caseCount, 1);
assert.equal(report.results[0].questionId, 'q2');
const capped = await runMemFuseBench({ dataset, maxQuestionsPerScenario: 1, limit: 5 });
assert.equal(capped.overall.caseCount, 2);
});
test('runMemFuseBench clamps limit to the pgvector backend ceiling', async () => {
const report = await runMemFuseBench({ dataset: loadFixtureDataset(), limit: 500 });
assert.equal(report.limit, 50);
});
test('runMemFuseBench marks an injected embedder as external and reports progress', async () => {
const dataset = loadFixtureDataset();
const seen = [];
const report = await runMemFuseBench({
dataset,
limit: 5,
embedText: createLexicalHashEmbedder({ dimensions: 32 }),
onProgress: (event) => seen.push(event.questionId),
});
assert.equal(report.embeddingMode, 'external');
assert.equal(seen.length, 3);
});
test('runMemFuseBench rejects an empty dataset or an empty selection', async () => {
await assert.rejects(() => runMemFuseBench({ dataset: { scenarios: [] } }), /requires a loaded dataset/);
await assert.rejects(
() => runMemFuseBench({ dataset: loadFixtureDataset(), dimensions: ['not_a_dimension'] }),
/selected zero questions/,
);
});
test('summarizeMemFuseBench rounds metrics and names the weakest dimension', async () => {
const report = await runMemFuseBench({ dataset: loadFixtureDataset(), limit: 5 });
const summary = summarizeMemFuseBench(report);
assert.equal(summary.caseCount, 3);
assert.equal(summary.embeddingMode, 'lexical-hash');
assert.ok(summary.recallAtK >= 0 && summary.recallAtK <= 1);
assert.ok(summary.candidateRecall >= summary.recallAtK);
assert.ok(summary.rankingLoss >= 0);
assert.ok(summary.mrr >= 0 && summary.mrr <= 1);
assert.ok(summary.distractorRate >= 0 && summary.distractorRate <= 1);
assert.ok(typeof summary.weakestDimension === 'string');
});