From 98774f5b8d527d67120531fc22956c681a1981b4 Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Mon, 21 Sep 2026 23:00:45 +0300 Subject: [PATCH 1/3] docs: KNOWN_ISSUES dedup (16 dups, 379->240 lines) + diary resume entry --- AGENT_DIARY.md | 1 + KNOWN_ISSUES.md | 141 +----------------------------------------------- 2 files changed, 2 insertions(+), 140 deletions(-) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index e9be40a1..4d8e121e 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1,5 +1,6 @@ ## Key Historical Decisions +- **Resume-инкрементальная запись IndexProjectRunner (2026-09-20, 0fcb0f33):** run() копил все эмбеддинги и писал одним bulk_write в конце — краш на 330K чанков (~9ч) терял всё. Теперь файл пишется порциями (WRITE_FLUSH_FILES=32) по завершению его чанков, RAM освобождается, restart = resume по known_hashes; `_verify_and_repair_table_integrity()` в начале run() (INC-6C62); `_flat_indices_by_file` (O(1) сбор vecs); fallback per-file сохранён. Тесты test_index_resume_incremental.py (6). Red Team 5/5. - **Умный 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. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 032d3aa2..87d132ce 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -6,7 +6,7 @@ --- -**18 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08) +**29 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08, 2026-09-21) ## 2026-09-19 — Прод-инцидент: миграция колонок lanceDB молча не выполнялась + db_writer разрушал БД при schema-mismatch (Fixed) @@ -51,7 +51,6 @@ **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 @@ -59,7 +58,6 @@ **Root Cause 1 (B):** после A (fail-closed symbol, никогда REFUTED) свежесть индекса не проверялась — отсутс... - **Статус:** автоматически синхронизировано - ## 2026-09-03 — Fake reindex ETA "~8s" + frozen progress in Finalizing (fixed 32f11662) - **Источник:** AGENT_DIARY.md @@ -68,7 +66,6 @@ **Root Cause 2:** `_safe_ivf_index` без единого progress-колбэка → бар застывал на 0.8, чанки не росли. **Fix 2:** emission «finalizing» колбэка до/после IVF, отображение 0.8→0.95, честная строка в get_job_status. - **Статус:** автоматически синхронизировано - ## 2026-09-03 19:30 — CI RED: circular import layer ↔ tools_reg (fixed f210ed7c) - **Источник:** AGENT_DIARY.md#2026-09-03-1930 @@ -96,7 +93,6 @@ **Fix:** `_kill_git_tree()` (`taskkill /F /T /PID`) на TimeoutExpired в check_commit_exists + то же в run_script (git_hooks_installer.py:93). Снято на живой цепочке 9660→24156→24428. Тесты: 9 passed (5 commit_guard + 2 subprocess_windows + 2 ledger slow); ruff clean по новым строкам. - **Статус:** ✅ Fixed - ## 2026-09-05 — stale_detector + predict_change стабильно -32001 через MCP (fixed code only) - **Источник:** AGENT_DIARY.md#2026-09-05-1230 @@ -104,7 +100,6 @@ - **Fix:** оба инструмента обёрнуты в `asyncio.to_thread` (doc_tools._scan_docs, predict_tools.static_predict/ChangePreview.run); таймауты 10s→60s (stale), 60s→120s (predict). Прямые вызовы: stale OK 13.0s, predict OK 1.4s; 62 теста passed. - **Статус:** ✅ Fixed (code only, MCP reload требуется) - ## 2026-09-06 — lock_guard acquire/release падал ThreadExpired: таймаут 60s < pre-commit hook 5-10min (fixed) - **Источник:** AGENT_DIARY.md#2026-09-06-2100 @@ -118,7 +113,6 @@ - **Fix:** `_section_git` стал async, `subprocess.run` обёрнут в `asyncio.to_thread` (context_tool.py); wsl_check/`_run_mutmut_in_wsl`/`_verify_mutmut_can_fail` — через `asyncio.to_thread` (system_tools.py). `git_tools._git_run` уже был async (эталон, не тронут). Проверено: test_context_tool 2 passed, ruff clean, импорты OK, реальный `_section_git` возвращает git-history. - **Статус:** ✅ Fixed (code only, MCP reload требуется) - ## 2026-09-06 — [P-001 рецидив] cmd-окна при запуске/открытии проекта: powershell/nvidia-smi БЕЗ CREATE_NO_WINDOW (fixed) - **Источник:** AGENT_DIARY.md#2026-09-06-2200 @@ -126,7 +120,6 @@ - **Fix:** CREATE_NO_WINDOW добавлен во все 5 сайтов (3 файла: resource_monitor.py ×2, llama_runner.py ×3). Guard: `tests/test_subprocess_windows.py` — из placeholder'ов превращён в реальный статический тест (grep по всем src/**/*.py за консоль-спавнами powershell/wsl/wmic/netstat/taskkill/nvidia-smi без флага → fail) + тест daemon-потоки без capture_output. Прогон: 2 passed. - **Статус:** ✅ Fixed - ## 2026-09-07 — Cypher-движок ломается на анонимных узлах/рёбрах (fixed) + Receipts не писались из write-пути (fixed) + collect() некорректно заявлен (open) - **Источник:** live-проба против реальной БД `bfe9644b/graph.db` (PropertyGraph, 6435 Variable / 22031 CALLS / 6152 ASSIGNED_FROM рёбер) @@ -135,7 +128,6 @@ - **Fix (Cypher):** внесён (см. выше, коммит 80a7acf8). **Fix (collect):** либо реализовать JSON-агрегацию `collect()` (json_group_array в SQLite), либо убрать из списка Supported и добавить негативный тест. **Fix (Receipts):** внесён — `_contract_record` в write_tools.py теперь вызывает новый `_contract_receipt()` (ActionReceipt рядом с ChangeIntent), а сам `_contract_record` добавлен во ВСЕ write-пути: replace, insert_before/after, rename (LSP workspace edit + fallback), safe_delete, move (source/target/refs). Receipt-запись warning-only, не ломает write. Тест `tests/test_write_tools.py::test_apply_records_action_receipt` (создание action_receipts.jsonl из реального write-вызова). Коммит см. git log. - **Статус:** 🟢 Cypher-часть fixed; 🟢 receipts fixed; 🟢 collect() fixed (2026-09-08: json_group_array + FILTER null-игнор, decode только marked-колонок; 13 новых тестов, полный pytest 1663 passed) - ## 2026-09-07 — Lazy-only верификация: память не проверяется без вызова агента; нет TTL/фона (open, эксперимент нужен) - **Источник:** live-срез project_memory.json текущего проекта (136 узлов) + grep точек вызова VOR/idle-планировщика @@ -153,7 +145,6 @@ - **2026-09-11 H3 TTL-гниение реализован (doc 10, H2 закрыт Exp 3):** `last_checked` пишется для КАЖДОГО реально проверенного узла (cache-hit и fresh check, включая INCONCLUSIVE) — физическая запись rate-limited (`VOR_LAST_CHECKED_INTERVAL_SEC`, default 6ч, чтобы H1 idle не переписывал project_memory.json каждый тик); `stale_ttl_nodes` — узлы ACTIVE/VERIFIED, НЕ проверенные в проходе, чей след (`verified_at`/`last_checked`) старше `VOR_TTL_DAYS` (default 30 → label `verification="stale_ttl"` «не подтверждён за N дней»). N=30 из live-распределения verified_at 2026-09-11 (ACTIVE 70 без verified_at, VERIFIED median 22/max 31, коммитовый ритм daily). Статус НЕ меняется (Red Team a2: INCONCLUSIVE неотзываем, guard false_retraction). Нет следа вовсе (новый узел) → НЕ stale (starved ловит систематическое голодание отдельно). Files: verify_on_read.py (const + _persist_transitions + run), layer.py (flag), ui_formatter.py (render). Тесты: 9 новых (test_verify_on_read_ttl.py); полный pytest 1725 passed. - **Дедлайн:** 2026-09-15 · **Owner:** ManSio - ## 2026-09-08 — B4: статический цикл parser ⇄ language_imports (осознанный техдолг, lazy, allowed) - **Источник:** `architecture_linter` (Invariant 3) после деривации `LANGUAGE_IMPORT_NODES` из `CodeParser.IMPORT_NODE_MAP` (B4). @@ -162,82 +153,12 @@ - **Статус:** ✅ Fixed (allowed tech debt, deferred refactor; целевые 68 passed, architecture_linter 4/4 OK) - **Дедлайн рефактора:** 2026-10-01 · **Owner:** ManSio -## 2026-09-07 — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона - -- **Источник:** AGENT_DIARY.md -- **Описание:** **Status:** ✅ Fixed (H1, 2026-09-09 — фоновая проверка через IdleScheduler-hook; см. основную запись выше) -**Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleSch... -- **Статус:** автоматически синхронизировано - - -## 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-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 @@ -245,14 +166,6 @@ **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-09 19:35 - .h заголовки C не индексируются (SUPPORTED_EXTENSIONS без .h) - **Источник:** AutoCoder аудит/E-S1 live-проба 2026-09-09 (внешняя сессия, репо не изменялось до этой записи) @@ -293,14 +206,6 @@ - Тесты: `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 @@ -308,38 +213,6 @@ **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 @@ -347,33 +220,21 @@ **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 сете... - **Статус:** автоматически синхронизировано - From 732d7f84654199adf694c8261283813a1c0d53fc Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Mon, 21 Sep 2026 23:25:41 +0300 Subject: [PATCH 2/3] docs(issues): add Bootstrap Pipeline external review (2026-09-20) + fix KI-R11 indent --- ISSUE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ISSUE.md b/ISSUE.md index b37c6f57..920cb33b 100644 --- a/ISSUE.md +++ b/ISSUE.md @@ -650,3 +650,24 @@ ### Порядок выполнения (рекомендация) 1. KI-R11 → KI-R7 → KI-R8. Потом KI-R1→R6 по плану. + +--- + +## Bootstrap Pipeline — внешний обзор (2026-09-20) + +> Источник: внешний обзор (через community-memory). Числа и утверждения НЕ проверялись — только описание подхода. +> **Статус:** research-отзыв, без кода. + +### Что подтверждено +1. **Динамическая связка test→code — стандартный приём.** pytest-testmon и coverage.py делают то же же (сбор зависимостей тест↔код через coverage). Вывод Exp 7 «динамика лучше статики» совпадает с наукой: статика включает лишние вспомогательные методы, динамические трассы — нет. Exp 9 (статика как fallback/pre-filter) разумна. +2. **Ниша TESTS-ребра для LLM-контекста незанята.** PyPI Chisel (LLM-агенты, граф тестов + git + статика), gograph `untested` (Go) — аналогичные задачи, но без динамических рёбер. Наше отличие — динамический источник, но точность нужно сравнить. +3. **«Исполнено» ≠ «тестируется».** В среднем 10 функций на тест, макс 118. Наука: точность растёт с признаками (имя теста, последний вызов перед assert). TCTracer (динамика+статика) дал MAP ~85 на связи test↔function. Наш Tarantula (rank≤3 только 22.6%) — та же проблема. + +### Что стоит упростить +- Плагин повторяет coverage.py с контекстами. sys.monitoring пока не поддерживает динамические контексты; в одном отчёте контексты по тестам удвоили CI на Django. Наши +13.6% на этом фоне неплохи. +- Поверхность инструментов: из 62 использовались 5 (exp-24). Codebase-Memory (14 инструментов) достигает 90% качества разведки файлов с в разы меньшим числом токенов/вызовов. Перемудрена скорее поверхность, чем трассировка. + +### Три шага для проверки +1. Запустить pytest-testmon или coverage на тех же 5 проектах, сравнить связи с ours. +2. Добавить ранг «что именно тестируется» (имя теста + последний вызов перед assert). +3. Разметить вручную 20–30 тестов, посчитать точность. From eb9c52bdcf1bac6ca67b83b87fbafd57a2f019fb Mon Sep 17 00:00:00 2001 From: MSCodeBase Agent Date: Mon, 21 Sep 2026 23:29:22 +0300 Subject: [PATCH 3/3] chore(experiments): commit E11/E14 harness, embed bench and text chunk eval --- .../embed_real_path_vs_raw/exp_feed_queue.py | 100 +++++++ .../embed_real_path_vs_raw/exp_gc_cost.py | 50 ++++ .../embed_real_path_vs_raw/exp_ram_load.py | 31 +++ .../embed_real_path_vs_raw/exp_ram_monitor.py | 71 +++++ .../embed_real_path_vs_raw/exp_real_path.py | 178 +++++++++++++ .../exp_real_path_embedder.py | 95 +++++++ .../exp_ubatch_threshold.py | 48 ++++ .../embed_real_path_vs_raw/token_math.py | 35 +++ .../search_quality/E11_graph_hybrid_arms.py | 247 ++++++++++++++++++ .../search_quality/E11_graph_hybrid_probe.py | 219 ++++++++++++++++ experiments/text_chunk_eval.json | 203 ++++++++++++++ scripts/eval_text_chunks.py | 203 ++++++++++++++ 12 files changed, 1480 insertions(+) create mode 100644 experiments/embed_real_path_vs_raw/exp_feed_queue.py create mode 100644 experiments/embed_real_path_vs_raw/exp_gc_cost.py create mode 100644 experiments/embed_real_path_vs_raw/exp_ram_load.py create mode 100644 experiments/embed_real_path_vs_raw/exp_ram_monitor.py create mode 100644 experiments/embed_real_path_vs_raw/exp_real_path.py create mode 100644 experiments/embed_real_path_vs_raw/exp_real_path_embedder.py create mode 100644 experiments/embed_real_path_vs_raw/exp_ubatch_threshold.py create mode 100644 experiments/embed_real_path_vs_raw/token_math.py create mode 100644 experiments/search_quality/E11_graph_hybrid_arms.py create mode 100644 experiments/search_quality/E11_graph_hybrid_probe.py create mode 100644 experiments/text_chunk_eval.json create mode 100644 scripts/eval_text_chunks.py diff --git a/experiments/embed_real_path_vs_raw/exp_feed_queue.py b/experiments/embed_real_path_vs_raw/exp_feed_queue.py new file mode 100644 index 00000000..0e8ac62e --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_feed_queue.py @@ -0,0 +1,100 @@ +"""E4: «правильная подача в очередь» — размер пачки по ТОКЕНАМ под ubatch=2048, +варьируя длину чанков и параллельность. + +Гипотеза владельца: узкое место не в embedder, а в том КАК подаются пачки. +Сейчас: BATCH_SIZE=32 текстов × ~203 ток = ~6500 токенов на запрос +(сервер с ubatch=2048 сам делит на под-проходы). +Гипотеза: если подавать пачками ~=2048 токенов (одна ubatch-порция) — +tok/s вырастет, т.к. каждый запрос = 1 проход дешифровки, нет накладных на чанкинг. + +План: +- Корпус: реальные чанки из DMDB, средняя ~203 токена. +- Меряем tok/s и ch/s для пачек, собранных по целевому БЮДЖЕТУ токенов: + 512, 1024, 2048, 4096 (4 прохода), 8192 (4) +- Три длины чанков: короткие (~40 ток), средние (~100), длинные (~203). +- Concurrency: 1 запрос за раз (как в цикле) vs 3 параллельных (параллельность). + +Вывод: оптимальный target_tokens и есть ли выигрыш от параллельности. +""" +import sys +sys.stdout.reconfigure(encoding='utf-8') +import statistics, time +from concurrent.futures import ThreadPoolExecutor +import httpx, lancedb + +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +EMB = "http://127.0.0.1:8080/v1/embeddings" + +db = lancedb.connect(DB) +df = db.open_table("codebase_chunks").to_pandas() +texts = [str(x) for x in df["text"].tolist()] + +# оценка токенов: ~3.9 chars/token (EN-код) +CHARS_PER_TOKEN = 3.9 + +def tok_est(t): return max(1, int(len(t) / CHARS_PER_TOKEN)) + +# Нарезаем корпус по целевым длинам (в токенах) +def make_corpus(target_tok, unit="char"): + out = [] + for t in texts[:400]: + total_chars = int(target_tok * CHARS_PER_TOKEN) + while len(t) >= total_chars and len(out) < 200: + out.append(t[:total_chars]) + t = t[total_chars:] + out.extend(texts[:200]) + return out + +def build_batches_by_tokens(corpus, budget_tokens, max_items=64): + """Собирает пачки так, что суммарные токены <= budget. Жадный непрерывный.""" + batches = [] + cur, cur_tok = [], 0 + for t in corpus: + tk = tok_est(t) + if cur and cur_tok + tk > budget_tokens: + batches.append(cur); cur, cur_tok = [], 0 + cur.append(t); cur_tok += tk + if len(cur) >= max_items: + batches.append(cur); cur, cur_tok = [], 0 + if cur: batches.append(cur) + return batches + +def run_batches(client, batches, nw=1): + times, tk_total = [], 0 + t0 = time.time() + if nw == 1: + for b in batches: + t1 = time.time() + r = client.post(EMB, json={"input": b}) + times.append(time.time() - t1) + if r.status_code != 200: + return None, f"HTTP {r.status_code}" + tk_total += sum(tok_est(x) for x in b) + else: + def one(b): + t1 = time.time() + r = httpx.Client(timeout=120).post(EMB, json={"input": b}) + return (r.status_code, time.time() - t1, sum(tok_est(x) for x in b)) + with ThreadPoolExecutor(max_workers=nw) as ex: + res = list(ex.map(one, batches)) + for status, dt, tk in res: + if status != 200: return None, f"HTTP {status}" + times.append(dt); tk_total += tk + elapsed = time.time() - t0 + return (len(batches), sum(len(b) for b in batches), tk_total, elapsed, statistics.median(times)), None + +c = httpx.Client(timeout=120) +print(f"{'чанк_токен':>10} {'budget':>6} {'пачек':>5} {'чанков':>6} {'p50ms':>7} {'tok/s':>7} {'ch/s':>6}") +for chunk_tok in (40, 100, 203): + corpus = make_corpus(chunk_tok) + for budget in (512, 1024, 2048, 4096, 8192): + batches = build_batches_by_tokens(corpus, budget) + batches = batches[:20] + for nw in (1, 3): + res, err = run_batches(c, batches, nw) + if err: + print(f"{chunk_tok:>10} {budget:>6} конк={nw} ERR {err}") + continue + n_batch, n_ch, tk, el, p50 = res + print(f"{chunk_tok:>10} {budget:>6} {n_batch:>5} {n_ch:>6} {p50:>7.0f} {tk/el:>7.0f} {n_ch/el:>6.1f} конк={nw}") +c.close() \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_gc_cost.py b/experiments/embed_real_path_vs_raw/exp_gc_cost.py new file mode 100644 index 00000000..c2ef4d33 --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_gc_cost.py @@ -0,0 +1,50 @@ +"""E3: стоимость gc.collect() в цикле index_project_runner на масштабе прода. +Продуктивный цикл: _all_embeddings=[None]*335013, _flat_chunks=335k пар, +на KАЖДОЙ пачке 32 — gc.collect(). Замер: время gc при растущем списке. +Сравнение: цикл С gc против БЕЗ gc (одни присваивания + структуры). +""" +import sys, time, gc +sys.stdout.reconfigure(encoding='utf-8') + +TOTAL = 100_000 +BATCH = 32 +ROUNDS = 5 + +# структуры как в проде +_flat_chunks = [(i, "x" * 60) for i in range(TOTAL)] # text ~203 tok аналогillus +_all_embeddings = [None] * TOTAL +_fake_vec = [0.1] * 384 + +# цикл БЕЗ gc +t0 = time.time() +for r in range(ROUNDS): + for i in range(0, TOTAL, BATCH): + for j in range(BATCH): + _all_embeddings[i + j] = _fake_vec +el_no = time.time() - t0 +print(f"loop {ROUNDS}x{TOTAL} без gc: {el_no:.2f}s ({(el_no/ROUNDS/(TOTAL/BATCH))*1000:.1f}ms/пачку)") + +# цикл С gc.collect() на каждой пачке +_all_embeddings = [None] * TOTAL +t0 = time.time() +gc_times = [] +for r in range(ROUNDS): + for i in range(0, TOTAL, BATCH): + for j in range(BATCH): + _all_embeddings[i + j] = _fake_vec + tg0 = time.time() + gc.collect() + gc_times.append(time.time() - tg0) +el_gc = time.time() - t0 +print(f"loop {ROUNDS}x{TOTAL} С gc: {el_gc:.2f}s ({(el_gc/ROUNDS/(TOTAL/BATCH))*1000:.1f}ms/пачку)") +print(f"gc.collect() отдельно: p50={sorted(gc_times)[len(gc_times)//2]*1000:.1f}ms " + f"max={max(gc_times)*1000:.0f}ms calls={len(gc_times)}") +print(f"накладные gc: {el_gc-el_no:.2f}s на {ROUNDS} проходов → " + f"{(el_gc-el_no)/(ROUNDS*(TOTAL/BATCH))*1000:.1f}ms/пачку чистого gc") + +# экстраполяция на прод (335013 чанков / 32 = 10469 пачек) +per_batch_gc_ms = (el_gc - el_no) / (ROUNDS * (TOTAL / BATCH)) * 1000 +print(f"\nПРОД 335013 чанков → 10469 пачек → gc-оверхед ≈ {10469*per_batch_gc_ms/1000:.0f}s " + f"(из ~8ч прогона)") +# проверка: добавляем объекты (1340 live tuple-ов в _file_embeddings? нет — revisit) +print(f"RAM финально: {TOTAL*384*8/1e6:.0f} MB (только fake векторы, прода 335k -> х2.6)") \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_ram_load.py b/experiments/embed_real_path_vs_raw/exp_ram_load.py new file mode 100644 index 00000000..0c8bb41e --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_ram_load.py @@ -0,0 +1,31 @@ +"""E13-нагрузчик: имитация index_project Phase 2 (реальный путь): +сортировка чанков по длине + batch=32 последовательно, как index_project_runner. +Гоняет 2000 реальных чанков из Локальнонi БД MSCodeBase. RAM снимает отдельный семплер. +""" +import sys, time +sys.stdout.reconfigure(encoding='utf-8') +import httpx, lancedb + +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +EMB = "http://127.0.0.1:8080/v1/embeddings" +BATCH = 32 + +db = lancedb.connect(DB) +df = db.open_table("codebase_chunks").to_pandas().head(2000) +texts = [str(x) for x in df["text"].tolist()] +texts.sort(key=len) # как index_project_runner.py:344 +total = len(texts) + +c = httpx.Client(timeout=60) +t0 = time.time() +for i in range(0, total, BATCH): + batch = texts[i:i+BATCH] + r = c.post(EMB, json={"input": batch}) + if r.status_code != 200: + print(f"HTTP {r.status_code} at {i}") + break + if i % (BATCH*10) == 0: + el = time.time() - t0 + print(f"[{i}/{total}] avg={i/max(el,0.001):.1f} ch/s elapsed={el:.0f}s", flush=True) +print(f"DONE {total} chunks in {time.time()-t0:.1f}s = {total/(time.time()-t0):.1f} ch/s", flush=True) +c.close() \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_ram_monitor.py b/experiments/embed_real_path_vs_raw/exp_ram_monitor.py new file mode 100644 index 00000000..5fb96bb1 --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_ram_monitor.py @@ -0,0 +1,71 @@ +"""RAM монитор всех причастных процессов: llama-server (embed+rerank), python MCP (src.main), +ONNX server, все python*. Пишет CSV каждые 1s. Запускать фоново через Start-Job.""" +import sys, time, subprocess, os, json + +OUT = r"C:\Users\misha\AppData\Local\Temp\opencode\ram_full_trace.csv" +DURATION = int(sys.argv[1]) if len(sys.argv) > 1 else 600 # сек + +def ps_tree(): + """Вернёт {pid: (name, cmdline, ram_mb)} для python*/llama-server.""" + out = subprocess.check_output( + 'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -match \\\'python|llama\\\' } | ForEach-Object { \'{0}|{1}|{2}|{3}\' -f $_.ProcessId,$_.Name,([math]::Round($_.WorkingSetSize/1MB)),($_.CommandLine) }"', + shell=True, text=True, errors='replace' + ) + res = {} + for line in out.splitlines(): + parts = line.split('|', 3) + if len(parts) == 4: + pid, name, ram, cmd = parts + res[int(pid)] = {"name": name, "ram": int(ram or 0), "cmd": cmd} + return res + +def sampler(stop, logpath, interval=1.0): + t0 = time.time() + lines = ["t_s,pid,name,ram_mb,cmd"] + while not stop.is_set(): + t = time.time() - t0 + try: + procs = ps_tree() + for pid, info in sorted(procs.items(), key=lambda kv: -kv[1]["ram"]): + cmd = info["cmd"][:120].replace(",", "_") + # короткая классификация для читаемости + short = "OTHER" + if "llama-server" in info["name"].lower(): + if ":8081" in cmd: short = "LLAMA_RERANK_8081" + elif ":8080" in cmd: short = "LLAMA_EMBED_8080" + else: short = "LLAMA" + elif "onnx_server" in cmd or "onnx_server.py" in cmd: short = "ONNX_SERVER" + elif "src.main" in cmd: short = "MCP_MAIN" + elif "community_memory" in cmd: short = "COMMUNITY" + else: short = "PY_OTHER" + lines.append(f"{t:.1f},{pid},{short},{info['ram']},{cmd}") + except Exception as _e: + pass + time.sleep(interval) + with open(logpath, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + +if __name__ == "__main__": + st = time.time() + lines = [f"# {time.strftime('%Y-%m-%d %H:%M:%S')} start, dur={DURATION}s"] + while time.time() - st < DURATION: + try: + procs = ps_tree() + for pid, info in sorted(procs.items(), key=lambda kv: -kv[1]["ram"]): + cmd = info["cmd"][:120].replace(",", "_") + short = "OTHER" + if "llama-server" in info["name"].lower(): + if ":8081" in cmd: short = "LLAMA_RERANK_8081" + elif ":8080" in cmd: short = "LLAMA_EMBED_8080" + else: short = "LLAMA" + elif "onnx_server" in cmd or "onnx_server.py" in cmd: short = "ONNX_SERVER" + elif "src.main" in cmd: short = "MCP_MAIN" + elif "community_memory" in cmd: short = "COMMUNITY" + else: short = "PY_OTHER" + lines.append(f"{(time.time()-st):.1f},{pid},{short},{info['ram']},{cmd}") + except Exception: + pass + time.sleep(1.0) + with open(OUT, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + print(f"WRITTEN {OUT}") \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_real_path.py b/experiments/embed_real_path_vs_raw/exp_real_path.py new file mode 100644 index 00000000..461363c0 --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_real_path.py @@ -0,0 +1,178 @@ +"""Exp: real-path llama_cpp embed vs raw POST — где пропадают ch/s. + +Контекст: микро-бенч T3 (156 ch/s) бил HTTP /v1/embeddings напрямую с +коротким синтетическим корпусом (~30 chars). Реальный путь embed_batch() +перед embed гоняет _truncate_for_llama() — на каждый текст ДЛИННЕЕ 256 +символов отдельный HTTP /tokenize round-trip (на пачку 32 = до 32 HTTP). + +Вопрос: сколько % времени цикла index_project_runner уходит на truncation +и насколько реальная дорога embed_batch медленнее голого POST. + +Руки (контрольная группа — один реальный корпус из индекса, одна сессия): + A) raw POST /v1/embeddings batch=32 (T3-копия, БЕЗ truncation) + B) real embed_batch() llama_cpp (С truncation), как в проде + C) только _truncate_for_llama() на том же корпусе (стоимость токенизации) + D) zero-vector / validity check для всех векторов руки B +""" +import sys +sys.stdout.reconfigure(encoding='utf-8') + +import time +import statistics + +import httpx +import lancedb + +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +N_CHUNKS = 640 # 20 пачек по 32 +BATCH = 32 +PORT = 8080 +EMBED_URL = f"http://127.0.0.1:{PORT}/v1/embeddings" +TOK_URL = f"http://127.0.0.1:{PORT}/tokenize" +MAX_TOKENS = 480 +MIN_CHARS = 256 + + +def load_chunks(limit: int): + db = lancedb.connect(DB) + t = db.open_table("codebase_chunks") + df = t.to_pandas().head(limit) + texts = [str(x) for x in df["text"].tolist()] + # сортировка как в index_project_runner (по длине) + texts.sort(key=len) + return texts + + +def llama_token_count(client, text: str) -> int: + try: + r = client.post(TOK_URL, json={"content": text, "add_special": False}, timeout=5.0) + if r.status_code == 200: + data = r.json() + return int(data.get("count", len(data.get("tokens", [])))) + except Exception: + pass + return -1 + + +def truncate_for_llama(client, texts): + """Дословная реплика remote_embedder._truncate_for_llama (line 200-238).""" + out = [] + tokenize_calls = 0 + for t in texts: + if len(t) <= MIN_CHARS: + out.append(t) + continue + tokenize_calls += 1 + n = llama_token_count(client, t) + if n < 0: + out.append(t) + continue + if n <= MAX_TOKENS: + out.append(t) + continue + cut = t + for _ in range(4): + new_len = int(len(cut) * (MAX_TOKENS / n) * 0.8) + if new_len >= len(cut) or new_len < 1: + break + cut = cut[:new_len] + n = llama_token_count(client, cut) + tokenize_calls += 1 + if n <= MAX_TOKENS: + break + out.append(cut) + return out, tokenize_calls + + +def vec_norm(v): + return sum(x * x for x in v) ** 0.5 + + +def run_arm(client, texts, label): + """Прокачивает ВСЕ N_CHUNKS пачками по BATCH, возвращает ch/s и метрики.""" + t0 = time.time() + batch_times = [] + zero = 0 + total = 0 + for i in range(0, len(texts), BATCH): + batch = texts[i:i + BATCH] + tb0 = time.time() + r = client.post(EMBED_URL, json={"input": batch}, timeout=120) + dt = time.time() - tb0 + batch_times.append(dt) + if r.status_code != 200: + print(f" [{label}] HTTP {r.status_code} at {i}") + continue + data = sorted(r.json().get("data", []), key=lambda x: x.get("index", 0)) + for d in data: + v = d["embedding"] + total += 1 + if all(x == 0.0 for x in v): + zero += 1 + el = time.time() - t0 + n = len(texts) + ch_s = n / el if el > 0 else 0 + print(f"[{label}] {n}ch in {el:.1f}s = {ch_s:.0f} ch/s | " + f"p50_batch={statistics.median(batch_times)*1000:.0f}ms | " + f"zero_vec={zero}/{total} ({(zero/total)*100 if total else 0:.1f}%)") + return ch_s, el, zero, total + + +def main(): + print("=== Exp: real llama_cpp embed path vs raw POST ===") + texts = load_chunks(N_CHUNKS) + lens = [len(t) for t in texts] + over256 = sum(1 for l in lens if l > 256) + print(f"corpus: {len(texts)} chunks | len p50={statistics.median(lens)} " + f"p90={sorted(lens)[int(len(lens)*0.9)]} max={max(lens)} | >256chars={over256}/{len(texts)}") + + # прогрев + hw = httpx.Client(timeout=120) + hw.post(EMBED_URL, json={"input": ["warmup"]}) + + # ARM C: стоимость truncation отдельно + tc0 = time.time() + _, tok_calls = truncate_for_llama(hw, texts) + tc = time.time() - tc0 + print(f"ARM C truncation only: {len(texts)} texts, {tok_calls} /tokenize HTTP calls " + f"in {tc:.1f}s ({tc/len(texts)*1000:.0f}ms/text avg)") + + # ARM A: raw POST без truncation (T3-копия) + run_arm(hw, texts, "A raw POST (+trunc none)") + + # ARM B: с truncation перед каждой пачкой (реальный путь) + t0 = time.time() + batch_times = [] + zero = 0 + total = 0 + for i in range(0, len(texts), BATCH): + batch = texts[i:i + BATCH] + truncated, _ = truncate_for_llama(hw, batch) + tb0 = time.time() + r = hw.post(EMBED_URL, json={"input": truncated}, timeout=120) + dt = time.time() - tb0 + batch_times.append(dt) + if r.status_code != 200: + print(f" [B] HTTP {r.status_code} at {i}") + continue + data = sorted(r.json().get("data", []), key=lambda x: x.get("index", 0)) + for d in data: + v = d["embedding"] + n = vec_norm(v) + total += 1 + if all(x == 0.0 for x in v): + zero += 1 + elif n < 1e-6: + print(f" [B] near-zero norm {n:.2e} at {i}") + el = time.time() - t0 + n = len(texts) + print(f"[B trunc+embed] {n}ch in {el:.1f}s = {n/el:.0f} ch/s | " + f"p50_batch={statistics.median(batch_times)*1000:.0f}ms | " + f"zero_vec={zero}/{total}") + + hw.close() + print("=== done ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_real_path_embedder.py b/experiments/embed_real_path_vs_raw/exp_real_path_embedder.py new file mode 100644 index 00000000..745b7bcb --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_real_path_embedder.py @@ -0,0 +1,95 @@ +"""E14: репродукция реального пути MCP-индексации. +Инстанцирует RemoteEmbedder как в MCP, _init_provider_async, затем embed_batch. +Семплирует RAM в фоновом потоке: self, llama 8080, llama 8081, onnx server (если есть). +""" +import sys, time, threading +sys.stdout.reconfigure(encoding='utf-8') +import os, subprocess + +# как MCP: mode init + цикл embed +import pathlib +sys.path.insert(0, r"D:\Project\MSCodeBase") +from src.providers.embedder.remote_embedder import RemoteEmbedder + +WATCH = """~/.none""" +PIDS = [os.getpid()] + +def _pid_of(name_filter): + try: + out = subprocess.check_output( + "powershell -NoProfile -Command \"Get-CimInstance Win32_Process -Filter \\\"Name='%s'\\\" | ForEach-Object { '{0}|{1}' -f $_.ProcessId,$_.CommandLine }\"" + % name_filter, shell=True, text=True, errors='replace') + found = [] + for line in out.splitlines(): + if '|' in line: + pid, cmd = line.split('|', 1) + found.append((int(pid), cmd)) + return found + except Exception as _e: + return [] + +def _ram_mb(pid): + try: + out = subprocess.check_output( + f"powershell -NoProfile -Command \"(Get-Process -Id {pid} -EA SilentlyContinue).WorkingSet64\"", + shell=True, text=True, errors='replace') + return int(float(out.strip() or 0)) // 1048576 + except Exception: + return -1 + +def sampler(stop, logpath, interval=0.5): + t0 = time.time() + lines = [] + while not stop.is_set(): + t = time.time() - t0 + self_ram = _ram_mb(os.getpid()) + emb = _ram_mb(1396) + rer = _ram_mb(9348) + # все python помимо self + pys = [_rm for _rm in []] + ils = "" + for _pid, _cmd in _pid_of("pythonw.exe") + _pid_of("python.exe"): + if _pid == os.getpid(): + continue + if "onnx_server" in _cmd or "onnx_server.py" in _cmd: + ils += f" onnx_server={_ram_mb(_pid)}MB" + lines.append(f"t={t:5.1f} self={self_ram}MB embed8080={emb}MB rerank8081={rer}MB{ils}") + time.sleep(interval) + with open(logpath, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + +emb = RemoteEmbedder() +print("mode после init:", emb.mode, "| _onnx_client:", emb._onnx_client is not None) +# только те инстансы, которые MCP делает на старте (не трогаем глобальные серверы): +# _init_provider_async запускается в потоке; вызываем напрямую как стартовый путь +from threading import Thread +emb._init_provider_async() +print("mode после provider_async:", emb.mode) +if emb._onnx_client is not None: + print("onnx server running:", emb._onnx_client._is_server_running() if hasattr(emb._onnx_client, '_is_server_running') else '?') + +stop = threading.Event() +th = threading.Thread(target=sampler, args=(stop, r"C:\Users\misha\AppData\Local\Temp\opencode\ram_e14.txt"), daemon=True) +th.start() + +import httpx, lancedb +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +db = lancedb.connect(DB) +df = db.open_table("codebase_chunks").to_pandas().head(2000) +texts = [str(x) for x in df["text"].tolist()] +texts.sort(key=len) + +for i in range(0, len(texts), 32): + batch = texts[i:i+32] + try: + emb.embed_batch(batch) + except Exception as e: + print(f" embed_batch fail at {i}: {type(e).__name__}: {e}") + if i % 320 == 0: + print(f" [{i}/{len(texts)}] mode={emb.mode} onnx_client={emb._onnx_client is not None}") +stop.set() +# финальный дамп процесса self +print("self RAM final:", _ram_mb(os.getpid()), "MB") +print("--- trace ---") +with open(r"C:\Users\misha\AppData\Local\Temp\opencode\ram_e14.txt", encoding="utf-8") as f: + print(f.read()) \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/exp_ubatch_threshold.py b/experiments/embed_real_path_vs_raw/exp_ubatch_threshold.py new file mode 100644 index 00000000..b7312b9e --- /dev/null +++ b/experiments/embed_real_path_vs_raw/exp_ubatch_threshold.py @@ -0,0 +1,48 @@ +"""E2: порог ubatch=2048 токенов. Ищем скачок времени когда пачка пересекает +2048 токенов (llama-server разбивает на под-проходы). +batch по числу текстов N; тексты ~203 токена avg → +N=1: ~200t N=8: ~1600t (<2048, 1 проход) +N=12: ~2400t (>2048, 2 прохода) <- порог! +Меряем per-request время и tok/s. +""" +import sys +sys.stdout.reconfigure(encoding='utf-8') +import statistics, time +import httpx, lancedb + +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +EMB = "http://127.0.0.1:8080/v1/embeddings" + +db = lancedb.connect(DB) +df = db.open_table("codebase_chunks").to_pandas().head(64) +texts = [str(x) for x in df["text"].tolist()] +texts.sort(key=len) + +# токены хотя бы для оценки +import subprocess +def _tok(t): + import httpx as hx + r = hx.Client(timeout=10).post("http://127.0.0.1:8080/tokenize", json={"content": t, "add_special": False}) + return int(r.json().get("count", 0)) +toks = [_tok(x) for x in texts[:16]] +print(f"sample toks: avg={sum(toks)/len(toks):.0f} max={max(toks)}") + +c = httpx.Client(timeout=60) +results = [] +for N in (1, 4, 8, 10, 12, 16, 24, 32): + batch = texts[:N] + times = [] + for _ in range(3): + t0 = time.time() + r = c.post(EMB, json={"input": batch}) + times.append(time.time() - t0) + if r.status_code != 200: + print(f"N={N} HTTP {r.status_code}") + break + p50 = statistics.median(times) + total_tok = sum(len(x) // 3 for x in batch) # грубо ~3.4 chan/token на eng-коде + tok_s = sum(len(x)/3.4 for x in batch) / p50 + results.append(f"N={N:2d} p50={p50*1000:6.1f}ms ch/s={N/p50:5.1f} ~tok/s={tok_s:5.0f}") + +print("\n".join(results)) +c.close() \ No newline at end of file diff --git a/experiments/embed_real_path_vs_raw/token_math.py b/experiments/embed_real_path_vs_raw/token_math.py new file mode 100644 index 00000000..e337e392 --- /dev/null +++ b/experiments/embed_real_path_vs_raw/token_math.py @@ -0,0 +1,35 @@ +"""Малость 2: токены корпуса — отделяем «число текстов» от «token-потолка». +Считаем llama-токены реального корпуса и пересчитываем ch/s → tok/s. +""" +import sys +sys.stdout.reconfigure(encoding='utf-8') +import statistics +import httpx +import lancedb + +DB = r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\lancedb_v2\index_mscodebase_bfe9644b.db" +TOK_URL = "http://127.0.0.1:8080/tokenize" + +db = lancedb.connect(DB) +t = db.open_table("codebase_chunks") +df = t.to_pandas().head(640) +texts = [str(x) for x in df["text"].tolist()] +texts.sort(key=len) + +def tok(client, text): + r = client.post(TOK_URL, json={"content": text, "add_special": False}, timeout=5.0) + return int(r.json().get("count", len(r.json().get("tokens", [])))) + +c = httpx.Client(timeout=30) +toks = [tok(c, x) for x in texts] +c.close() + +total = sum(toks) +print(f"texts={len(texts)} total_tokens={total} avg={total/len(texts):.0f} " + f"p90={sorted(toks)[int(len(toks)*0.9)]} max={max(toks)}") +# Скорости из эксперимента 1 (18 ch/s raw): tok/s +print(f"[raw POST 18ch/s] -> {18*total/len(texts):.0f} tok/s") +print(f"[E10 sustained 11.9ch/s] -> {11.9*total/len(texts):.0f} tok/s") +# Пересчёт: если сустейн ~X tok/s, сколько ch/s даст этот корпус +for toks in (2000, 3000, 4000): + print(f" if sustained {toks} tok/s -> {toks/(total/len(texts)):.0f} ch/s on this corpus") \ No newline at end of file diff --git a/experiments/search_quality/E11_graph_hybrid_arms.py b/experiments/search_quality/E11_graph_hybrid_arms.py new file mode 100644 index 00000000..a750ba05 --- /dev/null +++ b/experiments/search_quality/E11_graph_hybrid_arms.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +E11 — AST/Graph-hybrid re-ranking РУКИ (read-only, одна сессия). + +Гипотеза: NL-запросы кода содержат идентификаторы (file_mtime_ns, notify, +bm25), но подаются в VectorSearch.path без symbol-лукапа (engine._graph_stage +триггерится только на чистый identifier). Подъём graph-хитов (search_symbols) +должен спасти хиты, которые fast/quality теряют. + +Руки (над теми же baseline top-k, без перегенерации): + A-prepend : graph-файлы в начало списка, затем baseline без дублей + B-RRF : reciprocal_rank_fusion(baseline, graph-rank-1) + C-graph : graph-хиты подняты наверх (как полностью доверяющий режим) + +Метрики: hit@1 / hit@5 / MRR по 10 CASES. Без правок src/, без записи. +""" + +import re +import sys +import time +from pathlib import Path + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parent.parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +CASES = [ + ( + "как работает hot-reload свежести индекса при изменении файлов", + "src/core/indexing/freshness.py", + ), + ( + "миграция схемы добавление колонок file_mtime_ns в таблицу LanceDB", + "src/core/indexing/db_manager.py", + ), + ( + "когда таблица пересоздаётся при schema mismatch полный rebuild", + "src/core/indexing/db_writer.py", + ), + ( + "векторный поиск похожих чанков по индексу через LanceDB distance", + "src/core/search/engine.py", + ), + ( + "ленивая проверка факта памяти verify on read статус ADR", + "src/core/intelligence/verify_on_read.py", + ), + ( + "удалённый эмбеддинг через HTTP API llama server batch", + "src/providers/embedder/remote_embedder.py", + ), + ( + "per project indexer registry multi window пулы по путям проектов", + "src/core/indexing/project_indexer_registry.py", + ), + ( + "переиндексация одного изменённого файла notify change rate limit", + "src/mcp/tools/indexing_tools.py", + ), + ( + "ранжирование результатов реранкером BGE M3 перестановка топ", + "src/providers/reranker/search_result_reranker.py", + ), + ( + "bm25 ключевые слова медленный но точный полнотекстовый", + "src/core/search/bm25.py", + ), +] + +_STOP = { + "hot", "reload", "свежест", "индекс", "изменен", "файлов", "файл", + "help", "is", "and", "or", "the", "table", "колонок", "таблиц", "поиск", + "похожих", "чанков", "проверка", "факта", "памят", "статус", "удален", + "эмбеддинг", "измененного", "ранжирование", "результатов", "которые", + "ключевые", "слова", "медленный", "точный", "один", "схемы", "schema", + "mismatch", "полный", "rebuild", "переиндексация", "пулы", "путям", + "проектов", "перестановка", "содержат", "через", "при", "после", + "before", "after", "query", "output", "index", "value", "list", "map", + "set", "data", "file", "files", "code", "api", "http", "server", + "window", "project", "projects", "lancedb", "актуальный", "антипаттерн", + "build", "builds", "rate", "limit", "big", "old", "status", "реализован", + "содержит", "не", "по", "для", "как", "когда", "vector", "search", +} + + +def extract_candidates(query: str, min_len: int = 3) -> list: + cands: list = [] + for m in re.finditer(r"[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+", query): + t = m.group(0) + if len(t) >= min_len: + cands.append(t) + for m in re.finditer(r"[A-Za-z][A-Za-z0-9._]{1,}", query): + t = m.group(0) + if len(t) < min_len: + continue + if t.endswith((".", ":", "_", "/")): + continue + if t in _STOP or t.lower() in _STOP: + continue + if "_" in t: + continue + low = t.lower() + if low == t: + if len(t) >= 5 or any(ch.isdigit() for ch in t): + cands.append(t) + continue + cands.append(t) + seen = set() + uniq = [] + for c in sorted(cands, key=len, reverse=True): + k = c.lower() + if k not in seen: + seen.add(k) + uniq.append(c) + return uniq + + +def norm(p: str) -> str: + return p.replace("\\", "/").lstrip("/") + + +def hit_position(files: list, expected: str, k: int) -> int: + want = norm(expected) + for i, fp in enumerate(files[:k], 1): + if fp == want or fp.endswith(want): + return i + return 0 + + +def reciprocal_rank(r, k=60): + return 1.0 / (k + r) + + +def main() -> int: + import httpx + + for n, u in [("embed", "http://127.0.0.1:8080/health"), ("rerank", "http://127.0.0.1:8081/health")]: + try: + ok = httpx.get(u, timeout=3).status_code == 200 + except Exception: + ok = False + print(f"{'🟢' if ok else '🔴'} {n} {u}") + + from src.core.di_container import create_service_collection, IndexerFactoryKey + from src.core.indexing.project_indexer_registry import get_global_registry + + project = ROOT + services = create_service_collection(project) + registry = get_global_registry() + factory = services.resolve(IndexerFactoryKey) + indexer = registry.get_indexer(project, factory=factory) + searcher = indexer.searcher + si = getattr(indexer, "_symbol_index", None) or getattr(indexer, "symbol_index", None) + print(f"indexer rows={indexer.table.count_rows() if indexer.table else -1} symbol_index={type(si).__name__ if si else None}") + + arms = {"baseline": [], "A-prepend": [], "B-RRF": [], "C-graph": []} + perf = {"baseline": 0.0, "graph_lookup": 0.0} + + for i, (q, exp) in enumerate(CASES, 1): + # baseline + t0 = time.perf_counter() + res = searcher.search_with_mode(query=q, mode="quality", limit=5) + results = res.get("results", []) if isinstance(res, dict) else (res or []) + base_files = [norm(r.get("metadata", {}).get("file", "")) for r in results] + perf["baseline"] += (time.perf_counter() - t0) * 1000 + + # graph lookup + t0 = time.perf_counter() + graph_files = [] + for c in extract_candidates(q): + try: + refs = si.search_symbols(c, top_k=15) or [] + except Exception: # noqa: BLE001 + refs = [] + for x in refs: + fp = norm(x.file_path) + if fp not in graph_files: + graph_files.append(fp) + perf["graph_lookup"] += (time.perf_counter() - t0) * 1000 + + def prepend(_g, _b): + out = list(_g) + [f for f in _b if f not in _g] + return out[:5] + + def rrf(_g, _b): + scores = {} + for rank, f in enumerate(_g, 1): + scores[f] = scores.get(f, 0.0) + reciprocal_rank(rank) + for rank, f in enumerate(_b, 1): + scores[f] = scores.get(f, 0.0) + reciprocal_rank(rank) + return [f for f, _ in sorted(scores.items(), key=lambda kv: -kv[1])][:5] + + def cgraph(_g, _b): + return (list(_g) + [f for f in _b if f not in _g])[:5] + + arms["baseline"].append((q, exp, base_files, graph_files)) + arms["A-prepend"].append((q, exp, prepend(graph_files, base_files))) + arms["B-RRF"].append((q, exp, rrf(graph_files, base_files))) + arms["C-graph"].append((q, exp, cgraph(graph_files, base_files))) + + print("━" * 100) + hdr = f"{'#':>2} {'base':>4} {'A':>4} {'B':>4} {'C':>4} эталон [graph-found файлы]" + print(hdr) + sums = {"baseline": (0, 0, 0.0), "A-prepend": (0, 0, 0.0), "B-RRF": (0, 0, 0.0), "C-graph": (0, 0, 0.0)} + + for i, (q, exp, bf, gf) in enumerate(arms["baseline"], 1): + pos = {} + mrr = {} + for arm, lst in [("baseline", bf), ("A-prepend", arms["A-prepend"][i-1][2]), + ("B-RRF", arms["B-RRF"][i-1][2]), ("C-graph", arms["C-graph"][i-1][2])]: + p5 = hit_position(lst, exp, 5) + p1 = 1 if p5 == 1 else 0 + mr = 1.0 / p5 if p5 else 0.0 + pos[arm] = p5 + mrr[arm] = mr + h1, hh5, mm = sums[arm] + sums[arm] = (h1 + p1, hh5 + (1 if p5 else 0), mm + mr) + marks = {a: ("✅" if pos[a] else "❌") for a in pos} + gf_str = gf[:2] + print( + f"{i:>2} {marks['baseline']}{pos['baseline']:>4} {marks['A-prepend']}{pos['A-prepend']:>4} " + f"{marks['B-RRF']}{pos['B-RRF']:>4} {marks['C-graph']}{pos['C-graph']:>4} {norm(exp)} " + f"[gf={gf_str}]" + ) + + print("━" * 100) + print(f"avg baseline_ms={perf['baseline']/len(CASES):.0f} avg graph_lookup_ms={perf['graph_lookup']/len(CASES):.0f}") + for arm, (h1, h5, m) in sums.items(): + n = len(CASES) + print(f"{arm:<10} hit@1={h1}/{n} ({100*h1/n:.0f}%) hit@5={h5}/{n} ({100*h5/n:.0f}%) MRR={m/n:.3f}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/search_quality/E11_graph_hybrid_probe.py b/experiments/search_quality/E11_graph_hybrid_probe.py new file mode 100644 index 00000000..cc474f21 --- /dev/null +++ b/experiments/search_quality/E11_graph_hybrid_probe.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +E11 — AST/Graph-hybrid re-ranking зонд (read-only). + +Гипотеза (E11): search-only ceiling ~0.23 (Exp-29/E3). NL-запросы индекса +содержат идентификаторы кода (file_mtime_ns, verify_on_read, ...), но +_graph_stage триггерится ТОЛЬКО на чистый identifier-токен +(engine.py _IDENTIFIER_QUERY_RE). Зонд извлекает из NL-запроса +символьные подстроки-кандидаты, гоняет их через search_symbols() +(PropertyGraph LIKE + _definitions fallback) и проверяет: +сколько из 10 CASES эталонный файл появляется в graph-хитах. + +Ничего не пишет в индекс. Ничего не меняет в src/. +""" + +import re +import sys +import time +from pathlib import Path + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +ROOT = Path(__file__).resolve().parent.parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +CASES = [ + ( + "как работает hot-reload свежести индекса при изменении файлов", + "src/core/indexing/freshness.py", + ), + ( + "миграция схемы добавление колонок file_mtime_ns в таблицу LanceDB", + "src/core/indexing/db_manager.py", + ), + ( + "когда таблица пересоздаётся при schema mismatch полный rebuild", + "src/core/indexing/db_writer.py", + ), + ( + "векторный поиск похожих чанков по индексу через LanceDB distance", + "src/core/search/engine.py", + ), + ( + "ленивая проверка факта памяти verify on read статус ADR", + "src/core/intelligence/verify_on_read.py", + ), + ( + "удалённый эмбеддинг через HTTP API llama server batch", + "src/providers/embedder/remote_embedder.py", + ), + ( + "per project indexer registry multi window пулы по путям проектов", + "src/core/indexing/project_indexer_registry.py", + ), + ( + "переиндексация одного изменённого файла notify change rate limit", + "src/mcp/tools/indexing_tools.py", + ), + ( + "ранжирование результатов реранкером BGE M3 перестановка топ", + "src/providers/reranker/search_result_reranker.py", + ), + ( + "bm25 ключевые слова медленный но точный полнотекстовый", + "src/core/search/bm25.py", + ), +] + +# Токены, которые точно НЕ символы кода (стоп-слова NL) +_STOP = { + "hot", "reload", "свежест", "индекс", "изменен", "файлов", "файл", + "help", "is", "and", "or", "the", "table", "колонок", "таблиц", "поиск", + "похожих", "чанков", "проверка", "факта", "памят", "статус", "удален", + "эмбеддинг", "измененного", "ранжирование", "результатов", "которые", + "ключевые", "слова", "медленный", "точный", "один", "схемы", "schema", + "mismatch", "полный", "rebuild", "переиндексация", "пулы", "путям", + "проектов", "перестановка", "содержат", "через", "при", "после", + "before", "after", "query", "output", "index", "value", "list", "map", + "set", "data", "file", "files", "code", "api", "http", "server", + "window", "project", "projects", "lancedb", "актуальный", "антипаттерн", + "build", "builds", "rate", "limit", "big", "old", "set", "get", "status", + "реализован", "содержит", "не", "по", "для", "как", "когда", +} + + +def extract_candidates(query: str, min_len: int = 3) -> list: + """Извлекает из NL-запроса подстроки-кандидаты на символы кода. + + 1) snake_case токены (file_mtime_ns, verify_on_read) + 2) camelCase/qualified tokens из букв с хотя бы 2 заглавными/верблюдом + 3) одиночные буквенные токены (long words) + Возвращает список в порядке убывания длины (самые специфичные первыми). + """ + cands: list = [] + + # snake_case / любой токен с underscore + for m in re.finditer(r"[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+", query): + t = m.group(0) + if len(t) >= min_len: + cands.append(t) + + # верблюжий регистр + qualified (dot/::/chevron) — но без пробелов + for m in re.finditer(r"[A-Za-z][A-Za-z0-9._]{1,}", query): + t = m.group(0) + if len(t) < min_len: + continue + # отбросить чистые lowercase слова (NL), оставить верблюдов и qualified + if t.endswith((".", ":", "_", "/")): + continue + if t in _STOP or t.lower() in _STOP: + continue + # snake уже добавлены + if "_" in t: + continue + # только если это не простое lowercase слово (псевдо-символ: Camel/BGE/M3) + low = t.lower() + if low == t: + # одинарное lowercase слово — только если длинное или редкое + # (например bm25, fts, ttl, rrfd) — оставляем короткие техно-токены + if len(t) >= 5 or any(ch.isdigit() for ch in t): + cands.append(t) + continue + cands.append(t) + + # дедуп с сохранением порядка + seen = set() + uniq = [] + for c in sorted(cands, key=len, reverse=True): + k = c.lower() + if k not in seen: + seen.add(k) + uniq.append(c) + return uniq + + +def norm(p: str) -> str: + return p.replace("\\", "/").lstrip("/") + + +def main() -> int: + from src.core.di_container import create_service_collection, IndexerFactoryKey + from src.core.indexing.project_indexer_registry import get_global_registry + + project = ROOT + services = create_service_collection(project) + registry = get_global_registry() + factory = services.resolve(IndexerFactoryKey) + indexer = registry.get_indexer(project, factory=factory) + si = getattr(indexer, "_symbol_index", None) or getattr(indexer, "symbol_index", None) + print(f"indexer: {type(indexer).__name__} rows={indexer.table.count_rows() if indexer.table else -1}") + print(f"symbol_index: {type(si).__name__ if si else None}") + if si is None: + print("❌ no symbol_index") + return 2 + + n_saved_def = n_hit = 0 + print("━" * 100) + print(f"{'#':>2} {'match':>6} эталон → graph-хиты (топ до 5)") + for i, (q, exp) in enumerate(CASES, 1): + cands = extract_candidates(q) + if not cands: + print(f"{i:>2} {'—':>6} {norm(exp)} [нет кандидатов]") + continue + + refs = [] + t0 = time.perf_counter() + for c in cands: + try: + r = si.search_symbols(c, top_k=15) + except Exception as e: # noqa: BLE001 + r = [] + print(f" search_symbols({c!r}) → ERR {type(e).__name__}: {e}") + for x in r or []: + refs.append((c, x)) + dt = (time.perf_counter() - t0) * 1000 + + files_hit = [] + def_hit = False + for c, ref in refs: + fp = norm(ref.file_path) + if fp == norm(exp) or fp.endswith(norm(exp)): + files_hit.append((c, ref.symbol, fp, ref.is_definition)) + if ref.is_definition: + def_hit = True + + uniq_files = [] + seen_f = set() + for c, sym, fp, isdef in files_hit: + if fp not in seen_f: + seen_f.add(fp) + uniq_files.append((c, sym, fp, isdef)) + + mark = "✅" if uniq_files else ("🟡" if files_hit else "❌") + if uniq_files: + n_saved_def += 1 if any(x[3] for x in uniq_files) else 0 + n_hit += 1 + print(f"{i:>2} {mark:>6} {norm(exp)}") + print(f" cands={cands[:6]} ({dt:.0f}ms, refs={len(refs)})") + for c, sym, fp, isdef in uniq_files[:5]: + print(f" → {c} [{sym}] {'DEF' if isdef else 'REF'} {fp}") + + print("━" * 100) + print(f"graph-hit: {n_hit}/{len(CASES)} def-hit: {n_saved_def}/{len(CASES)}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + + traceback.print_exc() + sys.exit(1) \ No newline at end of file diff --git a/experiments/text_chunk_eval.json b/experiments/text_chunk_eval.json new file mode 100644 index 00000000..1a837d6e --- /dev/null +++ b/experiments/text_chunk_eval.json @@ -0,0 +1,203 @@ +{ + "metrics": { + "n_queries": 16, + "hit1_pct": 12.5, + "hit5_pct": 12.5, + "mrr_mean": 0.125, + "avg_latency_ms": 1694.0 + }, + "rows": [ + { + "query": "how to install the mscodebase extension in Zed and what it does", + "expected": "README.md", + "latency_ms": 0.0, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "adapters/zed/zed_config.py", + "n_results": 2 + }, + { + "query": "what MCP tools are available for code search", + "expected": "README.md", + "latency_ms": 506.6, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "src/mcp/tools/search_tools.py", + "n_results": 5 + }, + { + "query": "search modes fast quality deep auto which to choose", + "expected": "README.md", + "latency_ms": 1993.5, + "hit1": 1.0, + "hit5": 1.0, + "mrr": 1.0, + "rank1": 1, + "rank5": 1, + "top_file": "README.md", + "n_results": 5 + }, + { + "query": "how does the clean architecture layers DI container work", + "expected": "docs/en/ARCHITECTURE.md", + "latency_ms": 495.3, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "experiments/misc_probes/e2e_results_v2.json", + "n_results": 5 + }, + { + "query": "how is the search pipeline structured BM25 RRF reranker", + "expected": "docs/en/SEARCH_PIPELINE.md", + "latency_ms": 2399.3, + "hit1": 1.0, + "hit5": 1.0, + "mrr": 1.0, + "rank1": 1, + "rank5": 1, + "top_file": "docs/en/SEARCH_PIPELINE.md", + "n_results": 1 + }, + { + "query": "graceful degradation levels llama ONNX BM25", + "expected": "docs/en/GRACEFUL_DEGRADATION.md", + "latency_ms": 589.1, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "scripts/download_model.py", + "n_results": 5 + }, + { + "query": "frequently asked questions installation setup", + "expected": "docs/en/FAQ.md", + "latency_ms": 2164.5, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "experiments/universal-engine/e-s1-polygon/fixtures/berry/package.json", + "n_results": 5 + }, + { + "query": "how to configure telemetry ETA data collection", + "expected": "docs/en/TELEMETRY.md", + "latency_ms": 460.2, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "experiments/misc_probes/e2e_results_v2.json", + "n_results": 5 + }, + { + "query": "security policy vulnerability reporting", + "expected": "docs/en/SECURITY.md", + "latency_ms": 2130.3, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "experiments/universal-engine/e-s1-polygon/fixtures/commons-lang/pom.xml", + "n_results": 5 + }, + { + "query": "Windows Zed restricted mode quirks", + "expected": "docs/en/ZED_WINDOWS_QUIRKS.md", + "latency_ms": 496.8, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "adapters/zed/zed_config.py", + "n_results": 5 + }, + { + "query": "how to contribute development PR code review", + "expected": "docs/en/CONTRIBUTING.md", + "latency_ms": 2147.0, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "docs/archive/AGENT_DIARY_2026_07.md", + "n_results": 5 + }, + { + "query": "version history changelog", + "expected": "docs/en/CHANGELOG.md", + "latency_ms": 520.7, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "scripts/bump_version.py", + "n_results": 5 + }, + { + "query": "how to install ONNX llama reranker models", + "expected": "docs/en/INSTALL_MODELS.md", + "latency_ms": 3099.7, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "src/providers/reranker/llama_install.py", + "n_results": 2 + }, + { + "query": "LM Studio reranker embedder setup", + "expected": "docs/en/LM_STUDIO_SETUP.md", + "latency_ms": 3062.2, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "src/config/settings.py", + "n_results": 5 + }, + { + "query": "system requirements Windows macOS Linux", + "expected": "docs/en/SYSTEM_REQUIREMENTS.md", + "latency_ms": 4960.9, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "tools/stale_detector/stale_config.json", + "n_results": 5 + }, + { + "query": "handoff context transfer between sessions", + "expected": "docs/en/HANDFOFF.md", + "latency_ms": 378.6, + "hit1": 0.0, + "hit5": 0.0, + "mrr": 0.0, + "rank1": 0, + "rank5": 0, + "top_file": "experiments/context_engine/results_v2.json", + "n_results": 5 + } + ] +} \ No newline at end of file diff --git a/scripts/eval_text_chunks.py b/scripts/eval_text_chunks.py new file mode 100644 index 00000000..c3eb0976 --- /dev/null +++ b/scripts/eval_text_chunks.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +eval_text_chunks.py — Текстовый RAG: Hit@1 / Hit@Gold(Top-5) / MRR +по живому индексу (без переиндексации). Прямым lookup без LLM-судей. + +Gold = markdown_section чанк из ожидаемого .md файла (любой язык). +Сравнение с E10/E11 baseline (кодовые замеры 2026-09-19). +""" +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +# ── Gold standard: вопрос → эталонный .md файл (любой язык) ── +# Для doc-запросов принимаем ru/en/zh варианты как золото, +# т.к. контент переведён. +TEXT_CASES = [ + ("how to install the mscodebase extension in Zed and what it does", + "README.md"), + ("what MCP tools are available for code search", + "README.md"), + ("search modes fast quality deep auto which to choose", + "README.md"), + ("how does the clean architecture layers DI container work", + "docs/en/ARCHITECTURE.md"), + ("how is the search pipeline structured BM25 RRF reranker", + "docs/en/SEARCH_PIPELINE.md"), + ("graceful degradation levels llama ONNX BM25", + "docs/en/GRACEFUL_DEGRADATION.md"), + ("frequently asked questions installation setup", + "docs/en/FAQ.md"), + ("how to configure telemetry ETA data collection", + "docs/en/TELEMETRY.md"), + ("security policy vulnerability reporting", + "docs/en/SECURITY.md"), + ("Windows Zed restricted mode quirks", + "docs/en/ZED_WINDOWS_QUIRKS.md"), + ("how to contribute development PR code review", + "docs/en/CONTRIBUTING.md"), + ("version history changelog", + "docs/en/CHANGELOG.md"), + ("how to install ONNX llama reranker models", + "docs/en/INSTALL_MODELS.md"), + ("LM Studio reranker embedder setup", + "docs/en/LM_STUDIO_SETUP.md"), + ("system requirements Windows macOS Linux", + "docs/en/SYSTEM_REQUIREMENTS.md"), + ("handoff context transfer between sessions", + "docs/en/HANDFOFF.md"), +] + + +def norm(p: str) -> str: + return p.replace("\\", "/").lstrip("/") + + +def is_doc_chunk(result: dict, expected_file: str) -> bool: + meta = result.get("metadata") or {} + fp = norm(str(meta.get("file", ""))) + # Принимаем любой язык: docs/ru/README.md тоже золото для README.md + return fp.endswith(norm(expected_file)) + + +def hit_at(results: list, expected_file: str, k: int) -> int: + for i, r in enumerate(results[:k]): + if is_doc_chunk(r, expected_file): + return i + 1 + return 0 + + +def mrr(results: list, expected_file: str) -> float: + pos = hit_at(results, expected_file, len(results)) + return 1.0 / pos if pos else 0.0 + + +def main() -> int: + from src.core.di_container import create_service_collection, IndexerFactoryKey + from src.core.indexing.project_indexer_registry import get_global_registry + + project = ROOT + services = create_service_collection(project) + registry = get_global_registry() + factory = services.resolve(IndexerFactoryKey) + indexer = registry.get_indexer(project, factory=factory) + searcher = indexer.searcher + rows_cnt = indexer.table.count_rows() if indexer.table else -1 + print(f"searcher={type(searcher).__name__} table_rows={rows_cnt}") + + # ── Санити ────────────────────────────────────────────── + q0 = TEXT_CASES[0][0] + res0 = searcher.search_with_mode(query=q0, mode="quality", limit=5) + r0s = res0.get("results", []) if isinstance(res0, dict) else (res0 or []) + print(f"SANITY q={q0!r} n_results={len(r0s)}") + if r0s: + m = r0s[0].get("metadata") or {} + print(f" meta keys={list(m.keys())}") + print(f" first file={m.get('file','')!r} text[:100]={r0s[0].get('text','')[:100]!r}") + + # ── Полный прогон ──────────────────────────────────────── + rows = [] + for q, exp_file in TEXT_CASES: + t0 = time.perf_counter() + try: + res = searcher.search_with_mode(query=q, mode="quality", limit=5) + results = res.get("results", []) if isinstance(res, dict) else (res or []) + dt_ms = (time.perf_counter() - t0) * 1000 + except Exception as e: + results = [] + dt_ms = -1 + print(f" ERR q={q!r}: {type(e).__name__}: {e}", file=sys.stderr) + + h1 = hit_at(results, exp_file, 1) + h5 = hit_at(results, exp_file, 5) + rr = mrr(results, exp_file) + top_file = norm((results[0].get("metadata") or {}).get("file", "")) if results else "" + rows.append({ + "query": q, + "expected": exp_file, + "latency_ms": round(dt_ms, 1), + "hit1": 1.0 if h1 else 0.0, + "hit5": 1.0 if h5 else 0.0, + "mrr": round(rr, 3), + "rank1": h1, + "rank5": h5, + "top_file": top_file, + "n_results": len(results), + }) + + # ── Отчёт ──────────────────────────────────────────────── + n = len(rows) + h1 = sum(r["hit1"] for r in rows) + h5 = sum(r["hit5"] for r in rows) + mrr_sum = sum(r["mrr"] for r in rows) + avg_ms = sum(r["latency_ms"] for r in rows if r["latency_ms"] > 0) / max( + sum(1 for r in rows if r["latency_ms"] > 0), 1 + ) + metrics = { + "n_queries": n, + "hit1_pct": round(100 * h1 / n, 1), + "hit5_pct": round(100 * h5 / n, 1), + "mrr_mean": round(mrr_sum / n, 3), + "avg_latency_ms": round(avg_ms, 0), + } + print("━" * 110) + print("## Текстовый RAG — doc-чанки (README + docs/en + docstrings)") + print(f"{'#':>2} {'hit1':>6} {'hit5':>6} {'MRR':>6} {'ms':>8} эталон → топ-файл") + for i, r in enumerate(rows, 1): + mark5 = "✅" if r["hit5"] else "❌" + print( + f"{i:>2} {r['hit1']:>6} {r['hit5']:6} {r['mrr']:6} {r['latency_ms']:8.0f} " + f"{mark5} {r['expected']} → {r['top_file']}" + ) + print( + f"hit@1={h1}/{n} ({metrics['hit1_pct']}%) " + f"hit@5={h5}/{n} ({metrics['hit5_pct']}%) " + f"MRR={metrics['mrr_mean']} avg_ms={metrics['avg_latency_ms']:.0f}" + ) + + # ── Сравнение с прошлым кодовым baseline ───────────────── + print() + print("━" * 110) + print("## Сравнение: Текстовый RAG vs Кодовый (E10/E11 baseline, 2026-09-19)") + print(f"{'Метрика':<20} | {'Текстовый RAG':>14} | {'Прошлый код (E10/E11)':>22} | {'Дельта':>8}") + print("-" * 110) + print( + f"{'hit@1':<20} | {metrics['hit1_pct']:>13}% | {'0% / 20% (fast/quality)':>22} | {'—':>8}" + ) + print( + f"{'hit@5 (Gold Top-K)':<20} | {metrics['hit5_pct']:>13}% | {'50% / 40% (fast/quality)':>22} | {'—':>8}" + ) + print( + f"{'MRR':<20} | {metrics['mrr_mean']:>13} | {'0.200 (E11 baseline)':>22} | {'—':>8}" + ) + print("-" * 110) + print("Примечание: E10/E11 — по .py файлам (код), текстовый — по .md-чанкам.") + print("Gold-стандарты разные (файл vs секция), прямое сравнение условное.") + print("Текстовый RAG ниже кода — doc-чанки менее дискриминативны в эмбеддинг-пространстве.") + + out = ROOT / "experiments" / "text_chunk_eval.json" + out.write_text(json.dumps({"metrics": metrics, "rows": rows}, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\n✅ Результаты → {out}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + import traceback + traceback.print_exc() + sys.exit(1) \ No newline at end of file