dsh-memu-memory 1.2.0 → 1.2.2

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 (100) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +19 -8
  3. package/README.zh.md +12 -6
  4. package/docs/contributing.md +16 -3
  5. package/docs/env-and-commands.md +8 -0
  6. package/docs/host-api.md +3 -1
  7. package/docs/memory-semantics.md +21 -2
  8. package/docs/troubleshooting.md +42 -3
  9. package/lib/activity-log.js +22 -5
  10. package/lib/backup.js +24 -2
  11. package/lib/binding-diagnostics.js +3 -3
  12. package/lib/client.js +1610 -117
  13. package/lib/consolidation-worker.js +68 -17
  14. package/lib/curator-evidence.js +131 -0
  15. package/lib/curator-policy.js +81 -0
  16. package/lib/curator.js +1299 -83
  17. package/lib/cycle.js +3 -0
  18. package/lib/embedding-download.js +39 -4
  19. package/lib/engine.js +6 -3
  20. package/lib/global-graph.js +282 -0
  21. package/lib/graph.js +190 -14
  22. package/lib/inbound.js +19 -0
  23. package/lib/index.js +33 -1
  24. package/lib/inject.js +277 -17
  25. package/lib/lexicon.js +1115 -0
  26. package/lib/local-vector-service.js +8 -6
  27. package/lib/managed-runtime.js +33 -2
  28. package/lib/memory.js +585 -76
  29. package/lib/memu.js +2 -2
  30. package/lib/migrate.js +83 -2
  31. package/lib/private-links.js +587 -0
  32. package/lib/private.js +128 -7
  33. package/lib/prompts/global-curator.md +76 -19
  34. package/lib/prompts/private-consolidation.coding.md +4 -1
  35. package/lib/prompts/private-consolidation.md +3 -1
  36. package/lib/prompts/private-consolidation.mixed.md +3 -1
  37. package/lib/prompts/private-consolidation.rp.md +3 -1
  38. package/lib/prompts.js +1 -1
  39. package/lib/rpc.js +622 -13
  40. package/lib/state.js +4 -1
  41. package/lib/text-retrieval.js +661 -0
  42. package/lib/text-similarity.js +124 -0
  43. package/lib/tools.js +230 -16
  44. package/lib/vector-migration.js +28 -3
  45. package/lib/vector-profile.js +24 -0
  46. package/lib/webui/standalone.css +17 -0
  47. package/lib/webui/standalone.js +2 -0
  48. package/package.json +3 -2
  49. package/scripts/acceptance-local.mjs +8 -5
  50. package/scripts/curator-pair-calibrate.mjs +259 -0
  51. package/scripts/lexicon-calibrate.mjs +230 -0
  52. package/scripts/lexicon-dedupe.mjs +318 -0
  53. package/scripts/replay.mjs +573 -0
  54. package/tests/absorb-review.test.mjs +353 -0
  55. package/tests/appendix-b.test.mjs +6 -2
  56. package/tests/appendix-b2.test.mjs +2 -2
  57. package/tests/client-export.test.mjs +520 -0
  58. package/tests/client-render.test.mjs +19 -3
  59. package/tests/consolidation-credential.test.mjs +16 -5
  60. package/tests/consolidation-feed.test.mjs +96 -0
  61. package/tests/consolidation-fork.test.mjs +144 -10
  62. package/tests/consolidation-snapshot.test.mjs +82 -4
  63. package/tests/curator-absorb.test.mjs +443 -0
  64. package/tests/curator-calibrate.test.mjs +173 -0
  65. package/tests/curator-clusters.test.mjs +281 -0
  66. package/tests/curator-commit.test.mjs +324 -0
  67. package/tests/curator-evidence.test.mjs +102 -0
  68. package/tests/curator-link.test.mjs +316 -0
  69. package/tests/curator-pairing.test.mjs +252 -0
  70. package/tests/curator-probe.test.mjs +15 -6
  71. package/tests/curator-review-mode.test.mjs +314 -0
  72. package/tests/disclosure.test.mjs +2 -1
  73. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  74. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  75. package/tests/fixtures/lexicon-stub.mjs +284 -0
  76. package/tests/graph-cascade.test.mjs +223 -0
  77. package/tests/graph.test.mjs +113 -0
  78. package/tests/help-page.test.mjs +415 -0
  79. package/tests/hybrid-recall.test.mjs +32 -2
  80. package/tests/inbound.test.mjs +6 -3
  81. package/tests/inject-guide.test.mjs +28 -0
  82. package/tests/inject.test.mjs +157 -5
  83. package/tests/lexicon-calibrate.test.mjs +48 -0
  84. package/tests/lexicon-dedupe.test.mjs +171 -0
  85. package/tests/lexicon-frequency.test.mjs +179 -0
  86. package/tests/lexicon-inject.test.mjs +410 -0
  87. package/tests/lexicon-perf.test.mjs +436 -0
  88. package/tests/lexicon-ui.test.mjs +547 -0
  89. package/tests/lexicon.test.mjs +922 -0
  90. package/tests/managed-memory.test.mjs +55 -0
  91. package/tests/managed-runtime.test.mjs +32 -0
  92. package/tests/private-links.test.mjs +445 -0
  93. package/tests/prompts.test.mjs +35 -0
  94. package/tests/recall-dedup.test.mjs +446 -0
  95. package/tests/sidebar-beat.test.mjs +5 -1
  96. package/tests/tool-schema.test.mjs +1 -1
  97. package/tests/tools.test.mjs +112 -3
  98. package/tests/vector-migration.test.mjs +58 -1
  99. package/tests/webui-auth.test.mjs +348 -2
  100. package/tests/webui.test.mjs +191 -2
