Files
Orchestrator/bahn/teamlandkarte-mcp/tests/test_pbt_matcher_bm25_unconditional.py
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
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.
2026-06-30 20:39:52 +02:00

194 lines
6.6 KiB
Python

# 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<x>" and "cand<y>" 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}"
)