recallnest 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -0,0 +1,753 @@
1
+ /**
2
+ * KG 矛盾候选检测(影子期,只读)
3
+ *
4
+ * 在 kg_triples 上按 (scope, subject, predicate) 分组,组内 object 归一化去重后
5
+ * 多于 1 个取值的,报为「候选」。候选不是判决:同一组的多个取值可能是合法多值
6
+ * (同时用好几个工具)、先后变更(负责人换人)或同期互斥(真矛盾),区分它们
7
+ * 靠人工抽样,不靠这里。
8
+ *
9
+ * 不做时间过滤:三元组层没有事实有效期,timestamp / first_seen 只原样附在输出里。
10
+ * 不做同义词 / 语义合并:归一化只处理空白、大小写、全角、首尾标点。
11
+ *
12
+ * 另附对照工具:拿现有线上正则检测(memory-lint 的 findContradictions,原样复用)
13
+ * 在同一 scope 的命中对,与分组候选做双向差异。
14
+ *
15
+ * 可选过滤(默认全关,关着时输出与没有这组功能时逐字节一致):谓词白名单、按数据算出的
16
+ * 函数型谓词判定(functionality = 不同 subject 数 ÷ 不同 (subject, 归一化 object) 对数)、
17
+ * 第二多取值的证据门槛。每一步剩多少组记在 funnel 里。
18
+ *
19
+ * 整个模块只用 query() 读库:不建表、不加列、不建索引、不写 conflict store。
20
+ */
21
+
22
+ import type { KGTriple } from "./kg-store.js";
23
+ import { parseSourceIds, rowToTriple } from "./kg-store.js";
24
+ import { findContradictions } from "./memory-lint.js";
25
+ import { isActiveMemory } from "./memory-evolution.js";
26
+ import { loadLanceDB, type MemoryEntry } from "./store.js";
27
+
28
+ // ============================================================================
29
+ // Types
30
+ // ============================================================================
31
+
32
+ /** 组内一个(归一化后去重的)取值。 */
33
+ export interface KGCandidateValue {
34
+ /** 代表写法:归一化到同一键的原始 object 里 mention_count 最高的那个 */
35
+ object: string;
36
+ /** 归一化键 */
37
+ normalized: string;
38
+ /** 归一化到同一键的全部原始写法(去重,按出现顺序) */
39
+ rawObjects: string[];
40
+ /** 支撑这个取值的不同来源记忆数(多个原始写法合并时取 max(并集大小, 单条最大值)) */
41
+ mention_count: number;
42
+ /** 来源记忆 id 并集 */
43
+ source_memory_ids: string[];
44
+ /** 最早一次抽取(合并时取最小) */
45
+ first_seen: number;
46
+ /** 最近一次抽取(合并时取最大) */
47
+ timestamp: number;
48
+ }
49
+
50
+ /** 一个矛盾候选组:同一 (scope, subject, predicate) 下并存多个取值。 */
51
+ export interface KGContradictionCandidate {
52
+ scope: string;
53
+ subject: string;
54
+ predicate: string;
55
+ /** 按 mention_count 降序(并列按归一化键字典序) */
56
+ values: KGCandidateValue[];
57
+ /** 归一化去重后的取值个数(恒 ≥ 2) */
58
+ distinctValueCount: number;
59
+ /** 第二多的取值的 mention_count——排序主键 */
60
+ secondMentionCount: number;
61
+ }
62
+
63
+ /** 正则检测命中的一对记忆。 */
64
+ export interface RegexHitPair {
65
+ a: string;
66
+ b: string;
67
+ detail?: string;
68
+ }
69
+
70
+ export interface CoveredPair {
71
+ pair: RegexHitPair;
72
+ /** 让这对记忆在同一组里各自贡献不同取值的候选组 */
73
+ groups: Array<{ subject: string; predicate: string }>;
74
+ }
75
+
76
+ export interface NoTriplePair {
77
+ pair: RegexHitPair;
78
+ /** 这对里在本 scope 没有任何三元组的记忆 id(1 或 2 个) */
79
+ memoriesWithoutTriples: string[];
80
+ }
81
+
82
+ export interface BlindSpotPair {
83
+ pair: RegexHitPair;
84
+ aTripleCount: number;
85
+ bTripleCount: number;
86
+ /** 两条记忆都落进、但给的是同一个归一化取值的 (subject, predicate) */
87
+ sharedSameValueGroups: Array<{ subject: string; predicate: string }>;
88
+ }
89
+
90
+ export interface UncaughtCandidate {
91
+ candidate: KGContradictionCandidate;
92
+ /** 候选的来源记忆里,不在正则检测输入集合(本 scope 活跃记忆)里的 id */
93
+ sourcesOutsideRegexInput: string[];
94
+ }
95
+
96
+ export interface RegexVsGroupsComparison {
97
+ regexPairCount: number;
98
+ candidateCount: number;
99
+ /** 两边都有:正则命中且被某个候选组覆盖 */
100
+ regexCovered: CoveredPair[];
101
+ /** 方向一 a:正则命中,至少一条记忆在本 scope 没有三元组(KG 抽取覆盖面缺口) */
102
+ regexOnlyNoTriples: NoTriplePair[];
103
+ /** 方向一 b:正则命中,两条都有三元组但没在同一组里对撞(分组方法盲区) */
104
+ regexOnlyBlindSpot: BlindSpotPair[];
105
+ /** 两边都有:候选组被至少一对正则命中覆盖 */
106
+ candidatesCaught: KGContradictionCandidate[];
107
+ /** 方向二:正则没抓到的候选组 */
108
+ candidatesUncaught: UncaughtCandidate[];
109
+ }
110
+
111
+ // ============================================================================
112
+ // Normalization
113
+ // ============================================================================
114
+
115
+ const EDGE_PUNCT = /^[\p{P}\s]+|[\p{P}\s]+$/gu;
116
+
117
+ /** 全角 ASCII(U+FF01–U+FF5E)转半角,全角空格(U+3000)转普通空格。 */
118
+ function toHalfWidth(s: string): string {
119
+ let out = "";
120
+ for (const ch of s) {
121
+ const code = ch.codePointAt(0)!;
122
+ if (code === 0x3000) out += " ";
123
+ else if (code >= 0xff01 && code <= 0xff5e) out += String.fromCodePoint(code - 0xfee0);
124
+ else out += ch;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * 取值归一化:全角转半角 → 大小写折叠 → 去首尾空白与标点。
131
+ * 不做同义词、语义、内部空白合并。结果为空串表示这个取值没有可比内容。
132
+ */
133
+ export function normalizeKGValue(value: string): string {
134
+ return toHalfWidth(value).toLowerCase().replace(EDGE_PUNCT, "");
135
+ }
136
+
137
+ // ============================================================================
138
+ // Grouping
139
+ // ============================================================================
140
+
141
+ function groupKey(scope: string, subject: string, predicate: string): string {
142
+ return `${scope}\x00${subject}\x00${predicate}`;
143
+ }
144
+
145
+ function compareCandidates(a: KGContradictionCandidate, b: KGContradictionCandidate): number {
146
+ if (b.secondMentionCount !== a.secondMentionCount) return b.secondMentionCount - a.secondMentionCount;
147
+ if (b.distinctValueCount !== a.distinctValueCount) return b.distinctValueCount - a.distinctValueCount;
148
+ return (
149
+ a.scope.localeCompare(b.scope) ||
150
+ a.subject.localeCompare(b.subject) ||
151
+ a.predicate.localeCompare(b.predicate)
152
+ );
153
+ }
154
+
155
+ /**
156
+ * 按 (scope, subject, predicate) 精确分组,组内 object 归一化去重后多于 1 个取值的报为候选。
157
+ * 一遍扫描,O(N)(外加候选组内的排序)。
158
+ *
159
+ * 排序:第二多取值的 mention_count 降序(两边都有多条证据的更可能是真问题),
160
+ * 再按去重后取值个数降序,最后按 scope / subject / predicate 字典序保证稳定。
161
+ */
162
+ export function findKGContradictionCandidates(triples: readonly KGTriple[]): KGContradictionCandidate[] {
163
+ const groups = new Map<string, { scope: string; subject: string; predicate: string; values: Map<string, KGCandidateValue> }>();
164
+
165
+ for (const t of triples) {
166
+ const normalized = normalizeKGValue(t.object ?? "");
167
+ if (normalized === "") continue;
168
+ const key = groupKey(t.scope, t.subject, t.predicate);
169
+ let g = groups.get(key);
170
+ if (!g) {
171
+ g = { scope: t.scope, subject: t.subject, predicate: t.predicate, values: new Map() };
172
+ groups.set(key, g);
173
+ }
174
+ const sources = parseSourceIds(t.source_memory_ids, t.source_memory_id);
175
+ const prev = g.values.get(normalized);
176
+ if (!prev) {
177
+ g.values.set(normalized, {
178
+ object: t.object,
179
+ normalized,
180
+ rawObjects: [t.object],
181
+ mention_count: t.mention_count,
182
+ source_memory_ids: [...new Set(sources)],
183
+ first_seen: t.first_seen,
184
+ timestamp: t.timestamp,
185
+ });
186
+ continue;
187
+ }
188
+ if (!prev.rawObjects.includes(t.object)) prev.rawObjects.push(t.object);
189
+ const maxSingle = Math.max(prev.mention_count, t.mention_count);
190
+ // 代表写法跟着证据最多的那条原始 object 走
191
+ if (t.mention_count > prev.mention_count) prev.object = t.object;
192
+ const union = new Set([...prev.source_memory_ids, ...sources]);
193
+ prev.source_memory_ids = [...union];
194
+ prev.mention_count = Math.max(union.size, maxSingle);
195
+ prev.first_seen = Math.min(prev.first_seen, t.first_seen);
196
+ prev.timestamp = Math.max(prev.timestamp, t.timestamp);
197
+ }
198
+
199
+ const out: KGContradictionCandidate[] = [];
200
+ for (const g of groups.values()) {
201
+ if (g.values.size < 2) continue;
202
+ const values = [...g.values.values()].sort(
203
+ (a, b) => b.mention_count - a.mention_count || a.normalized.localeCompare(b.normalized),
204
+ );
205
+ out.push({
206
+ scope: g.scope,
207
+ subject: g.subject,
208
+ predicate: g.predicate,
209
+ values,
210
+ distinctValueCount: values.length,
211
+ secondMentionCount: values[1].mention_count,
212
+ });
213
+ }
214
+ return out.sort(compareCandidates);
215
+ }
216
+
217
+ // ============================================================================
218
+ // Predicate functionality & candidate filtering
219
+ // ============================================================================
220
+
221
+ /** 一个谓词在本批三元组上的「一个主体一个值」程度。 */
222
+ export interface KGPredicateStat {
223
+ scope: string;
224
+ predicate: string;
225
+ /** 用到这个谓词的不同 subject 数 */
226
+ subjects: number;
227
+ /** 不同 (subject, 归一化 object) 对数 */
228
+ pairs: number;
229
+ /** subjects ÷ pairs,取值 (0, 1],越接近 1 越像「一个主体一个值」 */
230
+ functionality: number;
231
+ /** 有 ≥ 2 个归一化取值的 subject 数 */
232
+ multiValuedSubjects: number;
233
+ }
234
+
235
+ /**
236
+ * 候选过滤选项。全部不给时不过滤。
237
+ * - predicates:谓词白名单,精确匹配(与分组键同一口径,谓词本身不归一化)
238
+ * - functionalMin:只留 functionality ≥ 这个比例的谓词上的候选
239
+ * - functionalMinSubjects:谓词的 subject 数少于它时样本太少判不了,按不满足处理(默认 3,只在给了 functionalMin 时起作用)
240
+ * - minSecondMentions:只留第二多取值的 mention_count ≥ n 的组
241
+ */
242
+ export interface KGCandidateFilterOptions {
243
+ predicates?: readonly string[];
244
+ functionalMin?: number;
245
+ functionalMinSubjects?: number;
246
+ minSecondMentions?: number;
247
+ }
248
+
249
+ export const DEFAULT_FUNCTIONAL_MIN_SUBJECTS = 3;
250
+
251
+ export type KGFunnelStepName = "all" | "predicates" | "functional" | "min-second-mentions";
252
+
253
+ /** 漏斗的一步。skipped 的步骤不过滤,remaining 等于上一步。 */
254
+ export interface KGFunnelStep {
255
+ step: KGFunnelStepName;
256
+ status: "applied" | "skipped";
257
+ /** 这一步的判据(skipped 时为空串) */
258
+ criterion: string;
259
+ /** 过完这一步还剩的组数 */
260
+ remaining: number;
261
+ /** 这一步砍掉的组数 */
262
+ removed: number;
263
+ }
264
+
265
+ export interface KGCandidateFilterResult {
266
+ candidates: KGContradictionCandidate[];
267
+ funnel: KGFunnelStep[];
268
+ predicateStats: KGPredicateStat[];
269
+ }
270
+
271
+ function predicateKey(scope: string, predicate: string): string {
272
+ return `${scope}\x00${predicate}`;
273
+ }
274
+
275
+ /**
276
+ * 对每个 (scope, predicate) 算 functionality = 不同 subject 数 ÷ 不同 (subject, 归一化 object) 对数。
277
+ * object 用 normalizeKGValue 归一化,归一化后相同的取值不重复计对;归一化为空串的取值不计(与分组一致)。
278
+ * 排序:functionality 降序,再按 subjects 降序,最后按 predicate / scope 字典序。
279
+ */
280
+ export function computePredicateStats(triples: readonly KGTriple[]): KGPredicateStat[] {
281
+ const byPredicate = new Map<string, { scope: string; predicate: string; subjects: Map<string, Set<string>> }>();
282
+ for (const t of triples) {
283
+ const normalized = normalizeKGValue(t.object ?? "");
284
+ if (normalized === "") continue;
285
+ const key = predicateKey(t.scope, t.predicate);
286
+ let p = byPredicate.get(key);
287
+ if (!p) {
288
+ p = { scope: t.scope, predicate: t.predicate, subjects: new Map() };
289
+ byPredicate.set(key, p);
290
+ }
291
+ let values = p.subjects.get(t.subject);
292
+ if (!values) {
293
+ values = new Set();
294
+ p.subjects.set(t.subject, values);
295
+ }
296
+ values.add(normalized);
297
+ }
298
+
299
+ const out: KGPredicateStat[] = [];
300
+ for (const p of byPredicate.values()) {
301
+ let pairs = 0;
302
+ let multiValuedSubjects = 0;
303
+ for (const values of p.subjects.values()) {
304
+ pairs += values.size;
305
+ if (values.size > 1) multiValuedSubjects++;
306
+ }
307
+ const subjects = p.subjects.size;
308
+ out.push({ scope: p.scope, predicate: p.predicate, subjects, pairs, functionality: subjects / pairs, multiValuedSubjects });
309
+ }
310
+ return out.sort(
311
+ (a, b) =>
312
+ b.functionality - a.functionality ||
313
+ b.subjects - a.subjects ||
314
+ a.predicate.localeCompare(b.predicate) ||
315
+ a.scope.localeCompare(b.scope),
316
+ );
317
+ }
318
+
319
+ /** 命令行上的原始选项值(commander 给的字符串,没传为 undefined)。 */
320
+ export interface KGCandidateFilterArgs {
321
+ predicates?: string;
322
+ functionalMin?: string;
323
+ functionalMinSubjects?: string;
324
+ minSecondMentions?: string;
325
+ }
326
+
327
+ function parsePositiveInt(value: string, flag: string): number {
328
+ const trimmed = value.trim();
329
+ if (!/^\d+$/.test(trimmed) || Number(trimmed) < 1) throw new Error(`${flag} must be an integer >= 1, got ${JSON.stringify(value)}`);
330
+ return Number(trimmed);
331
+ }
332
+
333
+ /** 校验并转换命令行选项;非法值直接抛错,不静默改成默认值。 */
334
+ export function parseCandidateFilterArgs(args: KGCandidateFilterArgs): KGCandidateFilterOptions {
335
+ const opts: KGCandidateFilterOptions = {};
336
+ if (args.predicates !== undefined) {
337
+ const list = [...new Set(args.predicates.split(",").map((p) => p.trim()).filter((p) => p !== ""))];
338
+ if (list.length === 0) throw new Error("--predicates needs at least one predicate");
339
+ opts.predicates = list;
340
+ }
341
+ if (args.functionalMin !== undefined) {
342
+ const ratio = Number(args.functionalMin.trim());
343
+ if (args.functionalMin.trim() === "" || !Number.isFinite(ratio) || ratio <= 0 || ratio > 1) {
344
+ throw new Error(`--functional-min must be a number in (0, 1], got ${JSON.stringify(args.functionalMin)}`);
345
+ }
346
+ opts.functionalMin = ratio;
347
+ }
348
+ if (args.functionalMinSubjects !== undefined) {
349
+ opts.functionalMinSubjects = parsePositiveInt(args.functionalMinSubjects, "--functional-min-subjects");
350
+ }
351
+ if (args.minSecondMentions !== undefined) {
352
+ opts.minSecondMentions = parsePositiveInt(args.minSecondMentions, "--min-second-mentions");
353
+ }
354
+ return opts;
355
+ }
356
+
357
+ /** 是否给了任何一个过滤选项(决定输出里要不要带 funnel / predicateStats)。 */
358
+ export function hasCandidateFilter(opts: KGCandidateFilterOptions): boolean {
359
+ return (
360
+ opts.predicates !== undefined ||
361
+ opts.functionalMin !== undefined ||
362
+ opts.functionalMinSubjects !== undefined ||
363
+ opts.minSecondMentions !== undefined
364
+ );
365
+ }
366
+
367
+ /**
368
+ * 依次过「谓词白名单 → 函数型判定 → 证据门槛」,每一步记下还剩多少组。
369
+ * `candidates` 与 `triples` 必须是同一批(predicateStats 从 triples 算)。输入顺序保持不变。
370
+ */
371
+ export function filterKGCandidates(
372
+ candidates: readonly KGContradictionCandidate[],
373
+ triples: readonly KGTriple[],
374
+ opts: KGCandidateFilterOptions,
375
+ ): KGCandidateFilterResult {
376
+ const predicateStats = computePredicateStats(triples);
377
+ const statByKey = new Map(predicateStats.map((s) => [predicateKey(s.scope, s.predicate), s]));
378
+
379
+ let current = [...candidates];
380
+ const funnel: KGFunnelStep[] = [{ step: "all", status: "applied", criterion: "", remaining: current.length, removed: 0 }];
381
+ const apply = (step: KGFunnelStepName, filter?: { criterion: string; keep: (c: KGContradictionCandidate) => boolean }) => {
382
+ if (!filter) {
383
+ funnel.push({ step, status: "skipped", criterion: "", remaining: current.length, removed: 0 });
384
+ return;
385
+ }
386
+ const next = current.filter(filter.keep);
387
+ funnel.push({ step, status: "applied", criterion: filter.criterion, remaining: next.length, removed: current.length - next.length });
388
+ current = next;
389
+ };
390
+
391
+ const { predicates, functionalMin, minSecondMentions } = opts;
392
+ const minSubjects = opts.functionalMinSubjects ?? DEFAULT_FUNCTIONAL_MIN_SUBJECTS;
393
+
394
+ if (predicates !== undefined) {
395
+ const whitelist = new Set(predicates);
396
+ apply("predicates", { criterion: `predicate in {${[...whitelist].join(", ")}}`, keep: (c) => whitelist.has(c.predicate) });
397
+ } else {
398
+ apply("predicates");
399
+ }
400
+
401
+ if (functionalMin !== undefined) {
402
+ apply("functional", {
403
+ criterion: `functionality >= ${functionalMin} and subjects >= ${minSubjects}`,
404
+ keep: (c) => {
405
+ const s = statByKey.get(predicateKey(c.scope, c.predicate));
406
+ return s !== undefined && s.subjects >= minSubjects && s.functionality >= functionalMin;
407
+ },
408
+ });
409
+ } else {
410
+ apply("functional");
411
+ }
412
+
413
+ if (minSecondMentions !== undefined) {
414
+ apply("min-second-mentions", {
415
+ criterion: `secondMentionCount >= ${minSecondMentions}`,
416
+ keep: (c) => c.secondMentionCount >= minSecondMentions,
417
+ });
418
+ } else {
419
+ apply("min-second-mentions");
420
+ }
421
+
422
+ return { candidates: current, funnel, predicateStats };
423
+ }
424
+
425
+ // ============================================================================
426
+ // Comparison with the existing regex detector
427
+ // ============================================================================
428
+
429
+ /**
430
+ * 线上正则检测的命中对:原样调用 memory-lint 的 findContradictions(类别过滤、
431
+ * scope+category 分组、组内按 importance 取前 100、向量相似度下限、否定词对 + 共享长词)。
432
+ * entries 必须带真实向量,否则相似度恒为 0、一对都出不来。
433
+ */
434
+ export function regexHitPairs(entries: MemoryEntry[]): RegexHitPair[] {
435
+ return findContradictions(entries)
436
+ .filter((f) => f.memoryIds.length === 2)
437
+ .map((f) => ({ a: f.memoryIds[0], b: f.memoryIds[1], detail: f.detail }));
438
+ }
439
+
440
+ /** 候选组里 a、b 是否各自贡献了不同的取值。 */
441
+ function candidateCoversPair(c: KGContradictionCandidate, a: string, b: string): boolean {
442
+ for (let i = 0; i < c.values.length; i++) {
443
+ if (!c.values[i].source_memory_ids.includes(a)) continue;
444
+ for (let j = 0; j < c.values.length; j++) {
445
+ if (j !== i && c.values[j].source_memory_ids.includes(b)) return true;
446
+ }
447
+ }
448
+ return false;
449
+ }
450
+
451
+ /**
452
+ * 双向对照。「覆盖」= 两条记忆在同一个 (S,P) 候选组里各自贡献了不同的取值。
453
+ *
454
+ * - 方向一(正则有、分组没覆盖)拆两类:有记忆在 `triples` 里没有三元组的(抽取覆盖面缺口),
455
+ * 和两条都有三元组却没在同一组对撞的(分组方法盲区)。
456
+ * - 方向二(分组有、正则没抓到):任何一对正则命中都没覆盖的候选组。
457
+ *
458
+ * `triples` 应是与候选同一批的全部三元组(含单值组),用来判断一条记忆有没有三元组。
459
+ * `regexInputIds` 是正则检测实际看过的记忆 id,用来标出候选里正则根本没机会看到的来源。
460
+ */
461
+ export function compareRegexWithCandidates(
462
+ pairs: readonly RegexHitPair[],
463
+ candidates: readonly KGContradictionCandidate[],
464
+ triples: readonly KGTriple[],
465
+ regexInputIds?: ReadonlySet<string>,
466
+ ): RegexVsGroupsComparison {
467
+ // 每条记忆贡献过的 (S,P) → 归一化取值集合
468
+ const byMemory = new Map<string, Map<string, { subject: string; predicate: string; values: Set<string> }>>();
469
+ const tripleCount = new Map<string, number>();
470
+ for (const t of triples) {
471
+ const normalized = normalizeKGValue(t.object ?? "");
472
+ for (const mid of parseSourceIds(t.source_memory_ids, t.source_memory_id)) {
473
+ tripleCount.set(mid, (tripleCount.get(mid) ?? 0) + 1);
474
+ if (normalized === "") continue;
475
+ let sp = byMemory.get(mid);
476
+ if (!sp) {
477
+ sp = new Map();
478
+ byMemory.set(mid, sp);
479
+ }
480
+ const key = groupKey(t.scope, t.subject, t.predicate);
481
+ const entry = sp.get(key) ?? { subject: t.subject, predicate: t.predicate, values: new Set<string>() };
482
+ entry.values.add(normalized);
483
+ sp.set(key, entry);
484
+ }
485
+ }
486
+
487
+ const regexCovered: CoveredPair[] = [];
488
+ const regexOnlyNoTriples: NoTriplePair[] = [];
489
+ const regexOnlyBlindSpot: BlindSpotPair[] = [];
490
+ const caught = new Set<KGContradictionCandidate>();
491
+
492
+ for (const pair of pairs) {
493
+ const covering = candidates.filter((c) => candidateCoversPair(c, pair.a, pair.b));
494
+ if (covering.length > 0) {
495
+ for (const c of covering) caught.add(c);
496
+ regexCovered.push({ pair, groups: covering.map((c) => ({ subject: c.subject, predicate: c.predicate })) });
497
+ continue;
498
+ }
499
+ const missing = [pair.a, pair.b].filter((id) => (tripleCount.get(id) ?? 0) === 0);
500
+ if (missing.length > 0) {
501
+ regexOnlyNoTriples.push({ pair, memoriesWithoutTriples: missing });
502
+ continue;
503
+ }
504
+ const spA = byMemory.get(pair.a) ?? new Map();
505
+ const spB = byMemory.get(pair.b);
506
+ const sharedSameValueGroups: Array<{ subject: string; predicate: string }> = [];
507
+ if (spB) {
508
+ for (const [key, ea] of spA) {
509
+ const eb = spB.get(key);
510
+ if (!eb) continue;
511
+ if ([...ea.values].some((v) => eb.values.has(v))) {
512
+ sharedSameValueGroups.push({ subject: ea.subject, predicate: ea.predicate });
513
+ }
514
+ }
515
+ }
516
+ regexOnlyBlindSpot.push({
517
+ pair,
518
+ aTripleCount: tripleCount.get(pair.a) ?? 0,
519
+ bTripleCount: tripleCount.get(pair.b) ?? 0,
520
+ sharedSameValueGroups,
521
+ });
522
+ }
523
+
524
+ const candidatesCaught: KGContradictionCandidate[] = [];
525
+ const candidatesUncaught: UncaughtCandidate[] = [];
526
+ for (const c of candidates) {
527
+ if (caught.has(c)) {
528
+ candidatesCaught.push(c);
529
+ continue;
530
+ }
531
+ const sources = new Set(c.values.flatMap((v) => v.source_memory_ids));
532
+ candidatesUncaught.push({
533
+ candidate: c,
534
+ sourcesOutsideRegexInput: regexInputIds ? [...sources].filter((id) => !regexInputIds.has(id)) : [],
535
+ });
536
+ }
537
+
538
+ return {
539
+ regexPairCount: pairs.length,
540
+ candidateCount: candidates.length,
541
+ regexCovered,
542
+ regexOnlyNoTriples,
543
+ regexOnlyBlindSpot,
544
+ candidatesCaught,
545
+ candidatesUncaught,
546
+ };
547
+ }
548
+
549
+ // ============================================================================
550
+ // Sampling
551
+ // ============================================================================
552
+
553
+ /** 确定性抽样(mulberry32 + Fisher–Yates),同一 seed 同一输入得到同一批。 */
554
+ export function sampleCandidates<T>(items: readonly T[], n: number, seed: number): T[] {
555
+ let state = seed >>> 0;
556
+ const rand = () => {
557
+ state = (state + 0x6d2b79f5) >>> 0;
558
+ let t = state;
559
+ t = Math.imul(t ^ (t >>> 15), t | 1);
560
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
561
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
562
+ };
563
+ const arr = [...items];
564
+ for (let i = arr.length - 1; i > 0; i--) {
565
+ const j = Math.floor(rand() * (i + 1));
566
+ [arr[i], arr[j]] = [arr[j], arr[i]];
567
+ }
568
+ return arr.slice(0, Math.max(0, n));
569
+ }
570
+
571
+ // ============================================================================
572
+ // Read-only loaders
573
+ // ============================================================================
574
+
575
+ const KG_TABLE_NAME = "kg_triples";
576
+ const MEMORY_TABLE_NAME = "memories";
577
+ /** 与 runMemoryLint 的扫描上限一致 */
578
+ const LINT_SCAN_LIMIT = 10000;
579
+
580
+ function sqlLiteral(value: string): string {
581
+ return `'${value.replace(/'/g, "''")}'`;
582
+ }
583
+
584
+ /** 读出某个 scope(精确匹配)的全部三元组。表不存在返回空,不建表。 */
585
+ export async function loadScopeTriples(dbPath: string, scope: string): Promise<KGTriple[]> {
586
+ const lancedb = await loadLanceDB();
587
+ const db = await lancedb.connect(dbPath);
588
+ const names = await db.tableNames();
589
+ if (!names.includes(KG_TABLE_NAME)) return [];
590
+ const table = await db.openTable(KG_TABLE_NAME);
591
+ const rows = await table.query().where(`scope = ${sqlLiteral(scope)}`).toArray();
592
+ return (rows as Array<Record<string, unknown>>)
593
+ .map(rowToTriple)
594
+ .filter((t) => t.id !== "__schema__");
595
+ }
596
+
597
+ export interface RegexInputLoad {
598
+ entries: MemoryEntry[];
599
+ /** 与 runMemoryLint 相同:按时间取最新 10000 条后才过滤活跃,触顶即为 true */
600
+ scanLimited: boolean;
601
+ }
602
+
603
+ /**
604
+ * 读出正则检测的输入:本 scope(精确匹配)的记忆,按 runMemoryLint 的口径取最新 10000 条、
605
+ * 只留活跃记忆、带真实向量。表不存在返回空,不建表。
606
+ */
607
+ export async function loadRegexInput(dbPath: string, scope: string): Promise<RegexInputLoad> {
608
+ const lancedb = await loadLanceDB();
609
+ const db = await lancedb.connect(dbPath);
610
+ const names = await db.tableNames();
611
+ if (!names.includes(MEMORY_TABLE_NAME)) return { entries: [], scanLimited: false };
612
+ const table = await db.openTable(MEMORY_TABLE_NAME);
613
+ const rows = await table
614
+ .query()
615
+ .where(`scope = ${sqlLiteral(scope)}`)
616
+ .select(["id", "text", "category", "scope", "importance", "timestamp", "metadata", "vector"])
617
+ .toArray();
618
+ const all: MemoryEntry[] = (rows as Array<Record<string, unknown>>)
619
+ .map((row): MemoryEntry => ({
620
+ id: String(row.id),
621
+ text: String(row.text ?? ""),
622
+ vector: row.vector ? Array.from(row.vector as Iterable<number>) : [],
623
+ category: row.category as MemoryEntry["category"],
624
+ scope: String(row.scope ?? ""),
625
+ importance: Number(row.importance),
626
+ timestamp: Number(row.timestamp),
627
+ metadata: typeof row.metadata === "string" && row.metadata ? row.metadata : "{}",
628
+ }))
629
+ .filter((e) => e.id !== "__schema__")
630
+ .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
631
+ const scanLimited = all.length >= LINT_SCAN_LIMIT;
632
+ const entries = all.slice(0, LINT_SCAN_LIMIT).filter((e) => isActiveMemory(e.metadata));
633
+ return { entries, scanLimited };
634
+ }
635
+
636
+ // ============================================================================
637
+ // Formatting
638
+ // ============================================================================
639
+
640
+ function fmtDate(ms: number): string {
641
+ return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString().slice(0, 10) : "?";
642
+ }
643
+
644
+ function shortIds(ids: readonly string[], max = 4): string {
645
+ const shown = ids.slice(0, max).map((id) => id.slice(0, 8)).join(",");
646
+ return ids.length > max ? `${shown},+${ids.length - max}` : shown;
647
+ }
648
+
649
+ export function formatCandidate(c: KGContradictionCandidate, index: number): string {
650
+ const lines = [`${index}. [${c.subject}] —${c.predicate}→ ${c.distinctValueCount} values (2nd mentions=${c.secondMentionCount})`];
651
+ for (const v of c.values) {
652
+ const raw = v.rawObjects.length > 1 ? ` (raw: ${v.rawObjects.map((r) => JSON.stringify(r)).join(" | ")})` : "";
653
+ lines.push(
654
+ ` - ${JSON.stringify(v.object)}${raw} mentions=${v.mention_count} first_seen=${fmtDate(v.first_seen)} last=${fmtDate(v.timestamp)} src=${shortIds(v.source_memory_ids)}`,
655
+ );
656
+ }
657
+ return lines.join("\n");
658
+ }
659
+
660
+ const FUNNEL_LABELS: Record<KGFunnelStepName, string> = {
661
+ all: "all candidate groups",
662
+ predicates: "predicate whitelist",
663
+ functional: "functional predicates",
664
+ "min-second-mentions": "min 2nd-value mentions",
665
+ };
666
+
667
+ /** 漏斗的文本行:每一步还剩多少组、砍掉多少、判据;没启用的标 skipped。 */
668
+ export function formatFunnel(funnel: readonly KGFunnelStep[]): string[] {
669
+ const lines = ["funnel (same triples, steps applied in order):"];
670
+ for (const f of funnel) {
671
+ const label = FUNNEL_LABELS[f.step].padEnd(24);
672
+ if (f.status === "skipped") lines.push(` ${label}skipped`);
673
+ else if (f.step === "all") lines.push(` ${label}${f.remaining}`);
674
+ else lines.push(` ${label}${f.remaining} (-${f.removed}) ${f.criterion}`);
675
+ }
676
+ return lines;
677
+ }
678
+
679
+ /** `funnel` 只在用了过滤选项时传;不传时输出与没有过滤功能时逐字节一致。 */
680
+ export function formatCandidateReport(
681
+ scope: string,
682
+ tripleCount: number,
683
+ total: number,
684
+ shown: readonly KGContradictionCandidate[],
685
+ mode: string,
686
+ funnel?: readonly KGFunnelStep[],
687
+ ): string {
688
+ const lines = [
689
+ `KG contradiction candidates — scope=${scope}`,
690
+ `候选,不是判决:同组多值可能是合法多值、先后变更或真矛盾,需人工判断。未做时间过滤。`,
691
+ `triples=${tripleCount} candidate groups=${total} showing=${shown.length} (${mode})`,
692
+ ...(funnel ? formatFunnel(funnel) : []),
693
+ "",
694
+ ];
695
+ shown.forEach((c, i) => lines.push(formatCandidate(c, i + 1)));
696
+ return lines.join("\n");
697
+ }
698
+
699
+ function pairLine(p: RegexHitPair): string {
700
+ return `${p.a.slice(0, 8)} × ${p.b.slice(0, 8)}${p.detail ? ` ${p.detail}` : ""}`;
701
+ }
702
+
703
+ function capped<T>(items: readonly T[], limit: number): { items: readonly T[]; more: number } {
704
+ if (limit <= 0 || items.length <= limit) return { items, more: 0 };
705
+ return { items: items.slice(0, limit), more: items.length - limit };
706
+ }
707
+
708
+ export function formatComparisonReport(
709
+ scope: string,
710
+ meta: { tripleCount: number; regexInputCount: number; scanLimited: boolean },
711
+ cmp: RegexVsGroupsComparison,
712
+ limit: number,
713
+ funnel?: readonly KGFunnelStep[],
714
+ ): string {
715
+ const lines: string[] = [
716
+ `Regex vs KG-group candidates — scope=${scope}`,
717
+ `regex input: ${meta.regexInputCount} active memories${meta.scanLimited ? " (hit lint scan cap 10000)" : ""}; triples: ${meta.tripleCount}`,
718
+ `regex pairs=${cmp.regexPairCount} candidate groups=${cmp.candidateCount}`,
719
+ ` both (regex pair covered by a group): ${cmp.regexCovered.length} pairs / ${cmp.candidatesCaught.length} groups`,
720
+ ` direction 1 — regex only: ${cmp.regexOnlyNoTriples.length + cmp.regexOnlyBlindSpot.length}` +
721
+ ` (no triples: ${cmp.regexOnlyNoTriples.length}, method blind spot: ${cmp.regexOnlyBlindSpot.length})`,
722
+ ` direction 2 — group candidates regex missed: ${cmp.candidatesUncaught.length}`,
723
+ ...(funnel ? formatFunnel(funnel) : []),
724
+ "",
725
+ ];
726
+
727
+ const section = <T>(title: string, items: readonly T[], render: (item: T, i: number) => string) => {
728
+ lines.push(`## ${title} (${items.length})`);
729
+ const { items: shown, more } = capped(items, limit);
730
+ shown.forEach((item, i) => lines.push(render(item, i + 1)));
731
+ if (more > 0) lines.push(` … ${more} more (raise --limit or use --json)`);
732
+ lines.push("");
733
+ };
734
+
735
+ section("Direction 1a — regex only, memory has no triples (KG extraction gap)", cmp.regexOnlyNoTriples, (x, i) =>
736
+ `${i}. ${pairLine(x.pair)}\n without triples: ${shortIds(x.memoriesWithoutTriples)}`,
737
+ );
738
+ section("Direction 1b — regex only, both have triples but no collision (grouping blind spot)", cmp.regexOnlyBlindSpot, (x, i) => {
739
+ const shared = x.sharedSameValueGroups.map((g) => `[${g.subject}]—${g.predicate}`).join("; ");
740
+ return `${i}. ${pairLine(x.pair)}\n triples: ${x.aTripleCount} / ${x.bTripleCount}${shared ? ` same value in: ${shared}` : ""}`;
741
+ });
742
+ section("Direction 2 — group candidates the regex missed", cmp.candidatesUncaught, (x, i) => {
743
+ const outside = x.sourcesOutsideRegexInput.length > 0
744
+ ? `\n sources outside regex input (inactive/unscanned): ${shortIds(x.sourcesOutsideRegexInput)}`
745
+ : "";
746
+ return `${formatCandidate(x.candidate, i)}${outside}`;
747
+ });
748
+ section("Both — regex pairs covered by a group", cmp.regexCovered, (x, i) =>
749
+ `${i}. ${pairLine(x.pair)}\n groups: ${x.groups.map((g) => `[${g.subject}]—${g.predicate}`).join("; ")}`,
750
+ );
751
+
752
+ return lines.join("\n");
753
+ }