Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,11 @@ chunk_index -(20_000_000+line), graph_score=0.4 (ниже функций 1.0). E
**P-003 — правка не в той ветке.** Сигмоида попала в ONNX-блок вместо `llama_cpp` (oldString оказался уникальным, но не тем), ветка ONNX осиротела, `if scores:` выехал из `try`. Поймано `ast.parse` + просмотром diff. **Правило:** после правки в много-ветвистом коде — `git diff` целиком, а не только «применилось».

**P-004 — letter-vs-spirit instruction reading.** GPT-5.5 читает «never» буквально (jitter vs page_one_exit, Nishikanta 2026-09-27): perverse-compliant прочтение проходит фильтр, задуманный смысл — нет. Наш зеркальный кейс — qwen temporal-hint (E4b): БЕЗ хинта 'NOT FOUND AT HEAD' ни одна модель не робастна, т.е. правило работает только в дух-прочтении, буква его не несёт. **Guard:** тестировать граничные прочтения каждого правила (perverse-compliant кейс), а не только задуманное.

## [2026-09-28] P2 — gold не входит в пул: якоря идентификаторов
**Status:** ✅ Код+тесты+live (ветка `fix/p2-pool-contains-gold`, PR следует).
**Root Cause (числа, fresh-process):** срез `rrf_results[:limit]`, raw_limit=min(limit*2,30); цель P2: BM25#126, FTS#74, dense вне @200. Пул 5→1.7с/10→4.0с/20→7.7с/50→22.9с реранка — глубина 126 (≈50с) отвергнута. O1 не спасает: кандидат `reciprocal_rank_fusion` (df=4), символ цели `hybrid_search_async` (df=100) — буст уходил в scoring.py.
**Fix:** `_anchor_identifier_chunks_async` (single-token FTS, def-first, df-кап 120, docs/data ineligible, капы 2+3+MAX) → P2 rank 1 live (engine.py:18). Red-team: H2-коллизия (BM25 df=327 не якорится), doc-guard (docs/X.md, canary .json), caller-vs-def (live_search_audit vs engine).
**Harness-находка:** `asyncio.run()` на запрос роняет чётные запросы в reranker-passthrough (ms=0) — детерминировано по паритету; гейт идёт одним loop + degraded-флаг. Void-флаг KI этот класс не ловил.
**Guard:** `tests/test_p2_pool_anchors.py` (13) + `scripts/p2_holdout_gate.py` (GATE PASS 15/15); смежные 64 passed; ruff check чист. Формат-откат: `ruff format` давал +447 строк churn — откачен, diff +171/-0.
**P-005 — n=5 не значит «пул был 5».** Passthrough реранкера режет `[:top_n]`, пряча сработавшие якоря (6–8-е места) — трижды неверно выводил «якоря не сработали». **Правило:** судить pool-этап только трейсом пула до реранкера, не финальным n.
10 changes: 6 additions & 4 deletions KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

- **Правило:** все retriever-замеры и A/B-тесты — только в свежем процессе либо с явным сбросом реранкер-кэша (`Searcher._reranker_cache.clear()`). Ключ кэша включает текст запроса (engine.py:1646): повтор того же запроса в том же процессе отдаёт закэшированные скоры, а не измеряет код.
- **Эвристика void-замера:** wall <2s на `hybrid_search_async` при ожидании полного пайплайна (embed+BM25+FTS+rerank) = подозрение на cache hit; сверяться с `Searcher._last_rerank_timing` (пусто = реранкер не работал). Холодный FTS-билд (~2.5s) — обратная ловушка: ПЕРВЫЙ замер в свежем процессе молча теряет FTS-тир (2s `wait_for`), нужен discarded warm-up на чужом запросе.
- **Harness-ловушка (2026-09-28, Verified):** `asyncio.run()` на КАЖДЫЙ запрос роняет чётные запросы в reranker-passthrough (`reranker_ms=0`, `model='-'`, возврат пула без скоринга) — детерминировано по паритету позиции, свежая/здоровая инфра, флаги провайдера в норме. Серия обязана идти в ОДНОМ event loop; плюс явный degraded-флаг (`not reranker_ms` → замер недействителен). Void-флаг (`timing=={}`) этот класс НЕ ловит (timing={ms:0,...} ≠ {}).
- **Статус:** 🟡 Open (процедурное правило; guard-скрипт `scripts/o1_holdout_gate.py` — fresh-process + warm-up + void-флаг).

