@modusensus/dsh-mneme 0.8.13 → 0.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,249 +1,249 @@
1
- // Recall benchmark (v0.5.0 评测体系): a self-contained harness that seeds an
2
- // in-memory store with labelled memories, runs a standard query set through
3
- // the real searchMemories pipeline, and reports Recall@K / MRR per case and
4
- // in aggregate. Runs in two configurations so the BM25/third-path lift is
5
- // visible: `legacy` (bm25 + adaptive + dedup off) vs `fused` (defaults on).
6
- //
7
- // Usage:
8
- // node scripts/benchmark-recall.js # run both configurations
9
- // node scripts/benchmark-recall.js --json # machine-readable output
10
- // The harness exports runBenchmark()/TEST_CASES for the test suite; the CLI
11
- // path below only executes when invoked directly.
12
- import { createStore } from "../src/store.js";
13
- import { createService } from "../src/service.js";
14
- import { createVectorIndex } from "../src/vector-index.js";
15
-
16
- // Deterministic toy embedder: bag-of-words hashed into a fixed-dimension
17
- // vector, so cosine similarity ≈ lexical overlap. Good enough to exercise
18
- // the vector path mechanically — semantic quality is not under test here.
19
- const DIM = 256;
20
- function hashVec(text) {
21
- const v = new Array(DIM).fill(0);
22
- const tokens = String(text ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
23
- for (const t of tokens) {
24
- let h = 0;
25
- for (const ch of t) h = (h * 31 + ch.codePointAt(0)) >>> 0;
26
- v[h % DIM] += 1;
27
- }
28
- const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
29
- return v.map((x) => x / norm);
30
- }
31
-
32
- const SEED = [
33
- { id: "mem_user_pref", type: "preference", title: "编辑器偏好", content: "用户偏好 VS Code,深色主题,等宽字体 JetBrains Mono", importance: 4, tags: ["editor"] },
34
- { id: "mem_user_project", type: "project", title: "dsh-mneme 插件项目", content: "用户在开发 dsh-mneme 记忆插件,TypeScript 与 cordis 框架", importance: 5, tags: ["plugin"] },
35
- { id: "mem_rust_switch", type: "decision", title: "语言迁移决策", content: "项目编译模块从 Go 迁移到 Rust,理由是内存安全", importance: 4, tags: ["rust"] },
36
- { id: "mem_zfs_bug", type: "project", title: "ZFS-4421 数据损坏", content: "线上池 ZFS-4421 出现 checksum 错误,根因是 HBA 固件 bug", importance: 5, tags: ["ops"] },
37
- { id: "mem_city_thesis", type: "project", title: "湿地论文", content: "毕业论文研究城市湿地公园周边开发案例,ArcGIS 空间分析", importance: 4, tags: ["thesis"] },
38
- { id: "mem_async_pattern", type: "decision", title: "异步并发模式", content: "async runtime 选用 tokio,任务用 spawn 管理,channel 通信", importance: 3, tags: ["rust"] },
39
- { id: "mem_python_etl", type: "project", title: "ETL 脚本", content: "夜间 ETL 用 Python 编写,pandas 清洗,SQLite 落地", importance: 3, tags: ["etl"] },
40
- { id: "mem_ui_style", type: "preference", title: "界面审美", content: "喜欢编辑风 brutalism 排版,低饱和度配色,衬线标题", importance: 3, tags: ["design"] },
41
- // Cross-topic distractors (plan #0): memories that share keywords with a
42
- // target but belong to a different subject. They raise the recall bar — the
43
- // fused ranking must keep the true positive ahead of the distractor, which
44
- // is exactly what a scale-mixed blend (raw cosine + keyword score + IDF)
45
- // tends to get wrong.
46
- { id: "mem_ops_alert", type: "project", title: "存储告警", content: "Prometheus 存储告警走 zfs 池健康检查与磁盘替换流程", importance: 3, tags: ["ops"] },
47
- { id: "mem_rust_dep", type: "project", title: "rust 依赖", content: "Rust 项目的 cargo 依赖管理与 workspace 组织", importance: 3, tags: ["rust"] },
48
- { id: "mem_etl_csv", type: "project", title: "ETL CSV", content: "每日 CSV 导入脚本用 golang 而非 python,写 postgres", importance: 2, tags: ["etl"] },
49
- { id: "mem_ux_toolbar", type: "preference", title: "工具栏", content: "偏好 IDE 顶栏简洁,避免深色浮层遮挡代码", importance: 2, tags: ["design"] }
50
- ];
51
-
52
- // Standard query set: each case is a query plus the ids that MUST appear in
53
- // the top-K for the case to count as a hit. Covers the three recall paths —
54
- // multi-term lexical (BM25's home turf), identifier lookup, and semantic.
55
- // Optional per case: `forbidden` — ids that must NOT appear in the top-K.
56
- // Anti-update probes (E6, #280 链;StatemenBench anti-update probes):锁的
57
- // 回归类是「supersede 后旧值复现」——被取代旧条靠 archived=1 从四路检索里
58
- // **排除**(不是降权)。未来谁把任一默认检索路径改成 archived 宽容的降权、
59
- // 或把归档行重新放回候选池,forbiddenHit 就会 >0,这里先红。
60
- export const TEST_CASES = [
61
- { query: "rust 异步", expected: ["mem_async_pattern", "mem_rust_switch"], note: "scattered terms — BM25 territory" },
62
- { query: "ZFS-4421 checksum", expected: ["mem_zfs_bug"], note: "identifier + keyword" },
63
- { query: "插件 开发", expected: ["mem_user_project"], note: "multi-term CJK" },
64
- { query: "论文 空间分析", expected: ["mem_city_thesis"], note: "scattered CJK terms" },
65
- { query: "ETL 脚本", expected: ["mem_python_etl"], note: "mixed" },
66
- { query: "深色主题", expected: ["mem_user_pref"], note: "substring match" },
67
- { query: "channel 通信 任务", expected: ["mem_async_pattern"], note: "scattered terms" },
68
- { query: "内存安全 语言", expected: ["mem_rust_switch"], note: "scattered terms" },
69
- { query: "配色 审美", expected: ["mem_ui_style"], note: "scattered CJK" },
70
- { query: "HBA 固件", expected: ["mem_zfs_bug"], note: "scattered terms" },
71
- // Plan #0 additions: a distractor-dominance case (the target shares the
72
- // leading token with a cross-topic memory that must rank below it) and an
73
- // exact-token case that leans on the BM25 path.
74
- { query: "zfs 磁盘 替换", expected: ["mem_zfs_bug"], note: "shared-token distractor" },
75
- { query: "tokio spawn channel", expected: ["mem_async_pattern"], note: "exact async tokens" },
76
- // Anti-update probes(配 seedService 里的 supersede pair):第二条 query 的
77
- // 措辞只与被取代的旧值(Go 选型)重叠——归档旧条若经任一默认路径复现,
78
- // forbiddenHit > 0;同时新值必须照常命中(取代后话题有活答案)。
79
- { query: "编译模块 语言 选型", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "anti-update: winner must answer the topic" },
80
- { query: "Go 部署 简单", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "stale-value bait: wording matches only the superseded value" }
81
- ];
82
-
83
- export function seedService(overrides = {}) {
84
- const store = createStore(":memory:");
85
- const config = {
86
- bm25SearchEnabled: true,
87
- adaptiveThresholdEnabled: true,
88
- searchSemanticDedup: true,
89
- searchSemanticDedupThreshold: 0.95,
90
- selectiveInjectEnabled: true,
91
- entitySearchEnabled: false,
92
- ...overrides
93
- };
94
- const service = createService({ store, mirror: null, config, logger: null });
95
- const vectorIndex = createVectorIndex({ store, logger: null });
96
- service.setVectorIndex(vectorIndex);
97
- service.setEmbedder({
98
- embedSingle: async (text) => hashVec(text)
99
- });
100
- for (const m of SEED) {
101
- // store.save accepts a caller-supplied id (store.js: memory.id ?? randomUUID).
102
- // Passing m.id keeps the seeded id stable so TEST_CASES.expected (which
103
- // references mem_*) match — without it every row gets a UUID and the
104
- // benchmark always reports 0% recall.
105
- const row = store.save({ id: m.id, type: m.type, title: m.title, content: m.content, tags: m.tags, importance: m.importance, source: "seed" });
106
- store.setEmbedding(row.id, hashVec(`${m.title} ${m.content}`));
107
- }
108
- // Supersede pair(anti-update 探针的种子):复刻 dream/decisions.js
109
- // applySupersede 留下的确切落库状态——没有独立的取代列,取代关系完全由
110
- // loser 的 archived=1 + 正文 (superseded by: …) 注记表达。winner 就是
111
- // 上面的 mem_rust_switch 种子行,让「编译语言」话题始终有活答案,
112
- // 归档的 Go 旧决策必须保持不可见。
113
- const loserContent = "项目编译模块使用 Go 语言,选型理由是部署简单\n\n(superseded by: 语言迁移决策)";
114
- const loser = store.save({ id: "mem_rust_switch_old", type: "decision", title: "编译模块语言(旧)", content: loserContent, importance: 4, tags: ["rust"], source: "seed" });
115
- store.setEmbedding(loser.id, hashVec(`编译模块语言(旧) ${loserContent}`));
116
- store.setArchived(loser.id, true);
117
- return service;
118
- }
119
-
120
- export async function runBenchmark({ topK = 5, mode = "auto" } = {}) {
121
- const configs = [
122
- { name: "legacy", overrides: { bm25SearchEnabled: false, adaptiveThresholdEnabled: false, searchSemanticDedup: false } },
123
- { name: "fused", overrides: {} }
124
- ];
125
- const runs = [];
126
- for (const cfg of configs) {
127
- const service = seedService(cfg.overrides);
128
- const rows = [];
129
- let hits = 0;
130
- let mrrSum = 0;
131
- let leaks = 0;
132
- for (const tc of TEST_CASES) {
133
- const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
134
- const ids = results.map((r) => r.id);
135
- const metrics = service.computeRetrievalMetrics(ids, tc.expected);
136
- if (metrics.recall === 1) hits++;
137
- mrrSum += metrics.mrr;
138
- // forbidden 命中数:anti-update 探针的判定(见 TEST_CASES 头注释)。
139
- const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
140
- if (forbiddenHit > 0) leaks++;
141
- rows.push({
142
- query: tc.query,
143
- note: tc.note,
144
- expected: tc.expected,
145
- ...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
146
- forbiddenHit,
147
- got: ids,
148
- ...metrics
149
- });
150
- }
151
- runs.push({
152
- config: cfg.name,
153
- recallAtK: +(hits / TEST_CASES.length).toFixed(3),
154
- avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
155
- forbiddenHits: leaks,
156
- rows
157
- });
158
- }
159
- return { topK, mode, runs };
160
- }
161
-
162
- /**
163
- * Fusion-recipe A/B (plan #1): runs the same seed + query set with
164
- * config.recallFusion forced to each of blend / rrf / minmax, so the scale
165
- * mismatch fix can be judged on identical data. `blend` is the legacy recipe
166
- * and acts as the control — the pre-fusion behavior.
167
- */
168
- export async function runFusionBenchmark({ topK = 5, mode = "auto" } = {}) {
169
- const recipes = ["blend", "rrf", "minmax"];
170
- const runs = [];
171
- for (const recipe of recipes) {
172
- const service = seedService({ recallFusion: recipe });
173
- const rows = [];
174
- let hits = 0;
175
- let mrrSum = 0;
176
- let leaks = 0;
177
- for (const tc of TEST_CASES) {
178
- const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
179
- const ids = results.map((r) => r.id);
180
- const metrics = service.computeRetrievalMetrics(ids, tc.expected);
181
- if (metrics.recall === 1) hits++;
182
- mrrSum += metrics.mrr;
183
- const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
184
- if (forbiddenHit > 0) leaks++;
185
- rows.push({
186
- query: tc.query,
187
- note: tc.note,
188
- expected: tc.expected,
189
- ...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
190
- forbiddenHit,
191
- got: ids,
192
- ...metrics
193
- });
194
- }
195
- runs.push({
196
- config: recipe,
197
- recallAtK: +(hits / TEST_CASES.length).toFixed(3),
198
- avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
199
- forbiddenHits: leaks,
200
- rows
201
- });
202
- }
203
- return { topK, mode, runs };
204
- }
205
-
206
- function printFusionReport(report) {
207
- for (const run of report.runs) {
208
- console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
209
- for (const r of run.rows) {
210
- const ok = r.recall === 1 ? "PASS" : "MISS";
211
- const leak = r.forbiddenHit > 0 ? " LEAK" : "";
212
- console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
213
- if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
214
- if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
215
- }
216
- console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
217
- (run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
218
- }
219
- const summary = report.runs.map((r) => `${r.config}=${(r.recallAtK * 100).toFixed(1)}%`).join(" ");
220
- console.log(`\n融合配方 A/B (Recall@${report.topK}, ${report.mode}): ${summary}`);
221
- }
222
-
223
- function printReport(report) {
224
- for (const run of report.runs) {
225
- console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
226
- for (const r of run.rows) {
227
- const ok = r.recall === 1 ? "PASS" : "MISS";
228
- const leak = r.forbiddenHit > 0 ? " LEAK" : "";
229
- console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
230
- if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
231
- if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
232
- }
233
- console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
234
- (run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
235
- }
236
- const [legacy, fused] = report.runs;
237
- const lift = ((fused.recallAtK - legacy.recallAtK) * 100).toFixed(1);
238
- console.log(`\n三路融合 vs 旧两路: Recall@${report.topK} ${legacy.recallAtK * 100}% → ${fused.recallAtK * 100}% (${lift >= 0 ? "+" : ""}${lift}pp)`);
239
- }
240
-
241
- const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop() ?? "");
242
- if (invokedDirectly) {
243
- const asJson = process.argv.includes("--json");
244
- const asFusion = process.argv.includes("--fusion");
245
- const report = asFusion ? await runFusionBenchmark({}) : await runBenchmark({});
246
- if (asJson) console.log(JSON.stringify(report, null, 2));
247
- else if (asFusion) printFusionReport(report);
248
- else printReport(report);
249
- }
1
+ // Recall benchmark (v0.5.0 评测体系): a self-contained harness that seeds an
2
+ // in-memory store with labelled memories, runs a standard query set through
3
+ // the real searchMemories pipeline, and reports Recall@K / MRR per case and
4
+ // in aggregate. Runs in two configurations so the BM25/third-path lift is
5
+ // visible: `legacy` (bm25 + adaptive + dedup off) vs `fused` (defaults on).
6
+ //
7
+ // Usage:
8
+ // node scripts/benchmark-recall.js # run both configurations
9
+ // node scripts/benchmark-recall.js --json # machine-readable output
10
+ // The harness exports runBenchmark()/TEST_CASES for the test suite; the CLI
11
+ // path below only executes when invoked directly.
12
+ import { createStore } from "../src/store.js";
13
+ import { createService } from "../src/service.js";
14
+ import { createVectorIndex } from "../src/vector-index.js";
15
+
16
+ // Deterministic toy embedder: bag-of-words hashed into a fixed-dimension
17
+ // vector, so cosine similarity ≈ lexical overlap. Good enough to exercise
18
+ // the vector path mechanically — semantic quality is not under test here.
19
+ const DIM = 256;
20
+ function hashVec(text) {
21
+ const v = new Array(DIM).fill(0);
22
+ const tokens = String(text ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
23
+ for (const t of tokens) {
24
+ let h = 0;
25
+ for (const ch of t) h = (h * 31 + ch.codePointAt(0)) >>> 0;
26
+ v[h % DIM] += 1;
27
+ }
28
+ const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
29
+ return v.map((x) => x / norm);
30
+ }
31
+
32
+ const SEED = [
33
+ { id: "mem_user_pref", type: "preference", title: "编辑器偏好", content: "用户偏好 VS Code,深色主题,等宽字体 JetBrains Mono", importance: 4, tags: ["editor"] },
34
+ { id: "mem_user_project", type: "project", title: "dsh-mneme 插件项目", content: "用户在开发 dsh-mneme 记忆插件,TypeScript 与 cordis 框架", importance: 5, tags: ["plugin"] },
35
+ { id: "mem_rust_switch", type: "decision", title: "语言迁移决策", content: "项目编译模块从 Go 迁移到 Rust,理由是内存安全", importance: 4, tags: ["rust"] },
36
+ { id: "mem_zfs_bug", type: "project", title: "ZFS-4421 数据损坏", content: "线上池 ZFS-4421 出现 checksum 错误,根因是 HBA 固件 bug", importance: 5, tags: ["ops"] },
37
+ { id: "mem_city_thesis", type: "project", title: "湿地论文", content: "毕业论文研究城市湿地公园周边开发案例,ArcGIS 空间分析", importance: 4, tags: ["thesis"] },
38
+ { id: "mem_async_pattern", type: "decision", title: "异步并发模式", content: "async runtime 选用 tokio,任务用 spawn 管理,channel 通信", importance: 3, tags: ["rust"] },
39
+ { id: "mem_python_etl", type: "project", title: "ETL 脚本", content: "夜间 ETL 用 Python 编写,pandas 清洗,SQLite 落地", importance: 3, tags: ["etl"] },
40
+ { id: "mem_ui_style", type: "preference", title: "界面审美", content: "喜欢编辑风 brutalism 排版,低饱和度配色,衬线标题", importance: 3, tags: ["design"] },
41
+ // Cross-topic distractors (plan #0): memories that share keywords with a
42
+ // target but belong to a different subject. They raise the recall bar — the
43
+ // fused ranking must keep the true positive ahead of the distractor, which
44
+ // is exactly what a scale-mixed blend (raw cosine + keyword score + IDF)
45
+ // tends to get wrong.
46
+ { id: "mem_ops_alert", type: "project", title: "存储告警", content: "Prometheus 存储告警走 zfs 池健康检查与磁盘替换流程", importance: 3, tags: ["ops"] },
47
+ { id: "mem_rust_dep", type: "project", title: "rust 依赖", content: "Rust 项目的 cargo 依赖管理与 workspace 组织", importance: 3, tags: ["rust"] },
48
+ { id: "mem_etl_csv", type: "project", title: "ETL CSV", content: "每日 CSV 导入脚本用 golang 而非 python,写 postgres", importance: 2, tags: ["etl"] },
49
+ { id: "mem_ux_toolbar", type: "preference", title: "工具栏", content: "偏好 IDE 顶栏简洁,避免深色浮层遮挡代码", importance: 2, tags: ["design"] }
50
+ ];
51
+
52
+ // Standard query set: each case is a query plus the ids that MUST appear in
53
+ // the top-K for the case to count as a hit. Covers the three recall paths —
54
+ // multi-term lexical (BM25's home turf), identifier lookup, and semantic.
55
+ // Optional per case: `forbidden` — ids that must NOT appear in the top-K.
56
+ // Anti-update probes (E6, #280 链;StatemenBench anti-update probes):锁的
57
+ // 回归类是「supersede 后旧值复现」——被取代旧条靠 archived=1 从四路检索里
58
+ // **排除**(不是降权)。未来谁把任一默认检索路径改成 archived 宽容的降权、
59
+ // 或把归档行重新放回候选池,forbiddenHit 就会 >0,这里先红。
60
+ export const TEST_CASES = [
61
+ { query: "rust 异步", expected: ["mem_async_pattern", "mem_rust_switch"], note: "scattered terms — BM25 territory" },
62
+ { query: "ZFS-4421 checksum", expected: ["mem_zfs_bug"], note: "identifier + keyword" },
63
+ { query: "插件 开发", expected: ["mem_user_project"], note: "multi-term CJK" },
64
+ { query: "论文 空间分析", expected: ["mem_city_thesis"], note: "scattered CJK terms" },
65
+ { query: "ETL 脚本", expected: ["mem_python_etl"], note: "mixed" },
66
+ { query: "深色主题", expected: ["mem_user_pref"], note: "substring match" },
67
+ { query: "channel 通信 任务", expected: ["mem_async_pattern"], note: "scattered terms" },
68
+ { query: "内存安全 语言", expected: ["mem_rust_switch"], note: "scattered terms" },
69
+ { query: "配色 审美", expected: ["mem_ui_style"], note: "scattered CJK" },
70
+ { query: "HBA 固件", expected: ["mem_zfs_bug"], note: "scattered terms" },
71
+ // Plan #0 additions: a distractor-dominance case (the target shares the
72
+ // leading token with a cross-topic memory that must rank below it) and an
73
+ // exact-token case that leans on the BM25 path.
74
+ { query: "zfs 磁盘 替换", expected: ["mem_zfs_bug"], note: "shared-token distractor" },
75
+ { query: "tokio spawn channel", expected: ["mem_async_pattern"], note: "exact async tokens" },
76
+ // Anti-update probes(配 seedService 里的 supersede pair):第二条 query 的
77
+ // 措辞只与被取代的旧值(Go 选型)重叠——归档旧条若经任一默认路径复现,
78
+ // forbiddenHit > 0;同时新值必须照常命中(取代后话题有活答案)。
79
+ { query: "编译模块 语言 选型", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "anti-update: winner must answer the topic" },
80
+ { query: "Go 部署 简单", expected: ["mem_rust_switch"], forbidden: ["mem_rust_switch_old"], note: "stale-value bait: wording matches only the superseded value" }
81
+ ];
82
+
83
+ export function seedService(overrides = {}) {
84
+ const store = createStore(":memory:");
85
+ const config = {
86
+ bm25SearchEnabled: true,
87
+ adaptiveThresholdEnabled: true,
88
+ searchSemanticDedup: true,
89
+ searchSemanticDedupThreshold: 0.95,
90
+ selectiveInjectEnabled: true,
91
+ entitySearchEnabled: false,
92
+ ...overrides
93
+ };
94
+ const service = createService({ store, mirror: null, config, logger: null });
95
+ const vectorIndex = createVectorIndex({ store, logger: null });
96
+ service.setVectorIndex(vectorIndex);
97
+ service.setEmbedder({
98
+ embedSingle: async (text) => hashVec(text)
99
+ });
100
+ for (const m of SEED) {
101
+ // store.save accepts a caller-supplied id (store.js: memory.id ?? randomUUID).
102
+ // Passing m.id keeps the seeded id stable so TEST_CASES.expected (which
103
+ // references mem_*) match — without it every row gets a UUID and the
104
+ // benchmark always reports 0% recall.
105
+ const row = store.save({ id: m.id, type: m.type, title: m.title, content: m.content, tags: m.tags, importance: m.importance, source: "seed" });
106
+ store.setEmbedding(row.id, hashVec(`${m.title} ${m.content}`));
107
+ }
108
+ // Supersede pair(anti-update 探针的种子):复刻 dream/decisions.js
109
+ // applySupersede 留下的确切落库状态——没有独立的取代列,取代关系完全由
110
+ // loser 的 archived=1 + 正文 (superseded by: …) 注记表达。winner 就是
111
+ // 上面的 mem_rust_switch 种子行,让「编译语言」话题始终有活答案,
112
+ // 归档的 Go 旧决策必须保持不可见。
113
+ const loserContent = "项目编译模块使用 Go 语言,选型理由是部署简单\n\n(superseded by: 语言迁移决策)";
114
+ const loser = store.save({ id: "mem_rust_switch_old", type: "decision", title: "编译模块语言(旧)", content: loserContent, importance: 4, tags: ["rust"], source: "seed" });
115
+ store.setEmbedding(loser.id, hashVec(`编译模块语言(旧) ${loserContent}`));
116
+ store.setArchived(loser.id, true);
117
+ return service;
118
+ }
119
+
120
+ export async function runBenchmark({ topK = 5, mode = "auto" } = {}) {
121
+ const configs = [
122
+ { name: "legacy", overrides: { bm25SearchEnabled: false, adaptiveThresholdEnabled: false, searchSemanticDedup: false } },
123
+ { name: "fused", overrides: {} }
124
+ ];
125
+ const runs = [];
126
+ for (const cfg of configs) {
127
+ const service = seedService(cfg.overrides);
128
+ const rows = [];
129
+ let hits = 0;
130
+ let mrrSum = 0;
131
+ let leaks = 0;
132
+ for (const tc of TEST_CASES) {
133
+ const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
134
+ const ids = results.map((r) => r.id);
135
+ const metrics = service.computeRetrievalMetrics(ids, tc.expected);
136
+ if (metrics.recall === 1) hits++;
137
+ mrrSum += metrics.mrr;
138
+ // forbidden 命中数:anti-update 探针的判定(见 TEST_CASES 头注释)。
139
+ const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
140
+ if (forbiddenHit > 0) leaks++;
141
+ rows.push({
142
+ query: tc.query,
143
+ note: tc.note,
144
+ expected: tc.expected,
145
+ ...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
146
+ forbiddenHit,
147
+ got: ids,
148
+ ...metrics
149
+ });
150
+ }
151
+ runs.push({
152
+ config: cfg.name,
153
+ recallAtK: +(hits / TEST_CASES.length).toFixed(3),
154
+ avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
155
+ forbiddenHits: leaks,
156
+ rows
157
+ });
158
+ }
159
+ return { topK, mode, runs };
160
+ }
161
+
162
+ /**
163
+ * Fusion-recipe A/B (plan #1): runs the same seed + query set with
164
+ * config.recallFusion forced to each of blend / rrf / minmax, so the scale
165
+ * mismatch fix can be judged on identical data. `blend` is the legacy recipe
166
+ * and acts as the control — the pre-fusion behavior.
167
+ */
168
+ export async function runFusionBenchmark({ topK = 5, mode = "auto" } = {}) {
169
+ const recipes = ["blend", "rrf", "minmax"];
170
+ const runs = [];
171
+ for (const recipe of recipes) {
172
+ const service = seedService({ recallFusion: recipe });
173
+ const rows = [];
174
+ let hits = 0;
175
+ let mrrSum = 0;
176
+ let leaks = 0;
177
+ for (const tc of TEST_CASES) {
178
+ const results = await service.searchMemories(tc.query, { mode, topK, useRerank: false });
179
+ const ids = results.map((r) => r.id);
180
+ const metrics = service.computeRetrievalMetrics(ids, tc.expected);
181
+ if (metrics.recall === 1) hits++;
182
+ mrrSum += metrics.mrr;
183
+ const forbiddenHit = (tc.forbidden ?? []).filter((id) => ids.includes(id)).length;
184
+ if (forbiddenHit > 0) leaks++;
185
+ rows.push({
186
+ query: tc.query,
187
+ note: tc.note,
188
+ expected: tc.expected,
189
+ ...(tc.forbidden ? { forbidden: tc.forbidden } : {}),
190
+ forbiddenHit,
191
+ got: ids,
192
+ ...metrics
193
+ });
194
+ }
195
+ runs.push({
196
+ config: recipe,
197
+ recallAtK: +(hits / TEST_CASES.length).toFixed(3),
198
+ avgMrr: +(mrrSum / TEST_CASES.length).toFixed(3),
199
+ forbiddenHits: leaks,
200
+ rows
201
+ });
202
+ }
203
+ return { topK, mode, runs };
204
+ }
205
+
206
+ function printFusionReport(report) {
207
+ for (const run of report.runs) {
208
+ console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
209
+ for (const r of run.rows) {
210
+ const ok = r.recall === 1 ? "PASS" : "MISS";
211
+ const leak = r.forbiddenHit > 0 ? " LEAK" : "";
212
+ console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
213
+ if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
214
+ if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
215
+ }
216
+ console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
217
+ (run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
218
+ }
219
+ const summary = report.runs.map((r) => `${r.config}=${(r.recallAtK * 100).toFixed(1)}%`).join(" ");
220
+ console.log(`\n融合配方 A/B (Recall@${report.topK}, ${report.mode}): ${summary}`);
221
+ }
222
+
223
+ function printReport(report) {
224
+ for (const run of report.runs) {
225
+ console.log(`\n=== ${run.config} (topK=${report.topK}, mode=${report.mode}) ===`);
226
+ for (const r of run.rows) {
227
+ const ok = r.recall === 1 ? "PASS" : "MISS";
228
+ const leak = r.forbiddenHit > 0 ? " LEAK" : "";
229
+ console.log(` [${ok}${leak}] "${r.query}" (${r.note}) recall=${r.recall} mrr=${r.mrr}`);
230
+ if (r.recall < 1) console.log(` expected ⊇ ${r.expected.join(", ")} got: ${r.got.join(", ") || "—"}`);
231
+ if (r.forbiddenHit > 0) console.log(` forbidden hit ×${r.forbiddenHit}: ${r.got.filter((id) => r.forbidden.includes(id)).join(", ")}`);
232
+ }
233
+ console.log(` → Recall@${report.topK}: ${(run.recallAtK * 100).toFixed(1)}% avg MRR: ${run.avgMrr}` +
234
+ (run.forbiddenHits > 0 ? ` forbiddenHits: ${run.forbiddenHits}` : ""));
235
+ }
236
+ const [legacy, fused] = report.runs;
237
+ const lift = ((fused.recallAtK - legacy.recallAtK) * 100).toFixed(1);
238
+ console.log(`\n三路融合 vs 旧两路: Recall@${report.topK} ${legacy.recallAtK * 100}% → ${fused.recallAtK * 100}% (${lift >= 0 ? "+" : ""}${lift}pp)`);
239
+ }
240
+
241
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop() ?? "");
242
+ if (invokedDirectly) {
243
+ const asJson = process.argv.includes("--json");
244
+ const asFusion = process.argv.includes("--fusion");
245
+ const report = asFusion ? await runFusionBenchmark({}) : await runBenchmark({});
246
+ if (asJson) console.log(JSON.stringify(report, null, 2));
247
+ else if (asFusion) printFusionReport(report);
248
+ else printReport(report);
249
+ }