bharatgraph / tests /test_entity_resolver_v2.py
abinazebinoy's picture
feat(phase-32): add 15 new tests for entity resolution edge cases
6a0d2fb
Raw
History Blame Contribute Delete
14.6 kB
"""
Tests for EntityResolverV2 -- Phase 32.
Pure ASCII.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class TestJaroWinkler:
def test_identical_strings(self):
from processing.entity_resolver_v2 import jaro_winkler
assert jaro_winkler("modi", "modi") == 1.0
def test_empty_strings(self):
from processing.entity_resolver_v2 import _jaro
assert _jaro("", "test") == 0.0
assert _jaro("test", "") == 0.0
def test_similar_names(self):
from processing.entity_resolver_v2 import jaro_winkler
score = jaro_winkler("rahulkumar", "rahul kumar")
assert score > 0.85
def test_different_names(self):
from processing.entity_resolver_v2 import jaro_winkler
score = jaro_winkler("priya", "rajesh")
assert score < 0.7
class TestNormaliseIndianName:
def test_honorific_removal_person(self):
from processing.entity_resolver_v2 import normalise_indian_name
assert normalise_indian_name("Sh. Ram Kumar", "person") == "ram kumar"
assert normalise_indian_name("Smt. Priya Devi", "person") == "priya devi"
assert normalise_indian_name("Dr. Manmohan Singh", "person") == "manmohan singh"
assert normalise_indian_name("Late Shri Rajiv Gandhi", "person") == "rajiv gandhi"
def test_company_suffix_normalisation(self):
from processing.entity_resolver_v2 import normalise_indian_name
a = normalise_indian_name("Sample Private Limited", "company")
b = normalise_indian_name("Sample Pvt Ltd", "company")
assert a == b
def test_ms_prefix_stripped(self):
from processing.entity_resolver_v2 import normalise_indian_name
result = normalise_indian_name("M/S. Delhi Roads Ltd", "company")
assert "m/s" not in result.lower()
def test_uppercase_lowercased(self):
from processing.entity_resolver_v2 import normalise_indian_name
result = normalise_indian_name("NARENDRA MODI", "person")
assert result == result.lower()
class TestEntityResolverV2:
def test_exact_cin_match_returns_one(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2()
rec1 = {"name": "Adani Enterprises", "cin": "L51100GJ1988PLC013248"}
rec2 = {"name": "Adani Ltd", "cin": "L51100GJ1988PLC013248"}
assert r.combined_score("Adani Enterprises", "Adani Ltd", rec1, rec2) == 1.0
def test_different_cin_returns_zero(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2()
rec1 = {"name": "Company A", "cin": "L51100GJ1988PLC013248"}
rec2 = {"name": "Company A", "cin": "U12345MH2010PLC123456"}
assert r.combined_score("Company A", "Company A", rec1, rec2) == 0.0
def test_same_name_after_normalise_returns_one(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
score = r.combined_score("RAHUL KUMAR", "Rahul Kumar")
assert score == 1.0
def test_honorific_variant_matches(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
assert r.is_same_entity("Sh. Ram Kumar Gupta", "Ram Kumar Gupta")
def test_clearly_different_names_no_match(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
assert not r.is_same_entity("Priya Sharma", "Rajesh Gupta")
def test_resolve_dataset_merges_duplicates(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
records = [
{"name": "RAHUL KUMAR", "_source": "myneta"},
{"name": "Rahul Kumar", "_source": "wikidata"},
{"name": "Priya Sharma", "_source": "myneta"},
]
resolved = r.resolve_dataset(records, "name")
assert len(resolved) == 2
def test_resolve_dataset_backward_compat_keys(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
records = [
{"name": "RAHUL KUMAR", "_source": "myneta"},
{"name": "Rahul Kumar", "_source": "wikidata"},
]
resolved = r.resolve_dataset(records, "name")
assert "aliases" in resolved[0]
assert "duplicates" in resolved[0] # backward compat key
assert resolved[0]["_resolved_v2"] is True
def test_cross_dataset_match_finds_link(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
politicians = [{"name": "Rahul Kumar", "_source": "myneta"}]
directors = [{"director_name": "RAHUL KUMAR", "_source": "mca"}]
matches = r.cross_dataset_match(politicians, directors,
"name", "director_name")
assert len(matches) == 1
assert matches[0]["score"] >= 0.72
def test_cross_dataset_match_has_canonical_id(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
politicians = [{"name": "Rahul Kumar", "_source": "myneta"}]
directors = [{"director_name": "RAHUL KUMAR", "_source": "mca"}]
matches = r.cross_dataset_match(politicians, directors,
"name", "director_name")
assert "canonical_id" in matches[0]
assert len(matches[0]["canonical_id"]) == 20
def test_build_alias_graph(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
records = [
{"name": "RAHUL KUMAR", "id": "abc123",
"_source": "myneta",
"aliases": [{"name": "Rahul Kumar", "id": "def456", "score": 1.0,
"source": "wikidata"}]},
]
graph = r.build_alias_graph(records, "name")
assert "rahul kumar" in graph
assert graph["rahul kumar"] == "abc123"
def test_entity_resolver_alias(self):
# EntityResolver must still work as an alias
from processing.entity_resolver_v2 import EntityResolver, EntityResolverV2
assert EntityResolver is EntityResolverV2
class TestAliasGraph:
def test_add_and_resolve(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.add("RAHUL KUMAR", "abc123")
assert ag.resolve("rahul kumar") == "abc123"
assert ag.resolve("RAHUL KUMAR") == "abc123"
def test_not_found_returns_empty(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
assert ag.resolve("nobody") == ""
def test_contains(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.add("Priya Sharma", "xyz789")
assert "priya sharma" in ag
def test_bulk_add(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
records = [
{"alias_name": "Modi", "canonical": "pol_001"},
{"alias_name": "Narendra Modi", "canonical": "pol_001"},
]
ag.bulk_add(records, "alias_name", "canonical")
assert ag.resolve("modi") == "pol_001"
assert ag.resolve("Narendra Modi") == "pol_001"
def test_merge(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.merge({"Amit Shah": "pol_002", "amit shah": "pol_002"})
assert ag.resolve("amit shah") == "pol_002"
assert len(ag) == 1 # both normalise to "amit shah"
def test_stats(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.add("Modi", "pol_001")
ag.add("Narendra Modi", "pol_001")
ag.add("Rahul Gandhi", "pol_002")
stats = ag.stats()
assert stats["total_aliases"] == 3
assert stats["unique_canonical_ids"] == 2
class TestCanonicalId:
def test_deterministic(self):
from processing.canonical_id import canonical_id
assert canonical_id("narendra modi", "gujarat") == \
canonical_id("narendra modi", "gujarat")
def test_case_insensitive(self):
from processing.canonical_id import canonical_id
assert canonical_id("MODI", "GUJARAT") == \
canonical_id("modi", "gujarat")
def test_length(self):
from processing.canonical_id import canonical_id
assert len(canonical_id("test")) == 20
def test_cin_shortcut(self):
from processing.canonical_id import canonical_id_for_company
by_cin = canonical_id_for_company(cin="L51100GJ1988PLC013248")
by_name = canonical_id_for_company(name="adani", state="gj")
assert by_cin != by_name # CIN and name-based IDs differ
assert len(by_cin) == 20
# ---- Phase 32 additions: 15 new tests for missing coverage ----
class TestJaroWinklerIdentical:
"""BUG: test_identical_strings was missing from the original test file."""
def test_identical_strings(self):
from processing.entity_resolver_v2 import jaro_winkler
assert jaro_winkler("modi", "modi") == 1.0
def test_prefix_boost(self):
"""Jaro-Winkler gives extra weight to common prefixes."""
from processing.entity_resolver_v2 import jaro_winkler
score_prefix = jaro_winkler("ramkumar", "ramesh")
score_no_pfx = jaro_winkler("kumar", "ramesh")
assert score_prefix > score_no_pfx
class TestNormaliseEdgeCases:
def test_empty_string(self):
from processing.entity_resolver_v2 import normalise_indian_name
result = normalise_indian_name("", "person")
assert result == ""
def test_stacked_honorifics(self):
"""Late Shri -> both honorifics stripped."""
from processing.entity_resolver_v2 import normalise_indian_name
result = normalise_indian_name("Late Shri Ram Kumar", "person")
assert "late" not in result
assert "shri" not in result
assert "ram kumar" in result
def test_retd_honorific(self):
from processing.entity_resolver_v2 import normalise_indian_name
result = normalise_indian_name("Retd. Col. Priya Singh", "person")
assert "retd" not in result
assert "col" not in result
def test_pvt_ltd_canonical(self):
from processing.entity_resolver_v2 import normalise_indian_name
forms = [
"Sample Pvt. Ltd.",
"Sample Private Limited",
"Sample Pvt Ltd",
"Sample P. Ltd.",
]
results = [normalise_indian_name(f, "company") for f in forms]
assert len(set(results)) == 1, f"Expected 1 canonical form, got: {set(results)}"
class TestEntityResolverScoring:
def test_wikidata_id_exact_match(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2()
rec1 = {"name": "Narendra Modi", "wikidata_id": "Q658025"}
rec2 = {"name": "N. Modi", "wikidata_id": "Q658025"}
assert r.combined_score("Narendra Modi", "N. Modi", rec1, rec2) == 1.0
def test_gstin_mismatch_returns_zero(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2()
rec1 = {"name": "Company X", "gstin": "27AAACM0629R1ZU"}
rec2 = {"name": "Company X", "gstin": "29AAACM0629R1ZU"}
assert r.combined_score("Company X", "Company X", rec1, rec2) == 0.0
def test_threshold_boundary(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.90)
# Perfect match
assert r.is_same_entity("RAHUL KUMAR", "Rahul Kumar")
# Clearly different
assert not r.is_same_entity("Alpha Corp", "Beta Industries", kind="company")
def test_resolve_dataset_preserves_source_field(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2(threshold=0.72)
records = [
{"name": "RAM KUMAR", "_source": "myneta", "state": "UP"},
{"name": "Ram Kumar", "_source": "mca", "state": "UP"},
]
resolved = r.resolve_dataset(records, "name")
assert len(resolved) == 1
assert resolved[0]["_source"] == "myneta" # first record wins
def test_resolve_empty_dataset(self):
from processing.entity_resolver_v2 import EntityResolverV2
r = EntityResolverV2()
assert r.resolve_dataset([], "name") == []
class TestAliasGraphEdgeCases:
def test_resolve_case_insensitive(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.add("Narendra Modi", "pol_001")
assert ag.resolve("NARENDRA MODI") == "pol_001"
assert ag.resolve("narendra modi") == "pol_001"
def test_overwrite_updates_canonical(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
ag.add("Test Name", "old_id")
ag.add("Test Name", "new_id")
assert ag.resolve("test name") == "new_id"
def test_stats_empty_graph(self):
from processing.alias_graph import AliasGraph
ag = AliasGraph()
stats = ag.stats()
assert stats["total_aliases"] == 0
assert stats["unique_canonical_ids"] == 0
assert stats["avg_aliases_per_id"] == 0
class TestCanonicalIdEdgeCases:
def test_separator_in_name(self):
from processing.canonical_id import canonical_id
# Pipe is the internal separator -- should still produce stable ID
id1 = canonical_id("test|name", "state")
id2 = canonical_id("test|name", "state")
assert id1 == id2
assert len(id1) == 20
def test_politician_vs_company_different_ids(self):
from processing.canonical_id import canonical_id_for_politician, canonical_id_for_company
pol = canonical_id_for_politician("Ram Kumar", "UP")
com = canonical_id_for_company(name="Ram Kumar", state="UP")
# Same name+state but different function prefix -> different IDs
assert pol != com
def test_ngo_with_darpan_id(self):
from processing.canonical_id import canonical_id_for_ngo
id1 = canonical_id_for_ngo(darpan_id="DL/2021/0012345")
id2 = canonical_id_for_ngo(darpan_id="DL/2021/0012345")
assert id1 == id2
assert len(id1) == 20