**21 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21). Closed entries moved to docs/archive/KNOWN_ISSUES_2026_09.md on 2026-09-27 (R1 size guard; second batch on merge experiment/4a-unit-of-return).
Expand Down Expand Up @@ -45,12 +46,13 @@
- **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 live 2026-09-28, fresh process + discarded warm-up):** срез пула — `rrf_results[:limit]` (`engine.py`, `raw_limit=min(limit*2,30)`), а цель многотермовым RRF зарыта глубоко: **BM25#126, FTS#74, dense вне @200** (индекс загрязнён собственными артефактами — дословный текст запроса лежит в `experiments/`). Ни лимит 50, ни O1 пул не чинят: (a) расширение пула до глубины 126 стоило бы ~126×0.4с реранка (~50с) — замерено и отвергнуто (пул 5→1.7с, 10→4.0с, 20→7.7с, 50→22.9с); (b) O1-кандидат — `reciprocal_rank_fusion` (df=4, строго редчайший), а символ цели — `hybrid_search_async` (df=100): exact-совпадения нет, буст уходит в `scoring.py`. Старый standalone-BM25-rank-0 — устаревший замер на незагрязнённом индексе. Per-tier top-1 anchoring (ветка `adopt/reranker-threshold-and-pool`) для текущего индекса refuted: топы тиров — мусор, цель на #74–126.
- **Fix (ветка `fix/p2-pool-contains-gold`):** `_anchor_identifier_chunks_async` (`engine.py`) — single-token FTS-добор exact-символов редких идентификаторов (df≤120: `hybrid_search_async` 100 ✓, `BM25` 327 ✗, `FTS5` 140 ✗) прямо в pre-rerank пул, def-first, docs/data ineligible, капы 2/токен + 3 всего + MAX_RERANKER_INPUT. Live: P2 rank **1** (чанк engine.py:18). Guard: `tests/test_p2_pool_anchors.py` (13) + `scripts/p2_holdout_gate.py` (GATE PASS 15/15, свежий процесс).
- **Побочно (Verified):** индекс содержит вывод собственных экспериментов — отдельная проблема индексации, не фильтра. Harness-урок: `asyncio.run()` на запрос роняет чётные запросы в reranker-passthrough (ms=0) — гейт идёт одним loop + явный degraded-флаг; void-флаг KI это не ловил (timing≠{}).
- **Остаточное:** df-кап 120 эвристичен и привязан к текущему индексу (100 vs 140 — тонкая граница); P2 rank=1 требует живого реранкера (без него цель в пуле, но не в топе). Валидация O1-гейта (`o1_holdout_gate.py`) тем же harness-багом занижена — не чинилось (чужой мёрджнутый файл).

## 2026-09-25 — Падения не фиксировались: zombie-job + глушение исключений + нет ledger (Fixed) / Open (server hard-death)

Expand Down
154 changes: 154 additions & 0 deletions scripts/p2_holdout_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""P2 pool-anchor holdout gate (live, fresh-process): P2 + H1-H12 + N/doc controls.

Usage:
python scripts/p2_holdout_gate.py [--project D:/Project/MSCodeBase]

Pattern follows scripts/o1_holdout_gate.py (fresh process, discarded warm-up,
void-flag), PLUS a blocking FTS prebuild: the cold FTS5 to_pandas build takes
~2.9s live, exceeding the 2s tier budget — without a prebuild the FIRST
measured queries race a half-built index and results flake run to run
(measured 2026-09-28: gate-exact repro rank 1, 3/3 with prebuild rank 1,
o1-gate run without prebuild rank None at wall=0.48s).

Case table is imported from o1_holdout_gate (O1-holdout-v1, single source of
truth — no duplicated query list to rot).

HARNESS LESSON (2026-09-28, verified): all queries run inside ONE event loop.
The o1-gate pattern (`asyncio.run()` per query) silently degrades every
even-positioned query to reranker passthrough (provider-None, reranker_ms=0):
asyncio primitives (locks/semaphores/client) bound to the first, now-closed
loop misbehave on alternating fresh loops. A per-query loop makes P2 (position
0) fail deterministically even with correct code. Reranker-cache discipline is
kept via explicit `searcher._reranker_cache.clear()` per case.

