Skip to content

Edge collapse picks the surviving relation alphabetically, so references overwrites calls every time (144/144 pairs) and those calls leave the call graph #2803

Description

@abhay-codes07

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions