diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 082e4950..ae340c9c 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -40,12 +40,14 @@ - **Guard:** `tests/test_reranker.py` — `test_sigmoid_matches_reference_values` (4 кейса), `test_sigmoid_is_numerically_stable_at_extremes`, `test_llama_cpp_scores_normalized_to_unit_interval`, `test_llama_cpp_negative_logit_does_not_leave_unit_interval`. Проверено мутацией (`_sigmoid` → identity): **7 failed / 38 passed**; чисто — 45 passed. - **T3 (обобщение):** иных мест с абсолютным порогом по логитам в `src/` нет. `_DEFAULT_THRESHOLD = 0.85` в `duplication.py:37` — порог по Jaccard (по определению в [0,1], `clamp` на строке 136), другой механизм. -## 2026-09-27 — P2: целевой файл не доходит до финального пула; причина не установлена (Open) +## 2026-09-27 — P2: целевой файл не доходит до финального пула (Root Cause установлен, fix в PR) - **Симптом:** для запроса P2 (`hybrid_search_async reciprocal_rank_fusion FTS5 BM25`) целевой `src/core/search/engine.py` не найден. Top-хиты — собственные артефакты эксперимента: `experiments/**/*.txt`, `results.json`, `docs/zh/SEARCH_PIPELINE.md`. Реранкер ни при чём — цели нет в пуле ещё до него. -- **Root Cause: НЕ УСТАНОВЛЕН.** Зафиксировано открытое противоречие: отдельный standalone-прогон BM25 вернул цель на **rank 0**, что несовместимо с утверждением «цель не находится вовсе». Расхождение между standalone BM25 и путём внутри `hybrid_search_async` не изучено. **Причину не утверждать** до разбора построения пула и RRF-слияния. -- **Побочно (Verified):** индекс содержит вывод собственных экспериментов, что загрязняет lexical-выдачу по общим терминам — это отдельная проблема индексации, не фильтра. -- **Что нужно:** разобрать построение pre-rerank пула и слияние RRF; выяснить, почему BM25 rank-0 не доходит до финального пула. +- **Root Cause (Verified чтением кода + синтетической регрессией):** RRF-консенсус размывает однотирные находки. Цель P2 — BM25 rank 0, но только одного тира → `1/(60+1)≈0.0164`; мусор из 2-3 тиров на средних рангах накапливает 2-3x → `rrf_results[:limit]` (`engine.py:746`) ампутирует цель до реранкера. MMR невиновен (reorder-only), bucket-веса фаворизируют цель (.py 1.0 vs .txt/.md 0.5), expansion держит verbatim-запрос как variants[0] — «противоречие» standalone-BM25-rank-0 vs hybrid-loss этим и разрешается: hybrid никогда не возвращает сырой BM25-порядок. +- **Fix (adopt/reranker-threshold-and-pool):** `anchor_tier_winners` (`scoring.py`) — пул = MMR-база + лидеры тиров (per_tier=1, потолок MAX_RERANKER_INPUT), дописываются в хвост после MMR; `top_n` реранкера (=limit) и путь без реранкера не меняются. Плюс top-N recall floor `MAX_RERANKER_TOPN` (default 0=off) и `threshold_calibration.calibrate_threshold` с кодовым запретом калибровки на eval (ValueError). Дефолт 0.3 не тронут — калибровать только на holdout, дизъюнктном с frozen eval-16. +- **Guard:** `tests/test_reranker_pool_and_threshold.py` (18: P2-регрессия падала до фикса, якоря/дедуп/cap/fallback/limit=0, top-N вкл/выкл/cap, F1-калибровка, 6 eval-маркеров → ValueError). Смежные: 143 passed (reranker+searcher+hardening+ubatch+bs_audit); ruff чист. +- **Остаточное:** live-проверка P2/P3 на реальном индексе+llama.cpp не выполнена в этой среде (только синтетика + залогированные скоры P3/R2); holdout-набор для калибровки не собран (нужно ≥10 запросов вне eval-16) — решение владельца. +- **Статус:** 🔧 Fixed (код+тесты) / 🔬 Open (live-валидация + holdout-калибровка). ## 2026-09-25 — Падения не фиксировались: zombie-job + глушение исключений + нет ledger (Fixed) / Open (server hard-death) diff --git a/src/config/settings.py b/src/config/settings.py index bcf202a8..0a601acc 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -264,6 +264,17 @@ class PerformanceConfig: default_factory=lambda: float(os.getenv("DOCS_BUCKET_WEIGHT", "0.5")) ) + # Reranker top-N recall floor (adopt/reranker-threshold-and-pool): + # сколько лучших по скору чанков переживают MIN_RERANK_SCORE-фильтр + # безусловно (union с прошедшими порог). 0 = выключено (текущее поведение: + # фильтр + fallback "вернуть всё", если не прошёл никто). + # P3-прецедент: цель 0.271<0.3 отсекалась при 2 прошедших; top_n_keep>=3 + # вернул бы её как 3-ю по скору. Калибровка значения — только на holdout + # (см. src/providers/reranker/threshold_calibration.py), default 0. + reranker_topn_keep: int = field( + default_factory=lambda: int(os.getenv("MAX_RERANKER_TOPN", "0")) + ) + # SYSTEM PROFILE (v2.6.0+) system_profile: str = os.getenv("SYSTEM_PROFILE", "light") diff --git a/src/core/search/engine.py b/src/core/search/engine.py index e8d012b6..829fe7ef 100644 --- a/src/core/search/engine.py +++ b/src/core/search/engine.py @@ -30,11 +30,13 @@ from .fts5_mixin import FTS5Mixin from .scoring import ( _apply_co_change_boost, + anchor_tier_winners, apply_bucket_weights, apply_mmr_diversity, auto_detect_intent, reciprocal_rank_fusion, reciprocal_rank_fusion_3way, + rrf_key, ) from .token_savings import calculate_token_savings from .trace import SearchTracer @@ -757,6 +759,32 @@ async def hybrid_search_async( if tracer and _mmr_before: tracer.record_mmr(_mmr_before, pre_rerank_results, lambda_param=0.6) + # === P2-fix: лидеры тиров в pre-rerank пул (anchor_tier_winners) === + # RRF награждает multi-tier консенсус: цель из ОДНОГО тира (P2: BM25 + # rank 0) проигрывает multi-tier мусору и срезалась [:limit] до + # реранкера. Якоря дописываются ПОСЛЕ MMR (он только переупорядочивает), + # потолок — MAX_RERANKER_INPUT; top_n реранкера (=limit) не меняется. + if use_rrf: + # MMR-порядок базового среза сохраняется как есть (важен для + # пути без реранкера); якоря только дописываются в хвост — + # при живом реранкере он всё равно пересортирует по своим скорам. + _mmr_keys = {rrf_key(c) for c in pre_rerank_results} + _anchored = anchor_tier_winners( + rrf_results, + [ + (unique_bm25, "bm25_score"), + (all_dense_results, "dense_score"), + (all_fts5_results, "fts5_score"), + (graph_results, "graph_score"), + ], + limit, + per_tier=1, + pool_cap=MAX_RERANKER_INPUT, + ) + pre_rerank_results = list(pre_rerank_results) + [ + c for c in _anchored if rrf_key(c) not in _mmr_keys + ] + # Мульти-провайдерный реранкинг (Ollama / LM Studio) — опциональный # Реранкер перезаписывает final_score своими семантическими весами _pre_rerank = list(pre_rerank_results) if tracer else None diff --git a/src/core/search/scoring.py b/src/core/search/scoring.py index 5a3f007b..28923d12 100644 --- a/src/core/search/scoring.py +++ b/src/core/search/scoring.py @@ -17,6 +17,9 @@ __all__ = [ "reciprocal_rank_fusion", + "reciprocal_rank_fusion_3way", + "rrf_key", + "anchor_tier_winners", "auto_detect_intent", "apply_bucket_weights", "apply_mmr_diversity", @@ -149,6 +152,82 @@ def _ingest(results, score_key): return out +def rrf_key(item: dict) -> str: + """Канонический ключ фьюжена ``file:chunk_index`` (единый для RRF, + дедупа тиров и якорей — расхождение форматов роняло бы сверку).""" + meta = item.get("metadata", {}) or {} + return f"{meta.get('file', '?')}:{meta.get('chunk_index', 0)}" + + +def anchor_tier_winners( + rrf_ranked: List[dict], + tiers: List[tuple], + limit: int, + per_tier: int = 1, + pool_cap: int = 30, + rrf_k: int = 60, +) -> List[dict]: + """Гарантирует место в pre-rerank пуле лидерам каждого тира (P2-fix). + + Мотивация (P2, verified): 3-way RRF награждает multi-tier консенсус — + цель, найденная ОДНИМ тиром (BM25 rank 0 → ``1/(60+1)``), проигрывает + мусору из 2-3 тиров (``2-3x``) и ампутируется срезом ``[:limit]`` до + реранкера. MMR невиновен (только переупорядочивает), bucket-веса + фаворизируют цель (.py=1.0 против .txt/.md=0.5). + + Механика: пул = RRF top-``limit`` + недостающие лидеры тиров + (``per_tier`` голов каждого тира), взятые из полного RRF-списка; + если лидера нет даже там (сверхзагрязнённый индекс) — fused-запись + строится из сырого тир-элемента с его RRF-вкладом. Порядок RRF + сохраняется, дубли исключаются, размер ограничен ``pool_cap``. + + Args: + rrf_ranked: Полный RRF-ранжированный список (до среза, ~raw_limit). + tiers: Список ``(tier_items, score_key)`` — score_key один из + ``bm25_score`` / ``dense_score`` / ``fts5_score`` / ``graph_score``. + limit: Базовый размер пула (RRF top-limit входит безусловно). + per_tier: Сколько голов тира закреплять (default 1). + pool_cap: Жёсткий потолок пула (реранкер ~37ms/текст). + rrf_k: Та же константа RRF, что при фьюжене. + + Returns: + Пул для реранкера (схема записей = RRF, с ``final_score``). + """ + if limit <= 0: + return [] # контракт hybrid_search_async: limit=0 -> пустой пул + pool = list(rrf_ranked[:limit]) + pool_keys = {rrf_key(c) for c in pool} + ranked_by_key = {rrf_key(c): c for c in rrf_ranked} + + for tier_items, score_key in tiers: + for rank, item in enumerate(tier_items[:per_tier], 1): + if len(pool) >= pool_cap: + return pool + key = rrf_key(item) + if key in pool_keys: + continue + if key in ranked_by_key: + pool.append(ranked_by_key[key]) + else: + # Лидер тира вне RRF-списка: восстанавливаем fused-запись + # с его собственным RRF-вкладом 1/(k+rank). + contrib = 1.0 / (rrf_k + rank) + entry = { + "text": item.get("text", ""), + "metadata": item.get("metadata", {}), + "bm25_score": 0.0, + "dense_score": 0.0, + "fts5_score": 0.0, + "graph_score": 0.0, + "final_score": contrib, + } + if score_key in entry: + entry[score_key] = contrib + pool.append(entry) + pool_keys.add(key) + return pool + + def auto_detect_intent(query: str) -> str: """Авто-определение intent по тексту запроса (v3.2.1 B1). diff --git a/src/providers/reranker/multi_provider.py b/src/providers/reranker/multi_provider.py index a9571182..6269dd71 100644 --- a/src/providers/reranker/multi_provider.py +++ b/src/providers/reranker/multi_provider.py @@ -23,6 +23,7 @@ import asyncio import logging +import math import time from typing import Any, Dict, List, Optional @@ -84,10 +85,24 @@ def _truncate_rerank_pair( # Минимальный скор реранкера для фильтрации низкокачественных чанков -# Chunk'и со скором ниже этого значения отсекаются из финальных результатов +# Chunk'и со скором ниже этого значения отсекаются из финальных результатов. +# +# ВАЖНО: порог задан для шкалы [0,1]. llama.cpp /v1/rerank отдаёт СЫРЫЕ логиты +# кросс-энкодера (диапазон ≈[-11,+11]), хотя endpoint позиционируется как +# Cohere-совместимый, где контракт обещает нормализацию в [0,1] +# (ggml-org/llama.cpp#9510 — собственный пример ggerganov: 5.97 и -11.03). +# Без нормализации порог 0.3 отсекает 70-97% выдачи, в т.ч. целевые файлы. MIN_RERANK_SCORE = 0.3 +def _sigmoid(x: float) -> float: + """Логит -> вероятность [0,1], численно устойчиво для |x| > 700.""" + if x >= 0.0: + return 1.0 / (1.0 + math.exp(-x)) + e = math.exp(x) + return e / (1.0 + e) + + class MultiProviderReranker(IReranker): """Реранкер на основе внешних LLM-провайдеров (Ollama / LM Studio). @@ -631,6 +646,11 @@ async def rerank( ] scores = await self._llama_cpp_rerank(query, passages) if scores: + # llama.cpp отдаёт сырые логиты кросс-энкодера (≈[-11,+11]), + # а не нормализованный [0,1] по Cohere-контракту. Без + # приведения MIN_RERANK_SCORE отсекает 70-97% выдачи. + # Сортировка не меняется: сигмоида монотонна. + scores = [_sigmoid(s) for s in scores] scored = [{"index": i, "score": s} for i, s in enumerate(scores)] chunks = apply_scores(chunks, scored, top_n) self.last_timing["reranker_ms"] = (_time.perf_counter() - t1) * 1000 @@ -664,11 +684,24 @@ async def rerank( self.last_timing["total_ms"] = (_time.perf_counter() - t_start) * 1000 - # Фильтр низкорелевантных чанков (мусор — JSON локали, битые fallback) + # Фильтр низкорелевантных чанков (мусор — JSON локали, битые fallback). + # На этом месте chunks уже отсортированы по reranker_score desc + # (apply_scores) и обрезаны до top_n. _filtered = [ c for c in chunks[:top_n] if c.get("reranker_score", 1.0) >= MIN_RERANK_SCORE ] + # Top-N recall floor (MAX_RERANKER_TOPN, default 0 = выключено): + # union прошедших порог с top-N по скору. Абсолютный порог хрупок на + # некалиброванных кросс-энкодерных скорах (P3: цель 0.271<0.3); + # floor гарантирует, что N лучших всегда доходят до выдачи. + _topn_keep = get_config().performance.reranker_topn_keep + if _topn_keep > 0 and len(_filtered) < min(_topn_keep, top_n): + _n = min(_topn_keep, top_n, len(chunks)) + _keep_ids = {id(c) for c in _filtered} | { + id(c) for c in chunks[:_n] + } + _filtered = [c for c in chunks if id(c) in _keep_ids] if _filtered: return _filtered return chunks[:top_n] diff --git a/src/providers/reranker/threshold_calibration.py b/src/providers/reranker/threshold_calibration.py new file mode 100644 index 00000000..ea4ce240 --- /dev/null +++ b/src/providers/reranker/threshold_calibration.py @@ -0,0 +1,107 @@ +"""Holdout-калибровка порога реранкера (adopt/reranker-threshold-and-pool). + +Протокол (обязателен; нарушение = перебор на оценочной выборке): + +1. HOLDOUT-СПЛИТ: калибровать ТОЛЬКО на запросах, дизъюнктных с 16 frozen + eval-правилами ``experiments/token_reduction_v3_lancedb/frozen/rules.jsonl`` + (sha256 ``31f1b0c9…``). Рекомендуемый размер: >=10 holdout-запросов со + своими positive-контролями (целевые файлы вне eval-16); eval-16 при + калибровке НЕ СМОТРЕТЬ (freeze-before-look, §17). +2. ``calibrate_threshold`` (F1-максимум) на holdout-скорах -> кандидат порога. +3. Проверка кандидата на eval-16 БЕЗ подстройки: сообщить hits до/после; + любой добор по eval = новый overfit-цикл, запрещён. +4. Дефолт ``MIN_RERANK_SCORE=0.3`` в ``multi_provider.py`` НЕ МЕНЯТЬ без + holdout-замера из пп.1-3 (прецедент отказа: sweep 0.3->0.05/0.02 дал + 7-8 hits на тех же правилах, которыми мерялся результат — + EXPERIMENTS_LOG 2026-09-27). + +Анти-перебор закодирован: ``source`` с eval-маркером бросает ValueError +(тест ``test_calibration_refuses_eval_source``). +""" + +from __future__ import annotations + +from typing import Sequence + +__all__ = ["calibrate_threshold", "EVAL_SOURCE_MARKERS"] + +# Маркеры eval-источников: калибровка на них = подгонка под метрику. +EVAL_SOURCE_MARKERS = frozenset( + { + "eval", + "evaluation", + "test", + "frozen", + "frozen-eval", + "token_reduction_v3", + "token_reduction_v3_lancedb", + "v3", + } +) + + +def _is_eval_source(source: str) -> bool: + src = (source or "").strip().lower() + return src in EVAL_SOURCE_MARKERS + + +def calibrate_threshold( + scores: Sequence[float], + labels: Sequence[bool], + *, + source: str, + min_recall: float = 0.0, +) -> float: + """Подбирает порог ``score >= t`` максимумом F1 на HOLDOUT-разметке. + + Args: + scores: Скоре реранкера (шкала [0,1], после сигмоиды). + labels: Релевантность (True = целевой чанк holdout-запроса). + source: Происхождение разметки (напр. ``"holdout-2026-10-03"``). + Eval-маркеры (``"eval"``, ``"frozen"``, ``"token_reduction_v3"``, + …) запрещены — ValueError. Правило необратимо: кто калибрует + на eval, тот подгоняет метрику. + min_recall: Минимальный допустимый recall (кандидаты ниже отсекаются; + 0.0 = чистый F1-максимум). + + Returns: + Порог-кандидат. Ничья по F1 — в пользу БОЛЕЕ ВЫСОКОГО порога + (фильтр должен резать мусор, а не пропускать всё). + + Raises: + ValueError: ``source`` — eval-источник, пустая выборка, длины + расходятся, нет ни одного positive. + """ + if _is_eval_source(source): + raise ValueError( + f"Калибровка порога на eval-источнике {source!r} запрещена: " + "это подгонка под метрику (см. EXPERIMENTS_LOG 2026-09-27, " + "sweep 0.3->0.05/0.02). Используйте holdout-сплит, дизъюнктный " + "с frozen eval-правилами." + ) + scores = [float(s) for s in scores] + labels = [bool(lb) for lb in labels] + if not scores or len(scores) != len(labels): + raise ValueError("scores/labels пусты или длины расходятся") + n_pos = sum(labels) + if n_pos == 0: + raise ValueError("нет ни одного positive — F1 неопределим") + + best_t, best_f1 = max(scores) + 1e-9, -1.0 # sentinel: пустая выдача + for t in sorted(set(scores)): + kept = [lb for s, lb in zip(scores, labels) if s >= t] + tp = sum(kept) + if tp == 0: + continue + precision = tp / len(kept) + recall = tp / n_pos + if recall < min_recall: + continue + f1 = 2 * precision * recall / (precision + recall) + # Строго больше — либо равный F1 при БОЛЕЕ ВЫСОКОМ пороге + # (кандидаты идут по возрастанию, >= перезаписывает ничью). + if f1 >= best_f1: + best_f1, best_t = f1, t + if best_f1 < 0.0: + raise ValueError("ни один порог не даёт tp>0 при min_recall") + return best_t diff --git a/tests/test_reranker.py b/tests/test_reranker.py index d3b660a4..03ef6f04 100644 --- a/tests/test_reranker.py +++ b/tests/test_reranker.py @@ -17,7 +17,7 @@ import httpx import pytest -from src.providers.reranker.multi_provider import MultiProviderReranker +from src.providers.reranker.multi_provider import MultiProviderReranker, _sigmoid from src.providers.reranker.reranker_scoring import ( apply_scores, cosine_similarity, @@ -708,3 +708,91 @@ def test_cosine_similarity_empty_vectors(): """Cosine similarity пустых векторов = 0.0.""" assert cosine_similarity([], []) == 0.0 assert cosine_similarity([1.0], []) == 0.0 + + +# ═══════════════════════════════════════════════════════════════════════════ +# Нормализация шкалы скора реранкера (регрессия от 2026-09-27) +# +# llama.cpp /v1/rerank позиционируется как Cohere-совместимый endpoint, где +# контракт обещает relevance_score в [0,1], но фактически отдаёт СЫРЫЕ логиты +# кросс-энкодера (собственный пример ggml-org/llama.cpp#9510: 5.97 и -11.03). +# MIN_RERANK_SCORE откалиброван под [0,1], поэтому до нормализации фильтр +# отбрасывал 70-97% выдачи. Здесь _llama_cpp_rerank замокан — проверяется +# именно преобразование шкалы, а не HTTP-слой. +# ═══════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.parametrize( + "raw,expected", + [ + (5.97, 0.99745), # релевантный — пример из llama.cpp PR #9510 + (-11.03, 0.0000162), # мусор — тот же пример + (0.0, 0.5), # точка перегиба + (-0.99, 0.27091), # цель P3 из замера 2026-09-27 + ], +) +def test_sigmoid_matches_reference_values(raw, expected): + """Сигмоида совпадает с эталонными значениями 1/(1+e^-x).""" + assert _sigmoid(raw) == pytest.approx(expected, rel=1e-3) + + +def test_sigmoid_is_numerically_stable_at_extremes(): + """exp(-x) не вызывает OverflowError на больших |x| (регрессия P-002).""" + assert _sigmoid(-1e4) == pytest.approx(0.0, abs=1e-12) + assert _sigmoid(1e4) == pytest.approx(1.0, abs=1e-12) + for x in (-800.0, -745.0, 0.0, 745.0, 800.0): + assert 0.0 <= _sigmoid(x) <= 1.0 + + +@pytest.mark.asyncio +async def test_llama_cpp_scores_normalized_to_unit_interval(sample_chunks): + """Сырые логиты llama.cpp попадают в reranker_score как [0,1], а не как есть. + + Контроли: + * positive — релевантный чанк (логит +5.97) обязан выжить и быть первым; + * negative — мусорный чанк (логит -9.38) обязан быть отсечён фильтром; + * контракт — ни один выживший скор не выходит за [0,1]. + """ + reranker = MultiProviderReranker() + reranker.ollama_available = False + reranker.lm_studio_available = False + reranker.llama_cpp_available = True + + # auth.py — релевантен, repo.py — умеренно, utils.py — мусор + reranker._llama_cpp_rerank = AsyncMock(return_value=[5.97, -0.99, -9.38]) + + result = await reranker.rerank("запрос", sample_chunks, top_n=3) + + files = [c["metadata"]["file"] for c in result] + # positive control: релевантный чанк выжил и возглавил выдачу + assert files[0] == "auth.py" + # negative control: мусор отсечён (sigmoid(-9.38) ≈ 8e-5 << MIN_RERANK_SCORE) + assert "utils.py" not in files + # контракт шкалы соблюдён — именно это и было сломано + for chunk in result: + assert 0.0 <= chunk["reranker_score"] <= 1.0, chunk["reranker_score"] + assert result[0]["reranker_score"] == pytest.approx(0.99745, rel=1e-3) + + +@pytest.mark.asyncio +async def test_llama_cpp_negative_logit_does_not_leave_unit_interval(sample_chunks): + """Отрицательные логиты тоже нормализуются, а fallback не теряет чанки. + + Без сигмоиды в reranker_score попадали бы сырые -0.99 / -2.60 — значения + вне [0,1], которые MIN_RERANK_SCORE сравнивает с 0.3 в бессмысленной шкале. + """ + reranker = MultiProviderReranker() + reranker.ollama_available = False + reranker.lm_studio_available = False + reranker.llama_cpp_available = True + + # все три логита отрицательны и после нормализации ниже MIN_RERANK_SCORE + reranker._llama_cpp_rerank = AsyncMock(return_value=[-0.99, -2.60, -9.38]) + + result = await reranker.rerank("запрос", sample_chunks, top_n=3) + + for chunk in result: + assert 0.0 <= chunk["reranker_score"] <= 1.0, chunk["reranker_score"] + # -0.99 -> 0.271, -2.60 -> 0.069, -9.38 -> 0.00008: всё ниже 0.3, + # поэтому срабатывает fallback и возвращаются все три чанка. + assert [c["metadata"]["file"] for c in result] == ["auth.py", "repo.py", "utils.py"] diff --git a/tests/test_reranker_pool_and_threshold.py b/tests/test_reranker_pool_and_threshold.py new file mode 100644 index 00000000..160b68dc --- /dev/null +++ b/tests/test_reranker_pool_and_threshold.py @@ -0,0 +1,235 @@ +"""P2 pre-rerank pool + threshold/top-N selection + holdout calibration. + +P2 (root cause, verified by code reading, engine.py/scoring.py): +`hybrid_search_async` fuses tiers with 3-way RRF and then amputates the +pre-rerank pool with ``rrf_results[:limit]`` (engine.py). RRF rewards +multi-tier consensus: a target found by ONE tier only (P2: BM25 rank 0 for +``src/core/search/engine.py``) scores ``1/(60+1)`` while junk present in 2-3 +tiers at mediocre ranks accumulates 2-3x that. The ``[:limit]`` cut then drops +the single-tier winner before the reranker ever sees it. MMR is innocent +(reorder-only, no drops), bucket weights favour the target (.py=1.0 vs +.txt/.md=0.5), query expansion keeps the verbatim query as variants[0] — +so the standalone-BM25-rank-0 vs hybrid-loss "contradiction" is exactly this +fusion dilution, not a retrieval miss. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from src.core.search.scoring import anchor_tier_winners, reciprocal_rank_fusion_3way, rrf_key +from src.providers.reranker.multi_provider import MultiProviderReranker +from src.providers.reranker.threshold_calibration import calibrate_threshold + +TARGET_FILE = "src/core/search/engine.py" + + +def _chunk(path: str, idx: int = 0) -> dict: + return { + "text": f"chunk of {path}", + "metadata": {"file": path, "chunk_index": idx}, + } + + +def _p2_tiers(): + """Synthetic P2: BM25 winner + multi-tier junk (mirrors the polluted + index: experiments/**/*.txt share the generic P2 query terms).""" + target = _chunk(TARGET_FILE, 5) + junk = [_chunk(f"experiments/probe_{i}.txt", 0) for i in range(12)] + bm25 = [target] + [_chunk(f"experiments/probe_{i}.txt", 0) for i in range(12)] + bm25 += [_chunk(f"experiments/filler_{i}.txt", 0) for i in range(7)] + dense = list(junk) + [_chunk(f"src/unrelated_{i}.py", 0) for i in range(8)] + fts5 = list(junk) + [_chunk(f"docs/note_{i}.md", 0) for i in range(8)] + return target, bm25, dense, fts5 + + +def _pool_keys(pool) -> list: + return [c["metadata"]["file"] for c in pool] + + +def _assemble_pool(rrf, tiers, limit, pool_cap=30): + """Pool assembly как в engine.hybrid_search_async (RRF-порядок; engine + дополнительно сохраняет MMR-порядок базы — якоря только в хвосте).""" + return anchor_tier_winners( + rrf, tiers, limit, per_tier=1, pool_cap=pool_cap + ) + + +def test_p2_bm25_winner_survives_pre_rerank_pool(): + """P2 regression: single-tier BM25 rank-0 target must enter the pool.""" + target, bm25, dense, fts5 = _p2_tiers() + limit, raw_limit = 10, 20 + rrf = reciprocal_rank_fusion_3way(bm25, dense, fts5, raw_limit) + # Старый срез rrf[:limit] цель ампутировал (тест падал до фикса); + # сборка с якорями обязана её вернуть. + assert TARGET_FILE not in _pool_keys(rrf[:limit]) + pool = _assemble_pool( + rrf, + [(bm25, "bm25_score"), (dense, "dense_score"), (fts5, "fts5_score")], + limit, + ) + assert TARGET_FILE in _pool_keys(pool), ( + "P2: BM25 rank-0 target missing even with tier anchors " + f"(pool={_pool_keys(pool)})" + ) + + +def test_anchor_preserves_rrf_order_and_dedupes(): + target, bm25, dense, fts5 = _p2_tiers() + rrf = reciprocal_rank_fusion_3way(bm25, dense, fts5, 20) + pool = _assemble_pool( + rrf, + [(bm25, "bm25_score"), (dense, "dense_score"), (fts5, "fts5_score")], + 10, + ) + base_keys = [rrf_key(c) for c in rrf[:10]] + assert [rrf_key(c) for c in pool[:10]] == base_keys + assert len({rrf_key(c) for c in pool}) == len(pool) + + +def test_anchor_pool_cap_respected(): + target, bm25, dense, fts5 = _p2_tiers() + rrf = reciprocal_rank_fusion_3way(bm25, dense, fts5, 20) + pool = _assemble_pool( + rrf, + [(bm25, "bm25_score"), (dense, "dense_score"), (fts5, "fts5_score")], + 10, + pool_cap=11, + ) + assert len(pool) <= 11 + + +def test_anchor_empty_pool_for_zero_limit(): + """Контракт hybrid_search_async: limit=0 -> пустой пул (якоря не воскрешают).""" + target, bm25, dense, fts5 = _p2_tiers() + rrf = reciprocal_rank_fusion_3way(bm25, dense, fts5, 20) + assert _assemble_pool(rrf, [(bm25, "bm25_score")], 0) == [] + + +def test_anchor_reconstructs_winner_outside_rrf_list(): + """Лидер тира вне RRF-списка: fused-запись строится из тир-элемента.""" + ghost = _chunk("src/ghost.py", 0) + rrf = [_chunk(f"src/other_{i}.py", 0) for i in range(5)] + for i, c in enumerate(rrf): + c.update( + {"bm25_score": 0.0, "dense_score": 0.0, "fts5_score": 0.0, + "graph_score": 0.0, "final_score": 0.05 - i * 0.001} + ) + pool = anchor_tier_winners( + rrf, [([ghost], "bm25_score")], 5, per_tier=1, pool_cap=30 + ) + keys = [rrf_key(c) for c in pool] + assert "src/ghost.py:0" in keys + entry = pool[keys.index("src/ghost.py:0")] + assert entry["bm25_score"] == entry["final_score"] > 0.0 + + +# ── Top-N recall floor (MAX_RERANKER_TOPN) ───────────────────────────── + + +def _llama_reranker_with(logits): + r = MultiProviderReranker() + r.ollama_available = False + r.lm_studio_available = False + r.llama_cpp_available = True + r._llama_cpp_rerank = AsyncMock(return_value=list(logits)) + return r + + +def _p3_chunks(): + """Пул из замера 2026-09-27: 10 чанков, цель P3 — логит -0.99 (0.271).""" + return [ + {"text": f"chunk {i}", "metadata": {"file": f"src/f{i}.py", "chunk_index": 0}} + for i in range(10) + ] + + +@pytest.mark.asyncio +async def test_topn_disabled_preserves_current_behavior(): + """Default MAX_RERANKER_TOPN=0: P3-цель (0.271<0.3) отсекается, как раньше.""" + reranker = _llama_reranker_with( + [1.65, 0.75, -0.20, -0.99, -2.95, -5.55, -6.88, -8.08, -8.25, -9.38] + ) + with patch( + "src.providers.reranker.multi_provider.get_config" + ) as cfg: + cfg.return_value.performance.reranker_topn_keep = 0 + result = await reranker.rerank("q", _p3_chunks(), top_n=10) + scores = [c["reranker_score"] for c in result] + assert all(s >= 0.3 for s in scores) + # проходят 1.65->0.839, 0.75->0.679, -0.20->0.450; цель P3 (-0.99->0.271) + # и хвост отсечены — поведение до adopt-ветки + assert len(result) == 3 + + +@pytest.mark.asyncio +async def test_topn_floor_returns_p3_target(): + """MAX_RERANKER_TOPN=4: union порога с top-4 возвращает цель P3 + (-0.99->0.271 — 4-я по скору, отсекалась абсолютным порогом).""" + reranker = _llama_reranker_with( + [1.65, 0.75, -0.20, -0.99, -2.95, -5.55, -6.88, -8.08, -8.25, -9.38] + ) + with patch( + "src.providers.reranker.multi_provider.get_config" + ) as cfg: + cfg.return_value.performance.reranker_topn_keep = 4 + result = await reranker.rerank("q", _p3_chunks(), top_n=10) + assert len(result) == 4 + assert [c["reranker_score"] for c in result] == sorted( + [c["reranker_score"] for c in result], reverse=True + ) + # 4-я — цель P3 (sigmoid(-0.99)≈0.271), отсекавшаяся порогом + assert result[3]["reranker_score"] == pytest.approx(0.271, rel=1e-3) + + +@pytest.mark.asyncio +async def test_topn_floor_never_exceeds_top_n(): + reranker = _llama_reranker_with([5.0, 4.0, 3.0]) + chunks = _p3_chunks()[:3] + with patch( + "src.providers.reranker.multi_provider.get_config" + ) as cfg: + cfg.return_value.performance.reranker_topn_keep = 100 + result = await reranker.rerank("q", chunks, top_n=2) + assert len(result) <= 2 + + +# ── Holdout-калибровка + анти-перебор ────────────────────────────────── + + +def test_calibration_selects_f1_maximum_on_holdout(): + scores = [0.9, 0.8, 0.5, 0.35, 0.2, 0.1] + labels = [True, True, False, True, False, False] + # t=0.35: P=3/4 R=1.0 F1=0.857 (максимум); t=0.5: F1=0.667; t=0.8: F1=0.8 + t = calibrate_threshold(scores, labels, source="holdout-2026-10-03") + assert t == pytest.approx(0.35) + + +def test_calibration_tie_prefers_higher_threshold(): + scores = [0.9, 0.8, 0.1] + labels = [True, False, False] + # t=0.9: P=1 R=1 F1=1.0; t<=0.8: P<=0.5 — максимум единственный + assert calibrate_threshold(scores, labels, source="holdout-A") == pytest.approx(0.9) + + +@pytest.mark.parametrize( + "source", ["eval", "Evaluation", "frozen", "frozen-eval", "token_reduction_v3", "v3"] +) +def test_calibration_refuses_eval_source(source): + """Анти-перебор как тест: калибровка на eval запрещена кодом, не словом.""" + with pytest.raises(ValueError, match="[Кк]алибровка"): + calibrate_threshold([0.9, 0.1], [True, False], source=source) + + +def test_calibration_accepts_holdout_source(): + t = calibrate_threshold([0.9, 0.1], [True, False], source="holdout-batch-1") + assert t == pytest.approx(0.9) + + +def test_calibration_rejects_empty_and_labelless(): + with pytest.raises(ValueError): + calibrate_threshold([], [], source="holdout-A") + with pytest.raises(ValueError): + calibrate_threshold([0.5], [False], source="holdout-A")