mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-20 02:41:13 +00:00
fix(dedup): preserve pre-existing self-loops during entity deduplication
This commit is contained in:
2 files changed
+46
-1
No files matched your search
+4
-1
@@ -1030,7 +1030,10 @@ def deduplicate_entities(
|
||||
# Remove legacy keys so they don't leak into edge attrs in graph.json.
|
||||
e.pop("from", None)
|
||||
e.pop("to", None)
|
||||
if e["source"] != e["target"]:
|
||||
# Drop only self-loops created by the merge (an edge whose distinct
|
||||
# endpoints collapsed into one node); preserve pre-existing self-loops
|
||||
# (e.g. recursive calls, self-referencing foreign keys) (#3809).
|
||||
if e["source"] != e["target"] or src == tgt:
|
||||
deduped_edges.append(e)
|
||||
|
||||
return deduped_nodes, deduped_edges
|
||||
|
||||
@@ -94,6 +94,48 @@ def test_self_loops_dropped_after_merge():
|
||||
assert result_edges == []
|
||||
|
||||
|
||||
def test_preexisting_self_loops_preserved_after_unrelated_merge():
|
||||
# #3809: Pre-existing self-loops (e.g. recursive calls, self-referencing FKs)
|
||||
# must survive entity deduplication even when an unrelated merge occurs.
|
||||
# Only self-loops created by the merge itself should be dropped.
|
||||
nodes = [
|
||||
{"id": "walk", "label": "walk()", "source_file": "src/tree.py", "file_type": "code"},
|
||||
{"id": "categories", "label": "categories", "source_file": "schema.sql", "file_type": "code"},
|
||||
{"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"},
|
||||
{"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"},
|
||||
]
|
||||
edges = [
|
||||
{"source": "walk", "target": "walk", "relation": "calls"},
|
||||
{"source": "categories", "target": "categories", "relation": "references"},
|
||||
# An edge between the duplicate concepts that collapses upon merge
|
||||
{"source": "cache_2", "target": "cache", "relation": "references"},
|
||||
]
|
||||
_, result_edges = deduplicate_entities(nodes, edges, communities={})
|
||||
|
||||
loops = [(e["source"], e["target"], e.get("relation")) for e in result_edges if e["source"] == e["target"]]
|
||||
assert ("walk", "walk", "calls") in loops
|
||||
assert ("categories", "categories", "references") in loops
|
||||
# The collapsed edge cache_2 -> cache must NOT produce a self-loop
|
||||
assert len(loops) == 2
|
||||
|
||||
|
||||
def test_preexisting_self_loop_on_merged_node_rewires_to_winner():
|
||||
# If a merged node itself had a pre-existing self-loop, the self-loop
|
||||
# is rewired to the winner, not dropped.
|
||||
nodes = [
|
||||
{"id": "cache", "label": "Cache", "source_file": "docs/guide.md", "file_type": "concept"},
|
||||
{"id": "cache_2", "label": "cache", "source_file": "docs/guide.md", "file_type": "concept"},
|
||||
]
|
||||
edges = [
|
||||
{"source": "cache_2", "target": "cache_2", "relation": "references"},
|
||||
]
|
||||
_, result_edges = deduplicate_entities(nodes, edges, communities={})
|
||||
assert len(result_edges) == 1
|
||||
assert result_edges[0]["source"] == "cache"
|
||||
assert result_edges[0]["target"] == "cache"
|
||||
|
||||
|
||||
|
||||
def test_community_boost_aids_merge():
|
||||
# Two nodes in same community with score in 0.75-0.85 zone get boosted
|
||||
nodes = _make_nodes("AuthManager", "Auth Manager")
|
||||
|
||||
Reference in new issue
Block a user