Skip to content

fix(query): render every edge between visited nodes, not just traversal edges - #2338

Open
Rishet11 wants to merge 1 commit into
Graphify-Labs:v8from
Rishet11:fix/2323-induced-subgraph-edges
Open

fix(query): render every edge between visited nodes, not just traversal edges#2338
Rishet11 wants to merge 1 commit into
Graphify-Labs:v8from
Rishet11:fix/2323-induced-subgraph-edges

Conversation

@Rishet11

@Rishet11 Rishet11 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Closes #2323

Thanks for the precise root cause — it held up. Both traversals recorded an edge only when it discovered an unvisited neighbour, so what query rendered was a traversal tree, not the induced subgraph over the node set it reports.

Before / after on your repro

Your two files, graphify extract . --code-only, then graphify query "How does checkout calculate the final total?":

# BEFORE — 3 edges, the calls edge missing
NODE checkout() [src=app.py loc=L3 community=0]
NODE discounted_total() [src=pricing.py loc=L1 community=0]
EDGE app.py --imports [EXTRACTED context=import]--> discounted_total() at=app.py:L1
EDGE pricing.py --contains [EXTRACTED]--> discounted_total() at=pricing.py:L1
EDGE app.py --contains [EXTRACTED]--> checkout() at=app.py:L3

# AFTER — 5 edges
...
EDGE app.py --imports_from [EXTRACTED context=import]--> pricing.py at=app.py:L1
EDGE checkout() --calls [EXTRACTED context=call]--> discounted_total() at=app.py:L4

One correction to the report

_dfs does not share the defect in the general case. It appends on push rather than on visit, so it already captured seed-to-seed and ordinary cross-edges — I searched every connected four- and five-node graph exhaustively and found no miss. Its only gap is an edge between two non-seed hubs: the hub guard means neither endpoint is ever expanded, so neither records it. That case has its own test rather than a _dfs test that would have passed trivially.

The fix

_complete_induced_edges, called at the end of both traversals.

  • Placed inside the traversals, not at the call site. _bfs/_dfs receive traversal_graph (serve.py:1054-1055), so the completion pass is bound to the context-filtered graph by construction and cannot resurrect a relation the user filtered out. At the call site that would be a thing to remember.
  • Scans edges incident to the visited set, not all of G.edges(), so cost tracks the subgraph. Bounded by O(2E). A visited hub is rescanned in full; that is unavoidable, since a hub-to-hub edge is precisely the case _dfs misses.
  • Dedup keys on the ordered pair when G.is_directed(), the unordered pair otherwise. On a DiGraph u->v and v->u are genuinely distinct (mutual recursion, circular imports) and unordered dedup would drop a real edge. The MCP path loads every graph with directed: True, so this matters there.
  • Self-loops skipped. A recursive function carries one, but no traversal ever recorded one, and surfacing them is a separate output change from the missing edges reported here.
  • Traversal edges keep their discovery order; completions are appended after.

Verification

  • 11 new tests in tests/test_query_induced_edges.py; 9 fail with the fix reverted
  • Full suite on base 4fe1109: 4 failed, 3895 passed, 3 skipped. With this change: 4 failed, 3906 passed, 3 skipped — identical failure sets (the 4 are pre-existing backend-detection tests reading real env vars)
  • tests/test_serve.py:476, the only exact-equality assertion on edges_seen, passes untouched
  • ruff check . clean; python -m tools.skillgen --check, --audit-coverage, --schema-singleton all OK

Known limitations

  • The MCP query_graph tool and its HTTP surface share this traversal, so their output gains edges too. Intentional; covered by a directed-graph test.
  • Parallel edges on a multigraph still collapse to one rendered line — the renderer already showed only the first (serve.py:944 pre-change). Unchanged here.
  • _bfs's own traversal order is hash-seed sensitive (frontier is a plain set). Pre-existing and untouched; the appended completions are sorted, so only the tail is deterministic.
  • benchmark.py:50-71 carries a hand-inlined copy of the same traversal-plus-render logic. It is token estimation only and not in the render path, so I left it alone to keep the diff traceable to this issue — flagging it in case you want it mirrored.

