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.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- 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)
|