Summary
When two edges land on the same node pair, the simple graph keeps one — and which relation survives is decided alphabetically. references sorts after calls, so references wins every time. On graphify's own codebase that is 144 out of 144 pairs where the extraction found both: the graph says references for every one of them.
This is a systematic downgrade rather than a tie-break, and it is not cosmetic: callflow filters on relation and its list does not include references, so all 144 call sites leave the call graph.
Related to #2791 but distinct. #2791 is about edges being dropped by the simple-graph model. This is about the surviving edge carrying the wrong fact, which is a smaller and separately fixable problem.
Mechanism
graphify/build.py:
# Iterate edges in a deterministic order. ... when two edges collapse onto
# the same node pair the last write wins, so an unstable iteration order
# flips _src/_tgt run-to-run ... Sorting fixes the last-write outcome.
for edge in sorted(
extraction.get("edges", []),
key=lambda e: (
str(e.get("source", e.get("from", ""))),
str(e.get("target", e.get("to", ""))),
str(e.get("relation", "")), # <-- decides the surviving relation
),
):
The sort was added for determinism (#1061 — an unstable order flipped _src/_tgt between runs). The side effect is that the third key element also decides which relation wins, alphabetically:
calls < contains < imports < imports_from < indirect_call < method < rationale_for < references < uses
nx.Graph.add_edge overwrites the attribute dict of an existing edge, and the only same-pair guard (#1061) fires when the relation matches and the direction is reversed — so two edges with different relations always resolve to the alphabetically-last one.
Reproduce
On graphify's own package (81 files, 2287 nodes, 6176 extracted edges):
pairs where the extraction found BOTH `calls` and `references`: 144
relation on the surviving edge: {'references': 143, 'uses': 1}
pairs where a real `calls` was overwritten by a weaker relation: 144
graphify_build_build_from_json <-> graphify_build_py_path graph says 'references', extraction also found 'calls'
graphify_cache_py_path <-> graphify_cache_stat_key_to_relative graph says 'references', extraction also found 'calls'
...
pairs whose surviving relation is weaker than one the extraction found: 146
Minimal case:
from graphify.build import build_from_json, edge_data
ex = {"nodes": [{"id": "a", "label": "a()", "file_type": "code", "source_file": "a.py"},
{"id": "b", "label": "b()", "file_type": "code", "source_file": "b.py"}],
"edges": [{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED"},
{"source": "a", "target": "b", "relation": "references", "confidence": "EXTRACTED"}],
"hyperedges": []}
G = build_from_json(ex)
print(edge_data(G, "a", "b")["relation"]) # -> 'references'
Swapping the two edges in the list makes no difference: the sort reorders them either way.
Why it matters
- callflow loses the call.
callflow_html.py keeps edges whose relation is in ("calls", "imports", "imports_from", "uses", "method", "indirect_call"). references is not in that set, so a downgraded pair is simply absent from the call graph. On this corpus that is 143 edges — 2788 visible instead of 2931.
- The audit trail states the weaker fact. An edge the extractor tagged
EXTRACTED / calls is reported as references, and nothing records that a call was found.
- "What calls X" is answered from it.
query, path and explain all read the surviving relation.
Every extractor that emits a call also tends to emit a reference for the same pair, so this fires on ordinary code rather than on some unusual shape — the 144/144 rate is the tell.
Suggested fix
A generic relation (references, uses, mentions — the ones that say only "these two appear together") should never overwrite a specific one on the same pair. The reverse should still overwrite, so the outcome stops depending on arrival order in either direction.
A small denylist seems better here than a full precedence order over every relation: ranking contains against calls would be inventing a cross-axis judgement, whereas "specific beats generic" is the only comparison this collapse needs. Two specific relations can keep their current behaviour.
Measured on the graph above:
|
before |
after |
calls edges |
1914 |
2060 |
references edges |
548 |
405 |
| edges visible to callflow |
2788 |
2931 |
| pairs reporting a weaker relation |
146 |
0 |
| total edges |
5396 |
5396 |
Node and edge counts are unchanged — this only changes which of the colliding edges survives.
I have this working with tests and will open a PR shortly.
Environment
|
|
| graphify |
v8 @ 4fca621 (0.9.44) |
| Python |
3.12 |
Summary
When two edges land on the same node pair, the simple graph keeps one — and which relation survives is decided alphabetically.
referencessorts aftercalls, soreferenceswins every time. On graphify's own codebase that is 144 out of 144 pairs where the extraction found both: the graph saysreferencesfor every one of them.This is a systematic downgrade rather than a tie-break, and it is not cosmetic:
callflowfilters on relation and its list does not includereferences, so all 144 call sites leave the call graph.Related to #2791 but distinct. #2791 is about edges being dropped by the simple-graph model. This is about the surviving edge carrying the wrong fact, which is a smaller and separately fixable problem.
Mechanism
graphify/build.py:The sort was added for determinism (#1061 — an unstable order flipped
_src/_tgtbetween runs). The side effect is that the third key element also decides which relation wins, alphabetically:nx.Graph.add_edgeoverwrites the attribute dict of an existing edge, and the only same-pair guard (#1061) fires when the relation matches and the direction is reversed — so two edges with different relations always resolve to the alphabetically-last one.Reproduce
On graphify's own package (81 files, 2287 nodes, 6176 extracted edges):
Minimal case:
Swapping the two edges in the list makes no difference: the sort reorders them either way.
Why it matters
callflow_html.pykeeps edges whose relation is in("calls", "imports", "imports_from", "uses", "method", "indirect_call").referencesis not in that set, so a downgraded pair is simply absent from the call graph. On this corpus that is 143 edges — 2788 visible instead of 2931.EXTRACTED/callsis reported asreferences, and nothing records that a call was found.query,pathandexplainall read the surviving relation.Every extractor that emits a call also tends to emit a reference for the same pair, so this fires on ordinary code rather than on some unusual shape — the 144/144 rate is the tell.
Suggested fix
A generic relation (
references,uses,mentions— the ones that say only "these two appear together") should never overwrite a specific one on the same pair. The reverse should still overwrite, so the outcome stops depending on arrival order in either direction.A small denylist seems better here than a full precedence order over every relation: ranking
containsagainstcallswould be inventing a cross-axis judgement, whereas "specific beats generic" is the only comparison this collapse needs. Two specific relations can keep their current behaviour.Measured on the graph above:
callsedgesreferencesedgesNode and edge counts are unchanged — this only changes which of the colliding edges survives.
I have this working with tests and will open a PR shortly.
Environment
v8@4fca621(0.9.44)