@aalis/plugin-user-relation 0.4.1

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/dist/actions.d.ts +13 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +705 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/commands.d.ts +46 -0
  7. package/dist/commands.d.ts.map +1 -0
  8. package/dist/commands.js +601 -0
  9. package/dist/commands.js.map +1 -0
  10. package/dist/consolidate-llm.d.ts +150 -0
  11. package/dist/consolidate-llm.d.ts.map +1 -0
  12. package/dist/consolidate-llm.js +373 -0
  13. package/dist/consolidate-llm.js.map +1 -0
  14. package/dist/extractor.d.ts +308 -0
  15. package/dist/extractor.d.ts.map +1 -0
  16. package/dist/extractor.js +1356 -0
  17. package/dist/extractor.js.map +1 -0
  18. package/dist/index.d.ts +33 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +626 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/middleware.d.ts +41 -0
  23. package/dist/middleware.d.ts.map +1 -0
  24. package/dist/middleware.js +281 -0
  25. package/dist/middleware.js.map +1 -0
  26. package/dist/rename-watcher.d.ts +16 -0
  27. package/dist/rename-watcher.d.ts.map +1 -0
  28. package/dist/rename-watcher.js +16 -0
  29. package/dist/rename-watcher.js.map +1 -0
  30. package/dist/service.d.ts +1108 -0
  31. package/dist/service.d.ts.map +1 -0
  32. package/dist/service.js +4462 -0
  33. package/dist/service.js.map +1 -0
  34. package/dist/store.d.ts +89 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +207 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/tools.d.ts +48 -0
  39. package/dist/tools.d.ts.map +1 -0
  40. package/dist/tools.js +1630 -0
  41. package/dist/tools.js.map +1 -0
  42. package/dist/types.d.ts +432 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +94 -0
  45. package/dist/types.js.map +1 -0
  46. package/dist/utils.d.ts +419 -0
  47. package/dist/utils.d.ts.map +1 -0
  48. package/dist/utils.js +1630 -0
  49. package/dist/utils.js.map +1 -0
  50. package/package.json +57 -0
