# Feature: remove-embedding-competence-similarity, Property 5: compute_role_similarity Wertebereich """Property-based tests for compute_role_similarity value range.""" 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",), whitelist_characters="", max_codepoint=127, ), min_size=1, max_size=50, ).filter(lambda s: s.strip().lower() != "(unknown)") # Strategy: similarity score returned by the LLM (intentionally includes # out-of-range values to verify clamping behaviour) _llm_score = st.floats(min_value=-1.0, max_value=2.0) class _DummyClient: """Stub that returns a configurable similarity score.""" def __init__(self, score: float = 0.5) -> None: self._score = score async def chat_completion(self, system: str, user: str) -> str: return json.dumps({"similarity": self._score}) # **Validates: Requirement 5.1 (value range [0.0, 1.0])** @settings(max_examples=100) @given(role_a=_role_str, role_b=_role_str, score=_llm_score) @pytest.mark.asyncio async def test_role_similarity_always_in_unit_interval( role_a: str, role_b: str, score: float ) -> None: """Property 5a: compute_role_similarity always returns a value in [0.0, 1.0]. For any two non-empty, non-"(unknown)" role names, the returned similarity must lie in the closed interval [0.0, 1.0], regardless of what the underlying LLM returns (clamping). """ client = _DummyClient(score=score) engine = SimilarityEngine(client=client) # type: ignore[arg-type] result = await engine.compute_role_similarity(role_a, role_b) assert isinstance(result, float), ( f"Expected float, got {type(result)}" ) assert 0.0 <= result <= 1.0, ( f"Score {result} out of range [0.0, 1.0] " f"(roles: {role_a!r}, {role_b!r}, llm_score: {score})" ) # **Validates: Requirement 5.1 (identity → 1.0)** @settings(max_examples=100) @given(role=_role_str) @pytest.mark.asyncio async def test_role_similarity_identity_returns_one( role: str, ) -> None: """Property 5b: identical role names (case-insensitive) return 1.0. The engine short-circuits without calling the LLM when both role arguments normalize to the same string. """ class _FailingClient: """Client that fails if called (identity must short-circuit).""" async def chat_completion(self, system: str, user: str) -> str: raise AssertionError( "LLM should not be called for identical roles" ) engine = SimilarityEngine(client=_FailingClient()) # type: ignore[arg-type] # Same role, same casing result = await engine.compute_role_similarity(role, role) assert result == 1.0, ( f"Expected 1.0 for identical role {role!r}, got {result}" ) # Same role, different casing (upper vs lower) result_mixed = await engine.compute_role_similarity( role.upper(), role.lower() ) assert result_mixed == 1.0, ( f"Expected 1.0 for case-insensitive match " f"({role.upper()!r} vs {role.lower()!r}), got {result_mixed}" )