# Feature: remove-embedding-competence-similarity, Property 2: Matcher baut BM25-Index bedingungslos """Property-based tests for unconditional BM25 index building in Matcher. For any non-empty list of filtered candidates with competences, the Matcher shall always build a global BM25 index and use it for competence scoring, producing results where candidates with no token overlap receive score 0.0. """ from __future__ import annotations from datetime import date import pytest from hypothesis import given, settings from hypothesis import strategies as st from teamlandkarte_mcp.config import MatchingConfig from teamlandkarte_mcp.matching.matcher import Matcher from teamlandkarte_mcp.matching.similarity import SimilarityEngine from teamlandkarte_mcp.models import Capacity, Requirements class _DummyClient: """Minimal stub for AzureOpenAIClient.""" async def chat_completion(self, system: str, user: str) -> str: return '{"similarity": 0.5}' def _make_matcher() -> Matcher: engine = SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type] cfg = MatchingConfig() return Matcher(similarity=engine, cfg=cfg) # Strategy: ASCII-only letters to avoid Unicode tokenization edge cases. # The BM25 tokenizer lowercases then splits on [\W_]+. Some Unicode letters # produce non-word characters when lowercased (e.g. İ -> i + combining dot), # which would create unexpected token boundaries and shared tokens. _ascii_letters = st.text( alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz"), min_size=3, max_size=15, ) _competence_list = st.lists(_ascii_letters, min_size=1, max_size=4) @st.composite def _capacity_strategy(draw: st.DrawFn) -> Capacity: """Generate a Capacity with competences prefixed by 'cand'.""" comps = draw(_competence_list) prefixed = [f"cand{c}" for c in comps] return Capacity( id=draw(st.integers(min_value=1, max_value=10000)), owner_name="TestOwner", role_name="TestRole", role_level=None, begin_date=date(2025, 1, 1), end_date=date(2025, 12, 31), competences=prefixed, ) # **Validates: Requirements 2.6** @settings(max_examples=100) @given( capacities=st.lists(_capacity_strategy(), min_size=1, max_size=5), required_comps=_competence_list, ) @pytest.mark.asyncio async def test_matcher_builds_bm25_index_unconditionally( capacities: list[Capacity], required_comps: list[str], ) -> None: """Property 2: Matcher builds BM25 index unconditionally for all filtered candidates. For any non-empty list of filtered candidates, the Matcher always builds a global BM25 index. When required competences have no token overlap with any candidate competence, the competence score must be 0.0. """ # Prefix required competences with "req" to guarantee disjoint tokens. # Since all generated strings are pure ASCII lowercase letters, and # "req" != "cand", the tokens "req" and "cand" can never overlap. disjoint_required = [f"req{c}" for c in required_comps] requirements = Requirements( role_name="TestRole", competences=disjoint_required, date_start=date(2025, 1, 1), date_end=date(2025, 12, 31), ) matcher = _make_matcher() result = await matcher.match(capacities, requirements) # All candidates should be in the result (dates overlap) assert len(result.scored) == len(capacities) # Since required competences (req*) have no token overlap with # candidate competences (cand*), all competence scores must be 0.0 for scored in result.scored: assert scored.competence_score == 0.0, ( f"Expected competence_score 0.0 for capacity {scored.capacity.id}, " f"got {scored.competence_score}. " f"Required: {disjoint_required}, " f"Candidate: {scored.capacity.competences}" ) assert set(scored.missing_competences) == set(disjoint_required), ( f"Expected all required competences to be missing, " f"got missing={scored.missing_competences}" ) @st.composite def _capacities_with_unique_token(draw: st.DrawFn) -> list[Capacity]: """Generate 3 capacities where the first has a unique competence. BM25 IDF = log((N - df + 0.5) / (df + 0.5)). With N=2 and df=1, IDF = log(1) = 0. We need N >= 3 with df=1 for IDF > 0. We generate 3 capacities with 3 distinct competences in the global corpus. The unique token appears only in cap1 (df=1, N=3 → IDF > 0). """ base = draw(_ascii_letters) other = f"other{draw(_ascii_letters)}" unique_token = f"zzuniq{draw(_ascii_letters)}" cap1 = Capacity( id=1, owner_name="TestOwner", role_name="TestRole", role_level=None, begin_date=date(2025, 1, 1), end_date=date(2025, 12, 31), competences=[base, unique_token], ) cap2 = Capacity( id=2, owner_name="TestOwner", role_name="TestRole", role_level=None, begin_date=date(2025, 1, 1), end_date=date(2025, 12, 31), competences=[base], ) cap3 = Capacity( id=3, owner_name="TestOwner", role_name="TestRole", role_level=None, begin_date=date(2025, 1, 1), end_date=date(2025, 12, 31), competences=[other], ) return [cap1, cap2, cap3] @settings(max_examples=50) @given(capacities=_capacities_with_unique_token()) @pytest.mark.asyncio async def test_matcher_scores_nonzero_for_token_overlap( capacities: list[Capacity], ) -> None: """Property 2 (supplementary): When the BM25 index is built and a required competence shares tokens with a candidate, the score is non-zero. This confirms the index is actually built and used for scoring (not skipped). BM25 IDF is positive when a term appears in fewer than half the corpus documents. With 3+ corpus docs and df=1, IDF > 0. """ unique_comp = capacities[0].competences[1] requirements = Requirements( role_name="TestRole", competences=[unique_comp], date_start=date(2025, 1, 1), date_end=date(2025, 12, 31), ) matcher = _make_matcher() result = await matcher.match(capacities, requirements) assert len(result.scored) == len(capacities) # Cap1 must have non-zero competence score (it has the unique token) first_cap_scored = next( s for s in result.scored if s.capacity.id == capacities[0].id ) assert first_cap_scored.competence_score > 0.0, ( f"Expected non-zero competence score for unique token match, " f"got {first_cap_scored.competence_score}. " f"Required: [{unique_comp}], Candidate: {capacities[0].competences}" )