diff --git a/graphify/__main__.py b/graphify/__main__.py index 3ff348c..01b69b7 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -3559,17 +3559,20 @@ def main() -> None: else: # No labels file yet (or `graphify label` forced a refresh). When run # standalone there is no orchestrating agent to do skill.md Step 5, so - # auto-name communities with the configured backend rather than leave - # "Community N" (#1097). Degrades to placeholders if no backend/on error. + # auto-name communities rather than leave "Community N" (#1097). + from graphify.cluster import label_communities_by_hub from graphify.llm import generate_community_labels print("Labeling communities...") - # The final labels (LLM or placeholder fallback) are persisted to - # .graphify_labels.json by the unconditional write below. + # Deterministic, LLM-free base labels: name each community after its + # highest-degree hub, so the report is readable even with no backend + # (previously bare "Community N"). A configured LLM backend overrides these + # with richer names below; its no-backend placeholder fallback does NOT. + hub_labels = label_communities_by_hub(G, communities) label_communities_input = communities - labels = {} + labels = dict(hub_labels) if missing_only: labels = { - cid: existing_labels.get(cid, f"Community {cid}") + cid: existing_labels.get(cid, hub_labels[cid]) for cid in communities } label_communities_input = { @@ -3581,7 +3584,13 @@ def main() -> None: G, label_communities_input, backend=label_backend, model=label_model, gods=gods, max_concurrency=label_max_concurrency, batch_size=label_batch_size, ) - labels.update(generated_labels) + # Only let the LLM OVERRIDE where it produced a real name — its no-backend + # fallback returns "Community {cid}" placeholders, which must not clobber + # the deterministic hub labels. + labels.update({ + cid: v for cid, v in generated_labels.items() + if v and v != f"Community {cid}" + }) stages.mark("label") questions = suggest_questions(G, communities, labels) tokens = {"input": 0, "output": 0} diff --git a/graphify/cluster.py b/graphify/cluster.py index 82f063d..d35c90d 100644 --- a/graphify/cluster.py +++ b/graphify/cluster.py @@ -83,6 +83,33 @@ _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 label_communities_by_hub( + G: nx.Graph, communities: dict[int, list[str]] +) -> dict[int, str]: + """Deterministic, LLM-free community labels: name each community after its + highest-degree member — the structural hub — so a report reads ``auth`` / + ``log_action`` instead of ``Community 70``. Degree is measured on the full graph + ``G``; ties break by node id for run-to-run stability. A community whose members + are all absent from ``G`` falls back to ``Community {cid}``. + + Used as the default (no-backend) labeler; an LLM naming pass, when configured, + overrides these with richer names. + """ + labels: dict[int, str] = {} + for cid, members in communities.items(): + present = [n for n in members if n in G] + if not present: + labels[cid] = f"Community {cid}" + continue + # highest degree wins; ties broken by node id (ascending) for determinism + hub = min(present, key=lambda n: (-G.degree(n), str(n))) + name = str(G.nodes[hub].get("label") or hub).strip() + if name.endswith("()"): + name = name[:-2] + labels[cid] = name or f"Community {cid}" + return labels + + def cluster( G: nx.Graph, resolution: float = 1.0, diff --git a/graphify/watch.py b/graphify/watch.py index c877036..c3fa5ed 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -803,9 +803,12 @@ def _rebuild_code( except Exception: raw = {} labels = {} - for cid in communities: - if cid not in labels: - labels[cid] = "Community " + str(cid) + missing = {cid: members for cid, members in communities.items() if cid not in labels} + if missing: + # Deterministic hub name (highest-degree member) beats a bare "Community N" + # placeholder for any community without a saved label. + from graphify.cluster import label_communities_by_hub + labels.update(label_communities_by_hub(G, missing)) questions = suggest_questions(G, communities, labels) from graphify.report import load_learning_for_report as _llfr report = generate(G, communities, cohesion, labels, gods, surprises, detection, diff --git a/tests/test_community_hub_labels.py b/tests/test_community_hub_labels.py new file mode 100644 index 0000000..1e50abc --- /dev/null +++ b/tests/test_community_hub_labels.py @@ -0,0 +1,66 @@ +"""Deterministic, LLM-free community labels — `label_communities_by_hub`. + +Names each community after its highest-degree member so a report reads "log_action" +instead of "Community 70", with no backend. Ties break by node id for run-to-run +stability; a community with no members present in the graph falls back to "Community N". +""" +import networkx as nx + +from graphify.cluster import label_communities_by_hub + + +def _g(node_labels, edges): + g = nx.Graph() + for nid, label in node_labels.items(): + if label is None: + g.add_node(nid) + else: + g.add_node(nid, label=label) + g.add_edges_from(edges) + return g + + +def test_labels_by_highest_degree_hub(): + # 'a' is the hub (degree 3); the community is named after it, "()" stripped. + g = _g( + {"a": "log_action()", "b": "b()", "c": "c()", "d": "d()"}, + [("a", "b"), ("a", "c"), ("a", "d")], + ) + labels = label_communities_by_hub(g, {0: ["a", "b", "c", "d"]}) + assert labels[0] == "log_action" + + +def test_not_a_placeholder_for_a_real_community(): + g = _g({"a": "handler()", "b": "b()"}, [("a", "b")]) + labels = label_communities_by_hub(g, {0: ["a", "b"]}) + assert labels[0] == "handler" and labels[0] != "Community 0" + + +def test_tie_breaks_deterministically_by_node_id(): + # both nodes degree 1 → the lexicographically smaller id wins, regardless of order + g = _g({"z": "z()", "a": "a()"}, [("z", "a")]) + assert label_communities_by_hub(g, {0: ["z", "a"]})[0] == "a" + assert label_communities_by_hub(g, {0: ["a", "z"]})[0] == "a" + + +def test_absent_members_fall_back_to_placeholder(): + # no member of community 5 is in the graph → keep the "Community N" placeholder + g = _g({"a": "a()"}, []) + assert label_communities_by_hub(g, {5: ["ghost1", "ghost2"]})[5] == "Community 5" + + +def test_node_without_label_attr_uses_id(): + g = nx.Graph() + g.add_nodes_from(["hub", "x", "y"]) + g.add_edges_from([("hub", "x"), ("hub", "y")]) # hub degree 2, no label attrs + assert label_communities_by_hub(g, {0: ["hub", "x", "y"]})[0] == "hub" + + +def test_multiple_communities_each_get_their_own_hub(): + g = _g( + {"h1": "auth()", "a1": "a1()", "a2": "a2()", + "h2": "billing()", "b1": "b1()", "b2": "b2()"}, + [("h1", "a1"), ("h1", "a2"), ("h2", "b1"), ("h2", "b2")], + ) + labels = label_communities_by_hub(g, {0: ["h1", "a1", "a2"], 1: ["h2", "b1", "b2"]}) + assert labels[0] == "auth" and labels[1] == "billing"