From 22a863d9ad23e5055d1a84f44a1e478e81e737f8 Mon Sep 17 00:00:00 2001 From: s-heppner Date: Wed, 10 Dec 2025 09:26:50 +0100 Subject: [PATCH] algorithm: Fix non-starting-node loops Previously, there was a bug that potentially caused non-termination of the algorithm. If there was a loop that did not include the starting node, and the `min_score` was sufficiently low, the algorithm could return an arbitrarily large number of paths going round and round that loop. While these paths are technically valid matches, they are not really constructive, in a sense that they do repeat the nodes that they match, just with different weights. Furthermore, it is necessary from a technical point of view to avoid them so that the algorithm terminates. This adds an additional check that the current path does not run into such a loop, as well as the corresponding unittest to check that it works as intended. --- semantic_match_registry/src/smr/algorithm.py | 8 ++++-- .../tests/test_algorithm.py | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/semantic_match_registry/src/smr/algorithm.py b/semantic_match_registry/src/smr/algorithm.py index 7081d31..2b6ac3e 100644 --- a/semantic_match_registry/src/smr/algorithm.py +++ b/semantic_match_registry/src/smr/algorithm.py @@ -206,8 +206,12 @@ def find_semantic_matches( # Traverse to the neighboring and therefore connected `semantic_id`s for neighbor, edge_data in graph[node].items(): - if path and neighbor == path[-1]: - continue # avoid immediate backtrack A->B->A ping-pong + # Avoid any cycle: no revisiting nodes that are already in this path + # Note: `path` holds all previous nodes; `node` is *not* yet in `path` + # This also lets us avoid immediate backtrack A->B->A ping-pong for free + if neighbor in path or neighbor == node: + continue + edge_weight = float(edge_data.get("weight", 0.0)) new_score: float = score * edge_weight # Multiplicative propagation diff --git a/semantic_match_registry/tests/test_algorithm.py b/semantic_match_registry/tests/test_algorithm.py index 7eb879e..5427f34 100644 --- a/semantic_match_registry/tests/test_algorithm.py +++ b/semantic_match_registry/tests/test_algorithm.py @@ -382,6 +382,33 @@ def test_loop_prevention(self): self.assertIn("D", matched_semantic_ids) self.assertNotIn("A", matched_semantic_ids) # "A" should not be revisited + def test_avoid_longer_cycles(self): + """Ensure that non-start-node cycles (e.g. B -> C -> D -> B) do not create infinite paths.""" + # Create a cycle B -> C -> D -> B on top of the existing setup: + # A -> B, B -> C, C -> D, B -> D, D -> E + self.graph.add_edge("D", "B", weight=0.4) + + matches: List[algorithm.SemanticMatch] = algorithm.find_semantic_matches( + self.graph, + semantic_id="A", + min_score=0.0, # allow all paths that pass the structural constraints + ) + + # 1) We still only get the finite set of simple paths starting at A. + # The additional D -> B edge must not create extra paths like A->B->C->D->B->... + self.assertEqual(6, len(matches)) + + # 2) Every returned path must be simple: no node appears twice. + for m in matches: + # Reconstruct the full node sequence for the path: + # path... -> match_semantic_id + full_path = m.path + [m.match_semantic_id] + self.assertEqual( + len(full_path), + len(set(full_path)), + msg=f"Path contains a cycle: {full_path}", + ) + def test_minimum_threshold(self): """Ensure that results below the minimum score are excluded.""" matches = algorithm.find_semantic_matches(self.graph, "A", min_score=0.6)