feat(cluster): deterministic hub community labels (readable without an LLM)

Community labels defaulted to "Community N" whenever no LLM backend was configured,
making the report + suggested questions unreadable ("why does log_action connect
Community 70 to Community 129?"). Add `label_communities_by_hub`: name each community
after its highest-degree member — the structural hub — so the report reads "log_action"
/ "auth" with zero LLM cost. Ties break by node id for run-to-run stability; a community
with no members in the graph keeps the "Community N" placeholder.

Wired as the default base at both label-building sites — the label/standalone path in
__main__ and the watch/detect rebuild in watch.py. A configured LLM naming pass still
runs and overrides these with richer names; its no-backend placeholder fallback is
guarded so it can't clobber the hub labels.
This commit is contained in:
Sheik Ershad
2026-07-01 10:30:47 +01:00
committed by safishamsi
parent 8fdbf50197
commit 1aab291caf
4 changed files with 115 additions and 10 deletions
+16 -7
View File
@@ -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}
+27
View File
@@ -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,
+6 -3
View File
@@ -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,
+66
View File
@@ -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"