hippocamp 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -2
- package/README.md +2 -0
- package/docs/development.md +21 -0
- package/docs/how-it-works.md +1 -1
- package/docs/results.md +52 -0
- package/docs/setup.md +1 -1
- package/package.json +2 -1
- package/scripts/hippocamp-memory.cjs +67 -0
- package/scripts/install-claude.cjs +8 -4
- package/scripts/install-common.cjs +30 -1
- package/scripts/eval-lagoon.cjs +0 -135
- package/scripts/eval-qa.cjs +0 -371
- package/scripts/eval-retrieval.cjs +0 -323
- package/test/dream-runner.test.cjs +0 -86
- package/test/dream.test.cjs +0 -190
- package/test/install-common.test.cjs +0 -129
- package/test/mcp-params.test.cjs +0 -80
- package/test/memory-efficiency.test.cjs +0 -718
- package/test/package-paths.test.cjs +0 -42
- package/test/sync-memory.test.cjs +0 -119
package/scripts/eval-qa.cjs
DELETED
|
@@ -1,371 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const crypto = require("node:crypto");
|
|
4
|
-
const fsSync = require("node:fs");
|
|
5
|
-
const fs = require("node:fs/promises");
|
|
6
|
-
const os = require("node:os");
|
|
7
|
-
const path = require("node:path");
|
|
8
|
-
const memory = require("./hippocamp-memory.cjs");
|
|
9
|
-
|
|
10
|
-
const DEFAULT_DATA = ".context/longmemeval/longmemeval_s_cleaned.json";
|
|
11
|
-
const DEFAULT_CACHE = ".context/qa-results.jsonl";
|
|
12
|
-
const DEFAULT_SYSTEMS = "hippocamp,oracle";
|
|
13
|
-
const SYSTEMS = ["hippocamp", "hippocamp-k10", "oracle"];
|
|
14
|
-
const DATA_URL =
|
|
15
|
-
"https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json";
|
|
16
|
-
|
|
17
|
-
// Prompts are verbatim from the official LongMemEval repo (xiaowu0162/LongMemEval):
|
|
18
|
-
// src/generation/run_generation.py for the reader, src/evaluation/evaluate_qa.py for the grader.
|
|
19
|
-
const READER_PROMPT =
|
|
20
|
-
"I will give you several history chats between you and a user. Please answer the question based on the relevant chat history. Answer the question step by step: first extract all the relevant information, and then reason over the information to get the answer.\n\n\nHistory Chats:\n\n{}\n\nCurrent Date: {}\nQuestion: {}\nAnswer (step by step):";
|
|
21
|
-
|
|
22
|
-
const BASE_CHECK =
|
|
23
|
-
"I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response is equivalent to the correct answer or contains all the intermediate steps to get the correct answer, you should also answer yes. If the response only contains a subset of the information required by the answer, answer no. ";
|
|
24
|
-
const GRADER_PROMPTS = {
|
|
25
|
-
"single-session-user": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
26
|
-
"single-session-assistant": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
27
|
-
"multi-session": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
28
|
-
"temporal-reasoning": `${BASE_CHECK}In addition, do not penalize off-by-one errors for the number of days. If the question asks for the number of days/weeks/months, etc., and the model makes off-by-one errors (e.g., predicting 19 days when the answer is 18), the model's response is still correct. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
|
|
29
|
-
"knowledge-update":
|
|
30
|
-
"I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response contains some previous information along with an updated answer, the response should be considered as correct as long as the updated answer is the required answer.\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
|
|
31
|
-
"single-session-preference":
|
|
32
|
-
"I will give you a question, a rubric for desired personalized response, and a response from a model. Please answer yes if the response satisfies the desired response. Otherwise, answer no. The model does not need to reflect all the points in the rubric. The response is correct as long as it recalls and utilizes the user's personal information correctly.\n\nQuestion: {}\n\nRubric: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function printHelp() {
|
|
36
|
-
console.log(`Hippocamp QA eval
|
|
37
|
-
|
|
38
|
-
LongMemEval-S answer accuracy. A reader model answers each question from the
|
|
39
|
-
retrieved history, and a grader model checks the answer, both with the official
|
|
40
|
-
LongMemEval prompts. Calls go to an OpenAI-compatible chat completions endpoint.
|
|
41
|
-
|
|
42
|
-
Systems:
|
|
43
|
-
hippocamp searchMemory top 5 snippets
|
|
44
|
-
hippocamp-k10 searchMemory top 10 snippets
|
|
45
|
-
oracle the labeled answer sessions, in full
|
|
46
|
-
|
|
47
|
-
Usage:
|
|
48
|
-
npm run eval:qa -- [--data PATH] [--limit N] [--systems LIST] [--cache PATH] [--dry-run]
|
|
49
|
-
|
|
50
|
-
Options:
|
|
51
|
-
--data PATH LongMemEval-S JSON. Default: ${DEFAULT_DATA}
|
|
52
|
-
--limit N Evaluate N questions spread evenly across the set. Default: all answerable questions
|
|
53
|
-
--systems LIST Comma-separated systems. Default: ${DEFAULT_SYSTEMS}
|
|
54
|
-
--cache PATH JSONL results cache; reruns only pay for missing calls. Default: ${DEFAULT_CACHE}
|
|
55
|
-
--concurrency N Parallel reader and grader calls. Default: 6
|
|
56
|
-
--dry-run Print jobs and prompt token estimates without any API call
|
|
57
|
-
--help Show this help
|
|
58
|
-
|
|
59
|
-
Environment:
|
|
60
|
-
MANIFEST_BASE_URL, MANIFEST_API_KEY OpenAI-compatible endpoint and key
|
|
61
|
-
QA_READER_MODEL Default: anthropic/claude-sonnet-5
|
|
62
|
-
QA_GRADER_MODEL Default: openrouter/openai/gpt-4o
|
|
63
|
-
|
|
64
|
-
Download the data once:
|
|
65
|
-
mkdir -p .context/longmemeval
|
|
66
|
-
curl -L -o ${DEFAULT_DATA} \\
|
|
67
|
-
${DATA_URL}
|
|
68
|
-
`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function parsePositiveInteger(value, name) {
|
|
72
|
-
const number = Number(value);
|
|
73
|
-
|
|
74
|
-
if (!Number.isInteger(number) || number < 1) {
|
|
75
|
-
throw new Error(`${name} must be a positive integer.`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function parseArgs(argv) {
|
|
82
|
-
const options = {
|
|
83
|
-
data: DEFAULT_DATA,
|
|
84
|
-
limit: null,
|
|
85
|
-
systems: DEFAULT_SYSTEMS.split(","),
|
|
86
|
-
cache: DEFAULT_CACHE,
|
|
87
|
-
concurrency: 6,
|
|
88
|
-
dryRun: false,
|
|
89
|
-
help: false,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
93
|
-
const token = argv[index];
|
|
94
|
-
|
|
95
|
-
if (token === "--help" || token === "-h") {
|
|
96
|
-
options.help = true;
|
|
97
|
-
} else if (token === "--data") {
|
|
98
|
-
options.data = argv[++index];
|
|
99
|
-
} else if (token === "--limit") {
|
|
100
|
-
options.limit = parsePositiveInteger(argv[++index], "--limit");
|
|
101
|
-
} else if (token === "--systems") {
|
|
102
|
-
options.systems = String(argv[++index] || "").split(",").filter(Boolean);
|
|
103
|
-
|
|
104
|
-
for (const system of options.systems) {
|
|
105
|
-
if (!SYSTEMS.includes(system)) {
|
|
106
|
-
throw new Error(`Unknown system: ${system}. Use ${SYSTEMS.join(", ")}.`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} else if (token === "--cache") {
|
|
110
|
-
options.cache = argv[++index];
|
|
111
|
-
} else if (token === "--concurrency") {
|
|
112
|
-
options.concurrency = parsePositiveInteger(argv[++index], "--concurrency");
|
|
113
|
-
} else if (token === "--dry-run") {
|
|
114
|
-
options.dryRun = true;
|
|
115
|
-
} else {
|
|
116
|
-
throw new Error(`Unknown argument: ${token}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return options;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function fill(template, ...values) {
|
|
124
|
-
return values.reduce((text, value) => text.replace("{}", () => String(value)), template);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function formatSession(sessionId, date, turns) {
|
|
128
|
-
const lines = [`# Session ${sessionId}`, "", `Date: ${date || "unknown"}`, ""];
|
|
129
|
-
|
|
130
|
-
for (const turn of turns || []) {
|
|
131
|
-
lines.push(`## ${turn.role === "assistant" ? "Assistant" : "User"}`, "", String(turn.content || "").trim(), "");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return `${lines.join("\n").trim()}\n`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function historyString(chunks) {
|
|
138
|
-
return chunks
|
|
139
|
-
.sort((left, right) => left.date.localeCompare(right.date))
|
|
140
|
-
.map((chunk, index) => `\n### Session ${index + 1}:\nSession Date: ${chunk.date}\nSession Content:\n${chunk.content}\n`)
|
|
141
|
-
.join("");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function hippocampChunks(item, projectRoot, maxResults) {
|
|
145
|
-
const pathToIndex = new Map();
|
|
146
|
-
|
|
147
|
-
for (let index = 0; index < item.haystack_sessions.length; index += 1) {
|
|
148
|
-
const relativePath = `sessions/${String(index).padStart(4, "0")}.md`;
|
|
149
|
-
const content = formatSession(item.haystack_session_ids[index], item.haystack_dates[index], item.haystack_sessions[index]);
|
|
150
|
-
|
|
151
|
-
await memory.writeMemoryFile({ scope: "project", path: relativePath, content, projectRoot, sync: false });
|
|
152
|
-
pathToIndex.set(relativePath, index);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const search = await memory.searchMemory({ query: item.question, scope: "project", projectRoot, maxResults });
|
|
156
|
-
|
|
157
|
-
return search.results.map((result) => ({
|
|
158
|
-
date: item.haystack_dates[pathToIndex.get(result.path)],
|
|
159
|
-
content: `\n${result.snippet}`,
|
|
160
|
-
}));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function oracleChunks(item) {
|
|
164
|
-
const answerIds = new Set(item.answer_session_ids.map(String));
|
|
165
|
-
|
|
166
|
-
return item.haystack_sessions
|
|
167
|
-
.map((turns, index) => ({ id: String(item.haystack_session_ids[index]), date: item.haystack_dates[index], turns }))
|
|
168
|
-
.filter((session) => answerIds.has(session.id))
|
|
169
|
-
.map((session) => ({
|
|
170
|
-
date: session.date,
|
|
171
|
-
content: `\n${JSON.stringify(session.turns.map(({ role, content }) => ({ role, content })))}`,
|
|
172
|
-
}));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Models that rejected temperature (Claude 5 does); the official runs use 0 wherever it is accepted.
|
|
176
|
-
const NO_TEMPERATURE = new Set();
|
|
177
|
-
|
|
178
|
-
async function chat(endpoint, model, content, maxTokens) {
|
|
179
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
180
|
-
try {
|
|
181
|
-
const body = { model, messages: [{ role: "user", content }], max_tokens: maxTokens };
|
|
182
|
-
|
|
183
|
-
if (!NO_TEMPERATURE.has(model)) {
|
|
184
|
-
body.temperature = 0;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const response = await fetch(endpoint, {
|
|
188
|
-
method: "POST",
|
|
189
|
-
headers: { Authorization: `Bearer ${process.env.MANIFEST_API_KEY}`, "Content-Type": "application/json" },
|
|
190
|
-
body: JSON.stringify(body),
|
|
191
|
-
});
|
|
192
|
-
const text = await response.text();
|
|
193
|
-
|
|
194
|
-
// Checks this request, not the set: parallel calls can all be refused before the first one marks the model.
|
|
195
|
-
if (response.status === 400 && /temperature/i.test(text) && "temperature" in body) {
|
|
196
|
-
NO_TEMPERATURE.add(model);
|
|
197
|
-
attempt -= 1;
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!response.ok) {
|
|
202
|
-
throw Object.assign(new Error(`HTTP ${response.status}: ${text.slice(0, 300)}`), { status: response.status });
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return JSON.parse(text).choices[0].message.content || "";
|
|
206
|
-
} catch (error) {
|
|
207
|
-
const retryable = !error.status || error.status === 429 || error.status >= 500;
|
|
208
|
-
|
|
209
|
-
if (!retryable || attempt >= 5) {
|
|
210
|
-
throw error;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function percent(rows) {
|
|
219
|
-
return `${((rows.filter((row) => row.correct).length / Math.max(rows.length, 1)) * 100).toFixed(1)}%`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function run(options) {
|
|
223
|
-
if (!fsSync.existsSync(options.data)) {
|
|
224
|
-
throw new Error(`Missing ${options.data}. Run with --help for the download command.`);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const reader = process.env.QA_READER_MODEL || "anthropic/claude-sonnet-5";
|
|
228
|
-
const grader = process.env.QA_GRADER_MODEL || "openrouter/openai/gpt-4o";
|
|
229
|
-
const baseUrl = String(process.env.MANIFEST_BASE_URL || "").replace(/\/+$/, "");
|
|
230
|
-
const endpoint = `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/chat/completions`;
|
|
231
|
-
// The reader prompt's hash is in the key, so a change to the data or to retrieval never reuses a stale answer.
|
|
232
|
-
const promptHash = (prompt) => crypto.createHash("sha1").update(prompt).digest("hex").slice(0, 12);
|
|
233
|
-
const key = (system, id, hash) => `${system}|${reader}|${grader}|${id}|${hash}`;
|
|
234
|
-
|
|
235
|
-
const answerable = JSON.parse(await fs.readFile(options.data, "utf8")).filter(
|
|
236
|
-
(item) => !String(item.question_id).endsWith("_abs"),
|
|
237
|
-
);
|
|
238
|
-
const limit = Math.min(options.limit || answerable.length, answerable.length);
|
|
239
|
-
const items = Array.from({ length: limit }, (_, index) => answerable[Math.floor((index * answerable.length) / limit)]);
|
|
240
|
-
const done = new Map();
|
|
241
|
-
|
|
242
|
-
if (fsSync.existsSync(options.cache)) {
|
|
243
|
-
for (const line of (await fs.readFile(options.cache, "utf8")).split("\n").filter(Boolean)) {
|
|
244
|
-
const row = JSON.parse(line);
|
|
245
|
-
done.set(`${row.system}|${row.reader}|${row.grader}|${row.id}|${row.promptHash}`, row);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Build every prompt first (retrieval is local and sequential), then call models concurrently.
|
|
250
|
-
const jobs = [];
|
|
251
|
-
const current = new Map();
|
|
252
|
-
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-qa-"));
|
|
253
|
-
const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
254
|
-
|
|
255
|
-
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
|
|
256
|
-
|
|
257
|
-
try {
|
|
258
|
-
for (const [position, item] of items.entries()) {
|
|
259
|
-
const projectRoot = path.join(sandboxRoot, "questions", String(position));
|
|
260
|
-
|
|
261
|
-
for (const system of options.systems) {
|
|
262
|
-
const chunks =
|
|
263
|
-
system === "oracle" ? oracleChunks(item) : await hippocampChunks(item, projectRoot, system === "hippocamp-k10" ? 10 : 5);
|
|
264
|
-
const prompt = fill(READER_PROMPT, historyString(chunks), item.question_date, item.question);
|
|
265
|
-
const hash = promptHash(prompt);
|
|
266
|
-
|
|
267
|
-
current.set(`${system}|${item.question_id}`, key(system, item.question_id, hash));
|
|
268
|
-
|
|
269
|
-
if (!done.has(key(system, item.question_id, hash))) {
|
|
270
|
-
jobs.push({ system, item, prompt, hash });
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
await fs.rm(process.env.HIPPOCAMP_GLOBAL_ROOT, { recursive: true, force: true });
|
|
275
|
-
}
|
|
276
|
-
} finally {
|
|
277
|
-
if (previousGlobalRoot === undefined) {
|
|
278
|
-
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
279
|
-
} else {
|
|
280
|
-
process.env.HIPPOCAMP_GLOBAL_ROOT = previousGlobalRoot;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
await fs.rm(sandboxRoot, { recursive: true, force: true });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
console.error(`${items.length} questions, ${jobs.length} new jobs; reader ${reader}, grader ${grader}`);
|
|
287
|
-
|
|
288
|
-
for (const system of options.systems) {
|
|
289
|
-
const chars = jobs.filter((job) => job.system === system).reduce((sum, job) => sum + job.prompt.length, 0);
|
|
290
|
-
console.error(` ${system}: ${Math.round(chars / 4)} reader prompt tokens (estimate)`);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (options.dryRun) {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (jobs.length && (!baseUrl || !process.env.MANIFEST_API_KEY)) {
|
|
298
|
-
throw new Error("Set MANIFEST_BASE_URL and MANIFEST_API_KEY.");
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
await fs.mkdir(path.dirname(path.resolve(options.cache)), { recursive: true });
|
|
302
|
-
let next = 0;
|
|
303
|
-
let finished = 0;
|
|
304
|
-
const worker = async () => {
|
|
305
|
-
while (next < jobs.length) {
|
|
306
|
-
const job = jobs[next++];
|
|
307
|
-
const response = await chat(endpoint, reader, job.prompt, 800);
|
|
308
|
-
const verdict = await chat(
|
|
309
|
-
endpoint,
|
|
310
|
-
grader,
|
|
311
|
-
fill(GRADER_PROMPTS[job.item.question_type], job.item.question, job.item.answer, response),
|
|
312
|
-
10,
|
|
313
|
-
);
|
|
314
|
-
const row = {
|
|
315
|
-
system: job.system,
|
|
316
|
-
reader,
|
|
317
|
-
grader,
|
|
318
|
-
id: job.item.question_id,
|
|
319
|
-
type: job.item.question_type,
|
|
320
|
-
// Same check as the official evaluate_qa.py: 'yes' in the lowercased verdict.
|
|
321
|
-
correct: /yes/i.test(verdict),
|
|
322
|
-
promptHash: job.hash,
|
|
323
|
-
contextChars: job.prompt.length,
|
|
324
|
-
response,
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
await fs.appendFile(options.cache, `${JSON.stringify(row)}\n`);
|
|
328
|
-
done.set(key(row.system, row.id, row.promptHash), row);
|
|
329
|
-
|
|
330
|
-
if (++finished % 20 === 0) {
|
|
331
|
-
console.error(`progress ${finished}/${jobs.length}`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
await Promise.all(Array.from({ length: options.concurrency }, worker));
|
|
337
|
-
|
|
338
|
-
console.log(`LongMemEval-S QA, ${items.length} answerable questions, reader ${reader}, grader ${grader}\n`);
|
|
339
|
-
|
|
340
|
-
for (const system of options.systems) {
|
|
341
|
-
const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
|
|
342
|
-
const contextChars = Math.round(rows.reduce((sum, row) => sum + row.contextChars, 0) / Math.max(rows.length, 1));
|
|
343
|
-
console.log(`${system.padEnd(14)} QA ${percent(rows)} (${rows.length} graded, prompt ${contextChars} chars)`);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
for (const system of options.systems) {
|
|
347
|
-
const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
|
|
348
|
-
console.log(`\n${system}`);
|
|
349
|
-
|
|
350
|
-
for (const type of [...new Set(rows.map((row) => row.type))].sort()) {
|
|
351
|
-
const byType = rows.filter((row) => row.type === type);
|
|
352
|
-
console.log(` ${type.padEnd(26)} ${percent(byType).padStart(6)} (${byType.length})`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
async function main() {
|
|
358
|
-
const options = parseArgs(process.argv.slice(2));
|
|
359
|
-
|
|
360
|
-
if (options.help) {
|
|
361
|
-
printHelp();
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
await run(options);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
main().catch((error) => {
|
|
369
|
-
console.error(error.message);
|
|
370
|
-
process.exit(1);
|
|
371
|
-
});
|
|
@@ -1,323 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const fsSync = require("node:fs");
|
|
4
|
-
const fs = require("node:fs/promises");
|
|
5
|
-
const os = require("node:os");
|
|
6
|
-
const path = require("node:path");
|
|
7
|
-
const memory = require("./hippocamp-memory.cjs");
|
|
8
|
-
|
|
9
|
-
const MAX_TOP_K = 20;
|
|
10
|
-
const DEFAULT_DATA = ".context/longmemeval/longmemeval_s_cleaned.json";
|
|
11
|
-
const DATA_URL =
|
|
12
|
-
"https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json";
|
|
13
|
-
|
|
14
|
-
function printHelp() {
|
|
15
|
-
console.log(`Hippocamp retrieval eval
|
|
16
|
-
|
|
17
|
-
Retrieval-only LongMemEval-S check. No model or API key is used.
|
|
18
|
-
Each question's sessions are written as memory files in a temporary Lagoon,
|
|
19
|
-
then searchMemory and an in-process BM25 reference retrieve the top K.
|
|
20
|
-
|
|
21
|
-
Usage:
|
|
22
|
-
npm run eval:retrieval -- [--data PATH] [--limit N] [--top-k K]
|
|
23
|
-
|
|
24
|
-
Options:
|
|
25
|
-
--data PATH LongMemEval-S JSON. Default: ${DEFAULT_DATA}
|
|
26
|
-
--limit N Evaluate N questions spread evenly across the set. Default: all answerable questions
|
|
27
|
-
--top-k K Results retrieved per question, at most ${MAX_TOP_K} (the search_memory cap). Default: 5
|
|
28
|
-
--help Show this help
|
|
29
|
-
|
|
30
|
-
Download the data once:
|
|
31
|
-
mkdir -p .context/longmemeval
|
|
32
|
-
curl -L -o ${DEFAULT_DATA} \\
|
|
33
|
-
${DATA_URL}
|
|
34
|
-
`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function parsePositiveInteger(value, name) {
|
|
38
|
-
const number = Number(value);
|
|
39
|
-
|
|
40
|
-
if (!Number.isInteger(number) || number < 1) {
|
|
41
|
-
throw new Error(`${name} must be a positive integer.`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseArgs(argv) {
|
|
48
|
-
const options = { data: DEFAULT_DATA, limit: null, topK: 5, help: false };
|
|
49
|
-
|
|
50
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
51
|
-
const token = argv[index];
|
|
52
|
-
|
|
53
|
-
if (token === "--help" || token === "-h") {
|
|
54
|
-
options.help = true;
|
|
55
|
-
} else if (token === "--data") {
|
|
56
|
-
options.data = argv[++index];
|
|
57
|
-
} else if (token === "--limit") {
|
|
58
|
-
options.limit = parsePositiveInteger(argv[++index], "--limit");
|
|
59
|
-
} else if (token === "--top-k") {
|
|
60
|
-
options.topK = parsePositiveInteger(argv[++index], "--top-k");
|
|
61
|
-
|
|
62
|
-
if (options.topK > MAX_TOP_K) {
|
|
63
|
-
throw new Error(`--top-k must be at most ${MAX_TOP_K}, the search_memory result cap.`);
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
throw new Error(`Unknown argument: ${token}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return options;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function formatSession(sessionId, date, turns) {
|
|
74
|
-
const lines = [`# Session ${sessionId}`, "", `Date: ${date || "unknown"}`, ""];
|
|
75
|
-
|
|
76
|
-
for (const turn of turns || []) {
|
|
77
|
-
lines.push(`## ${turn.role === "assistant" ? "Assistant" : "User"}`, "", String(turn.content || "").trim(), "");
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return `${lines.join("\n").trim()}\n`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function tokenize(value) {
|
|
84
|
-
return (
|
|
85
|
-
String(value || "")
|
|
86
|
-
.normalize("NFKD")
|
|
87
|
-
.replace(/[̀-ͯ]/g, "")
|
|
88
|
-
.toLowerCase()
|
|
89
|
-
.match(/[a-z0-9]+/g) || []
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function rankBm25(query, documents) {
|
|
94
|
-
const tokenized = documents.map((document) => tokenize(document.content));
|
|
95
|
-
const averageLength = tokenized.reduce((sum, tokens) => sum + tokens.length, 0) / Math.max(tokenized.length, 1);
|
|
96
|
-
const documentFrequency = new Map();
|
|
97
|
-
|
|
98
|
-
for (const tokens of tokenized) {
|
|
99
|
-
for (const token of new Set(tokens)) {
|
|
100
|
-
documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const queryTokens = [...new Set(tokenize(query))];
|
|
105
|
-
|
|
106
|
-
return documents
|
|
107
|
-
.map((document, index) => {
|
|
108
|
-
const frequencies = new Map();
|
|
109
|
-
|
|
110
|
-
for (const token of tokenized[index]) {
|
|
111
|
-
frequencies.set(token, (frequencies.get(token) || 0) + 1);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
let score = 0;
|
|
115
|
-
|
|
116
|
-
for (const token of queryTokens) {
|
|
117
|
-
const frequency = frequencies.get(token) || 0;
|
|
118
|
-
|
|
119
|
-
if (!frequency) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const matching = documentFrequency.get(token);
|
|
124
|
-
const idf = Math.log(1 + (documents.length - matching + 0.5) / (matching + 0.5));
|
|
125
|
-
const lengthRatio = tokenized[index].length / Math.max(averageLength, 1);
|
|
126
|
-
score += idf * ((frequency * 2.5) / (frequency + 1.5 * (0.25 + 0.75 * lengthRatio)));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return { document, score };
|
|
130
|
-
})
|
|
131
|
-
.sort((left, right) => right.score - left.score || left.document.index - right.document.index)
|
|
132
|
-
.map((item) => item.document);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Words too common to show that a question and its evidence share wording.
|
|
136
|
-
const STOPWORDS = new Set(
|
|
137
|
-
"about after also been before could does from have into just many much over some such than that their them then there these they this what when where which while with would your".split(" "),
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
function contentTokens(value) {
|
|
141
|
-
return new Set(tokenize(value).filter((token) => token.length > 3 && !STOPWORDS.has(token)));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function squashSpace(value) {
|
|
145
|
-
return String(value || "").replace(/\s+/g, " ").trim();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function retrievalMetrics(retrievedIds, answerIds) {
|
|
149
|
-
const expected = new Set(answerIds);
|
|
150
|
-
const hits = retrievedIds.filter((id) => expected.has(id)).length;
|
|
151
|
-
const firstRank = retrievedIds.findIndex((id) => expected.has(id));
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
recall: hits / expected.size,
|
|
155
|
-
hit: hits ? 1 : 0,
|
|
156
|
-
mrr: firstRank === -1 ? 0 : 1 / (firstRank + 1),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function addMetrics(bucket, system, metrics) {
|
|
161
|
-
const total = (bucket[system] ||= { recall: 0, hit: 0, mrr: 0, evidence: 0, contextChars: 0, latencyMs: 0, count: 0 });
|
|
162
|
-
|
|
163
|
-
for (const key of ["recall", "hit", "mrr", "evidence", "contextChars", "latencyMs"]) {
|
|
164
|
-
total[key] += metrics[key] ?? Number.NaN;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
total.count += 1;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function formatRow(label, total) {
|
|
171
|
-
const average = (key) => total[key] / total.count;
|
|
172
|
-
|
|
173
|
-
return [
|
|
174
|
-
label.padEnd(12),
|
|
175
|
-
`Recall@K ${(average("recall") * 100).toFixed(1)}%`,
|
|
176
|
-
`Hit@K ${(average("hit") * 100).toFixed(1)}%`,
|
|
177
|
-
`MRR ${average("mrr").toFixed(3)}`,
|
|
178
|
-
Number.isNaN(total.evidence) ? "evidence n/a " : `evidence ${(average("evidence") * 100).toFixed(1)}%`,
|
|
179
|
-
`context ${Math.round(average("contextChars"))} chars`,
|
|
180
|
-
`latency ${average("latencyMs").toFixed(0)} ms`,
|
|
181
|
-
].join(" ");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function run(options) {
|
|
185
|
-
if (!fsSync.existsSync(options.data)) {
|
|
186
|
-
throw new Error(`Missing ${options.data}. Run with --help for the download command.`);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const answerable = JSON.parse(await fs.readFile(options.data, "utf8")).filter(
|
|
190
|
-
(item) => !String(item.question_id).endsWith("_abs"),
|
|
191
|
-
);
|
|
192
|
-
const limit = Math.min(options.limit || answerable.length, answerable.length);
|
|
193
|
-
const items = Array.from({ length: limit }, (_, index) => answerable[Math.floor((index * answerable.length) / limit)]);
|
|
194
|
-
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-retrieval-"));
|
|
195
|
-
const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
196
|
-
const overall = {};
|
|
197
|
-
const byType = {};
|
|
198
|
-
const misses = [];
|
|
199
|
-
let withEvidenceTurns = 0;
|
|
200
|
-
|
|
201
|
-
process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
for (const [position, item] of items.entries()) {
|
|
205
|
-
const projectRoot = path.join(sandboxRoot, "questions", String(position));
|
|
206
|
-
const documents = [];
|
|
207
|
-
const pathToSessionId = new Map();
|
|
208
|
-
|
|
209
|
-
for (let index = 0; index < item.haystack_sessions.length; index += 1) {
|
|
210
|
-
const sessionId = String(item.haystack_session_ids[index]);
|
|
211
|
-
const relativePath = `sessions/${String(index).padStart(4, "0")}.md`;
|
|
212
|
-
const content = formatSession(sessionId, item.haystack_dates[index], item.haystack_sessions[index]);
|
|
213
|
-
|
|
214
|
-
await memory.writeMemoryFile({ scope: "project", path: relativePath, content, projectRoot, sync: false });
|
|
215
|
-
documents.push({ index, content, sessionId });
|
|
216
|
-
pathToSessionId.set(relativePath, sessionId);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const answerIds = item.answer_session_ids.map(String);
|
|
220
|
-
const evidenceTurns = item.haystack_sessions
|
|
221
|
-
.filter((_, index) => answerIds.includes(String(item.haystack_session_ids[index])))
|
|
222
|
-
.flat()
|
|
223
|
-
.filter((turn) => turn.has_answer)
|
|
224
|
-
.map((turn) => squashSpace(turn.content));
|
|
225
|
-
withEvidenceTurns += evidenceTurns.length ? 1 : 0;
|
|
226
|
-
let startedAt = performance.now();
|
|
227
|
-
const search = await memory.searchMemory({
|
|
228
|
-
query: item.question,
|
|
229
|
-
scope: "project",
|
|
230
|
-
projectRoot,
|
|
231
|
-
maxResults: options.topK,
|
|
232
|
-
});
|
|
233
|
-
const hippocamp = {
|
|
234
|
-
...retrievalMetrics(search.results.map((result) => pathToSessionId.get(result.path)), answerIds),
|
|
235
|
-
latencyMs: performance.now() - startedAt,
|
|
236
|
-
contextChars: search.results.reduce((sum, result) => sum + result.snippet.length, 0),
|
|
237
|
-
// The snippet itself comes from a turn marked has_answer, not only the right session.
|
|
238
|
-
evidence: search.results.some((result) =>
|
|
239
|
-
result.snippet
|
|
240
|
-
.split(/\n\s*\n/)
|
|
241
|
-
.map(squashSpace)
|
|
242
|
-
.some((part) => evidenceTurns.some((turn) => turn.includes(part) || part.includes(turn))),
|
|
243
|
-
)
|
|
244
|
-
? 1
|
|
245
|
-
: 0,
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
if (!hippocamp.hit && evidenceTurns.length) {
|
|
249
|
-
const shared = [...contentTokens(item.question)].filter((token) =>
|
|
250
|
-
evidenceTurns.some((turn) => contentTokens(turn).has(token)),
|
|
251
|
-
);
|
|
252
|
-
misses.push({ type: item.question_type, sharedWords: shared.length });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
startedAt = performance.now();
|
|
256
|
-
const bm25Documents = rankBm25(item.question, documents).slice(0, options.topK);
|
|
257
|
-
const bm25 = {
|
|
258
|
-
...retrievalMetrics(bm25Documents.map((document) => document.sessionId), answerIds),
|
|
259
|
-
latencyMs: performance.now() - startedAt,
|
|
260
|
-
contextChars: bm25Documents.reduce((sum, document) => sum + document.content.length, 0),
|
|
261
|
-
evidence: null,
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
for (const bucket of [overall, (byType[item.question_type] ||= {})]) {
|
|
265
|
-
addMetrics(bucket, "hippocamp", hippocamp);
|
|
266
|
-
addMetrics(bucket, "bm25", bm25);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
await fs.rm(process.env.HIPPOCAMP_GLOBAL_ROOT, { recursive: true, force: true });
|
|
270
|
-
|
|
271
|
-
if ((position + 1) % 50 === 0) {
|
|
272
|
-
console.error(`progress ${position + 1}/${items.length}`);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
} finally {
|
|
276
|
-
if (previousGlobalRoot === undefined) {
|
|
277
|
-
delete process.env.HIPPOCAMP_GLOBAL_ROOT;
|
|
278
|
-
} else {
|
|
279
|
-
process.env.HIPPOCAMP_GLOBAL_ROOT = previousGlobalRoot;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
await fs.rm(sandboxRoot, { recursive: true, force: true });
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
console.log(`LongMemEval-S retrieval, ${items.length} answerable questions, K=${options.topK}`);
|
|
286
|
-
console.log("bm25 returns whole sessions; hippocamp returns bounded snippets.\n");
|
|
287
|
-
|
|
288
|
-
for (const [system, total] of Object.entries(overall)) {
|
|
289
|
-
console.log(formatRow(system, total));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const noSharedWords = misses.filter((miss) => miss.sharedWords === 0).length;
|
|
293
|
-
console.log(
|
|
294
|
-
`\nevidence: a hippocamp snippet is text from a has_answer turn (${withEvidenceTurns} questions have such turns).`,
|
|
295
|
-
);
|
|
296
|
-
console.log(
|
|
297
|
-
`wording: ${misses.length} hippocamp misses (Hit@K 0); ${noSharedWords} share no content word with the answer turns.`,
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
for (const [type, systems] of Object.entries(byType)) {
|
|
301
|
-
console.log(`\n${type} (${systems.bm25.count})`);
|
|
302
|
-
|
|
303
|
-
for (const [system, total] of Object.entries(systems)) {
|
|
304
|
-
console.log(` ${formatRow(system, total)}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function main() {
|
|
310
|
-
const options = parseArgs(process.argv.slice(2));
|
|
311
|
-
|
|
312
|
-
if (options.help) {
|
|
313
|
-
printHelp();
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
await run(options);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
main().catch((error) => {
|
|
321
|
-
console.error(error.message);
|
|
322
|
-
process.exit(1);
|
|
323
|
-
});
|