# filepath: tests/test_bm25.py """Unit tests for :mod:`teamlandkarte_mcp.matching.bm25`. Covers tokenization, :class:`Bm25Index` behaviour, and the :func:`bm25_rank_competences` convenience wrapper. """ from __future__ import annotations from teamlandkarte_mcp.matching.bm25 import ( Bm25Index, _tokenize, bm25_rank_competences, ) # --------------------------------------------------------------------------- # Tokenization # --------------------------------------------------------------------------- def test_tokenize_lowercase() -> None: """Tokens are always lowercase.""" assert _tokenize("Python") == ["python"] def test_tokenize_slash() -> None: """Slashes split tokens.""" assert _tokenize("CI/CD Pipeline") == ["ci", "cd", "pipeline"] def test_tokenize_parentheses_and_space() -> None: """Parentheses and spaces split tokens; PWA is its own token.""" assert _tokenize("Progressive Web App (PWA)") == [ "progressive", "web", "app", "pwa", ] def test_tokenize_dot() -> None: """Dots split tokens (e.g. React.js → react, js).""" assert _tokenize("React.js") == ["react", "js"] def test_tokenize_hyphen() -> None: """Hyphens split tokens.""" assert _tokenize("Type-Script") == ["type", "script"] def test_tokenize_empty_string() -> None: """Empty string produces empty token list.""" assert _tokenize("") == [] def test_tokenize_only_special_chars() -> None: """String of only special characters produces empty list.""" assert _tokenize("---///") == [] # --------------------------------------------------------------------------- # Bm25Index — empty corpus # --------------------------------------------------------------------------- def test_bm25_index_empty_corpus_rank_returns_empty() -> None: """Empty corpus → rank() returns empty list.""" index = Bm25Index(corpus=[]) assert index.rank("Python") == [] # --------------------------------------------------------------------------- # Bm25Index — exact match # --------------------------------------------------------------------------- def test_bm25_index_exact_match_score_positive() -> None: """Exact match produces a positive BM25 score in a realistic-sized corpus. A corpus of five distinct skills ensures each term's IDF is positive (no term appears in more than half the documents), so BM25 assigns a meaningful positive score to the matching document. """ corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"] index = Bm25Index(corpus=corpus) result = index.rank("Python") py_score = next(s for d, s in result if d == "Python") assert py_score > 0.0 def test_bm25_index_case_insensitive_match() -> None: """BM25 matching is case-insensitive (tokenization lowercases both sides). Uses a realistic-sized corpus so IDF weights are positive. """ corpus = ["python", "JavaScript", "Java", "Go", "Ruby"] index = Bm25Index(corpus=corpus) result = index.rank("Python") py_score = next(s for d, s in result if d == "python") assert py_score > 0.0 # --------------------------------------------------------------------------- # Bm25Index — no token overlap # --------------------------------------------------------------------------- def test_bm25_index_no_token_overlap_score_zero() -> None: """No shared token between query and document → score 0.0.""" index = Bm25Index(corpus=["JavaScript"]) result = index.rank("Python") assert len(result) == 1 assert result[0][0] == "JavaScript" assert result[0][1] == 0.0 def test_bm25_index_partial_token_overlap() -> None: """Partial token overlap gives positive score in a realistic-sized corpus. "Machine Learning" shares the "learning" token with "Deep Learning". A five-document corpus keeps "learning" IDF positive (it appears in 2/5 docs, well below the 50 % threshold that would zero out the IDF). """ corpus = ["Deep Learning", "Python", "JavaScript", "Java", "Go"] index = Bm25Index(corpus=corpus) result = index.rank("Machine Learning") dl_score = next(s for d, s in result if d == "Deep Learning") assert dl_score > 0.0 # --------------------------------------------------------------------------- # Bm25Index — multiple documents, ranking order # --------------------------------------------------------------------------- def test_bm25_index_best_match_ranked_first() -> None: """The document with the highest score appears first. Uses five documents so all query tokens have positive IDF and the exact-match document outscores partial-match documents. """ corpus = ["Machine Learning", "Deep Learning", "JavaScript", "Python", "Java"] index = Bm25Index(corpus=corpus) result = index.rank("Machine Learning") assert result[0][0] == "Machine Learning" assert result[0][1] > result[1][1] def test_bm25_index_zero_score_documents_included() -> None: """Documents with score 0.0 are included in the ranked list. Uses a realistic-sized corpus so the Python document itself gets a positive score, while the unrelated JavaScript document correctly scores 0.0 (no token overlap). """ corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"] index = Bm25Index(corpus=corpus) result = index.rank("Python") docs = [r[0] for r in result] assert "JavaScript" in docs js_score = next(s for d, s in result if d == "JavaScript") assert js_score == 0.0 def test_bm25_index_result_sorted_descending() -> None: """Ranked list is sorted by score descending. Uses a realistic-sized corpus to ensure stable positive IDF values. """ corpus = ["Python", "Python Advanced", "JavaScript", "Java", "Go"] index = Bm25Index(corpus=corpus) result = index.rank("Python") scores = [s for _, s in result] assert scores == sorted(scores, reverse=True) # --------------------------------------------------------------------------- # bm25_rank_competences convenience wrapper # --------------------------------------------------------------------------- def test_bm25_rank_competences_empty_candidates() -> None: """Empty candidate list returns empty list.""" assert bm25_rank_competences("Python", []) == [] def test_bm25_rank_competences_consistent_with_index() -> None: """Convenience wrapper produces same result as Bm25Index.rank().""" candidates = ["Python", "JavaScript", "Machine Learning", "Java", "Go"] index_result = Bm25Index(corpus=candidates).rank("Python") wrapper_result = bm25_rank_competences("Python", candidates) assert index_result == wrapper_result # --------------------------------------------------------------------------- # Global-index filtering # --------------------------------------------------------------------------- def test_bm25_index_rank_filters_to_candidate_subset() -> None: """Scores from a global index can be filtered to a candidate's own skills. This mirrors the production path in ``_bm25_rrf_similarity`` where a global ``Bm25Index`` is built over all candidates and then the result is filtered to only include the current candidate's competences. """ global_corpus = [ "Python", "Machine Learning", "JavaScript", "TypeScript", "Java", "Go", "Ruby", ] global_index = Bm25Index(corpus=global_corpus) # Simulate one candidate who only knows Python + Machine Learning candidate_skills = {"Python", "Machine Learning"} all_ranked = global_index.rank("Python") filtered = [(doc, score) for doc, score in all_ranked if doc in candidate_skills] assert len(filtered) == 2 docs = {d for d, _ in filtered} assert docs == candidate_skills # Python should score higher than Machine Learning for query "Python" py_score = next(s for d, s in filtered if d == "Python") ml_score = next(s for d, s in filtered if d == "Machine Learning") assert py_score > ml_score def test_bm25_ubiquitous_term_clamped_to_zero() -> None: """A term that appears in more than half the corpus gets IDF ≤ 0. ``rank()`` clamps the resulting negative BM25 score to 0.0 — the term is non-discriminative and should not contribute positively to any match. """ # "dev" appears in 4 out of 5 documents → IDF < 0 corpus = ["dev Python", "dev Java", "dev Go", "dev Ruby", "JavaScript"] index = Bm25Index(corpus=corpus) result = index.rank("dev") scores = [s for _, s in result] # All scores must be ≥ 0 (negative raw scores clamped to 0.0) assert all(s >= 0.0 for s in scores)