Property check, and one pre-existing oddity it surfaced

To confirm this holds generally and not just on the reported shape, I compared the returned edge list against the mathematically correct induced subgraph over 8,000 randomised traversals (2-9 nodes, directed and undirected, random densities, random seeds and depths, both _bfs and _dfs):

missing induced edges
base 4fe1109 34,547
this branch 0

That run also surfaced something pre-existing and unrelated to this change, which I did not touch: _dfs places 2,568 pairs in edges_seen whose endpoints were never visited. It appends on push, and a pushed node can then be dropped by the d > depth cut-off, so the pair stays in the list while the node never enters visited. The count is byte-identical before and after this PR, and _subgraph_to_text's if u in nodes and v in nodes guard means none of them ever render — so it is invisible junk in the return value rather than a bug in output. Mentioning it only because the property test made it visible; happy to open a separate issue if you would like it cleaned up.

…al edges

`graphify query` returned the right nodes but dropped real edges between them.
Both traversals recorded an edge only when it discovered an unvisited
neighbour, so the result was a traversal tree rather than the induced subgraph
over the node set the query reports.

`_bfs` marks every seed visited before the loop starts, so an edge between two
seeds could never be recorded — the reported symptom, where both endpoints
render and the edge between them does not. It drops ordinary cross-edges and
hub-adjacent edges for the same reason.

`_dfs` needed the same completion for a narrower case. It appends on push
rather than on visit, so it already captured seed-to-seed and cross-edges; an
exhaustive search over every connected four- and five-node graph found no miss.
Its one gap is an edge between two non-seed hubs, where the hub guard means
neither endpoint is ever expanded and so neither records it.

The new `_complete_induced_edges` runs at the end of both. It scans only edges
incident to the visited set, so cost tracks the subgraph rather than the whole
graph. A visited hub is rescanned in full, which is unavoidable: a hub-to-hub
edge is exactly the case `_dfs` misses. Placing the pass inside the traversals
rather than at the call site binds it to the context-filtered `traversal_graph`
by construction, so a relation the user filtered out cannot reappear.

Dedup keys on the ordered pair for directed graphs and the unordered pair
otherwise: on a DiGraph `u->v` and `v->u` are distinct edges (mutual recursion,
circular imports) and collapsing them would drop a real one. Parallel edges on
a multigraph collapse to one entry, matching the renderer, which already shows
only the first. Self-loops are skipped, since no traversal ever recorded one
and surfacing recursion edges is a separate output change.

Traversal edges keep their discovery order; completions are appended after.

Closes Graphify-Labs#2323
Copilot AI review requested due to automatic review settings July 31, 2026 04:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR addresses missing edges in graphify query output where the graph traversals (_bfs/_dfs) produced a traversal tree rather than the full induced subgraph over the visited node set (e.g., edges between two seed nodes or two unexpanded hubs were dropped). It introduces a new _complete_induced_edges helper that scans edges incident to visited nodes and appends any edges between visited nodes that weren't already recorded, with handling for self-loops, directed vs. undirected dedup, and context filtering; this helper is called at the end of both _bfs and _dfs. The PR also adds a new test file (tests/test_query_induced_edges.py) covering the reported seed-to-seed case, hub-to-hub edges, ordering/dedup invariants, context-filter respect, directed graphs, and an end-to-end CLI test.

No blocking issues surfaced. 2 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 421 functions depend on the 139 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: main() — 82 callers, 2 callees
  • worse: _query_graph_text() — 14 callers, 8 callees
  • worse: _bfs() — 15 callers, 1 callees

Verification — 421 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 359 function(s) in the blast radius were not formally verified this run

· 1 grounded finding(s) anchored inline below; 2 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/serve.py
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.

graphify query omits edges between seed nodes and other visited nodes

2 participants