Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,31 @@ def _is_json_key_node(G: nx.Graph, node_id: str) -> bool:
return label in _JSON_NOISE_LABELS


def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
def god_nodes(G: nx.Graph, top_n: int = 10,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regressiongod_nodes()

32 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

exclude_hubs_percentile: float | None = None) -> list[dict]:
"""Return the top_n most-connected real entities - the core abstractions.

File-level hub nodes are excluded: they accumulate import/contains edges
mechanically and don't represent meaningful architectural abstractions.

``exclude_hubs_percentile`` (0-100) suppresses nodes whose degree exceeds
that percentile of the graph's degree distribution, using the same
threshold computation ``cluster()`` applies (#3205) - so the one setting
suppresses utility hubs in the ranking AND in community resolution,
instead of only the latter. ``None`` keeps the historical ranking.
"""
degree = dict(G.degree())
hub_threshold: float | None = None
if exclude_hubs_percentile is not None:
degrees = sorted(degree.values())
if degrees:
idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
hub_threshold = degrees[idx]
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
result = []
for node_id, deg in sorted_nodes:
if hub_threshold is not None and deg > hub_threshold:
continue
if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id):
continue
if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS:
Expand Down
23 changes: 20 additions & 3 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,7 @@ def dispatch_command(cmd: str) -> None:
from graphify.security import sanitize_label as _sanitize_label
graph_path = _default_graph_path()
top_n = 10
gn_exclude_hubs: float | None = None
as_json = "--json" in sys.argv
args = sys.argv[2:]
i = 0
Expand All @@ -1422,6 +1423,20 @@ def dispatch_command(cmd: str) -> None:
print("error: --top must be an integer", file=sys.stderr)
sys.exit(1)
i += 1
elif args[i] == "--exclude-hubs" and i + 1 < len(args):
try:
gn_exclude_hubs = float(args[i + 1])
except ValueError:
print("error: --exclude-hubs must be a number (percentile 0-100)", file=sys.stderr)
sys.exit(1)
i += 2
elif args[i].startswith("--exclude-hubs="):
try:
gn_exclude_hubs = float(args[i].split("=", 1)[1])
except ValueError:
print("error: --exclude-hubs must be a number (percentile 0-100)", file=sys.stderr)
sys.exit(1)
i += 1
else:
i += 1
gp = Path(graph_path).resolve()
Expand All @@ -1436,7 +1451,7 @@ def dispatch_command(cmd: str) -> None:
except Exception as exc:
print(f"error: could not load graph: {exc}", file=sys.stderr)
sys.exit(1)
gods = _god_nodes(G, top_n=top_n)
gods = _god_nodes(G, top_n=top_n, exclude_hubs_percentile=gn_exclude_hubs)
if as_json:
print(json.dumps(gods, indent=2))
else:
Expand Down Expand Up @@ -2099,7 +2114,7 @@ def dispatch_command(cmd: str) -> None:
communities = remap_communities_to_previous(communities, previous_node_community)
stages.mark("cluster")
cohesion = score_all(G, communities)
gods = god_nodes(G)
gods = god_nodes(G, exclude_hubs_percentile=co_exclude_hubs)
surprises = surprising_connections(G, communities)
stages.mark("analyze")
# Where outputs (GRAPH_REPORT.md, re-clustered graph.json, labels,
Expand Down Expand Up @@ -4376,7 +4391,9 @@ def _invalidate_file_manifest_for_db_graph() -> None:
stages.mark("cluster")
cohesion = _score_all(G, communities)
try:
gods = _god_nodes(G)
# The percentile that suppressed hubs in cluster() above suppresses
# them in the ranking too (#3205).
gods = _god_nodes(G, exclude_hubs_percentile=cli_exclude_hubs)
except Exception:
gods = []
try:
Expand Down
12 changes: 10 additions & 2 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1670,7 +1670,11 @@ async def list_tools() -> list[types.Tool]:
types.Tool(
name="god_nodes",
description="Return the most connected nodes - the core abstractions of the knowledge graph.",
inputSchema={"type": "object", "properties": {"top_n": {"type": "integer", "default": 10}}},
inputSchema={"type": "object", "properties": {
"top_n": {"type": "integer", "default": 10},
"exclude_hubs_percentile": {"type": "number",
"description": "Suppress nodes whose degree exceeds this percentile (0-100) of the degree distribution, matching cluster()'s hub exclusion"},
}},
),
types.Tool(
name="graph_stats",
Expand Down Expand Up @@ -1873,7 +1877,11 @@ def _tool_get_community(arguments: dict) -> str:

def _tool_god_nodes(arguments: dict) -> str:
from graphify.analyze import god_nodes as _god_nodes
nodes = _god_nodes(G, top_n=int(arguments.get("top_n", 10)))
_pct = arguments.get("exclude_hubs_percentile")
nodes = _god_nodes(
G, top_n=int(arguments.get("top_n", 10)),
exclude_hubs_percentile=float(_pct) if _pct is not None else None,
)
lines = ["God nodes (most connected):"]
lines += [f" {i}. {n['label']} - {n['degree']} edges" for i, n in enumerate(nodes, 1)]
return "\n".join(lines)
Expand Down
113 changes: 113 additions & 0 deletions tests/test_god_nodes_exclude_hubs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""god_nodes() honours exclude_hubs_percentile (#3205).

The percentile only ever reached cluster()'s community-resolution step; the
god-node ranking had no exclusion parameter at all, so running it at every
percentile value returned identical output and utility hubs stayed at the
top regardless of the setting. The same threshold computation cluster()
uses now applies to the ranking, and the CLI/MCP surfaces expose it.
"""
from __future__ import annotations

import inspect
import json

import networkx as nx
import pytest

from graphify.analyze import god_nodes


def _graph():
"""One mega-hub (degree 40), two mid symbols, a tail of leaves.

The hub label must not be in _BUILTIN_NOISE_LABELS - the ranking already
filters those - so the test isolates the percentile mechanism."""
G = nx.Graph()
G.add_node("hub", label="Registry", file_type="code", source_file="u.py", source_location="L1")
for i in range(40):
G.add_node(f"leaf{i}", label=f"leaf{i}", file_type="code",
source_file=f"l{i}.py", source_location="L1")
G.add_edge("hub", f"leaf{i}", relation="calls")
for name, deg in (("core", 6), ("svc", 4)):
G.add_node(name, label=name, file_type="code", source_file=f"{name}.py",
source_location="L1")
for i in range(deg):
G.add_edge(name, f"leaf{i}", relation="calls")
return G


def test_without_the_parameter_the_hub_still_ranks_first():
assert god_nodes(_graph(), top_n=3)[0]["label"] == "Registry"


def test_the_percentile_suppresses_the_hub():
gods = god_nodes(_graph(), top_n=10, exclude_hubs_percentile=90)
labels = [g["label"] for g in gods]
assert "Registry" not in labels, labels
assert gods, "suppressing the hub must not empty the ranking"
assert max(g["degree"] for g in gods) < 40


def test_the_threshold_matches_clusters_computation():
"""The ranking must exclude exactly what cluster()'s formula excludes:
degrees sorted ascending, idx = max(0, int(n * pct / 100) - 1),
everything with degree > degrees[idx] is a hub."""
G = _graph()
pct = 90
degrees = sorted(d for _, d in G.degree())
idx = max(0, int(len(degrees) * pct / 100) - 1)
threshold = degrees[idx]
expected_hubs = {n for n, d in G.degree() if d > threshold}
assert "hub" in expected_hubs
gods = {g["id"] for g in god_nodes(G, top_n=100, exclude_hubs_percentile=pct)}
assert gods.isdisjoint(expected_hubs)
assert gods, "non-hub symbols must survive"


def test_percentile_100_excludes_nothing():
gods = god_nodes(_graph(), top_n=3, exclude_hubs_percentile=100)
assert gods[0]["label"] == "Registry"


def test_the_analyzer_signature_stays_backward_compatible():
sig = inspect.signature(god_nodes)
assert sig.parameters["exclude_hubs_percentile"].default is None


# ---------------------------------------------------------------------------
# CLI surface
# ---------------------------------------------------------------------------

def test_the_cli_command_takes_the_flag(tmp_path, monkeypatch, capsys):
import graphify.__main__ as mainmod
from graphify.export import to_json
G = _graph()
gp = tmp_path / "graph.json"
to_json(G, {0: list(G.nodes)}, str(gp))
monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_a, **_k: None)

def run(*extra):
monkeypatch.setattr(mainmod.sys, "argv",
["graphify", "god-nodes", "--graph", str(gp), "--json", *extra])
try:
mainmod.main()
except SystemExit as exc:
assert exc.code in (None, 0)
return json.loads(capsys.readouterr().out)

assert run()[0]["label"] == "Registry"
filtered = run("--exclude-hubs", "90")
assert filtered and all(g["label"] != "Registry" for g in filtered)
filtered2 = run("--exclude-hubs=90")
assert filtered2 and all(g["label"] != "Registry" for g in filtered2)


def test_a_bad_flag_value_is_a_usage_error(tmp_path, monkeypatch, capsys):
import graphify.__main__ as mainmod
monkeypatch.setattr(mainmod, "_check_skill_version", lambda *_a, **_k: None)
monkeypatch.setattr(mainmod.sys, "argv",
["graphify", "god-nodes", "--exclude-hubs", "lots"])
with pytest.raises(SystemExit) as info:
mainmod.main()
assert info.value.code == 1
assert "--exclude-hubs" in capsys.readouterr().err
Loading