Files
graphify/tests/test_community_hub_labels.py
safishamsi 8127ff9a9c fix(cluster): detect stale community labels on cluster-only re-cluster
Reported from a real run: after re-scoping a repo (5807->3710 nodes), the
228-community re-cluster kept run-1's 300 saved labels, so cids now covering a
different community wore the wrong (LLM) names — silently. cluster-only reused
.graphify_labels.json wholesale, and the overlap-based cid remap grabs a prior
cid on any overlap, inheriting a stale name.

Fix: write a per-community membership signature (sha256 of sorted member ids)
beside the labels. On reuse, keep a saved label only when the community's
signature is unchanged; a changed community is renamed by its deterministic
hub (correct-by-construction) with a warning to run `graphify label` for fresh
LLM names. For label files predating the signature, fall back to a community-
count check (a differing count means a different clustering -> don't trust cid
labels). Unchanged graphs reuse labels silently — no false warnings.

Verified: stale legacy labels (42) on a 12-community graph -> warned + hub-
renamed all + sig written; rerun on the unchanged graph -> silent reuse, labels
stable. Unit tests for the signature (deterministic, order-independent, changes
on membership change). Full suite 2771.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:52:50 +01:00

84 lines
3.2 KiB
Python

"""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"
# ── community membership signatures (stale-label detection, cluster-only) ──────
def test_community_member_sigs_are_deterministic_and_order_independent():
from graphify.cluster import community_member_sigs
a = community_member_sigs({0: ["x", "y", "z"], 1: ["a"]})
b = community_member_sigs({0: ["z", "x", "y"], 1: ["a"]}) # member order shuffled
assert a == b
assert a[0] != a[1]
def test_community_member_sigs_change_when_membership_changes():
from graphify.cluster import community_member_sigs
before = community_member_sigs({0: ["x", "y", "z"]})
after = community_member_sigs({0: ["x", "y"]}) # a node left the community
assert before[0] != after[0], "signature must change when a community's members change"