package/dist/utils.js ADDED
@@ -0,0 +1,1630 @@
1
+ const MAX_EVIDENCE_PER_ENTITY = 10; // 单实体保留的 evidence 上限,更早的会被裁掉
2
+ const MAX_ALIASES_PER_ENTITY = 20; // 单实体保留的 aliases 上限,防 prompt 注入塞爆
3
+ /**
4
+ * 合并实体别名:
5
+ * - 入参中空串/全空白/与 name 等价的项一律剔除(按 normalizeName 比较)
6
+ * - 与已有 alias 也按 normalizeName 去重(大小写、全半角、标点都标准化后比较,保留首次写法)
7
+ * - 总长度截断到 MAX_ALIASES_PER_ENTITY;超出部分**优先丢弃 incoming 里靠后的新加项**,
8
+ * 保留稳定的旧别名顺序,避免 LLM 反复输出新别名挤掉真有用的旧别名
9
+ *
10
+ * 若 incoming 为 undefined:返回 existing 的去重 + 截断版(顺手清掉历史脏数据)。
11
+ * 返回值长度为 0 时返回 undefined(避免落库一个空数组)。
12
+ */
13
+ export function mergeAliases(existing, incoming, primaryName) {
14
+ const out = [];
15
+ const seen = new Set();
16
+ const primaryKey = primaryName ? normalizeName(primaryName) : '';
17
+ const tryPush = (raw) => {
18
+ if (!raw)
19
+ return;
20
+ const trimmed = String(raw).trim();
21
+ if (!trimmed)
22
+ return;
23
+ const key = normalizeName(trimmed);
24
+ if (!key)
25
+ return;
26
+ if (primaryKey && key === primaryKey)
27
+ return; // 别名 == name 直接丢
28
+ if (seen.has(key))
29
+ return;
30
+ seen.add(key);
31
+ out.push(trimmed);
32
+ };
33
+ for (const a of existing ?? [])
34
+ tryPush(a);
35
+ for (const a of incoming ?? []) {
36
+ if (out.length >= MAX_ALIASES_PER_ENTITY)
37
+ break;
38
+ tryPush(a);
39
+ }
40
+ if (out.length === 0)
41
+ return undefined;
42
+ return out.slice(0, MAX_ALIASES_PER_ENTITY);
43
+ }
44
+ /**
45
+ * 人-事件角色优先级。同一 (person, event) 只保留最强角色的一条边:
46
+ * 发起者 > 参与者 > 被指向 > 转述者 > 旁观者。语义上强角色含盖弱角色。
47
+ */
48
+ export const PERSON_EVENT_ROLE_RANK = {
49
+ initiator: 5,
50
+ participant: 4,
51
+ target: 3,
52
+ reporter: 2,
53
+ witness: 1,
54
+ };
55
+ /**
56
+ * 人-实体角色优先级。同一 (person, entity) 只保留最强角色。
57
+ * 热爱 > 创作者 > 拥有者 > 批评者 > 参与者 > 访问者 > 仅提及。
58
+ */
59
+ export const PERSON_ENTITY_ROLE_RANK = {
60
+ enthusiast: 6,
61
+ creator: 5,
62
+ owner: 4,
63
+ critic: 3,
64
+ participant: 2,
65
+ visitor: 1,
66
+ mentioned: 0,
67
+ };
68
+ /**
69
+ * 各类边的"按 role / relationType 区分"的首次建边默认权重表(保守档)。
70
+ *
71
+ * 设计意图:
72
+ * - 边语义有强弱(initiator 比 witness 重,enthusiast 比 mentioned 重,
73
+ * is-alias-of 是强声明),第一条 evidence 进来时就该体现差异,
74
+ * 不要全部 0.5 一刀切。
75
+ * - 数值整体偏保守,给后续 `reinforceWeight(prev, 0.1)` 累积留出空间,
76
+ * 避免高初始权 + 频繁 reinforce 把上限榨干(误差被反复放大)。
77
+ * - **仅作用于"首次建边"**。`reinforce` / agent `correctEdge` 路径不变。
78
+ * - LLM / agent 仍可显式传 `input.weight` 覆盖默认值;表只在 `?? fallback` 处生效。
79
+ *
80
+ * 未列举的自创 relationType(LLM 可能造词)走 `default`。
81
+ *
82
+ * 当前仅作为 `roleDefaultWeight()` 的内部查表,不对外 export;
83
+ * 若将来需要让 agent 查"系统默认权是多少",再考虑改 export。
84
+ *
85
+ * ─────────────────────────────────────────────────────────────
86
+ * ⚠️ weight 字段的**双重语义**(**有意妥协**,非 bug):
87
+ *
88
+ * 最终 weight = 初始 role 默认权(语义强弱 / 亲密度档位)
89
+ * ⊕ reinforceWeight 累积(频次:被反复提及的次数)
90
+ *
91
+ * 这意味着:一条初始 `mentioned`(0.1) 的边被频繁提及后,
92
+ * weight 可能累积到 0.95,超过一条初始 `friend`(0.5) 但只被提一次的边。
93
+ * 此时按 weight 排序 ≠ 按"亲密度"排序,而是 ≈ "亲密度 × 关注度" 的混合。
94
+ *
95
+ * 为什么不拆?淘汰评分、强节点保护、相似度评分都需要的是这个混合量
96
+ * ("既亲又频"的关系才该保护,"虽亲但久不提"和"虽频但浅"都该降权),
97
+ * 单独的 baseStrength 字段没有独立消费方,YAGNI。
98
+ *
99
+ * 消费方注意:**weight ≠ 纯亲密度**。如果要"按关系亲密度"分类,
100
+ * 应该按 role/relationType(如 friend/cp/mentor)筛,**不要**按 weight 阈值切。
101
+ * ─────────────────────────────────────────────────────────────
102
+ */
103
+ const ROLE_DEFAULT_WEIGHT = {
104
+ /** person-event:发起 > 参与 ≈ 被指向 > 转述 > 旁观 */
105
+ personEvent: {
106
+ initiator: 0.5,
107
+ participant: 0.4,
108
+ target: 0.4,
109
+ reporter: 0.25,
110
+ witness: 0.15,
111
+ },
112
+ /** person-entity:热爱 > 创作 > 拥有 > 批评 > 参与 > 访问 > 仅提及 */
113
+ personEntity: {
114
+ enthusiast: 0.55,
115
+ creator: 0.5,
116
+ owner: 0.45,
117
+ critic: 0.4,
118
+ participant: 0.3,
119
+ visitor: 0.2,
120
+ mentioned: 0.1,
121
+ },
122
+ /** person-person:is-alias-of/alt-account-of 是强声明;friend/cp/mentor 是稳态关系 */
123
+ personPerson: {
124
+ 'is-alias-of': 0.7,
125
+ 'alt-account-of': 0.7,
126
+ friend: 0.5,
127
+ cp: 0.5,
128
+ mentor: 0.5,
129
+ colleague: 0.4,
130
+ rival: 0.4,
131
+ familiar: 0.35,
132
+ admirer: 0.35,
133
+ antagonist: 0.35,
134
+ default: 0.4,
135
+ },
136
+ /** event-event:part-of 是结构性,caused-by 是因果,其他弱 */
137
+ eventEvent: {
138
+ 'part-of': 0.5,
139
+ 'caused-by': 0.45,
140
+ default: 0.3,
141
+ },
142
+ /** event-entity:part-of 强 > about > related */
143
+ eventEntity: {
144
+ 'part-of': 0.5,
145
+ about: 0.35,
146
+ related: 0.25,
147
+ default: 0.3,
148
+ },
149
+ /** entity-entity:is-alias-of 强声明 > part-of 结构 > 其他 */
150
+ entityEntity: {
151
+ 'is-alias-of': 0.6,
152
+ 'part-of': 0.45,
153
+ default: 0.3,
154
+ },
155
+ };
156
+ export function roleDefaultWeight(kind, key) {
157
+ switch (kind) {
158
+ case 'person-event': {
159
+ const v = ROLE_DEFAULT_WEIGHT.personEvent[key];
160
+ return typeof v === 'number' ? v : 0.3;
161
+ }
162
+ case 'person-entity': {
163
+ const v = ROLE_DEFAULT_WEIGHT.personEntity[key];
164
+ return typeof v === 'number' ? v : 0.3;
165
+ }
166
+ case 'person-person': {
167
+ const t = ROLE_DEFAULT_WEIGHT.personPerson;
168
+ return t[key] ?? t.default;
169
+ }
170
+ case 'event-event': {
171
+ const t = ROLE_DEFAULT_WEIGHT.eventEvent;
172
+ return t[key] ?? t.default;
173
+ }
174
+ case 'event-entity': {
175
+ const t = ROLE_DEFAULT_WEIGHT.eventEntity;
176
+ return t[key] ?? t.default;
177
+ }
178
+ case 'entity-entity': {
179
+ const t = ROLE_DEFAULT_WEIGHT.entityEntity;
180
+ return t[key] ?? t.default;
181
+ }
182
+ default:
183
+ return 0.3;
184
+ }
185
+ }
186
+ /** 边邻接索引:供 BFS 复用,避免每次扫全表 */
187
+ export function buildAdjacency(edges) {
188
+ const peByPerson = new Map();
189
+ const ppByPerson = new Map();
190
+ const peByEvent = new Map();
191
+ const pentByPerson = new Map();
192
+ const pentByEntity = new Map();
193
+ const eeByEvent = new Map();
194
+ // event-entity 双向索引(事件节点 / 实体节点都可能作为 BFS 起点)
195
+ const eentByEvent = new Map();
196
+ const eentByEntity = new Map();
197
+ // entity-entity 索引:无向边两端均插入
198
+ const ententByEntity = new Map();
199
+ const push = (map, k, v) => {
200
+ const arr = map.get(k);
201
+ if (arr)
202
+ arr.push(v);
203
+ else
204
+ map.set(k, [v]);
205
+ };
206
+ for (const e of edges) {
207
+ if (e.kind === 'person-event') {
208
+ push(peByPerson, e.fromPersonId, e);
209
+ push(peByEvent, e.toEventId, e);
210
+ }
211
+ else if (e.kind === 'person-entity') {
212
+ push(pentByPerson, e.fromPersonId, e);
213
+ push(pentByEntity, e.toEntityId, e);
214
+ }
215
+ else if (e.kind === 'event-event') {
216
+ push(eeByEvent, e.fromEventId, e);
217
+ if (!e.directed)
218
+ push(eeByEvent, e.toEventId, e);
219
+ }
220
+ else if (e.kind === 'event-entity') {
221
+ push(eentByEvent, e.fromEventId, e);
222
+ push(eentByEntity, e.toEntityId, e);
223
+ }
224
+ else if (e.kind === 'entity-entity') {
225
+ push(ententByEntity, e.fromEntityId, e);
226
+ if (!e.directed)
227
+ push(ententByEntity, e.toEntityId, e);
228
+ }
229
+ else {
230
+ push(ppByPerson, e.fromPersonId, e);
231
+ if (!e.directed)
232
+ push(ppByPerson, e.toPersonId, e);
233
+ }
234
+ }
235
+ return {
236
+ peByPerson,
237
+ ppByPerson,
238
+ peByEvent,
239
+ pentByPerson,
240
+ pentByEntity,
241
+ eeByEvent,
242
+ eentByEvent,
243
+ eentByEntity,
244
+ ententByEntity,
245
+ };
246
+ }
247
+ // ----- 辅助函数 -----
248
+ /** 节点名称归一化:用于按名去重和别名匹配。
249
+ * 零风险规则(确定无歧义、不会错合):
250
+ * - NFKC 归一化:全角→半角、合字拆分(避免「ABC」与「ABC」不等)
251
+ * - trim + 压缩中间空白
252
+ * - 小写化(英文)
253
+ * - 去除外层装饰符号:中英文书名号《》「」『』【】〈〉、引号""''""''、括号()()[]、空格
254
+ * (这些只是"装饰",不改变指代——「《绝航》」与「绝航」是同一对象)
255
+ * - 内部空格也一并删除:中文为主的场景里 "吐槽 API 调用限流" 与 "吐槽API调用限流"
256
+ * 显然指同一事件;纯英文短语 "open ai" 与 "openai" 也合并到同一节点(影响有限)。
257
+ * 注意:不做后缀剥离(如「OL/手游/PC版」),那种语义合并交给 LLM verifyAliasPair。
258
+ */
259
+ export function normalizeName(name) {
260
+ return (name
261
+ .normalize('NFKC')
262
+ .toLowerCase()
263
+ .replace(/[《》「」『』【】〈〉()()[\]"'""''""''`]/g, '')
264
+ // 连接符 / 下划线 / 中点 视为「装饰」去除:
265
+ // 「三角洲-行动」/「三角洲_行动」/「三角洲·行动」 ≡ 「三角洲行动」
266
+ .replace(/[-_·]/g, '')
267
+ // 所有空白字符(含 trim/内部空格/全角空格 \u3000 等)一并删除
268
+ .replace(/\s+/g, ''));
269
+ }
270
+ /** 计算两字符串的最长公共前缀(按 UTF-16 code unit,对中文足够稳定)。 */
271
+ export function commonPrefix(a, b) {
272
+ const n = Math.min(a.length, b.length);
273
+ let i = 0;
274
+ while (i < n && a[i] === b[i])
275
+ i++;
276
+ return a.slice(0, i);
277
+ }
278
+ /** weight 累积:增量按 (1 - weight) * delta 收敛,避免无限增长 */
279
+ export function reinforceWeight(prev, delta) {
280
+ return prev + (1 - prev) * delta;
281
+ }
282
+ export function clamp01(x) {
283
+ if (!Number.isFinite(x))
284
+ return 0;
285
+ return Math.max(0, Math.min(1, x));
286
+ }
287
+ /**
288
+ * 计算"当前有效 weight":raw × max(0.5^(days/halfLife), floor)。
289
+ *
290
+ * - halfLifeDays<=0:直接返回 raw(向后兼容)
291
+ * - 使用 max(factor, floor):衰减不会无限趋近 0,保留长期关系底色
292
+ */
293
+ export function effectiveWeight(raw, lastReinforcedAt, now, cfg) {
294
+ const w = raw ?? 0;
295
+ if (cfg.halfLifeDays <= 0)
296
+ return w;
297
+ const days = Math.max(0, (now - lastReinforcedAt) / 86400000);
298
+ const factor = Math.max(cfg.floor, 0.5 ** (days / cfg.halfLifeDays));
299
+ return clamp01(w * factor);
300
+ }
301
+ /** 单实体保留最近 N 条 evidence(按 extractedAt DESC 截断 + 同 key 去重)
302
+ * 去重双键:
303
+ * 1) `sessionId|sorted(messageIds).join(',')` — 精确判同:同会话同批 messageIds 视为重复。
304
+ * 2) 内容合并:同 sessionId + 同 quote 且 messageIds **存在交集** → 合并为一条
305
+ * (messageIds 取并集,extractedAt 取较新者)。这覆盖「滑动窗口对同句重复抽取」场景,
306
+ * 但不会把完全不相交的同句条目(可能是不同时段不同人重复说)错误合并。
307
+ */
308
+ export function trimEvidence(list) {
309
+ // 1) 按 sessionId|quote 分桶,桶内按 messageIds 是否相交进行合并
310
+ const buckets = new Map(); // key = sessionId|q:quote
311
+ const passthrough = []; // 无 quote → 不参与内容合并,仅走 evidenceKey 兜底
312
+ for (const e of list) {
313
+ const qk = quoteKey(e);
314
+ if (!qk) {
315
+ passthrough.push(e);
316
+ continue;
317
+ }
318
+ const arr = buckets.get(qk);
319
+ if (!arr) {
320
+ buckets.set(qk, [{ ...e, messageIds: [...new Set(e.messageIds)] }]);
321
+ continue;
322
+ }
323
+ // 在桶内寻找 messageIds 有交集的现存条目并合并;否则新增一条
324
+ const incomingIds = new Set(e.messageIds);
325
+ let mergedInto;
326
+ for (let i = 0; i < arr.length; i++) {
327
+ const cur = arr[i];
328
+ if (cur.messageIds.some(id => incomingIds.has(id))) {
329
+ const unionIds = [...new Set([...cur.messageIds, ...e.messageIds])];
330
+ arr[i] = {
331
+ ...cur,
332
+ messageIds: unionIds,
333
+ extractedAt: Math.max(cur.extractedAt, e.extractedAt),
334
+ quote: cur.quote ?? e.quote,
335
+ };
336
+ mergedInto = arr[i];
337
+ break;
338
+ }
339
+ }
340
+ if (!mergedInto)
341
+ arr.push({ ...e, messageIds: [...new Set(e.messageIds)] });
342
+ }
343
+ // 2) 合并后做 messageIds-key 兜底去重 + 按 extractedAt DESC 截断
344
+ const merged = [...passthrough];
345
+ for (const arr of buckets.values())
346
+ merged.push(...arr);
347
+ const sorted = merged.sort((a, b) => b.extractedAt - a.extractedAt);
348
+ const seen = new Set();
349
+ const out = [];
350
+ for (const e of sorted) {
351
+ const k = evidenceKey(e);
352
+ if (seen.has(k))
353
+ continue;
354
+ seen.add(k);
355
+ out.push(e);
356
+ if (out.length >= MAX_EVIDENCE_PER_ENTITY)
357
+ break;
358
+ }
359
+ return out;
360
+ }
361
+ /** 两条 evidence 是否识别为「同一条」(来自同会话 + 同批 messageIds)。 */
362
+ function evidenceKey(e) {
363
+ return `${e.sessionId}|${[...e.messageIds].sort().join(',')}`;
364
+ }
365
+ /** quote 归一化去重键:仅当 quote 存在且非空时返回 `${sessionId}|q:${stripped}`;否则返回 undefined。 */
366
+ function quoteKey(e) {
367
+ const raw = e.quote?.trim();
368
+ if (!raw)
369
+ return undefined;
370
+ const normalized = raw.replace(/\s+/g, '').toLowerCase();
371
+ if (!normalized)
372
+ return undefined;
373
+ return `${e.sessionId}|q:${normalized}`;
374
+ }
375
+ /** incoming 是否被 existing 完全覆盖(同一批消息已记过)。
376
+ * 成立时调用方可跳过 reinforce,避免同事实被重复计权。
377
+ * 双键判定:
378
+ * - 精确:incoming.evidenceKey ∈ existing.evidenceKey
379
+ * - 内容:存在同 sessionId+quote 的 existing 条目,且 messageIds 有交集
380
+ * incoming 为空时返回 false(保留原有「无 evidence 仍允许动作」语义)。
381
+ */
382
+ export function isEvidenceFullyCovered(incoming, existing) {
383
+ if (incoming.length === 0)
384
+ return false;
385
+ const evKeys = new Set(existing.map(evidenceKey));
386
+ const byQuote = new Map();
387
+ for (const e of existing) {
388
+ const qk = quoteKey(e);
389
+ if (!qk)
390
+ continue;
391
+ const arr = byQuote.get(qk);
392
+ if (arr)
393
+ arr.push(e);
394
+ else
395
+ byQuote.set(qk, [e]);
396
+ }
397
+ return incoming.every(e => {
398
+ if (evKeys.has(evidenceKey(e)))
399
+ return true;
400
+ const qk = quoteKey(e);
401
+ if (!qk)
402
+ return false;
403
+ const peers = byQuote.get(qk);
404
+ if (!peers)
405
+ return false;
406
+ const ids = new Set(e.messageIds);
407
+ return peers.some(p => p.messageIds.some(id => ids.has(id)));
408
+ });
409
+ }
410
+ /**
411
+ * 归一化关系类型:把同义词收敛到推荐词表里的标准形式。
412
+ * 未匹配的自创词保持原样(允许 LLM 自由扩展,但应用层尝试合并显然同义的)。
413
+ */
414
+ const RELATION_SYNONYMS = {
415
+ best_friend: 'friend',
416
+ buddy: 'friend',
417
+ bestie: 'friend',
418
+ lovers: 'cp',
419
+ couple: 'cp',
420
+ partner: 'cp',
421
+ enemy: 'antagonist',
422
+ hater: 'antagonist',
423
+ opponent: 'rival',
424
+ competitor: 'rival',
425
+ coworker: 'colleague',
426
+ teammate: 'colleague',
427
+ teacher: 'mentor',
428
+ master: 'mentor',
429
+ student: 'admirer', // 单向:student → mentor 反过来就是 admirer 也行;UI 上可视化区分由 directed 控制
430
+ fan: 'admirer',
431
+ acquaintance: 'familiar',
432
+ };
433
+ export function normalizeRelationType(input) {
434
+ const trimmed = input.trim().toLowerCase().replace(/\s+/g, '_');
435
+ return RELATION_SYNONYMS[trimmed] ?? trimmed;
436
+ }
437
+ /** 对称关系:双向无方向区别 */
438
+ const SYMMETRIC_RELATIONS = new Set(['friend', 'cp', 'rival', 'colleague', 'familiar', 'antagonist']);
439
+ export function isSymmetricRelation(relationType) {
440
+ return SYMMETRIC_RELATIONS.has(relationType);
441
+ }
442
+ /** event-event 边的方向性默认:有向的常见关系 */
443
+ const DIRECTED_EVENT_EVENT_RELATIONS = new Set(['caused-by', 'follows', 'part-of']);
444
+ export function isDirectedEventEventRelation(relationType) {
445
+ return DIRECTED_EVENT_EVENT_RELATIONS.has(relationType);
446
+ }
447
+ /** entity-entity 边的方向性默认:「part-of / contains / variant-of / is-alias-of」有向;「related / opposite」无向 */
448
+ const DIRECTED_ENTITY_ENTITY_RELATIONS = new Set(['part-of', 'contains', 'variant-of', 'is-alias-of']);
449
+ export function isDirectedEntityEntityRelation(relationType) {
450
+ return DIRECTED_ENTITY_ENTITY_RELATIONS.has(relationType);
451
+ }
452
+ /** description 裁剪:去首尾空白、限长 40 字,空串返回 undefined */
453
+ export function trimDescription(d) {
454
+ if (!d)
455
+ return undefined;
456
+ const t = d.trim();
457
+ if (!t)
458
+ return undefined;
459
+ return t.length > 40 ? `${t.slice(0, 40)}…` : t;
460
+ }
461
+ // ============================================================
462
+ // Alias merge helpers
463
+ // ============================================================
464
+ const ALIAS_MARKER_RELATIONS = new Set(['is-alias-of', 'alt-account-of']);
465
+ export function isAliasMarkerEdge(e) {
466
+ if (e.kind === 'entity-entity' || e.kind === 'person-person' || e.kind === 'event-event') {
467
+ return ALIAS_MARKER_RELATIONS.has(e.relationType);
468
+ }
469
+ return false;
470
+ }
471
+ /** 判断边是否在任意字段引用了某 id(覆盖 6 种 edge kind) */
472
+ export function edgeReferences(e, id) {
473
+ switch (e.kind) {
474
+ case 'person-event':
475
+ return e.fromPersonId === id || e.toEventId === id;
476
+ case 'person-person':
477
+ return e.fromPersonId === id || e.toPersonId === id;
478
+ case 'person-entity':
479
+ return e.fromPersonId === id || e.toEntityId === id;
480
+ case 'event-event':
481
+ return e.fromEventId === id || e.toEventId === id;
482
+ case 'event-entity':
483
+ return e.fromEventId === id || e.toEntityId === id;
484
+ case 'entity-entity':
485
+ return e.fromEntityId === id || e.toEntityId === id;
486
+ }
487
+ }
488
+ export function edgeInvolvesBoth(e, a, b) {
489
+ return edgeReferences(e, a) && edgeReferences(e, b);
490
+ }
491
+ /**
492
+ * 给定一条边和"我方 nodeId",解析出"对端 id + 对端 kind + 我方是否为 to 端(incoming)"。
493
+ * 不匹配(边不引用 nodeId)时返回 null。
494
+ *
495
+ * 用途:聚合邻居(区分人物/事件/实体)、计算入边权重时复用统一逻辑,
496
+ * 避免各处用 `(e as any).to ?? (e as any).toId` 这类基于"虚构字段名"的脆弱访问
497
+ * (RelationEdge 的字段都有 kind 后缀,如 fromPersonId/toEventId,没有裸 to/from/toId/fromId)。
498
+ */
499
+ export function getEdgeOtherEnd(e, nodeId) {
500
+ switch (e.kind) {
501
+ case 'person-event':
502
+ if (e.fromPersonId === nodeId)
503
+ return { otherId: e.toEventId, otherKind: 'event', isIncoming: false };
504
+ if (e.toEventId === nodeId)
505
+ return { otherId: e.fromPersonId, otherKind: 'person', isIncoming: true };
506
+ return null;
507
+ case 'person-person':
508
+ if (e.fromPersonId === nodeId)
509
+ return { otherId: e.toPersonId, otherKind: 'person', isIncoming: false };
510
+ if (e.toPersonId === nodeId)
511
+ return { otherId: e.fromPersonId, otherKind: 'person', isIncoming: true };
512
+ return null;
513
+ case 'person-entity':
514
+ if (e.fromPersonId === nodeId)
515
+ return { otherId: e.toEntityId, otherKind: 'entity', isIncoming: false };
516
+ if (e.toEntityId === nodeId)
517
+ return { otherId: e.fromPersonId, otherKind: 'person', isIncoming: true };
518
+ return null;
519
+ case 'event-event':
520
+ if (e.fromEventId === nodeId)
521
+ return { otherId: e.toEventId, otherKind: 'event', isIncoming: false };
522
+ if (e.toEventId === nodeId)
523
+ return { otherId: e.fromEventId, otherKind: 'event', isIncoming: true };
524
+ return null;
525
+ case 'event-entity':
526
+ if (e.fromEventId === nodeId)
527
+ return { otherId: e.toEntityId, otherKind: 'entity', isIncoming: false };
528
+ if (e.toEntityId === nodeId)
529
+ return { otherId: e.fromEventId, otherKind: 'event', isIncoming: true };
530
+ return null;
531
+ case 'entity-entity':
532
+ if (e.fromEntityId === nodeId)
533
+ return { otherId: e.toEntityId, otherKind: 'entity', isIncoming: false };
534
+ if (e.toEntityId === nodeId)
535
+ return { otherId: e.fromEntityId, otherKind: 'entity', isIncoming: true };
536
+ return null;
537
+ }
538
+ }
539
+ export function isAliasEdgeDirectionCorrect(e, aliasId, canonicalId) {
540
+ if (e.kind === 'entity-entity')
541
+ return e.fromEntityId === aliasId && e.toEntityId === canonicalId;
542
+ if (e.kind === 'person-person')
543
+ return e.fromPersonId === aliasId && e.toPersonId === canonicalId;
544
+ if (e.kind === 'event-event')
545
+ return e.fromEventId === aliasId && e.toEventId === canonicalId;
546
+ return true;
547
+ }
548
+ export function flipDirectedEdge(e, aliasId, canonicalId) {
549
+ if (e.kind === 'entity-entity')
550
+ return { ...e, fromEntityId: aliasId, toEntityId: canonicalId };
551
+ if (e.kind === 'person-person')
552
+ return { ...e, fromPersonId: aliasId, toPersonId: canonicalId };
553
+ if (e.kind === 'event-event')
554
+ return { ...e, fromEventId: aliasId, toEventId: canonicalId };
555
+ return null;
556
+ }
557
+ /** 把边中任一等于 aliasId 的字段替换为 canonicalId,返回浅拷贝 */
558
+ export function rewriteEdgeIds(e, aliasId, canonicalId) {
559
+ const swap = (v) => (v === aliasId ? canonicalId : v);
560
+ switch (e.kind) {
561
+ case 'person-event':
562
+ return { ...e, fromPersonId: swap(e.fromPersonId), toEventId: swap(e.toEventId) };
563
+ case 'person-person':
564
+ return { ...e, fromPersonId: swap(e.fromPersonId), toPersonId: swap(e.toPersonId) };
565
+ case 'person-entity':
566
+ return { ...e, fromPersonId: swap(e.fromPersonId), toEntityId: swap(e.toEntityId) };
567
+ case 'event-event':
568
+ return { ...e, fromEventId: swap(e.fromEventId), toEventId: swap(e.toEventId) };
569
+ case 'event-entity':
570
+ return { ...e, fromEventId: swap(e.fromEventId), toEntityId: swap(e.toEntityId) };
571
+ case 'entity-entity':
572
+ return { ...e, fromEntityId: swap(e.fromEntityId), toEntityId: swap(e.toEntityId) };
573
+ }
574
+ }
575
+ export function isEdgeSelfLoop(e) {
576
+ switch (e.kind) {
577
+ case 'person-event':
578
+ return false; // 跨类型,不可能自环
579
+ case 'person-person':
580
+ return e.fromPersonId === e.toPersonId;
581
+ case 'person-entity':
582
+ return false;
583
+ case 'event-event':
584
+ return e.fromEventId === e.toEventId;
585
+ case 'event-entity':
586
+ return false;
587
+ case 'entity-entity':
588
+ return e.fromEntityId === e.toEntityId;
589
+ }
590
+ }
591
+ /** 边的去重键(用于合并冲突检测)。无向边按字典序规范化端点。 */
592
+ export function edgeDedupKey(e) {
593
+ switch (e.kind) {
594
+ case 'person-event':
595
+ return `pe|${e.fromPersonId}|${e.toEventId}|${e.role}`;
596
+ case 'person-person': {
597
+ const [a, b] = e.directed ? [e.fromPersonId, e.toPersonId] : [e.fromPersonId, e.toPersonId].sort();
598
+ return `pp|${a}|${b}|${e.relationType}|${e.directed ? 'd' : 'u'}`;
599
+ }
600
+ case 'person-entity':
601
+ return `pent|${e.fromPersonId}|${e.toEntityId}|${e.role}`;
602
+ case 'event-event': {
603
+ const [a, b] = e.directed ? [e.fromEventId, e.toEventId] : [e.fromEventId, e.toEventId].sort();
604
+ return `ee|${a}|${b}|${e.relationType}|${e.directed ? 'd' : 'u'}`;
605
+ }
606
+ case 'event-entity':
607
+ return `eent|${e.fromEventId}|${e.toEntityId}|${e.relationType}`;
608
+ case 'entity-entity': {
609
+ const [a, b] = e.directed ? [e.fromEntityId, e.toEntityId] : [e.fromEntityId, e.toEntityId].sort();
610
+ return `entent|${a}|${b}|${e.relationType}|${e.directed ? 'd' : 'u'}`;
611
+ }
612
+ }
613
+ }
614
+ /** 合并两条同 dedupKey 的边:保留 keeper.id,合并 evidence、weight 取强化、时间取较新 */
615
+ export function mergeTwoEdges(keeper, incoming) {
616
+ const allEvidence = trimEvidence([...keeper.evidence, ...incoming.evidence]);
617
+ const weight = clamp01(reinforceWeight(keeper.weight ?? 0, incoming.weight ?? 0));
618
+ const lastReinforcedAt = Math.max(keeper.lastReinforcedAt, incoming.lastReinforcedAt);
619
+ const firstSeenAt = Math.min(keeper.firstSeenAt, incoming.firstSeenAt);
620
+ const description = keeper.description ?? incoming.description;
621
+ return { ...keeper, weight, lastReinforcedAt, firstSeenAt, description, evidence: allEvidence };
622
+ }
623
+ /** 启发式:决定真正的 (alias, canonical) 方向。返回 null 表示无需翻转。 */
624
+ export function chooseCanonicalDirection(snapshot, aId, bId, kind) {
625
+ const score = (id) => {
626
+ let name = 0;
627
+ let aliases = 0;
628
+ let evidence = 0;
629
+ if (kind === 'entity') {
630
+ const node = snapshot.entities.find(n => n.id === id);
631
+ name = node?.name.length ?? 0;
632
+ aliases = node?.aliases?.length ?? 0;
633
+ }
634
+ else if (kind === 'event') {
635
+ const node = snapshot.events.find(n => n.id === id);
636
+ name = node?.title.length ?? 0;
637
+ }
638
+ else {
639
+ const node = snapshot.persons.find(n => n.id === id);
640
+ name = node?.displayName?.length ?? 0;
641
+ }
642
+ for (const e of snapshot.edges) {
643
+ if (edgeReferences(e, id))
644
+ evidence += e.evidence.length;
645
+ }
646
+ return { name, aliases, evidence };
647
+ };
648
+ const sa = score(aId);
649
+ const sb = score(bId);
650
+ // a 得分越高 → a 更应是 canonical
651
+ const cmp = sa.aliases - sb.aliases || sa.name - sb.name || sa.evidence - sb.evidence;
652
+ // 当前调用方传入 alias=aId, canonical=bId;若 a 评分更高 → 翻转
653
+ if (cmp > 0)
654
+ return { alias: bId, canonical: aId };
655
+ return null;
656
+ }
657
+ /**
658
+ * 标准 PageRank(带个性化向量),用于淘汰打分。
659
+ *
660
+ * - 把所有节点(人/事件/实体)放进同一张图;边权重取 `edge.weight`(最低 0.05)。
661
+ * - 无向边(is-alias-of 之外的 person-person/entity-entity directed=false)双向传播;
662
+ * directed=true 边只按 from→to 传播。
663
+ * - 个性化向量按 kind 分配种子权重(默认 人=3 / 物=2 / 事=1),从而"重要性 人>物>事"
664
+ * 不需要硬编码到打分,而是体现在 PR 的偏置上:人物附近的事件/实体 PR 更高。
665
+ * - 迭代到 L1 误差 < epsilon 或达到 maxIter 终止。
666
+ */
667
+ export function computePageRank(snap, opts) {
668
+ const allIds = [];
669
+ const kindOf = new Map();
670
+ for (const p of snap.persons) {
671
+ allIds.push(p.id);
672
+ kindOf.set(p.id, 'person');
673
+ }
674
+ for (const e of snap.events) {
675
+ allIds.push(e.id);
676
+ kindOf.set(e.id, 'event');
677
+ }
678
+ for (const en of snap.entities) {
679
+ allIds.push(en.id);
680
+ kindOf.set(en.id, 'entity');
681
+ }
682
+ const n = allIds.length;
683
+ if (n === 0)
684
+ return new Map();
685
+ // 出向加权邻接 + 节点出度权和
686
+ const outAdj = new Map();
687
+ const outWeightSum = new Map();
688
+ const addOut = (from, to, w) => {
689
+ if (!kindOf.has(from) || !kindOf.has(to) || from === to)
690
+ return;
691
+ if (!outAdj.has(from))
692
+ outAdj.set(from, []);
693
+ outAdj.get(from).push({ to, w });
694
+ outWeightSum.set(from, (outWeightSum.get(from) ?? 0) + w);
695
+ };
696
+ for (const e of snap.edges) {
697
+ const w = Math.max(e.weight ?? 0.5, 0.05);
698
+ let from = '';
699
+ let to = '';
700
+ let directed = true;
701
+ switch (e.kind) {
702
+ case 'person-event':
703
+ from = e.fromPersonId;
704
+ to = e.toEventId;
705
+ directed = true;
706
+ break;
707
+ case 'person-entity':
708
+ from = e.fromPersonId;
709
+ to = e.toEntityId;
710
+ directed = true;
711
+ break;
712
+ case 'person-person':
713
+ from = e.fromPersonId;
714
+ to = e.toPersonId;
715
+ directed = e.directed;
716
+ break;
717
+ case 'event-event':
718
+ from = e.fromEventId;
719
+ to = e.toEventId;
720
+ directed = e.directed;
721
+ break;
722
+ case 'event-entity':
723
+ from = e.fromEventId;
724
+ to = e.toEntityId;
725
+ directed = e.directed;
726
+ break;
727
+ case 'entity-entity':
728
+ from = e.fromEntityId;
729
+ to = e.toEntityId;
730
+ directed = e.directed;
731
+ break;
732
+ }
733
+ addOut(from, to, w);
734
+ if (!directed) {
735
+ addOut(to, from, w);
736
+ }
737
+ else if (e.kind === 'person-event' || e.kind === 'person-entity') {
738
+ // 反向虚拟边:让事件/实体的 PR 反哺参与者,避免人节点 PR 退化为种子常数。
739
+ // reverseEdgeFactor=0 则不加反向边。
740
+ const ref = opts.reverseEdgeFactor ?? 0.5;
741
+ if (ref > 0)
742
+ addOut(to, from, w * ref);
743
+ }
744
+ }
745
+ // 个性化向量(归一化)
746
+ const seedRaw = new Map();
747
+ let seedTotal = 0;
748
+ for (const id of allIds) {
749
+ const k = kindOf.get(id);
750
+ const s = k === 'person' ? opts.personSeed : k === 'entity' ? opts.entitySeed : opts.eventSeed;
751
+ seedRaw.set(id, s);
752
+ seedTotal += s;
753
+ }
754
+ const seed = new Map();
755
+ for (const [id, v] of seedRaw)
756
+ seed.set(id, v / Math.max(seedTotal, 1));
757
+ // 初始化 PR = 个性化向量
758
+ let pr = new Map(seed);
759
+ const d = opts.damping;
760
+ for (let iter = 0; iter < opts.maxIter; iter++) {
761
+ const next = new Map();
762
+ let danglingMass = 0;
763
+ for (const id of allIds) {
764
+ const w = outWeightSum.get(id);
765
+ if (!w || w === 0)
766
+ danglingMass += pr.get(id) ?? 0;
767
+ }
768
+ for (const id of allIds) {
769
+ // teleport + dangling 均匀按 seed 分布
770
+ next.set(id, (1 - d) * (seed.get(id) ?? 0) + d * danglingMass * (seed.get(id) ?? 0));
771
+ }
772
+ for (const [from, neighbors] of outAdj) {
773
+ const ws = outWeightSum.get(from) ?? 1;
774
+ const share = ((pr.get(from) ?? 0) * d) / ws;
775
+ for (const { to, w } of neighbors) {
776
+ next.set(to, (next.get(to) ?? 0) + share * w);
777
+ }
778
+ }
779
+ // L1 收敛判断
780
+ let delta = 0;
781
+ for (const id of allIds)
782
+ delta += Math.abs((next.get(id) ?? 0) - (pr.get(id) ?? 0));
783
+ pr = next;
784
+ if (delta < opts.epsilon)
785
+ break;
786
+ }
787
+ // Component-weighted Personalized PageRank 后处理。
788
+ // 在无向连通分量内 BFS 求 size,每个节点 PR *= sqrt(componentSize / n),再归一化 sum=1。
789
+ // 关闭则跳过(用于希望保留纯 PR 语义的场景或单元测试)。
790
+ if ((opts.componentScale ?? true) !== false) {
791
+ const undirAdj = buildUndirectedAdj(snap);
792
+ const compSize = new Map();
793
+ const visited = new Set();
794
+ for (const start of allIds) {
795
+ if (visited.has(start))
796
+ continue;
797
+ const queue = [start];
798
+ visited.add(start);
799
+ const members = [];
800
+ while (queue.length > 0) {
801
+ const u = queue.shift();
802
+ members.push(u);
803
+ const nbrs = undirAdj.get(u);
804
+ if (!nbrs)
805
+ continue;
806
+ for (const v of nbrs.keys()) {
807
+ if (!visited.has(v)) {
808
+ visited.add(v);
809
+ queue.push(v);
810
+ }
811
+ }
812
+ }
813
+ const size = members.length;
814
+ for (const m of members)
815
+ compSize.set(m, size);
816
+ }
817
+ let scaledSum = 0;
818
+ const scaled = new Map();
819
+ for (const id of allIds) {
820
+ const s = compSize.get(id) ?? 1;
821
+ const v = (pr.get(id) ?? 0) * Math.sqrt(s / n);
822
+ scaled.set(id, v);
823
+ scaledSum += v;
824
+ }
825
+ if (scaledSum > 0) {
826
+ for (const id of allIds)
827
+ scaled.set(id, (scaled.get(id) ?? 0) / scaledSum);
828
+ pr = scaled;
829
+ }
830
+ }
831
+ return pr;
832
+ }
833
+ function buildUndirectedAdj(snap) {
834
+ const adj = new Map();
835
+ const ensure = (id) => {
836
+ if (!adj.has(id))
837
+ adj.set(id, new Map());
838
+ };
839
+ for (const p of snap.persons)
840
+ ensure(p.id);
841
+ for (const e of snap.events)
842
+ ensure(e.id);
843
+ for (const en of snap.entities)
844
+ ensure(en.id);
845
+ const addUndirected = (a, b, w) => {
846
+ if (a === b || !adj.has(a) || !adj.has(b))
847
+ return;
848
+ const ma = adj.get(a);
849
+ ma.set(b, (ma.get(b) ?? 0) + w);
850
+ const mb = adj.get(b);
851
+ mb.set(a, (mb.get(a) ?? 0) + w);
852
+ };
853
+ for (const e of snap.edges) {
854
+ const w = Math.max(e.weight ?? 0.5, 0.05);
855
+ let from = '';
856
+ let to = '';
857
+ switch (e.kind) {
858
+ case 'person-event':
859
+ from = e.fromPersonId;
860
+ to = e.toEventId;
861
+ break;
862
+ case 'person-entity':
863
+ from = e.fromPersonId;
864
+ to = e.toEntityId;
865
+ break;
866
+ case 'person-person':
867
+ from = e.fromPersonId;
868
+ to = e.toPersonId;
869
+ break;
870
+ case 'event-event':
871
+ from = e.fromEventId;
872
+ to = e.toEventId;
873
+ break;
874
+ case 'event-entity':
875
+ from = e.fromEventId;
876
+ to = e.toEntityId;
877
+ break;
878
+ case 'entity-entity':
879
+ from = e.fromEntityId;
880
+ to = e.toEntityId;
881
+ break;
882
+ }
883
+ addUndirected(from, to, w);
884
+ }
885
+ return adj;
886
+ }
887
+ /**
888
+ * 计算 modularity Q ∈ [-0.5, 1)。Q > 0.3 通常认为社群结构清晰;Q < 0.1 几乎是随机划分。
889
+ *
890
+ * 公式:Q = (1/2m) Σ_ij [A_ij - k_i k_j / 2m] · δ(c_i, c_j)
891
+ * 实现:按社群内边权和 Σ_in 和社群总度 Σ_tot 聚合,等价为
892
+ * Q = Σ_c [ Σ_in(c) / 2m - (Σ_tot(c) / 2m)^2 ]
893
+ */
894
+ export function computeModularity(snap, communityMap) {
895
+ const adj = buildUndirectedAdj(snap);
896
+ let twoM = 0;
897
+ const ki = new Map();
898
+ for (const [id, nbrs] of adj) {
899
+ let s = 0;
900
+ for (const w of nbrs.values())
901
+ s += w;
902
+ ki.set(id, s);
903
+ twoM += s;
904
+ }
905
+ if (twoM === 0)
906
+ return 0;
907
+ const sigmaIn = new Map();
908
+ const sigmaTot = new Map();
909
+ for (const [u, nbrs] of adj) {
910
+ const cu = communityMap.get(u);
911
+ if (cu === undefined)
912
+ continue;
913
+ sigmaTot.set(cu, (sigmaTot.get(cu) ?? 0) + (ki.get(u) ?? 0));
914
+ for (const [v, w] of nbrs) {
915
+ if (communityMap.get(v) === cu) {
916
+ // 内部边权重计入两次(u→v + v→u),公式系数已含 1/2m,无需修正
917
+ sigmaIn.set(cu, (sigmaIn.get(cu) ?? 0) + w);
918
+ }
919
+ }
920
+ }
921
+ let q = 0;
922
+ for (const c of sigmaTot.keys()) {
923
+ const sin = sigmaIn.get(c) ?? 0;
924
+ const stot = sigmaTot.get(c) ?? 0;
925
+ q += sin / twoM - (stot / twoM) ** 2;
926
+ }
927
+ return q;
928
+ }
929
+ /**
930
+ * 按图规模自适应推导 Louvain/Leiden 的分辨率 γ。
931
+ *
932
+ * 公式:γ = clamp(0.6, 2.5, 0.5 + log10(n / 30)),其中 n = snap.persons.length。
933
+ * - n ≤ 30 → 0.6(地板,小图保持粗粒度,避免每人一个社群)
934
+ * - n = 100 → ≈1.02(接近标准 Louvain)
935
+ * - n = 150 → ≈1.20(解决我们当前 36 人巨型社群被过度合并的问题)
936
+ * - n = 500 → ≈1.72
937
+ * - n ≥ 750 → 2.5(天花板,避免极细碎导致 c0~c100 难以消费)
938
+ *
939
+ * 背景:标准 Louvain(γ=1)存在 "resolution limit"(Fortunato & Barthélemy 2007)——
940
+ * 节点数越多越倾向于把小社群合并成超级社群。让 γ 随 n 单调微增是文献推荐做法。
941
+ *
942
+ * 不依赖边数 / 平均度:实现简单、可解释,对当前 < 1000 节点的图足够;如果后续图规模或密度
943
+ * 差异显著(比如某 scope 节点很少但边很密),再升级到 sqrt(2m/(n·d_target)) 的密度感知公式。
944
+ *
945
+ * persons 为空 → 返回 1.0(标准默认,安全兜底)。
946
+ */
947
+ export function computeAdaptiveResolution(snap) {
948
+ const n = snap.persons?.length ?? 0;
949
+ if (n <= 0)
950
+ return 1.0;
951
+ const raw = 0.5 + Math.log10(n / 30);
952
+ return Math.max(0.6, Math.min(2.5, raw));
953
+ }
954
+ /**
955
+ * Louvain 社群发现(无向加权图,单次 coarsening)。
956
+ *
957
+ * 适用场景:在 PageRank 算完后顺手把节点聚类成"小圈子"——
958
+ * - 群里有几个核心团体
959
+ * - 某人最常一起出现的是谁
960
+ *
961
+ * 实现要点:
962
+ * - 把全图视为无向加权图:所有边权重双向叠加;有向边只在 from→to 方向加权一次,无向边两端各加一次。
963
+ * - 阶段 1:局部移动——每个节点尝试加入邻居社群,按 modularity 增益 ΔQ 贪心选最大;扫一轮无改进即收敛。
964
+ * - 阶段 2:coarsening——把社群当超节点,社群间边权聚合,再跑一轮局部移动(标准 Louvain 多轮,
965
+ * 实测两轮已经稳定;我们的图 < 1000 节点,再多收益边际递减)。
966
+ * - 返回 `nodeId → communityId` 映射,communityId 是字符串(c0/c1/...)。
967
+ *
968
+ * 复杂度:O(E · iter),iter ≤ 10。对 360 节点几乎 instant。
969
+ *
970
+ * 不做:
971
+ * - 不返回 modularity 值(请用独立的 computeModularity)
972
+ * - resolution(γ)参数:默认 1.0(标准 Louvain);> 1 → 划得更细更多社群;< 1 → 划得更粗更少社群。
973
+ * 实现:把 ΔQ 公式里的 `kii * sigmaTot(C) / 2m` 项乘以 γ(标准做法,对应 RB 多分辨率模块度)。
974
+ * 常用范围:0.5 ~ 3.0。极端值(< 0.1 / > 10)会导致全图一个社群 / 每节点自成社群。
975
+ */
976
+ export function computeLouvain(snap, opts) {
977
+ const maxIter = opts?.maxIterPerPass ?? 10;
978
+ const minImp = opts?.minImprovement ?? 1e-6;
979
+ // resolution:clamp 到合理范围避免数值溢出 / 全图坍缩;超出仍允许但提示
980
+ const gamma = (() => {
981
+ const raw = opts?.resolution ?? 1.0;
982
+ if (!Number.isFinite(raw) || raw <= 0)
983
+ return 1.0;
984
+ return Math.max(0.01, Math.min(raw, 100));
985
+ })();
986
+ const adj = buildUndirectedAdj(snap);
987
+ const allIds = Array.from(adj.keys());
988
+ if (allIds.length === 0)
989
+ return new Map();
990
+ /** 对当前图(adj + 节点 ki = 邻居权之和)跑一轮局部移动,返回 nodeId→communityIdx */
991
+ const runLocalMove = (graph) => {
992
+ const ids = Array.from(graph.keys());
993
+ if (ids.length === 0)
994
+ return new Map();
995
+ const ki = new Map();
996
+ let twoM = 0;
997
+ for (const id of ids) {
998
+ let s = 0;
999
+ for (const w of graph.get(id).values())
1000
+ s += w;
1001
+ ki.set(id, s);
1002
+ twoM += s;
1003
+ }
1004
+ if (twoM === 0) {
1005
+ // 所有节点都孤立,每个自成一社群
1006
+ const m = new Map();
1007
+ for (let i = 0; i < ids.length; i++)
1008
+ m.set(ids[i], i);
1009
+ return m;
1010
+ }
1011
+ // 初始:每个节点自成一社群
1012
+ const node2com = new Map();
1013
+ const comTotalK = new Map(); // 社群总度数 Σ_tot
1014
+ for (let i = 0; i < ids.length; i++) {
1015
+ node2com.set(ids[i], i);
1016
+ comTotalK.set(i, ki.get(ids[i]) ?? 0);
1017
+ }
1018
+ for (let iter = 0; iter < maxIter; iter++) {
1019
+ let improved = 0;
1020
+ // 随机顺序更鲁棒,但为可复现先按字典序
1021
+ for (const i of ids) {
1022
+ const ci = node2com.get(i);
1023
+ const kii = ki.get(i) ?? 0;
1024
+ // 邻居社群权 Σ_in
1025
+ const neighborWeightToCom = new Map();
1026
+ for (const [j, w] of graph.get(i)) {
1027
+ const cj = node2com.get(j);
1028
+ neighborWeightToCom.set(cj, (neighborWeightToCom.get(cj) ?? 0) + w);
1029
+ }
1030
+ // 把 i 移出 ci
1031
+ const wToCi = neighborWeightToCom.get(ci) ?? 0;
1032
+ comTotalK.set(ci, (comTotalK.get(ci) ?? 0) - kii);
1033
+ // 计算移入每个候选社群的 ΔQ:2*(wToC - γ * ki * sigma_tot(C) / 2m)
1034
+ // 等价比较:argmax_c { wToC - γ * ki * sigma_tot(C) / 2m }
1035
+ // γ > 1:放大对 sigma_tot 大社群的惩罚 → 倾向多个小社群
1036
+ // γ < 1:缩小惩罚 → 倾向少数大社群
1037
+ let bestCom = ci;
1038
+ let bestGain = wToCi - (gamma * (kii * (comTotalK.get(ci) ?? 0))) / twoM;
1039
+ for (const [c, wToC] of neighborWeightToCom) {
1040
+ if (c === ci)
1041
+ continue;
1042
+ const gain = wToC - (gamma * (kii * (comTotalK.get(c) ?? 0))) / twoM;
1043
+ if (gain > bestGain + minImp) {
1044
+ bestGain = gain;
1045
+ bestCom = c;
1046
+ }
1047
+ }
1048
+ // 把 i 加入 bestCom
1049
+ comTotalK.set(bestCom, (comTotalK.get(bestCom) ?? 0) + kii);
1050
+ if (bestCom !== ci) {
1051
+ node2com.set(i, bestCom);
1052
+ improved++;
1053
+ }
1054
+ }
1055
+ if (improved === 0)
1056
+ break;
1057
+ }
1058
+ return node2com;
1059
+ };
1060
+ // 阶段 1:原图局部移动
1061
+ const firstPass = runLocalMove(adj);
1062
+ // 阶段 2:用 firstPass 的社群作为超节点,构造新图再跑一轮
1063
+ const superAdj = new Map();
1064
+ for (const id of allIds) {
1065
+ const c = firstPass.get(id);
1066
+ const key = `c${c}`;
1067
+ if (!superAdj.has(key))
1068
+ superAdj.set(key, new Map());
1069
+ }
1070
+ for (const [u, neighbors] of adj) {
1071
+ const cu = `c${firstPass.get(u)}`;
1072
+ for (const [v, w] of neighbors) {
1073
+ const cv = `c${firstPass.get(v)}`;
1074
+ if (cu === cv)
1075
+ continue; // 社群内部边在 modularity 公式里已通过 ki 反映,不必显式加(其实标准实现要保留,但我们只用 superAdj 做第二轮聚类,self-loop 无影响)
1076
+ const m = superAdj.get(cu);
1077
+ m.set(cv, (m.get(cv) ?? 0) + w);
1078
+ }
1079
+ }
1080
+ const secondPass = runLocalMove(superAdj);
1081
+ // 合成最终 nodeId → 最终社群字符串
1082
+ const result = new Map();
1083
+ // 把 secondPass 的 community idx 规范化为连续 c0..cN(避免暴露原始 idx)
1084
+ const finalRemap = new Map();
1085
+ let counter = 0;
1086
+ for (const id of allIds) {
1087
+ const inter = `c${firstPass.get(id)}`;
1088
+ const finalIdx = secondPass.get(inter);
1089
+ if (finalIdx === undefined) {
1090
+ // 不应发生:兜底自成一社群
1091
+ result.set(id, `c${counter++}`);
1092
+ continue;
1093
+ }
1094
+ if (!finalRemap.has(finalIdx))
1095
+ finalRemap.set(finalIdx, `c${counter++}`);
1096
+ result.set(id, finalRemap.get(finalIdx));
1097
+ }
1098
+ return result;
1099
+ }
1100
+ /**
1101
+ * Leiden 社群发现(实用简化版:Louvain local-move + connectivity-based refinement)。
1102
+ *
1103
+ * ⚠️ **不是论文版完整 Leiden**:论文版 Leiden(Traag et al. 2019)含三阶段——
1104
+ * 1. local move(同 Louvain)
1105
+ * 2. refinement:在每个社群内部跑 modularity-weighted singleton subset move(随机走子集)
1106
+ * 3. aggregation 时只压缩 refinement 后的子集
1107
+ * 论文版能保证「γ-separation」+「γ-connection」严格性质,但实现 ~300 行。
1108
+ *
1109
+ * **本实现简化为**:
1110
+ * 1. local move(同 Louvain)
1111
+ * 2. refinement:对每个 Louvain 社群做 BFS 连通分量检测,把内部不连通的社群拆成多个子社群
1112
+ * 3. (省略 aggregation 第二轮,直接返回 refined 结果)
1113
+ *
1114
+ * **解决了 Louvain 最大的诟病**——社群内部可能不连通("badly connected communities")。
1115
+ * 在我们这种密度较高、规模 < 1000 的关系图上,质量提升足够明显,又不需要论文版的复杂度。
1116
+ *
1117
+ * Agent 视角:当怀疑 Louvain 把两群明显没交集的人分到同一社群时,换 leiden 跑一次能看到更细的划分。
1118
+ */
1119
+ export function computeLeiden(snap, opts) {
1120
+ // Step 1: 先跑 Louvain
1121
+ const louvain = computeLouvain(snap, opts);
1122
+ if (louvain.size === 0)
1123
+ return louvain;
1124
+ // Step 2: 按 Louvain 社群分组,组内做 BFS 连通分量
1125
+ const adj = buildUndirectedAdj(snap);
1126
+ const groups = new Map();
1127
+ for (const [id, c] of louvain) {
1128
+ if (!groups.has(c))
1129
+ groups.set(c, []);
1130
+ groups.get(c).push(id);
1131
+ }
1132
+ const result = new Map();
1133
+ let nextIdx = 0;
1134
+ for (const [, members] of groups) {
1135
+ const inGroup = new Set(members);
1136
+ const visited = new Set();
1137
+ // 对该社群内的节点做多次 BFS,每次拿到一个连通分量
1138
+ for (const seed of members) {
1139
+ if (visited.has(seed))
1140
+ continue;
1141
+ const stack = [seed];
1142
+ visited.add(seed);
1143
+ const componentId = `c${nextIdx++}`;
1144
+ while (stack.length > 0) {
1145
+ const u = stack.pop();
1146
+ result.set(u, componentId);
1147
+ const nbrs = adj.get(u);
1148
+ if (!nbrs)
1149
+ continue;
1150
+ for (const v of nbrs.keys()) {
1151
+ // 只在同一 Louvain 社群内部走
1152
+ if (!inGroup.has(v) || visited.has(v))
1153
+ continue;
1154
+ visited.add(v);
1155
+ stack.push(v);
1156
+ }
1157
+ }
1158
+ }
1159
+ }
1160
+ return result;
1161
+ }
1162
+ /**
1163
+ * SLPA — Speaker-Listener Label Propagation Algorithm(原生重叠社区发现)。
1164
+ *
1165
+ * 论文:Xie, Szymanski, Liu (2011) "SLPA: Uncovering Overlapping Communities in
1166
+ * Social Networks via a Speaker-Listener Interaction Dynamic Process"。
1167
+ *
1168
+ * **与 Louvain/Leiden 的关键差别**:Louvain 是硬划分(每个节点恰好属于一个社群),
1169
+ * SLPA 是软划分(节点可同时属于多个社群,按隶属度排序)。适合社交图里"残龙在全性基地+Aalis 周边
1170
+ * 都活跃"这种跨群人物——硬划分会丢失次社群信息,SLPA 直接给出 `{c2: 0.62, c4: 0.31, c1: 0.07}`。
1171
+ *
1172
+ * 算法骨架(论文原版,未做加权扩展时):
1173
+ * 1. 每个节点初始化一个 label memory:`{自身id: 1}`
1174
+ * 2. 共 T 轮迭代;每轮按节点顺序:
1175
+ * a. 选当前节点为 listener
1176
+ * b. 它的每个邻居都作为 speaker,按 speaker 自己 memory 中的频率分布**随机抽**一个 label 吐出
1177
+ * c. listener 收齐所有邻居吐的 label,按"票数最高"挑一个加进自己 memory(计数 +1)
1178
+ * 3. 后处理:每个节点 memory 中频率 ≥ r·(T+1) 的 label 才保留为它的社群隶属;
1179
+ * 然后按"出现次数"归一化为权重 ∈ (0, 1],按 weight 降序排列。
1180
+ *
1181
+ * **本实现的加权扩展**(默认开启,`weightedSpeaker=true`):
1182
+ * - speaker 抽样时:按邻居边权 `w` 对其 memory 频率加权(边越重的邻居"声音越大")
1183
+ * - listener 计票时:每张票按边权计数(不是 +1 而是 +w),同样让重边邻居有更高影响力
1184
+ * - 这与本仓库带权图(edge.weight ∈ [0.05, 1.0])天然契合;纯无权图退化为论文原版
1185
+ *
1186
+ * **复杂度**:O(N · avgDeg · T)。对 400 节点 + T=20 几乎 instant;< 1000 节点都可放心用。
1187
+ *
1188
+ * **确定性**:内置 mulberry32 伪随机种子(由 node id 串字典序导出),跨进程同一 snapshot 输出
1189
+ * 完全可复现;避免 Math.random 让 evictByQuota 每次结果都漂移。
1190
+ *
1191
+ * **不做**:
1192
+ * - 不做社群间合并 / 拆分(论文里的 post-merge 步骤);阈值 r 已能调粒度
1193
+ * - 不返回 modularity(重叠社区的 modularity 没有标准定义;想看质量看 `bridges` 跨社群占比)
1194
+ *
1195
+ * @returns nodeId → CommunityMembership[](按 weight 降序);空图返回空 Map
1196
+ */
1197
+ export function computeSlpa(snap, opts) {
1198
+ const T = Math.max(5, Math.floor(opts?.iterations ?? 20));
1199
+ const r = Math.max(0, Math.min(opts?.threshold ?? 0.1, 0.5));
1200
+ const weighted = opts?.weightedSpeaker ?? true;
1201
+ const adj = buildUndirectedAdj(snap);
1202
+ const ids = Array.from(adj.keys());
1203
+ if (ids.length === 0)
1204
+ return new Map();
1205
+ // label memory:每个节点 → (labelId → 累计出现次数)
1206
+ const memory = new Map();
1207
+ for (const id of ids)
1208
+ memory.set(id, new Map([[id, 1]]));
1209
+ // 确定性 PRNG:mulberry32,种子由所有 node id 串聚合而成,保证同一 snapshot 输出一致
1210
+ let seed = 0x9e3779b9;
1211
+ for (const id of ids) {
1212
+ for (let i = 0; i < id.length; i++)
1213
+ seed = (Math.imul(seed, 31) + id.charCodeAt(i)) >>> 0;
1214
+ }
1215
+ const rand = () => {
1216
+ seed = (seed + 0x6d2b79f5) >>> 0;
1217
+ let t = seed;
1218
+ t = Math.imul(t ^ (t >>> 15), t | 1);
1219
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
1220
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
1221
+ };
1222
+ /** speaker 按 memory 频率分布抽一个 label;weight 是 listener 到 speaker 的边权(用于加权扩展) */
1223
+ const speakerSample = (mem, _w) => {
1224
+ // 频率分布抽样:边权不影响抽样概率(speaker 的"个人意志"),只影响 listener 计票
1225
+ let total = 0;
1226
+ for (const c of mem.values())
1227
+ total += c;
1228
+ if (total <= 0) {
1229
+ // 兜底:返回第一个 key
1230
+ const it = mem.keys().next();
1231
+ return it.done ? '' : it.value;
1232
+ }
1233
+ let pick = rand() * total;
1234
+ for (const [label, c] of mem) {
1235
+ pick -= c;
1236
+ if (pick <= 0)
1237
+ return label;
1238
+ }
1239
+ // 浮点误差兜底:返回最后一个 key
1240
+ let last = '';
1241
+ for (const k of mem.keys())
1242
+ last = k;
1243
+ return last;
1244
+ };
1245
+ // T 轮 listener-speaker 交互
1246
+ for (let iter = 0; iter < T; iter++) {
1247
+ for (const listener of ids) {
1248
+ const neighbors = adj.get(listener);
1249
+ if (neighbors.size === 0)
1250
+ continue;
1251
+ const votes = new Map();
1252
+ for (const [speaker, w] of neighbors) {
1253
+ const speakerMem = memory.get(speaker);
1254
+ const label = speakerSample(speakerMem, w);
1255
+ if (label === '')
1256
+ continue;
1257
+ // 加权计票:边重的邻居票更重
1258
+ votes.set(label, (votes.get(label) ?? 0) + (weighted ? w : 1));
1259
+ }
1260
+ // 选票数最高的 label,加进 listener.memory(票数并列时取字典序最小的 label,稳定可复现)
1261
+ let bestLabel = '';
1262
+ let bestVote = -1;
1263
+ for (const [label, v] of votes) {
1264
+ if (v > bestVote || (v === bestVote && label < bestLabel)) {
1265
+ bestVote = v;
1266
+ bestLabel = label;
1267
+ }
1268
+ }
1269
+ if (bestLabel !== '') {
1270
+ const mem = memory.get(listener);
1271
+ mem.set(bestLabel, (mem.get(bestLabel) ?? 0) + 1);
1272
+ }
1273
+ }
1274
+ }
1275
+ // 后处理:阈值过滤 + 归一化 + raw label → c0/c1/... 重命名
1276
+ // raw label 是 node id,对外暴露会泄漏 id 细节且不像社群名;统一映射为 c{idx}
1277
+ const minCount = r * (T + 1);
1278
+ const pending = new Map();
1279
+ for (const id of ids) {
1280
+ const mem = memory.get(id);
1281
+ const keep = [];
1282
+ for (const [label, c] of mem) {
1283
+ if (c >= minCount)
1284
+ keep.push({ rawLabel: label, freq: c });
1285
+ }
1286
+ // 兜底:阈值过严导致全部被剔除时,至少保留频率最高的一个 label
1287
+ if (keep.length === 0) {
1288
+ let bestLabel = id;
1289
+ let bestC = 0;
1290
+ for (const [label, c] of mem) {
1291
+ if (c > bestC) {
1292
+ bestC = c;
1293
+ bestLabel = label;
1294
+ }
1295
+ }
1296
+ keep.push({ rawLabel: bestLabel, freq: Math.max(bestC, 1) });
1297
+ }
1298
+ // 归一化到 weight ∈ (0, 1] 且 Σweight = 1
1299
+ let sum = 0;
1300
+ for (const k of keep)
1301
+ sum += k.freq;
1302
+ if (sum > 0)
1303
+ for (const k of keep)
1304
+ k.freq = k.freq / sum;
1305
+ keep.sort((a, b) => b.freq - a.freq || a.rawLabel.localeCompare(b.rawLabel));
1306
+ pending.set(id, keep);
1307
+ }
1308
+ // raw label → c{idx} 命名:按 node id 字典序首次出现顺序分配,保证稳定
1309
+ const labelRemap = new Map();
1310
+ let counter = 0;
1311
+ const sortedIds = [...ids].sort();
1312
+ for (const id of sortedIds) {
1313
+ for (const m of pending.get(id)) {
1314
+ if (!labelRemap.has(m.rawLabel))
1315
+ labelRemap.set(m.rawLabel, `c${counter++}`);
1316
+ }
1317
+ }
1318
+ const result = new Map();
1319
+ for (const id of ids) {
1320
+ result.set(id, pending.get(id).map(m => ({ id: labelRemap.get(m.rawLabel), weight: m.freq })));
1321
+ }
1322
+ return result;
1323
+ }
1324
+ // ─── 别名簇并查集 + canonical 挑选(consolidate 宽召回 P1 范式) ───
1325
+ /**
1326
+ * 把若干个"语义同一对象"的 pair 按并查集合并成簇。
1327
+ *
1328
+ * 输入:LLM 已判 yes 的 pair 列表({aId, bId})。
1329
+ * 输出:`Map<rootId, Set<memberId>>`;同一 root 下所有 id 在同一簇。
1330
+ *
1331
+ * 自然解决传递闭包:A↔B yes & B↔C yes ⇒ {A,B,C} 一簇。
1332
+ * size<2 的簇(孤立 id)也会出现(如果某 id 只出现在自身),调用方按需过滤。
1333
+ *
1334
+ * 实现:路径压缩 + 按 rank 启发式合并,O(α(n)) 近似常数。
1335
+ */
1336
+ export function clusterEntitiesByPairs(yesPairs) {
1337
+ const parent = new Map();
1338
+ const rank = new Map();
1339
+ const find = (x) => {
1340
+ let cur = x;
1341
+ while ((parent.get(cur) ?? cur) !== cur)
1342
+ cur = parent.get(cur);
1343
+ // 路径压缩
1344
+ let p = x;
1345
+ while ((parent.get(p) ?? p) !== cur) {
1346
+ const next = parent.get(p) ?? p;
1347
+ parent.set(p, cur);
1348
+ p = next;
1349
+ }
1350
+ return cur;
1351
+ };
1352
+ const union = (x, y) => {
1353
+ const rx = find(x);
1354
+ const ry = find(y);
1355
+ if (rx === ry)
1356
+ return;
1357
+ const ra = rank.get(rx) ?? 0;
1358
+ const rb = rank.get(ry) ?? 0;
1359
+ if (ra < rb)
1360
+ parent.set(rx, ry);
1361
+ else if (ra > rb)
1362
+ parent.set(ry, rx);
1363
+ else {
1364
+ parent.set(ry, rx);
1365
+ rank.set(rx, ra + 1);
1366
+ }
1367
+ };
1368
+ for (const p of yesPairs) {
1369
+ if (!parent.has(p.aId))
1370
+ parent.set(p.aId, p.aId);
1371
+ if (!parent.has(p.bId))
1372
+ parent.set(p.bId, p.bId);
1373
+ union(p.aId, p.bId);
1374
+ }
1375
+ const clusters = new Map();
1376
+ for (const id of parent.keys()) {
1377
+ const r = find(id);
1378
+ if (!clusters.has(r))
1379
+ clusters.set(r, new Set());
1380
+ clusters.get(r).add(id);
1381
+ }
1382
+ return clusters;
1383
+ }
1384
+ /**
1385
+ * 在一个簇内挑选 canonical(合并后保留的代表)。
1386
+ *
1387
+ * 打分公式("合并专用",**不复用 compositeScore**,因为后者含 recency 偏向新节点):
1388
+ * `mergeScore = 0.5·weightSum + 0.3·edgeCount + 0.2·evidenceCount`,
1389
+ * 三项各自按"簇内 max"归一化到 [0,1]。
1390
+ *
1391
+ * 语义:信息越丰富(关系密、权值高、evidence 多)越当代表。
1392
+ * 平局规则:分数并列时取 id 字典序最小者(稳定可复现)。
1393
+ *
1394
+ * @param members 簇内成员 id 集合
1395
+ * @param entityById 实体 id → EntityNode 映射(用于查 evidence 数量)
1396
+ * @param edgeStats 实体 id → {weightSum, edgeCount}(由调用方一次扫边表得到)
1397
+ * @returns canonical 的 id;簇为空时返回 ''(调用方应过滤 size<2 簇,不应触发)
1398
+ */
1399
+ export function pickCanonicalByMergeScore(members, entityById, edgeStats) {
1400
+ if (members.size === 0)
1401
+ return '';
1402
+ // 簇内 max 归一化基准
1403
+ let maxW = 0;
1404
+ let maxE = 0;
1405
+ let maxEv = 0;
1406
+ for (const id of members) {
1407
+ const stat = edgeStats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1408
+ const node = entityById.get(id);
1409
+ const evCount = node?.evidence?.length ?? 0;
1410
+ if (stat.weightSum > maxW)
1411
+ maxW = stat.weightSum;
1412
+ if (stat.edgeCount > maxE)
1413
+ maxE = stat.edgeCount;
1414
+ if (evCount > maxEv)
1415
+ maxEv = evCount;
1416
+ }
1417
+ let canonicalId = '';
1418
+ let bestScore = -Infinity;
1419
+ for (const id of members) {
1420
+ const stat = edgeStats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1421
+ const node = entityById.get(id);
1422
+ const evCount = node?.evidence?.length ?? 0;
1423
+ const wN = maxW > 0 ? stat.weightSum / maxW : 0;
1424
+ const eN = maxE > 0 ? stat.edgeCount / maxE : 0;
1425
+ const vN = maxEv > 0 ? evCount / maxEv : 0;
1426
+ const score = wN * 0.5 + eN * 0.3 + vN * 0.2;
1427
+ if (score > bestScore || (score === bestScore && (canonicalId === '' || id < canonicalId))) {
1428
+ bestScore = score;
1429
+ canonicalId = id;
1430
+ }
1431
+ }
1432
+ return canonicalId;
1433
+ }
1434
+ /**
1435
+ * 一次扫边表,为每个 entity 节点聚合 weightSum + edgeCount,供 pickCanonicalByMergeScore 使用。
1436
+ *
1437
+ * 统计范围:所有涉及 entity 的边(entity-entity 两端均计入;person-entity / event-entity 的 entity 端计入)。
1438
+ * Person/event 节点不需要聚合(不会作为合并 canonical 候选)。
1439
+ */
1440
+ export function computeEntityEdgeStats(edges) {
1441
+ const stats = new Map();
1442
+ const bump = (id, w) => {
1443
+ const cur = stats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1444
+ cur.weightSum += w;
1445
+ cur.edgeCount += 1;
1446
+ stats.set(id, cur);
1447
+ };
1448
+ for (const e of edges) {
1449
+ const w = typeof e.weight === 'number' ? e.weight : 0;
1450
+ if (e.kind === 'entity-entity') {
1451
+ bump(e.fromEntityId, w);
1452
+ bump(e.toEntityId, w);
1453
+ }
1454
+ else if (e.kind === 'person-entity') {
1455
+ bump(e.toEntityId, w);
1456
+ }
1457
+ else if (e.kind === 'event-entity') {
1458
+ bump(e.toEntityId, w);
1459
+ }
1460
+ }
1461
+ return stats;
1462
+ }
1463
+ /**
1464
+ * 余弦相似度。两个向量长度必须一致,返回 [-1, 1];任一为 0 向量返回 0。
1465
+ * consolidate event 阶段用于 embedding-based 文本相似度。
1466
+ */
1467
+ export function cosineSimilarity(a, b) {
1468
+ if (!a || !b || a.length === 0 || a.length !== b.length)
1469
+ return 0;
1470
+ let dot = 0;
1471
+ let na = 0;
1472
+ let nb = 0;
1473
+ for (let i = 0; i < a.length; i++) {
1474
+ const x = a[i];
1475
+ const y = b[i];
1476
+ dot += x * y;
1477
+ na += x * x;
1478
+ nb += y * y;
1479
+ }
1480
+ if (na === 0 || nb === 0)
1481
+ return 0;
1482
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
1483
+ }
1484
+ /**
1485
+ * 字符级 Jaccard:以 Unicode 字符(非字节)建集合,返回 |A∩B| / |A∪B|。
1486
+ * 用于无 embedding 服务时的 fallback 文本相似度(对中文友好,比 token 化更稳)。
1487
+ * 自动 normalizeName(去空白/小写/全半角合并),空串返回 0。
1488
+ */
1489
+ export function jaccardChars(a, b) {
1490
+ const sa = normalizeName(a || '');
1491
+ const sb = normalizeName(b || '');
1492
+ if (!sa || !sb)
1493
+ return 0;
1494
+ const setA = new Set(Array.from(sa));
1495
+ const setB = new Set(Array.from(sb));
1496
+ let inter = 0;
1497
+ for (const c of setA)
1498
+ if (setB.has(c))
1499
+ inter++;
1500
+ const uni = setA.size + setB.size - inter;
1501
+ if (uni === 0)
1502
+ return 0;
1503
+ return inter / uni;
1504
+ }
1505
+ /**
1506
+ * 计算两个 event 的字符级 Jaccard。专用于 consolidate 候选评估:
1507
+ * - title 必算(normalize 后通常很短,含义稳定);
1508
+ * - summary 仅在**两边都有**时参与加权:fused = 0.6·titleJac + 0.4·summaryJac;
1509
+ * - 任一方缺 summary → 退化为 titleJac,避免"一方有长 summary、一方空"导致并集被
1510
+ * 单侧 token 拉大、相似度被人为压低(这是历史上同会话同主题事件未被合并的主因)。
1511
+ *
1512
+ * 这里把 title/summary 分开算,比 `jaccardChars('${title} ${summary}', '${title} ${summary}')`
1513
+ * 更稳定:原写法把 title 字符稀释到 summary 池里,标题完全相同时也会被低估。
1514
+ */
1515
+ export function eventPairJaccard(a, b) {
1516
+ const titleJac = jaccardChars(a.title, b.title);
1517
+ const aSum = (a.summary ?? '').trim();
1518
+ const bSum = (b.summary ?? '').trim();
1519
+ if (!aSum || !bSum)
1520
+ return titleJac;
1521
+ const sumJac = jaccardChars(aSum, bSum);
1522
+ return 0.6 * titleJac + 0.4 * sumJac;
1523
+ }
1524
+ /**
1525
+ * 计算 EventNode embedding 的指纹(title + summary 的 sha1 前 16 hex)。
1526
+ * 当节点的 title/summary 发生变化时,hash 自动变 → consolidate 阶段触发重新 embed。
1527
+ * 使用 fnv1a + djb2 组合的 64bit 简化版(避免在浏览器/node 都依赖 crypto)。
1528
+ */
1529
+ export function computeEventEmbeddingHash(title, summary) {
1530
+ const input = `${(title || '').trim()}\n${(summary || '').trim()}`;
1531
+ // fnv1a-64
1532
+ let h1 = 0xcbf29ce484222325n;
1533
+ const prime = 0x100000001b3n;
1534
+ for (let i = 0; i < input.length; i++) {
1535
+ h1 ^= BigInt(input.charCodeAt(i));
1536
+ h1 = (h1 * prime) & 0xffffffffffffffffn;
1537
+ }
1538
+ // djb2-mix as second half for collision robustness
1539
+ let h2 = 5381n;
1540
+ for (let i = 0; i < input.length; i++) {
1541
+ h2 = ((h2 << 5n) + h2 + BigInt(input.charCodeAt(i))) & 0xffffffffffffffffn;
1542
+ }
1543
+ return h1.toString(16).padStart(16, '0') + h2.toString(16).padStart(16, '0');
1544
+ }
1545
+ /**
1546
+ * 计算 EntityNode embedding 的指纹(entityKind + name + summary 的 fnv1a+djb2 32 hex)。
1547
+ * 当 entityKind / name / summary 任一变化时,hash 自动变 → consolidate 阶段触发重新 embed。
1548
+ * entityKind 加入 hash 是有意为之:同名跨 kind 视为不同实体,各自独立 embed。
1549
+ */
1550
+ export function computeEntityEmbeddingHash(name, summary, entityKind) {
1551
+ const input = `${entityKind ?? ''}\n${(name || '').trim()}\n${(summary || '').trim()}`;
1552
+ let h1 = 0xcbf29ce484222325n;
1553
+ const prime = 0x100000001b3n;
1554
+ for (let i = 0; i < input.length; i++) {
1555
+ h1 ^= BigInt(input.charCodeAt(i));
1556
+ h1 = (h1 * prime) & 0xffffffffffffffffn;
1557
+ }
1558
+ let h2 = 5381n;
1559
+ for (let i = 0; i < input.length; i++) {
1560
+ h2 = ((h2 << 5n) + h2 + BigInt(input.charCodeAt(i))) & 0xffffffffffffffffn;
1561
+ }
1562
+ return h1.toString(16).padStart(16, '0') + h2.toString(16).padStart(16, '0');
1563
+ }
1564
+ /**
1565
+ * 一次扫边表,为每个 event 节点聚合 weightSum + edgeCount,供 pickCanonicalForEvents 使用。
1566
+ * 统计范围:所有涉及 event 的边(event-event 两端均计入;person-event / event-entity 的 event 端计入)。
1567
+ */
1568
+ export function computeEventEdgeStats(edges) {
1569
+ const stats = new Map();
1570
+ const bump = (id, w) => {
1571
+ const cur = stats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1572
+ cur.weightSum += w;
1573
+ cur.edgeCount += 1;
1574
+ stats.set(id, cur);
1575
+ };
1576
+ for (const e of edges) {
1577
+ const w = typeof e.weight === 'number' ? e.weight : 0;
1578
+ if (e.kind === 'event-event') {
1579
+ bump(e.fromEventId, w);
1580
+ bump(e.toEventId, w);
1581
+ }
1582
+ else if (e.kind === 'person-event') {
1583
+ bump(e.toEventId, w);
1584
+ }
1585
+ else if (e.kind === 'event-entity') {
1586
+ bump(e.fromEventId, w);
1587
+ }
1588
+ }
1589
+ return stats;
1590
+ }
1591
+ /**
1592
+ * 在一个 event 簇内挑选 canonical(合并后保留的代表)。
1593
+ * 算法与 pickCanonicalByMergeScore 一致:mergeScore = 0.5·weightSum + 0.3·edgeCount + 0.2·evidenceCount。
1594
+ * 平局取 id 字典序最小者。
1595
+ */
1596
+ export function pickCanonicalForEvents(members, eventById, edgeStats) {
1597
+ if (members.size === 0)
1598
+ return '';
1599
+ let maxW = 0;
1600
+ let maxE = 0;
1601
+ let maxEv = 0;
1602
+ for (const id of members) {
1603
+ const stat = edgeStats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1604
+ const node = eventById.get(id);
1605
+ const evCount = node?.evidence?.length ?? 0;
1606
+ if (stat.weightSum > maxW)
1607
+ maxW = stat.weightSum;
1608
+ if (stat.edgeCount > maxE)
1609
+ maxE = stat.edgeCount;
1610
+ if (evCount > maxEv)
1611
+ maxEv = evCount;
1612
+ }
1613
+ let canonicalId = '';
1614
+ let bestScore = -Infinity;
1615
+ for (const id of members) {
1616
+ const stat = edgeStats.get(id) ?? { weightSum: 0, edgeCount: 0 };
1617
+ const node = eventById.get(id);
1618
+ const evCount = node?.evidence?.length ?? 0;
1619
+ const wN = maxW > 0 ? stat.weightSum / maxW : 0;
1620
+ const eN = maxE > 0 ? stat.edgeCount / maxE : 0;
1621
+ const vN = maxEv > 0 ? evCount / maxEv : 0;
1622
+ const score = wN * 0.5 + eN * 0.3 + vN * 0.2;
1623
+ if (score > bestScore || (score === bestScore && (canonicalId === '' || id < canonicalId))) {
1624
+ bestScore = score;
1625
+ canonicalId = id;
1626
+ }
1627
+ }
1628
+ return canonicalId;
1629
+ }
1630
+ //# sourceMappingURL=utils.js.map