fix calls edge direction flipping on bidirectional pairs in undirected build (#1061)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-28 17:40:04 +01:00
parent c2de9fa72f
commit 66acfd81aa
3 changed files with 73 additions and 1 deletions
+13
View File
@@ -210,6 +210,19 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# causing display functions to show edges backwards.
attrs["_src"] = src
attrs["_tgt"] = tgt
# When the graph is undirected and the same node pair appears twice with
# the same relation but opposite directions (e.g. a `calls` b and b `calls` a),
# nx.Graph collapses them into one edge. The deterministic sort above means
# the lexicographically-later direction would systematically overwrite the
# earlier one's _src/_tgt, silently flipping the surviving edge's caller
# and callee. First-seen direction wins instead — drop the redundant
# reverse-direction duplicate so the original direction is preserved (#1061).
if not G.is_directed() and G.has_edge(src, tgt):
existing = edge_data(G, src, tgt)
if existing.get("relation") == attrs.get("relation") and (
existing.get("_src") == tgt and existing.get("_tgt") == src
):
continue
G.add_edge(src, tgt, **attrs)
hyperedges = extraction.get("hyperedges", [])
if hyperedges:
+59
View File
@@ -216,6 +216,65 @@ def test_build_merge_preserves_call_edge_direction(tmp_path):
)
def test_build_from_json_preserves_first_direction_on_bidirectional_pair(tmp_path):
"""Regression for #1061.
When an extraction emits two `calls` edges between the same pair in
opposite directions (mutual recursion, callbacks, event handlers, etc.),
nx.Graph collapses them into a single undirected edge. The deterministic
edge sort introduced in #1010 ordered edges by (source, target, relation),
so the lexicographically-later direction always wrote second and clobbered
the first edge's _src/_tgt — the surviving edge then exported with caller
and callee systematically swapped on every collision.
build_from_json must keep the first-seen direction for the surviving edge
instead of letting the second add_edge overwrite _src/_tgt.
"""
from graphify.export import to_json
# Lexicographic order of (src, tgt, rel) puts `a` < `z` first, so the sort
# processes `a -> z` BEFORE `z -> a`. Without the fix, the second write
# overwrites _src/_tgt and the exported edge becomes z -> a. With the fix,
# the first-seen `a -> z` direction is preserved.
extraction = {
"nodes": [
{"id": "a_handler", "label": "a", "file_type": "code", "source_file": "a.ts"},
{"id": "z_emitter", "label": "z", "file_type": "code", "source_file": "z.ts"},
],
"edges": [
{"source": "a_handler", "target": "z_emitter", "relation": "calls",
"confidence": "EXTRACTED", "source_file": "a.ts"},
{"source": "z_emitter", "target": "a_handler", "relation": "calls",
"confidence": "EXTRACTED", "source_file": "z.ts"},
],
"input_tokens": 0,
"output_tokens": 0,
}
G = build_from_json(extraction)
# Only one undirected edge between the pair survives, but its stored
# direction must be the first-seen one (a_handler -> z_emitter), not the
# lexicographically-later one (z_emitter -> a_handler).
assert G.number_of_edges() == 1
data = edge_data(G, "a_handler", "z_emitter")
assert data["_src"] == "a_handler"
assert data["_tgt"] == "z_emitter"
graph_path = tmp_path / "graph.json"
assert to_json(G, {}, str(graph_path), force=True)
saved = json.loads(graph_path.read_text())
saved_calls = [e for e in saved.get("links", saved.get("edges", []))
if e.get("relation") == "calls"]
assert len(saved_calls) == 1
assert saved_calls[0]["source"] == "a_handler", (
f"calls edge source flipped on bidirectional collision: "
f"expected a_handler, got {saved_calls[0]['source']}"
)
assert saved_calls[0]["target"] == "z_emitter", (
f"calls edge target flipped on bidirectional collision: "
f"expected z_emitter, got {saved_calls[0]['target']}"
)
# Regression tests for #796 — edge_data / edge_datas helpers must tolerate
# MultiGraph and MultiDiGraph, which networkx's node_link_graph() produces
# whenever the loaded JSON has multigraph: true. Plain G.edges[u, v] crashes
Generated
+1 -1
View File
@@ -1110,7 +1110,7 @@ wheels = [
[[package]]
name = "graphifyy"
version = "0.8.22"
version = "0.8.23"
source = { editable = "." }
dependencies = [
{ name = "datasketch" },