dsh-memu-memory 1.2.1 → 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 (87) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +12 -5
  3. package/README.zh.md +4 -2
  4. package/docs/contributing.md +16 -3
  5. package/docs/host-api.md +3 -1
  6. package/docs/memory-semantics.md +21 -2
  7. package/docs/troubleshooting.md +42 -4
  8. package/lib/backup.js +24 -2
  9. package/lib/binding-diagnostics.js +2 -2
  10. package/lib/client.js +1361 -146
  11. package/lib/consolidation-worker.js +30 -8
  12. package/lib/curator-evidence.js +131 -0
  13. package/lib/curator-policy.js +81 -0
  14. package/lib/curator.js +1252 -91
  15. package/lib/cycle.js +3 -0
  16. package/lib/embedding-download.js +39 -4
  17. package/lib/engine.js +6 -3
  18. package/lib/global-graph.js +282 -0
  19. package/lib/graph.js +51 -9
  20. package/lib/inbound.js +19 -0
  21. package/lib/inject.js +277 -17
  22. package/lib/lexicon.js +1115 -0
  23. package/lib/local-vector-service.js +8 -6
  24. package/lib/managed-runtime.js +33 -2
  25. package/lib/memory.js +282 -17
  26. package/lib/memu.js +2 -2
  27. package/lib/migrate.js +83 -2
  28. package/lib/private-links.js +587 -0
  29. package/lib/private.js +32 -3
  30. package/lib/prompts/global-curator.md +76 -19
  31. package/lib/prompts/private-consolidation.coding.md +4 -1
  32. package/lib/prompts/private-consolidation.md +3 -1
  33. package/lib/prompts/private-consolidation.mixed.md +3 -1
  34. package/lib/prompts/private-consolidation.rp.md +3 -1
  35. package/lib/prompts.js +1 -1
  36. package/lib/rpc.js +577 -9
  37. package/lib/state.js +4 -1
  38. package/lib/text-retrieval.js +661 -0
  39. package/lib/text-similarity.js +36 -18
  40. package/lib/tools.js +168 -10
  41. package/lib/webui/standalone.css +17 -0
  42. package/lib/webui/standalone.js +2 -0
  43. package/package.json +1 -1
  44. package/scripts/acceptance-local.mjs +8 -5
  45. package/scripts/curator-pair-calibrate.mjs +259 -0
  46. package/scripts/lexicon-calibrate.mjs +230 -0
  47. package/scripts/lexicon-dedupe.mjs +318 -0
  48. package/tests/absorb-review.test.mjs +353 -0
  49. package/tests/appendix-b2.test.mjs +2 -2
  50. package/tests/client-export.test.mjs +520 -0
  51. package/tests/client-render.test.mjs +19 -3
  52. package/tests/consolidation-fork.test.mjs +14 -12
  53. package/tests/consolidation-snapshot.test.mjs +39 -0
  54. package/tests/curator-absorb.test.mjs +443 -0
  55. package/tests/curator-calibrate.test.mjs +173 -0
  56. package/tests/curator-clusters.test.mjs +281 -0
  57. package/tests/curator-commit.test.mjs +52 -5
  58. package/tests/curator-evidence.test.mjs +102 -0
  59. package/tests/curator-link.test.mjs +316 -0
  60. package/tests/curator-pairing.test.mjs +252 -0
  61. package/tests/curator-probe.test.mjs +15 -6
  62. package/tests/curator-review-mode.test.mjs +314 -0
  63. package/tests/disclosure.test.mjs +2 -1
  64. package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
  65. package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
  66. package/tests/fixtures/lexicon-stub.mjs +284 -0
  67. package/tests/graph-cascade.test.mjs +35 -2
  68. package/tests/graph.test.mjs +96 -0
  69. package/tests/help-page.test.mjs +415 -0
  70. package/tests/inbound.test.mjs +6 -3
  71. package/tests/inject-guide.test.mjs +28 -0
  72. package/tests/inject.test.mjs +157 -5
  73. package/tests/lexicon-calibrate.test.mjs +48 -0
  74. package/tests/lexicon-dedupe.test.mjs +171 -0
  75. package/tests/lexicon-frequency.test.mjs +179 -0
  76. package/tests/lexicon-inject.test.mjs +410 -0
  77. package/tests/lexicon-perf.test.mjs +436 -0
  78. package/tests/lexicon-ui.test.mjs +547 -0
  79. package/tests/lexicon.test.mjs +922 -0
  80. package/tests/managed-runtime.test.mjs +32 -0
  81. package/tests/private-links.test.mjs +445 -0
  82. package/tests/prompts.test.mjs +35 -0
  83. package/tests/recall-dedup.test.mjs +33 -4
  84. package/tests/tool-schema.test.mjs +1 -1
  85. package/tests/tools.test.mjs +112 -3
  86. package/tests/webui-auth.test.mjs +348 -2
  87. package/tests/webui.test.mjs +188 -0
