From 038d934155f5afaf861f852c1959f6d9325c546a Mon Sep 17 00:00:00 2001 From: Khaled Alam Date: Sat, 11 Jul 2026 15:14:05 +0400 Subject: [PATCH] Fix #2427: preserve original node names in stable_lcc so case-sensitive BYOG entity titles are not dropped from communities. --- .../patch-20260711111442208341.json | 4 +++ .../graphrag/graphrag/graphs/stable_lcc.py | 32 ++++++++----------- tests/unit/graphs/test_stable_lcc.py | 28 ++++++++++++---- .../unit/indexing/test_create_communities.py | 29 +++++++++++++++++ 4 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 .semversioner/next-release/patch-20260711111442208341.json diff --git a/.semversioner/next-release/patch-20260711111442208341.json b/.semversioner/next-release/patch-20260711111442208341.json new file mode 100644 index 0000000000..20f4057293 --- /dev/null +++ b/.semversioner/next-release/patch-20260711111442208341.json @@ -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." +} diff --git a/packages/graphrag/graphrag/graphs/stable_lcc.py b/packages/graphrag/graphrag/graphs/stable_lcc.py index aa01044eaf..505d5af382 100644 --- a/packages/graphrag/graphrag/graphs/stable_lcc.py +++ b/packages/graphrag/graphrag/graphs/stable_lcc.py @@ -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 @@ -38,18 +40,15 @@ 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 ) @@ -57,19 +56,14 @@ def stable_lcc( 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() diff --git a/tests/unit/graphs/test_stable_lcc.py b/tests/unit/graphs/test_stable_lcc.py index 835a402318..af0b4f0818 100644 --- a/tests/unit/graphs/test_stable_lcc.py +++ b/tests/unit/graphs/test_stable_lcc.py @@ -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"} # --------------------------------------------------------------------------- diff --git a/tests/unit/indexing/test_create_communities.py b/tests/unit/indexing/test_create_communities.py index e3f225bf0b..f1d4f5cb03 100644 --- a/tests/unit/indexing/test_create_communities.py +++ b/tests/unit/indexing/test_create_communities.py @@ -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)