mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-16 04:17:19 +00:00
ef0e6ee681
Use safe JSON serialization fallbacks for deterministic sort keys in clustering and graph canonicalization, and skip invalid community IDs with a stderr warning instead of raising during update rebuilds. Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
8.5 KiB
Python
222 lines
8.5 KiB
Python
"""Community detection on NetworkX graphs. Uses Leiden (graspologic) if available, falls back to Louvain (networkx). Splits oversized communities. Returns cohesion scores."""
|
|
from __future__ import annotations
|
|
import contextlib
|
|
import inspect
|
|
import io
|
|
import json
|
|
import sys
|
|
import networkx as nx
|
|
|
|
|
|
def _suppress_output():
|
|
"""Context manager to suppress stdout/stderr during library calls.
|
|
|
|
graspologic's leiden() emits ANSI escape sequences (progress bars,
|
|
colored warnings) that corrupt PowerShell 5.1's scroll buffer on
|
|
Windows (see issue #19). Redirecting stdout/stderr to devnull during
|
|
the call prevents this without losing any graphify output.
|
|
"""
|
|
return contextlib.redirect_stdout(io.StringIO())
|
|
|
|
|
|
def _partition(G: nx.Graph) -> dict[str, int]:
|
|
"""Run community detection. Returns {node_id: community_id}.
|
|
|
|
Tries Leiden (graspologic) first — best quality.
|
|
Falls back to Louvain (built into networkx) if graspologic is not installed.
|
|
|
|
Output from graspologic is suppressed to prevent ANSI escape codes
|
|
from corrupting terminal scroll buffers on Windows PowerShell 5.1.
|
|
"""
|
|
stable = nx.Graph()
|
|
stable.add_nodes_from(sorted(G.nodes(), key=str))
|
|
edge_rows = sorted(
|
|
G.edges(data=True),
|
|
key=lambda row: (
|
|
str(row[0]),
|
|
str(row[1]),
|
|
json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str),
|
|
),
|
|
)
|
|
for src, tgt, attrs in edge_rows:
|
|
stable.add_edge(src, tgt, **attrs)
|
|
|
|
try:
|
|
from graspologic.partition import leiden
|
|
lsig = inspect.signature(leiden).parameters
|
|
kwargs: dict = {}
|
|
if "random_seed" in lsig:
|
|
kwargs["random_seed"] = 42
|
|
if "trials" in lsig:
|
|
kwargs["trials"] = 1
|
|
# Suppress graspologic output to prevent ANSI escape codes from
|
|
# corrupting PowerShell 5.1 scroll buffer (issue #19)
|
|
old_stderr = sys.stderr
|
|
try:
|
|
sys.stderr = io.StringIO()
|
|
with _suppress_output():
|
|
result = leiden(stable, **kwargs)
|
|
finally:
|
|
sys.stderr = old_stderr
|
|
return result
|
|
except ImportError:
|
|
pass
|
|
|
|
# Fallback: networkx louvain (available since networkx 2.7).
|
|
# Inspect kwargs to stay compatible across NetworkX versions — max_level
|
|
# was added in a later release and prevents hangs on large sparse graphs.
|
|
kwargs: dict = {"seed": 42, "threshold": 1e-4}
|
|
if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
|
|
kwargs["max_level"] = 10
|
|
communities = nx.community.louvain_communities(stable, **kwargs)
|
|
return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
|
|
|
|
|
|
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
|
|
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
|
|
_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
|
|
_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
|
|
|
|
|
|
def cluster(G: nx.Graph) -> 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. DiGraphs are converted to undirected
|
|
internally since Louvain/Leiden require undirected input.
|
|
"""
|
|
if G.number_of_nodes() == 0:
|
|
return {}
|
|
if G.is_directed():
|
|
G = G.to_undirected()
|
|
if G.number_of_edges() == 0:
|
|
return {i: [n] for i, n in enumerate(sorted(G.nodes))}
|
|
|
|
# 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)
|
|
|
|
raw: dict[int, list[str]] = {}
|
|
if connected.number_of_nodes() > 0:
|
|
partition = _partition(connected)
|
|
for node, cid in partition.items():
|
|
raw.setdefault(cid, []).append(node)
|
|
|
|
# Each isolate becomes its own single-node community
|
|
next_cid = max(raw.keys(), default=-1) + 1
|
|
for node in isolates:
|
|
raw[next_cid] = [node]
|
|
next_cid += 1
|
|
|
|
# Split oversized communities
|
|
max_size = max(_MIN_SPLIT_SIZE, int(G.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))
|
|
else:
|
|
final_communities.append(nodes)
|
|
|
|
# Second pass: re-split low-cohesion communities caused by doc-hub nodes
|
|
# that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
|
|
second_pass: list[list[str]] = []
|
|
for nodes in final_communities:
|
|
if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
|
|
splits = _split_community(G, nodes)
|
|
second_pass.extend(splits if len(splits) > 1 else [nodes])
|
|
else:
|
|
second_pass.append(nodes)
|
|
final_communities = second_pass
|
|
|
|
# Re-index by size descending for deterministic ordering
|
|
final_communities.sort(key=len, reverse=True)
|
|
return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
|
|
|
|
|
|
def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]:
|
|
"""Run a second Leiden pass on a community subgraph to split it further."""
|
|
subgraph = G.subgraph(nodes)
|
|
if subgraph.number_of_edges() == 0:
|
|
# No edges - split into individual nodes
|
|
return [[n] for n in sorted(nodes)]
|
|
try:
|
|
sub_partition = _partition(subgraph)
|
|
sub_communities: dict[int, list[str]] = {}
|
|
for node, cid in sub_partition.items():
|
|
sub_communities.setdefault(cid, []).append(node)
|
|
if len(sub_communities) <= 1:
|
|
return [sorted(nodes)]
|
|
return [sorted(v) for v in sub_communities.values()]
|
|
except Exception:
|
|
return [sorted(nodes)]
|
|
|
|
|
|
def cohesion_score(G: nx.Graph, 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
|
|
return round(actual / possible, 2) if possible > 0 else 0.0
|
|
|
|
|
|
def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]:
|
|
return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()}
|
|
|
|
|
|
def remap_communities_to_previous(
|
|
communities: dict[int, list[str]],
|
|
previous_node_community: dict[str, int],
|
|
) -> dict[int, list[str]]:
|
|
"""Remap community IDs to maximize overlap with a previous assignment.
|
|
|
|
Uses greedy one-to-one matching by intersection size, then assigns fresh IDs
|
|
to unmatched communities in deterministic order (size desc, lexical tie-break).
|
|
"""
|
|
if not communities:
|
|
return {}
|
|
|
|
new_sets = {cid: set(nodes) for cid, nodes in communities.items()}
|
|
old_sets: dict[int, set[str]] = {}
|
|
for node, old_cid in previous_node_community.items():
|
|
old_sets.setdefault(old_cid, set()).add(node)
|
|
|
|
overlaps: list[tuple[int, int, int]] = []
|
|
for old_cid, old_nodes in old_sets.items():
|
|
for new_cid, new_nodes in new_sets.items():
|
|
overlap = len(old_nodes & new_nodes)
|
|
if overlap > 0:
|
|
overlaps.append((overlap, old_cid, new_cid))
|
|
overlaps.sort(key=lambda x: (-x[0], x[1], x[2]))
|
|
|
|
new_to_final: dict[int, int] = {}
|
|
used_old_ids: set[int] = set()
|
|
matched_new_ids: set[int] = set()
|
|
for _overlap, old_cid, new_cid in overlaps:
|
|
if old_cid in used_old_ids or new_cid in matched_new_ids:
|
|
continue
|
|
new_to_final[new_cid] = old_cid
|
|
used_old_ids.add(old_cid)
|
|
matched_new_ids.add(new_cid)
|
|
|
|
unmatched = [cid for cid in communities if cid not in matched_new_ids]
|
|
unmatched.sort(key=lambda cid: (-len(communities[cid]), tuple(sorted(communities[cid]))))
|
|
next_id = 0
|
|
for new_cid in unmatched:
|
|
while next_id in used_old_ids:
|
|
next_id += 1
|
|
new_to_final[new_cid] = next_id
|
|
used_old_ids.add(next_id)
|
|
next_id += 1
|
|
|
|
remapped: dict[int, list[str]] = {}
|
|
for new_cid, nodes in communities.items():
|
|
remapped[new_to_final[new_cid]] = sorted(nodes)
|
|
return dict(sorted(remapped.items(), key=lambda kv: kv[0]))
|