dsh-memu-memory 1.1.2 → 1.1.3

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.
Files changed (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +32 -5
  8. package/docs/troubleshooting.md +36 -7
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +697 -49
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +36 -0
  20. package/lib/inject.js +131 -30
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. package/scripts/repetition-eval.mjs +702 -0
@@ -0,0 +1,813 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reproducible recall benchmark harness (W6).
4
+ *
5
+ * This is a SYNTHETIC-DRAFT benchmark, not a gold set. It seeds each sample into
6
+ * its own temporary library under os.tmpdir(), uses a fixed clock per sample and a
7
+ * seeded PRNG, and never reads or writes the user's memory directory. The production
8
+ * ranking code is imported and exercised read-only; no production ordering is changed.
9
+ *
10
+ * The local lexical backend is the only backend in this harness. Mock/lexical
11
+ * vectors only validate the pipeline: they do not establish embedding quality.
12
+ */
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+ import { execFileSync } from 'node:child_process'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
21
+ export const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'recall-bench')
22
+ export const CANDIDATE_FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'recall-bench-gold-candidates')
23
+ export const DEFAULT_STATUS = 'synthetic-draft-not-gold'
24
+ export const CANDIDATE_STATUS = 'gold-candidate-pending-review'
25
+ export const DATASET_STATUSES = Object.freeze([DEFAULT_STATUS, CANDIDATE_STATUS])
26
+ export const SUBCLASSES = Object.freeze([
27
+ 'paraphrase',
28
+ 'exact',
29
+ 'negation',
30
+ 'no-answer',
31
+ 'conflict',
32
+ 'boundary',
33
+ 'secret',
34
+ 'coding',
35
+ ])
36
+ export const SAMPLE_FIELDS = Object.freeze([
37
+ 'id',
38
+ 'draft',
39
+ 'notGold',
40
+ 'subclass',
41
+ 'query',
42
+ 'context',
43
+ 'time',
44
+ 'expectedIds',
45
+ 'forbiddenIds',
46
+ 'noAnswer',
47
+ 'library',
48
+ ])
49
+ export const RUN_CONFIG = Object.freeze({
50
+ schemaVersion: 1,
51
+ benchmark: 'recall-bench',
52
+ status: DEFAULT_STATUS,
53
+ seed: 20261029,
54
+ candidateTopK: 20,
55
+ privateTopK: 3,
56
+ turnTokens: 640,
57
+ relevanceThreshold: 0.4,
58
+ dedupeTurns: 16,
59
+ backend: 'local-lexical',
60
+ })
61
+
62
+ const DAY_MS = 24 * 60 * 60 * 1000
63
+
64
+ function fixtureError(message) {
65
+ throw new Error(`recall-bench fixture: ${message}`)
66
+ }
67
+
68
+ function isPlainObject(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
70
+ }
71
+
72
+ function assertString(value, field, where, { nonEmpty = true } = {}) {
73
+ if (typeof value !== 'string' || (nonEmpty && !value.trim())) fixtureError(`${where}: ${field} must be a non-empty string`)
74
+ return value
75
+ }
76
+
77
+ function assertStringArray(value, field, where, { allowEmpty = true } = {}) {
78
+ if (!Array.isArray(value)) fixtureError(`${where}: ${field} must be an array`)
79
+ if (!allowEmpty && value.length === 0) fixtureError(`${where}: ${field} must not be empty`)
80
+ if (value.some(item => typeof item !== 'string' || !item.trim())) fixtureError(`${where}: ${field} must contain non-empty strings`)
81
+ const unique = new Set(value)
82
+ if (unique.size !== value.length) fixtureError(`${where}: ${field} contains duplicate ids`)
83
+ return value
84
+ }
85
+
86
+ export function validateSampleShape(sample, where = 'sample', options = {}) {
87
+ if (!isPlainObject(sample)) fixtureError(`${where}: sample must be an object`)
88
+ for (const field of SAMPLE_FIELDS) {
89
+ if (!Object.hasOwn(sample, field)) fixtureError(`${where}: missing field ${field}`)
90
+ }
91
+ assertString(sample.id, 'id', where)
92
+ if (!/^[a-z0-9][a-z0-9-]{2,80}$/.test(sample.id)) fixtureError(`${where}: invalid sample id ${sample.id}`)
93
+ if (sample.draft !== true) fixtureError(`${where}: draft must be true (do not present unconfirmed samples as confirmed)`)
94
+ if (sample.notGold !== true) fixtureError(`${where}: notGold must be true (unconfirmed samples are not a gold set)`)
95
+ if (options.status === CANDIDATE_STATUS) {
96
+ if (sample.status !== CANDIDATE_STATUS) fixtureError(`${where}: candidate sample status must be ${CANDIDATE_STATUS}`)
97
+ if (sample.review !== 'pending') fixtureError(`${where}: candidate sample review must be pending`)
98
+ }
99
+ if (sample.gold === true) fixtureError(`${where}: gold must not be marked before human review`)
100
+ if (!SUBCLASSES.includes(sample.subclass)) fixtureError(`${where}: unknown subclass ${sample.subclass}`)
101
+ assertString(sample.query, 'query', where)
102
+ if (!isPlainObject(sample.context)) fixtureError(`${where}: context must be an object`)
103
+ assertString(sample.time, 'time', where)
104
+ if (!Number.isFinite(Date.parse(sample.time))) fixtureError(`${where}: time must be an ISO date/time`)
105
+ assertStringArray(sample.expectedIds, 'expectedIds', where)
106
+ assertStringArray(sample.forbiddenIds, 'forbiddenIds', where)
107
+ assertStringArray(sample.library, 'library', where, { allowEmpty: false })
108
+ if (sample.library.length > 10) fixtureError(`${where}: library subset is capped at 10 entries`)
109
+ if (typeof sample.noAnswer !== 'boolean') fixtureError(`${where}: noAnswer must be boolean`)
110
+ if (sample.noAnswer && sample.expectedIds.length > 0) fixtureError(`${where}: noAnswer=true requires expectedIds=[]`)
111
+ if (!sample.noAnswer && sample.expectedIds.length === 0) fixtureError(`${where}: answerable sample requires expectedIds`)
112
+ for (const id of sample.expectedIds) {
113
+ if (!sample.library.includes(id)) fixtureError(`${where}: expected id ${id} is outside the declared library`)
114
+ }
115
+ for (const id of sample.forbiddenIds) {
116
+ if (!sample.library.includes(id)) fixtureError(`${where}: forbidden id ${id} is outside the declared library`)
117
+ if (sample.expectedIds.includes(id)) fixtureError(`${where}: id ${id} is both expected and forbidden`)
118
+ }
119
+ if (sample.explicitExpectedIds !== undefined) {
120
+ assertStringArray(sample.explicitExpectedIds, 'explicitExpectedIds', where, { allowEmpty: false })
121
+ for (const id of sample.explicitExpectedIds) {
122
+ if (!sample.library.includes(id)) fixtureError(`${where}: explicit expected id ${id} is outside the declared library`)
123
+ if (!sample.expectedIds.includes(id) && sample.noAnswer !== true) {
124
+ // Explicit recall may surface a secret that automatic recall hides, so an
125
+ // extra id is allowed; it must still be declared in the subset.
126
+ }
127
+ }
128
+ for (const id of sample.expectedIds) {
129
+ if (!sample.explicitExpectedIds.includes(id)) fixtureError(`${where}: explicitExpectedIds must include every expectedId`)
130
+ }
131
+ }
132
+ if (sample.explicitForbiddenIds !== undefined) {
133
+ assertStringArray(sample.explicitForbiddenIds, 'explicitForbiddenIds', where)
134
+ for (const id of sample.explicitForbiddenIds) {
135
+ if (!sample.library.includes(id)) fixtureError(`${where}: explicit forbidden id ${id} is outside the declared library`)
136
+ if (sample.explicitExpectedIds?.includes(id)) fixtureError(`${where}: id ${id} is both explicit expected and forbidden`)
137
+ }
138
+ }
139
+ return sample
140
+ }
141
+
142
+ function validateEntry(entry, id, where) {
143
+ if (!isPlainObject(entry)) fixtureError(`${where}: entry ${id} must be an object`)
144
+ if (entry.id !== id) fixtureError(`${where}: entry key ${id} must equal entry.id`)
145
+ assertString(entry.title, 'title', `${where} entry ${id}`)
146
+ assertString(entry.content, 'content', `${where} entry ${id}`)
147
+ if (!Number.isFinite(entry.ageDays) || entry.ageDays < 0) fixtureError(`${where} entry ${id}: ageDays must be a non-negative number`)
148
+ if (entry.updatedDaysAgo !== undefined && (!Number.isFinite(entry.updatedDaysAgo) || entry.updatedDaysAgo < 0)) {
149
+ fixtureError(`${where} entry ${id}: updatedDaysAgo must be a non-negative number`)
150
+ }
151
+ if (entry.tags !== undefined) assertStringArray(entry.tags, 'tags', `${where} entry ${id}`)
152
+ if (entry.visibility !== undefined && !['shared', 'secret'].includes(entry.visibility)) {
153
+ fixtureError(`${where} entry ${id}: visibility must be shared or secret`)
154
+ }
155
+ if (entry.status !== undefined && !['active', 'superseded', 'pending_review', 'archived', 'suppressed', 'expired'].includes(entry.status)) {
156
+ fixtureError(`${where} entry ${id}: unknown status ${entry.status}`)
157
+ }
158
+ if (entry.secret === true) fixtureError(`${where} entry ${id}: secret is a config flag; use visibility=secret for memory entries`)
159
+ return entry
160
+ }
161
+
162
+ function validateFileShape(raw, fileName) {
163
+ if (!isPlainObject(raw)) fixtureError(`${fileName}: file must be an object`)
164
+ if (raw.draft !== true) fixtureError(`${fileName}: draft must be true`)
165
+ if (raw.notGold !== true) fixtureError(`${fileName}: notGold must be true`)
166
+ if (!DATASET_STATUSES.includes(raw.status)) fixtureError(`${fileName}: status must be one of ${DATASET_STATUSES.join(', ')}`)
167
+ if (raw.status === CANDIDATE_STATUS && raw.review !== 'pending') {
168
+ fixtureError(`${fileName}: candidate file review must be pending`)
169
+ }
170
+ if (raw.gold === true) fixtureError(`${fileName}: gold must not be marked before human review`)
171
+ assertString(raw.name, 'name', fileName)
172
+ if (!SUBCLASSES.includes(raw.subclass)) fixtureError(`${fileName}: unknown file subclass ${raw.subclass}`)
173
+ if (!isPlainObject(raw.entries)) fixtureError(`${fileName}: entries must be an object`)
174
+ if (!Array.isArray(raw.samples) || raw.samples.length === 0) fixtureError(`${fileName}: samples must be a non-empty array`)
175
+ return { status: raw.status, review: raw.status === CANDIDATE_STATUS ? 'pending' : undefined }
176
+ }
177
+
178
+ /**
179
+ * Load and validate all synthetic fixtures. Validation is intentionally strict:
180
+ * a typo in an expected id must fail the benchmark instead of silently becoming
181
+ * a zero score.
182
+ */
183
+ export function loadDataset(fixtureDir = FIXTURE_DIR) {
184
+ const dir = path.resolve(fixtureDir)
185
+ let names
186
+ try {
187
+ names = fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort()
188
+ } catch (error) {
189
+ fixtureError(`cannot read fixture directory ${dir}: ${error?.message || error}`)
190
+ }
191
+ if (names.length === 0) fixtureError(`no .json fixtures in ${dir}`)
192
+
193
+ const entries = new Map()
194
+ const samples = []
195
+ const files = []
196
+ const sampleIds = new Set()
197
+ const statuses = new Set()
198
+ for (const fileName of names) {
199
+ const filePath = path.join(dir, fileName)
200
+ let raw
201
+ try {
202
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
203
+ } catch (error) {
204
+ fixtureError(`${fileName}: invalid JSON: ${error?.message || error}`)
205
+ }
206
+ const fileShape = validateFileShape(raw, fileName)
207
+ statuses.add(fileShape.status)
208
+ for (const [id, entry] of Object.entries(raw.entries)) {
209
+ if (entries.has(id)) fixtureError(`duplicate entry id ${id} across fixture files`)
210
+ entries.set(id, validateEntry(entry, id, fileName))
211
+ }
212
+ for (let index = 0; index < raw.samples.length; index += 1) {
213
+ const sample = validateSampleShape(raw.samples[index], `${fileName} samples[${index}]`, { status: fileShape.status })
214
+ if (sample.subclass !== raw.subclass) fixtureError(`${fileName}: sample ${sample.id} subclass does not match file subclass`)
215
+ if (sampleIds.has(sample.id)) fixtureError(`duplicate sample id ${sample.id}`)
216
+ sampleIds.add(sample.id)
217
+ samples.push(sample)
218
+ }
219
+ files.push({ name: fileName, sha256: sha256(fs.readFileSync(filePath)) })
220
+ }
221
+
222
+ for (const sample of samples) {
223
+ for (const id of sample.library) {
224
+ if (!entries.has(id)) fixtureError(`sample ${sample.id}: library id ${id} is not defined in any fixture file`)
225
+ }
226
+ for (const id of sample.expectedIds) {
227
+ if (!sample.library.includes(id)) fixtureError(`sample ${sample.id}: expected id ${id} is outside its library`)
228
+ }
229
+ for (const id of sample.forbiddenIds) {
230
+ if (!sample.library.includes(id)) fixtureError(`sample ${sample.id}: forbidden id ${id} is outside its library`)
231
+ }
232
+ }
233
+ if (statuses.size !== 1) fixtureError('fixture directory mixes dataset statuses')
234
+ const datasetStatus = [...statuses][0]
235
+ if (samples.length < 30 || samples.length > 50) {
236
+ fixtureError(`expected 30-50 samples, found ${samples.length}`)
237
+ }
238
+ for (const subclass of SUBCLASSES) {
239
+ if (!samples.some(sample => sample.subclass === subclass)) fixtureError(`missing subclass ${subclass}`)
240
+ }
241
+
242
+ const digest = crypto.createHash('sha256')
243
+ for (const file of files) {
244
+ digest.update(file.name)
245
+ digest.update('\0')
246
+ digest.update(file.sha256)
247
+ digest.update('\0')
248
+ }
249
+ return {
250
+ name: datasetStatus === CANDIDATE_STATUS ? 'recall-bench-gold-candidates' : 'recall-bench-synthetic-draft',
251
+ status: datasetStatus,
252
+ draft: true,
253
+ notGold: true,
254
+ gold: false,
255
+ review: datasetStatus === CANDIDATE_STATUS ? 'pending' : undefined,
256
+ dir,
257
+ files,
258
+ sha256: digest.digest('hex'),
259
+ entries,
260
+ samples,
261
+ }
262
+ }
263
+
264
+ function sha256(buffer) {
265
+ return crypto.createHash('sha256').update(buffer).digest('hex')
266
+ }
267
+
268
+ function round(value, digits = 6) {
269
+ const factor = 10 ** digits
270
+ const rounded = Math.round((Number(value) + Number.EPSILON) * factor) / factor
271
+ return Object.is(rounded, -0) ? 0 : rounded
272
+ }
273
+
274
+ function top3Of(ids) {
275
+ return Array.isArray(ids) ? ids.slice(0, 3) : []
276
+ }
277
+
278
+ /**
279
+ * Compute the benchmark metrics for one projection (candidate ids, final injected
280
+ * ids, or explicit-probe ids).
281
+ *
282
+ * Definitions and empty-set rules:
283
+ * - Recall@3 denominator = answerable samples only. A sample counts as a hit if
284
+ * at least one expected id is in the first three positions.
285
+ * - MRR@3 denominator = answerable samples only. The reciprocal rank is 0 when no
286
+ * expected id appears in the first three positions.
287
+ * - No-answer false-recall denominator = no-answer samples only. It is the fraction
288
+ * whose first three positions contain any id at all.
289
+ * - Isolation violations = every occurrence of a forbidden id in any scored top-3.
290
+ * The requirement is exactly 0; the denominator is reported for transparency.
291
+ * - If a denominator is empty, the corresponding rate is reported as 0 with an
292
+ * explicit emptySet flag. The harness refuses an empty overall dataset.
293
+ */
294
+ export function computeMetrics(records) {
295
+ let answerable = 0
296
+ let hits = 0
297
+ let reciprocalRankSum = 0
298
+ let noAnswer = 0
299
+ let noAnswerFalseRecall = 0
300
+ let forbiddenChecked = 0
301
+ const violations = []
302
+ for (const record of records) {
303
+ const top3 = top3Of(record.top3)
304
+ if (record.noAnswer === true) {
305
+ noAnswer += 1
306
+ if (top3.length > 0) noAnswerFalseRecall += 1
307
+ } else {
308
+ answerable += 1
309
+ let rank = 0
310
+ for (let index = 0; index < top3.length; index += 1) {
311
+ if (record.expectedIds?.includes(top3[index])) {
312
+ rank = index + 1
313
+ break
314
+ }
315
+ }
316
+ if (rank > 0) {
317
+ hits += 1
318
+ reciprocalRankSum += 1 / rank
319
+ }
320
+ }
321
+ for (const id of record.forbiddenIds || []) {
322
+ forbiddenChecked += 1
323
+ if (top3.includes(id)) violations.push({ sampleId: record.id, id, top3: top3.slice() })
324
+ }
325
+ }
326
+ return {
327
+ recallAt3: answerable === 0 ? 0 : round(hits / answerable),
328
+ mrrAt3: answerable === 0 ? 0 : round(reciprocalRankSum / answerable),
329
+ noAnswerFalseRecallRate: noAnswer === 0 ? 0 : round(noAnswerFalseRecall / noAnswer),
330
+ isolationViolations: violations.length,
331
+ denominators: {
332
+ answerable,
333
+ noAnswer,
334
+ forbiddenChecked,
335
+ },
336
+ emptySet: {
337
+ recallAt3: answerable === 0,
338
+ mrrAt3: answerable === 0,
339
+ noAnswerFalseRecallRate: noAnswer === 0,
340
+ isolationViolations: forbiddenChecked === 0,
341
+ },
342
+ violations: violations.slice(0, 50),
343
+ }
344
+ }
345
+
346
+ function latencyStats(values) {
347
+ const sorted = values.map(Number).filter(Number.isFinite).sort((a, b) => a - b)
348
+ if (sorted.length === 0) return { backend: RUN_CONFIG.backend, samples: 0, medianMs: null, p95Ms: null, method: 'no samples' }
349
+ const mid = Math.floor(sorted.length / 2)
350
+ const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
351
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1)
352
+ return {
353
+ backend: RUN_CONFIG.backend,
354
+ samples: sorted.length,
355
+ medianMs: round(median, 3),
356
+ p95Ms: round(sorted[p95Index], 3),
357
+ method: 'wall-clock around automatic candidate + buildRecall per sample; single Node process; performance.now(); not service latency',
358
+ }
359
+ }
360
+
361
+ function prepareRoot(workRoot) {
362
+ const tmp = path.resolve(os.tmpdir())
363
+ let root = workRoot ? path.resolve(workRoot) : fs.mkdtempSync(path.join(tmp, 'dsh-memu-recall-bench-'))
364
+ const relative = path.relative(tmp, root)
365
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
366
+ throw new Error(`recall-bench: workRoot must be a fresh directory under os.tmpdir(): ${root}`)
367
+ }
368
+ if (fs.existsSync(root)) {
369
+ if (!fs.statSync(root).isDirectory()) throw new Error(`recall-bench: workRoot is not a directory: ${root}`)
370
+ if (fs.readdirSync(root).length > 0) throw new Error(`recall-bench: workRoot must be empty: ${root}`)
371
+ } else {
372
+ fs.mkdirSync(root, { recursive: true })
373
+ }
374
+ return root
375
+ }
376
+
377
+ function installClock(initialMs) {
378
+ const RealDate = Date
379
+ let now = Number(initialMs)
380
+ class BenchDate extends RealDate {
381
+ constructor(...args) {
382
+ super(...(args.length === 0 ? [now] : args))
383
+ }
384
+ static now() {
385
+ return now
386
+ }
387
+ }
388
+ globalThis.Date = BenchDate
389
+ return {
390
+ set(ms) { now = Number(ms) },
391
+ restore() { globalThis.Date = RealDate },
392
+ }
393
+ }
394
+
395
+ function installRandom(seed) {
396
+ const original = Math.random
397
+ let state = Number(seed) >>> 0
398
+ if (state === 0) state = 0x9e3779b9
399
+ Math.random = () => {
400
+ state |= 0
401
+ state = (state + 0x6d2b79f5) | 0
402
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
403
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
404
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
405
+ }
406
+ return { restore() { Math.random = original } }
407
+ }
408
+
409
+ function entryForSample(source, sample) {
410
+ const { id: _id, ageDays = 7, updatedDaysAgo, ...rest } = source
411
+ const nowMs = Date.parse(sample.time)
412
+ const createdMs = nowMs - Number(ageDays) * DAY_MS
413
+ const updatedMs = nowMs - Number(updatedDaysAgo ?? ageDays) * DAY_MS
414
+ return {
415
+ createdAt: new Date(createdMs).toISOString(),
416
+ updatedAt: new Date(updatedMs).toISOString(),
417
+ layer: 'long_term',
418
+ status: 'active',
419
+ visibility: 'shared',
420
+ context: {},
421
+ confidence: 0.9,
422
+ owner: 'user',
423
+ source: 'user_said',
424
+ accessCount: 0,
425
+ injectCount: 0,
426
+ tags: [],
427
+ ...rest,
428
+ }
429
+ }
430
+
431
+ function seedSample(state, sample, entries, root, suffix = '') {
432
+ const library = state.createLibrary({
433
+ name: `bench-${sample.id}${suffix}`,
434
+ scopeKey: path.join(root, 'scopes', `${sample.id}${suffix}`),
435
+ })
436
+ const index = { version: 1, entries: {} }
437
+ for (const id of sample.library) {
438
+ const source = entries.get(id)
439
+ if (!source) throw new Error(`recall-bench: missing entry ${id}`)
440
+ index.entries[id] = entryForSample(source, sample)
441
+ }
442
+ state.savePrivateIndex(library.id, index)
443
+ const sessionId = `bench-session-${sample.id}${suffix}`
444
+ state.setSessionBinding(sessionId, {
445
+ libraryId: library.id,
446
+ scopeKey: library.scopeKey,
447
+ useLibrary: true,
448
+ useGlobal: false,
449
+ memoryContext: { ...sample.context },
450
+ memoryOwnerSessionId: sessionId,
451
+ domain: sample.subclass === 'coding' ? 'coding' : 'rp',
452
+ domainOverride: sample.subclass === 'coding' ? 'coding' : 'rp',
453
+ injectedAt: {},
454
+ injectedIds: [],
455
+ recentQueries: [],
456
+ lastInjectedTurn: 0,
457
+ })
458
+ state.flushState()
459
+ return { library, sessionId }
460
+ }
461
+
462
+ function execFor(sessionId, root) {
463
+ return {
464
+ agent: {
465
+ session: {
466
+ header: {
467
+ id: sessionId,
468
+ cwd: path.join(root, 'cwd'),
469
+ agentPreset: 'rp',
470
+ },
471
+ },
472
+ },
473
+ }
474
+ }
475
+
476
+ function gitCommit() {
477
+ try {
478
+ return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
479
+ cwd: ROOT,
480
+ encoding: 'utf8',
481
+ stdio: ['ignore', 'pipe', 'ignore'],
482
+ timeout: 3000,
483
+ }).trim() || null
484
+ } catch {
485
+ return null
486
+ }
487
+ }
488
+
489
+ function packageVersion() {
490
+ try {
491
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || null
492
+ } catch {
493
+ return null
494
+ }
495
+ }
496
+
497
+ function buildProjection(records, key, expectedKey, forbiddenKey, { noAnswerEligible = true } = {}) {
498
+ const projected = records.map(record => ({
499
+ id: record.id,
500
+ subclass: record.subclass,
501
+ noAnswer: noAnswerEligible ? record.noAnswer : false,
502
+ expectedIds: record[expectedKey] || [],
503
+ forbiddenIds: record[forbiddenKey] || [],
504
+ top3: top3Of(record[key]),
505
+ }))
506
+ return computeMetrics(projected)
507
+ }
508
+
509
+ function bySubclass(records) {
510
+ const result = {}
511
+ for (const subclass of SUBCLASSES) {
512
+ const subset = records.filter(record => record.subclass === subclass)
513
+ if (subset.length === 0) continue
514
+ const candidate = buildProjection(subset, 'candidateTop3', 'expectedIds', 'forbiddenIds')
515
+ const finalInjected = buildProjection(subset, 'injectedTop3', 'expectedIds', 'forbiddenIds')
516
+ result[subclass] = {
517
+ samples: subset.length,
518
+ candidateRecallAt3: candidate.recallAt3,
519
+ candidateMrrAt3: candidate.mrrAt3,
520
+ candidateNoAnswerFalseRecallRate: candidate.noAnswerFalseRecallRate,
521
+ candidateIsolationViolations: candidate.isolationViolations,
522
+ finalInjectedRecallAt3: finalInjected.recallAt3,
523
+ finalInjectedMrrAt3: finalInjected.mrrAt3,
524
+ finalInjectedNoAnswerFalseRecallRate: finalInjected.noAnswerFalseRecallRate,
525
+ finalInjectedIsolationViolations: finalInjected.isolationViolations,
526
+ denominators: finalInjected.denominators,
527
+ }
528
+ }
529
+ return result
530
+ }
531
+
532
+ function sumEstimate(records) {
533
+ return records.reduce((total, record) => total + Number(record.injectedTokenEstimate || 0), 0)
534
+ }
535
+
536
+ /**
537
+ * Run the benchmark. All state lives under a fresh os.tmpdir() root. The env
538
+ * overrides are restored in finally, and the root is removed after the report is
539
+ * built unless the caller supplied workRoot or --keep for inspection.
540
+ */
541
+ export async function runBenchmark(options = {}) {
542
+ const fixtureDir = path.resolve(options.fixtureDir || FIXTURE_DIR)
543
+ const dataset = loadDataset(fixtureDir)
544
+ const sampleLimit = options.sampleLimit === undefined ? dataset.samples.length : Number(options.sampleLimit)
545
+ if (!Number.isInteger(sampleLimit) || sampleLimit < 1 || sampleLimit > dataset.samples.length) {
546
+ throw new Error(`recall-bench: sampleLimit must be 1..${dataset.samples.length}`)
547
+ }
548
+ const samples = dataset.samples.slice(0, sampleLimit)
549
+ const root = prepareRoot(options.workRoot)
550
+ const seed = Number.isInteger(options.seed) ? options.seed : RUN_CONFIG.seed
551
+ const startedAt = new Date().toISOString()
552
+ const clock = installClock(Date.parse(samples[0].time))
553
+ const random = installRandom(seed)
554
+ const previousHome = process.env.DSH_MEMU_HOME
555
+ const previousMemoryHome = process.env.DSH_MEMU_MEMORY_HOME
556
+ let state = null
557
+ const records = []
558
+
559
+ try {
560
+ process.env.DSH_MEMU_HOME = path.join(root, 'dsh-memu-home')
561
+ process.env.DSH_MEMU_MEMORY_HOME = path.join(root, 'dsh-memu-data')
562
+ fs.mkdirSync(process.env.DSH_MEMU_HOME, { recursive: true })
563
+ fs.mkdirSync(process.env.DSH_MEMU_MEMORY_HOME, { recursive: true })
564
+
565
+ state = await import(pathToFileURL(path.join(ROOT, 'lib', 'state.js')).href)
566
+ const memory = await import(pathToFileURL(path.join(ROOT, 'lib', 'memory.js')).href)
567
+ const inject = await import(pathToFileURL(path.join(ROOT, 'lib', 'inject.js')).href)
568
+ const budget = await import(pathToFileURL(path.join(ROOT, 'lib', 'budget.js')).href)
569
+
570
+ for (const sample of samples) {
571
+ const sampleNow = Date.parse(sample.time)
572
+ clock.set(sampleNow)
573
+ const automatic = seedSample(state, sample, dataset.entries, root, '')
574
+ const autoExec = execFor(automatic.sessionId, root)
575
+ const settings = {
576
+ ...state.getSettings(),
577
+ privateTopK: RUN_CONFIG.privateTopK,
578
+ turnTokens: RUN_CONFIG.turnTokens,
579
+ relevanceThreshold: RUN_CONFIG.relevanceThreshold,
580
+ dedupeTurns: RUN_CONFIG.dedupeTurns,
581
+ autoInject: 'smart',
582
+ globalEveryNTurns: 0,
583
+ }
584
+
585
+ const started = performance.now()
586
+ const candidate = await memory.privateRecall({
587
+ exec: autoExec,
588
+ query: sample.query,
589
+ topK: RUN_CONFIG.candidateTopK,
590
+ relevanceThreshold: RUN_CONFIG.relevanceThreshold,
591
+ purpose: 'automatic',
592
+ })
593
+ const candidateIds = (candidate.files || []).map(file => file.name)
594
+ const binding = state.getSessionBinding(automatic.sessionId)
595
+ if (!binding) throw new Error(`recall-bench: session binding disappeared for ${sample.id}`)
596
+ const injected = await inject.buildRecall(binding, sample.query, 1, settings, autoExec, automatic.sessionId)
597
+ const latencyMs = performance.now() - started
598
+
599
+ const injectedIds = Array.isArray(injected.ids) ? injected.ids.slice() : []
600
+ const injectedText = String(injected.text || '')
601
+ const injectedTokenEstimate = budget.estTokens(injectedText)
602
+
603
+ let explicitIds = []
604
+ if (Array.isArray(sample.explicitExpectedIds) && sample.explicitExpectedIds.length > 0) {
605
+ const explicit = seedSample(state, sample, dataset.entries, root, '-explicit')
606
+ const explicitExec = execFor(explicit.sessionId, root)
607
+ const explicitResult = await memory.privateRecall({
608
+ exec: explicitExec,
609
+ query: sample.query,
610
+ topK: RUN_CONFIG.candidateTopK,
611
+ relevanceThreshold: RUN_CONFIG.relevanceThreshold,
612
+ purpose: 'explicit',
613
+ })
614
+ explicitIds = (explicitResult.files || []).map(file => file.name)
615
+ }
616
+
617
+ records.push({
618
+ id: sample.id,
619
+ subclass: sample.subclass,
620
+ draft: true,
621
+ notGold: true,
622
+ noAnswer: sample.noAnswer,
623
+ query: sample.query,
624
+ context: { ...sample.context },
625
+ time: sample.time,
626
+ expectedIds: sample.expectedIds.slice(),
627
+ forbiddenIds: sample.forbiddenIds.slice(),
628
+ library: sample.library.slice(),
629
+ candidateIds,
630
+ candidateRoute: candidate.recallRoute || null,
631
+ candidateErrors: Array.isArray(candidate.errors) ? candidate.errors.slice() : [],
632
+ candidateFusion: candidate.fusion || null,
633
+ candidateTop3: top3Of(candidateIds),
634
+ candidateCount: candidateIds.length,
635
+ injectedIds,
636
+ injectedTop3: top3Of(injectedIds),
637
+ injectedOutcome: injected.outcome || null,
638
+ injectedErrors: Array.isArray(injected.errors) ? injected.errors.slice() : [],
639
+ injectedTextChars: injectedText.length,
640
+ injectedTokenEstimate,
641
+ explicitIds,
642
+ explicitTop3: top3Of(explicitIds),
643
+ latencyMs: round(latencyMs, 3),
644
+ })
645
+ }
646
+ state.flushState()
647
+
648
+ const candidateMetrics = buildProjection(records, 'candidateTop3', 'expectedIds', 'forbiddenIds')
649
+ const injectedMetrics = buildProjection(records, 'injectedTop3', 'expectedIds', 'forbiddenIds')
650
+ const explicitRecords = records.filter(record => {
651
+ const sample = samples.find(item => item.id === record.id)
652
+ return Array.isArray(sample?.explicitExpectedIds) && sample.explicitExpectedIds.length > 0
653
+ })
654
+ const explicitMetrics = buildProjection(
655
+ explicitRecords.map(record => {
656
+ const sample = samples.find(item => item.id === record.id)
657
+ return {
658
+ ...record,
659
+ explicitExpectedIds: sample.explicitExpectedIds,
660
+ explicitForbiddenIds: sample.explicitForbiddenIds || [],
661
+ }
662
+ }),
663
+ 'explicitTop3',
664
+ 'explicitExpectedIds',
665
+ 'explicitForbiddenIds',
666
+ { noAnswerEligible: false },
667
+ )
668
+ const candidateViolations = candidateMetrics.violations.map(item => ({ ...item, projection: 'candidate' }))
669
+ const injectedViolations = injectedMetrics.violations.map(item => ({ ...item, projection: 'final-injected' }))
670
+ const explicitViolations = explicitMetrics.violations.map(item => ({ ...item, projection: 'explicit-probe' }))
671
+ const injectedTotalEstimate = sumEstimate(records)
672
+ const sampleCount = records.length
673
+
674
+ return {
675
+ schemaVersion: RUN_CONFIG.schemaVersion,
676
+ kind: 'recall-bench',
677
+ ok: true,
678
+ status: dataset.status,
679
+ gold: false,
680
+ review: dataset.review,
681
+ dataset: {
682
+ name: dataset.name,
683
+ status: dataset.status,
684
+ draft: true,
685
+ notGold: true,
686
+ gold: false,
687
+ review: dataset.review,
688
+ sampleCount,
689
+ fileCount: dataset.files.length,
690
+ sha256: dataset.sha256,
691
+ files: dataset.files,
692
+ },
693
+ code: {
694
+ commit: gitCommit(),
695
+ packageVersion: packageVersion(),
696
+ worktree: 'dirty-or-uncommitted (recorded from current workspace; not a commit)',
697
+ },
698
+ run: {
699
+ startedAt,
700
+ root,
701
+ clock: 'fixed per sample (Date.now overridden from sample.time)',
702
+ randomSeed: seed,
703
+ random: 'seeded mulberry32',
704
+ sampleLimit,
705
+ },
706
+ config: {
707
+ backend: RUN_CONFIG.backend,
708
+ candidateTopK: RUN_CONFIG.candidateTopK,
709
+ privateTopK: RUN_CONFIG.privateTopK,
710
+ turnTokens: RUN_CONFIG.turnTokens,
711
+ relevanceThreshold: RUN_CONFIG.relevanceThreshold,
712
+ dedupeTurns: RUN_CONFIG.dedupeTurns,
713
+ mockVectors: 'pipeline-only',
714
+ vectorPath: 'none (local lexical only)',
715
+ note: 'local lexical only; any mock/lexical vector path validates pipeline plumbing, not embedding quality',
716
+ },
717
+ metrics: {
718
+ candidate: candidateMetrics,
719
+ finalInjected: injectedMetrics,
720
+ explicitProbe: explicitMetrics,
721
+ isolation: {
722
+ violations: candidateViolations.length + injectedViolations.length + explicitViolations.length,
723
+ mustBeZero: true,
724
+ candidate: candidateMetrics.isolationViolations,
725
+ finalInjected: injectedMetrics.isolationViolations,
726
+ explicitProbe: explicitMetrics.isolationViolations,
727
+ forbiddenDenominator: candidateMetrics.denominators.forbiddenChecked
728
+ + injectedMetrics.denominators.forbiddenChecked
729
+ + explicitMetrics.denominators.forbiddenChecked,
730
+ details: [...candidateViolations, ...injectedViolations, ...explicitViolations].slice(0, 100),
731
+ },
732
+ latency: latencyStats(records.map(record => record.latencyMs)),
733
+ bySubclass: bySubclass(records),
734
+ },
735
+ budget: {
736
+ estimateOnly: true,
737
+ field: 'estimate',
738
+ estimate: {
739
+ injectedTokenTotal: round(injectedTotalEstimate, 3),
740
+ injectedTokenMeanPerSample: sampleCount === 0 ? 0 : round(injectedTotalEstimate / sampleCount, 3),
741
+ turnTokens: RUN_CONFIG.turnTokens,
742
+ method: 'lib/budget.js estTokens; CJK ~1 token/char, Latin ~3.4 chars/token',
743
+ },
744
+ method: 'lib/budget.js estTokens; CJK ~1 token/char, Latin ~3.4 chars/token',
745
+ turnTokens: RUN_CONFIG.turnTokens,
746
+ injectedTokenEstimateTotal: round(injectedTotalEstimate, 3),
747
+ injectedTokenEstimateMean: sampleCount === 0 ? 0 : round(injectedTotalEstimate / sampleCount, 3),
748
+ note: 'Token estimates only. These are not actual token usage and must not be reported as consumed tokens.',
749
+ },
750
+ samples: records,
751
+ }
752
+ } finally {
753
+ try {
754
+ state?.flushState()
755
+ } catch {}
756
+ clock.restore()
757
+ random.restore()
758
+ if (previousHome === undefined) delete process.env.DSH_MEMU_HOME
759
+ else process.env.DSH_MEMU_HOME = previousHome
760
+ if (previousMemoryHome === undefined) delete process.env.DSH_MEMU_MEMORY_HOME
761
+ else process.env.DSH_MEMU_MEMORY_HOME = previousMemoryHome
762
+ if (!options.workRoot && !options.keep) {
763
+ try { fs.rmSync(root, { recursive: true, force: true }) } catch {}
764
+ }
765
+ }
766
+ }
767
+
768
+ export function parseArgs(argv) {
769
+ const options = {}
770
+ for (let index = 0; index < argv.length; index += 1) {
771
+ const arg = argv[index]
772
+ if (arg === '--out') options.out = argv[++index]
773
+ else if (arg === '--fixture-dir') {
774
+ const fixtureDir = argv[++index]
775
+ if (!fixtureDir || fixtureDir.startsWith('--')) throw new Error('recall-bench: --fixture-dir requires a directory')
776
+ options.fixtureDir = fixtureDir
777
+ }
778
+ else if (arg === '--work-root') options.workRoot = argv[++index]
779
+ else if (arg === '--seed') options.seed = Number(argv[++index])
780
+ else if (arg === '--samples') options.sampleLimit = Number(argv[++index])
781
+ else if (arg === '--keep') options.keep = true
782
+ else if (arg === '--help' || arg === '-h') options.help = true
783
+ else throw new Error(`recall-bench: unknown argument ${arg}`)
784
+ }
785
+ return options
786
+ }
787
+
788
+ async function main() {
789
+ const options = parseArgs(process.argv.slice(2))
790
+ if (options.help) {
791
+ console.log('Usage: node scripts/recall-bench.mjs [--fixture-dir DIR] [--out report.json] [--work-root DIR] [--seed N] [--samples N] [--keep]')
792
+ console.log(' --fixture-dir DIR fixture directory; default tests/fixtures/recall-bench; candidate package tests/fixtures/recall-bench-gold-candidates')
793
+ console.log(' --samples N run the first N samples, 1..dataset size; default all samples')
794
+ return
795
+ }
796
+ const report = await runBenchmark(options)
797
+ const json = JSON.stringify(report, null, 2)
798
+ if (options.out) {
799
+ fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true })
800
+ fs.writeFileSync(path.resolve(options.out), json.replace(/\r?\n/g, '\r\n'), 'utf8')
801
+ console.log(`recall-bench: wrote ${path.resolve(options.out)}`)
802
+ } else {
803
+ console.log(json)
804
+ }
805
+ }
806
+
807
+ const invoked = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url : false
808
+ if (invoked) {
809
+ main().catch(error => {
810
+ console.error(JSON.stringify({ ok: false, kind: 'recall-bench', error: error?.message || String(error) }, null, 2))
811
+ process.exitCode = 1
812
+ })
813
+ }