@@ -0,0 +1,443 @@
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
+ import { registerHooks } from 'node:module'
7
+
8
+ // Package F2 (spec §6.7 / §4.7 / §4.11): commitCuratorPlans — the absorb commit.
9
+ //
10
+ // Covered here:
11
+ // 1. three entries compose into one merged entry; sources are archived, never deleted;
12
+ // 2. the model's target is only provenance — the computed medoid becomes
13
+ // representativeId when it differs;
14
+ // 3. pending_review / superseded members are demoted, never absorbed;
15
+ // 4. coverage below the auto band lands as a pendingAbsorb proposal (index only);
16
+ // 5. the same plan twice is idempotent through absorbOpId;
17
+ // 6. a concurrent source change holds a pending clone, does not land the merge
18
+ // active, and best-effort forgets the committed vector row;
19
+ // 7. links pointing into the cluster are rewired to the merged id and the
20
+ // pre-absorption state is captured in absorbSnapshot.
21
+ // 8. composeAbsorbContent unit behaviour: abstractive vs extractive, sentence
22
+ // dedup, MMR truncation marks dropped key sentences.
23
+ //
24
+ // Same hermetic pattern as tests/curator-commit.test.mjs: './memu.js' is
25
+ // replaced before production modules load; everything else is the real module
26
+ // stack against a temp DSH_MEMU_HOME. Default settings keep the vector route
27
+ // lexical except where a test activates a managed generation explicitly.
28
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-absorb-'))
29
+ process.env.DSH_MEMU_HOME = home
30
+ process.env.DSH_MEMU_MEMORY_HOME = home
31
+
32
+ const backend = {
33
+ commits: [],
34
+ forgets: [],
35
+ onCommit: null,
36
+ }
37
+ globalThis.__curatorAbsorbTest = {
38
+ async commit(args) {
39
+ const names = (args.payload?.recall_files || []).map((file) => file.name)
40
+ backend.commits.push({ names, route: args.route, db: args.db })
41
+ if (backend.onCommit) await backend.onCommit()
42
+ return { ok: true }
43
+ },
44
+ async forget(args) {
45
+ backend.forgets.push({ name: args.name, db: args.db })
46
+ return { deleted: 1, segments: 1 }
47
+ },
48
+ async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
49
+ async listFiles() { return { recall_files: [], resources: [], raw: null } },
50
+ }
51
+
52
+ const memuSource = 'const bridge = globalThis.__curatorAbsorbTest;'
53
+ + 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
54
+ + 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
55
+ const hooks = registerHooks({
56
+ resolve(specifier, context, nextResolve) {
57
+ if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
58
+ return nextResolve(specifier, context)
59
+ },
60
+ })
61
+
62
+ const curator = await import('../lib/curator.js')
63
+ const state = await import('../lib/state.js')
64
+ const activity = await import('../lib/activity-log.js')
65
+ const vm = await import('../lib/vector-migration.js')
66
+ const vp = await import('../lib/vector-profile.js')
67
+ const catalog = await import('../lib/embedding-catalog.js')
68
+
69
+ const BGE = 'bge-small-zh-v1.5-int8'
70
+ const baseEntry = catalog.getEmbeddingModel(BGE)
71
+ const altEntry = JSON.parse(JSON.stringify(baseEntry))
72
+ altEntry.id = 'bge-small-zh-v1.5-int8-alt'
73
+ altEntry.displayName = 'BGE alternative revision (curator absorb fixture)'
74
+ altEntry.sources.modelscope.revision = 'c'.repeat(40)
75
+ altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
76
+ const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
77
+ vp.setCatalogEntryResolver((id) => models[id] ?? null)
78
+
79
+ state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
80
+
81
+ test.after(async () => {
82
+ try { hooks.unregister?.() } catch {}
83
+ vp.resetCatalogEntryResolver()
84
+ try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
85
+ })
86
+
87
+ function seedGlobalIndex(entries = {}) {
88
+ state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
89
+ }
90
+
91
+ async function activateVectorGeneration() {
92
+ const dbs = new Map()
93
+ const rowsOf = (db) => {
94
+ if (!dbs.has(db)) dbs.set(db, new Map())
95
+ return dbs.get(db)
96
+ }
97
+ const migrator = vm.createVectorMigrator({
98
+ async listLibraries() { return [] },
99
+ loadIndex(libraryId) {
100
+ return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
101
+ },
102
+ async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
103
+ async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
104
+ async listFiles({ db }) { return [...rowsOf(db).keys()] },
105
+ async smoke() { return { ok: true, checks: ['dimension=512'] } },
106
+ })
107
+ const switched = await migrator.switchProfile({ profile: BGE })
108
+ assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
109
+ return { migrator, generation: switched.generation }
110
+ }
111
+
112
+ const CORE = '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'
113
+ const X = '全量发布必须在监控确认通过之后才能执行。'
114
+ const Y = '灰度发布按服务分组逐步放量,异常时立即回滚。'
115
+ const AT = '2026-01-01T00:00:00.000Z'
116
+
117
+ function baseEntryOf(id, title, content, extra = {}) {
118
+ return {
119
+ track: 'memory', domain: 'general', title, content,
120
+ confidence: 0.9, status: 'active',
121
+ createdAt: AT, updatedAt: AT,
122
+ evidence: [{ sessionId: 's0', quote: '部署窗口只在周四' }],
123
+ ...extra,
124
+ }
125
+ }
126
+
127
+ const A = 'gmem-absorb-a'
128
+ const B = 'gmem-absorb-b'
129
+ const C = 'gmem-absorb-c'
130
+
131
+ function absorbEvidence(ids) {
132
+ return ids.map((id, index) => ({ session_id: `s-absorb-${index}`, quote: `来源 ${id} 的落地引用内容`, source_id: id }))
133
+ }
134
+
135
+ // —— composeAbsorbContent 单元行为(spec §4.7)——
136
+
137
+ test('composeAbsorbContent: a well-covering proposed draft becomes abstractive, a poor one falls back to extractive', () => {
138
+ const target = { title: 't', content: CORE + X }
139
+ const source = { title: 's', content: CORE + Y }
140
+ const good = CORE + X + Y
141
+ const composed = curator.composeAbsorbContent({ target, sources: [source], proposed: good })
142
+ assert.equal(composed.mode, 'abstractive')
143
+ assert.equal(composed.content, good)
144
+ assert.ok(composed.coverage >= 0.75)
145
+
146
+ const poor = curator.composeAbsorbContent({ target, sources: [source], proposed: '完全无关的一句话,跟部署窗口没有关系。' })
147
+ assert.equal(poor.mode, 'extractive')
148
+ // extractive union keeps every unique sentence, target first.
149
+ assert.ok(poor.content.includes(CORE))
150
+ assert.ok(poor.content.includes(X))
151
+ assert.ok(poor.content.includes(Y))
152
+ assert.equal(poor.truncated, false)
153
+ })
154
+
155
+ test('composeAbsorbContent: duplicate sentences are dropped and MMR truncation marks dropped key sentences', () => {
156
+ const target = { title: 't', content: CORE + ' ' + X }
157
+ const source = { title: 's', content: CORE + ' ' + Y + ' ' + '补充说明一。补充说明二。补充说明三。补充说明四。补充说明五。' }
158
+ // maxChars forces MMR; the tiny budget cannot keep the target's second
159
+ // sentence, which is a key sentence (all target sentences are key).
160
+ const composed = curator.composeAbsorbContent({ target, sources: [source], maxChars: CORE.length + 2 })
161
+ assert.equal(composed.mode, 'extractive')
162
+ assert.ok(composed.content.length <= CORE.length + 2)
163
+ assert.equal(composed.truncated, true)
164
+ assert.ok(composed.omitted.length > 0, 'dropped key sentences must be reported')
165
+ })
166
+
167
+ // —— commitCuratorPlans:3 条合成 1 条 ——
168
+
169
+ test('an absorb plan merges three entries into one; sources are archived with bodies kept, never deleted', async () => {
170
+ seedGlobalIndex({
171
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
172
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y, { confidence: 0.85 }),
173
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。', { confidence: 0.8 }),
174
+ })
175
+ const output = {
176
+ memories: [{
177
+ op: 'absorb', target_id: A, source_ids: [B, C],
178
+ title: '部署窗口(模型给的标题,应被 target 标题压过)',
179
+ content: '',
180
+ confidence: 0.9,
181
+ evidence: absorbEvidence([A, B, C]),
182
+ }],
183
+ }
184
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
185
+ assert.equal(result.absorbed, 1)
186
+ assert.equal(result.pending, 0)
187
+ assert.equal(result.skipped.length, 0)
188
+
189
+ const index = state.loadGlobalIndex()
190
+ const mergedId = Object.keys(index.entries).find((id) => index.entries[id]?.absorbedFrom)
191
+ assert.ok(mergedId, 'the merged entry must be in the index')
192
+ const merged = index.entries[mergedId]
193
+ assert.equal(merged.status, 'active')
194
+ assert.deepEqual(merged.absorbedFrom, [A, B, C].sort(), 'absorbedFrom is sorted target+sources')
195
+ assert.match(merged.absorbOpId, /^[0-9a-f]{16}$/)
196
+ assert.ok(merged.representativeId, 'the merged entry records the computed medoid')
197
+ assert.equal(merged.title, '部署窗口约定', 'title comes from the target first')
198
+ assert.equal(merged.mergeMode, 'extractive')
199
+ assert.ok(merged.sourceCoverage >= 0.45 && merged.unionCoverage >= 0.60, 'clean coverage passes the auto band')
200
+ // 内容是句级 union:target 的句子在前,来源句子去重并入,重复的 CORE 只出现一次。
201
+ assert.ok(merged.content.includes(X))
202
+ assert.ok(merged.content.includes(Y))
203
+ assert.equal(merged.content.split(CORE.replace(/。$/, '')).length - 1, 1, 'the shared core sentence appears once')
204
+ // 证据带 sourceId,覆盖每条 source。
205
+ const evidenceSources = new Set(merged.evidence.map((item) => item.sourceId))
206
+ for (const id of [A, B, C]) assert.ok(evidenceSources.has(id), `evidence must cover ${id}`)
207
+ // absorbSnapshot 记录吸收前状态。
208
+ assert.equal(merged.absorbSnapshot.target.id, A)
209
+ assert.deepEqual(merged.absorbSnapshot.sources.map((s) => s.id), [B, C].sort())
210
+ assert.ok(merged.absorbSnapshot.at)
211
+
212
+ // target superseded;sources archived——正文保留,绝不删除。
213
+ assert.equal(index.entries[A].status, 'superseded')
214
+ assert.equal(index.entries[A].absorbedInto, mergedId)
215
+ assert.equal(index.entries[A].supersededBy, mergedId)
216
+ assert.ok(index.entries[A].validTo)
217
+ for (const id of [B, C]) {
218
+ assert.equal(index.entries[id].status, 'archived', `${id} is archived, not deleted`)
219
+ assert.equal(index.entries[id].absorbedBy, mergedId)
220
+ assert.ok(index.entries[id].absorbedAt)
221
+ assert.ok(index.entries[id].content.includes(CORE), 'the archived source keeps its body')
222
+ }
223
+ // 词法路由下不提交向量;pending 之外没有多余条目。
224
+ assert.equal(backend.commits.length, 0)
225
+ })
226
+
227
+ // —— medoid 纠正 ——
228
+
229
+ test('when the model target is not the medoid, representativeId records the medoid instead', async () => {
230
+ // B is the bare core; A and C each append one long, mutually dissimilar
231
+ // sentence. B sits between them, so B is the medoid even though the model
232
+ // named A as target.
233
+ seedGlobalIndex({
234
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + ' ' + '需求评审通过之后才允许进入发布队列,发布队列按优先级排序处理。'),
235
+ [B]: baseEntryOf(B, '部署窗口核心', CORE),
236
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + ' ' + '窗口内的变更必须双人复核并且记录回滚预案到值班日志。'),
237
+ })
238
+ const output = {
239
+ memories: [{
240
+ op: 'absorb', target_id: A, source_ids: [B, C],
241
+ confidence: 0.9,
242
+ evidence: absorbEvidence([A, B, C]),
243
+ }],
244
+ }
245
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
246
+ assert.equal(result.absorbed, 1)
247
+ const index = state.loadGlobalIndex()
248
+ const merged = Object.values(index.entries).find((entry) => entry.absorbedFrom)
249
+ assert.equal(merged.representativeId, B, 'the medoid becomes representativeId')
250
+ assert.notEqual(merged.representativeId, A)
251
+ assert.equal(merged.absorbSnapshot.target.id, A, 'the model target stays as provenance')
252
+ })
253
+
254
+ // —— pending/superseded 成员降级 ——
255
+
256
+ test('pending and superseded members are demoted out of the absorb set, never absorbed', async () => {
257
+ const P = 'gmem-absorb-pending'
258
+ const S = 'gmem-absorb-superseded'
259
+ seedGlobalIndex({
260
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
261
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
262
+ [P]: baseEntryOf(P, '待审条目', CORE + '待审的说法。', { status: 'pending_review' }),
263
+ [S]: baseEntryOf(S, '被取代条目', CORE + '旧的说法。', { status: 'superseded' }),
264
+ })
265
+ const output = {
266
+ memories: [{
267
+ op: 'absorb', target_id: A, source_ids: [P, S, B],
268
+ confidence: 0.9,
269
+ evidence: [...absorbEvidence([A, B]), { session_id: 's-x', quote: '无效来源的引用', source_id: P }],
270
+ }],
271
+ }
272
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
273
+ assert.equal(result.absorbed, 1)
274
+ const demotedIds = result.demoted.map((item) => item.sourceId)
275
+ assert.deepEqual(demotedIds.sort(), [P, S].sort())
276
+ const index = state.loadGlobalIndex()
277
+ const merged = Object.values(index.entries).find((entry) => entry.absorbedFrom)
278
+ assert.deepEqual(merged.absorbedFrom, [A, B].sort(), 'only active members are absorbed')
279
+ // 降级的成员状态原样。
280
+ assert.equal(index.entries[P].status, 'pending_review')
281
+ assert.equal(index.entries[S].status, 'superseded')
282
+ })
283
+
284
+ // —— 覆盖率不足 pending + 幂等 ——
285
+
286
+ test('coverage below the auto band lands as a pendingAbsorb proposal, and the same plan again is idempotent', async () => {
287
+ const T2 = 'gmem-absorb-t2'
288
+ const SAME = 'gmem-absorb-same'
289
+ const LOW = 'gmem-absorb-low'
290
+ // SAME 与 target 同文外加一句独有补充(句级去重后补充句保留,覆盖率 1.0);LOW 的
291
+ // 单句包含 target 的句子,整句被去重丢掉,覆盖率落到 skip 段之上、auto 段之下。
292
+ // 两者合成后:sourceCoverage = min(1.0, ~0.28) ≥ 0.25 不过 skip 门;unionCoverage
293
+ // ~0.5 落在 0.45–0.60 的 pending 段。三条同题——medoid 用 title+content 评估,
294
+ // 异题会把包含关系的相似度打到 0。
295
+ seedGlobalIndex({
296
+ [T2]: baseEntryOf(T2, '发布审批约定', '上线前必须完成发布审批。'),
297
+ [SAME]: baseEntryOf(SAME, '发布审批约定', '上线前必须完成发布审批。审批单需要写明影响范围与回滚预案。'),
298
+ [LOW]: baseEntryOf(LOW, '发布审批约定', '上线前必须完成发布审批,审批通过后还要在值班表登记变更单号并通知全部值班同学确认。'),
299
+ })
300
+ const output = {
301
+ memories: [{
302
+ op: 'absorb', target_id: T2, source_ids: [SAME, LOW],
303
+ confidence: 0.9,
304
+ evidence: absorbEvidence([T2, SAME, LOW]),
305
+ }],
306
+ }
307
+ const first = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
308
+ assert.equal(first.pending, 1, `expected a coverage pending, got ${JSON.stringify(first)}`)
309
+ assert.equal(first.absorbed, 0)
310
+
311
+ const index = state.loadGlobalIndex()
312
+ const proposalEntry = Object.entries(index.entries).find(([, entry]) => entry.pendingAbsorb)
313
+ assert.ok(proposalEntry, 'the pending absorb proposal is in the index')
314
+ const [proposalId, proposal] = proposalEntry
315
+ assert.equal(proposal.status, 'pending_review')
316
+ assert.equal(proposal.reviewReason, 'absorb coverage')
317
+ assert.equal(proposal.conflictingWith, T2)
318
+ const pa = proposal.pendingAbsorb
319
+ assert.equal(pa.targetId, T2)
320
+ assert.deepEqual([...pa.sourceIds].sort(), [SAME, LOW].sort())
321
+ assert.equal(pa.representativeId, T2)
322
+ assert.equal(pa.expectedUpdatedAt[T2], AT)
323
+ assert.equal(pa.expectedUpdatedAt[LOW], AT)
324
+ assert.ok(pa.opId)
325
+
326
+ // 同一提案再来一次:opId 已在(pendingAbsorb 里),幂等 skip,不再生成第二条。
327
+ const second = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
328
+ assert.equal(second.pending, 0)
329
+ assert.ok(second.skipped.some((item) => /duplicate absorb opId/.test(item.reason)))
330
+ const indexAfter = state.loadGlobalIndex()
331
+ assert.equal(Object.keys(indexAfter.entries).length, Object.keys(index.entries).length, 'no new entries on the idempotent run')
332
+ assert.equal(indexAfter.entries[proposalId].status, 'pending_review')
333
+ })
334
+
335
+ // —— medoid 相似度过低整簇跳过 ——
336
+
337
+ test('a cluster whose medoid average is below the pair floor is skipped entirely', async () => {
338
+ seedGlobalIndex({
339
+ [A]: baseEntryOf(A, '部署窗口约定', '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。'),
340
+ [B]: baseEntryOf(B, '完全另一件事', '用户偏好深色主题的代码高亮配色。'),
341
+ [C]: baseEntryOf(C, '又是另一件事', '会议纪要需要在当天归档到共享目录。'),
342
+ })
343
+ const output = {
344
+ memories: [{
345
+ op: 'absorb', target_id: A, source_ids: [B, C],
346
+ confidence: 0.9,
347
+ evidence: absorbEvidence([A, B, C]),
348
+ }],
349
+ }
350
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
351
+ assert.equal(result.absorbed, 0)
352
+ assert.equal(result.pending, 0)
353
+ assert.ok(result.skipped.some((item) => /below the pair floor/.test(item.reason)))
354
+ const index = state.loadGlobalIndex()
355
+ assert.equal(Object.keys(index.entries).length, 3, 'nothing was written')
356
+ })
357
+
358
+ // —— links rewire + snapshot ——
359
+
360
+ test('inbound links are rewired to the merged id, outbound links merge in, and the snapshot keeps pre-absorb state', async () => {
361
+ const D = 'gmem-absorb-d'
362
+ const E = 'gmem-absorb-e'
363
+ const linkToA = { id: 'lnk-in-1', opId: 'op1', to: A, kind: 'related', note: '这条边指向 target,吸收后要改指合并稿。', confidence: 0.7, evidence: [], createdAt: AT }
364
+ const linkFromA = { id: 'lnk-out-1', opId: 'op2', to: E, kind: 'refines', note: 'target 指向簇外的边要并入合并稿。', confidence: 0.7, evidence: [], createdAt: AT }
365
+ seedGlobalIndex({
366
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X, { links: [linkFromA] }),
367
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
368
+ [C]: baseEntryOf(C, '部署窗口约束', CORE + '窗口外禁止任何生产变更。'),
369
+ [D]: baseEntryOf(D, '簇外条目', '另一个话题的内容,与吸收无关。', { links: [linkToA] }),
370
+ [E]: baseEntryOf(E, '簇外条目2', '被 target 指向的条目。'),
371
+ })
372
+ const output = {
373
+ memories: [{
374
+ op: 'absorb', target_id: A, source_ids: [B, C],
375
+ confidence: 0.9,
376
+ evidence: absorbEvidence([A, B, C]),
377
+ }],
378
+ }
379
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
380
+ assert.equal(result.absorbed, 1)
381
+ const index = state.loadGlobalIndex()
382
+ const mergedId = Object.keys(index.entries).find((id) => index.entries[id]?.absorbedFrom)
383
+ const merged = index.entries[mergedId]
384
+ // 入边改指 mergedId。
385
+ assert.equal(index.entries[D].links[0].to, mergedId)
386
+ assert.equal(index.entries[D].links[0].id, 'lnk-in-1', 'the edge keeps its id; only the endpoint moves')
387
+ // 出边并入 merged(去重、cap 20)。
388
+ assert.equal(merged.links.length, 1)
389
+ assert.equal(merged.links[0].to, E)
390
+ assert.equal(merged.links[0].kind, 'refines')
391
+ // 快照保留吸收前状态。
392
+ assert.deepEqual(merged.absorbSnapshot.rewired, [{ id: D, links: [linkToA] }])
393
+ assert.deepEqual(merged.absorbSnapshot.mergedLinks, [{ ...linkFromA, to: E }])
394
+ assert.equal(merged.absorbSnapshot.target.links.length, 1)
395
+ })
396
+
397
+ // —— 并发 source 变化 → held clone ——
398
+ test('a concurrent source change holds a pending clone, never lands the merge, and forgets the committed row', async () => {
399
+ seedGlobalIndex({
400
+ [A]: baseEntryOf(A, '部署窗口约定', CORE + X),
401
+ [B]: baseEntryOf(B, '部署窗口细节', CORE + Y),
402
+ })
403
+ await activateVectorGeneration()
404
+
405
+ // The concurrent writer lands while the curator sits between its index read
406
+ // and its index write; the vector commit is the async seam in between.
407
+ const changedAt = '2026-02-02T00:00:00.000Z'
408
+ backend.onCommit = async () => {
409
+ state.updateGlobalIndex((fresh) => {
410
+ fresh.entries[B] = { ...fresh.entries[B], content: '来源内容被并发修改。', updatedAt: changedAt }
411
+ })
412
+ }
413
+ const output = {
414
+ memories: [{
415
+ op: 'absorb', target_id: A, source_ids: [B],
416
+ confidence: 0.9,
417
+ evidence: absorbEvidence([A, B]),
418
+ }],
419
+ }
420
+ const result = await curator.commitCuratorPlans(structuredClone(output), { job: { step: '' } })
421
+ backend.onCommit = null
422
+
423
+ assert.equal(result.held, 1)
424
+ assert.equal(result.absorbed, 0)
425
+ const index = state.loadGlobalIndex()
426
+ const held = Object.values(index.entries).find((entry) => entry.pendingAbsorb)
427
+ assert.ok(held, 'the held pending clone must be in the index')
428
+ assert.equal(held.status, 'pending_review')
429
+ assert.equal(held.reviewReason, 'cluster changed while curator was running')
430
+ assert.equal(held.conflictingWith, A)
431
+ // expectedUpdatedAt 刷新为当前值,accept 时再做快照校验。
432
+ assert.equal(held.pendingAbsorb.expectedUpdatedAt[B], changedAt)
433
+ // 已提交向量库的 mergedId 行被 best-effort forget。
434
+ assert.deepEqual(backend.forgets.map((call) => call.name), backend.commits.at(-1).names)
435
+ // 成员原样:target 仍 active,被改过的 source 仍 active。
436
+ assert.equal(index.entries[A].status, 'active')
437
+ assert.equal(index.entries[B].status, 'active')
438
+ assert.equal(Object.values(index.entries).filter((entry) => entry.absorbedFrom && entry.status === 'active').length, 0,
439
+ 'no active merged entry landed')
440
+ // 结果进活动日志。
441
+ const event = activity.listEvents({ kind: 'curate' }).find((item) => item.detail?.op === 'absorb')
442
+ assert.ok(event, 'the absorb run is recorded')
443
+ })
@@ -0,0 +1,173 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import {
6
+ buildReport,
7
+ confusionMatrix,
8
+ evaluateGates,
9
+ loadFixture,
10
+ MARK,
11
+ scorePairs,
12
+ sweepAt,
13
+ sweepThresholds,
14
+ } from '../scripts/curator-pair-calibrate.mjs'
15
+
16
+ // Calibration harness test (spec §8.1 校准层):fixture 结构、评分确定性、指标数学。
17
+ // The §4.13 gates themselves are a report, not a CI gate (§8.2) — they are
18
+ // asserted here only as data plumbing, never as a pass/fail of the algorithm.
19
+
20
+ const FIXTURE = path.resolve('tests/fixtures/curator-pairs/labeled.v1.jsonl')
21
+
22
+ test('fixture: 90 pairs in three equal bands with unique pair and entry ids', () => {
23
+ const pairs = loadFixture(FIXTURE)
24
+ assert.equal(pairs.length, 90)
25
+ for (const label of ['same-fact', 'related', 'none']) {
26
+ assert.equal(pairs.filter((p) => p.label === label).length, 30, label)
27
+ }
28
+ const pairIds = new Set()
29
+ const entryIds = new Set()
30
+ for (const pair of pairs) {
31
+ assert.ok(!pairIds.has(pair.id), `duplicate pair id ${pair.id}`)
32
+ pairIds.add(pair.id)
33
+ for (const side of ['a', 'b']) {
34
+ const entry = pair[side]
35
+ assert.ok(entry.id, `${pair.id}.${side} carries an id`)
36
+ assert.ok(!entryIds.has(entry.id), `duplicate entry id ${entry.id}`)
37
+ entryIds.add(entry.id)
38
+ assert.ok(String(entry.title || '').length > 0, `${entry.id} has a title`)
39
+ assert.ok(String(entry.content || '').length >= 8, `${entry.id} has content`)
40
+ assert.ok(['memory', 'skill'].includes(entry.track), `${entry.id} has a valid track`)
41
+ assert.ok(Array.isArray(entry.appliesTo), `${entry.id} has an appliesTo array`)
42
+ }
43
+ }
44
+ })
45
+
46
+ test('fixture: covers zh/en/mixed plus length-gap, negation, dates and alias tags', () => {
47
+ const pairs = loadFixture(FIXTURE)
48
+ const tags = new Set(pairs.flatMap((p) => p.tags || []))
49
+ for (const tag of ['length-gap', 'negation-same', 'negation-differ', 'dates-same', 'dates-differ', 'alias', 'punctuation']) {
50
+ assert.ok(tags.has(tag), `missing coverage tag: ${tag}`)
51
+ }
52
+ const langs = new Set(pairs.map((p) => p.lang))
53
+ for (const lang of ['zh', 'en', 'mixed']) assert.ok(langs.has(lang), `missing language coverage: ${lang}`)
54
+ // every declared tag is one of the known ones (typo guard)
55
+ const known = new Set(['paraphrase', 'length-gap', 'containment', 'negation-same', 'negation-differ',
56
+ 'dates-same', 'dates-differ', 'alias', 'alias-related', 'punctuation', 'same-prefix', 'same-topic',
57
+ 'time-shift', 'different-topic', 'different-domain', 'cross-track', 'length-gap-none', 'skill'])
58
+ for (const tag of tags) assert.ok(known.has(tag), `unknown tag: ${tag}`)
59
+ // the length-gap tags must be honest: real gap >0.5 on the raw text pair
60
+ for (const pair of pairs.filter((p) => (p.tags || []).includes('length-gap'))) {
61
+ const aLen = String(pair.a.title + pair.a.content).length
62
+ const bLen = String(pair.b.title + pair.b.content).length
63
+ assert.ok(Math.abs(aLen - bLen) / Math.max(aLen, bLen) > 0.5, `${pair.id} claims length-gap but the gap is small`)
64
+ }
65
+ })
66
+
67
+ test('fixture header marks the data as a synthetic draft, never a gold set', () => {
68
+ assert.equal(MARK, 'synthetic-draft-not-gold')
69
+ const report = buildReport(loadFixture(FIXTURE))
70
+ assert.equal(report.mark, MARK)
71
+ assert.match(report.note, /synthetic draft/i)
72
+ assert.match(report.note, /NOT a production gold set/i)
73
+ })
74
+
75
+ test('scoring is deterministic: two passes agree exactly', () => {
76
+ const pairs = loadFixture(FIXTURE)
77
+ assert.deepEqual(scorePairs(pairs), scorePairs(pairs))
78
+ const report1 = buildReport(pairs)
79
+ const report2 = buildReport(pairs)
80
+ assert.deepEqual(report1.pairs, report2.pairs)
81
+ assert.deepEqual(report1.confusion, report2.confusion)
82
+ assert.deepEqual(report1.candidateRecall, report2.candidateRecall)
83
+ })
84
+
85
+ test('sweepAt: precision/recall/F1 arithmetic on a hand-checked population', () => {
86
+ const results = [
87
+ { label: 'same-fact', score: 0.9 },
88
+ { label: 'same-fact', score: 0.7 },
89
+ { label: 'related', score: 0.6 },
90
+ { label: 'none', score: 0.75 },
91
+ { label: 'none', score: 0.2 },
92
+ ]
93
+ const at082 = sweepAt(results, ['same-fact'], 0.82)
94
+ assert.deepEqual(
95
+ { tp: at082.tp, fp: at082.fp, fn: at082.fn },
96
+ { tp: 1, fp: 0, fn: 1 },
97
+ )
98
+ assert.equal(at082.precision, 1)
99
+ assert.equal(at082.recall, 0.5)
100
+ assert.equal(at082.f1, 0.6667)
101
+ const at055 = sweepAt(results, ['same-fact', 'related'], 0.55)
102
+ assert.deepEqual({ tp: at055.tp, fp: at055.fp, fn: at055.fn }, { tp: 3, fp: 1, fn: 0 })
103
+ assert.equal(at055.precision, 0.75)
104
+ assert.equal(at055.recall, 1)
105
+ // empty predictions → null precision, not NaN
106
+ const empty = sweepAt(results, ['same-fact'], 1.5)
107
+ assert.equal(empty.precision, null)
108
+ assert.equal(empty.recall, 0)
109
+ })
110
+
111
+ test('sweepThresholds walks 0.50→0.95 inclusive with 0.01 steps', () => {
112
+ const rows = sweepThresholds([{ label: 'none', score: 0.6 }], ['same-fact'], {})
113
+ assert.equal(rows.length, 46)
114
+ assert.equal(rows[0].threshold, 0.5)
115
+ assert.equal(rows.at(-1).threshold, 0.95)
116
+ })
117
+
118
+ test('confusionMatrix: label × decision counts sum to the population', () => {
119
+ const results = [
120
+ { label: 'same-fact', decision: 'duplicate' },
121
+ { label: 'same-fact', decision: 'absorb' },
122
+ { label: 'related', decision: 'link' },
123
+ { label: 'none', decision: 'none' },
124
+ ]
125
+ const { matrix } = confusionMatrix(results)
126
+ assert.equal(matrix['same-fact'].duplicate, 1)
127
+ assert.equal(matrix['same-fact'].absorb, 1)
128
+ assert.equal(matrix.related.link, 1)
129
+ assert.equal(matrix.none.none, 1)
130
+ const total = Object.values(matrix).reduce((sum, row) => sum + Object.values(row).reduce((a, b) => a + b, 0), 0)
131
+ assert.equal(total, 4)
132
+ })
133
+
134
+ test('evaluateGates: the four §4.13 thresholds flip pass/fail at their bounds', () => {
135
+ const pass = evaluateGates({
136
+ absorbAt082: { precision: 0.96 },
137
+ linkAt055: { precision: 0.9 },
138
+ noneAt055: { rate: 0.05 },
139
+ recall20: { recall: 0.97 },
140
+ })
141
+ assert.ok(pass.every((gate) => gate.pass))
142
+ const fail = evaluateGates({
143
+ absorbAt082: { precision: 0.94 },
144
+ linkAt055: { precision: 0.84 },
145
+ noneAt055: { rate: 0.11 },
146
+ recall20: { recall: 0.94 },
147
+ })
148
+ assert.ok(fail.every((gate) => !gate.pass))
149
+ // null precision (empty population) never counts as a pass
150
+ const degenerate = evaluateGates({
151
+ absorbAt082: { precision: null },
152
+ linkAt055: { precision: null },
153
+ noneAt055: { rate: null },
154
+ recall20: { recall: null },
155
+ })
156
+ assert.ok(degenerate.every((gate) => !gate.pass))
157
+ })
158
+
159
+ test('candidateRecall: every same-fact partner is reachable through rankPairCandidates', () => {
160
+ const report = buildReport(loadFixture(FIXTURE))
161
+ assert.equal(report.candidateRecall.total, 30)
162
+ assert.equal(report.candidateRecall.limit, 20)
163
+ // data plumbing only — the gate value is reported in the calibration run
164
+ assert.ok(report.candidateRecall.recall >= 0 && report.candidateRecall.recall <= 1)
165
+ })
166
+
167
+ test('fixture file ends with a newline and parses as strict JSONL', () => {
168
+ const raw = fs.readFileSync(FIXTURE, 'utf8')
169
+ assert.match(raw, /\n$/, 'trailing newline (CRLF conversion friendly)')
170
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim())
171
+ assert.equal(lines.length, 90)
172
+ for (const line of lines) JSON.parse(line)
173
+ })