From dc26423820d3fc6e300256a9ae76b7acb6d6af6e Mon Sep 17 00:00:00 2001 From: freezingflamecode <274730460+freezingflamecode@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:25:05 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20--directed=20flag=20to=20preserve?= =?UTF-8?q?=20edge=20direction=20(source=E2=86=92target)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undirected graphs store edges in arbitrary order, causing 60% of arrows in HTML visualization to point the wrong way. This adds an opt-in --directed flag that builds a DiGraph, preserving call/import direction. Default remains undirected for backward compatibility. Louvain/Leiden community detection converts to undirected internally as required. Includes worked example with A/B test evidence (worked/directed-graph/). --- README.md | 1 + graphify/analyze.py | 4 +++- graphify/build.py | 19 +++++++++++----- graphify/cluster.py | 25 +++++++++++++-------- graphify/export.py | 7 +++--- graphify/skill-claw.md | 23 ++++++++++--------- graphify/skill-codex.md | 23 ++++++++++--------- graphify/skill-droid.md | 23 ++++++++++--------- graphify/skill-opencode.md | 23 ++++++++++--------- graphify/skill-trae.md | 23 ++++++++++--------- graphify/skill-windows.md | 23 ++++++++++--------- graphify/skill.md | 23 ++++++++++--------- graphify/watch.py | 5 +++-- worked/directed-graph/review.md | 39 +++++++++++++++++++++++++++++++++ 14 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 worked/directed-graph/review.md diff --git a/README.md b/README.md index 6423a8774..a1d5a773b 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ When the user types `/graphify`, invoke the Skill tool with `skill: "graphify"` /graphify ./raw # run on a specific folder /graphify ./raw --mode deep # more aggressive INFERRED edge extraction /graphify ./raw --update # re-extract only changed files, merge into existing graph +/graphify ./raw --directed # build directed graph (preserves edge direction: source→target) /graphify ./raw --cluster-only # rerun clustering on existing graph, no re-extraction /graphify ./raw --no-viz # skip HTML, just produce report + JSON /graphify ./raw --obsidian # also generate Obsidian vault (opt-in) diff --git a/graphify/analyze.py b/graphify/analyze.py index 50f85acaf..1da426bde 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -468,7 +468,9 @@ def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict: for n in removed_node_ids ] - def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple: + def edge_key(G: nx.Graph | nx.DiGraph, u: str, v: str, data: dict) -> tuple: + if G.is_directed(): + return (u, v, data.get("relation", "")) return (min(u, v), max(u, v), data.get("relation", "")) old_edge_keys = { diff --git a/graphify/build.py b/graphify/build.py index 1bcb51b17..4e49cfbd8 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -26,13 +26,18 @@ from .validate import validate_extraction -def build_from_json(extraction: dict) -> nx.Graph: +def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph | nx.DiGraph: + """Build a NetworkX graph from an extraction dict. + + directed=True produces a DiGraph that preserves edge direction (source→target). + directed=False (default) produces an undirected Graph for backward compatibility. + """ errors = validate_extraction(extraction) # Dangling edges (stdlib/external imports) are expected - only warn about real schema errors. real_errors = [e for e in errors if "does not match any node id" not in e] if real_errors: print(f"[graphify] Extraction warning ({len(real_errors)} issues): {real_errors[0]}", file=sys.stderr) - G = nx.Graph() + G = nx.DiGraph() if directed else nx.Graph() for node in extraction.get("nodes", []): G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"}) node_set = set(G.nodes()) @@ -41,8 +46,7 @@ def build_from_json(extraction: dict) -> nx.Graph: if src not in node_set or tgt not in node_set: continue # skip edges to external/stdlib nodes - expected, not an error attrs = {k: v for k, v in edge.items() if k not in ("source", "target")} - # Preserve original edge direction - undirected graphs lose it otherwise, - # causing display functions to show edges backwards. + # _src/_tgt kept for backward compatibility with existing consumers. attrs["_src"] = src attrs["_tgt"] = tgt G.add_edge(src, tgt, **attrs) @@ -52,9 +56,12 @@ def build_from_json(extraction: dict) -> nx.Graph: return G -def build(extractions: list[dict]) -> nx.Graph: +def build(extractions: list[dict], *, directed: bool = False) -> nx.Graph | nx.DiGraph: """Merge multiple extraction results into one graph. + directed=True produces a DiGraph that preserves edge direction (source→target). + directed=False (default) produces an undirected Graph for backward compatibility. + Extractions are merged in order. For nodes with the same ID, the last extraction's attributes win (NetworkX add_node overwrites). Pass AST results before semantic results so semantic labels take precedence, or @@ -67,4 +74,4 @@ def build(extractions: list[dict]) -> nx.Graph: combined["hyperedges"].extend(ext.get("hyperedges", [])) combined["input_tokens"] += ext.get("input_tokens", 0) combined["output_tokens"] += ext.get("output_tokens", 0) - return build_from_json(combined) + return build_from_json(combined, directed=directed) diff --git a/graphify/cluster.py b/graphify/cluster.py index 363d555c8..328381631 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -56,22 +56,28 @@ def _partition(G: nx.Graph) -> dict[str, int]: _MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes -def cluster(G: nx.Graph) -> dict[int, list[str]]: +def cluster(G: nx.Graph | nx.DiGraph) -> dict[int, list[str]]: """Run Leiden community detection. Returns {community_id: [node_ids]}. Community IDs are stable across runs: 0 = largest community after splitting. Oversized communities (> 25% of graph nodes, min 10) are split by running a second Leiden pass on the subgraph. + + Accepts directed or undirected graphs. Directed graphs are converted to + undirected internally since Louvain/Leiden require undirected input. """ if G.number_of_nodes() == 0: return {} if G.number_of_edges() == 0: return {i: [n] for i, n in enumerate(sorted(G.nodes))} + # Louvain/Leiden require undirected graphs + G_undirected = G.to_undirected() if G.is_directed() else G + # Leiden warns and drops isolates - handle them separately - isolates = [n for n in G.nodes() if G.degree(n) == 0] - connected_nodes = [n for n in G.nodes() if G.degree(n) > 0] - connected = G.subgraph(connected_nodes) + isolates = [n for n in G_undirected.nodes() if G_undirected.degree(n) == 0] + connected_nodes = [n for n in G_undirected.nodes() if G_undirected.degree(n) > 0] + connected = G_undirected.subgraph(connected_nodes) raw: dict[int, list[str]] = {} if connected.number_of_nodes() > 0: @@ -85,12 +91,12 @@ def cluster(G: nx.Graph) -> dict[int, list[str]]: raw[next_cid] = [node] next_cid += 1 - # Split oversized communities - max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) + # Split oversized communities (use undirected for Leiden compatibility) + max_size = max(_MIN_SPLIT_SIZE, int(G_undirected.number_of_nodes() * _MAX_COMMUNITY_FRACTION)) final_communities: list[list[str]] = [] for nodes in raw.values(): if len(nodes) > max_size: - final_communities.extend(_split_community(G, nodes)) + final_communities.extend(_split_community(G_undirected, nodes)) else: final_communities.append(nodes) @@ -117,14 +123,15 @@ def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]: return [sorted(nodes)] -def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float: +def cohesion_score(G: nx.Graph | nx.DiGraph, community_nodes: list[str]) -> float: """Ratio of actual intra-community edges to maximum possible.""" n = len(community_nodes) if n <= 1: return 1.0 subgraph = G.subgraph(community_nodes) actual = subgraph.number_of_edges() - possible = n * (n - 1) / 2 + # Directed: max edges = n*(n-1); undirected: n*(n-1)/2 + possible = n * (n - 1) if G.is_directed() else n * (n - 1) / 2 return round(actual / possible, 2) if possible > 0 else 0.0 diff --git a/graphify/export.py b/graphify/export.py index 16271a730..affeaa004 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -104,8 +104,9 @@ def _hyperedge_script(hyperedges_json: str) -> str: """ -def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: +def _html_script(nodes_json: str, edges_json: str, legend_json: str, *, directed: bool = False) -> str: return f"""