Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
246 lines
8.5 KiB
Python
246 lines
8.5 KiB
Python
# 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)
|