Skip to content

[build] Add multigraph option to preserve parallel edges - #709

Open
ibrahimyuecel wants to merge 1 commit into
Graphify-Labs:v7from
ibrahimyuecel:multigraph-option
Open

[build] Add multigraph option to preserve parallel edges#709
ibrahimyuecel wants to merge 1 commit into
Graphify-Labs:v7from
ibrahimyuecel:multigraph-option

Conversation

@ibrahimyuecel

Copy link
Copy Markdown

[build] Add MultiGraph option to preserve parallel edges between same node pair

Summary

build_from_json() returns nx.Graph (undirected, single edge per pair). When
multiple relations exist between the same (src, tgt) pair — which is the
norm for code graphs (imports + references, method + instantiates,
contains + calls) — only one survives.

This PR adds multigraph=True parameter to build_from_json() (and a --multigraph
CLI flag) to opt into nx.MultiGraph / nx.MultiDiGraph, preserving every
distinct relation as its own edge.

Why this matters

Tested on a 21,627-node TypeScript monorepo (NestJS + Next.js 16, after this
project's local augmenter applied):

Pairs with multiple relations in graph: 2,767
Total edges hidden by undirected Graph:  2,767  (~%8 of all edges)

Sample lost edges:

src/main.ts <-> getListenHost():  imports + references     (only one survives)
ParcelMatchSuggestion class <-> .reject():  method + instantiates    (only one)
exceptions/*.ts:  3 different methods + instantiations on same exception

These hidden edges break common queries:

  • graphify path "Module" "Service" — returns imports but misses the
    references (named import) — caller traceability lost
  • graphify explain "ExceptionClass" — degree under-counted because
    multiple constructors that throw it collapse to one edge
  • Custom analysis: PageRank / centrality scores skewed by missing parallel edges

Changes

1. build.py — opt-in MultiGraph

def build_from_json(extraction: dict, *, directed: bool = False,
                    multigraph: bool = False) -> nx.Graph:
    """Build graph from extraction dict.

    directed=True       → preserves edge direction (source→target)
    multigraph=True     → preserves multiple relations between same node pair
    """
    if multigraph:
        G = nx.MultiDiGraph() if directed else nx.MultiGraph()
    else:
        G = nx.DiGraph() if directed else nx.Graph()

    # ... existing node + edge ingestion logic, unchanged ...

    return G

2. serve.py — handle MultiGraph in iteration

NetworkX MultiGraph requires .edges(data=True, keys=True) to enumerate
parallel edges. serve.py BFS/DFS traversal currently uses .edges(u, v)
which returns only one edge per pair on MultiGraph (drops parallel edges
during query traversal too).

# In _bfs / _dfs (serve.py):
def _enumerate_edges(G, u, v):
    if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
        return [(u, v, k, G.edges[u, v, k]) for k in G[u][v]]
    return [(u, v, None, G.edges[u, v])]

3. CLI flag

# In __main__.py argparse:
ap.add_argument("--multigraph", action="store_true",
                help="Preserve parallel edges (multiple relations between same pair)")

4. to_json / to_html / to_obsidian — emit edge keys

NetworkX node_link_data already serializes MultiGraph keys when present, so
exports work without modification. HTML viz needs minor update to render
multiple edges between same pair (offset curves).

Backward compatibility

  • Default is multigraph=False — identical behaviour to current.
  • Opt-in via flag means no surprise behavioural change for existing users.
  • Graph file format: data["multigraph"] flag already exists in node_link
    serialization; consumers (serve.py) detect it.

Test fixture

tests/fixtures/multigraph_collision.py:

"""Two relations between same pair: import + reference."""
from foo import bar  # → import edge

def baz():
    return bar()     # → call/reference edge

Expected:

  • multigraph=False: 1 edge (baz → bar with one relation, last-write wins)
  • multigraph=True: 2 edges (imports + calls)

Out of scope

  • Cypher / GraphML exports of MultiGraph: trivially handled (both formats
    support parallel edges natively); just confirm round-trip in tests.
  • Visualization rendering of parallel edges in graph.html: separate PR with
    vis.js multi-edge support.

Tested against

  • SharedModel monorepo: ~%8 more edges preserved (2,767 added on a 32,440-edge
    graph). Custom local augmenter for TypeScript already produced references
    edges that get lost on import paths today.

Add multigraph=True parameter to build_from_json() that produces
nx.MultiGraph (or nx.MultiDiGraph if directed=True) instead of nx.Graph.
This preserves parallel edges with different `relation` values between
the same node pair, which today collapse silently.

Default (multigraph=False) keeps the legacy single-edge behaviour, so
existing graphs round-trip identically.

Why this matters:
- Code graphs routinely have multiple relations between same pair:
  (file, file): imports + references
  (class, method): method + instantiates
  (module, exception): provides + throws
- Measured on a 21,627-node TypeScript monorepo: 2,767 pairs (~%8 of
  unique pairs) carry multiple relations that currently collapse.

serve.py is already MultiGraph-aware in _filter_graph_by_context (uses
edges(keys=True, data=True) when isinstance MultiGraph), so traversal
helpers benefit automatically.

Smoke test:
  build_from_json(extraction with 3 (a,b) edges, multigraph=False) -> 1 edge
  build_from_json(extraction with 3 (a,b) edges, multigraph=True)  -> 3 edges
@jippi

jippi commented May 4, 2026

Copy link
Copy Markdown
Contributor

Hit a related-but-distinct case of the same bug class on a 1,873-file SvelteKit codebase, supporting this PR's design. Worth flagging here in case it shapes the implementation.

This PR's evidence covers same-pair multi-relation collapse (imports + references, method + instantiates). I additionally observed same-pair bidirectional-direction collapse:

  • BlurHash.svelte imports compute_scale from ImageV2.svelte
  • ImageV2.svelte imports <BlurHash> from BlurHash.svelte

Two real edges in the source. After build_from_json(result) in _rebuild_code (no directed=True), they collapse to a single undirected edge with one direction's information lost. Querying "incoming edges to BlurHash" returns zero, so dead-component / "where is X used" tooling false-flags it.

On this repo: 43 such edges (41 imports_from, 2 calls) compared to ~13k total — small in absolute terms, but it specifically breaks the bidirectional-import case which is structurally legal and not rare in component libraries.

MultiDiGraph (the directed variant of this PR's option) would preserve both ends. So the fix here covers our case too — provided _rebuild_code adopts multigraph=True, directed=True (or equivalent) once the option lands. Otherwise the option exists but the default code path still drops edges.

Methodology note for anyone validating: _rebuild_code preserves nodes/edges from a previous graph.json whose IDs aren't in the new AST output, which inflates incremental-rebuild edge counts. Wipe graphify-out/ fully before measuring impact — I burned a few hours trusting incremental numbers before catching this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants