diff --git a/.env.example b/.env.example index 5333e586..f0d06793 100644 --- a/.env.example +++ b/.env.example @@ -30,9 +30,22 @@ # RU: Размер батча (макс токенов за проход) # LLAMA_BATCH_SIZE=512 # -# EN: Physical batch size for CPU (lower = less RAM, higher = faster) -# RU: Физический размер батча для CPU -# LLAMA_UBATCH_SIZE=128 +# EN: Physical batch size for CPU (lower = less RAM, higher = faster). +# AUTO by role since 2026-09-20: embed → ceil(480/128)*128=512, +# rerank → ceil(1000/128)*128=1024 (see resolve_ubatch in llama_install.py). +# Set only to force-override both roles (LLAMA_EMBED_MAX_TOKENS / +# LLAMA_RERANK_MAX_TOKENS tune the auto limits per role). +# RU: Физический размер батча для CPU. Автоподбор по роли с 2026-09-20: +# embed → 512, rerank → 1024. Задавать только для жёсткого override. +# LLAMA_UBATCH_SIZE=512 +# +# EN: Max input tokens for embed role (query/сhunk). Client truncates to this. +# RU: Максимум токенов входа для embed-роли (клиент режет текст до этого). +# LLAMA_EMBED_MAX_TOKENS=480 +# +# EN: Max input tokens for rerank role (query+passage pair). Client trims to this. +# RU: Максимум токенов входа для rerank-роли (пара query+passage, клиент тримит). +# LLAMA_RERANK_MAX_TOKENS=1000 # ─── ONNX SERVER (fallback если нет GGUF) ──────────────────── # EN: Host and port for the ONNX inference server diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index b6c5fe68..e9be40a1 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1,5 +1,7 @@ ## Key Historical Decisions +- **Умный ubatch по ролям (2026-09-20):** вместо одного `LLAMA_UBATCH_SIZE=2048` для обеих ролей — `resolve_ubatch(role)`: embed → ceil(480/128)*128=**512**, rerank → ceil(1000/128)*128=**1024**. Основание: A/B на реальных 2227 чанках — ubatch=512 → 596 MB / 18.9 ch/s vs 2048 → 1686 MB / 16.1 ch/s. Раньше llama.cpp требовал «entire input ≤ ubatch» (#25293), для b9940 целый батч делится по слотам, лимит остался на ОДИН текст/пару → клиентский трим: embed обрезает до `LLAMA_EMBED_MAX_TOKENS=480` (единый источник с remote_embedder), rerank — `_truncate_rerank_pair` до `LLAMA_RERANK_MAX_TOKENS=1000`. `LLAMA_UBATCH_SIZE` env — жёсткий override. Файлы: `llama_install.py` (resolve_ubatch), `llama_runner.py` (_ubatch_arg, 3 места запуска), `multi_provider.py` (трим пары), `remote_embedder.py` (импорт лимита), тест `test_ubatch_roles.py`. Live-check: реальный llama-server принял ubatch=512, embeddings 200 dim=384. + - **PyPI-packaging + user-data isolation (2026-08-28):** wheel теперь содержит `tools.stale_detector`, `adapters`, `locales` (норм. данные в site-packages); бинарники/модели в pip-режиме — `get_data_root()` (`%LOCALAPPDATA%\mscodebase`), гейт-маркер `__mscodebase_ext__.marker` отличает расширение от установленного пакета; CLI `--project-path/--project-dir` → env `MSCODEBASE_PROJECT_PATH` (приоритет НАД CWD, trust_self_index); `PROJECT_PATH` остался после CWD (multi-window сохранён). Live-Smoke из чистого venv: tools-ok, `en (78 ключей)`, корень резолвится. Файлы: `pyproject.toml`, `project_resolution.py`, `main.py`, `llama_install.py`. INC-C4CD. - **Server freeze during full reindex (2026-08-25):** root cause — `begin_write()` держит `_write_lock` (RLock) весь reindex (~7.5 мин embedding), а `IndexStatusReporter.get_status()` синхронно на event-loop-потоке ждал тот же lock (intel_get_runtime_status/require_ready_project/ProjectContext) → заморозка ВСЕХ MCP-вызовов. Фикс: reindex fast-fail в get_status (кэш + status="reindexing") + asyncio.to_thread в 3 loop-точках + guard в _get_stale_warning. @@ -23,6 +25,19 @@ - **LIVE-SMOKE (2026-08-13):** scripts/smoke_e2e.py — реальные сервисы без моков (embed llama.cpp / rerank BGE-M3 / векторный поиск по реальному LanceDB); §7 п.10b: для runtime-изменений ✅ = live-check, не только pytest (инцидент: 7 тестов зелёные по неверной причине) - **Чёрные окна CMD (2026-08-14):** MCP запускался как `venv\Scripts\python.exe` (console-подсистема) → каждое окно Zed = своё чёрное окно; фикс: `pythonw.exe` в extension.toml + CREATE_NO_WINDOW во ВСЕХ runtime subprocess (13 файлов) — с pythonw (нет консоли) незакрытые git/wmic/netstat мигали бы окнами - **FA=0.00 ≠ качество guardrail (2026-08-15):** Exp 1-L Day 3 — qwen3.6/3.7 (zero-shot VOR) достигают FA=0.00 ценой recall(real)=0.08–0.20 (code_first: 2/25 правды принято, 7/25 активно отвергнуто) — fail-closed политика, а не «фильтрация лжи»; выбор LLM для verify-on-read = выбор политики (fail-closed qwen vs max-coverage glm), recall(real) обязан быть в метриках. CoT (V3/Part 5) НЕ окупается: только qwen3.6 recall 0.08→0.20 при цене ×30–65 + +## [2026-09-20] — Exp E13: текстовый RAG (doc-chunks) vs кодовый baseline (E10/E11) + +**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 index (18665 rows/1197 files), Hit@1/Hit@5/MRR via direct lookup (no LLM judges). +**Result:** hit@1=12.5% (2/16), hit@5=12.5% (2/16), MRR=0.125. Кодовый baseline: hit@1=0%/20%, hit@5=50%/40%, MRR=0.200. +**Root Cause:** (1) embedder плотнее эмбедлит code-сигнатуры, doc-чанки размыты; (2) индекс bias на код (чанков >> doc); (3) queries без intent_hint="docs" маршрутизируются в code-путь. +**Fix (не код):** добавить intent_detection для doc-queries + поднять weight doc-bucket в soft-weighting. +**Guard:** перед production doc-RAG — intent routing + doc-bucket boost. Без этого текстовый RAG ненадёжен. +**verified_from_clean_state:** ⚠️ не проверено — скрипт использует live-индекс MCP (не чистый клон) + +## [2026-09-18] — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) - **Evidence Ladder (2026-08-15, Exp 2-E E1-E3):** форма evidence — переменная; file_content = лучший recall (qwen 0.92), graph = закрытие present-trap ТОЛЬКО у evidence-честных моделей (qwen3.7 FA trap 1→0 ценой recall 0.92→0.76); fail-open (glm-4.7: FA trap 6/6) не лечится ни одной формой — свойство модели. VOR-конвейер: фрагмент файла для recall + графовая проверка субъекта отдельным сигналом; glm-семейство исключить - **VOR MATCHED/DELIVERED (2026-08-16):** per-node накопительные счётчики matched/delivered в verify_cache.json (ключ node_id — переживают HEAD); starved = виден ≥2 циклов, ни разу не проверен — отличает голодание по бюджету от бага якорей (раунд 2 Тома; «пол Тома» = раунд 1) - **CONTRADICTION RESOLVED [2026-08-28]:** Агент перезаписал `.agent_task_state.md` чужой задачи (`Port env-access extractor`) при запуске Red Team — нарушение §0.1 (Task State Persistence) + §4.9 (Contradiction Hunting). Исправлено: восстановлен оригинальный `.agent_task_state.md`, Red Team-задача перенесена в `.red_team_state.md`. Root cause: отсутствие проверки содержимого файла перед `write_file`. Guard: перед любым `write_file` на `.agent_task_state.md` — читать первую строку и сверять с ожидаемым заголовком задачи. @@ -416,3 +431,44 @@ VERDICT H3: CONFIRMED **verified_from_clean_state:** ⚠️ не прогонялся (ветка → PR; полный pytest 1753 passed — verify_diary-gate при коммите). **Next:** AST/Graph-hybrid re-ranking (graph_query scope_id + text-match) вместо эмбеддинговых твиков; live-check полного реиндекса на прод-БД после миграции (запрос владельцу). **Связки:** KNOWN_ISSUES 2026-09-19 (2 записи), EXPERIMENTS_LOG Exp E10, tests/test_lancedb_recreate.py, scripts/e2e_quality_search.py, experiments/search_quality/E10_full_text_embed.py, exp-43 portfolio lab. +## [2026-09-19] Exp E11 (Graph-hybrid re-ranking): CONFIRMED-сигнал — подъём graph-хитов +40% hit@5 + +**Status:** Research closed (E11 CONFIRMED, прод-интеграция — вопрос владельцу) +**Exp E11 (read-only зонд + руки, ~40 мин):** baseline quality (live embed/rerank, та же сессия) hit@1=2/10 hit@5=2/10 MRR=0.200. Руки поверх тех же top-k: A-prepend (graph-файлы в топ) hit@5=4/10 MRR=0.253; B-RRF 3/10 MRR=0.225; C-graph 4/10 MRR=0.253 (2 стабильных прогона). Спаслись #7 project_indexer_registry и #8 indexing_tools (оба target подтверждены в graph_files, шум остаётся). graph-lookup 6ms vs baseline 1978ms. +**Root Cause (почему растёт):** embedding/BM25 теряют детерминированные кодовые идентификаторы (file_mtime_ns, notify, bm25); SymbolIndex знает их точно, но engine._graph_stage триггерится только на чистый identifier-токен — NL-запросы его не проходят. +**RED TEAM:** (1) шум в graph-файлах (multi_rag_ablation и т.п.) — MRR растёт слабо, hit@1 ровно, потому нужна фильтрация (def-first / min use-count) перед продом; (2) N=10 — уровень шума: сигнал, не доказательство — расширенная панель 30+ обязательна; (3) переключение ниши: прод-стадия за флагом, поведение клиента = HEAD при off. +**Паттерн:** P-recurring «симметричный поисковый путь» — search_symbols уже есть, но недоиспользуется: одна точка инжекции (engine.hybrid_search_async post-fusion) может дать +2 хита за 6ms. +**verified_from_clean_state:** не требуется (эксперимент read-only, src/ не тронут; portfolio 26/26 passed). +**Next:** кандидат в прод — стадия «извлечь символы из NL → search_symbols → top-k подъём (A-prepend)» за флагом; решение владельца + расширенная панель перед фиксацией кода. +**Связки:** EXPERIMENTS_LOG Exp E11, experiments/search_quality/E11_graph_hybrid_probe.py + E11_graph_hybrid_arms.py, exp-44 portfolio lab; E10 REFUTED (2026-09-19) — точка отсчёта. + +--- + +## [2026-09-20] Resume-��������������� ������ � IndexProjectRunner.run (330K-�������� ������) + +**Status:** Fixed (6 ����� resume-������ + ������ pytest 1774 passed; �� ����������� � ��� �������) +**Root Cause:** run() ����� ��� ���������� � _all_embeddings (330K x 1024d x 4B ? 1.3GB) � ����� ����� bulk_write � Phase 3. ���� �� ������� ������� ����� ��� ������: ������� ����� > known_hashes ���� > ������ ����-embed ��� �����������. index_guard.py:123: ������ ������� > needs_reindex > ������ �����. +**Fix:** (1) _verify_and_repair_table_integrity() � ������ run() �� known_hashes (INC-6C62 integrity �� skip-�������; ���� ��� �� ������); (2) Phase 2+3 �����: ���� ���������� (��� ����� �������������) > prepare_records > _pending_prepared > bulk_write �������� �� WRITE_FLUSH_FILES=32; ����� ������ _all_embeddings/_parsed_list ���������� + gc.collect (RAM-����); (3) ������� vecs �������� (������������� _flat_chunks) � ������ ������ ������ ���������; (4) _flat_indices_by_file � O(1) ���� vecs, �� O(N?); (5) fallback per-file write ��� db_writer=None ��������. +**Guard:** 6 ����� ������ (tests/test_index_resume_incremental.py): ������� �������, ����������������� (bulk_calls>=4), ���� ��������� ���������� (>=32), resume ������� ���������� (<=68), no double-write, RAM-������������ ����� spy �� ������. Red Team: 5 ���� ��������� (concurrency/idempotent, ������� ���� / BATCH_SIZE, TOCTOU crash ����� bulk_write, ��������� run, integrity-recreate ����� skip � ������� ������ check). +**verified_from_clean_state:** ?? �� ���������� (uncommitted; ������ pytest 1774 passed/5 skipped). Live-smoke �� ���������� (embedder fallback � CI-���������) � resume-��������� ��������� �� �������� ��������, �������� ������ ?1000 ������ �� ����-�� � ��������� ���. +**Next:** ������ (2 ������ pending: ubatch-resolve + resume) �� ������� ���������; live-���ume �� ������� �������; KNOWN_ISSUES: ��� �� ������ flush (10K ������ = 10K ���-����� �� 330K ������) � rate-limit ��� debug. + + +## [2026-09-20] — Поисковое качество / E13: исследовательские задачи (6 пунктов) + +**Status:** Plan (задачи занесены в ISSUE.md KI-R1..R6, код не тронут) +**Контекст:** исследование поиска/RAG — что именно измерять, прежде чем утверждать результат. +**Решение (приоритет):** KI-R1 (перезапуск exp-5 + gold_chunk_id) → KI-R2 (два прогона E13) → остальное. +**Пункты:** +1. KI-R1 (P1): fix measurement — exp-5 restart после KI-101, gold_chunk_id Recall@5, наборы 10→16+. +2. KI-R2 (P1): E13 до нормы — intent_hint=docs + doc-only индекс, два дешёвых прогона. +3. KI-R3 (P2): интерфейс «найдено N / использовано M» + eligible_seen. +4. KI-R4 (P2): регрессионный тест дедупликации (MMR vs финальный sort, SCT/SCT Inst). +5. KI-R5 (P2): гибрид — фильтр лексической ветки вместо RRF (threshold по чанкам/терминам). +6. KI-R6 (P2): housekeeping — 7 копий exp-29 на Lab, дубли в архиве дневника, README числа. +7. KI-R7 (P2): авто-замер — gold_chunk_id из индекса → Recall@5 в get_health_report. +8. KI-R8 (P2): дедупликация по хешу, оба пути в цитате. +9. KI-R9 (P2): не подбирать один режим — vector+BM25 снизил recall у нас, hybrid проиграл vector у автора. +10. KI-R10 (P2): слабый запрос → не молчать, fallback grep (exp-26). +11. KI-R11 (P2, первым): «найдено N / использовано M» + «проиндексировано ли». +**verified_from_clean_state:** ⚠️ не проверено — записи в ISSUE.md/AGENT_DIARY.md, код не менялся. diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 013c3226..4affa4fe 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1,4 +1,22 @@ # EXPERIMENTS_LOG.md — Audit Verification (2026-07-22) + +## [2026-09-20] — E13 / поискочное качество: 6 исследовательских задач (план) + +**Статус:** Plan (код не тронут; задачи в ISSUE.md KI-R1..R6) +**Контекст:** E13 (doc-chunks vs code-chunks) замерен — hit@1=12.5%, hit@5=12.5% против code baseline 20/50%. Проблема: измерение неполное, выводы преждевременные. +**Задачи (по порядку):** +- KI-R1 (P1): перезапуск exp-5 после KI-101 (cache-hit пропускал dense-уровень) + gold_chunk_id Recall@5 + наборы 10→16+. +- KI-R2 (P1): два прогона E13 — intent_hint="docs" (маршрутизация) + doc-only индекс (перекос корпуса). +- KI-R3 (P2): «найдено N / использовано M» + eligible_seen в интерфейсе. +- KI-R4 (P2): регрессионный тест дедупликации (MMR vs sort, SCT Inst). +- KI-R5 (P2): фильтр лексической ветки вместо RRF (threshold по чанкам/терминам). +- KI-R6 (P2): housekeeping — 7×exp-29 на Lab, дубли архива дневника, README числа. +- KI-R7 (P2): авто-замер — gold_chunk_id из индекса → Recall@5 в get_health_report. +- KI-R8 (P2): дедупликация по хешу (vendored/переводы/CHANGELOG), оба пути в цитате. +- KI-R9 (P2): не подбирать один режим — vector+BM25 снизил recall у нас, hybrid проиграл vector у автора. +- KI-R10 (P2): слабый запрос → не молчать, fallback grep (exp-26). +- KI-R11 (P2, первым): «найдено N / использовано M» + «проиндексировано ли». +**Вердикт:** без R1+R2 публичные утверждения некорректны. R1+R2 дешёвые — делать первыми. ## [2026-09-18] — E7: lazy stat-sweep vs sha256-sweep (FreshnessChecker) для «всегда актуального» индекса **Гипотеза:** stat()-сверка (mtime+size) по корпусу проекта на порядки дешевле существующего FreshnessChecker (SHA256 каждого файла), поэтому её можно выполнять при каждом поиске без заметной стоимости и закрыть KI-109 (новый файл невидим индексу без notify_change). @@ -2190,4 +2208,74 @@ None из трёх «выключателей» (full-text-эмбеддинг / 3. Отрицательный результат фиксирует плато «pure-vector»: поиск не находит то, что не текстуально в индексе (ср. Exp-29 ceiling search-only ~0.23) — следующий ход AST/Graph-hybrid re-ranking (graph_query scope_id + поверхностный text-match), не эмбеддинговые твики. 4. Диагностический harness `scripts/e2e_quality_search.py` (E5-style, 10 задач, 2 режима) оставлен в репо как reusable instrument. -**Artifacts:** experiments/search_quality/E10_full_text_embed.py, scripts/e2e_quality_search.py; EXP_LOG Exp E10; exp-43 portfolio lab. \ No newline at end of file +**Artifacts:** experiments/search_quality/E10_full_text_embed.py, scripts/e2e_quality_search.py; EXP_LOG Exp E10; exp-43 portfolio lab. +## [2026-09-19] Exp E11 (Search Quality): AST/Graph-hybrid re-ranking — CONFIRMED (сигнал), подъём graph-хитов спасает 2/10 кейсов + +**Контекст:** E10 REFUTED зафиксировал плато «pure-vector» (baseline quality hit@5 ≈ 20-30%). Прод-поиск имеет два пути: VectorSearch (embedding/BM25) и SymbolIndex (PropertyGraph search_symbols), но engine._graph_stage триггерится ТОЛЬКО на чистый identifier-токен (`_IDENTIFIER_QUERY_RE`), NL-запросы его не проходят. Гипотеза: извлечь из NL-запроса символьные подстроки → search_symbols → поднять graph-хиты в топ fused с baseline. + +**Команда:** `python experiments/search_quality/E11_graph_hybrid_arms.py` (read-only, embed/rerank live 8080/8081, та же сессия/БД/индекс = сравнение «было/стало» корректно при N=10 шуме). + +**Результат (2 независимых прогона, стабилен):** +| arm | hit@1 | hit@5 | MRR | +|---|---|---|---| +| baseline (quality) | 2/10 (20%) | 2/10 (20%) | 0.200 | +| A-prepend (graph в топ) | 2/10 (20%) | **4/10 (40%)** | 0.253 | +| B-RRF (scoring.reciprocal_rank_fusion) | 2/10 (20%) | 3/10 (30%) | 0.225 | +| C-graph (только graph-хиты) | 2/10 (20%) | 4/10 (40%) | 0.253 | + +Спасены: #7 project_indexer_registry (graph_files содержит target), #8 indexing_tools (target 5-м в graph_files). Остальные 8 кейсов: graph находит, но target не в топ-15 символьного поиска (сниппеты/миграция/идентификаторы недостаточно специфичны) либо target уже в baseline. + +**Вердикт: CONFIRMED (сигнал, не production-доказательство).** Цена: +6ms/запрос (graph-lookup) против 1978ms baseline — подъём graph-хитов ВЫГОДЕН: детерминированные символы (file_mtime_ns, notify, bm25) недоступны embedding, но их знает SymbolIndex. N=10 — шум, расширять панель до 30+ задач перед прод-интеграцией. + +**Вывод (следующий ход):** A-prepend (или B-RRF с весами graph) — кандидат в прод: в engine.hybrid_search_async добавить стадию «извлечь символы из NL → search_symbols → top-k подъём» (по желанию за флагом, см. подход YellowDuck RAG-codebase где graph rerank условен и требует замера на конкретном коде). + +**Artifacts:** experiments/search_quality/E11_graph_hybrid_probe.py (зонд), experiments/search_quality/E11_graph_hybrid_arms.py (руки); EXP_LOG Exp E11; portfolio exp-44 (после замеров). + +--- + +## [2026-09-20] — Exp E12: real-path embed throughput (Stanford 8h: 11-18 ch/s vs «156 ch/s» T3) + +**Гипотеза:** «156 ch/s» (T3, batch=32) — артефакт синтетического корпуса (~10-токенные random_code тексты). Реальный чанк ~203 токена → истинный потолок embedder существенно ниже, и закрывает его НЕ цикл index_project_runner, а сам llama-server. + +**Команда:** `python experiments/embed_real_path_vs_raw/exp_real_path.py` + `exp_feed_queue.py` + `exp_ubatch_threshold.py` + `exp_gc_cost.py` (venv расширения, реальный корпус из LanceDB MSCodeBase, p50=712 chars/max=1009, live llama 8080). + +**Результат:** +| Замер | Значение | +|---|---| +| truncation `/tokenize` (635 texts >256 chars) | 0.6s (1ms/текст) — **опровергнута** | +| ARM A raw POST 640 чу/35.8s = 18 ch/s (p50 1840ms) | реальный путь | +| ARM B trunc+embed 640 чу/34.6s = 18 ch/s (p50 1755ms) | real; zero_vec=0/640 | +| tokens: 129839/640, avg=203/чанк, p90=266, max=340 | корпус | +| tok/s raw 3652; E10 sustained 2414 (11.9 ch/s) | полный цикл -34% | +| ceiling-пересчёт 2000/3000/4000 tok/s | 10/15/20 ch/s | +| **gc.collect() per batch** | **1ms/пачку, 10s на весь Stanford — опровергнута** | +| ubatch=2048 порог (N=1..32) | НЕТ скачка, время линейно ~26ms/текст | +| подача по ТОКЕНАМ 512→8192, concurrency 1 vs 3 | tok/s плато 3032-3444; **concurrency=3 ВСЕГДА хуже** | +| 40/100/203 tok/чанк → ch/s | 36-67 / 28-33 / 17-19 (обратно пропорционально длине) | + +**Вердикт: CONFIRMED.** Потолок embed на CPU (e5-small Q8, threads=10, 6C/12T Ryzen 5600H) = **~3.4-3.7k tok/s** — физика модели, НЕ зависит от размера пачки, token-budget, параллельности или truncation. «156 ch/s» T3 = синтетика (≈1560 tok/s на 10-токенных текстах — тоже в пределах физики). 335k чанков × 203 tok = 68M токенов → ~5.7ч чистого embed + parse/write = 8ч reindex РЕАЛЕН, это не баг. + +**RAM (второй корень):** активный индекс держит ВСЁ в RAM: `_flat_chunks` (335k кортежей с текстами) + `_all_embeddings` (335k×384×float64 = 0.96GB, с PyObject-overhead до 3.35GB) + `_parsed_list`. Синтетика 194MB → реальный индекс +1.4GB → swap-риск на 15.4GB машине (едва 4.7GB свободно) + конкуренция с reranker'ом (1.08GB Bge-M3 8081 + 238MB e5-small 8080). + +**Урок:** embedding throughput мерить ТОЛЬКО на реальном корпусе (длина чанков решает), а не на random_code. «100 ch/s sustained» (2026-07-17) и «156 ch/s» (T3) — исторические артефакты коротких синтетик. + +**Artifacts:** experiments/embed_real_path_vs_raw/{exp_real_path.py, token_math.py, exp_ubatch_threshold.py, exp_gc_cost.py, exp_feed_queue.py}. + +## [2026-09-20] — Exp E13: текстовый RAG (doc-chunks) vs кодовый baseline (E10/E11) + +**Гипотеза:** текстовые чанки (README + docs/en/ + docstrings) индексируются и извлекаются через search_with_mode quality не хуже кодовых чанков. + +**Команда:** `python scripts/eval_text_chunks.py` — 16 doc-запросов (EN), live-индекс (18665 строк, 1197 файлов), Hit@1/Hit@5/MRR прямым lookup по metadata.file (без LLM-судей). + +**Сырой вывод:** +| Метрика | Текстовый RAG | Кодовый (E10/E11) | +|---|---|---| +| hit@1 | 12.5% (2/16) | 0% / 20% | +| hit@5 (Gold Top-K) | 12.5% (2/16) | 50% / 40% | +| MRR | 0.125 | 0.200 | + +**Вердикт: HYPOTHESIS REFUTED.** Текстовый RAG значительно уступает кодовому: doc-чанки не берутся в топ-5 для 14/16 запросов. Причины: (1) embedder эмбедлит code-чанки плотнее (сигнатуры/имена), doc-чанки размыты; (2) поисковый индекс bias на код (кодовых чанков >> doc); (3) queries без intent_hint="docs" маршрутизируются в code-путь. README.md извлекается только на query о режимах поиска; SEARCH_PIPELINE.md — на query о пайплайне. + +**Guard:** перед production RAG по документации — добавить intent_detection для doc-queries + поднять вес doc-bucket в soft-weighting. Без этого текстовый RAG ненадёжен. + +**Artifacts:** scripts/eval_text_chunks.py, experiments/text_chunk_eval.json. diff --git a/ISSUE.md b/ISSUE.md index aee52aa0..b37c6f57 100644 --- a/ISSUE.md +++ b/ISSUE.md @@ -585,3 +585,68 @@ ### Не делать - ❌ `DEV_DIARY.md` в расширении — НЕ нарушение (§0.6: заглушка-редирект на AGENT_DIARY.md). - ⚠️ `financial.py:72-75` (сравнение дат) — код проекта Bot_snow, НЕ расширения. Отдельная задача для окна Bot_snow. + +## Поисковое качество / E13 — исследовательские задачи (2026-09-20) + +> Источник: исследование поиска/RAG (exp-5, E13, Exp 43). Порядок выполнения — см. ниже. + +### KI-R1 (P1) — Исправить измерение перед любыми выводами +- **Статус:** ⏳ Open +- **Действия:** + 1. Перезапустить exp-5 после фикса KI-101 (cache-hit пропускал dense-уровень); добавить в лог отдельную строку `vector-only`. + 2. Ввести золотой набор с `gold_chunk_id` → Recall@5 по нему (без текстовой метрики). Предложение Edward из статьи — закрывает «попал ли нужный чанк в топ-5». + 3. Увеличить наборы: 10–16 вопросов — шум (один вопрос = 6–10 пунктов). +- **Why first:** без корректного измерения остальное — догадки. + +### KI-R2 (P1) — Довести E13 до нормального эксперимента +- **Статус:** ⏳ Open +- **Действия:** Два дешёвых прогона на тех же 16 запросах: + 1. с `intent_hint="docs"` — проверяет маршрутизацию; + 2. на индексе только из документации — проверяет перекос корпуса. +- **Why second:** только после них можно писать причину в дневник, а не гипотезу. + +### KI-R3 (P2) — Интерфейс: «найдено N, использовано M» +- **Статус:** ⏳ Open +- **Действия:** Добавить в обычный ответ (не только `explain=True`): `found=N`, `used=M`, `eligible_seen` (0 из 0 против 0 из N). Отличие промаха retrieval от отказа генератора. + +### KI-R4 (P2) — Регрессионный тест на дедупликацию +- **Статус:** ⏳ Open +- **Действия:** Тест на две почти одинаковые записи в одном запросе: в топ-5 должна остаться одна, в цитатах — обе. Случай SCT / SCT Inst. Напоминание: MMR тихо отменялся финальной сортировкой (P2-24). + +### KI-R5 (P2) — Гибрид: не считать улучшением по умолчанию +- **Статус:** ⏳ Open +- **Действия:** vector поверх BM25 снижает recall на 0.098 (у автора статьи гибрид тоже проигрывал). Вместо RRF — фильтр для лексической ветки (порог по числу совпавших чанков / по редким терминам), замер на том же наборе. + +### KI-R6 (P2) — Навести порядок перед публичными ссылками +- **Статус:** ⏳ Open +- **Действия:** + 1. Повторяющаяся запись exp-29 на странице Lab (7 копий). + 2. Дубли и противоречивые записи в архиве дневника (две записи «2026-07-27» с разным статусом проверки). + 3. Проверить, что числа в README (число инструментов, тесты) совпадают с текущими. + +### Порядок выполнения (рекомендация) +1. KI-R1 (перезапуск exp-5 + gold_chunk_id) → KI-R2 (два прогона E13). Дешёвые, от них зависит публичные утверждения. +2. KI-R3 … KI-R6 параллельно/последующе. + +### KI-R7 (P2) — Авто-замер на проекте пользователя +- **Статус:** ⏳ Open +- **Действия:** заголовок раздела / имя символа / docstring → `gold_chunk_id` по построению → Recall@5 в `get_health_report`. Оговорка: запросы похожи на текст, это проверка исправности, не качества. + +### KI-R8 (P2) — Дедупликация по хешу содержимого +- **Статус:** ⏳ Open +- **Действия:** vendored/переводы/CHANGELOG — одинаковый текст в двух файлах занимал 2 из 5 слотов. Схлопывайте по хешу перед выдачей, оба пути оставляйте в цитате. + +### KI-R9 (P2) — Не подбирать один режим на всех +- **Статус:** ⏳ Open +- **Действия:** у нас vector+BM25 снизил recall на 0.098, у автора hybrid проиграл vector. Каскад «быстрый + граф» 0.433 vs 0.267 — более общий вывод, чем выбор режима. + +### KI-R10 (P2) — Слабый запрос → не молчать +- **Статус:** ⏳ Open +- **Действия:** возвращать найденное + оценки + «низкая уверенность»; fallback на grep, если векторный поиск ничего уверенного не дал (у нас exp-26). + +### KI-R11 (P2, первым) — «Найдено N / использовано M» + «проиндексировано ли» +- **Статус:** ⏳ Open +- **Действия:** промах retrieval ≠ отказ генератора. У нас exp-25: 4 вызова впустую на неиндексированных папках. + +### Порядок выполнения (рекомендация) +1. KI-R11 → KI-R7 → KI-R8. Потом KI-R1→R6 по плану. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 89601a65..032d3aa2 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -292,3 +292,88 @@ - `src/core/bootstrap_pipeline.py` (оркестратор: resolve_src_root → detect_entities → pytest subprocess §5.16-safe → index_src_functions → build_tests_edges) + `bootstrap_tool.py` (`bootstrap_pipeline`, MCP+CLI). Подводные камни — AGENT_DIARY 2026-09-18: PYTHONPATH только по авто-детекту `_plugin_importable` (namespace shadowing `src`-пакета), BOM-guard `utf-8-sig`. - Тесты: `tests/test_bootstrap_pipeline.py` (5 интеграц., без моков) + 3 на `index_src_functions`; 28/28 green + полный suite passed. Клиент параметризован по env (`TRACE_SRC_ROOT`/`TRACE_OUT`) → чужие проекты: gemma_agent 2737/2882 (95.0%) тестов имеют ≥1 src-функцию; black скомпилирован в `.pyd` → sys.settrace не ловит нативные кадры (fallback на статику Exp 9 обязателен). - **Веб-исследование и audit «гиблых мест» (2026-09-15, всё ПРОВЕРЕНО эмпирически):** (1) **sysmon+dynamic_context — ОПРОВЕРГНУТА**: верные контексты даёт pytest-коллекция, ручной `switch_context` → пустые `['']` (coverage.py 7.14.1); (2) **контексты ≈3-7% — НЕ воспроизвелось**: Exp 8 (2026-09-16) overhead **+19.96%** (221.78 vs 184.88s) > нашего sys.settrace (+13.6%) → штатный драйвер Шага 3 = `dynamic_trace_plugin.py`, coverage остаётся валидационным оракулом (контексты качественные: 1548/1549, 75.5% src-строк привязаны); (3) **Tarantula — Exp 7b**: rank≤3 у 22.6% тестов (далеко от 60-70%), НО precision низких рангов высока (все rank1-3 верны) → аннотация confidence (~16%), не селектор; TESTS-ребро строится из полной трассы; (4) **mutation-testing как ground truth — дорого/хрупко** (FSE'20, Google 33M; флаки раздувают score); (5) **pytest-testmon — не копируем** (line-based, сужение рерана ≠ граф-ребро TESTS для LLM-контекста); (6) **dev.to-кросс-чек**: «TRUE Coverage» (Dawson, 2026-07-22) подтверждает плато статики и шум shared-utils (наш safe_mkdir/get_data_root кейс 1:1; CI 43min→4min, precision 15%→95%); «Empirical Failure Modes» (Arthur, 2026-07-31) — Pass-Through Test Mirage (наш «фантомный код»), Python 3.14 sys.monitoring reachability = наш бэкенд, AST orphan-detection = наш Шаг 1; **ниша TESTS-рёбер для LLM-контекста ими не занята** (per-test coverage используется только для selection/rejection); (7) edge-case (Gemini): без тестов → статика; бинарники → Docker+microtrace; async → OpenTelemetry по trace_id. + +## 2026-09-18 — Фаза 1: Incremental Hot-Reload (FreshnessChecker оживлён + hot-reload + KI-109) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Fixed (7 тестов свежести включая concurrency-стресс N=16 + 1748 полный pytest green; ветка вне PR — локально) +**Root Cause:** FreshnessChecker (freshness.py) был мёртв (0 вызовов) и СЛОМАН... +- **Статус:** автоматически синхронизировано + + +## 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-09 — Аудит «Active MSCodeBase» (Exhibit #23: MCP tool available but never invoked) + +- **Источник:** AGENT_DIARY.md +- **Описание:** **Status:** Open — зафиксирован гэп (исследование + план, код НЕ вносился) +**Root Cause:** фундамент (VOR / DebounceBatch / ConsistencyTracker / IdleScheduler / PropagationEngine) существует, но компо... +- **Статус:** автоматически синхронизировано + + +## 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/README.md b/README.md index 4a2a1187..f46aad13 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io/) [![Zed](https://img.shields.io/badge/Zed-extension-orange.svg)](https://zed.dev/) [![CI](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml/badge.svg)](https://github.com/ManSio/mscodebase-intelligence/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/tests-1773%20passed-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-1835%20passed-brightgreen)](tests/) -[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-64-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) +[Features](#-features) • [Quick Start](#-quick-start) • [Tools](#mcp-tools-66-total) • [Documentation](#-documentation-map) • [Installation](docs/en/INSTALL.md) • [Architecture](docs/en/ARCHITECTURE.md) • [Contributing](CONTRIBUTING.md) • [Security](SECURITY.md) *Last updated: 2026-08-16* @@ -215,7 +215,7 @@ Deep-dives into specific technical findings from building this project: --- -## 🔧 MCP Tools (65 total) +## 🔧 MCP Tools (66 total) > 65 = 64 base + `execute_script` (регистрируется при `MSCODEBASE_EXECUTE_SCRIPT_ENABLED=true`). Без флага — 64 (32 core + 16 intel + 13 inline + 4 dev). diff --git a/src/core/indexing/index_project_runner.py b/src/core/indexing/index_project_runner.py index a26fc7e6..d74e2900 100644 --- a/src/core/indexing/index_project_runner.py +++ b/src/core/indexing/index_project_runner.py @@ -38,8 +38,15 @@ class IndexProjectRunner: Использует db_manager.begin_write() для сериализации записи в LanceDB. PID-lock (Layer 3) обеспечивается DatabaseLock (db_manager._db_lock). + + Resume (2026-09-20): запись инкрементальная. Файл, все чанки которого + эмбеддированы, записывается в LanceDB сразу (порциями по + WRITE_FLUSH_FILES), освобождая RAM. Краш переживает уже записанные + файлы: при перезапуске known_hashes (таблица) пропускает их. """ + WRITE_FLUSH_FILES = 32 # порция завершённых файлов на один bulk_write + def __init__( self, parse_file_only: Callable, @@ -75,6 +82,8 @@ def __init__( self._db_writer.set_on_recreate_callback(self._sync_table_ref) self._cached_total_chunks = 0 self._cached_unique_files: set[str] = set() + self._integrity_verified = False + self._file_embeddings: dict = {} # ── Self-healing helpers ─────────────────────────────────────────── @@ -130,6 +139,42 @@ def _safe_recreate_table(self): except Exception as e: logger.error(f"Table recreate failed: {e}") + def _verify_and_repair_table_integrity(self) -> None: + """Гарантирует целостность таблицы ДО индексации (resume-safe). + + Вызывается в начале run(), до загрузки known_hashes и до принятия + skip-решений. Если таблица битая (мёртвые фрагменты из drop+create + наследования) — физически пересоздаём и синхронизируем ссылки. + Чтобы не делать полное чтение на каждом прогоне, повторные проверки + в рамках этого прогона пропускаются (один раз достаточно — после + первой успешной записи фрагменты живые, их создаём сами). + """ + if getattr(self, "_integrity_verified", False): + return + self._integrity_verified = True + if self.table is None: + return + if self._verify_index_integrity(): + return + logger.warning( + "INC-6C62: таблица не целостна (мёртвые фрагменты). " + "Физическое пересоздание ДО индексации." + ) + ok = False + if self.db_manager is not None and hasattr(self.db_manager, "recreate_table_physical"): + try: + ok = self.db_manager.recreate_table_physical() + except Exception as e: + logger.error(f"Integrity repair: physical recreate failed: {e}") + if ok: + self._sync_table_ref(self.db_manager.table) + logger.info("Таблица физически пересоздана — известные хэши сброшены, полная индексация.") + else: + logger.error( + "Integrity repair FAILED — индексация начнётся, но optimize/IVF " + "может упасть с 'Not found'. Инкрементальная запись переживёт это." + ) + def _verify_index_integrity(self) -> bool: """Проверяет целостность таблицы LanceDB (INC-6C62). @@ -250,6 +295,17 @@ def run( if progress_callback: progress_callback("", 0, total_files, "scanning") + # INC-6C62 (resume-friendly): проверка целостности ДО загрузки + # known_hashes и ДО любых skip-решений. Если таблица унаследовала + # ссылки на мёртвые фрагменты (битая) — физически пересоздаём + # заранее: известных хэшей не будет → все файлы переиндексируются + # (правильнее, чем потерять skip-файлы после принятых решений). + # Инкрементальная запись (ниже) пишет по мере завершения файлов, + # поэтому целостность таблицы должна быть гарантирована до первого + # bulk_write. Один раз за прогон (на пустой таблице почти бесплатно: + # count_rows=0 + нет фрагментов). + self._verify_and_repair_table_integrity() + # P0-FIX (регрессия ac6e5ba0e P1-3): загружаем известные хэши ОДИН раз # в главном потоке (RLock reentrant — безопасно под begin_write) и # передаём воркерам. Без этого каждый воркер Phase 1 ходит в БД @@ -335,7 +391,13 @@ def _parse_worker(args): progress_callback("", total_files, total_files, "complete") return 0 - # Phase 2: Sort + Batch Embed + # ── Phase 2+3: Sort + Batch Embed + INCREMENTAL WRITE (resume) ── + # Раньше ВСЕ эмбеддинги копились в _all_embeddings и записывались + # одним bulk_write в Phase 3. Краш на большом проекте (330K чанков) + # терял всю работу: таблица пуста → known_hashes пуст → полный + # пере-embed. Теперь файлы, все чанки которых эмбеддированы, + # записываются сразу (порциями по WRITE_FLUSH_FILES) и освобождают + # RAM. Перезапуск = resume: записанные файлы пропускаются по хэшам. _flat_chunks: list = [(fp_idx, text) for fp_idx, fp_data in enumerate(_parsed_list) for text in fp_data["parsed"]["chunk_texts"]] total_chunks = len(_flat_chunks) @@ -347,7 +409,54 @@ def _parse_worker(args): logger.error("Embedder not ready. Indexing aborted.") return 0 + # Сколько чанков в каждом файле → знаем когда файл «завершён». + _chunks_per_file: Dict[int, int] = {} + _flat_indices_by_file: Dict[int, list] = {} + for _idx, (_fp_idx, _) in enumerate(_flat_chunks): + _chunks_per_file[_fp_idx] = _chunks_per_file.get(_fp_idx, 0) + 1 + _flat_indices_by_file.setdefault(_fp_idx, []).append(_idx) + _done_chunks: Dict[int, int] = {} # fp_idx -> сколько чанков эмбеддировано + _all_embeddings: list = [None] * total_chunks + # Инкрементальная запись: флешим порциями завершённые файлы. + _pending_prepared: list = [] # накопленные (records, escaped, existing_hash) + _pending_map: list = [] # (fp_idx, rec_count) для учёта после write + _indexed_count = 0 + + def _flush_pending(): + """Записывает накопленные готовые файлы в БД и освобождает RAM.""" + nonlocal _pending_prepared, _pending_map, _indexed_count + if not _pending_prepared: + return + _t_write = time.time() + _written = self._db_writer.bulk_write(_pending_prepared) if self._db_writer else 0 + _write_elapsed = time.time() - _t_write + _indexed_count += len(_pending_map) + for _fp_idx, _rec_count in _pending_map: + _rel = _parsed_list[_fp_idx]["parsed"]["rel_path"] + with self._index_lock: + self._cached_total_chunks += _rec_count + self._cached_unique_files.add(_rel) + if watchdog_heartbeat: + watchdog_heartbeat(f"write:{Path(_rel).name}") + logger.info( + f"Bulk write: {_written} records from {len(_pending_map)} files " + f"in {_write_elapsed:.1f}s (saved {len(_pending_prepared)} prepared)" + ) + # Освобождаем эмбеддинги и parsed-данные записанных файлов. + # _all_embeddings зануляем по индексам, чтобы big-проект не + # держал ВСЕ вектора в RAM до конца (330K x 1024d x 4B ≈ 1.3GB). + for _fp_idx, _ in _pending_map: + for _flat_i in _flat_indices_by_file.get(_fp_idx, ()): + _all_embeddings[_flat_i] = None + _pending_list = self._file_embeddings.pop(_fp_idx, None) + if _pending_list: + _pending_list.clear() + _parsed_list[_fp_idx] = None # освобождаем тексты чанков + _pending_prepared = [] + _pending_map = [] + gc.collect() + _embed_t0 = time.time() for batch_start in range(0, total_chunks, BATCH_SIZE): @@ -372,6 +481,69 @@ def _parse_worker(args): for i, flat_idx in enumerate(range(batch_start, batch_end)): _all_embeddings[flat_idx] = embeddings[i] + # Помечаем завершённые в этом батче файлы. + # Файл «завершён» когда эмбеддированы ВСЕ его чанки. + _just_completed = [] + for _fp_idx, _ in batch_data: + _done_chunks[_fp_idx] = _done_chunks.get(_fp_idx, 0) + 1 + if _done_chunks[_fp_idx] == _chunks_per_file[_fp_idx]: + _just_completed.append(_fp_idx) + if _just_completed: + for _fp_idx in _just_completed: + _file = self._file_embeddings.setdefault( + _fp_idx, {"parsed": _parsed_list[_fp_idx]["parsed"], "vecs": []} + ) + # Собираем vecs файла из _all_embeddings в порядке появления + # в отсортированном _flat_chunks (тот же порядок, что и раньше + # в Phase 3 — состав записи идентичен, индексы старых данных + # остаются совместимыми). + _file["vecs"] = [ + _all_embeddings[_flat_i] + for _flat_i in _flat_indices_by_file.get(_fp_idx, ()) + ] + if self._db_writer is None: + # Fallback: per-file write (оригинальный путь для + # внешних вызовов без db_writer). Resume-инвариант + # не затрагивается: одноразовость записи сохраняется. + for attempt in range(2): + try: + if self._write_file_records( + _parsed_list[_fp_idx]["parsed"], _file["vecs"] + ): + _indexed_count += 1 + break + except Exception as e: + if attempt == 0 and self._reset_table_if_not_found( + e, "write_file_records", attempt + ): + continue + logger.warning( + f"Write error {_file['parsed']['rel_path']} " + f"(attempt {attempt+1}/2): {e}" + ) + break + if watchdog_heartbeat: + watchdog_heartbeat(f"write:{Path(_file['parsed']['rel_path']).name}") + continue + _prepared = None + try: + _prepared = self._db_writer.prepare_records( + _parsed_list[_fp_idx]["parsed"], _file["vecs"], + summarizer=self.summarizer, enable_summaries=False, + ) + except Exception as e: + logger.warning( + f"Prepare error {_file['parsed']['rel_path']}: {e} — file skipped" + ) + if _prepared and _prepared[0]: + _pending_prepared.append(_prepared) + _pending_map.append((_fp_idx, len(_prepared[0]))) + + # Флеш порций завершённых файлов (не ждём конца прогона — + # это и есть resume-чекпойнт для краша). + if len(_pending_map) >= self.WRITE_FLUSH_FILES or batch_end >= total_chunks: + _flush_pending() + # sleep removed — benchmarked: batch=32 sustained 100ch/s without pauses (2026-07-26) if batch_start % (BATCH_SIZE * 5) == 0 or batch_end >= total_chunks: @@ -395,83 +567,16 @@ def _parse_worker(args): f"({total_chunks/max(_embed_total,0.001):.0f} ch/s)") _notify_progress(total_chunks, total_chunks, "writing", "", 90, 10) - # Phase 3: Write Results (с self-healing от Not Found) - _file_embeddings: dict = {} - for flat_idx, (fp_idx, _) in enumerate(_flat_chunks): - if fp_idx not in _file_embeddings: - _file_embeddings[fp_idx] = {"parsed": _parsed_list[fp_idx]["parsed"], "vecs": []} - _file_embeddings[fp_idx]["vecs"].append(_all_embeddings[flat_idx]) - - indexed_count = 0 - if self._db_writer: - # Bulk write: prepare all records, then one lock cycle - _all_prepared = [] - _prepared_map = [] # (fp_idx, record_count) - for fp_idx, fdata in _file_embeddings.items(): - try: - prepared = self._db_writer.prepare_records( - fdata["parsed"], fdata["vecs"], - summarizer=self.summarizer, enable_summaries=False, - ) - if prepared[0]: # has records - _all_prepared.append(prepared) - _prepared_map.append((fp_idx, len(prepared[0]))) - except Exception as e: - logger.warning(f"Prepare error {fdata['parsed']['rel_path']}: {e}") - - if _all_prepared: - t_write = time.time() - written = self._db_writer.bulk_write(_all_prepared) - write_elapsed = time.time() - t_write - indexed_count = len(_prepared_map) - logger.info(f"Bulk write: {written} records from {indexed_count} files in {write_elapsed:.1f}s") - - # INC-6C62: проверка целостности ДО optimize/IVF. Если таблица - # унаследовала ссылки на мёртвые фрагменты (drop+create не - # удаляет файлы) — физически пересоздаём и повторяем запись - # из уже готовых эмбеддингов (без повторного эмбеддинга). - if _all_prepared and not self._verify_index_integrity(): - logger.warning( - "INC-6C62: index corrupted (dead fragment refs), " - "recreating table physically + rewrite" - ) - if ( - self.db_manager is not None - and hasattr(self.db_manager, "recreate_table_physical") - and self.db_manager.recreate_table_physical() - ): - self.table = self.db_manager.table - rewritten = self._db_writer.bulk_write(_all_prepared) - indexed_count = len(_prepared_map) - logger.info(f"Rewrite after physical recreate: {rewritten} records") - else: - logger.error( - "INC-6C62: physical recreate failed — optimize will likely fail" - ) + # Файлы, у которых все чанки уже эмбеддированы, но не попали во + # флеш (хвост < WRITE_FLUSH_FILES) — добиваем безусловным флешем. + _flush_pending() - # Accounting (after bulk write) - for fp_idx, rec_count in _prepared_map: - rel = _parsed_list[fp_idx]["parsed"]["rel_path"] - with self._index_lock: - self._cached_total_chunks += rec_count - self._cached_unique_files.add(rel) - if watchdog_heartbeat: - watchdog_heartbeat(f"write:{Path(rel).name}") - else: - # Fallback: per-file write (original path) - for fp_idx, fdata in _file_embeddings.items(): - for attempt in range(2): - try: - if self._write_file_records(fdata["parsed"], fdata["vecs"]): - indexed_count += 1 - break - except Exception as e: - if attempt == 0 and self._reset_table_if_not_found(e, "write_file_records", attempt): - continue - logger.warning(f"Write error {fdata['parsed']['rel_path']} (attempt {attempt+1}/2): {e}") - break - if watchdog_heartbeat: - watchdog_heartbeat(f"write:{Path(fdata['parsed']['rel_path']).name}") + indexed_count = _indexed_count + # INC-6C62: verify-проверку больше не делаем здесь — она + # перенесена в _verify_and_repair_table_integrity() в начале + # run(). После инкрементальных bulk_write таблица всегда цела + # (фрагменты созданы нами), а повторное полное чтение на 330K + # чанков дорого и теперь не нужно. logger.info(f"Write complete: {indexed_count} files") if progress_callback: diff --git a/src/providers/embedder/remote_embedder.py b/src/providers/embedder/remote_embedder.py index b07628d5..af46b29d 100644 --- a/src/providers/embedder/remote_embedder.py +++ b/src/providers/embedder/remote_embedder.py @@ -15,6 +15,7 @@ from src.config.settings import get_config from src.core.interfaces import IEmbedder from src.core.platform_utils import get_extension_dir +from src.providers.reranker.llama_install import LLAMA_EMBED_MAX_TOKENS __all__ = [ "RemoteEmbedder", @@ -27,7 +28,7 @@ # llama.cpp жёстко ограничивает вход контекстом обучения модели (n_ctx_train=512 # для multilingual-e5-small). Усечение через HF-токенизатор НЕ гарантирует лимит # (разные BPE: замер 2026-08-01 — 512 HF-токенов -> до 526 llama-токенов на CJK). -_LLAMA_MAX_TOKENS = 480 # безопасный запас под жёсткий потолок 512 +_LLAMA_MAX_TOKENS = int(LLAMA_EMBED_MAX_TOKENS) # безопасный запас под жёсткий потолок 512 _LLAMA_FALLBACK_MAX_TOKENS = 448 # HF-fallback: запас поверх расхождения BPE _LLAMA_TOKENIZE_MIN_CHARS = 256 # короче — не проверяем (bounded даже ~2 ток/симв) diff --git a/src/providers/reranker/llama_install.py b/src/providers/reranker/llama_install.py index a6c9db98..d39db3a3 100644 --- a/src/providers/reranker/llama_install.py +++ b/src/providers/reranker/llama_install.py @@ -35,14 +35,40 @@ # 🏆 Оптимальные параметры для эмбеддингов (Qwen3-Embedding) # Основание: бенчмарки 2026-07-09 — ctx 1024 даёт 722 MB RAM (vs 1669 MB с полным) # batch-size 512: обрабатывает до 512 токенов за один проход -# ubatch-size 128: физический батч для CPU # device none: CPU-only (работает и на MSVC, и на Clang сборках) LLAMA_CTX_SIZE = int(os.getenv("LLAMA_CTX_SIZE", "2048")) # 2048 = ~1 GB RAM; embeddings mode requires ctx >= total input tokens LLAMA_BATCH_SIZE = int(os.getenv("LLAMA_BATCH_SIZE", "2048")) # must match ctx; embeddings mode uses full batch -LLAMA_UBATCH_SIZE = int(os.getenv("LLAMA_UBATCH_SIZE", "2048")) # embeddings: entire input must fit in one ubatch (GitHub #25293). Match ctx. +# ─── Умный ubatch по ролям (A/B 2026-09-20, реальные 2227 чанков, порт 8082) ─── +# llama.cpp требует: ОДИН вход (текст / пара query+passage) ≤ ubatch, иначе +# HTTP 500. При этом целый батч делится по слотам — комментарий GitHub #25293 +# («entire input must fit») устарел для b9940, лимит остался на ОДИН вход. +# Раньше ubatch=2048 для ВСЕХ ролей → vol 1686 MB на embed. Измерено: +# ubatch 512 → 596 MB / 18.9 ch/s (тексты клиент режет до 480 токенов) +# ubatch 2048 → 1686 MB / 16.1 ch/s +# Роль embed: вход ≤ LLAMA_EMBED_MAX_TOKENS (клиент, remote_embedder) → 512. +# Роль rerank: вход = query+passage → LLAMA_RERANK_MAX_TOKENS (клиент тримит) → 1024. +# LLAMA_UBATCH_SIZE остаётся жёстким env-override (см. resolve_ubatch). +LLAMA_EMBED_MAX_TOKENS = int(os.getenv("LLAMA_EMBED_MAX_TOKENS", "480")) +LLAMA_RERANK_MAX_TOKENS = int(os.getenv("LLAMA_RERANK_MAX_TOKENS", "1000")) +LLAMA_UBATCH_SIZE = int(os.getenv("LLAMA_UBATCH_SIZE", "0")) or None LLAMA_DEFRAG_THOLD = float(os.getenv("LLAMA_DEFRAG_THOLD", "0.3")) # дефрагментация KV при 30% LLAMA_CACHE_TYPE = os.getenv("LLAMA_CACHE_TYPE", "q4_0") # сжатие KV кэша (q4_0 = 4-bit, без потери качества) + +def resolve_ubatch(role: str = "embed") -> int: + """Возвращает ubatch для роли сервера (embed | rerank). + + Роль специфична, потому что лимит входа у них разный: + embed — один текст ≤ LLAMA_EMBED_MAX_TOKENS (клиент truncate) + rerank — пара query+passage ≤ LLAMA_RERANK_MAX_TOKENS (клиент триммит) + + Округляем вверх до 128 (кратность, которой достаточно — не 2048). + """ + if LLAMA_UBATCH_SIZE: + return LLAMA_UBATCH_SIZE + limit = LLAMA_RERANK_MAX_TOKENS if role == "rerank" else LLAMA_EMBED_MAX_TOKENS + return ((limit + 127) // 128) * 128 + # ─── Платформенная детекция ──────────────────────────────────── def _detect_platform() -> tuple: """Определяет platform tag для скачивания llama.cpp. @@ -776,7 +802,7 @@ def get_system_summary() -> dict: "avx2": True, "avx512": False, "provider": "llama.cpp", - "provider_ram_mb": 523, + "provider_ram_mb": 596, } """ os_name = sys.platform @@ -810,7 +836,6 @@ def get_system_summary() -> dict: os_name = "Linux" provider = "llama.cpp" if is_installed() else "ONNX server" - provider_ram = 523 if is_installed() else 1689 return { "os": os_name, @@ -822,16 +847,19 @@ def get_system_summary() -> dict: "avx2": _CPU_INFO.get("avx2", False), "avx512": _CPU_INFO.get("avx512", False), "provider": provider, - "provider_ram_mb": provider_ram, + "provider_ram_mb": 596 if is_installed() else 1686, # A/B 2026-09-20: ubatch=512 → 596 MB; ubatch=2048 → 1686 MB } __all__ = [ # constants "LLAMA_VERSION", "LLAMA_BASE_URL", "LLAMA_PORT", "LLAMA_HOST", - "LLAMA_CTX_SIZE", "LLAMA_BATCH_SIZE", "LLAMA_UBATCH_SIZE", + "LLAMA_CTX_SIZE", "LLAMA_BATCH_SIZE", + "LLAMA_EMBED_MAX_TOKENS", "LLAMA_RERANK_MAX_TOKENS", + "LLAMA_UBATCH_SIZE", "LLAMA_DEFRAG_THOLD", "LLAMA_CACHE_TYPE", "GGUF_MODELS", "DEFAULT_EMBEDDING_MODEL", "DEFAULT_RERANKER_MODEL", + "resolve_ubatch", "LLAMA_BIN_SHA256", "LLAMA_BIN_NAME", "LLAMA_BIN_ZIP", "LLAMA_BIN_URL", # module-level vars (public) "_IS_INSIDER", "_HAVE_VULKAN", "_EXE_SUFFIX", "_ZIP_EXT", diff --git a/src/providers/reranker/llama_runner.py b/src/providers/reranker/llama_runner.py index 1e37fbaf..bdb8afa1 100644 --- a/src/providers/reranker/llama_runner.py +++ b/src/providers/reranker/llama_runner.py @@ -63,7 +63,6 @@ LLAMA_CTX_SIZE, LLAMA_HOST, LLAMA_PORT, - LLAMA_UBATCH_SIZE, LLAMA_VERSION, _get_ext_dir, # Функции @@ -76,11 +75,19 @@ download_llama_binary, is_installed, is_model_downloaded, + resolve_ubatch, ) logger = logging.getLogger("mscodebase_server.llama_runner") +def _ubatch_arg(model_key: str) -> str: + """Роль от model_key (зеркалит флаги --embedding/--reranking в start()).""" + if model_key in GGUF_MODELS and model_key != DEFAULT_RERANKER_MODEL: + return str(resolve_ubatch("embed")) + return str(resolve_ubatch("rerank")) + + # ─── Планировщик модели ──────────────────────────────────────── @@ -905,7 +912,7 @@ async def start(self, model_key: str = DEFAULT_EMBEDDING_MODEL) -> bool: "--batch-size", str(LLAMA_BATCH_SIZE), - "--ubatch-size", str(LLAMA_UBATCH_SIZE), + "--ubatch-size", _ubatch_arg(model_key), "--threads", os.getenv("LLAMA_THREADS", "10"), @@ -1040,7 +1047,7 @@ def _spawn_embedder(self, model_key: str) -> bool: "-m", str(gguf_path), "-c", str(LLAMA_CTX_SIZE), "--batch-size", str(LLAMA_BATCH_SIZE), - "--ubatch-size", str(LLAMA_UBATCH_SIZE), + "--ubatch-size", _ubatch_arg(model_key), "--threads", os.getenv("LLAMA_THREADS", "10"), "--cache-type-k", str(LLAMA_CACHE_TYPE), "--cache-type-v", str(LLAMA_CACHE_TYPE), @@ -1138,7 +1145,7 @@ async def _spawn_reranker(self) -> bool: "-m", str(gguf_path), "-c", str(LLAMA_CTX_SIZE), "--batch-size", str(LLAMA_BATCH_SIZE), - "--ubatch-size", str(LLAMA_UBATCH_SIZE), + "--ubatch-size", str(resolve_ubatch("rerank")), "--threads", os.getenv("LLAMA_THREADS", "10"), "--cache-type-k", str(LLAMA_CACHE_TYPE), # 🧹 сжатие KV кэша "--cache-type-v", str(LLAMA_CACHE_TYPE), # 🧹 сжатие KV кэша diff --git a/src/providers/reranker/multi_provider.py b/src/providers/reranker/multi_provider.py index e30c42c0..a9571182 100644 --- a/src/providers/reranker/multi_provider.py +++ b/src/providers/reranker/multi_provider.py @@ -30,6 +30,7 @@ from src.config.settings import get_config from src.core.interfaces.reranker import IReranker +from src.providers.reranker.llama_install import LLAMA_RERANK_MAX_TOKENS from src.providers.reranker.reranker_scoring import ( apply_scores, cosine_similarity, @@ -63,6 +64,25 @@ def _get_max_chunk_preview_len() -> int: return get_config().search.max_chunk_preview_len +def _truncate_rerank_pair( + query: str, passages: List[str], max_tokens: int = LLAMA_RERANK_MAX_TOKENS +) -> str: + """Обрезает query так, чтобы query+максимальный passage не превысил ubatch. + + Реальный токенайзер bge-m3 недоступен на клиенте, поэтому используем + консервативную эвристику: 1 токен ≈ 2 символа (покрывает кириллицу, + латиницу и код; CJK наоборот 1 симв ≈ 1-2 токена — запас сохранён, т.к. + режем по символам строже). + + Возвращает обрезанный query (исходный, если пара в пределах лимита). + """ + max_passage = max((len(p) for p in passages), default=0) + q_limit = max(16, max_tokens * 2 - max_passage) + if len(query) + max_passage <= max_tokens * 2: + return query + return query[:q_limit] + + # Минимальный скор реранкера для фильтрации низкокачественных чанков # Chunk'и со скором ниже этого значения отсекаются из финальных результатов MIN_RERANK_SCORE = 0.3 @@ -465,7 +485,7 @@ async def _llama_cpp_rerank( resp = await self._client.post( f"{self.llama_cpp_url}/v1/rerank", json={ - "query": query, + "query": _truncate_rerank_pair(query, passages), "documents": passages, "top_n": len(passages), }, diff --git a/tests/test_index_resume_incremental.py b/tests/test_index_resume_incremental.py new file mode 100644 index 00000000..7535a825 --- /dev/null +++ b/tests/test_index_resume_incremental.py @@ -0,0 +1,321 @@ +""" +test_index_resume_incremental.py — резюмируемая инкрементальная запись. + +ПРОБЛЕМА (2026-09-20): IndexProjectRunner.run() копил ВСЕ эмбеддинги в +_all_embeddings и писал одним bulk_write в Phase 3. Краш на большом проекте +(330K чанков, ~9ч) терял всю работу: таблица пуста → known_hashes пуст → +полный пере-embed при перезапуске. + +ФИКС: файлы, все чанки которых эмбеддированы, записываются сразу порциями +(WRITE_FLUSH_FILES). Перезапуск = resume: записанные файлы пропускаются по +хэшам, теряются только цепочки последней незавершённой порции. + +ПРОВЕРКА КОРРЕКТНОСТИ: +1. run() НЕ держит все эмбеддинги в памяти: _all_embeddings зануляется + после записи каждой порции (RAM-слёты, а не постоянный рост). +2. Файл записывается РОВНО один раз (вход → выход, no double-write). +3. Краш середины прогона сохраняет уже записанные файлы в БД + (эмулируем embedder exception) → при повторном run отсутствуют. +4. Повторный run после краша индексирует только НЕзаписанные файлы + (H2H-incr: existing hash → skip). + +Запуск: pytest tests/test_index_resume_incremental.py -v +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +import pytest + +from src.core.indexing.index_project_runner import IndexProjectRunner + + +class _FakeTable: + """Имитация LanceDB-таблицы с персистентной памятью между запусками.""" + + def __init__(self): + self._known: dict = {} # file_path -> file_hash (persisted) + self._rows = 0 + + def to_lance(self): + return self + + def to_pandas(self, columns=None): + import pandas as pd + if not self._known: + return pd.DataFrame(columns=[]) + fp = list(self._known.keys()) + fh = list(self._known.values()) + return pd.DataFrame({"file_path": fp, "file_hash": fh}) + + def count_rows(self) -> int: + return len(self._known) + + def delete_all_known(self): + self._known = {} + + +class _FakeFileGuard: + def should_skip_dir(self, d) -> bool: + return False + + def should_skip_file(self, f) -> bool: + return False + + +class _FakePathManager: + def is_safe_to_process(self, p) -> bool: + return True + + +class _FakeEmbedder: + def __init__(self, dim: int = 4, fail_after: int | None = None): + self.dim = dim + self.fail_after = fail_after # кинуть exception после N embed_batch вызовов + self.calls = 0 + self.total_batches = 0 + + def is_ready(self) -> bool: + return True + + def embed_batch(self, texts): + self.calls += 1 + self.total_batches += 1 + if self.fail_after is not None and self.calls > self.fail_after: + raise RuntimeError(f"simulated embedder crash after {self.fail_after} batches") + return [[0.1] * self.dim for _ in texts] + + +class _FakeSearcher: + def reindex(self): + pass + + def invalidate_cache(self): + pass + + +class _FakeDbWriter: + """Пишет в _FakeTable: добавляет file_hash, считает записи.""" + + def __init__(self, table: _FakeTable): + self.table = table + self.written = 0 + self.bulk_calls = 0 + + def set_on_recreate_callback(self, cb): + self._on_recreate = cb + + def prepare_records(self, parsed, vecs, summarizer=None, enable_summaries=False): + rel = parsed["rel_path"] + n = len(parsed["chunk_texts"]) + records = [{ + "id": f"id_{rel}_{i}", + "vector": vecs[i], + "text": parsed["chunk_texts"][i], + "file_path": rel, + "file_hash": parsed["current_hash"], + "chunk_index": i, + } for i in range(n)] + return (records, parsed.get("escaped_path", rel), parsed.get("existing_hash")) + + def bulk_write(self, all_prepared): + total = 0 + for records, _escaped, _hash in all_prepared: + total += len(records) + for r in records: + self.table._known[r["file_path"]] = r["file_hash"] + self.written += total + self.bulk_calls += 1 + return total + + +class _FakeDbManager: + def __init__(self): + self._write_lock = threading.RLock() + + def begin_write(self): + return self._write_lock + + +def _make_parsed(rel: str, n_chunks: int = 2) -> dict: + return { + "chunk_texts": [f"chunk {rel} {i}" for i in range(n_chunks)], + "chunk_texts_full": [f"chunk {rel} {i}" for i in range(n_chunks)], + "chunk_metadatas": [{} for _ in range(n_chunks)], + "chunk_hashes": [f"ch_{rel}_{i}" for i in range(n_chunks)], + "rel_path": rel, + "current_hash": f"hash_{rel}", + "escaped_path": rel.replace("'", "''"), + "health": {"score": 0.0, "band": ""}, + "source": "filesystem", + } + + +def _make_project(tmp_path: Path, n_files: int, n_chunks: int = 1): + project = tmp_path / "project" + (project / "src").mkdir(parents=True, exist_ok=True) + rels = [] + for i in range(n_files): + rel = f"src/mod_{i:04d}.py" + (project / rel).write_text(f"def f{i}():\n return {i}\n", encoding="utf-8") + rels.append(rel) + return project, rels + + +def _make_runner(tmp_path, table: _FakeTable, n_files=40, n_chunks=1, + fail_after=None, db_writer_class=_FakeDbWriter): + project, rels = _make_project(tmp_path, n_files, n_chunks) + + def fake_parse_file_only(full_path, rel_path_str, source="filesystem", known_hashes=None): + # Resume-сценарий: если файл уже в БД (known_hashes) → skip (вернуть None), + # как реальный IndexParser делает при совпадении хэша. + if known_hashes and known_hashes.get(rel_path_str) == f"hash_{rel_path_str}": + return None + return _make_parsed(rel_path_str, n_chunks) + + dbm = _FakeDbManager() + writer = db_writer_class(table) + runner = IndexProjectRunner( + parse_file_only=fake_parse_file_only, + write_file_records=lambda parsed, vecs: True, + embedder=_FakeEmbedder(fail_after=fail_after), + file_guard=_FakeFileGuard(), + searcher=_FakeSearcher(), + table=table, + path_manager=_FakePathManager(), + project_path=project, + db_manager=dbm, + db_writer=writer, + ) + return runner, writer, rels + + +def test_first_run_writes_all_files(tmp_path): + """Полнота: первый прогон записывает все файлы, по нес колько bulk_write.""" + table = _FakeTable() + runner, writer, rels = _make_runner(tmp_path, table, n_files=40, n_chunks=1) + + count = runner.run(runner.project_path) + + assert count == 40, f"ожидали 40 файлов, получено {count}" + assert writer.written == 40, f"записей в БД {writer.written} вместо 40" + assert writer.bulk_calls >= 1, "bulk_write не вызывался вообще" + # 40 файлов по 1 чанку, WRITE_FLUSH_FILES=32 → минимум 2 флеша (32+8) + assert writer.bulk_calls >= 2, ( + f"ожидали >=2 инкрементальных флеша (resume-checkpoint), получили {writer.bulk_calls}" + ) + assert len(table._known) == 40, "таблица не содержит все записанные файлы" + + +def test_run_flushes_incrementally_not_all_at_end(tmp_path): + """Resume: bulk_write происходит ДО конца прогона (не ждём завершения). + + Ключевой атрибут фикса: порции завершённых файлов пишутся по ходу. + Если реализация откатится к «один bulk_write в конце» — bulk_calls == 1 + для проекта, где на каждый флеш накапливается более WRITE_FLUSH_FILES. + """ + table = _FakeTable() + runner, writer, _ = _make_runner(tmp_path, table, n_files=100, n_chunks=1) + + runner.run(runner.project_path) + + # 100 файлов / 32 = 3 полных + 4 хвост → >= 4 флеша + assert writer.bulk_calls >= 4, ( + f"bulk_write={writer.bulk_calls} — выглядит как единый финальный flush, " + "инкрементальная запись не работает" + ) + + +def test_crash_preserves_written_files_in_db(tmp_path): + """Resume: краш embedder'а сохраняет уже записанные файлы в БД. + + Эмулируем падение после 1-го embed_batch (первая порция уже записана). + Таблица должна содержать ~32 файла (первая завершённая порция), а не 0. + """ + table = _FakeTable() + # 100 файлов, embedder падает на 2-м батче (после успешной 1-й порции). + # batch=32 → 1-й батч (32 файла) успешен и записан, 2-й батч падает. + runner, writer, rels = _make_runner(tmp_path, table, n_files=100, n_chunks=1, fail_after=1) + + with pytest.raises(RuntimeError, match="simulated embedder crash"): + runner.run(runner.project_path) + + # Resume: в БД осталась первая записанная порция (до краша). + assert writer.written >= 32, ( + f"после краша в БД {writer.written} записей, ожидали >=32 из первой записи " + "(до краша должна быть хотя бы одна порция)" + ) + assert table.count_rows() >= 32, "таблица не пережила краш — resume невозможен" + + +def test_second_run_resumes_only_unwritten(tmp_path): + """Resume: повторный прогон после краша обрабатывает только НЕзаписанные.""" + table = _FakeTable() + runner, writer, _ = _make_runner(tmp_path, table, n_files=100, n_chunks=1, fail_after=1) + + with pytest.raises(RuntimeError, match="simulated embedder crash"): + runner.run(runner.project_path) + assert table.count_rows() >= 32, "предусловие: до краша записана первая порция" + + # НОВЫЙ runner (перезапуск процесса) с полным embedder и той же таблицей. + runner2, writer2, rels = _make_runner(tmp_path, table, n_files=100, n_chunks=1) + + count2 = runner2.run(runner2.project_path) + + # Из 100 файлов ~32 уже в БД (skip), эмбеддились только оставшиеся ~68. + assert count2 <= 68, ( + f"повторный прогон заново эмбеддил {count2} файлов вместо <=68 — " + "resume не работает (known_hashes не пропустил записанное)" + ) + assert table.count_rows() == 100, "таблица должна заполниться до 100" + + +def test_no_double_write_of_completed_files(tmp_path): + """Корректность: каждый файл попадает в БД ровно один раз (нет double-write). + + written в _FakeDbWriter = число ЗАПИСЕЙ (чанков). Для 40 файлов с 2 чанками + = 80 записей ожидается ровно один раз. Double-write показал бы >80. + """ + table = _FakeTable() + runner, writer, rels = _make_runner(tmp_path, table, n_files=40, n_chunks=2) + + runner.run(runner.project_path) + + assert writer.written == 80, f"double-write: {writer.written} записей вместо 80" + assert len(table._known) == 40, f"в БД {len(table._known)} файлов вместо 40" + + +def test_ram_is_freed_after_flush(tmp_path): + """RAM: эмбеддинги завершённых файлов занулены после инкрементальной записи. + + Не замеряем RAM (флейковый замер), а проверяем инвариант использования: + через инъекцию в bulk_write считаем, что каждый флеш получает подготовленные + данные, а таблица растёт порциями (а не единым всплеском в конце). + Это косвенно подтверждает: большой проект не держит все 1.3GB векторов + до самого конца прогона. + """ + table = _FakeTable() + runner, writer, _ = _make_runner(tmp_path, table, n_files=80, n_chunks=1) + + # Инъекция в db_writer: считаем размеры порций bulk_write. + write_lens = [] + + orig_bulk = writer.bulk_write + + def spy_bulk(all_prepared): + write_lens.append(len(all_prepared)) + return orig_bulk(all_prepared) + + writer.bulk_write = spy_bulk + + runner.run(runner.project_path) + + # 80 файлов, WRITE_FLUSH_FILES=32 → порции по ~32, а не один раз 80. + assert len(write_lens) >= 3, f"ожидали >=3 порций записи, получили {write_lens}" + assert write_lens[0] <= 33, ( + f"первая порция {write_lens[0]} файлов — инкрементальная запись работает не так" + ) + # Таблица заполнена полностью, все 80 файлов на месте. + assert table.count_rows() == 80 diff --git a/tests/test_ubatch_roles.py b/tests/test_ubatch_roles.py new file mode 100644 index 00000000..a0a13be6 --- /dev/null +++ b/tests/test_ubatch_roles.py @@ -0,0 +1,87 @@ +""" +Юнит-тесты для роль-специфичного ubatch (2026-09-20). + +Покрывают: +1. resolve_ubatch: роли embed/rerank, округление вверх до 128 +2. Жёсткий env-override LLAMA_UBATCH_SIZE обеих ролей +3. Кастомные лимиты LLAMA_EMBED_MAX_TOKENS / LLAMA_RERANK_MAX_TOKENS +4. Клиентский трим пары _truncate_rerank_pair (query+passage <= ubatch) +""" + +from __future__ import annotations + +import pytest + +from src.providers.reranker.llama_install import ( + LLAMA_EMBED_MAX_TOKENS, + LLAMA_RERANK_MAX_TOKENS, + resolve_ubatch, +) +from src.providers.reranker.multi_provider import _truncate_rerank_pair + + +def test_resolve_ubatch_default_roles(): + """Дефолтные лимиты: embed 480 -> 512, rerank 1000 -> 1024.""" + assert resolve_ubatch("embed") == 512 + assert resolve_ubatch("rerank") == 1024 + # role по умолчанию = embed + assert resolve_ubatch() == resolve_ubatch("embed") + + +def test_resolve_ubatch_rounds_up_to_128(): + """Всегда кратно 128 и покрывает лимит входа.""" + for role, limit in (("embed", LLAMA_EMBED_MAX_TOKENS), ("rerank", LLAMA_RERANK_MAX_TOKENS)): + ub = resolve_ubatch(role) + assert ub % 128 == 0 + assert ub >= limit + assert ub - limit < 128 # без избыточного запаса + + +@pytest.mark.parametrize( + "env_value,expected", + [(128, 128), (256, 256), (512, 512), (2048, 2048)], +) +def test_resolve_ubatch_env_override(monkeypatch, env_value, expected): + """LLAMA_UBATCH_SIZE — жёсткий override для обеих ролей.""" + monkeypatch.setattr("src.providers.reranker.llama_install.LLAMA_UBATCH_SIZE", env_value) + assert resolve_ubatch("embed") == expected + assert resolve_ubatch("rerank") == expected + + +def test_resolve_ubatch_custom_limits(monkeypatch): + """Кастомные лимиты ролей пересчитывают ubatch.""" + monkeypatch.setattr("src.providers.reranker.llama_install.LLAMA_EMBED_MAX_TOKENS", 600) + monkeypatch.setattr("src.providers.reranker.llama_install.LLAMA_RERANK_MAX_TOKENS", 1500) + assert resolve_ubatch("embed") == 640 # ceil(600/128)*128 + assert resolve_ubatch("rerank") == 1536 # ceil(1500/128)*128 + + +class TestTruncateRerankPair: + def test_short_pair_unchanged(self): + query = "как работает auth" + passages = ["def authenticate_user(token): return verify_jwt(token)"] + assert _truncate_rerank_pair(query, passages) == query + + def test_long_query_trimmed_to_limit(self): + query = "x" * 3000 + passages = ["p" * 800] + # лимит: 1000 токенов * 2 симв = 2000; на passage уходит 800 -> query <= 1200 + out = _truncate_rerank_pair(query, passages) + assert len(out) <= 1200 + assert out == query[:1200] + + def test_empty_passages(self): + query = "запрос" + assert _truncate_rerank_pair(query, []) == query + + def test_preserves_short_query_with_long_passage(self): + query = "краткий запрос" + passages = ["y" * 800] + assert _truncate_rerank_pair(query, passages) == query + + def test_custom_max_tokens(self): + query = "q" * 500 + passages = ["p" * 400] + out = _truncate_rerank_pair(query, passages, max_tokens=512) + # лимит 512*2=1024; passage 400 -> query <= 624 + assert len(out) <= 624