# Feature: remove-embedding-competence-similarity, Property 6: Rollen-Similarity-Cache ist symmetrisch und idempotent """Property-based tests for role similarity cache symmetry and idempotency.""" from __future__ import annotations import json import pytest from hypothesis import given, settings from hypothesis import strategies as st from teamlandkarte_mcp.matching.similarity import SimilarityEngine # Strategy: role name strings (ASCII letters only, 1-50 chars, # no "(unknown)") _role_str = st.text( alphabet=st.characters( whitelist_categories=("L",), max_codepoint=127, ), min_size=1, max_size=50, ).filter(lambda s: s.strip().lower() != "(unknown)") class _DummyClient: """Stub that returns a fixed similarity score and tracks call count.""" def __init__(self, score: float = 0.72) -> None: self._score = score self.call_count = 0 async def chat_completion(self, system: str, user: str) -> str: self.call_count += 1 return json.dumps({"similarity": self._score}) # **Validates: Requirements 5.8** @settings(max_examples=100) @given(role_a=_role_str, role_b=_role_str) @pytest.mark.asyncio async def test_role_similarity_cache_symmetry( role_a: str, role_b: str, ) -> None: """Property 6a: Cache is symmetric. For any role pair (A, B) where A != B (case-insensitive), compute_role_similarity(A, B) == compute_role_similarity(B, A), and the second call shall not invoke the LLM (cache hit). """ # Skip identical roles (they short-circuit to 1.0 without LLM) if role_a.strip().lower() == role_b.strip().lower(): return client = _DummyClient(score=0.72) engine = SimilarityEngine(client=client) # type: ignore[arg-type] # First call: A, B score_ab = await engine.compute_role_similarity(role_a, role_b) # Second call: B, A (should use cache) score_ba = await engine.compute_role_similarity(role_b, role_a) # Symmetry: both calls return the same score assert score_ab == score_ba, ( f"Symmetry violated: " f"sim({role_a!r}, {role_b!r})={score_ab} != " f"sim({role_b!r}, {role_a!r})={score_ba}" ) # Only one LLM call should have been made (second was a cache hit) assert client.call_count == 1, ( f"Expected 1 LLM call but got {client.call_count} " f"(roles: {role_a!r}, {role_b!r})" ) # **Validates: Requirements 5.8** @settings(max_examples=100) @given(role_a=_role_str, role_b=_role_str) @pytest.mark.asyncio async def test_role_similarity_cache_idempotent( role_a: str, role_b: str, ) -> None: """Property 6b: Cache is idempotent. Repeated calls with the same role pair always return the same cached value without additional LLM calls. """ # Skip identical roles (they short-circuit to 1.0 without LLM) if role_a.strip().lower() == role_b.strip().lower(): return client = _DummyClient(score=0.65) engine = SimilarityEngine(client=client) # type: ignore[arg-type] # First call populates the cache score_1 = await engine.compute_role_similarity(role_a, role_b) assert client.call_count == 1 # Second call with same order: cache hit score_2 = await engine.compute_role_similarity(role_a, role_b) assert score_2 == score_1, ( f"Idempotency violated: call 1={score_1}, call 2={score_2}" ) assert client.call_count == 1, ( f"Expected 1 LLM call after 2 same-order calls, " f"got {client.call_count}" ) # Third call with reversed order: also cache hit score_3 = await engine.compute_role_similarity(role_b, role_a) assert score_3 == score_1, ( f"Idempotency violated on reverse: " f"call 1={score_1}, call 3={score_3}" ) assert client.call_count == 1, ( f"Expected 1 LLM call after 3 total calls, " f"got {client.call_count}" )