# Feature: remove-embedding-competence-similarity, Property 1: BM25 Zero-Score für fehlenden Token-Overlap """Property-based tests for SimilarityEngine (BM25+RRF competence matching).""" from __future__ import annotations import re import pytest from hypothesis import given, settings from hypothesis import strategies as st from teamlandkarte_mcp.matching.bm25 import Bm25Index from teamlandkarte_mcp.matching.similarity import SimilarityEngine class _DummyClient: """Minimal stub for AzureOpenAIClient (unused for competence similarity).""" async def chat_completion(self, system: str, user: str) -> str: return '{"similarity": 0.5}' def _bm25_engine() -> SimilarityEngine: return SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type] def _tokenize(text: str) -> set[str]: """Mirror the BM25 tokenizer: lowercase + split on [\\W_]+.""" return {t for t in re.split(r"[\W_]+", text.lower()) if t} # Strategy: generate competence strings using ASCII letters only (min 3 chars). # We restrict to ASCII to avoid Unicode casefolding edge-cases (e.g. İ → i̇) # that produce unexpected token splits and break the prefix-disjointness guarantee. _competence_str = st.text( alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127), min_size=3, max_size=30, ) # Strategy: generate lists of competence strings (1-5 items) _competence_list = st.lists(_competence_str, min_size=1, max_size=5) def _make_disjoint_pair( required: list[str], candidate: list[str] ) -> tuple[list[str], list[str]]: """Prefix-separate required and candidate to guarantee disjoint tokens. Required competences get prefixed with 'req' and candidate competences get prefixed with 'cand'. Since both prefixes are pure letters and differ, and the original strings are pure letters, the resulting token sets are guaranteed to be disjoint after the BM25 tokenizer processes them. """ prefixed_req = [f"req{s}" for s in required] prefixed_cand = [f"cand{s}" for s in candidate] return prefixed_req, prefixed_cand # **Validates: Requirements 1.1** @settings(max_examples=100) @given(required=_competence_list, candidate=_competence_list) @pytest.mark.asyncio async def test_bm25_zero_score_on_disjoint_tokens( required: list[str], candidate: list[str] ) -> None: """Property 1: BM25 returns 0.0 for all required competences when there is no token overlap with any candidate competence. For any set of required competences and candidate competences where no candidate shares any token with a required competence, compute_competence_similarity shall return a score of 0.0 for that required competence. """ # Ensure disjoint tokens via prefix separation disjoint_req, disjoint_cand = _make_disjoint_pair(required, candidate) # Verify our disjoint guarantee holds (sanity check on the strategy) req_tokens = set() for r in disjoint_req: req_tokens.update(_tokenize(r)) cand_tokens = set() for c in disjoint_cand: cand_tokens.update(_tokenize(c)) assert req_tokens.isdisjoint(cand_tokens), ( f"Tokens not disjoint: {req_tokens & cand_tokens}" ) # Build a global BM25 index from the candidate competences (realistic usage) global_index = Bm25Index(corpus=disjoint_cand) engine = _bm25_engine() result = await engine.compute_competence_similarity( required=disjoint_req, candidate=disjoint_cand, global_index=global_index, ) # All required competences must have score 0.0 for req_competence in disjoint_req: assert req_competence in result, ( f"Missing key {req_competence!r} in result" ) score = float(result[req_competence]["score"]) # type: ignore[arg-type] assert score == 0.0, ( f"Expected 0.0 for {req_competence!r} but got {score}" )