graphitect 0.2.0__py3-none-any.whl

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 (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/dedup.py ADDED
@@ -0,0 +1,1213 @@
1
+ """Entity deduplication pipeline for graphify knowledge graphs.
2
+
3
+ Pipeline: exact normalization → entropy gate → MinHash/LSH blocking →
4
+ Jaro-Winkler verification → same-community boost → union-find merge.
5
+ """
6
+ from __future__ import annotations
7
+ import math
8
+ import re
9
+ import sys
10
+ import unicodedata
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+
14
+ from graphify._minhash import MinHash, MinHashLSH
15
+ from rapidfuzz.distance import DamerauLevenshtein, Jaro, JaroWinkler
16
+
17
+
18
+ # ── helpers ───────────────────────────────────────────────────────────────────
19
+
20
+ def _norm(label: str | None) -> str:
21
+ """Lowercase + collapse non-alphanumeric runs to space (Unicode-aware)."""
22
+ if not isinstance(label, str):
23
+ label = "" if label is None else str(label)
24
+ label = unicodedata.normalize("NFKC", label)
25
+ return re.sub(r"[\W_]+", " ", label.casefold(), flags=re.UNICODE).strip()
26
+
27
+
28
+ def _entropy(label: str) -> float:
29
+ """Shannon entropy in bits/char of the normalised label."""
30
+ s = _norm(label)
31
+ if not s:
32
+ return 0.0
33
+ freq: dict[str, int] = defaultdict(int)
34
+ for ch in s:
35
+ freq[ch] += 1
36
+ n = len(s)
37
+ return -sum((c / n) * math.log2(c / n) for c in freq.values())
38
+
39
+
40
+ def _shingles(text: str, k: int = 3) -> set[str]:
41
+ """Return k-gram character shingles of text."""
42
+ if len(text) < k:
43
+ return {text}
44
+ return {text[i : i + k] for i in range(len(text) - k + 1)}
45
+
46
+
47
+ def _make_minhash(text: str, num_perm: int = 128) -> MinHash:
48
+ # Strip spaces so "graph extractor" and "graphextractor" share shingles
49
+ m = MinHash(num_perm=num_perm)
50
+ for shingle in _shingles(text.replace(" ", "")):
51
+ m.update(shingle.encode("utf-8"))
52
+ return m
53
+
54
+
55
+ # Matches labels whose trailing token is a version/variant suffix:
56
+ # digits optionally followed by letters (chip SKUs: ASR1603, M1, Cortex-A55)
57
+ # or 2+ letters (codename revisions: cranelr vs cranel).
58
+ # Requires the stem to end in a letter so plain words don't accidentally match.
59
+ _VARIANT_SUFFIX = re.compile(r"^(.*[a-z])([0-9]+[a-z]*|[a-z]{2,})$")
60
+
61
+
62
+ def _is_variant_pair(a: str, b: str) -> bool:
63
+ """True if a and b are sibling model/SKU variants (same stem, different suffix).
64
+
65
+ Only applied to short labels (< 12 chars); long labels go through JW normally.
66
+ """
67
+ if a == b:
68
+ return False
69
+ if max(len(a), len(b)) >= 12:
70
+ return False
71
+ ma, mb = _VARIANT_SUFFIX.match(a), _VARIANT_SUFFIX.match(b)
72
+ if not (ma and mb):
73
+ return False
74
+ return ma.group(1) == mb.group(1) and ma.group(2) != mb.group(2)
75
+
76
+
77
+ def _short_label_blocked(a: str, b: str, jw_score: float) -> bool:
78
+ """Block fuzzy merge for short labels unless it's a same-length single-char substitution.
79
+
80
+ Insertions/deletions on short strings (cranel/cranelr, M1/M1 Pro) produce
81
+ high Jaro-Winkler scores due to the prefix bonus but are almost never true
82
+ duplicates — they're abbreviations or variants.
83
+ """
84
+ if max(len(a), len(b)) >= 12:
85
+ return False
86
+ from rapidfuzz.distance import DamerauLevenshtein
87
+ # Allow only same-length single-char substitutions (true typos like "Extractor"/"Extractar").
88
+ # Block length-differing pairs regardless of score.
89
+ if jw_score >= 97.0 and len(a) == len(b) and DamerauLevenshtein.distance(a, b) <= 1:
90
+ return False
91
+ return True
92
+
93
+
94
+ _DIGIT_RUN = re.compile(r"\d+")
95
+
96
+
97
+ def _numeric_tokens_differ(a: str, b: str) -> bool:
98
+ """True when two labels carry different embedded numbers (#1284).
99
+
100
+ Long labels that differ only in their digit runs ("ADR 0011 §D5" vs
101
+ "ADR 0013 D4", "3.1 Product Goals" vs "1.1 Product Goals", "block3" vs
102
+ "block13", "40%+ retention" vs "<20% retention") are numbered/versioned
103
+ siblings, not duplicates -- but the long shared boilerplate keeps
104
+ Jaro-Winkler above _MERGE_THRESHOLD, and _is_variant_pair only covers
105
+ short trailing suffixes. Digit runs are compared as multisets with
106
+ leading zeros stripped, so zero-padding ("09" vs "9") does not count as
107
+ a difference. (String comparison, not int(): a pathological label with a
108
+ >4300-digit run would crash int() on Python's conversion limit.) Labels
109
+ with identical numbers, or none at all, are unaffected.
110
+ """
111
+ if a == b:
112
+ return False
113
+ return sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(a)) != \
114
+ sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(b))
115
+
116
+
117
+ # Function words. A restatement of one entity is what inserts or swaps these
118
+ # ("export a read-only ..." vs "export the read-only ..."); a content word
119
+ # carries the entity's identity and swapping one names something else.
120
+ _STOPWORDS = frozenset({
121
+ "a", "an", "the", "and", "or", "of", "for", "to", "in", "on", "at", "by",
122
+ "with", "from", "as", "is", "are", "be", "this", "that", "its",
123
+ })
124
+
125
+
126
+ def _same_word_variant(x: str, y: str) -> bool:
127
+ """True when tokens x and y read as one word misspelt, not two words (#2576).
128
+
129
+ A same-length pair within one substitution/transposition is a typo
130
+ ("manager"/"nanager") -- the same rationale _short_label_blocked applies
131
+ to whole short labels, and unlike Jaro-Winkler it holds at position 0,
132
+ where the prefix bonus gives no help (JW scores "manager"/"nanager" at
133
+ 84.92, below threshold, yet it is as much a typo as "managr"). Below 6
134
+ chars JW cannot separate two words from a typo ("pane"/"plane" scores
135
+ 94.0), so short length-differing pairs never read as variants. Longer
136
+ pairs fall back to Jaro-Winkler on the merge threshold, so
137
+ "manager"/"managr" (97.14) still reads as one word. Accepted trade, per
138
+ the never-merge-two-distinct-entities bar: "colour"/"color" (5 chars,
139
+ lengths differ) now reads as two words and stays unmerged -- a spelling
140
+ variant kept separate beats a fabricated merge.
141
+ """
142
+ if len(x) == len(y) and DamerauLevenshtein.distance(x, y) <= 1:
143
+ return True # same-length 1-sub/transposition = typo, even at position 0
144
+ if min(len(x), len(y)) < 6:
145
+ return False # short tokens: JW can't separate pane/plane (94.0) from a typo
146
+ return JaroWinkler.normalized_similarity(x, y) * 100 >= _MERGE_THRESHOLD
147
+
148
+
149
+ def _content_token_swap(a: str, b: str) -> bool:
150
+ """True when two equal-token-count labels differ in at least one swapped
151
+ content word rather than only typos or function words (#2576, adopted
152
+ from @wilyan09007's PR #2587 and generalized from exactly-one to any
153
+ number of differing positions).
154
+
155
+ Whole-string scoring cannot separate a legit restatement from a
156
+ distinguishing-token swap: both edit one short run in the middle of a long
157
+ shared string, so both land in the same Jaro band (#1243). Which token
158
+ differs does separate them. Structured prose names sibling sections from a
159
+ template ("Asset Contribution Flow" / "Asset Consumption Flow", four
160
+ consecutive headings of one operations doc), and those siblings are densest
161
+ inside a single file -- exactly where Jaro-Winkler's prefix bonus still
162
+ applies, and where the shared affixes it rewards are boilerplate.
163
+
164
+ Each same-position differing pair is judged on its own: a function word on
165
+ either side is what a restatement swaps, a _same_word_variant pair is one
166
+ word misspelt, and anything else is a distinct content word naming a
167
+ different entity -- one such pair blocks the merge. A restatement differs
168
+ only in stopwords/typos at every position; a template sibling differs in
169
+ at least one distinct content word ("... Contribution Flow Handler" vs
170
+ "... Consumption Flows Handler" blocks on either position). Pairs with
171
+ different token counts are left to the prefix-extension guard (#1201) and
172
+ whole-label scoring. Known gap, out of scope here: fused camelCase labels
173
+ ("AssetContributionFlow" vs "AssetConsumptionFlow") normalize to single
174
+ tokens whose only differing "position" is the whole label, so this guard
175
+ reduces to whole-token _same_word_variant and long fused pairs can still
176
+ clear the JW fallback.
177
+ """
178
+ tokens_a, tokens_b = a.split(), b.split()
179
+ if len(tokens_a) != len(tokens_b):
180
+ return False
181
+ for x, y in zip(tokens_a, tokens_b):
182
+ if x == y:
183
+ continue
184
+ if x in _STOPWORDS or y in _STOPWORDS:
185
+ continue # restatement: a function word swapped in or out
186
+ if _same_word_variant(x, y):
187
+ continue # one word misspelt/inflected, not a different word
188
+ return True
189
+ return False
190
+
191
+
192
+ # file_type values whose identity is anchored to their source location, not
193
+ # their label text. Like code (#1205), these must not be label-merged across
194
+ # files: rationale = module/class docstrings, document = headings/positional
195
+ # content. `concept` is intentionally excluded -- it is the type meant to unify
196
+ # across files (protected from over-merge by the numeric/Jaro guards instead).
197
+ _FILE_ANCHORED_NONCODE = frozenset({"rationale", "document"})
198
+
199
+
200
+ def _crossfile_fileanchored_blocked(node: dict, neighbor: dict) -> bool:
201
+ """Block label-based merging of file-anchored non-code nodes across files (#1284).
202
+
203
+ rationale/document nodes are docstring- and heading-derived and as
204
+ file-anchored as the code they describe (#1205's reasoning, one layer up):
205
+ parallel modules carry near-identical boilerplate ("Django app config for
206
+ apps.<name>. No business logic here...") that differs by one word and sails
207
+ past the JW threshold. Same-file duplicates of these types may still merge.
208
+ """
209
+ if (node.get("file_type") not in _FILE_ANCHORED_NONCODE
210
+ and neighbor.get("file_type") not in _FILE_ANCHORED_NONCODE):
211
+ return False
212
+ return (node.get("source_file") or "") != (neighbor.get("source_file") or "")
213
+
214
+
215
+ # ── union-find ────────────────────────────────────────────────────────────────
216
+
217
+ class _UF:
218
+ def __init__(self) -> None:
219
+ self._parent: dict[str, str] = {}
220
+
221
+ def find(self, x: str) -> str:
222
+ self._parent.setdefault(x, x)
223
+ while self._parent[x] != x:
224
+ self._parent[x] = self._parent[self._parent[x]]
225
+ x = self._parent[x]
226
+ return x
227
+
228
+ def union(self, x: str, y: str) -> None:
229
+ self._parent.setdefault(x, x)
230
+ self._parent.setdefault(y, y)
231
+ rx, ry = self.find(x), self.find(y)
232
+ if rx != ry:
233
+ self._parent[ry] = rx
234
+
235
+ def components(self) -> dict[str, list[str]]:
236
+ groups: dict[str, list[str]] = defaultdict(list)
237
+ for x in self._parent:
238
+ groups[self.find(x)].append(x)
239
+ return dict(groups)
240
+
241
+
242
+ # ── constants ─────────────────────────────────────────────────────────────────
243
+
244
+ _ENTROPY_THRESHOLD = 2.5
245
+ _LSH_THRESHOLD = 0.7
246
+ _MERGE_THRESHOLD = 92.0 # rapidfuzz normalized_similarity * 100
247
+ _COMMUNITY_BOOST = 5.0 # score bonus when both nodes share community
248
+ _NUM_PERM = 128
249
+ _CHUNK_SUFFIX = re.compile(r"_c\d+$")
250
+
251
+
252
+ def _is_code(node: dict) -> bool:
253
+ """True for AST-extracted code symbols.
254
+
255
+ Code-node identity is the node ID (which already encodes the fully
256
+ qualified path: module/class/symbol). The label is only a display name
257
+ (e.g. a bare ``.draw()`` method name, or a function name shared by two
258
+ parallel backends), so label-based merging conflates distinct symbols
259
+ (#1205). Genuine duplicates — the same symbol re-extracted — share an ID
260
+ and are already collapsed by the exact-ID ``seen_ids`` pre-dedup above,
261
+ so code never needs label-based merging.
262
+ """
263
+ return node.get("file_type") == "code"
264
+
265
+
266
+ # ── ID collisions ─────────────────────────────────────────────────────────────
267
+
268
+ _ID_SEGMENT = re.compile(r"[^a-z0-9]+")
269
+ _EXTENSION = re.compile(r"\.[^./]+$")
270
+
271
+
272
+ def _id_prefixes(source_file: str) -> set[str]:
273
+ """The ID prefixes a node extracted from ``source_file`` may legitimately mint.
274
+
275
+ An ID is ``<path>_<entity>``, where the path is the extension-stripped source
276
+ path, each segment slugified and joined with ``_``. Every trailing slice of the
277
+ path counts as a prefix: the stored path may be absolute or repo-relative, and
278
+ graphs built under the pre-#1504 scheme keyed off the bare filename stem.
279
+ """
280
+ stem = _EXTENSION.sub("", source_file.replace("\\", "/"))
281
+ segments = [s for s in (_ID_SEGMENT.sub("_", p.casefold()).strip("_")
282
+ for p in stem.split("/")) if s]
283
+ return {"_".join(segments[i:]) for i in range(len(segments))}
284
+
285
+
286
+ def _defines_id(node: dict) -> bool:
287
+ """True when the node's own source_file is the file its ID encodes.
288
+
289
+ A doc that *references* an entity mints the ID of the entity's own file, not one
290
+ derived from the doc's path — so the referencing node collides with the defining
291
+ node by construction. This separates the two: the definer owns the ID.
292
+ """
293
+ nid = node.get("id") or ""
294
+ source_file = node.get("source_file") or ""
295
+ if not nid or not source_file:
296
+ return False
297
+ # `nid == prefix` covers a bare file-level node whose id is exactly the
298
+ # slugified path with no `_entity` suffix (a semantic node for the file
299
+ # itself); `startswith(prefix + "_")` covers the usual `<path>_<entity>` id.
300
+ return any(nid == prefix or nid.startswith(f"{prefix}_")
301
+ for prefix in _id_prefixes(source_file))
302
+
303
+
304
+ # `node_kind` values marking a node that is STRUCTURE of its source file rather
305
+ # than an entity mentioned inside it: `page` is the file's own node, `heading`
306
+ # one of its sections. The markdown extractor stamps these precisely because
307
+ # `file_type` cannot carry the distinction, and both are file-anchored in the
308
+ # #1284 sense — two files' `## Decisions` sections are two sections (#3094).
309
+ #
310
+ # PRODUCER CONTRACT: an extractor that mints a node standing for a *part* of its
311
+ # source file (a section, a sheet, a slide) must stamp one of these. The gate
312
+ # below reads an unstamped node as an entity, so an unstamped structural node is
313
+ # eligible to merge with its namesake in another file.
314
+ _FILE_STRUCTURE_NODE_KINDS = frozenset({"page", "heading"})
315
+
316
+
317
+ def _reads_as_file_entity(node: dict) -> bool:
318
+ """True when the node reads as an entity found inside its file, rather than
319
+ as the file itself or a stamped structural part of it (#296).
320
+
321
+ Be precise about what is proven and what is assumed, because the two halves
322
+ differ in strength:
323
+
324
+ * PROVEN — it is not its file's OWN node. ``_id_prefixes`` enumerates the ID
325
+ a node standing for ``source_file`` itself would carry, in every spelling
326
+ a stored path may take (absolute, repo-relative, or the pre-#1504 bare
327
+ stem). A file's own node IS one of those; an entity extracted from that
328
+ file carries an ``_<entity>`` suffix, so it never equals one. This is a
329
+ reconstruction, not a heuristic.
330
+ * ASSUMED — it is not a section of the file. That rests on ``node_kind``,
331
+ which only a producer that stamps it can attest. `heading`/`page` are
332
+ honoured when present, but ABSENCE OF THE MARKER IS NOT PROOF OF
333
+ ENTITY-NESS: a producer minting sub-file nodes without stamping
334
+ `node_kind` (see the contract above) yields structural nodes that this
335
+ returns True for, and two such nodes sharing a label in different files
336
+ would merge. The conservative fix is on the producer side — stamp
337
+ `node_kind` — not a guess here about what an unstamped node meant.
338
+
339
+ A node that cannot be checked at all (no ID, no provenance) answers False
340
+ and stays blocked.
341
+ """
342
+ nid = node.get("id") or ""
343
+ source_file = node.get("source_file") or ""
344
+ if not nid or not source_file:
345
+ return False # uncheckable — leave the file-anchored block in place
346
+ if node.get("node_kind") in _FILE_STRUCTURE_NODE_KINDS:
347
+ return False # the extractor says this node is part of the file's structure
348
+ return nid not in _id_prefixes(source_file)
349
+
350
+
351
+ # Path-segment lifecycle markers used by _collision_rank (#2532). Lower penalty
352
+ # wins. Without them, pure lexical source_file order makes ``plans/_done/…``
353
+ # beat ``plans/in-progress/…`` because "_" < "i" in ASCII. Active-vs-archived
354
+ # marker idea by @michaelxer (#2540); matched against ROOT-RELATIVE directory
355
+ # segments only, so a checkout directory that happens to be named ``wip`` or
356
+ # ``done`` never leaks into the ranking.
357
+ _ACTIVE_PATH_SEGMENTS = frozenset({
358
+ "in-progress",
359
+ "in_progress",
360
+ "active",
361
+ "current",
362
+ "wip",
363
+ })
364
+ _ARCHIVED_PATH_SEGMENTS = frozenset({
365
+ "_done",
366
+ "done",
367
+ "archive",
368
+ "archived",
369
+ "backup",
370
+ "bak",
371
+ "old",
372
+ "attic",
373
+ "graveyard",
374
+ "completed",
375
+ })
376
+
377
+
378
+ def _lifecycle_penalty(rank_path: str) -> int:
379
+ """0 for active/in-progress paths, 2 for archived/done paths, 1 otherwise.
380
+
381
+ Judged on the DIRECTORY segments of the root-relative rank path — a file
382
+ literally named ``done.md`` is not a marker. Among mixed markers the best
383
+ (lowest) score wins so an active segment is not drowned out by an unrelated
384
+ archive directory higher in the tree (#2532).
385
+ """
386
+ segments = [s for s in rank_path.casefold().split("/") if s]
387
+ marked = [
388
+ 0 if s in _ACTIVE_PATH_SEGMENTS else 2
389
+ for s in segments[:-1] # directories only, never the basename
390
+ if s in _ACTIVE_PATH_SEGMENTS or s in _ARCHIVED_PATH_SEGMENTS
391
+ ]
392
+ return min(marked) if marked else 1
393
+
394
+
395
+ def _rank_path(source_file: str, root: Path | None) -> str:
396
+ """The root-relative form of ``source_file`` used for collision ranking.
397
+
398
+ Mirrors ``_source_key`` in extractors/resolution.py: with a scan root, an
399
+ absolute stored path and its repo-relative twin rank identically, and the
400
+ checkout location's own segments never participate (#2532). Without a root
401
+ (or when relativizing fails) the normalized stored path is used as-is.
402
+ """
403
+ normalized = source_file.replace("\\", "/")
404
+ if root is not None and normalized:
405
+ try:
406
+ return Path(normalized).resolve().relative_to(root).as_posix()
407
+ except Exception:
408
+ pass
409
+ return normalized
410
+
411
+
412
+ def _collision_rank(node: dict, root: Path | None = None) -> tuple:
413
+ """A total order for choosing the survivor of an ID collision, independent of
414
+ the order the colliding nodes arrive in.
415
+
416
+ The winner is the node with the SMALLEST rank. A node whose ``source_file``
417
+ defines the ID always outranks a mere reference; among equally-(non-)defining
418
+ nodes an active/in-progress path outranks an archived/done one (#2532); then
419
+ it prefers the shorter, more canonical label over a longer qualified variant,
420
+ then breaks any remaining tie lexically on label and finally on the REVERSED
421
+ segments of the root-relative path. Basename-first comparison decides two
422
+ in-repo colliders by segments present in both path forms, so absolute and
423
+ repo-relative spellings of the same layout order identically — fully
424
+ deterministic regardless of arrival order (#1851) or checkout location.
425
+ """
426
+ label = node.get("label") or ""
427
+ rank_path = _rank_path(node.get("source_file") or "", root)
428
+ return (
429
+ not _defines_id(node), # definers (False) sort before references (True)
430
+ _lifecycle_penalty(rank_path), # active paths beat archived ones (#2532)
431
+ len(label), # shorter, more canonical label first
432
+ label, # lexical tiebreak
433
+ tuple(reversed([s for s in rank_path.split("/") if s and s != "."])),
434
+ )
435
+
436
+
437
+ def _same_source_entity(survivor: dict, duplicate: dict) -> bool:
438
+ """True when exact-ID records came from the same source file.
439
+
440
+ Exact IDs can also collide across files through references or slugged-path
441
+ ambiguity (#1504). Keep those records isolated rather than importing
442
+ attributes whose provenance belongs to another file.
443
+ """
444
+ keep_file = survivor.get("source_file") or ""
445
+ lose_file = duplicate.get("source_file") or ""
446
+ # Require a non-empty source_file: two provenance-less records ("" == "")
447
+ # are NOT proof of the same symbol (#1178), and merging their attributes
448
+ # would be a cross-pollination bug in the opposite direction (#2091 review).
449
+ return bool(keep_file) and keep_file == lose_file
450
+
451
+
452
+ def _merge_missing_attributes(survivor: dict, duplicate: dict) -> dict:
453
+ """Fill the survivor's absent/None attributes from a same-source duplicate,
454
+ without overriding values the survivor already has (#2091)."""
455
+ merged = dict(survivor)
456
+ for key, value in duplicate.items():
457
+ # Never inherit a provenance tag from a dropped record: a false
458
+ # _origin="ast" on an LLM survivor is read as an authority signal by the
459
+ # ghost-merge (#2068) and watch deletion logic (#2091 review).
460
+ if key == "_origin":
461
+ continue
462
+ if value is None:
463
+ continue
464
+ # Treat an explicit None on the survivor as absent — the codebase emits
465
+ # `source_location: None`, and that is exactly the attribute #2091 loses.
466
+ if merged.get(key) is None:
467
+ merged[key] = value
468
+ return merged
469
+
470
+
471
+ def _report_id_collision(nid: str, survivor: dict, losers: list[dict]) -> None:
472
+ """Report an ID collision in proportion to what dropping the loser actually costs.
473
+
474
+ Cross-reference to a defining node: the structural entity and its edges survive;
475
+ foreign-file attributes stay isolated, so no collision warning is needed. Same
476
+ file, different labels: the extractor emitted two labels for one entity and one is
477
+ discarded — note it. Two files that both encode this ID: they are distinct entities
478
+ and one is genuinely lost — warn, and point at the extraction split that keeps them
479
+ apart (#1504).
480
+ """
481
+ keep_file = survivor.get("source_file") or ""
482
+ keep_label = survivor.get("label") or ""
483
+ for loser in losers:
484
+ lose_file = loser.get("source_file") or ""
485
+ lose_label = loser.get("label") or ""
486
+ if lose_file == keep_file:
487
+ if _norm(lose_label) != _norm(keep_label):
488
+ print(
489
+ f"[graphify] note: node '{nid}' was extracted twice from "
490
+ f"'{keep_file}' under different labels — keeping '{keep_label}', "
491
+ f"dropping '{lose_label}'.",
492
+ file=sys.stderr,
493
+ )
494
+ elif _defines_id(survivor) and not _defines_id(loser):
495
+ continue # the loser only references the entity the survivor defines
496
+ else:
497
+ print(
498
+ f"[graphify] WARNING: node '{nid}' is minted by two different files — "
499
+ f"keeping '{keep_label}' from '{keep_file}', dropping '{lose_label}' "
500
+ f"from '{lose_file}'. An ID is derived from the source path plus the "
501
+ f"entity name, so this one does not identify a single entity and the "
502
+ f"dropped node is lost. To keep them distinct, run 'graphify extract' "
503
+ f"per subfolder and merge with 'graphify merge-graphs'.",
504
+ file=sys.stderr,
505
+ )
506
+
507
+
508
+ # ── main entry point ──────────────────────────────────────────────────────────
509
+
510
+ def _remap_hyperedge_members(hyperedges: list[dict], remap: dict[str, str]) -> None:
511
+ """Rewire hyperedge member ids onto dedup survivors, in place.
512
+
513
+ Members come in both shapes the rest of the codebase tolerates — a bare id
514
+ string, or an object carrying one — so both are handled;
515
+ ``_normalize_hyperedge_members`` fixes the SHAPE but never resolves a member
516
+ against surviving node ids, which is why this is needed as well.
517
+
518
+ Two members that remap onto the same survivor collapse to one entry. That
519
+ shrinks the group, but honestly: they were the same entity, and the previous
520
+ behaviour dropped the loser without promoting it, which shrank the group
521
+ *and* lost the participant. Order is preserved so a rebuilt graph does not
522
+ churn.
523
+ """
524
+ for he in hyperedges:
525
+ if not isinstance(he, dict):
526
+ continue
527
+ members = he.get("nodes")
528
+ if not isinstance(members, list):
529
+ continue
530
+ seen: set = set()
531
+ rewired: list = []
532
+ for m in members:
533
+ if isinstance(m, str):
534
+ new_id = remap.get(m, m)
535
+ entry = new_id
536
+ elif isinstance(m, dict):
537
+ raw = m.get("id")
538
+ new_id = remap.get(raw, raw) if isinstance(raw, str) else raw
539
+ entry = dict(m, id=new_id) if new_id != raw else m
540
+ else:
541
+ new_id, entry = None, m
542
+ if isinstance(new_id, str):
543
+ if new_id in seen:
544
+ continue
545
+ seen.add(new_id)
546
+ rewired.append(entry)
547
+ he["nodes"] = rewired
548
+
549
+
550
+ def deduplicate_entities(
551
+ nodes: list[dict],
552
+ edges: list[dict],
553
+ *,
554
+ communities: dict[str, int],
555
+ dedup_llm_backend: str | None = None,
556
+ root: str | Path | None = None,
557
+ hyperedges: "list[dict] | None" = None,
558
+ protected_ids: "set[str] | None" = None,
559
+ ) -> tuple[list[dict], list[dict]]:
560
+ """Deduplicate near-identical entities in a knowledge graph.
561
+
562
+ Args:
563
+ nodes: list of node dicts with at minimum {"id": str, "label": str}
564
+ edges: list of edge dicts with {"source": str, "target": str, ...}
565
+ communities: mapping of node_id -> community_id (from cluster())
566
+ dedup_llm_backend: if set, use LLM to resolve ambiguous pairs
567
+ root: scan root; ID-collision ranking judges source paths relative to
568
+ it so path form and checkout location cannot flip the survivor (#2532)
569
+ hyperedges: when given, member ids are rewired to survivors IN PLACE,
570
+ the same way edge endpoints are. Optional and mutating rather than
571
+ returned so existing two-tuple callers are unaffected (#2805).
572
+
573
+ Returns:
574
+ (deduped_nodes, deduped_edges) with edges rewired to survivors
575
+ """
576
+ # Guard: cross-project dedup is not supported — nodes from different repos
577
+ # share label names by coincidence and must never be merged by string similarity.
578
+ # If you need to dedup a global graph, run deduplicate_entities per-repo first.
579
+ repos_seen = {n.get("repo") for n in nodes if n.get("repo")}
580
+ if len(repos_seen) > 1:
581
+ raise ValueError(
582
+ f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. "
583
+ f"Cross-project dedup is disabled — run dedup per-repo before merging."
584
+ )
585
+
586
+ if len(nodes) <= 1:
587
+ return nodes, edges
588
+
589
+ # Resolve the scan root once: _collision_rank ranks each node's source_file
590
+ # relative to it, so an absolute stored path and its repo-relative twin rank
591
+ # identically and lifecycle markers in the checkout location's own segments
592
+ # cannot flip the survivor (#2532).
593
+ try:
594
+ root_resolved: Path | None = Path(root).resolve() if root else None
595
+ except Exception:
596
+ root_resolved = None
597
+
598
+ # Pre-deduplicate: one node per ID. The survivor is the node that *defines* the
599
+ # ID (its source_file is the file the ID encodes), not merely the first seen —
600
+ # otherwise chunk order decides whether an entity keeps its own attributes or a
601
+ # passing cross-reference's. Missing attributes from same-source records are
602
+ # retained so AST structure and semantic enrichment can coexist (#2091).
603
+ # Genuine cross-file ID collisions stay isolated and are reported below (#1504).
604
+ seen_ids: dict[str, dict] = {}
605
+ dropped: dict[str, list[dict]] = defaultdict(list)
606
+ for node in nodes:
607
+ nid = node.get("id", "")
608
+ if not nid:
609
+ continue
610
+ incumbent = seen_ids.get(nid)
611
+ if incumbent is None:
612
+ seen_ids[nid] = node
613
+ elif _collision_rank(node, root_resolved) < _collision_rank(incumbent, root_resolved):
614
+ # Smallest-ranked node wins; the min over a total order is independent
615
+ # of the order nodes arrive in, so the survivor no longer depends on
616
+ # chunk ordering (#1851).
617
+ seen_ids[nid] = node
618
+ dropped[nid].append(incumbent)
619
+ else:
620
+ dropped[nid].append(node)
621
+
622
+ # Gap-fill each survivor from its SAME-SOURCE losers, applied in deterministic
623
+ # _collision_rank order (best loser first). Merging here — not incrementally in
624
+ # the loop above — keeps the merged attributes independent of chunk arrival
625
+ # order with 3+ colliding records, preserving the #1851 order-independence
626
+ # contract (#2091 review).
627
+ for nid, losers in dropped.items():
628
+ survivor = seen_ids[nid]
629
+ same_source = sorted(
630
+ (l for l in losers if _same_source_entity(survivor, l)),
631
+ key=lambda l: _collision_rank(l, root_resolved),
632
+ )
633
+ for loser in same_source:
634
+ survivor = _merge_missing_attributes(survivor, loser)
635
+ seen_ids[nid] = survivor
636
+
637
+ for nid, losers in dropped.items():
638
+ _report_id_collision(nid, seen_ids[nid], losers)
639
+
640
+ unique_nodes = list(seen_ids.values())
641
+
642
+ if len(unique_nodes) <= 1:
643
+ return unique_nodes, edges
644
+
645
+ # ── pass 1: exact normalization ───────────────────────────────────────────
646
+ norm_to_nodes: dict[str, list[dict]] = defaultdict(list)
647
+ for node in unique_nodes:
648
+ # Code symbols are keyed by ID, never by label — skip them entirely so
649
+ # distinct same-named symbols are never merged by string similarity (#1205).
650
+ if _is_code(node):
651
+ continue
652
+ key = _norm(node.get("label", node.get("id", "")))
653
+ if key:
654
+ norm_to_nodes[key].append(node)
655
+
656
+ uf = _UF()
657
+ exact_merges = 0
658
+
659
+ protected_set: set[str] = set(protected_ids) if protected_ids is not None else set()
660
+ prot_by_root: dict[str, str] = {pid: pid for pid in protected_set}
661
+
662
+ def _get_prot(nid: str) -> str | None:
663
+ return prot_by_root.get(uf.find(nid))
664
+
665
+ def _union_with_prot(x: str, y: str) -> None:
666
+ px = _get_prot(x)
667
+ py = _get_prot(y)
668
+ uf.union(x, y)
669
+ new_root = uf.find(x)
670
+ prot = px or py
671
+ if prot is not None:
672
+ prot_by_root[new_root] = prot
673
+
674
+ for key, group in norm_to_nodes.items():
675
+ if len(group) <= 1:
676
+ continue
677
+ # Partition by source_file — same-file exact matches always merge here.
678
+ # Cross-file exact matches are handled just below, gated to `concept`
679
+ # nodes only: Pass 2 cannot form them because its candidate list keeps a
680
+ # single node per normalized label (#2182).
681
+ by_file: dict[str, list[dict]] = defaultdict(list)
682
+ for node in group:
683
+ sf = node.get("source_file") or ""
684
+ by_file[sf].append(node)
685
+ for sf, file_group in by_file.items():
686
+ if not sf:
687
+ # No source_file — cannot prove same symbol; skip to avoid
688
+ # collapsing distinct nodes that happen to share a label (#1178).
689
+ continue
690
+ if len(file_group) > 1:
691
+ if protected_set:
692
+ prot_file = [n for n in file_group if n.get("id") in protected_set]
693
+ inc_file = [n for n in file_group if n.get("id") not in protected_set]
694
+ if prot_file and not inc_file:
695
+ # All nodes belong exclusively to an untouched file — preserve all of them
696
+ continue
697
+ if prot_file and inc_file:
698
+ winner = _pick_winner(prot_file)
699
+ for node in inc_file:
700
+ px = _get_prot(winner["id"])
701
+ py = _get_prot(node["id"])
702
+ if px is not None and py is not None and px != py:
703
+ continue
704
+ if uf.find(winner["id"]) != uf.find(node["id"]):
705
+ _union_with_prot(winner["id"], node["id"])
706
+ exact_merges += 1
707
+ else:
708
+ winner = _pick_winner(file_group)
709
+ for node in file_group:
710
+ if uf.find(winner["id"]) != uf.find(node["id"]):
711
+ _union_with_prot(winner["id"], node["id"])
712
+ exact_merges += 1
713
+ else:
714
+ winner = _pick_winner(file_group)
715
+ for node in file_group:
716
+ uf.union(winner["id"], node["id"])
717
+ exact_merges += len(file_group) - 1
718
+ # Cross-file residue: union exact matches across files, but only where
719
+ # it is provably safe (#2182). `concept` is the one file_type meant to
720
+ # unify across files (#1284) — code is keyed by ID (#1205) and
721
+ # image/paper labels are often shared basenames (logo.png), so both stay
722
+ # blocked. rationale/document join `concept` here ONLY when the node
723
+ # reads as an entity inside its file (#296): a file-anchored
724
+ # *file_type* does not make an individual node file-anchored. An entity
725
+ # extracted from a note — a person, a project — inherits `document` from
726
+ # the file's extension, not from anything about itself, so in note-heavy
727
+ # corpora almost no entity node is typed `concept` and this merge never
728
+ # got to run on them. A file's own node and its headings still never
729
+ # merge (#1284, #3094).
730
+ # Provenance is required (#1178), and the entropy gate mirrors Pass 2 so
731
+ # short generic labels ("API") stay distinct — both untouched here.
732
+ # Scoped to this exact-normalization pass: Pass 2's fuzzy
733
+ # `_crossfile_fileanchored_blocked` is unchanged, so #1284's
734
+ # near-identical boilerplate and heading siblings stay blocked.
735
+ # Sorting by id keeps the winner order-independent.
736
+ mergeable = sorted(
737
+ (n for n in group
738
+ if (n.get("file_type") == "concept"
739
+ or (n.get("file_type") in _FILE_ANCHORED_NONCODE
740
+ and _reads_as_file_entity(n)))
741
+ and (n.get("source_file") or "")
742
+ and _entropy(n.get("label", "")) >= _ENTROPY_THRESHOLD),
743
+ key=lambda n: n["id"],
744
+ )
745
+ if len(mergeable) > 1:
746
+ if protected_set:
747
+ prot_members = [n for n in mergeable if n.get("id") in protected_set]
748
+ inc_members = [n for n in mergeable if n.get("id") not in protected_set]
749
+ if not inc_members:
750
+ # All participants belong exclusively to untouched files (#3477):
751
+ # NEVER collapse them during incremental merge.
752
+ continue
753
+ if prot_members:
754
+ # Mixed: pick AT MOST ONE protected survivor for incoming nodes to fold into.
755
+ # Multiple protected nodes must remain separate independent entities.
756
+ canonical_winner = _pick_winner(prot_members)
757
+ for inc in inc_members:
758
+ px = _get_prot(canonical_winner["id"])
759
+ py = _get_prot(inc["id"])
760
+ if px is not None and py is not None and px != py:
761
+ continue
762
+ if uf.find(canonical_winner["id"]) != uf.find(inc["id"]):
763
+ _union_with_prot(canonical_winner["id"], inc["id"])
764
+ exact_merges += 1
765
+ else:
766
+ # Incoming only: merge normally
767
+ winner = _pick_winner(inc_members)
768
+ for node in inc_members:
769
+ if uf.find(winner["id"]) != uf.find(node["id"]):
770
+ _union_with_prot(winner["id"], node["id"])
771
+ exact_merges += 1
772
+ else:
773
+ winner = _pick_winner(mergeable)
774
+ for node in mergeable:
775
+ if uf.find(winner["id"]) != uf.find(node["id"]):
776
+ uf.union(winner["id"], node["id"])
777
+ exact_merges += 1
778
+
779
+ # ── pass 2: MinHash/LSH + Jaro-Winkler (high-entropy nodes only) ─────────
780
+ candidates: list[dict] = []
781
+ seen_norms: set[str] = set()
782
+ for node in unique_nodes:
783
+ # Code symbols are excluded from fuzzy matching too: two functions with
784
+ # similar long names in different files (parallel backends, sibling
785
+ # classes) must not be fuzzy-merged, and a code↔concept fuzzy match must
786
+ # not transitively union two distinct code symbols via a concept (#1205).
787
+ if _is_code(node):
788
+ continue
789
+ key = _norm(node.get("label", node.get("id", "")))
790
+ if key and key not in seen_norms:
791
+ seen_norms.add(key)
792
+ if _entropy(node.get("label", "")) >= _ENTROPY_THRESHOLD:
793
+ candidates.append(node)
794
+
795
+ fuzzy_merges = 0
796
+ if len(candidates) >= 2:
797
+ lsh = MinHashLSH(threshold=_LSH_THRESHOLD, num_perm=_NUM_PERM)
798
+ minhashes: dict[str, MinHash] = {}
799
+ # Pre-build O(1) lookup structures so the query loop below doesn't scan
800
+ # the candidates list linearly for every LSH neighbor (was O(n²×B)).
801
+ candidates_by_id: dict[str, dict] = {}
802
+ norm_cache: dict[str, str] = {}
803
+
804
+ for node in candidates:
805
+ node_id = node["id"]
806
+ candidates_by_id[node_id] = node
807
+ nl = _norm(node.get("label", node.get("id", "")))
808
+ norm_cache[node_id] = nl
809
+ m = _make_minhash(nl)
810
+ minhashes[node_id] = m
811
+ try:
812
+ lsh.insert(node_id, m)
813
+ except ValueError:
814
+ pass # duplicate key in LSH — already inserted
815
+
816
+ for node in candidates:
817
+ node_id = node["id"]
818
+ norm_label = norm_cache[node_id]
819
+ neighbors = lsh.query(minhashes[node_id])
820
+
821
+ for neighbor_id in neighbors:
822
+ if neighbor_id == node_id:
823
+ continue
824
+ if uf.find(node_id) == uf.find(neighbor_id):
825
+ continue
826
+
827
+ if protected_set:
828
+ px = _get_prot(node_id)
829
+ py = _get_prot(neighbor_id)
830
+ if px is not None and py is not None and px != py:
831
+ # Prevent protected/protected unions and bridging across protected components
832
+ continue
833
+
834
+ neighbor = candidates_by_id.get(neighbor_id)
835
+ if neighbor is None:
836
+ continue
837
+
838
+ neighbor_norm = norm_cache.get(neighbor_id) or _norm(neighbor.get("label", neighbor.get("id", "")))
839
+ # Cross-file long labels score on plain Jaro (no prefix bonus).
840
+ # Jaro-Winkler's leading-prefix bonus lifts pairs that share a
841
+ # prefix but diverge in a distinguishing token ("testing-library
842
+ # jest-native" vs "react-native") past threshold, fabricating
843
+ # destructive cross-file merges; on Jaro alone they fall short
844
+ # while true cross-file duplicates still clear it (#1243). Same-file
845
+ # near-duplicates keep Jaro-Winkler (low-risk, and a mid-string
846
+ # stopword insertion needs the prefix bonus to merge); short labels
847
+ # keep Jaro-Winkler too (gated by _short_label_blocked).
848
+ _xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
849
+ if _xfile and max(len(norm_label), len(neighbor_norm)) >= 12:
850
+ score = Jaro.normalized_similarity(norm_label, neighbor_norm) * 100
851
+ else:
852
+ score = JaroWinkler.normalized_similarity(norm_label, neighbor_norm) * 100
853
+
854
+ if _is_variant_pair(norm_label, neighbor_norm):
855
+ continue
856
+ if _short_label_blocked(norm_label, neighbor_norm, score):
857
+ continue
858
+ # Prefix-extension pairs (getActiveSession / getActiveSessions,
859
+ # parseConfig / parseConfigFile) are almost never duplicates —
860
+ # one is a strict suffix-extension of the other. Block the merge
861
+ # regardless of JW score (#1201).
862
+ _lo, _hi = sorted((norm_label, neighbor_norm), key=len)
863
+ if _hi.startswith(_lo) and _hi != _lo:
864
+ continue
865
+ # Numbered/versioned siblings and cross-file file-anchored
866
+ # boilerplate (rationale/document) are decisively distinct
867
+ # regardless of score (#1284).
868
+ if _numeric_tokens_differ(norm_label, neighbor_norm):
869
+ continue
870
+ # Template-named siblings differing in a content word are
871
+ # distinct too, on either path: same-file pairs keep the prefix
872
+ # bonus, and a cross-file pair can still reach threshold on the
873
+ # community boost alone (#2576).
874
+ if _content_token_swap(norm_label, neighbor_norm):
875
+ continue
876
+ if _crossfile_fileanchored_blocked(node, neighbor):
877
+ continue
878
+
879
+ c1 = communities.get(node_id)
880
+ c2 = communities.get(neighbor_id)
881
+ if (c1 is not None and c2 is not None and c1 == c2
882
+ and min(len(norm_label), len(neighbor_norm)) >= 12):
883
+ score += _COMMUNITY_BOOST
884
+
885
+ if score >= _MERGE_THRESHOLD:
886
+ # Belt-and-braces (#1046, narrowed by #2182): candidates are
887
+ # norm-unique (`seen_norms` above), so two candidates can
888
+ # never share a normalized label and this branch is
889
+ # unreachable today. Retained in case candidate selection
890
+ # changes. Equal-norm cross-file pairs are handled in Pass 1
891
+ # instead, gated to `concept` nodes — the original #1046
892
+ # rationale (same-named code symbols) was obsoleted by code
893
+ # being excluded from label matching entirely (#1205, #1247).
894
+ if norm_label == neighbor_norm:
895
+ sf_a = node.get("source_file") or ""
896
+ sf_b = neighbor.get("source_file") or ""
897
+ if sf_a != sf_b:
898
+ continue
899
+ # Pick the winner from the verified pair only. Selecting it
900
+ # from the union of both normalized-label groups pulls
901
+ # never-compared nodes (same label, different source_file)
902
+ # into the merge, bypassing the #1046/#1178 guards.
903
+ if protected_set:
904
+ px = _get_prot(node_id)
905
+ py = _get_prot(neighbor_id)
906
+ if px is not None and py is not None and px != py:
907
+ continue
908
+ if node_id in protected_set:
909
+ winner = node
910
+ elif neighbor_id in protected_set:
911
+ winner = neighbor
912
+ else:
913
+ winner = _pick_winner([node, neighbor])
914
+ _union_with_prot(winner["id"], node_id)
915
+ _union_with_prot(winner["id"], neighbor_id)
916
+ else:
917
+ winner = _pick_winner([node, neighbor])
918
+ uf.union(winner["id"], node_id)
919
+ uf.union(winner["id"], neighbor_id)
920
+ fuzzy_merges += 1
921
+
922
+ # ── pass 3: LLM tiebreaker for ambiguous pairs (opt-in) ──────────────────
923
+ if dedup_llm_backend is not None:
924
+ _llm_tiebreak(
925
+ candidates, uf, communities, backend=dedup_llm_backend,
926
+ protected_set=protected_set, get_prot=_get_prot, union_with_prot=_union_with_prot,
927
+ )
928
+
929
+ # ── build remap table from union-find components ──────────────────────────
930
+ components = uf.components()
931
+ remap: dict[str, str] = {}
932
+
933
+ # id -> (position, node), built once. Previously each component re-scanned
934
+ # the whole unique_nodes list, making remap construction O(nodes x
935
+ # components) — 31% of dedup wall-clock on a 50k-node corpus.
936
+ # The position is carried so group_nodes keeps unique_nodes order: _pick_winner
937
+ # resolves ties (equal chunk-suffix status and equal id length) via min(),
938
+ # which returns the first minimum, so reordering here would silently change
939
+ # which node survives.
940
+ nodes_by_id: dict[str, tuple[int, dict]] = {
941
+ n["id"]: (i, n) for i, n in enumerate(unique_nodes)
942
+ }
943
+
944
+ # Survivors enriched with their losers' fields, substituted at the end.
945
+ enriched_by_id: dict[str, dict] = {}
946
+ for root, members in components.items():
947
+ if len(members) == 1:
948
+ continue
949
+ group_nodes = [
950
+ n for _, n in sorted(
951
+ (nodes_by_id[m] for m in members if m in nodes_by_id),
952
+ key=lambda pair: pair[0],
953
+ )
954
+ ]
955
+ if protected_set:
956
+ prot_in_group = [n for n in group_nodes if n.get("id") in protected_set]
957
+ if prot_in_group:
958
+ winner = _pick_winner(prot_in_group)
959
+ else:
960
+ winner = _pick_winner(group_nodes) if group_nodes else {"id": root}
961
+ else:
962
+ winner = _pick_winner(group_nodes) if group_nodes else {"id": root}
963
+ winner_id = winner["id"]
964
+ # Even with the right survivor, dropping the losers wholesale loses
965
+ # whatever fields only they carried. Fill the survivor's absent
966
+ # fields from each loser — the same never-override merge the
967
+ # same-source collision path already applies (#2091/#3372); loser
968
+ # order follows unique_nodes order, so the fill is deterministic.
969
+ merged = winner
970
+ for node in group_nodes:
971
+ if node["id"] != winner_id:
972
+ merged = _merge_missing_attributes(merged, node)
973
+ if merged != winner:
974
+ enriched_by_id[winner_id] = merged
975
+ for member in members:
976
+ if member != winner_id:
977
+ remap[member] = winner_id
978
+
979
+ # ── apply remap ───────────────────────────────────────────────────────────
980
+ if not remap:
981
+ return unique_nodes, edges
982
+
983
+ total = len(remap)
984
+ msg = f"[graphify] Deduplicated {total} node(s)"
985
+ # Both counters are reported when non-zero. Previous form nested the fuzzy
986
+ # branch inside `if exact_merges`, silently dropping the fuzzy count on
987
+ # doc/semantic-heavy runs where Pass 1 finds nothing (#1857).
988
+ parts: list[str] = []
989
+ if exact_merges:
990
+ parts.append(f"{exact_merges} exact")
991
+ if fuzzy_merges:
992
+ parts.append(f"{fuzzy_merges} fuzzy")
993
+ if parts:
994
+ msg += f" ({', '.join(parts)})"
995
+ print(msg + ".", flush=True)
996
+
997
+ # Hyperedge members are node references exactly like edge endpoints, and
998
+ # must follow the survivor for the same reason. Without this the member
999
+ # naming a merged-away id was simply absent from the rebuilt graph: the
1000
+ # group lost a participant silently, could fall under the 3-member threshold
1001
+ # that makes it a hyperedge at all, and left NO dangling reference, so a
1002
+ # referential-integrity check saw nothing wrong (#2805).
1003
+ if hyperedges:
1004
+ _remap_hyperedge_members(hyperedges, remap)
1005
+
1006
+ deduped_nodes = [
1007
+ enriched_by_id.get(n["id"], n) for n in unique_nodes if n["id"] not in remap
1008
+ ]
1009
+ deduped_edges = []
1010
+ for edge in edges:
1011
+ e = dict(edge)
1012
+ # Tolerate "from"/"to" keys from LLM backends that don't follow the
1013
+ # schema exactly — build_from_json normalises later but dedup runs
1014
+ # first so bracket access would KeyError here (#803).
1015
+ # Use explicit key presence check (not `or`) so empty-string src/tgt
1016
+ # aren't silently replaced by the fallback key.
1017
+ src = e["source"] if "source" in e else e.get("from")
1018
+ tgt = e["target"] if "target" in e else e.get("to")
1019
+ if src is None or tgt is None:
1020
+ continue
1021
+ e["source"] = remap.get(src, src)
1022
+ e["target"] = remap.get(tgt, tgt)
1023
+ # Remove legacy keys so they don't leak into edge attrs in graph.json.
1024
+ e.pop("from", None)
1025
+ e.pop("to", None)
1026
+ if e["source"] != e["target"]:
1027
+ deduped_edges.append(e)
1028
+
1029
+ return deduped_nodes, deduped_edges
1030
+
1031
+
1032
+ # Keys every node carries (or that describe placement rather than content).
1033
+ # They say nothing about which duplicate is the better-established record, so
1034
+ # the richness score below ignores them.
1035
+ _RICHNESS_IGNORED_KEYS = frozenset({
1036
+ "id", "label", "norm_label", "file_type", "source_file", "source_location",
1037
+ })
1038
+
1039
+
1040
+ def _content_richness(n: dict) -> int:
1041
+ """How much actual content a node carries, for survivor selection (#3372).
1042
+
1043
+ Counts populated fields beyond the identity/placement baseline, weighting
1044
+ ``attributes`` by entry count and ``_merged_from`` by its history length —
1045
+ a node that already absorbed prior merges is the established record.
1046
+ """
1047
+ score = 0
1048
+ for key, value in n.items():
1049
+ if key in _RICHNESS_IGNORED_KEYS:
1050
+ continue
1051
+ if value is None or value == "" or value == [] or value == {}:
1052
+ continue
1053
+ score += 1
1054
+ if key == "attributes" and isinstance(value, dict):
1055
+ score += len(value)
1056
+ elif key == "_merged_from" and isinstance(value, list):
1057
+ score += len(value)
1058
+ return score
1059
+
1060
+
1061
+ def _pick_winner(nodes: list[dict]) -> dict:
1062
+ """Pick the canonical survivor: no chunk suffix, then richer content,
1063
+ then shorter ID.
1064
+
1065
+ ID length used to be the primary signal after the chunk-suffix check,
1066
+ which made a passing one-line mention on a shallow page (short id, one
1067
+ fewer path segment) beat the dedicated, enriched page for the same entity
1068
+ — every time the pattern occurred, the established node's content was
1069
+ discarded (#3372). Content richness now decides first; ID shape only
1070
+ breaks ties between equally-rich candidates, preserving the old
1071
+ deterministic ordering there.
1072
+ """
1073
+ if not nodes:
1074
+ raise ValueError("Cannot pick winner from empty list")
1075
+
1076
+ def _score(n: dict) -> tuple[int, int, int]:
1077
+ has_suffix = bool(_CHUNK_SUFFIX.search(n["id"]))
1078
+ return (1 if has_suffix else 0, -_content_richness(n), len(n["id"]))
1079
+
1080
+ return min(nodes, key=_score)
1081
+
1082
+
1083
+ def _llm_tiebreak(
1084
+ candidates: list[dict],
1085
+ uf: _UF,
1086
+ communities: dict[str, int],
1087
+ *,
1088
+ backend: str,
1089
+ batch_size: int = 30,
1090
+ low: float = 75.0,
1091
+ high: float = 92.0,
1092
+ protected_set: set[str] | None = None,
1093
+ get_prot=None,
1094
+ union_with_prot=None,
1095
+ ) -> None:
1096
+ """Batch-resolve ambiguous pairs (score in [low, high)) via LLM."""
1097
+ try:
1098
+ from graphify.llm import BACKENDS, _format_backend_env_keys, _get_backend_api_key
1099
+ if backend not in BACKENDS:
1100
+ print(f"[graphify] --dedup-llm: unknown backend {backend!r}, skipping LLM tiebreaker.", flush=True)
1101
+ return
1102
+ if not _get_backend_api_key(backend):
1103
+ env_keys = _format_backend_env_keys(backend)
1104
+ print(f"[graphify] --dedup-llm: {env_keys} not set, skipping LLM tiebreaker.", flush=True)
1105
+ return
1106
+ except ImportError:
1107
+ return
1108
+
1109
+ ambiguous: list[tuple[dict, dict, float]] = []
1110
+ for i, node in enumerate(candidates):
1111
+ norm_i = _norm(node.get("label", node.get("id", "")))
1112
+ for j in range(i + 1, len(candidates)):
1113
+ neighbor = candidates[j]
1114
+ if uf.find(node["id"]) == uf.find(neighbor["id"]):
1115
+ continue
1116
+ norm_j = _norm(neighbor.get("label", neighbor.get("id", "")))
1117
+ # Mirror pass 2: plain Jaro for cross-file long labels (#1243).
1118
+ _xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
1119
+ if _xfile and max(len(norm_i), len(norm_j)) >= 12:
1120
+ score = Jaro.normalized_similarity(norm_i, norm_j) * 100
1121
+ else:
1122
+ score = JaroWinkler.normalized_similarity(norm_i, norm_j) * 100
1123
+ if _is_variant_pair(norm_i, norm_j):
1124
+ continue
1125
+ if _short_label_blocked(norm_i, norm_j, score):
1126
+ continue
1127
+ _lo, _hi = sorted((norm_i, norm_j), key=len)
1128
+ if _hi.startswith(_lo) and _hi != _lo:
1129
+ continue
1130
+ # Mirror pass 2: decisively-distinct pairs never reach the LLM
1131
+ # (#1284, #2576).
1132
+ if _numeric_tokens_differ(norm_i, norm_j):
1133
+ continue
1134
+ if _content_token_swap(norm_i, norm_j):
1135
+ continue
1136
+ if _crossfile_fileanchored_blocked(node, neighbor):
1137
+ continue
1138
+ c1 = communities.get(node["id"])
1139
+ c2 = communities.get(neighbor["id"])
1140
+ if (c1 is not None and c2 is not None and c1 == c2
1141
+ and min(len(norm_i), len(norm_j)) >= 12):
1142
+ score += _COMMUNITY_BOOST
1143
+ if low <= score < high:
1144
+ if protected_set and get_prot is not None:
1145
+ px = get_prot(node["id"])
1146
+ py = get_prot(neighbor["id"])
1147
+ if px is not None and py is not None and px != py:
1148
+ continue
1149
+ ambiguous.append((node, neighbor, score))
1150
+
1151
+ if not ambiguous:
1152
+ return
1153
+
1154
+ try:
1155
+ from graphify.llm import _call_llm
1156
+ except ImportError as exc:
1157
+ # F-038: previously this silent fallback hid the fact that `_call_llm`
1158
+ # didn't exist in `graphify.llm` at all, so `--dedup-llm` was a no-op.
1159
+ # Surface the import failure so future regressions are visible.
1160
+ print(
1161
+ f"[graphify] --dedup-llm: cannot import _call_llm ({exc}); skipping LLM tiebreaker.",
1162
+ flush=True,
1163
+ )
1164
+ return
1165
+
1166
+ for batch_start in range(0, len(ambiguous), batch_size):
1167
+ batch = ambiguous[batch_start : batch_start + batch_size]
1168
+ pairs_text = "\n".join(
1169
+ f"{i+1}. \"{a['label']}\" vs \"{b['label']}\""
1170
+ for i, (a, b, _) in enumerate(batch)
1171
+ )
1172
+ prompt = (
1173
+ "For each pair below, answer only 'yes' or 'no': are they the same real-world concept?\n\n"
1174
+ f"{pairs_text}\n\n"
1175
+ "Reply with one line per pair: '1. yes', '2. no', etc."
1176
+ )
1177
+ try:
1178
+ response = _call_llm(prompt, backend=backend, max_tokens=200)
1179
+ lines = response.strip().splitlines()
1180
+ for line in lines:
1181
+ line = line.strip()
1182
+ if not line:
1183
+ continue
1184
+ parts = line.split(".", 1)
1185
+ if len(parts) != 2:
1186
+ continue
1187
+ try:
1188
+ idx = int(parts[0].strip()) - 1
1189
+ except ValueError:
1190
+ continue
1191
+ if 0 <= idx < len(batch):
1192
+ answer = parts[1].strip().lower()
1193
+ if answer.startswith("yes"):
1194
+ a, b, _ = batch[idx]
1195
+ if protected_set and get_prot is not None and union_with_prot is not None:
1196
+ px = get_prot(a["id"])
1197
+ py = get_prot(b["id"])
1198
+ if px is not None and py is not None and px != py:
1199
+ continue
1200
+ if a["id"] in protected_set:
1201
+ winner = a
1202
+ elif b["id"] in protected_set:
1203
+ winner = b
1204
+ else:
1205
+ winner = _pick_winner([a, b])
1206
+ union_with_prot(winner["id"], a["id"])
1207
+ union_with_prot(winner["id"], b["id"])
1208
+ else:
1209
+ winner = _pick_winner([a, b])
1210
+ uf.union(winner["id"], a["id"])
1211
+ uf.union(winner["id"], b["id"])
1212
+ except Exception as exc:
1213
+ print(f"[graphify] --dedup-llm batch failed: {exc}", flush=True)