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,250 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-prompts-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const prompts = await import('../lib/prompts.js')
|
|
11
|
+
const state = await import('../lib/state.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('default prompt loads and renders variables', () => {
|
|
18
|
+
const state = prompts.getPromptState('system-guide')
|
|
19
|
+
assert.equal(state.source, 'default')
|
|
20
|
+
assert.ok(state.text.includes('MemU memory'))
|
|
21
|
+
const rendered = prompts.loadPrompt('system-guide', { mustRememberCap: 16 })
|
|
22
|
+
assert.ok(rendered.text.includes('16'))
|
|
23
|
+
assert.ok(!rendered.text.includes('{{mustRememberCap}}'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('user override wins, reset restores default', () => {
|
|
27
|
+
prompts.savePromptOverride('system-guide', 'CUSTOM {{mustRememberCap}}')
|
|
28
|
+
const custom = prompts.getPromptState('system-guide')
|
|
29
|
+
assert.equal(custom.source, 'user')
|
|
30
|
+
assert.equal(prompts.loadPrompt('system-guide', { mustRememberCap: 7 }).text, 'CUSTOM 7')
|
|
31
|
+
const reset = prompts.resetPrompt('system-guide')
|
|
32
|
+
assert.equal(reset.source, 'default')
|
|
33
|
+
assert.ok(reset.text.includes('MemU memory'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('default version update marks a user override', () => {
|
|
37
|
+
prompts.savePromptOverride('private-policy', 'CUSTOM POLICY')
|
|
38
|
+
const manifestPath = path.join(home, 'prompts', 'manifest.json')
|
|
39
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
40
|
+
manifest.prompts['private-policy'].defaultHash = 'old-default-hash'
|
|
41
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
42
|
+
prompts.syncPromptDefaults()
|
|
43
|
+
const state = prompts.getPromptState('private-policy')
|
|
44
|
+
assert.equal(state.source, 'user')
|
|
45
|
+
assert.equal(state.defaultUpdated, true)
|
|
46
|
+
prompts.syncPromptDefaults()
|
|
47
|
+
assert.equal(prompts.getPromptState('private-policy').defaultUpdated, true, 'a second startup must not hide an unacknowledged default update')
|
|
48
|
+
assert.equal(prompts.getPromptState('private-policy').text, 'CUSTOM POLICY', 'new defaults must never overwrite custom text')
|
|
49
|
+
prompts.savePromptOverride('private-policy', 'REVIEWED CUSTOM POLICY')
|
|
50
|
+
assert.equal(prompts.getPromptState('private-policy').defaultUpdated, false, 'saving acknowledges the new default')
|
|
51
|
+
prompts.resetPrompt('private-policy')
|
|
52
|
+
assert.equal(prompts.getPromptState('private-policy').defaultUpdated, false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('unknown template variables stay literal', () => {
|
|
56
|
+
assert.equal(prompts.renderTemplate('a {{known}} b {{unknown}}', { known: 'x' }), 'a x b {{unknown}}')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('prompts resolve to the conversation domain and fall back to base', () => {
|
|
60
|
+
const rp = prompts.loadPromptForDomain('system-guide', { mustRememberCap: 16, unpinBudget: 2 }, 'rp')
|
|
61
|
+
assert.equal(rp.key, 'system-guide.rp')
|
|
62
|
+
assert.match(rp.text, /角色扮演/)
|
|
63
|
+
const coding = prompts.loadPromptForDomain('system-guide', {}, 'coding')
|
|
64
|
+
assert.equal(coding.key, 'system-guide.coding')
|
|
65
|
+
assert.match(coding.text, /code wins over memory/)
|
|
66
|
+
|
|
67
|
+
const general = prompts.loadPromptForDomain('system-guide', {}, 'general')
|
|
68
|
+
assert.equal(general.key, 'system-guide')
|
|
69
|
+
assert.equal(general.fellBackToBase, true)
|
|
70
|
+
|
|
71
|
+
// Prompts whose job is to reason across domains are never split.
|
|
72
|
+
assert.equal(prompts.resolveDef('global-curator', 'rp').key, 'global-curator')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('a deleted variant silently falls back to the base prompt', () => {
|
|
76
|
+
const dir = prompts.DEFAULT_DIR
|
|
77
|
+
const variant = path.join(dir, 'private-policy.rp.md')
|
|
78
|
+
const original = fs.readFileSync(variant, 'utf8')
|
|
79
|
+
fs.rmSync(variant)
|
|
80
|
+
try {
|
|
81
|
+
const resolved = prompts.loadPromptForDomain('private-policy', {}, 'rp')
|
|
82
|
+
assert.equal(resolved.key, 'private-policy')
|
|
83
|
+
assert.equal(resolved.fellBackToBase, true)
|
|
84
|
+
} finally {
|
|
85
|
+
fs.writeFileSync(variant, original, 'utf8')
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('prompts carry the scope the settings sub-tabs group by', () => {
|
|
90
|
+
const states = prompts.getPromptStates()
|
|
91
|
+
const scopes = Object.fromEntries(states.map((s) => [s.key, s.scope]))
|
|
92
|
+
assert.equal(scopes['system-guide'], 'private')
|
|
93
|
+
assert.equal(scopes['system-guide.rp'], 'private', 'variants inherit their base scope')
|
|
94
|
+
assert.equal(scopes['private-policy.coding'], 'private')
|
|
95
|
+
assert.equal(scopes['global-curator'], 'global')
|
|
96
|
+
assert.ok(states.every((s) => s.scope === 'private' || s.scope === 'global'))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('adding a domain page creates its variants, seeded from the base prompt', () => {
|
|
100
|
+
state.addPromptDomain('novel')
|
|
101
|
+
try {
|
|
102
|
+
const keys = prompts.getPromptStates().map((s) => s.key)
|
|
103
|
+
assert.ok(keys.includes('system-guide.novel'))
|
|
104
|
+
assert.ok(keys.includes('private-consolidation.novel'))
|
|
105
|
+
// global-curator is not domain-layered, so no variant appears for it.
|
|
106
|
+
assert.ok(!keys.includes('global-curator.novel'))
|
|
107
|
+
|
|
108
|
+
// No file yet: it shows the base text so there is something to edit, and the
|
|
109
|
+
// runtime keeps using the base prompt until a real file exists.
|
|
110
|
+
const seeded = prompts.getPromptState('system-guide.novel')
|
|
111
|
+
assert.equal(seeded.inheritedFrom, 'system-guide')
|
|
112
|
+
assert.ok(seeded.text.includes('MemU memory'))
|
|
113
|
+
assert.equal(prompts.resolveDef('system-guide', 'novel').key, 'system-guide')
|
|
114
|
+
|
|
115
|
+
// Saving it stores the page separately and makes it authoritative.
|
|
116
|
+
prompts.savePromptOverride('system-guide.novel', 'NOVEL GUIDE')
|
|
117
|
+
assert.equal(prompts.getPromptState('system-guide.novel').source, 'user')
|
|
118
|
+
assert.equal(prompts.resolveDef('system-guide', 'novel').key, 'system-guide.novel')
|
|
119
|
+
assert.equal(prompts.loadPromptForDomain('system-guide', {}, 'novel').text, 'NOVEL GUIDE')
|
|
120
|
+
assert.ok(prompts.getPromptState('system-guide').text.includes('MemU memory'), 'the base page is untouched')
|
|
121
|
+
} finally {
|
|
122
|
+
for (const key of ['system-guide.novel', 'private-policy.novel', 'private-consolidation.novel']) prompts.resetPrompt(key)
|
|
123
|
+
state.removePromptDomain('novel')
|
|
124
|
+
}
|
|
125
|
+
assert.ok(!prompts.getPromptStates().some((s) => s.key === 'system-guide.novel'))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an explicit domain list is kept; all shipped pages are enabled by default', () => {
|
|
129
|
+
const saved = state.getPromptDomains()
|
|
130
|
+
assert.deepEqual(saved, ['rp', 'coding', 'mixed'])
|
|
131
|
+
try {
|
|
132
|
+
state.updateSettings({ promptDomains: ['rp', 'coding'] })
|
|
133
|
+
assert.deepEqual(state.getPromptDomains(), ['rp', 'coding'], 'disabling a page is a deliberate list')
|
|
134
|
+
state.updateSettings({ promptDomains: ['rp', 'novel'] })
|
|
135
|
+
assert.deepEqual(state.getPromptDomains(), ['rp', 'novel'], 'a deliberate list is kept')
|
|
136
|
+
} finally {
|
|
137
|
+
state.updateSettings({ promptDomains: saved })
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('every shipped domain has a prompt page', () => {
|
|
142
|
+
// The session picker lists general + promptDomains; the prompts page lists
|
|
143
|
+
// general + the domains that have variants. They must be the same list.
|
|
144
|
+
const domains = state.getPromptDomains()
|
|
145
|
+
assert.ok(domains.includes('mixed'), 'mixed is a session domain and needs a page')
|
|
146
|
+
const pages = new Set(prompts.getPromptStates().filter((s) => s.domain).map((s) => s.domain))
|
|
147
|
+
for (const domain of domains) assert.ok(pages.has(domain), `domain ${domain} has no prompt page`)
|
|
148
|
+
assert.equal(prompts.resolveDef('system-guide', 'mixed').key, 'system-guide.mixed')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('domain ids are validated', () => {
|
|
152
|
+
assert.throws(() => state.addPromptDomain('../evil'), /domain id/)
|
|
153
|
+
assert.throws(() => state.addPromptDomain('general'), /built-in/)
|
|
154
|
+
assert.throws(() => state.addPromptDomain(''), /domain id/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('syncPromptDefaults keeps records for keys it does not know about', () => {
|
|
158
|
+
prompts.savePromptOverride('system-guide.coding', 'MY CODING GUIDE')
|
|
159
|
+
const manifestPath = path.join(home, 'prompts', 'manifest.json')
|
|
160
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
161
|
+
manifest.prompts['a-key-from-an-older-version'] = { defaultHash: 'x', userHash: 'y', defaultUpdated: true, updatedAt: '2020-01-01T00:00:00.000Z' }
|
|
162
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
163
|
+
|
|
164
|
+
prompts.syncPromptDefaults()
|
|
165
|
+
|
|
166
|
+
const after = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
167
|
+
assert.ok(after.prompts['a-key-from-an-older-version'], 'unknown keys must survive a default sync')
|
|
168
|
+
assert.equal(after.prompts['system-guide.coding'].userHash, manifest.prompts['system-guide.coding'].userHash)
|
|
169
|
+
assert.equal(prompts.getPromptState('system-guide.coding').source, 'user')
|
|
170
|
+
prompts.resetPrompt('system-guide.coding')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('domain defaults distinguish engineering evidence, character continuity and mixed boundaries', () => {
|
|
174
|
+
for (const kind of ['system-guide', 'private-policy', 'private-consolidation']) {
|
|
175
|
+
const coding = prompts.getPromptState(`${kind}.coding`).defaultText
|
|
176
|
+
assert.match(coding, /代码块/)
|
|
177
|
+
assert.match(coding, /逻辑/)
|
|
178
|
+
assert.match(coding, /验证/)
|
|
179
|
+
assert.match(coding, /符号/)
|
|
180
|
+
assert.match(coding, /复核|review/)
|
|
181
|
+
assert.doesNotMatch(coding, /最多三句、约 200 字|runs at most three sentences/)
|
|
182
|
+
const rp = prompts.getPromptState(`${kind}.rp`).defaultText
|
|
183
|
+
assert.match(rp, /边界|界限/)
|
|
184
|
+
assert.match(rp, /承诺|约定/)
|
|
185
|
+
assert.match(rp, /waiting/)
|
|
186
|
+
assert.match(rp, /事实不能为了人味而记错/)
|
|
187
|
+
assert.doesNotMatch(rp, /允许模糊、偏见、自欺、记混|允许模糊、偏见、自欺、记错|可以记成另一个样子/)
|
|
188
|
+
const mixed = prompts.getPromptState(`${kind}.mixed`).defaultText
|
|
189
|
+
assert.match(mixed, /技术侧|技术/)
|
|
190
|
+
assert.match(mixed, /人物|人这一侧/)
|
|
191
|
+
assert.match(mixed, /OOC|ooc/)
|
|
192
|
+
assert.match(mixed, /不.*代替.*(权限|边界)|不是权限隔离/)
|
|
193
|
+
assert.match(mixed, /复核/)
|
|
194
|
+
}
|
|
195
|
+
assert.match(prompts.getPromptState('system-guide').defaultText, /不默认进入角色扮演/)
|
|
196
|
+
assert.match(prompts.getPromptState('private-policy').defaultText, /通用领域不默认角色扮演/)
|
|
197
|
+
const curator = prompts.getPromptState('global-curator').defaultText
|
|
198
|
+
assert.match(curator, /角色台词、虚构身份、感情和剧情承诺不是现实用户属性/)
|
|
199
|
+
assert.match(curator, /前置条件、适用版本与验证边界/)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('all shipped template variables are declared and fully rendered, with bounded resident guidance', () => {
|
|
203
|
+
const vars = { mustRememberCap: 16, unpinBudget: 2, shortTermTtlHours: 48, anchorTokens: 700, turnTokens: 500,
|
|
204
|
+
libraryName: 'test-library', domain: 'coding', input: '{"sessions":[]}' }
|
|
205
|
+
for (const definition of prompts.allDefs()) {
|
|
206
|
+
const text = prompts.getPromptState(definition.key).defaultText
|
|
207
|
+
for (const [, variable] of text.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) {
|
|
208
|
+
assert.ok(definition.variables.includes(variable), `${definition.key} does not expose ${variable} in the editor`)
|
|
209
|
+
}
|
|
210
|
+
assert.doesNotMatch(prompts.loadPrompt(definition.key, vars).text, /\{\{\s*\w+\s*\}\}/)
|
|
211
|
+
}
|
|
212
|
+
// A size guard, not a tokenizer or a claim about model behavior. Only the
|
|
213
|
+
// guide is resident now; private-policy is retained as a template but is not
|
|
214
|
+
// delivered to the main conversation.
|
|
215
|
+
for (const domain of ['general', 'coding', 'rp', 'mixed']) {
|
|
216
|
+
const text = prompts.loadPromptForDomain('system-guide', vars, domain).text
|
|
217
|
+
assert.ok(text.length < 7200, `${domain} resident instructions grew too large: ${text.length}`)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('no system prompt section is registered; the guide still follows the domain', async () => {
|
|
222
|
+
const { registerInjection } = await import('../lib/inject.js')
|
|
223
|
+
const injected = []
|
|
224
|
+
registerInjection({
|
|
225
|
+
inject: (...args) => injected.push(args),
|
|
226
|
+
on: () => () => {},
|
|
227
|
+
})
|
|
228
|
+
assert.equal(injected.length, 0, 'rules must travel as a plugin message, not a system section')
|
|
229
|
+
const vars = { mustRememberCap: 16, unpinBudget: 2, shortTermTtlHours: 48, anchorTokens: 700, turnTokens: 500 }
|
|
230
|
+
const bodies = ['coding', 'rp', 'mixed', 'general'].map(domain => prompts.loadPromptForDomain('system-guide', vars, domain).text)
|
|
231
|
+
for (const text of bodies) {
|
|
232
|
+
assert.ok(text.length > 0)
|
|
233
|
+
assert.doesNotMatch(text, /\{\{\s*\w+\s*\}\}/)
|
|
234
|
+
}
|
|
235
|
+
assert.notEqual(bodies[0], bodies[1], 'domain variants are still distinct')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('local consolidation adds actual tools while the shared domain body stays backend-neutral', async () => {
|
|
239
|
+
const { buildPrivateConsolidationPrompt } = await import('../lib/private.js')
|
|
240
|
+
const sessionId = 'consolidation-routing'
|
|
241
|
+
state.ensureSession(sessionId, { cwd: path.join(home, 'consolidation-routing') })
|
|
242
|
+
for (const domain of ['coding', 'rp', 'mixed', 'general']) {
|
|
243
|
+
state.setSessionBinding(sessionId, { domainOverride: domain })
|
|
244
|
+
const text = buildPrivateConsolidationPrompt(sessionId)
|
|
245
|
+
assert.ok(text.includes(prompts.CONSOLIDATION_CONTRACTS.local))
|
|
246
|
+
assert.match(text, /memu_private_update/)
|
|
247
|
+
assert.doesNotMatch(text, /\{\{\s*\w+\s*\}\}/)
|
|
248
|
+
assert.equal(text.includes('你没有记忆工具'), false)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
let api
|
|
4
|
+
let snapshots = 0
|
|
5
|
+
const React = {
|
|
6
|
+
createElement: (type, props, ...children) => typeof type === 'function' ? type({ ...props, children }) : { type, props, children },
|
|
7
|
+
Fragment: Symbol('fragment'),
|
|
8
|
+
useState: value => [typeof value === 'function' ? value() : value, () => {}],
|
|
9
|
+
useMemo: fn => fn(), useCallback: fn => fn, useEffect: () => {}, useRef: value => ({ current: value }),
|
|
10
|
+
useSyncExternalStore: (_subscribe, snapshot) => {
|
|
11
|
+
const first = snapshot()
|
|
12
|
+
assert.equal(snapshot(), first, 'a missing host store must return a stable snapshot')
|
|
13
|
+
snapshots++
|
|
14
|
+
return first
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
globalThis.window = { __ModuleLoader__: { load: spec => { api = spec.factory(name => name === 'react' ? React : { createRoot: () => ({ render() {}, unmount() {} }) }) } } }
|
|
18
|
+
globalThis.document = { createElement: () => ({ remove() {} }), head: { appendChild() {} } }
|
|
19
|
+
await import('../lib/client.js')
|
|
20
|
+
test('standalone sidebar without a host session store does not trigger an external-store render loop', () => {
|
|
21
|
+
assert.doesNotThrow(() => api.mountStandalone({}, { sessionId: 'preview' }))
|
|
22
|
+
assert.equal(snapshots, 1)
|
|
23
|
+
})
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-recall-bench-'))
|
|
8
|
+
const previousHome = process.env.DSH_MEMU_HOME
|
|
9
|
+
const previousMemoryHome = process.env.DSH_MEMU_MEMORY_HOME
|
|
10
|
+
process.env.DSH_MEMU_HOME = home
|
|
11
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
12
|
+
|
|
13
|
+
const bench = await import('../scripts/recall-bench.mjs')
|
|
14
|
+
const roots = [home]
|
|
15
|
+
const sentinel = path.join(home, 'sentinel.txt')
|
|
16
|
+
fs.writeFileSync(sentinel, 'do not touch the user library\n', 'utf8')
|
|
17
|
+
|
|
18
|
+
test.after(() => {
|
|
19
|
+
if (previousHome === undefined) delete process.env.DSH_MEMU_HOME
|
|
20
|
+
else process.env.DSH_MEMU_HOME = previousHome
|
|
21
|
+
if (previousMemoryHome === undefined) delete process.env.DSH_MEMU_MEMORY_HOME
|
|
22
|
+
else process.env.DSH_MEMU_MEMORY_HOME = previousMemoryHome
|
|
23
|
+
for (const root of roots.splice(0)) {
|
|
24
|
+
try { fs.rmSync(root, { recursive: true, force: true }) } catch {}
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function freshRoot(label) {
|
|
29
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `dsh-memu-recall-bench-${label}-`))
|
|
30
|
+
roots.push(root)
|
|
31
|
+
return root
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stableReport(report) {
|
|
35
|
+
return {
|
|
36
|
+
dataset: report.dataset,
|
|
37
|
+
code: report.code,
|
|
38
|
+
config: report.config,
|
|
39
|
+
metrics: {
|
|
40
|
+
...report.metrics,
|
|
41
|
+
latency: null,
|
|
42
|
+
},
|
|
43
|
+
budget: report.budget,
|
|
44
|
+
samples: report.samples.map(({ latencyMs: _latencyMs, ...rest }) => rest),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('CLI parses sample limits and rejects removed or unknown flags', () => {
|
|
49
|
+
assert.equal(bench.parseArgs(['--samples', '1']).sampleLimit, 1)
|
|
50
|
+
assert.throws(() => bench.parseArgs(['--hybrid-llm']), /unknown argument/)
|
|
51
|
+
assert.throws(() => bench.parseArgs(['--not-a-flag']), /unknown argument/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('synthetic fixtures are explicitly drafts, structurally valid, and cover every requested subclass', () => {
|
|
55
|
+
const dataset = bench.loadDataset()
|
|
56
|
+
assert.equal(dataset.status, 'synthetic-draft-not-gold')
|
|
57
|
+
assert.equal(dataset.draft, true)
|
|
58
|
+
assert.equal(dataset.notGold, true)
|
|
59
|
+
assert.equal(dataset.samples.length, 42)
|
|
60
|
+
assert.equal(dataset.files.length, 8)
|
|
61
|
+
assert.match(dataset.sha256, /^[a-f0-9]{64}$/)
|
|
62
|
+
|
|
63
|
+
const counts = {}
|
|
64
|
+
for (const sample of dataset.samples) {
|
|
65
|
+
assert.equal(sample.draft, true, sample.id + ' must be marked draft')
|
|
66
|
+
assert.equal(sample.notGold, true, sample.id + ' must be marked notGold')
|
|
67
|
+
assert.ok(bench.SUBCLASSES.includes(sample.subclass))
|
|
68
|
+
assert.ok(typeof sample.query === 'string' && sample.query.length > 0)
|
|
69
|
+
assert.ok(sample.context && typeof sample.context === 'object' && !Array.isArray(sample.context))
|
|
70
|
+
assert.ok(Number.isFinite(Date.parse(sample.time)))
|
|
71
|
+
assert.ok(Array.isArray(sample.expectedIds))
|
|
72
|
+
assert.ok(Array.isArray(sample.forbiddenIds))
|
|
73
|
+
assert.ok(Array.isArray(sample.library) && sample.library.length > 0 && sample.library.length <= 10)
|
|
74
|
+
assert.equal(typeof sample.noAnswer, 'boolean')
|
|
75
|
+
if (sample.noAnswer) assert.equal(sample.expectedIds.length, 0)
|
|
76
|
+
else assert.ok(sample.expectedIds.length > 0)
|
|
77
|
+
for (const id of [...sample.expectedIds, ...sample.forbiddenIds]) {
|
|
78
|
+
assert.ok(sample.library.includes(id), sample.id + ' id outside library: ' + id)
|
|
79
|
+
}
|
|
80
|
+
for (const id of sample.expectedIds) assert.ok(!sample.forbiddenIds.includes(id))
|
|
81
|
+
counts[sample.subclass] = (counts[sample.subclass] || 0) + 1
|
|
82
|
+
}
|
|
83
|
+
assert.deepEqual(counts, {
|
|
84
|
+
paraphrase: 6,
|
|
85
|
+
exact: 5,
|
|
86
|
+
negation: 4,
|
|
87
|
+
'no-answer': 6,
|
|
88
|
+
conflict: 5,
|
|
89
|
+
boundary: 6,
|
|
90
|
+
secret: 5,
|
|
91
|
+
coding: 5,
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('sample validation fails closed on a false answerable marker and on an out-of-library id', () => {
|
|
96
|
+
const base = {
|
|
97
|
+
id: 'rb-invalid',
|
|
98
|
+
draft: true,
|
|
99
|
+
notGold: true,
|
|
100
|
+
subclass: 'paraphrase',
|
|
101
|
+
query: '一个查询',
|
|
102
|
+
context: {},
|
|
103
|
+
time: '2026-01-01T00:00:00Z',
|
|
104
|
+
expectedIds: [],
|
|
105
|
+
forbiddenIds: [],
|
|
106
|
+
noAnswer: false,
|
|
107
|
+
library: ['mem-rb-invalid'],
|
|
108
|
+
}
|
|
109
|
+
assert.throws(() => bench.validateSampleShape(base, 'bad'), /answerable sample requires expectedIds/)
|
|
110
|
+
assert.throws(() => bench.validateSampleShape({ ...base, expectedIds: ['mem-rb-other'] }, 'bad'), /outside the declared library/)
|
|
111
|
+
assert.throws(() => bench.validateSampleShape({ ...base, draft: false, expectedIds: ['mem-rb-invalid'] }, 'bad'), /draft must be true/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('metric math uses explicit denominators and scores empty top-3 as a no-answer success', () => {
|
|
115
|
+
const metrics = bench.computeMetrics([
|
|
116
|
+
{ id: 'a', noAnswer: false, expectedIds: ['e1'], forbiddenIds: ['f'], top3: ['x', 'e1'] },
|
|
117
|
+
{ id: 'b', noAnswer: false, expectedIds: ['e2'], forbiddenIds: ['f'], top3: ['e2'] },
|
|
118
|
+
{ id: 'c', noAnswer: true, expectedIds: [], forbiddenIds: ['f'], top3: ['f'] },
|
|
119
|
+
{ id: 'd', noAnswer: true, expectedIds: [], forbiddenIds: [], top3: [] },
|
|
120
|
+
])
|
|
121
|
+
assert.equal(metrics.recallAt3, 1)
|
|
122
|
+
assert.equal(metrics.mrrAt3, 0.75)
|
|
123
|
+
assert.equal(metrics.noAnswerFalseRecallRate, 0.5)
|
|
124
|
+
assert.equal(metrics.isolationViolations, 1)
|
|
125
|
+
assert.deepEqual(metrics.denominators, { answerable: 2, noAnswer: 2, forbiddenChecked: 3 })
|
|
126
|
+
assert.equal(metrics.violations[0].sampleId, 'c')
|
|
127
|
+
|
|
128
|
+
const empty = bench.computeMetrics([
|
|
129
|
+
{ id: 'only-no-answer', noAnswer: true, expectedIds: [], forbiddenIds: [], top3: [] },
|
|
130
|
+
])
|
|
131
|
+
assert.equal(empty.recallAt3, 0)
|
|
132
|
+
assert.equal(empty.mrrAt3, 0)
|
|
133
|
+
assert.equal(empty.noAnswerFalseRecallRate, 0)
|
|
134
|
+
assert.equal(empty.emptySet.recallAt3, true)
|
|
135
|
+
assert.equal(empty.emptySet.noAnswerFalseRecallRate, false)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('benchmark runs in isolated temp roots, scores candidate and final ids, keeps isolation at zero, and is deterministic', async () => {
|
|
139
|
+
const firstRoot = freshRoot('run1')
|
|
140
|
+
const secondRoot = freshRoot('run2')
|
|
141
|
+
const first = await bench.runBenchmark({ workRoot: firstRoot, seed: 4242 })
|
|
142
|
+
const second = await bench.runBenchmark({ workRoot: secondRoot, seed: 4242 })
|
|
143
|
+
|
|
144
|
+
assert.equal(first.ok, true)
|
|
145
|
+
assert.equal(first.status, 'synthetic-draft-not-gold')
|
|
146
|
+
assert.equal(first.dataset.sampleCount, 42)
|
|
147
|
+
assert.equal(first.dataset.sha256, second.dataset.sha256)
|
|
148
|
+
assert.equal(first.metrics.candidate.denominators.answerable, 33)
|
|
149
|
+
assert.equal(first.metrics.candidate.denominators.noAnswer, 9)
|
|
150
|
+
assert.equal(first.metrics.finalInjected.denominators.answerable, 33)
|
|
151
|
+
assert.equal(first.metrics.finalInjected.denominators.noAnswer, 9)
|
|
152
|
+
assert.equal(first.metrics.explicitProbe.denominators.answerable, 5)
|
|
153
|
+
assert.equal(first.metrics.isolation.violations, 0)
|
|
154
|
+
assert.equal(first.metrics.isolation.mustBeZero, true)
|
|
155
|
+
assert.equal(first.metrics.isolation.candidate, 0)
|
|
156
|
+
assert.equal(first.metrics.isolation.finalInjected, 0)
|
|
157
|
+
assert.equal(first.metrics.isolation.explicitProbe, 0)
|
|
158
|
+
assert.ok(first.samples.every(sample => sample.candidateTop3.length <= 3 && sample.injectedTop3.length <= 3))
|
|
159
|
+
|
|
160
|
+
// No-answer and boundary samples must actually be scored, not skipped.
|
|
161
|
+
const noAnswer = first.samples.filter(sample => sample.subclass === 'no-answer')
|
|
162
|
+
assert.equal(noAnswer.length, 6)
|
|
163
|
+
assert.ok(noAnswer.every(sample => sample.candidateTop3.length === 0 && sample.injectedTop3.length === 0))
|
|
164
|
+
const boundary = first.samples.filter(sample => sample.subclass === 'boundary')
|
|
165
|
+
assert.equal(boundary.length, 6)
|
|
166
|
+
assert.ok(boundary.every(sample => sample.candidateTop3.length <= 3))
|
|
167
|
+
|
|
168
|
+
// Secret entries disappear from automatic recall but appear when explicitly requested.
|
|
169
|
+
const budget = first.samples.find(sample => sample.id === 'rb-sec-budget')
|
|
170
|
+
assert.deepEqual(budget.candidateTop3, [])
|
|
171
|
+
assert.ok(budget.explicitTop3.includes('mem-rb-sec-secret-budget'))
|
|
172
|
+
const diary = first.samples.find(sample => sample.id === 'rb-sec-diary')
|
|
173
|
+
assert.deepEqual(diary.candidateTop3, [])
|
|
174
|
+
assert.ok(diary.explicitTop3.includes('mem-rb-sec-secret-diary'))
|
|
175
|
+
|
|
176
|
+
// The run used a fresh root, and the test's own home stayed untouched.
|
|
177
|
+
assert.ok(first.run.root.startsWith(path.resolve(os.tmpdir())))
|
|
178
|
+
assert.ok(fs.existsSync(path.join(first.run.root, 'dsh-memu-data', 'state.json')))
|
|
179
|
+
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'do not touch the user library\n')
|
|
180
|
+
assert.equal(fs.existsSync(path.join(home, 'state.json')), false)
|
|
181
|
+
|
|
182
|
+
// Token budget is only an estimate, never an actual usage claim.
|
|
183
|
+
assert.equal(first.budget.estimateOnly, true)
|
|
184
|
+
assert.equal(first.budget.field, 'estimate')
|
|
185
|
+
assert.equal(typeof first.budget.injectedTokenEstimateTotal, 'number')
|
|
186
|
+
assert.equal(first.budget.estimate.injectedTokenTotal, first.budget.injectedTokenEstimateTotal)
|
|
187
|
+
assert.equal(first.budget.estimate.turnTokens, 640)
|
|
188
|
+
assert.match(first.budget.note, /estimate/i)
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(stableReport(first), stableReport(second))
|
|
191
|
+
|
|
192
|
+
console.log('RECALL_BENCH_SUMMARY ' + JSON.stringify({
|
|
193
|
+
dataset: { sampleCount: first.dataset.sampleCount, fileCount: first.dataset.fileCount, sha256: first.dataset.sha256 },
|
|
194
|
+
candidate: first.metrics.candidate,
|
|
195
|
+
finalInjected: first.metrics.finalInjected,
|
|
196
|
+
explicitProbe: first.metrics.explicitProbe,
|
|
197
|
+
isolation: first.metrics.isolation,
|
|
198
|
+
latency: first.metrics.latency,
|
|
199
|
+
bySubclass: first.metrics.bySubclass,
|
|
200
|
+
budget: first.budget,
|
|
201
|
+
startedAt: first.run.startedAt,
|
|
202
|
+
}))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('candidate pack loads 40 pending-review samples with complete metadata and balanced subclasses', () => {
|
|
206
|
+
const dataset = bench.loadDataset(bench.CANDIDATE_FIXTURE_DIR)
|
|
207
|
+
assert.equal(dataset.name, 'recall-bench-gold-candidates')
|
|
208
|
+
assert.equal(dataset.status, bench.CANDIDATE_STATUS)
|
|
209
|
+
assert.equal(dataset.status, 'gold-candidate-pending-review')
|
|
210
|
+
assert.equal(dataset.draft, true)
|
|
211
|
+
assert.equal(dataset.notGold, true)
|
|
212
|
+
assert.equal(dataset.gold, false)
|
|
213
|
+
assert.equal(dataset.review, 'pending')
|
|
214
|
+
assert.equal(dataset.samples.length, 40)
|
|
215
|
+
assert.equal(dataset.files.length, 8)
|
|
216
|
+
assert.equal(dataset.entries.size, 64)
|
|
217
|
+
assert.match(dataset.sha256, /^[a-f0-9]{64}$/)
|
|
218
|
+
|
|
219
|
+
const counts = {}
|
|
220
|
+
for (const sample of dataset.samples) {
|
|
221
|
+
assert.equal(sample.status, 'gold-candidate-pending-review', sample.id + ' must stay pending review')
|
|
222
|
+
assert.equal(sample.draft, true, sample.id + ' must be marked draft')
|
|
223
|
+
assert.equal(sample.notGold, true, sample.id + ' must be marked notGold')
|
|
224
|
+
assert.equal(sample.gold, false, sample.id + ' must not be marked gold')
|
|
225
|
+
assert.equal(sample.review, 'pending', sample.id + ' must have a pending review status')
|
|
226
|
+
assert.ok(bench.SUBCLASSES.includes(sample.subclass))
|
|
227
|
+
assert.ok(typeof sample.query === 'string' && sample.query.trim().length > 0)
|
|
228
|
+
assert.ok(sample.context && typeof sample.context === 'object' && !Array.isArray(sample.context))
|
|
229
|
+
assert.ok(Number.isFinite(Date.parse(sample.time)))
|
|
230
|
+
assert.ok(Array.isArray(sample.expectedIds))
|
|
231
|
+
assert.ok(Array.isArray(sample.forbiddenIds))
|
|
232
|
+
assert.ok(Array.isArray(sample.library) && sample.library.length >= 4 && sample.library.length <= 10)
|
|
233
|
+
assert.equal(typeof sample.noAnswer, 'boolean')
|
|
234
|
+
counts[sample.subclass] = (counts[sample.subclass] || 0) + 1
|
|
235
|
+
}
|
|
236
|
+
assert.deepEqual(counts, {
|
|
237
|
+
paraphrase: 5,
|
|
238
|
+
exact: 5,
|
|
239
|
+
negation: 5,
|
|
240
|
+
'no-answer': 5,
|
|
241
|
+
conflict: 5,
|
|
242
|
+
boundary: 5,
|
|
243
|
+
secret: 5,
|
|
244
|
+
coding: 5,
|
|
245
|
+
})
|
|
246
|
+
for (const file of dataset.files) assert.match(file.sha256, /^[a-f0-9]{64}$/)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('candidate pack keeps ids indexed, libraries self-contained, and queries distinct from the synthetic draft', () => {
|
|
250
|
+
const dataset = bench.loadDataset(bench.CANDIDATE_FIXTURE_DIR)
|
|
251
|
+
const defaultDataset = bench.loadDataset()
|
|
252
|
+
const defaultQueries = new Set(defaultDataset.samples.map(sample => sample.query))
|
|
253
|
+
const sampleIds = new Set()
|
|
254
|
+
const queries = new Set()
|
|
255
|
+
for (const sample of dataset.samples) {
|
|
256
|
+
assert.equal(sampleIds.has(sample.id), false, 'duplicate sample id ' + sample.id)
|
|
257
|
+
sampleIds.add(sample.id)
|
|
258
|
+
assert.equal(queries.has(sample.query), false, 'duplicate sample query ' + sample.id)
|
|
259
|
+
queries.add(sample.query)
|
|
260
|
+
assert.equal(defaultQueries.has(sample.query), false, sample.id + ' copies a synthetic-draft query')
|
|
261
|
+
assert.equal(new Set(sample.library).size, sample.library.length, sample.id + ' library has duplicate ids')
|
|
262
|
+
assert.ok(sample.library.length >= 4, sample.id + ' library must have at least four entries')
|
|
263
|
+
assert.equal(sample.noAnswer, sample.expectedIds.length === 0, sample.id + ' expectedIds/noAnswer mismatch')
|
|
264
|
+
for (const id of sample.expectedIds) assert.ok(sample.library.includes(id), sample.id + ' expected id outside library: ' + id)
|
|
265
|
+
for (const id of sample.forbiddenIds) {
|
|
266
|
+
assert.ok(sample.library.includes(id), sample.id + ' forbidden id outside library: ' + id)
|
|
267
|
+
assert.equal(sample.expectedIds.includes(id), false, sample.id + ' id is both expected and forbidden: ' + id)
|
|
268
|
+
}
|
|
269
|
+
for (const id of sample.library) {
|
|
270
|
+
const entry = dataset.entries.get(id)
|
|
271
|
+
assert.ok(entry, sample.id + ' references unknown entry ' + id)
|
|
272
|
+
assert.equal(entry.id, id)
|
|
273
|
+
assert.ok(typeof entry.title === 'string' && entry.title.trim().length > 0)
|
|
274
|
+
assert.ok(typeof entry.content === 'string' && entry.content.trim().length > 0)
|
|
275
|
+
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0)
|
|
276
|
+
assert.ok(Number.isFinite(entry.ageDays) && entry.ageDays >= 0)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
assert.equal(sampleIds.size, 40)
|
|
280
|
+
assert.equal(queries.size, 40)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('candidate validation fails closed on gold, review, and draft status leaks', () => {
|
|
284
|
+
const dataset = bench.loadDataset(bench.CANDIDATE_FIXTURE_DIR)
|
|
285
|
+
const sample = dataset.samples.find(item => item.subclass === 'paraphrase')
|
|
286
|
+
const options = { status: bench.CANDIDATE_STATUS }
|
|
287
|
+
assert.throws(() => bench.validateSampleShape({ ...sample, gold: true }, 'candidate', options), /gold must not be marked/)
|
|
288
|
+
assert.throws(() => bench.validateSampleShape({ ...sample, review: 'approved' }, 'candidate', options), /review must be pending/)
|
|
289
|
+
assert.throws(() => bench.validateSampleShape({ ...sample, status: bench.DEFAULT_STATUS }, 'candidate', options), /candidate sample status/)
|
|
290
|
+
assert.throws(() => bench.validateSampleShape({ ...sample, draft: false }, 'candidate', options), /draft must be true/)
|
|
291
|
+
assert.throws(() => bench.validateSampleShape({ ...sample, notGold: false }, 'candidate', options), /notGold must be true/)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('candidate no-answer top-3 emptiness and forbidden isolation hold without locking scores', async () => {
|
|
295
|
+
const report = await bench.runBenchmark({ fixtureDir: bench.CANDIDATE_FIXTURE_DIR, workRoot: freshRoot('candidate'), seed: 4242 })
|
|
296
|
+
assert.equal(report.status, 'gold-candidate-pending-review')
|
|
297
|
+
assert.equal(report.gold, false)
|
|
298
|
+
assert.equal(report.review, 'pending')
|
|
299
|
+
assert.equal(report.dataset.sampleCount, 40)
|
|
300
|
+
assert.equal(report.dataset.sha256, bench.loadDataset(bench.CANDIDATE_FIXTURE_DIR).sha256)
|
|
301
|
+
assert.equal(report.metrics.isolation.violations, 0)
|
|
302
|
+
const noAnswer = report.samples.filter(sample => sample.noAnswer)
|
|
303
|
+
assert.equal(noAnswer.length, 8)
|
|
304
|
+
assert.ok(noAnswer.every(sample => sample.candidateTop3.length === 0 && sample.injectedTop3.length === 0))
|
|
305
|
+
// Deliberately no recall/MRR assertion: these are pending-review candidates, not gold scores.
|
|
306
|
+
})
|