From 4f8de1e42bcec55cf73ebf6f54a6f44ed56cf455 Mon Sep 17 00:00:00 2001 From: Timo Derstappen Date: Thu, 11 Jun 2026 09:30:36 +0200 Subject: [PATCH] fix(dedup): pick pass-2 winner from the verified pair only (#1247) Pass 2 selected the merge winner from the union of both normalized-label groups, so never-compared same-label/cross-file nodes could be pulled into the union, bypassing the #1046/#1178 guards. Pick the winner from [node, neighbor] only; group members that belong together still merge via pass 1 (same file) or their own verified comparison. Co-authored-by: Cursor --- graphify/dedup.py | 8 +++++--- tests/test_dedup.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/graphify/dedup.py b/graphify/dedup.py index 5b45bc970..3faaf5c63 100644 --- a/graphify/dedup.py +++ b/graphify/dedup.py @@ -293,9 +293,11 @@ def deduplicate_entities( sf_b = neighbor.get("source_file") or "" if sf_a != sf_b: continue - all_group = norm_to_nodes.get(norm_label, [node]) + \ - norm_to_nodes.get(neighbor_norm, [neighbor]) - winner = _pick_winner(all_group) + # Pick the winner from the verified pair only. Selecting it + # from the union of both normalized-label groups pulls + # never-compared nodes (same label, different source_file) + # into the merge, bypassing the #1046/#1178 guards. + winner = _pick_winner([node, neighbor]) uf.union(winner["id"], node_id) uf.union(winner["id"], neighbor_id) fuzzy_merges += 1 diff --git a/tests/test_dedup.py b/tests/test_dedup.py index 419f009bc..057590081 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -208,6 +208,36 @@ def test_prefix_extension_symbols_not_merged(): ) +def test_pass2_winner_union_does_not_pull_in_uncompared_same_label_nodes(): + """Pass 2's winner selection must consider only the verified pair (#1247). + + Picking the winner from the union of both normalized-label groups pulls + never-compared nodes into the merge: here A ("Session Manager", auth.md) + and B ("Session Manager", billing.md) are deliberately kept distinct by + the cross-file identical-label guards (#1046, #1178), yet when the + A-C fuzzy match ("Session Managr" typo) fires, _pick_winner over + [A, B, C] selects B (shortest id) and unions B with both A and C — + merging B although it was never compared against anything. + """ + nodes = [ + {"id": "session_manager_auth", "label": "Session Manager", + "source_file": "auth.md"}, + {"id": "sm", "label": "Session Manager", + "source_file": "billing.md"}, + {"id": "session_managr_notes", "label": "Session Managr", + "source_file": "notes.md"}, + ] + result_nodes, _ = deduplicate_entities(nodes, [], communities={}) + ids = {n["id"] for n in result_nodes} + # B must survive as a distinct node: identical label across different + # source files is exactly what the #1046/#1178 guards keep separate. + assert "sm" in ids, ( + "uncompared cross-file node 'sm' was absorbed via pass-2 winner-union" + ) + # The verified fuzzy pair (A, C) still merges — only one of them survives. + assert len(result_nodes) == 2 + + def test_prefix_guard_does_not_block_same_length_typos(): """The prefix-extension guard must not fire for same-length pairs — only strict prefix-extensions (one is a substring of the other) should be blocked (#1201).