diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c7b8c7dd..f1b9c326 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -5,6 +5,12 @@ --- +## 2026-09-28 — Шкала реранкера + top-N floor (salvage из PR #52, tier-anchor пропущен) + +- **Спасено из конфликтного PR #52:** `_sigmoid`-нормализация логитов llama.cpp → [0,1] (без неё MIN_RERANK_SCORE=0.3 отсекал 70–97% выдачи), top-N recall floor `reranker_topn_keep` (default 0 = выключено), holdout-калибровка порога с запретом eval-источников кодом. Guard: 4 sigmoid-теста + 13 тестов top-N/калибровки. +- **Пропущено осознанно:** `anchor_tier_winners` — P2 закрыт влитым #54 (`_anchor_identifier_chunks_async`) в той же точке пула; второй P2-механизм без собственного A/B — нарушение. Ветка #52 сохранена как референс. +- **Статус:** ✅ Salvaged (PR #63). + ## 2026-09-28 — Pre-commit hook fail-open при потере маркеров (Fixed) - **Локация:** `.githooks/pre-commit:31-53` (`find_project_root` + `run_script`). @@ -249,3 +255,180 @@ **Контекст:** исследование поиска/RAG — что именно измерять, прежде чем утверждать результат. **Решение (приоритет):** KI-R1 (пер... - **Статус:** автоматически синхронизировано + +## 2026-09-20 — Exp E13: текстовый RAG (doc-chunks) vs кодовый baseline (E10/E11) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Measured (refuted hypothesis) +**Hypothesis:** doc-chunks (README + docs/en/ + docstrings) retrieve as well as code-chunks via search_with_mode quality. +**Method:** 16 EN doc-queries, live ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-11 — Burst-rename: fail-closed VOR отзывает 100% при ONE rename-sweep (ответ Statewave на dev.to) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Closed (эксперименты, ответ опубликован) +**Root Cause:** VOR (ADR-0003) проверяет ПУТЬ-якоря против текущего HEAD. Rename/move = старый путь отсутствует = SILENT_ABSENCE = отзыв, хотя файл... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H1: фоновый VOR-проход (IdleScheduler) — память перепроверяется без вызова агента + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (6 новых тестов + 1674 полный pytest green; ветка chore/experiments-es1-es2-0909) +**Root Cause:** VOR вызывался ровно из 1 места (intel_get_project_memory, layer.py:1097); idle-задач... +- **Статус:** автоматически синхронизировано + + +## 2026-09-09 — H2: .h заголовки C включены в AST-индексацию (PARSE_EXTENSIONS + C-парсер) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (commit 0301fa93; KNOWN_ISSUES 2026-09-09 19:35 закрыт) +**Root Cause:** ".h" был в INDEX_EXTENSIONS (вектор-чанкинг шёл), но НЕ в PARSE_EXTENSIONS → CodeParser.parse_file возвращал [... +- **Статус:** автоматически синхронизировано + + +## 2026-09-07 — Cypher-движок: анонимные узлы/рёбра ломали MATCH; ActionReceipt не писался из write-пути + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (оба блока закрыты, тесты зелёные) +**Root Cause:** (1) Cypher: `from_node_alias` дефолтил в `n1`, а генератор создавал `n{path_idx*2}` для анонимного узла → `no such column: n0.id`; ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-02 20:51 — drift_gate заблокировал коммит: контроль остановил самого автора + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ? Fixed (коммит A 08281f37 приземлился; B — отдельная незакоммиченная квитанция) +**Root Cause:** предсуществующий BROKEN drift_gate: GitBash bin/ (C:\Program Files\Git\bin) НЕ в PATH проце... +- **Статус:** автоматически синхронизировано + + +## 2026-09-02 21:40 — COMMIT B (head-freshness) приземлился: cb88c961; + cp1251 encoding-инцидент + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (коммит B cb88c961; все 5 pre-commit hook'ов OK; рабочее дерево чистое) +**Root Cause 1 (B):** после A (fail-closed symbol, никогда REFUTED) свежесть индекса не проверялась — отсутс... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (both fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit 32f11662; 5 pre-commit hooks OK; full pytest 1587 passed, 2 pre-existing unrelated env_extractor failures) +**Root Cause 1 (ETA "~8s"):** `_enrich_job_response` had a dead h... +- **Статус:** автоматически синхронизировано + + +## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (architecture_linter) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit f210ed7c; CI all-jobs green on ubuntu+windows) +**Root Cause:** My ETA refactor added `tools_reg → layer` import for `_embed_progress_from_log`, closing an existing `layer →... +- **Статус:** автоматически синхронизировано + + +## 2026-09-04 11:15 — CI RED: ruff lint errors caught only after push (3 commits) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (commit 986c9be7) +**Root Cause:** Pre-commit hook did not run ruff. CI (`ruff check src/ tests/` in ci.yml) caught F401/W292 only after push, forcing fix-commits. Repeated 3 times ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-05 12:30 — FIX: stale_detector + predict_change стабильно таймаутили через MCP (-32001): блокирующий sync-код в async-контексте + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (code only, не запушено) — src/mcp/tools/doc_tools.py + predict_tools.py +**Root Cause:** `error_boundary` применяет `asyncio.wait_for(timeout_ms)` вокруг `execute`, но внутри `exec... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:00 — Починка lock_guard: таймаут 60s ломал весь .locks-протокол + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause:** `scripts/lock_guard.py` `_run` default timeout=60s — любой `git commit` прогоняет pre-commit hook (verify_diary → полный pytest 5-10 мин на Windows), поэтому acqu... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 21:30 — sync-subprocess в async-MCP (context_tool, system_tools) — fixed + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed (code only) / **Root Cause:** системная проверка после фикса stale/predict: нашлись ещё sync `subprocess.run` внутри async `execute`. `GetContextTool._section_git` (git log через s... +- **Статус:** автоматически синхронизировано + + +## 2026-09-06 22:00 — P-001 рецидив: cmd-окна при запуске/открытии проекта (powershell/nvidia-smi без CREATE_NO_WINDOW) — FIXED + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause:** повтор инцидента 2026-08-14 (P-001, «чёрные окна CMD»). Фикс 2026-08-14 добавил CREATE_NO_WINDOW для git/netstat/wmic/taskkill в runtime, но ПОЗВОЛИЛ дыру: `resou... +- **Статус:** автоматически синхронизировано + + +## 2026-09-08 — B3: grammar-карты parser.py (imports/calls/assigns/conditions) внесены + живые фиксы + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause и итог:** внесены из study 05 карты CALL_NODES/IMPORT_NODE_MAP/ASSIGNMENT_NODE_MAP/CONDITIONAL_NODE_MAP (пер-язычные) в `src/core/indexing/parser.py`. Живые tree-sit... +- **Статус:** автоматически синхронизировано + + +## 2026-09-08 12:35 — B4: import-экстракция через language_imports (деривация карт + флаг-гейт) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause:** два источника node-типов импортов (parser.IMPORT_NODE_MAP и литерал LANGUAGE_IMPORT_NODES) расходились (kt/dart/php); ungated fallback-2 в мосте. +**Fix:** LANGUAG... +- **Статус:** автоматически синхронизировано + + +## 2026-09-08 19:40 — collect() в Cypher: json_group_array + типизированный декод (fixed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed. / **Root Cause:** KNOWN_ISSUES 2026-09-07 ⏳ — `_translate_return_expr` заявлял `collect` как Supported, но SQLite не имеет функции COLLECT («no such function»); ни одного теста на... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — H1 idle-VOR + system_alerts (цепь «файл изменён → STALE → VOR → alert агента» собрана) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed / **Root Cause (Exhibit #23, 2026-09-09):** компоненты цепи существовали по отдельности, но VOR вызывался ровно из 1 места (layer.py:intel_get_project_memory), mark_stale("memory")... +- **Статус:** автоматически синхронизировано + + +## 2026-09-11 — VOR read-path fix (PR #34) + «8-минутный коммит» = НЕ баг (решение владельца) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ PR #34 создан, hooks green; скорость тестов — осознанное решение, код НЕ менялся. +**Root Cause:** (1) read-path VOR ре-сканировал prose тела ADR через `_PATH_RE`, хотя явные `data.anchor... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — Exp 1 (Catch-up Rate) + Exp 3 (HEAD polling): VOR масштабирование и внешний дрифт + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fix (замеры, кода не менялось). **Root Cause (KNOW ISSUES «Lazy-only верификация»):** вопрос, успевает ли VOR проверить ACTIVE-узлы в рамках budget_ms=50 (read-path) / 250 (background id... +- **Статус:** автоматически синхронизировано + + +## 2026-09-10 — Exp 2 (Agent Behavior) + Exp 4 (Fail-Closed Freshness Gate) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** ✅ Fixed. **Root Cause (Exhibit #23, 2026-09-09):** inform-the-agent approach insufficient — agent can ignore STALE alerts; PlanFence 30/30 failures confirms action-validation unreliable; s... +- **Статус:** автоматически синхронизировано + + +## 2026-09-11 — H3 TTL-гниение: last_checked для всех проверенных + label stale_ttl (doc 10 closed) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (9 новых тестов + 1725 полный pytest green; doc 10-continuous-verification H1+H2+H3 done) +**Root Cause:** INCONCLUSIVE/непроверенные узлы «висят вечно» без следа проверки: live-срез ... +- **Статус:** автоматически синхронизировано + + +## 2026-09-13 — H4: agent-memory lifecycle в масштабе dev.to KB — бутылочное горлышко = сетевой capture, не граф + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (эксперимент подтверждён; сопровождение задачи closed) +**Root Cause:** при росте базы 3,989 → 13,519 статей (3.4x), refresh own занял 10м38с на 13.5k статей/82.5k комментов (134 сете... +- **Статус:** автоматически синхронизировано + 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/providers/reranker/multi_provider.py b/src/providers/reranker/multi_provider.py index a9571182..5058d37a 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 @@ -88,6 +89,14 @@ def _truncate_rerank_pair( 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 +640,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 +678,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..8fd921e3 --- /dev/null +++ b/tests/test_reranker_pool_and_threshold.py @@ -0,0 +1,129 @@ +"""Reranker top-N recall floor + holdout threshold calibration. + +Salvaged from PR #52 (non-conflicting part): tier-winner anchors were +skipped — P2 is covered by merged #54 (_anchor_identifier_chunks_async) +at the same pool site; a second P2 mechanism needs its own A/B. + +- Top-N floor (MAX_RERANKER_TOPN, default 0 = off): union of threshold + passers with top-N by score. P3 precedent: target 0.271<0.3 cut by the + absolute threshold returns as 4th by score with top_n_keep>=3. +- Holdout calibration: F1-max threshold selection; eval sources rejected + by code (ValueError), not by comment. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from src.providers.reranker.multi_provider import MultiProviderReranker +from src.providers.reranker.threshold_calibration import calibrate_threshold + +# ── 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")