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
4 changes: 4 additions & 0 deletions .semversioner/next-release/patch-20260711111442208341.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "patch",
"description": "Fix #2427 - preserve original node names in stable_lcc so case-sensitive BYOG entity titles are not dropped from communities."
}
32 changes: 13 additions & 19 deletions packages/graphrag/graphrag/graphs/stable_lcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
regardless of the original row order. This is achieved by:

1. Filtering to the largest connected component.
2. Normalizing node names (HTML unescape, uppercase, strip).
3. Sorting edges so the lesser node is always the source.
4. Sorting edges alphabetically for deterministic row order.
"""
2. Sorting edges so the lesser node is always the source.
3. Sorting edges alphabetically for deterministic row order.

import html
Node names are preserved verbatim. Downstream steps (e.g. create_communities)
match the resulting cluster labels against the original entity titles with an
exact, case-sensitive lookup, so any mutation of node names here would silently
drop entities whose titles are not already normalized (see issue #2427).
"""

import pandas as pd

Expand All @@ -38,38 +40,30 @@ def stable_lcc(
Returns
-------
pd.DataFrame
A copy of the input filtered to the LCC with normalized node names
and deterministic edge ordering.
A copy of the input filtered to the LCC with deterministic edge
ordering. Node names are preserved verbatim.
"""
if relationships.empty:
return relationships.copy()

# 1. Normalize node names
edges = relationships.copy()
edges[source_column] = edges[source_column].apply(_normalize_name)
edges[target_column] = edges[target_column].apply(_normalize_name)

# 2. Filter to the largest connected component
# 1. Filter to the largest connected component
lcc_nodes = largest_connected_component(
edges, source_column=source_column, target_column=target_column
)
edges = edges[
edges[source_column].isin(lcc_nodes) & edges[target_column].isin(lcc_nodes)
]

# 3. Stabilize edge direction: lesser node always first
# 2. Stabilize edge direction: lesser node always first
swapped = edges[source_column] > edges[target_column]
edges.loc[swapped, [source_column, target_column]] = edges.loc[
swapped, [target_column, source_column]
].to_numpy()

# 4. Deduplicate edges that were reversed pairs in the original data
# 3. Deduplicate edges that were reversed pairs in the original data
edges = edges.drop_duplicates(subset=[source_column, target_column])

# 5. Sort for deterministic order
# 4. Sort for deterministic order
return edges.sort_values([source_column, target_column]).reset_index(drop=True)


def _normalize_name(name: str) -> str:
"""Normalize a node name: HTML unescape, uppercase, strip whitespace."""
return html.unescape(name).upper().strip()
28 changes: 22 additions & 6 deletions tests/unit/graphs/test_stable_lcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,37 @@ def test_shuffled_rows_produce_same_result():


# ---------------------------------------------------------------------------
# Name normalization tests
# Name preservation tests
# ---------------------------------------------------------------------------


def test_normalizes_node_names():
"""Node names should be uppercased, stripped, and HTML-unescaped."""
def test_preserves_node_names():
"""Node names must be preserved verbatim (case, whitespace, HTML entities).

Downstream community construction matches cluster labels against the
original entity titles with an exact, case-sensitive lookup, so any
mutation here would silently drop mixed-case entities (issue #2427).
"""
rels = _make_relationships(
(" alice ", "bob", 1.0),
("bob", "carol & dave", 1.0),
)
result = stable_lcc(rels)
all_nodes = set(result["source"]).union(result["target"])
assert "ALICE" in all_nodes
assert "BOB" in all_nodes
assert "CAROL & DAVE" in all_nodes
assert " alice " in all_nodes
assert "bob" in all_nodes
assert "carol & dave" in all_nodes


def test_mixed_case_nodes_survive_lcc():
"""A mixed-case node connected to the LCC must not be dropped or renamed."""
rels = _make_relationships(
("Alice", "Bob", 1.0),
("Bob", "FooBar", 1.0),
)
result = stable_lcc(rels)
all_nodes = set(result["source"]).union(result["target"])
assert all_nodes == {"Alice", "Bob", "FooBar"}


# ---------------------------------------------------------------------------
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/indexing/test_create_communities.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,35 @@ async def test_lcc_reduces_community_count(self):
assert len(result_lcc) < len(result_no_lcc)
assert len(result_lcc) == 1

async def test_mixed_case_titles_are_not_dropped(self):
"""Regression for #2427: with use_lcc=True, mixed-case (BYOG) entity
titles must still be assigned to communities.

Previously stable_lcc uppercased node names, so the uppercased cluster
labels failed to match the original case-sensitive titles and the
entities were silently dropped, yielding empty communities.
"""
title_to_entity_id = _make_title_to_entity_id([
("e1", "Alice"),
("e2", "Bob"),
("e3", "FooBar"),
])
relationships = _make_relationships([
("r1", "Alice", "Bob", 1.0, ["t1"]),
("r2", "Alice", "FooBar", 1.0, ["t1"]),
("r3", "Bob", "FooBar", 1.0, ["t2"]),
])
result = await _run_create_communities(
title_to_entity_id,
relationships,
max_cluster_size=10,
use_lcc=True,
seed=42,
)
assert len(result) >= 1
all_entity_ids = {eid for _, row in result.iterrows() for eid in row["entity_ids"]}
assert all_entity_ids == {"e1", "e2", "e3"}


# -------------------------------------------------------------------
# Golden file regression (real test data)
Expand Down