`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#2323