package/lib/state.js CHANGED
@@ -101,6 +101,9 @@ export const DEFAULT_SETTINGS = {
101
101
  activeMemoryStarvationMinutes: 15,
102
102
  activeMemoryCooldownMinutes: 0,
103
103
  activeMemoryRecallCooldownMinutes: 15,
104
+ // 全局提炼的审核放行(§4.10):'auto' 只暂扣冲突、缺证据与高风险改写;
105
+ // 'review-all' 把全部内容操作送进复核。硬门在两档下都生效。
106
+ curatorReviewMode: 'auto',
104
107
  autoCurateEveryHours: 0,
105
108
  autoCurateSessionLimit: 8,
106
109
  // 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
@@ -353,7 +356,7 @@ export function updateSettings(patch) {
353
356
  activeMemoryMinScore: [0, 10], activeMemoryStarvationMinutes: [1, 1440],
354
357
  activeMemoryCooldownMinutes: [0, 10080], activeMemoryRecallCooldownMinutes: [0, 10080],
355
358
  }
356
- const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
359
+ const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'], curatorReviewMode: ['auto', 'review-all'] }
357
360
  for (const [key, value] of Object.entries(patch || {})) {
358
361
  if (!Object.hasOwn(DEFAULT_SETTINGS, key)) continue
359
362
  if (enums[key]) {
@@ -0,0 +1,661 @@
1
+ // Local candidate retrieval for the global curator (spec §4.1-§4.4, §4.8).
2
+ //
3
+ // Pipeline position: the LLM only arbitrates; everything here is model-free.
4
+ // tokenize + BM25 give the ranking spine, character-Dice/token/entity features
5
+ // give the pair score, weighted RRF fuses the three rankings, and the greedy
6
+ // all-pairs clique turns edges into candidate clusters. The commit layer
7
+ // (curator.js planCuratorOps) reuses rankPairCandidates as the final
8
+ // duplicate/conflict gate — one ruler from blocking to commit.
9
+ //
10
+ // Dependency discipline: this module imports only lib/text-similarity.js (the
11
+ // shared similarity ruler) and lib/curator-policy.js (the single constant
12
+ // table). No state, no IO, no clock — same input, same output, always.
13
+
14
+ import {
15
+ bigrams,
16
+ dice,
17
+ lengthGap,
18
+ similarityDetail,
19
+ similarTexts,
20
+ } from './text-similarity.js'
21
+ import {
22
+ BM25_B,
23
+ BM25_K1,
24
+ BM25_MAX_QUERY_TERMS,
25
+ BLOCK_DF_RATIO,
26
+ CLUSTER_BLOCK_MIN_SHARED,
27
+ CLUSTER_EXACT_PAIR_LIMIT,
28
+ CLUSTER_MAX,
29
+ CLUSTER_MAX_MEMBERS,
30
+ CLUSTER_MIN_PAIR,
31
+ CLUSTER_RECENT_LIMIT,
32
+ CROSS_DOMAIN_LINK_MIN,
33
+ PAIR_ABSORB_MIN,
34
+ PAIR_DUPLICATE_MIN,
35
+ PAIR_LINK_MIN,
36
+ PAIR_SCAN_LIMIT,
37
+ PAIR_W_ENTITY,
38
+ PAIR_W_LEXICAL,
39
+ PAIR_W_TITLE,
40
+ PAIR_W_TOKEN,
41
+ RRF_K,
42
+ RRF_W_BM25,
43
+ RRF_W_DICE,
44
+ RRF_W_ENTITY,
45
+ } from './curator-policy.js'
46
+
47
+ // —— §4.1 分词 ——
48
+
49
+ /**
50
+ * Verbatim §4.1: NFKC + lowercase; latin runs of ≥2 chars keep `._-` joins as
51
+ * one token; CJK runs become single chars when isolated, otherwise overlapping
52
+ * bigrams; the result is deduplicated (a set — Jaccard/blocking only care
53
+ * about presence). Build tf from tokenStream below, never from this.
54
+ */
55
+ export function tokenize(text) {
56
+ const s = String(text || '').normalize('NFKC').toLowerCase()
57
+ const out = []
58
+ for (const m of s.matchAll(/[a-z0-9]+(?:[._-][a-z0-9]+)*/g)) {
59
+ if (m[0].length >= 2) out.push(m[0])
60
+ }
61
+ for (const m of s.matchAll(/[\u3400-\u9fff\uf900-\ufaff]+/g)) {
62
+ const run = m[0]
63
+ if (run.length === 1) out.push(run)
64
+ else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
65
+ }
66
+ return [...new Set(out)]
67
+ }
68
+
69
+ /** Raw token stream (no dedupe) — the frequency source for BM25's tf. */
70
+ function tokenStream(text) {
71
+ const s = String(text || '').normalize('NFKC').toLowerCase()
72
+ const out = []
73
+ for (const m of s.matchAll(/[a-z0-9]+(?:[._-][a-z0-9]+)*/g)) {
74
+ if (m[0].length >= 2) out.push(m[0])
75
+ }
76
+ for (const m of s.matchAll(/[\u3400-\u9fff\uf900-\ufaff]+/g)) {
77
+ const run = m[0]
78
+ if (run.length === 1) out.push(run)
79
+ else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
80
+ }
81
+ return out
82
+ }
83
+
84
+ // —— §4.1 语料与 BM25 ——
85
+
86
+ /**
87
+ * entries: [{ id, title, content, ...rest }] — every extra field travels on
88
+ * doc.entry so callers can reach track/domain/appliesTo/updatedAt without a
89
+ * second lookup. Returns { docs, df, inverted, avgdl, size }.
90
+ */
91
+ export function buildCorpus(entries) {
92
+ const docs = new Map()
93
+ const df = new Map()
94
+ const inverted = new Map()
95
+ let totalLen = 0
96
+ for (const entry of entries || []) {
97
+ const id = entry?.id
98
+ if (id == null || docs.has(id)) continue
99
+ const stream = tokenStream(`${entry.title || ''} ${entry.content || ''}`)
100
+ const tf = new Map()
101
+ for (const term of stream) tf.set(term, (tf.get(term) || 0) + 1)
102
+ docs.set(id, { id, tokens: new Set(stream), tf, len: stream.length, entry })
103
+ totalLen += stream.length
104
+ for (const term of tf.keys()) {
105
+ df.set(term, (df.get(term) || 0) + 1)
106
+ if (!inverted.has(term)) inverted.set(term, new Set())
107
+ inverted.get(term).add(id)
108
+ }
109
+ }
110
+ return {
111
+ docs,
112
+ df,
113
+ inverted,
114
+ avgdl: docs.size ? totalLen / docs.size : 0,
115
+ size: docs.size,
116
+ }
117
+ }
118
+
119
+ function idfOf(corpus, term) {
120
+ const df = corpus.df.get(term) || 0
121
+ return Math.log(1 + (corpus.size - df + 0.5) / (df + 0.5))
122
+ }
123
+
124
+ /**
125
+ * Verbatim §4.1 formula. Queries wider than BM25_MAX_QUERY_TERMS keep only the
126
+ * 64 highest-IDF terms — measured among terms the corpus actually knows
127
+ * (df > 0): an absent term scores 0 no matter its nominal IDF, so letting it
128
+ * occupy the quota would only shrink the effective query. Ties break by term.
129
+ */
130
+ export function bm25(queryTokens, doc, corpus, { k1 = BM25_K1, b = BM25_B } = {}) {
131
+ if (!queryTokens?.length || !doc || !corpus || corpus.size === 0) return 0
132
+ let terms = [...new Set(queryTokens)].filter((term) => corpus.df.has(term))
133
+ if (terms.length === 0) return 0
134
+ if (terms.length > BM25_MAX_QUERY_TERMS) {
135
+ terms = terms
136
+ .map((term) => ({ term, idf: idfOf(corpus, term) }))
137
+ .sort((a, b2) => b2.idf - a.idf || (a.term < b2.term ? -1 : 1))
138
+ .slice(0, BM25_MAX_QUERY_TERMS)
139
+ .map((item) => item.term)
140
+ }
141
+ let score = 0
142
+ for (const term of terms) {
143
+ const tf = doc.tf.get(term) || 0
144
+ if (!tf) continue
145
+ const idf = idfOf(corpus, term)
146
+ score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc.len / corpus.avgdl))
147
+ }
148
+ return score
149
+ }
150
+
151
+ // —— §4.2 成对特征 ——
152
+
153
+ function jaccard(a, b) {
154
+ if (a.size === 0 || b.size === 0) return 0
155
+ let shared = 0
156
+ for (const item of a) if (b.has(item)) shared += 1
157
+ return shared / (a.size + b.size - shared)
158
+ }
159
+
160
+ function entitySet(appliesTo) {
161
+ const out = new Set()
162
+ for (const item of Array.isArray(appliesTo) ? appliesTo : []) {
163
+ const v = String(item || '').trim().toLowerCase()
164
+ if (v) out.add(v)
165
+ }
166
+ return out
167
+ }
168
+
169
+ function normalizedTitle(entry) {
170
+ return String(entry?.title || '').trim().toLowerCase()
171
+ }
172
+
173
+ /**
174
+ * §4.2 pair features over two index-shaped entries
175
+ * ({ title, content, track, domain, appliesTo }).
176
+ * charDice — similarTexts(title+\n+content):length-prune + containment floor
177
+ * containment — the probe floor when the length prune fired, else 0
178
+ * lexical — max(charDice, containment)
179
+ * tokenJaccard — tokenize() set Jaccard over title+content
180
+ * titleDice — bigram Dice over the raw titles
181
+ * titleExact — trimmed/lowercase title equality (non-empty)
182
+ * entityJaccard — appliesTo set Jaccard; both empty → 0.5 neutral
183
+ */
184
+ export function pairFeatures(a, b) {
185
+ const aText = `${a?.title || ''}\n${a?.content || ''}`
186
+ const bText = `${b?.title || ''}\n${b?.content || ''}`
187
+ const detail = similarityDetail(aText, bText)
188
+ return {
189
+ charDice: detail.dice,
190
+ containment: detail.containment,
191
+ lexical: Math.max(detail.dice, detail.containment),
192
+ tokenJaccard: jaccard(new Set(tokenize(aText)), new Set(tokenize(bText))),
193
+ titleDice: dice(bigrams(a?.title || ''), bigrams(b?.title || '')),
194
+ titleExact: normalizedTitle(a) !== '' && normalizedTitle(a) === normalizedTitle(b),
195
+ entityJaccard: entityScore(a?.appliesTo, b?.appliesTo),
196
+ lengthGap: detail.lengthGap,
197
+ }
198
+ }
199
+
200
+ function entityScore(aApplies, bApplies) {
201
+ const a = entitySet(aApplies)
202
+ const b = entitySet(bApplies)
203
+ if (a.size === 0 && b.size === 0) return 0.5
204
+ return jaccard(a, b)
205
+ }
206
+
207
+ /**
208
+ * §4.2 weighted sum. Accepts a features object (preferred — pairFeatures is
209
+ * already computed on hot paths) or the two entries directly.
210
+ */
211
+ export function pairScore(aOrFeatures, b) {
212
+ const f = aOrFeatures && typeof aOrFeatures === 'object' && 'lexical' in aOrFeatures
213
+ ? aOrFeatures
214
+ : pairFeatures(aOrFeatures, b)
215
+ return PAIR_W_LEXICAL * f.lexical
216
+ + PAIR_W_TOKEN * f.tokenJaccard
217
+ + PAIR_W_TITLE * f.titleDice
218
+ + PAIR_W_ENTITY * f.entityJaccard
219
+ }
220
+
221
+ function isRpCodingClash(aDomain, bDomain) {
222
+ return (aDomain === 'rp' && bDomain === 'coding') || (aDomain === 'coding' && bDomain === 'rp')
223
+ }
224
+
225
+ /**
226
+ * §4.2 pairDecision, verbatim bands:
227
+ * track differs → none
228
+ * domain differs → link only at pairScore ≥0.65(跨域只 link 不 absorb;
229
+ * rp↔coding 永不 link——§4.9 的校验层再拦一次)
230
+ * titleExact && lexical <0.55 → conflict(同标题正文迥异;lexical 而非裸 charDice,
231
+ * 因为 §4.8 明确 containment ≥0.92 是重复/细化不是冲突)
232
+ * ≥0.90 duplicate / ≥0.82 absorb / ≥0.55 link / else none
233
+ */
234
+ export function pairDecision(a, b, features = pairFeatures(a, b)) {
235
+ if ((a?.track || 'memory') !== (b?.track || 'memory')) return 'none'
236
+ const aDomain = a?.domain || 'unknown'
237
+ const bDomain = b?.domain || 'unknown'
238
+ const score = pairScore(features)
239
+ if (aDomain !== bDomain) {
240
+ if (isRpCodingClash(aDomain, bDomain)) return 'none'
241
+ return score >= CROSS_DOMAIN_LINK_MIN ? 'link' : 'none'
242
+ }
243
+ if (features.titleExact && features.lexical < PAIR_LINK_MIN) return 'conflict'
244
+ if (score >= PAIR_DUPLICATE_MIN) return 'duplicate'
245
+ if (score >= PAIR_ABSORB_MIN) return 'absorb'
246
+ if (score >= PAIR_LINK_MIN) return 'link'
247
+ return 'none'
248
+ }
249
+
250
+ // —— §4.8 全局冲突预筛 ——
251
+
252
+ // Same pattern as memory.js's NEGATION_RE (not exported there; lib/memory.js is
253
+ // outside this package's edit scope). One ruler for "opposite claim".
254
+ const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
255
+
256
+ /** True when the text carries a negation marker (memory.js findConflict 口径). */
257
+ export function negationPolarity(text) {
258
+ return NEGATION_RE.test(String(text || ''))
259
+ }
260
+
261
+ /** 数字、周X/星期X、YYYY-MM-DD、M月D日 —— 抽出来比集合(§4.8 numericSetsDiffer)。 */
262
+ export function numericSet(text) {
263
+ const s = String(text || '')
264
+ const out = new Set()
265
+ for (const m of s.matchAll(/\d{4}-\d{1,2}-\d{1,2}/g)) out.add(m[0])
266
+ for (const m of s.matchAll(/\d{1,2}月\d{1,2}[日号]/g)) out.add(m[0])
267
+ for (const m of s.matchAll(/周[一二三四五六日天]|星期[一二三四五六日天]/g)) out.add(m[0])
268
+ for (const m of s.matchAll(/\d+(?:\.\d+)?/g)) out.add(m[0])
269
+ return out
270
+ }
271
+
272
+ export function numericSetsDiffer(a, b) {
273
+ const sa = numericSet(a)
274
+ const sb = numericSet(b)
275
+ if (sa.size === 0 || sb.size === 0) return false
276
+ for (const item of sa) if (!sb.has(item)) return true
277
+ for (const item of sb) if (!sa.has(item)) return true
278
+ return false
279
+ }
280
+
281
+ /**
282
+ * §4.8 全局冲突预筛(同 domain/track 前提由调用方保证)。返回 'same_title' |
283
+ * 'negation' | 'value' 或 null。containment ≥0.92 或 charDice ≥0.95 一票豁免
284
+ * (重复/细化,不是冲突)。预筛只决定“送不送人审”,最终由 LLM + 人审裁决。
285
+ */
286
+ export function globalConflictCheck(a, b, features = pairFeatures(a, b)) {
287
+ if (features.containment >= 0.92 || features.charDice >= 0.95) return null
288
+ if (features.titleExact && features.charDice < 0.95) return 'same_title'
289
+ const aBody = String(a?.content || '')
290
+ const bBody = String(b?.content || '')
291
+ if (features.charDice >= 0.55 && negationPolarity(aBody) !== negationPolarity(bBody)) return 'negation'
292
+ if (features.titleDice >= 0.8 && features.charDice >= 0.55
293
+ && numericSetsDiffer(`${a?.title || ''} ${aBody}`, `${b?.title || ''} ${bBody}`)) return 'value'
294
+ return null
295
+ }
296
+
297
+ // —— §4.3 候选召回 ——
298
+
299
+ function trackOf(entry) {
300
+ return entry?.track || 'memory'
301
+ }
302
+
303
+ function updatedDesc(a, b) {
304
+ return String(b?.updatedAt || '').localeCompare(String(a?.updatedAt || ''))
305
+ }
306
+
307
+ function idAsc(a, b) {
308
+ return String(a.id).localeCompare(String(b.id))
309
+ }
310
+
311
+ /**
312
+ * §4.3 rankPairCandidates({ corpus, proposal, limit })。
313
+ *
314
+ * 1) blocking:同 track;N ≤ exactPairLimit 时精确两两,否则查询 token 中
315
+ * df ≤ 30%N 的稀有词、共享 ≥2 token,或 exact title。
316
+ * 2) 三路排名 bm25 / charDice / entityJaccard(tiebreak containment)。
317
+ * 3) 加权 RRF(k=60):0.5/(60+rankBm25) + 0.3/(60+rankDice) + 0.2/(60+rankEntity)。
318
+ * 4) 取前 limit 算完整 pairFeatures/pairScore/pairDecision,按 pairScore 降序。
319
+ * 5) 不足 limit 用同域最近条目补齐(“多看几眼”,不降低门槛)。
320
+ *
321
+ * 返回 [{ id, entry, score, decision, features, rrf, fallback }],score 降序,
322
+ * 平手 rrf 降序 → id 升序——完全确定。
323
+ */
324
+ export function rankPairCandidates({ corpus, proposal, limit = PAIR_SCAN_LIMIT }) {
325
+ const max = Number(limit) > 0 ? Math.floor(Number(limit)) : PAIR_SCAN_LIMIT
326
+ const out = []
327
+ if (!corpus || corpus.size === 0 || !proposal) return out
328
+ const n = corpus.size
329
+ const proposalTrack = trackOf(proposal)
330
+ const proposalDomain = proposal?.domain || 'unknown'
331
+ const proposalTitle = normalizedTitle(proposal)
332
+ const proposalId = proposal?.id ?? null
333
+ const queryTokens = tokenize(`${proposal.title || ''} ${proposal.content || ''}`)
334
+
335
+ // 1) blocking
336
+ const candidates = new Map()
337
+ if (n <= CLUSTER_EXACT_PAIR_LIMIT) {
338
+ for (const doc of corpus.docs.values()) {
339
+ if (proposalId != null && doc.id === proposalId) continue
340
+ if (trackOf(doc.entry) !== proposalTrack) continue
341
+ candidates.set(doc.id, 0)
342
+ }
343
+ } else {
344
+ const dfCap = Math.ceil(BLOCK_DF_RATIO * n)
345
+ const shared = new Map()
346
+ for (const term of queryTokens) {
347
+ const posting = corpus.inverted.get(term)
348
+ if (!posting || posting.size === 0 || posting.size > dfCap) continue
349
+ for (const id of posting) {
350
+ if (proposalId != null && id === proposalId) continue
351
+ shared.set(id, (shared.get(id) || 0) + 1)
352
+ }
353
+ }
354
+ for (const [id, count] of shared) {
355
+ if (count >= CLUSTER_BLOCK_MIN_SHARED) candidates.set(id, count)
356
+ }
357
+ if (proposalTitle) {
358
+ for (const doc of corpus.docs.values()) {
359
+ if (proposalId != null && doc.id === proposalId) continue
360
+ if (trackOf(doc.entry) !== proposalTrack) continue
361
+ if (normalizedTitle(doc.entry) === proposalTitle) candidates.set(doc.id, Math.max(candidates.get(doc.id) || 0, CLUSTER_BLOCK_MIN_SHARED))
362
+ }
363
+ }
364
+ }
365
+ if (candidates.size === 0) return backfill({ corpus, proposal, proposalDomain, proposalId, max, out: [], taken: new Set(), queryTokens })
366
+
367
+ // 2) three rankings
368
+ const ids = [...candidates.keys()]
369
+ const bm25Scores = ids.map((id) => ({ id, s: bm25(queryTokens, corpus.docs.get(id), corpus) }))
370
+ bm25Scores.sort((a, b) => b.s - a.s || idAsc(a, b))
371
+ const rankBm25 = new Map()
372
+ bm25Scores.forEach((item, index) => rankBm25.set(item.id, index + 1))
373
+
374
+ const probes = ids.map((id) => {
375
+ const doc = corpus.docs.get(id)
376
+ const detail = similarityDetail(`${proposal.title || ''}\n${proposal.content || ''}`, `${doc.entry?.title || ''}\n${doc.entry?.content || ''}`)
377
+ return { id, diceScore: detail.dice, containment: detail.containment, lexical: Math.max(detail.dice, detail.containment) }
378
+ })
379
+ const diceRankOrder = [...probes].sort((a, b) => b.diceScore - a.diceScore || b.containment - a.containment || idAsc(a, b))
380
+ const rankDice = new Map()
381
+ diceRankOrder.forEach((item, index) => rankDice.set(item.id, index + 1))
382
+
383
+ const entityOrder = ids.map((id) => {
384
+ const doc = corpus.docs.get(id)
385
+ return {
386
+ id,
387
+ entity: entityScore(proposal.appliesTo, doc.entry?.appliesTo),
388
+ containment: containmentOf(proposal, doc.entry),
389
+ }
390
+ }).sort((a, b) => b.entity - a.entity || b.containment - a.containment || idAsc(a, b))
391
+ const rankEntity = new Map()
392
+ entityOrder.forEach((item, index) => rankEntity.set(item.id, index + 1))
393
+
394
+ // 3) weighted RRF
395
+ const rrf = new Map()
396
+ for (const id of ids) {
397
+ rrf.set(id, RRF_W_BM25 / (RRF_K + rankBm25.get(id))
398
+ + RRF_W_DICE / (RRF_K + rankDice.get(id))
399
+ + RRF_W_ENTITY / (RRF_K + rankEntity.get(id)))
400
+ }
401
+ ids.sort((a, b) => rrf.get(b) - rrf.get(a) || idAsc(a, b))
402
+
403
+ // 4) features for the top slice only
404
+ const taken = new Set()
405
+ for (const id of ids.slice(0, max)) {
406
+ const doc = corpus.docs.get(id)
407
+ const features = pairFeatures(proposal, doc.entry)
408
+ const score = pairScore(features)
409
+ taken.add(id)
410
+ out.push({ id, entry: doc.entry, score, decision: pairDecision(proposal, doc.entry, features), features, rrf: rrf.get(id), fallback: false })
411
+ }
412
+ out.sort((a, b) => b.score - a.score || b.rrf - a.rrf || idAsc(a, b))
413
+ // 5) same-domain recent backfill — extra eyes, never a lower bar
414
+ return backfill({ corpus, proposal, proposalDomain, proposalId, max, out, taken, queryTokens })
415
+ }
416
+
417
+ function containmentOf(a, b) {
418
+ const detail = similarityDetail(`${a?.title || ''}\n${a?.content || ''}`, `${b?.title || ''}\n${b?.content || ''}`)
419
+ return detail.containment
420
+ }
421
+
422
+ function backfill({ corpus, proposal, proposalDomain, proposalId, max, out, taken, queryTokens }) {
423
+ if (out.length >= max) return out
424
+ const recent = [...corpus.docs.values()]
425
+ .filter((doc) => proposalId == null || doc.id !== proposalId)
426
+ .filter((doc) => (doc.entry?.domain || 'unknown') === proposalDomain)
427
+ .filter((doc) => !taken.has(doc.id))
428
+ .sort((a, b) => updatedDesc(a.entry, b.entry) || idAsc(a, b))
429
+ for (const doc of recent) {
430
+ if (out.length >= max) break
431
+ const features = pairFeatures(proposal, doc.entry)
432
+ const score = pairScore(features)
433
+ taken.add(doc.id)
434
+ out.push({ id: doc.id, entry: doc.entry, score, decision: pairDecision(proposal, doc.entry, features), features, rrf: 0, fallback: true })
435
+ }
436
+ out.sort((a, b) => b.score - a.score || b.rrf - a.rrf || idAsc(a, b))
437
+ return out
438
+ }
439
+
440
+ // —— §4.4 候选簇 ——
441
+
442
+ function pairKey(aId, bId) {
443
+ return aId < bId ? `${aId}\u0000${bId}` : `${bId}\u0000${aId}`
444
+ }
445
+
446
+ function round2(value) {
447
+ return Math.round(value * 100) / 100
448
+ }
449
+
450
+ /**
451
+ * §4.4 buildCandidateClusters(index, options)。
452
+ *
453
+ * 1) active 条目按 updatedAt 降序取最近 recentLimit(2000) 条,按 track 分组。
454
+ * 2) 同 track 内:N ≤ exactPairLimit(300) 全对;否则倒排 blocking(共享
455
+ * ≥ blockMinShared(2) token)。
456
+ * 3) 候选对算 pairFeatures/pairScore,保留 ≥ minPairScore(0.55) 的边。
457
+ * 4) 边排序用三路 RRF(同 §4.3:bm25 双向和 / charDice / entityJaccard)。
458
+ * 5) 贪心成簇:边按 RRF 降序;种子=边两端;新成员必须与簇内**所有**成员
459
+ * pairScore ≥ 0.55(全对约束,禁传递闭包);每簇 ≤6;每条目只进一个簇。
460
+ * 6) medoid = 成员两两平均分最高者(平手 updatedAt 降序 → id 升序)。
461
+ * 7) suggested_op:≥3 成员且 avg ≥0.82 且同 domain → absorb;2+ 成员且
462
+ * avg ≥0.55 → link;否则 none。medoid 平均 <0.55 整簇丢弃。
463
+ * 8) 按 avg_score 降序最多输出 maxClusters(12) 簇。
464
+ *
465
+ * 返回 { clusters, truncated, scanned }。
466
+ */
467
+ export function buildCandidateClusters(index, options = {}) {
468
+ const maxClusters = options.maxClusters ?? CLUSTER_MAX
469
+ const maxMembers = options.maxMembers ?? CLUSTER_MAX_MEMBERS
470
+ const minPair = options.minPairScore ?? CLUSTER_MIN_PAIR
471
+ const blockMinShared = options.blockMinShared ?? CLUSTER_BLOCK_MIN_SHARED
472
+ const exactPairLimit = options.exactPairLimit ?? CLUSTER_EXACT_PAIR_LIMIT
473
+ const recentLimit = options.recentLimit ?? CLUSTER_RECENT_LIMIT
474
+
475
+ const active = Object.entries(index?.entries || index || {})
476
+ .filter(([, e]) => e && e.status === 'active')
477
+ .map(([id, entry]) => ({ id, entry }))
478
+ .sort((a, b) => updatedDesc(a.entry, b.entry) || idAsc(a, b))
479
+ const truncated = active.length > recentLimit
480
+ const pool = active.slice(0, recentLimit)
481
+
482
+ const byTrack = new Map()
483
+ for (const item of pool) {
484
+ const track = trackOf(item.entry)
485
+ if (!byTrack.has(track)) byTrack.set(track, [])
486
+ byTrack.get(track).push(item)
487
+ }
488
+
489
+ // edges: { a, b, aId, bId, features, score, rrf }
490
+ const edges = []
491
+ for (const [track, items] of byTrack) {
492
+ const n = items.length
493
+ if (n < 2) continue
494
+ const tokenSets = new Map()
495
+ const inverted = new Map()
496
+ for (const item of items) {
497
+ const tokens = tokenize(`${item.entry?.title || ''} ${item.entry?.content || ''}`)
498
+ tokenSets.set(item.id, new Set(tokens))
499
+ for (const term of tokens) {
500
+ if (!inverted.has(term)) inverted.set(term, [])
501
+ inverted.get(term).push(item.id)
502
+ }
503
+ }
504
+ const byId = new Map(items.map((item) => [item.id, item]))
505
+ const pairSet = new Set()
506
+ if (n <= exactPairLimit) {
507
+ for (let i = 0; i < n; i++) {
508
+ for (let j = i + 1; j < n; j++) pairSet.add(pairKey(items[i].id, items[j].id))
509
+ }
510
+ } else {
511
+ const shared = new Map()
512
+ for (const posting of inverted.values()) {
513
+ if (posting.length < 2) continue
514
+ for (let i = 0; i < posting.length; i++) {
515
+ for (let j = i + 1; j < posting.length; j++) {
516
+ const key = pairKey(posting[i], posting[j])
517
+ shared.set(key, (shared.get(key) || 0) + 1)
518
+ }
519
+ }
520
+ }
521
+ for (const [key, count] of shared) {
522
+ if (count >= blockMinShared) pairSet.add(key)
523
+ }
524
+ }
525
+ if (pairSet.size === 0) continue
526
+
527
+ const corpus = buildCorpus(items.map((item) => ({ id: item.id, title: item.entry?.title || '', content: item.entry?.content || '', entry: item.entry })))
528
+ const pairList = [...pairSet].map((key) => {
529
+ const [aId, bId] = key.split('\u0000')
530
+ const aItem = byId.get(aId)
531
+ const bItem = byId.get(bId)
532
+ const features = pairFeatures(aItem.entry, bItem.entry)
533
+ const score = pairScore(features)
534
+ return { key, aId, bId, aItem, bItem, features, score }
535
+ }).filter((pair) => pair.score >= minPair)
536
+ if (pairList.length === 0) continue
537
+
538
+ // three route rankings for edge order only
539
+ const bm25Route = pairList.map((pair) => ({
540
+ key: pair.key,
541
+ s: bm25(tokenStream(`${pair.aItem.entry?.title || ''} ${pair.aItem.entry?.content || ''}`), corpus.docs.get(pair.bId), corpus)
542
+ + bm25(tokenStream(`${pair.bItem.entry?.title || ''} ${pair.bItem.entry?.content || ''}`), corpus.docs.get(pair.aId), corpus),
543
+ })).sort((a, b) => b.s - a.s || (a.key < b.key ? -1 : 1))
544
+ const rankBm25 = new Map()
545
+ bm25Route.forEach((item, i) => rankBm25.set(item.key, i + 1))
546
+ const diceRoute = [...pairList].sort((a, b) => b.features.charDice - a.features.charDice
547
+ || b.features.containment - a.features.containment || (a.key < b.key ? -1 : 1))
548
+ const rankDice = new Map()
549
+ diceRoute.forEach((item, i) => rankDice.set(item.key, i + 1))
550
+ const entityRoute = [...pairList].sort((a, b) => b.features.entityJaccard - a.features.entityJaccard
551
+ || b.features.containment - a.features.containment || (a.key < b.key ? -1 : 1))
552
+ const rankEntity = new Map()
553
+ entityRoute.forEach((item, i) => rankEntity.set(item.key, i + 1))
554
+ for (const pair of pairList) {
555
+ pair.rrf = RRF_W_BM25 / (RRF_K + rankBm25.get(pair.key))
556
+ + RRF_W_DICE / (RRF_K + rankDice.get(pair.key))
557
+ + RRF_W_ENTITY / (RRF_K + rankEntity.get(pair.key))
558
+ }
559
+ pairList.sort((a, b) => b.rrf - a.rrf || b.score - a.score || (a.key < b.key ? -1 : 1))
560
+ edges.push(...pairList)
561
+ }
562
+
563
+ // greedy all-pairs clique growth
564
+ edges.sort((a, b) => b.rrf - a.rrf || b.score - a.score || (a.key < b.key ? -1 : 1))
565
+ const memberOf = new Map()
566
+ const clusters = []
567
+ for (const edge of edges) {
568
+ const clusterA = memberOf.get(edge.aId)
569
+ const clusterB = memberOf.get(edge.bId)
570
+ if (clusterA && clusterB) continue
571
+ if (!clusterA && !clusterB) {
572
+ if (clusters.length >= maxClusters) continue
573
+ const cluster = { members: [edge.aItem, edge.bItem], scores: new Map([[edge.key, edge.score]]) }
574
+ clusters.push(cluster)
575
+ memberOf.set(edge.aId, cluster)
576
+ memberOf.set(edge.bId, cluster)
577
+ continue
578
+ }
579
+ const cluster = clusterA || clusterB
580
+ if (cluster.members.length >= maxMembers) continue
581
+ const candidate = clusterA ? edge.bItem : edge.aItem
582
+ let ok = true
583
+ for (const member of cluster.members) {
584
+ const key = pairKey(candidate.id, member.id)
585
+ let score = cluster.scores.get(key)
586
+ if (score == null) {
587
+ score = pairScore(pairFeatures(candidate.entry, member.entry))
588
+ cluster.scores.set(key, score)
589
+ }
590
+ if (score < minPair) { ok = false; break }
591
+ }
592
+ if (!ok) continue
593
+ cluster.members.push(candidate)
594
+ memberOf.set(candidate.id, cluster)
595
+ }
596
+
597
+ // medoid + output
598
+ const out = []
599
+ for (const cluster of clusters) {
600
+ const members = [...cluster.members].sort((a, b) => idAsc(a, b))
601
+ const pairScores = {}
602
+ let sum = 0
603
+ let count = 0
604
+ for (let i = 0; i < members.length; i++) {
605
+ for (let j = i + 1; j < members.length; j++) {
606
+ const key = pairKey(members[i].id, members[j].id)
607
+ let score = cluster.scores.get(key)
608
+ if (score == null) {
609
+ score = pairScore(pairFeatures(members[i].entry, members[j].entry))
610
+ cluster.scores.set(key, score)
611
+ }
612
+ pairScores[`${members[i].id}|${members[j].id}`] = round2(score)
613
+ sum += score
614
+ count += 1
615
+ }
616
+ }
617
+ const avg = count ? sum / count : 0
618
+ let medoid = null
619
+ let bestAvg = -1
620
+ for (const member of members) {
621
+ let mSum = 0
622
+ for (const other of members) {
623
+ if (other.id === member.id) continue
624
+ const key = pairKey(member.id, other.id)
625
+ mSum += cluster.scores.get(key) ?? 0
626
+ }
627
+ const mAvg = members.length > 1 ? mSum / (members.length - 1) : 0
628
+ if (mAvg > bestAvg + 1e-9
629
+ || (Math.abs(mAvg - bestAvg) <= 1e-9 && medoid
630
+ && (updatedDesc(member.entry, medoid.entry) < 0
631
+ || (updatedDesc(member.entry, medoid.entry) === 0 && String(member.id).localeCompare(String(medoid.id)) < 0)))) {
632
+ bestAvg = mAvg
633
+ medoid = member
634
+ }
635
+ }
636
+ // medoid 平均 <0.55 → 整簇不输出(§4.4.9;全对约束下属防御分支)
637
+ if (members.length > 1 && bestAvg < minPair) continue
638
+ const sameDomain = members.every((m) => (m.entry?.domain || 'unknown') === (members[0].entry?.domain || 'unknown'))
639
+ const suggestedOp = members.length >= 3 && avg >= PAIR_ABSORB_MIN && sameDomain
640
+ ? 'absorb'
641
+ : (members.length >= 2 && avg >= minPair ? 'link' : 'none')
642
+ out.push({
643
+ cluster_id: '',
644
+ members: members.map((m) => ({
645
+ id: m.id,
646
+ title: m.entry?.title || '',
647
+ track: trackOf(m.entry),
648
+ domain: m.entry?.domain || 'unknown',
649
+ updatedAt: m.entry?.updatedAt || null,
650
+ content: m.entry?.content || '',
651
+ })),
652
+ pair_scores: pairScores,
653
+ medoid_id: medoid?.id || members[0]?.id || '',
654
+ avg_score: round2(avg),
655
+ suggested_op: suggestedOp,
656
+ })
657
+ }
658
+ out.sort((a, b) => b.avg_score - a.avg_score || String(a.medoid_id).localeCompare(String(b.medoid_id)))
659
+ for (let i = 0; i < out.length; i++) out[i].cluster_id = `cl-${i + 1}`
660
+ return { clusters: out.slice(0, maxClusters), truncated, scanned: pool.length }
661
+ }