diff --git a/graphify/analyze.py b/graphify/analyze.py index 844a7c0d2..df1c20661 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -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, + 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: diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d5..bfcb3d928 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 @@ -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() @@ -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: @@ -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, @@ -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: diff --git a/graphify/serve.py b/graphify/serve.py index a9ecd3540..4eb3072f9 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -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", @@ -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) diff --git a/tests/test_god_nodes_exclude_hubs.py b/tests/test_god_nodes_exclude_hubs.py new file mode 100644 index 000000000..b87c8c7d7 --- /dev/null +++ b/tests/test_god_nodes_exclude_hubs.py @@ -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