mmap-worker / tests /test_graph_alignment.py
jugalgajjar's picture
feat(graph): entity alignment β€” normalize + fuzzy alias + relation predicates
322abe7
Raw
History Blame Contribute Delete
11.6 kB
"""Unit tests for the entity/relation alignment layer (#43a)."""
from app.graph.alignment import (
FUZZY_THRESHOLD,
RELATION_ALIASES,
AlignedEntity,
AlignedRelationship,
Candidate,
align_batch,
find_alias,
normalize_name,
normalize_relation,
)
from app.graph.schema import GraphEntity, GraphRelationship
class TestNormalizeName:
def test_lowercases_and_strips(self):
assert normalize_name(" Jugal Gajjar ") == "jugal gajjar"
def test_drops_trailing_punctuation(self):
assert normalize_name("GWU.") == "gwu"
assert normalize_name("MIT,") == "mit"
assert normalize_name("Stanford;") == "stanford"
def test_collapses_internal_whitespace(self):
assert normalize_name("George Washington University") == "george washington university"
def test_drops_leading_articles(self):
assert normalize_name("The MIT") == "mit"
assert normalize_name("A hospital") == "hospital"
assert normalize_name("An organization") == "organization"
def test_only_strips_leading_article_once(self):
# "the the office" β†’ "the office" (only one strip, not both)
assert normalize_name("The The Office") == "the office"
def test_nfkc_folds_fullwidth(self):
# Fullwidth Latin capitals fold to ASCII
assert normalize_name("οΌ­οΌ©οΌ΄") == "mit"
def test_empty_and_whitespace_return_empty(self):
assert normalize_name("") == ""
assert normalize_name(" ") == ""
def test_preserves_internal_dashes_and_slashes(self):
# "gpt-oss-120b" is a canonical identifier β€” must not lose the dashes.
assert normalize_name("gpt-oss-120b") == "gpt-oss-120b"
assert normalize_name("BAAI/bge-small-en-v1.5") == "baai/bge-small-en-v1.5"
class TestFindAlias:
def _cands(self, *pairs: tuple[str, str]) -> list[Candidate]:
return [Candidate(name_lower=n, type=t) for n, t in pairs]
def test_exact_match_short_circuits(self):
cands = self._cands(("jugal gajjar", "Person"), ("qdrant", "Technology"))
assert find_alias("jugal gajjar", "Person", cands) == "jugal gajjar"
def test_returns_none_when_no_candidates(self):
assert find_alias("anything", "Person", []) is None
def test_returns_none_when_type_never_matches(self):
cands = self._cands(("qdrant", "Technology"))
assert find_alias("qdrant", "Person", cands) is None
def test_catches_single_char_typo(self):
# "Kamalasankaris" vs "Kamalasankari" β€” 1 char off, ~96 similarity.
cands = self._cands(("kamalasankari subramaniakuppusamy", "Person"))
assert (
find_alias("kamalasankaris subramaniakuppusamy", "Person", cands)
== "kamalasankari subramaniakuppusamy"
)
def test_ignores_below_threshold(self):
# Substantially different names, same type β€” should NOT alias.
cands = self._cands(("java", "Technology"))
assert find_alias("javascript", "Technology", cands) is None
def test_token_sort_ratio_catches_reorder(self):
# "John Smith" vs "Smith John" β€” token order doesn't matter.
cands = self._cands(("smith john", "Person"))
assert find_alias("john smith", "Person", cands) == "smith john"
def test_type_scoping_prevents_cross_type_collisions(self):
# Two identical strings, different types β†’ no alias.
cands = self._cands(("apple", "Organization"))
assert find_alias("apple", "Concept", cands) is None
def test_threshold_is_the_documented_value(self):
# If someone lowers FUZZY_THRESHOLD accidentally we want to catch it.
assert FUZZY_THRESHOLD == 90
class TestNormalizeRelation:
def test_maps_authorship_variants_to_authored(self):
for phrase in ("author of", "authors", "wrote", "writes"):
assert normalize_relation(phrase) == "authored"
def test_maps_affiliation_variants(self):
for phrase in ("works at", "works for", "employed by", "member of"):
assert normalize_relation(phrase) == "affiliated with"
def test_maps_usage_variants(self):
for phrase in ("using", "utilizes", "leverages"):
assert normalize_relation(phrase) == "uses"
def test_maps_comparison_variants(self):
for phrase in ("better than", "exceeds", "surpasses"):
assert normalize_relation(phrase) == "outperforms"
def test_case_and_whitespace_insensitive(self):
assert normalize_relation(" Works At ") == "affiliated with"
def test_unknown_predicate_passes_through_normalized(self):
assert normalize_relation("Foo Bar Baz") == "foo bar baz"
def test_empty_returns_empty(self):
assert normalize_relation("") == ""
assert normalize_relation(" ") == ""
def test_alias_table_values_are_lowercase(self):
# Canonical forms must be lowercase β€” otherwise MERGE keys mismatch.
for canonical in RELATION_ALIASES.values():
assert canonical == canonical.lower()
class TestAlignBatch:
"""Integration-ish tests for the batch aligner β€” the entrypoint the
worker actually calls after extraction."""
def _entity(self, name: str, entity_type: str = "Person", desc: str = "") -> GraphEntity:
return GraphEntity(name=name, type=entity_type, description=desc)
def _rel(self, source: str, target: str, relation: str) -> GraphRelationship:
return GraphRelationship(source=source, target=target, relation=relation)
def test_empty_input_returns_empty(self):
entities, rels = align_batch([], [], [])
assert entities == []
assert rels == []
def test_no_existing_candidates_normalizes_only(self):
entities, rels = align_batch(
[self._entity("Jugal Gajjar")],
[],
existing_candidates=[],
)
assert len(entities) == 1
assert entities[0].name == "Jugal Gajjar"
assert entities[0].name_lower == "jugal gajjar"
def test_deduplicates_within_batch_by_normalized_form(self):
# Two entities that normalize to the same lower form β†’ one output.
entities, _ = align_batch(
[self._entity("Jugal Gajjar"), self._entity("jugal gajjar.")],
[],
[],
)
assert len(entities) == 1
assert entities[0].name_lower == "jugal gajjar"
def test_aliases_to_existing_candidate(self):
existing = [Candidate(name_lower="jugal gajjar", type="Person")]
entities, _ = align_batch(
[self._entity("Jugal V Gajjar", "Person")],
[],
existing,
)
# Fuzzy match should collapse to the existing name_lower.
assert len(entities) == 1
assert entities[0].name_lower == "jugal gajjar"
def test_second_entity_in_batch_can_alias_to_first(self):
# "GWU." and "GWU!" both normalize to "gwu"; we get exactly one entity.
entities, _ = align_batch(
[
self._entity("George Washington University", "Organization"),
self._entity("George Washington University.", "Organization"),
],
[],
[],
)
assert len(entities) == 1
def test_same_name_different_types_collapse_first_write_wins(self):
# Neo4j MERGE key is (user_id, name_lower) β€” type is not part of
# the key. So "Apple/Organization" and "Apple/Concept" resolve to
# the same node; the aligner emits one, and the first-written type
# sticks (via coalesce in _UPSERT_ENTITY_CYPHER). Documented
# limitation β€” see the module docstring.
entities, _ = align_batch(
[
self._entity("Apple", "Organization"),
self._entity("Apple", "Concept"),
],
[],
[],
)
assert len(entities) == 1
assert entities[0].type == "Organization"
def test_type_scoping_prevents_fuzzy_alias_across_types(self):
# But find_alias itself still type-scopes: an existing "apple" of
# type Organization does NOT fuzzy-attract "apple pie" of type
# Concept β€” they'd stay separate because the fuzzy check bails on
# type mismatch. (Would still collapse if they normalize identically
# per the test above.)
entities, _ = align_batch(
[self._entity("apple pie", "Concept")],
[],
existing_candidates=[Candidate(name_lower="apple", type="Organization")],
)
assert len(entities) == 1
assert entities[0].name_lower == "apple pie"
assert entities[0].type == "Concept"
def test_relationship_source_target_rewritten_through_alias(self):
existing = [Candidate(name_lower="jugal gajjar", type="Person")]
_, rels = align_batch(
[
self._entity("Jugal V Gajjar", "Person"),
self._entity("George Washington University", "Organization"),
],
[self._rel("Jugal V Gajjar", "George Washington University", "works at")],
existing,
)
assert len(rels) == 1
# source got aliased to existing candidate; target normalized in-batch.
assert rels[0].source_lower == "jugal gajjar"
assert rels[0].target_lower == "george washington university"
# And the predicate got mapped through L4.
assert rels[0].relation == "affiliated with"
def test_drops_relationships_with_missing_endpoint(self):
# source doesn't appear in the entities list at all β†’ drop.
_, rels = align_batch(
[self._entity("A")],
[self._rel("B", "A", "uses")],
[],
)
assert rels == []
def test_drops_self_loop_after_alignment(self):
# Two names that alias to the same node produce a self-loop β†’ drop.
entities, rels = align_batch(
[
self._entity("Jugal Gajjar", "Person"),
self._entity("jugal gajjar", "Person"),
],
[self._rel("Jugal Gajjar", "jugal gajjar", "same as")],
[],
)
assert len(entities) == 1
assert rels == [] # self-loop dropped
def test_deduplicates_relationships_by_key(self):
# Two rels that reduce to the same (src, tgt, canonical_relation) β†’ 1.
_, rels = align_batch(
[self._entity("A"), self._entity("B")],
[
self._rel("A", "B", "works at"),
self._rel("A", "B", "employed by"), # both map to "affiliated with"
],
[],
)
assert len(rels) == 1
assert rels[0].relation == "affiliated with"
def test_returns_correct_aligned_types(self):
# Type is preserved verbatim from the input GraphEntity.
entities, _ = align_batch([self._entity("X", "Technology")], [], [])
assert entities[0].type == "Technology"
class TestAlignmentDataclasses:
"""Guard the shape of the exported dataclasses so downstream (worker,
Neo4j client) doesn't silently break on rename."""
def test_aligned_entity_fields(self):
e = AlignedEntity(name="X", name_lower="x", type="Concept", description="d")
assert e.name == "X" and e.name_lower == "x" and e.type == "Concept"
def test_aligned_relationship_fields(self):
r = AlignedRelationship(source_lower="a", target_lower="b", relation="uses")
assert r.source_lower == "a" and r.target_lower == "b" and r.relation == "uses"