fix(embeddings): stop the registry dispatch from calling wrappers back into themselves (#1005)

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
This commit is contained in:
yzxcj797
2026-08-27 01:18:03 +05:30
committed by GitHub
co-authored by Sameer Kadam
parent 8e79c65542
commit f187d4b5da
2 changed files with 115 additions and 32 deletions
+29 -31
View File
@@ -147,9 +147,12 @@ def calculate_similarity(
>>> result = calculate_similarity(entity1, entity2, method="levenshtein")
>>> print(f"Similarity: {result.score:.2f}")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _multi_factor_similarity is registered under "multi_factor" and calls back
# into calculate_similarity(method="multi_factor"), creating indirect
# infinite recursion. The identity guard short-circuits that loop.
custom_method = method_registry.get("similarity", method)
if custom_method:
if custom_method and custom_method is not calculate_similarity:
return custom_method(entity1, entity2, **kwargs)
# Use default SimilarityCalculator
@@ -235,9 +238,11 @@ def detect_duplicates(
>>> duplicates = detect_duplicates(entities, method="pairwise", similarity_threshold=0.8)
>>> print(f"Found {len(duplicates)} duplicate candidates")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _pairwise_detection is registered under "pairwise" and calls back into
# detect_duplicates(method="pairwise"), creating indirect infinite recursion.
custom_method = method_registry.get("detection", method)
if custom_method:
if custom_method and custom_method is not detect_duplicates:
return custom_method(
entities, similarity_threshold=similarity_threshold, **kwargs
)
@@ -282,9 +287,10 @@ def dedup_triplets(
List of duplicate relationship piars (rel1, rel2).
"""
# Check for custom method in registry (but not ourself)
# Check for custom method in registry (but not ourself — identity guard
# consistent with the other dispatch functions in this module).
custom_method = method_registry.get("detection", "triplets")
if custom_method and custom_method.__name__ != "dedup_triplets":
if custom_method and custom_method is not dedup_triplets:
return custom_method(relationships, mode=mode, threshold=threshold, **kwargs)
detector = DuplicateDetector(**kwargs)
@@ -328,9 +334,11 @@ def merge_entities(
>>> operations = merge_entities(duplicate_entities, method="keep_most_complete")
>>> print(f"Performed {len(operations)} merge operations")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential registration.
# merge_entities is now registered directly under its default method name;
# the identity guard prevents a direct recursion loop.
custom_method = method_registry.get("merging", method)
if custom_method:
if custom_method and custom_method is not merge_entities:
return custom_method(
entities, preserve_provenance=preserve_provenance, **kwargs
)
@@ -374,9 +382,12 @@ def build_clusters(
>>> result = build_clusters(entities, method="graph_based", similarity_threshold=0.8)
>>> print(f"Found {len(result.clusters)} clusters")
"""
# Check for custom method in registry
# Check for custom method in registry, skip self-referential wrappers.
# _graph_based_clustering is registered under "graph_based" and calls back
# into build_clusters(method="graph_based"), creating indirect infinite
# recursion.
custom_method = method_registry.get("clustering", method)
if custom_method:
if custom_method and custom_method is not build_clusters:
return custom_method(
entities, similarity_threshold=similarity_threshold, **kwargs
)
@@ -546,25 +557,12 @@ def list_available_methods(task: Optional[str] = None) -> Dict[str, List[str]]:
return result
# Register default methods with registry
def _multi_factor_similarity(e1, e2, **kw):
return calculate_similarity(e1, e2, method="multi_factor", **kw)
def _pairwise_detection(entities, **kw):
return detect_duplicates(entities, method="pairwise", **kw)
def _keep_most_complete_merging(entities, **kw):
return merge_entities(entities, method="keep_most_complete", **kw)
def _graph_based_clustering(entities, **kw):
return build_clusters(entities, method="graph_based", **kw)
method_registry.register("similarity", "multi_factor", _multi_factor_similarity)
method_registry.register("detection", "pairwise", _pairwise_detection)
method_registry.register("merging", "keep_most_complete", _keep_most_complete_merging)
method_registry.register("clustering", "graph_based", _graph_based_clustering)
# Register default methods with registry.
# The public dispatch functions are registered directly so the identity guard
# in each function short-circuits the self-reference rather than going through
# an intermediate wrapper that re-enters the same dispatch path.
method_registry.register("similarity", "multi_factor", calculate_similarity)
method_registry.register("detection", "pairwise", detect_duplicates)
method_registry.register("merging", "keep_most_complete", merge_entities)
method_registry.register("clustering", "graph_based", build_clusters)
method_registry.register("detection", "triplets", dedup_triplets)
+86 -1
View File
@@ -98,8 +98,11 @@ class TestMethodDispatchRecursion(unittest.TestCase):
self.assertIsNotNone(emb)
def test_embed_text_default_does_not_self_recurse(self):
# Use the deterministic hash fallback to avoid model download;
# "fallback" is registered as embed_text itself, so the identity
# guard is the thing being tested — no sentence-transformers needed.
from semantica.embeddings.methods import embed_text
emb = embed_text("recursion probe", method="sentence_transformers")
emb = embed_text("recursion probe", method="fallback")
self.assertIsNotNone(emb)
def test_custom_registered_method_still_wins(self):
@@ -131,3 +134,85 @@ class TestMethodDispatchRecursion(unittest.TestCase):
with self.assertRaises(AttributeError):
getattr(bare, "model")
def test_calculate_similarity_cosine_does_not_self_recurse(self):
"""calculate_similarity is registered under "cosine"/"euclidean" — the
identity guard must prevent infinite recursion when those aliases fire."""
import numpy as np
from semantica.embeddings.methods import calculate_similarity
e1 = np.array([1.0, 0.0, 0.0])
e2 = np.array([0.0, 1.0, 0.0])
result = calculate_similarity(e1, e2, method="cosine")
self.assertIsNotNone(result)
def test_pool_embeddings_mean_does_not_self_recurse(self):
"""pool_embeddings is registered under all pooling aliases — the identity
guard must prevent infinite recursion for every built-in pooling method."""
import numpy as np
from semantica.embeddings.methods import pool_embeddings
embs = np.array([[1.0, 2.0], [3.0, 4.0]])
result = pool_embeddings(embs, method="mean")
self.assertIsNotNone(result)
class TestDeduplicationDispatchRecursion(unittest.TestCase):
"""Indirect recursion in deduplication/methods.py: the private wrapper
functions (_multi_factor_similarity, _pairwise_detection, _graph_based_clustering)
are registered as handlers under their respective default method names and
call back into the public dispatch functions with the same method, creating
an indirect infinite recursion loop without an identity guard."""
def test_calculate_similarity_multi_factor_does_not_recurse(self):
"""_multi_factor_similarity is registered under 'similarity/multi_factor'
and calls calculate_similarity(method='multi_factor'), which without a
guard would re-enter _multi_factor_similarity infinitely."""
from semantica.deduplication.methods import calculate_similarity
e1 = {"name": "Apple Inc.", "type": "Company"}
e2 = {"name": "Apple", "type": "Company"}
result = calculate_similarity(e1, e2, method="multi_factor")
self.assertIsNotNone(result)
def test_detect_duplicates_pairwise_does_not_recurse(self):
"""_pairwise_detection is registered under 'detection/pairwise' and
calls detect_duplicates(method='pairwise') — indirect loop without guard."""
from semantica.deduplication.methods import detect_duplicates
entities = [
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
]
result = detect_duplicates(entities, method="pairwise")
self.assertIsNotNone(result)
def test_build_clusters_graph_based_does_not_recurse(self):
"""_graph_based_clustering is registered under 'clustering/graph_based'
and calls build_clusters(method='graph_based') — indirect loop without guard."""
from semantica.deduplication.methods import build_clusters
entities = [
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
]
result = build_clusters(entities, method="graph_based")
self.assertIsNotNone(result)
def test_custom_deduplication_method_still_wins(self):
"""A genuinely user-registered custom method must still take precedence
over the built-in implementation after the guard is added."""
from semantica.deduplication.methods import (
calculate_similarity,
)
from semantica.deduplication.registry import method_registry
calls = []
def spy(e1, e2, **kw):
calls.append((e1, e2))
from semantica.deduplication.similarity_calculator import SimilarityResult
return SimilarityResult(score=0.99, method="spy")
method_registry.register("similarity", "spy_method", spy)
try:
e1 = {"name": "Alice"}
e2 = {"name": "Alice"}
result = calculate_similarity(e1, e2, method="spy_method")
self.assertEqual(result.score, 0.99)
self.assertEqual(len(calls), 1)
finally:
method_registry.unregister("similarity", "spy_method")