PASS RULES: P2 rank==1; expect_no_boost cases unboosted; doc chunks never
boosted; no reranker-cache void measurements; no degraded rows
(reranker_ms falsy = reranker did not run = invalid measurement, fails loudly
instead of judging rank on passthrough order).
"""

import argparse
import asyncio
import sys
import time
import traceback
from pathlib import Path

if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception: # noqa: BLE001 — encoding guard must never fail startup
pass

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

from scripts.o1_holdout_gate import ( # noqa: E402 — imported for the case table
HOLDOUT,
P2,
build_searcher,
rank_of,
)


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=str(ROOT))
args = ap.parse_args()
project = Path(args.project)
try:
import subprocess # noqa: PLC0415

rev = (
subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(ROOT),
capture_output=True,
timeout=10,
)
.stdout.decode()
.strip()
)
except Exception: # noqa: BLE001 — git metadata is best-effort diagnostics
rev = "unknown"
print(f"P2-anchor holdout gate | rev={rev} | project={project} | fresh process")
searcher = build_searcher(project)
rows = asyncio.run(_run_all(searcher))
print("---")
fails = []
p2 = rows[0]
if p2["rank"] != 1:
fails.append(f"P2 rank={p2['rank']} (expected 1)")
for row in rows:
case = next(c for c in [P2, *HOLDOUT] if c["id"] == row["id"])
if case.get("expect_no_boost") and row["n_boost"]:
fails.append(f"{row['id']} unexpectedly boosted")
if case.get("expect_no_doc_boost") and row["n_doc_boost"]:
fails.append(f"{row['id']} doc chunk boosted")
if row["void"]:
fails.append(f"{row['id']} reranker-cache void (wall<2s, no timing)")
if row["degraded"]:
fails.append(f"{row['id']} reranker degraded (reranker_ms=0, passthrough)")
if fails:
print("GATE FAIL: " + "; ".join(fails))
return 1
print("GATE PASS")
return 0


async def _run_all(searcher):
"""Whole sequence on ONE event loop (see HARNESS LESSON above)."""
# Blocking FTS prebuild (discarded): cold build ~2.9s > 2s tier budget.
t0 = time.perf_counter()
await asyncio.to_thread(searcher._build_fts5_index)
print(f"FTS prebuild (discarded): {time.perf_counter() - t0:.2f}s")
# Discarded warm-up on a disjoint query (cache key includes query text).
await searcher.hybrid_search_async("warmup cold start primer", limit=3)
rows = []
for case in [P2, *HOLDOUT]:
searcher._reranker_cache.clear()
t0 = time.perf_counter()
results = await searcher.hybrid_search_async(case["query"], limit=5)
wall = time.perf_counter() - t0
boosted = [r for r in results if r.get("identifier_boost")]
doc_boosted = [
r
for r in boosted
if str((r.get("metadata") or {}).get("file", ""))
.lower()
.endswith((".md", ".markdown", ".rst", ".txt", ".ipynb"))
]
rank = rank_of(results, case["target"]) if case.get("target") else None
timing = dict(getattr(searcher, "_last_rerank_timing", None) or {})
void = wall < 2.0 and timing == {}
degraded = not timing.get("reranker_ms")
rows.append(
{
"id": case["id"],
"rank": rank,
"target": case.get("target"),
"n_boost": len(boosted),
"n_doc_boost": len(doc_boosted),
"n": len(results),
"wall": round(wall, 2),
"void": void,
"degraded": degraded,
}
)
print(
f"{case['id']:>3} rank={rank} n={len(results)} target={case.get('target')} "
f"boost={len(boosted)} doc_boost={len(doc_boosted)} wall={wall:.2f}s "
f"rerank_ms={timing.get('reranker_ms')} model={timing.get('model')} "
f"flags={None if searcher._multi_reranker is None else (searcher._multi_reranker.ollama_available, searcher._multi_reranker.llama_cpp_available)}"
)
return rows


if __name__ == "__main__":
try:
sys.exit(main())
except Exception: # noqa: BLE001 — gate must report, never crash silently
traceback.print_exc()
sys.exit(1)
Loading
Loading