dsh-memu-memory 1.1.2 → 1.2.0
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 +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -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
|
+
}
|