mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 02:47:00 +00:00
e477825a97
- analyze.py: pass length_bound=max_cycle_length to nx.simple_cycles() so networkx prunes during enumeration instead of post-filtering; drops report generation from never-returns to ~0.1s on dense graphs (#1196) - llm.py: replace hardcoded min(40+16*n,4096) label_communities token budget with _resolve_max_tokens(min(64+24*n,8192)) — 24 tok/community covers 5-word JSON entries; 8192 cap fits 16k-context models; env var now honoured (#1200) - dedup.py: add prefix-extension guard in Pass 2 and _llm_tiebreak — skip merge when one normalised label is a strict prefix of the other (getActiveSession / getActiveSessions, parseConfig / parseConfigFile). Option (a) rejected: dropping the >=12 early-out from _short_label_blocked breaks test_typo_merged (#1201) - tests/test_dedup.py: two new regression tests verifying prefix guard fires for extension pairs and does not fire for same-length typo pairs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
241 lines
9.8 KiB
Python
241 lines
9.8 KiB
Python
"""Tests for graphify/dedup.py entity deduplication pipeline."""
|
|
from __future__ import annotations
|
|
import pytest
|
|
from graphify.dedup import deduplicate_entities, _entropy, _shingles
|
|
|
|
|
|
# ── entropy gate ─────────────────────────────────────────────────────────────
|
|
|
|
def test_entropy_short_label_low():
|
|
assert _entropy("AI") < 2.5
|
|
|
|
def test_entropy_normal_label_high():
|
|
assert _entropy("AuthenticationManager") >= 2.5
|
|
|
|
def test_entropy_empty_string():
|
|
assert _entropy("") == 0.0
|
|
|
|
|
|
# ── shingles ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_shingles_produces_trigrams():
|
|
s = _shingles("hello")
|
|
assert "hel" in s
|
|
assert "ell" in s
|
|
assert "llo" in s
|
|
|
|
def test_shingles_short_string():
|
|
# strings shorter than 3 chars return single shingle of the string itself
|
|
assert _shingles("ab") == {"ab"}
|
|
|
|
|
|
# ── full pipeline ─────────────────────────────────────────────────────────────
|
|
|
|
def _make_nodes(*labels):
|
|
return [{"id": label.lower().replace(" ", "_"), "label": label, "source_file": "test.md"} for label in labels]
|
|
|
|
def _make_edges(src, tgt, relation="relates_to"):
|
|
return [{"source": src, "target": tgt, "relation": relation}]
|
|
|
|
|
|
def test_exact_duplicates_merged():
|
|
nodes = _make_nodes("UserService", "userservice", "User Service")
|
|
edges = []
|
|
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
|
|
# All three are the same concept — only one survives
|
|
assert len(result_nodes) == 1
|
|
|
|
|
|
def test_typo_merged():
|
|
# "GraphExtractor" vs "Graph Extractor" — Jaro-Winkler >= 0.92
|
|
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
|
|
edges = []
|
|
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
|
|
assert len(result_nodes) == 1
|
|
|
|
|
|
def test_unrelated_not_merged():
|
|
nodes = _make_nodes("UserService", "OrderService")
|
|
edges = []
|
|
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
|
|
assert len(result_nodes) == 2
|
|
|
|
|
|
def test_short_low_entropy_not_merged():
|
|
# "AI" and "ML" are low-entropy — entropy gate skips them
|
|
nodes = _make_nodes("AI", "ML")
|
|
edges = []
|
|
result_nodes, _ = deduplicate_entities(nodes, edges, communities={})
|
|
assert len(result_nodes) == 2
|
|
|
|
|
|
def test_edges_rewired_after_merge():
|
|
nodes = _make_nodes("GraphExtractor", "Graph Extractor", "Parser")
|
|
# edge from loser to Parser should be rewired to winner
|
|
edges = [{"source": "graph_extractor", "target": "parser", "relation": "uses"}]
|
|
result_nodes, result_edges = deduplicate_entities(nodes, edges, communities={})
|
|
assert len(result_nodes) == 2 # merged + Parser
|
|
# edge should still exist (rewired to winner)
|
|
assert len(result_edges) == 1
|
|
|
|
|
|
def test_self_loops_dropped_after_merge():
|
|
# If both endpoints of an edge get merged into same node, drop the edge
|
|
nodes = _make_nodes("GraphExtractor", "Graph Extractor")
|
|
edges = [{"source": "graphextractor", "target": "graph_extractor", "relation": "same"}]
|
|
_, result_edges = deduplicate_entities(nodes, edges, communities={})
|
|
assert result_edges == []
|
|
|
|
|
|
def test_community_boost_aids_merge():
|
|
# Two nodes in same community with score in 0.75-0.85 zone get boosted
|
|
nodes = _make_nodes("AuthManager", "Auth Manager")
|
|
edges = []
|
|
# Same community → boost → merge
|
|
communities = {"authmanager": 1, "auth_manager": 1}
|
|
result_with, _ = deduplicate_entities(nodes, edges, communities=communities)
|
|
# Different community → no boost
|
|
communities_diff = {"authmanager": 1, "auth_manager": 2}
|
|
result_without, _ = deduplicate_entities(nodes, edges, communities=communities_diff)
|
|
assert len(result_with) <= len(result_without)
|
|
|
|
|
|
def test_empty_inputs():
|
|
result_nodes, result_edges = deduplicate_entities([], [], communities={})
|
|
assert result_nodes == []
|
|
assert result_edges == []
|
|
|
|
|
|
def test_single_node_no_crash():
|
|
nodes = _make_nodes("UserService")
|
|
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
|
|
assert len(result_nodes) == 1
|
|
|
|
|
|
def test_dedup_llm_flag_accepted():
|
|
"""deduplicate_entities accepts dedup_llm_backend without crashing when no ambiguous pairs exist."""
|
|
nodes = _make_nodes("UserService", "OrderService")
|
|
edges = []
|
|
result_nodes, _ = deduplicate_entities(nodes, edges, communities={}, dedup_llm_backend=None)
|
|
assert len(result_nodes) == 2
|
|
|
|
|
|
# ── build integration ─────────────────────────────────────────────────────────
|
|
|
|
def test_build_calls_dedup():
|
|
"""build() should deduplicate near-identical nodes across extractions."""
|
|
from graphify.build import build
|
|
chunk1 = {
|
|
"nodes": [{"id": "graphextractor", "label": "GraphExtractor", "source_file": "a.py"}],
|
|
"edges": [],
|
|
}
|
|
chunk2 = {
|
|
"nodes": [{"id": "graph_extractor", "label": "Graph Extractor", "source_file": "b.py"}],
|
|
"edges": [],
|
|
}
|
|
G = build([chunk1, chunk2])
|
|
assert G.number_of_nodes() == 1
|
|
|
|
|
|
# --- #878: fuzzy dedup false merges on short/variant labels ---
|
|
|
|
def test_dedup_does_not_merge_numeric_variants(tmp_path):
|
|
"""Chip SKU variants (ASR1603 vs ASR1605) must not be merged (#878)."""
|
|
nodes = _make_nodes("ASR1603", "ASR1605")
|
|
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
|
|
assert len(result_nodes) == 2, "ASR1603 and ASR1605 are distinct chip models, not duplicates"
|
|
|
|
|
|
def test_dedup_does_not_merge_short_insertion_variants(tmp_path):
|
|
"""Short labels differing by an insertion (cranel vs cranelr) must not merge (#878)."""
|
|
nodes = _make_nodes("cranel", "cranelr")
|
|
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
|
|
assert len(result_nodes) == 2, "cranel and cranelr are distinct, not a typo"
|
|
|
|
|
|
def test_dedup_does_not_merge_model_with_suffix(tmp_path):
|
|
"""M1 vs M1 Pro must not merge (#878)."""
|
|
nodes = _make_nodes("M1", "M1 Pro")
|
|
result_nodes, _ = deduplicate_entities(nodes, [], communities={})
|
|
assert len(result_nodes) == 2, "M1 and M1 Pro are distinct Apple chip variants"
|
|
|
|
|
|
def test_dedup_still_merges_real_typos():
|
|
"""Genuine same-length single-char typos should still merge (#878 non-regression)."""
|
|
from graphify.dedup import _is_variant_pair, _short_label_blocked
|
|
from rapidfuzz.distance import JaroWinkler
|
|
a, b = "graphextractor", "graphextractar"
|
|
score = JaroWinkler.normalized_similarity(a, b) * 100
|
|
assert not _is_variant_pair(a, b), "not a variant pair"
|
|
assert not _short_label_blocked(a, b, score), "long-enough label, should not be blocked"
|
|
|
|
|
|
def test_variant_pair_helper():
|
|
"""_is_variant_pair correctly identifies chip-model variant pairs (#878)."""
|
|
from graphify.dedup import _is_variant_pair
|
|
assert _is_variant_pair("asr1603", "asr1605")
|
|
assert _is_variant_pair("cortex a55", "cortex a55x")
|
|
assert not _is_variant_pair("graphextractor", "graphextracter")
|
|
assert not _is_variant_pair("foo", "foo")
|
|
|
|
|
|
def test_prefix_extension_symbols_not_merged():
|
|
"""Distinct symbols whose name is a strict prefix-extension of another must not
|
|
be merged (#1201). getActiveSession / getActiveSessions score ~98.82 JW but are
|
|
different functions; parseConfig / parseConfigFile likewise."""
|
|
import networkx as nx
|
|
from graphify.dedup import deduplicate_entities
|
|
|
|
pairs = [
|
|
("getActiveSession", "getActiveSessions"),
|
|
("parseConfig", "parseConfigFile"),
|
|
("load", "loadAll"),
|
|
("handleRequest", "handleRequestTimeout"),
|
|
]
|
|
for a, b in pairs:
|
|
nodes = [
|
|
{"id": f"{a}_id", "label": a, "type": "CODE", "src_file": "api.py"},
|
|
{"id": f"{b}_id", "label": b, "type": "CODE", "src_file": "api.py"},
|
|
]
|
|
edges = [{"src": f"{a}_id", "tgt": f"{b}_id", "relation": "calls",
|
|
"c": 1.0, "weight": 1.0}]
|
|
out_nodes, _ = deduplicate_entities(
|
|
nodes, edges, communities={f"{a}_id": 0, f"{b}_id": 0}
|
|
)
|
|
labels = {n["label"] for n in out_nodes}
|
|
assert a in labels and b in labels, (
|
|
f"#1201 regression: '{a}' and '{b}' were merged — they are distinct symbols"
|
|
)
|
|
|
|
|
|
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).
|
|
graphextractor / graphextractar have the same length, so neither starts-with the
|
|
other, and the guard must not fire."""
|
|
from graphify.dedup import _norm
|
|
a = _norm("GraphExtractor") # "graphextractor" — 14 chars
|
|
b = _norm("GraphExtractar") # "graphextractar" — 14 chars
|
|
lo, hi = sorted((a, b), key=len)
|
|
# Same-length pair: startswith only holds when strings are identical
|
|
assert not (hi.startswith(lo) and hi != lo), (
|
|
f"Prefix guard fires on same-length pair ({a!r}, {b!r}) — should not"
|
|
)
|
|
|
|
|
|
def test_prefix_guard_fires_for_extension_pairs():
|
|
"""The prefix-extension guard must fire for pairs where one is a strict prefix
|
|
of the other, preventing false merges (#1201)."""
|
|
from graphify.dedup import _norm
|
|
pairs = [
|
|
("getActiveSession", "getActiveSessions"),
|
|
("parseConfig", "parseConfigFile"),
|
|
("load", "loadAll"),
|
|
]
|
|
for a_raw, b_raw in pairs:
|
|
a, b = _norm(a_raw), _norm(b_raw)
|
|
lo, hi = sorted((a, b), key=len)
|
|
assert hi.startswith(lo) and hi != lo, (
|
|
f"Prefix guard should fire for ({a!r}, {b!r}) but did not"
|
|
)
|