diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3d983a37..a126c4ff 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -16,10 +16,20 @@ MSCodeBase pre-commit hook — автоматическая проверка п 8. check_known_issues — §4.8 R4: размер ≤ 300 строк + архивация старых записей """ +import os import subprocess import sys from pathlib import Path +# Per-gate budget. 900s is the historical value (documented flakiness at 300s); +# overridable only to make the timeout path testable without a 15-minute wait. +GATE_TIMEOUT = int(os.environ.get("MSCB_PRECOMMIT_GATE_TIMEOUT", "900")) + +# Indirection so a test can stub THIS module's spawn without patching the +# global `subprocess` module — a global patch leaks into every other test in +# the same worker (CI: 14 unrelated failures). +Popen = subprocess.Popen + # §9 п.9 (ENCODING SAFETY): при выводе emoji-строк из stdout скриптов # (например, «📊 Итог: 20 ✅ / 1 ❌») в cp1251-консоль падает @@ -42,7 +52,7 @@ def find_project_root() -> Path | None: return None -def run_script(script_path: str, label: str) -> bool: +def run_script(script_path: str, label: str, extra_args: list[str] | None = None) -> bool: """Запускает скрипт и возвращает True если успешно.""" project_root = find_project_root() if project_root is None: @@ -54,10 +64,13 @@ def run_script(script_path: str, label: str) -> bool: print(f" ⏭️ {label}: скрипт не найден ({script})") return True + # Печатаем ДО запуска: гейт с большим бюджетом (verify_diary тянет полный + # pytest) иначе выглядит как зависание — и его обходят через --no-verify. + print(f" ⏳ {label}: выполняется (бюджет {GATE_TIMEOUT}s)…", flush=True) # §5.16: Popen + communicate (не capture_output) — защита от pipe-deadlock # в фоновых потоках; encoding="utf-8" — декодирование stdout в utf-8. - proc = subprocess.Popen( - [sys.executable, str(script)], + proc = Popen( + [sys.executable, str(script), *(extra_args or [])], cwd=str(project_root), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, @@ -69,22 +82,43 @@ def run_script(script_path: str, label: str) -> bool: # нагрузкой) — кап 120s давал флаки TimeoutExpired на коммитах (2026-08-08); # 300→900 (2026-08-24): сюита выросла (live-sync + predict-наборы), 300s # начал флакать при параллельной нагрузке. - stdout, _ = proc.communicate(timeout=900) + try: + stdout, _ = proc.communicate(timeout=GATE_TIMEOUT) + except subprocess.TimeoutExpired: + # Раньше это было необработанное исключение: хук умирал с трейсбеком и + # БЕЗ вердикта — его путали с «гейт сломан» и обходили через --no-verify. + # Таймаут — это вердикт: называем гейт, который не ответил. + proc.kill() + proc.communicate() + print(f" ❌ {label}: TIMEOUT >{GATE_TIMEOUT}s — гейт не ответил, вердикта нет", + flush=True) + return False if proc.returncode != 0: - print(f" ❌ {label}: exit {proc.returncode}") + print(f" ❌ {label}: exit {proc.returncode}", flush=True) if stdout: for line in stdout.splitlines()[-10:]: print(f" {line}") return False - print(f" ✅ {label}: OK") + print(f" ✅ {label}: OK", flush=True) return True def main(): - print("🔍 MSCodeBase pre-commit checks:") + print("🔍 MSCodeBase pre-commit checks:", flush=True) all_ok = True - all_ok &= run_script("scripts/verify_diary.py", "verify_diary") + # gate-zero тянет полный `pytest tests/`. Измерено 2026-10-03 на этой машине: + # 36% за 600s, то есть ~1670s — бюджет гейта (900s) не выдерживает НИ ОДНОГО + # коммита. Раньше это выглядело как «хук висит», и коммиты уходили в + # --no-verify. Поэтому fast-режим: полный прогон остаётся за CI, а локальный + # коммит говорит ВСЛУХ, что именно пропущено. + fast = os.environ.get("MSCB_PRECOMMIT_FAST") == "1" + diary_args = ["--skip-gate-zero"] if fast else None + if fast: + print(" ⚠️ FAST: полный `pytest tests/` (gate-zero) ПРОПУЩЕН — " + "покрытие этого коммита обязана взять CI.", flush=True) + + all_ok &= run_script("scripts/verify_diary.py", "verify_diary", diary_args) all_ok &= run_script("scripts/stale_detector.py", "stale_detector") all_ok &= run_script("scripts/check_tool_names.py", "check_tool_names") all_ok &= run_script("scripts/negative_controls_runner.py", "negative_controls") @@ -97,9 +131,9 @@ def main(): all_ok &= run_script("scripts/ruff_gate.py", "ruff_gate") if not all_ok: - print("\n❌ Pre-commit checks FAILED. Исправьте ошибки перед коммитом.") + print("\n❌ Pre-commit checks FAILED. Исправьте ошибки перед коммитом.", flush=True) sys.exit(1) - print("\n✅ All pre-commit checks passed.") + print("\n✅ All pre-commit checks passed.", flush=True) sys.exit(0) diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index c261c8e1..76285e64 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1,4 +1,4 @@ - + ## [2026-10-03] P-020 / P-021 — гонка между тестами, непригодный гейт, реестр вне репозитория - **P-020 (Verified):** `test_negative_controls_runner.py` доказывал digest-pinning **правкой настоящей фикстуры** `dead_guard.py` + restore в `finally`. Под `-n auto` соседний воркер читал digest в окне между записью и restore → `unproven=1` на CI при зелёном локально. **Все ручные проверки проходили, потому что проверяли байты, а триггер — параллелизм.** Фикс: scratch-копия + контроль `PROVEN` до мутации. @@ -866,3 +866,26 @@ chunk_index -(20_000_000+line), graph_score=0.4 (ниже функций 1.0). E **Конкуренция агентов (важно):** параллельная сессия **активно пишет в это же рабочее дерево** — `tools/verification/verify_public_claims.py` записан 03.10 **08:38:16** (проверено `LastWriteTime`). `tools/knowledge/` тоже появлялся/исчезал в нём. **Не коммитить, пока это не согласовано:** мой `git status` загрязнён чужими файлами. Их файл реализует §19.11/T11 «каждое опубликованное число + команда, которая перевыводит его сегодня» = **мой E-2 уже в работе у них** → следующий агент не должен дублировать. **Открыто владельцу:** (1) `tools/verification/` неслитая ветка vs активная сессия; (2) чужое в дереве (`experiments/prompt_robustness/**` изменён, `results/` untracked); (3) `KNOWN_ISSUES.md` перевалил 300 строк → нужна ротация; (4) ни одного коммита не сделано. **Открыто владельцу:** (1) запускать ли P1/E-1/E-5; (2) коммитить ли `frozen/`+отчёт — в дереве лежит **чужое** (`experiments/prompt_robustness/` изменён, `results/` untracked), не моё, не откатывал (§19.9); (3) разрешение на запись в реестр `P-###` вне репозитория. + +## [2026-10-03] Гейт отвечал rc=2 на любой вход + мёртвое число A10 без метки (Fixed) +**Status:** ⚠️ Частично. Код и тесты зелёные; полный прогон с нуля не делался (нет venv в worktree). +**Root Cause (двойной, независимый):** (1) `tools/verification/gates.py:322` звал `fn(**kw)` без отбора по сигнатуре гейта; MCP-тул `gate` шлёт superset полей всем четырём гейтам → `TypeError` → верхний обработчик печатал traceback и exit 2, то есть «гейт недоступен» вместо вердикта. Гипотеза «PATH/GitBash, как в AGENT_DIARY.md:451» — **REFUTED**: в `tools/verification/` нет резолвера bash. (2) T-17 висел открытым: число `orphan 30s→120ms` публиковалось в 3 файлах с меткой «подтверждено», хотя `claims_audit/RESULTS.md` A10 = NOT REPRODUCED (путь удалён по дизайну, R3TF). +**Fix:** `run()` выбирает параметры своего гейта по `inspect.signature`, отброшенные ключи возвращает как `ignored_kwargs`, а отсутствующий обязательный ключ отвечает `UNKNOWN`/rc=2 с перечнем `required_kwargs` вместо трайсбека. A10 помечен `SUPERSEDED` (`EXPERIMENTS_LOG.md:1320`, `experiments/README.md:27`, `experiments/lock_zombie/README.md:9`) со sha `3798d6a9` и ссылкой на R3TF `7974d981`. +**Guard:** `tests/test_gates_kwarg_contract.py` (10 тестов). **Negative control:** на исходном коде падают 4 из них, на исправленном 10/10 — тесты умеют падать. Red team: опечатка в ключе → `UNKNOWN`, а не тихий ALLOW; чужой гейт → `UNKNOWN`; отброшенный ключ по построению не может изменить вердикт, поэтому понижения нет — решение записано в коде. +**Self-caught:** первая запись в дневник переписала файл целиком (CRLF→LF, +928/−872 в diff) — поймано на `git diff --stat` до коммита, откачено через `git checkout --`, перезаписано с CRLF. +**Не сделано (честно):** портфолио (`exp-10`, репозиторий `MSPortfolio`) публикует то же число как действующую практику — отсюда не правится; полный `pytest tests/` и `verify_clean_state.sh` не гонял (в worktree нет venv, использована venv основного дерева). Реестр ловушек вне рабочего каталога — P-16 предложен в `tools/knowledge/PENDING_LEDGER.md`, не внесён. +## [2026-10-03] Коммит с `--no-verify`: обоснование (Fixed, с исключением) +**Решение владельца (цитата):** «(б) сначала короткая диагностика hook, при невозможности быстрого исправления — (а) `--no-verify` с обязательным обоснованием». +**Диагностика (что успел исключить, 1 команда):** `where bash` → единственный `bash` в PATH — `C:\Windows\System32\bash.exe`, то есть WSL-шим; при прямом вызове печатает баннер «WSL не установлен/требует wsl.exe --update» и **возвращается**, а не виснет. GitBash `C:\Program Files\Git\bin\bash.exe` существует и отвечает. **Значит блокер не в bash-резолвере** (и это опять опровергает версию из P-450/AGENT_DIARY:451 как достаточное объяснение). +**Что осталось невыясненным (честно):** `.githooks/pre-commit` при запуске `python -u` не печатает **ни байта** и не завершается за 100 с; после принудительного завершения остаются висящие `python.exe` (6 шт.). Локализовать, какой из 10 гейтов виснет, не успел: дальше требовалось бы трогать инфраструктуру хуков и убивать чужие процессы (в т.ч. параллельной сессии — §21.2 запрещает). +**Почему это не моя правка:** (1) коммит не проходит и у параллельной сессии — их же дневник, запись 03.10: «ни одного коммита не сделано»; (2) хук не печатает ни строки, то есть падает до вывода, а мои файлы — `tools/verification/gates.py` + новый тест; (3) `gates.py` хуком не вызывается (единственный импорт — мой тест). +**Чем заменена проверка:** `tests/test_gates_kwarg_contract.py` — 10 passed; **negative control:** 4 из 10 падают на коде до правки; `--selftest` PASSED; `tools/verification/heldout_cli_contract.py` — «every documented invocation works»; ruff чист; `scripts/check_parallel_sessions.py` — blocking=0. То есть пропущены именно 10 гейтов хука, а не тесты. +**Хеши закоммиченного:** `tools/verification/gates.py` blob `53bb7cfc15be27ebbb49bf0a65057bb8dfa2ec64`, `tests/test_gates_kwarg_contract.py` blob `b135dda9ba92b97827cb300ecabb7b8d517ef0cf`. +**Открыто:** починка самого pre-commit hook — отдельная задача; считать ветку готовой к мержу без прогонa хука нельзя. +## [2026-10-03] Хук, ruff-гейт и парсер вердикта: коммит снова возможен (Fixed) +**Status:** ✅ Код зелёный, 10/10 гейтов ✅ (exit 0, 12 с). Не проверено: полный `pytest tests/` без fast-режима (~28 мин) и прогон с нуля. +**Root Cause (три независимых, каждый маскировал следующий):** (1) `.githooks/pre-commit` печатал вердикт только после гейта, а `communicate(timeout=900)` бросал необработанный `TimeoutExpired` → медленный гейт = трейсбек без вердикта = «хук сломан»; (2) `verify_diary.py` кап 900s против измеренных ~1670s на полный `pytest tests/` (36% за 600s) → коммит невозможен в принципе; (3) `ruff_gate` звал `python -m ruff`, а в venv пакет ruff 0.15.22 **без `__main__`** → выход 1 с пустым выводом, вечно красный и немой гейт. +**Fix:** прогресс-строка до запуска + таймаут как вердикт с именем гейта + `MSCB_PRECOMMIT_GATE_TIMEOUT`/`MSCB_GATE_ZERO_TIMEOUT`/`MSCB_PRECOMMIT_FAST`; `ruff_cmd()` резолвит живой ruff через `--version`-пробу; пустой вывод при ненулевом коде называется вслух как сломанный запуск, а не lint. Отдельно: `_parse_verdict` судьи вынесен в `scripts/judge_verdict.py`, обе копии (2 из 2) импортируют её — багованная `reconstruct_judge_cot.py` была без тестов. +**Guard:** `tests/test_precommit_hook_contract.py` (7), `tests/test_ruff_gate.py (+2 кейса), `tests/test_reconstruct_judge_verdict.py` (11). **Negative control:** pre-fix хук грузится из git и обязан **не уметь** ответить на таймаут; старый парсер судьи обязаны падать 7 тестов (проверено: 7 failed → 11 passed). +**Self-caught (третье за сессию, все три — моя измерительная ошибка, не дефект системы):** (а) «хук висит» = мой диагностический окно короче первого гейта; (б) «verify_diary не печатает» = буферизация `| Select-Object`; (в) «нет вывода» = то же. Общий класс: инструмент, измеряющий инструмент, молчит так же, как инструмент. Правило: прежде чем назвать что-то сломанным, проверить, что моя труба не съела вывод. +**Не сделано:** причина 3-кратного замедления сюиты не расследована; fast-режим hook — осознанный компромисс и может стать привычкой; полный прогон оставлен CI. \ No newline at end of file diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 7e8cc8b6..78dc7bf4 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1317,7 +1317,8 @@ zombie_probe: holder pid=12684 alive=False -> STALE (после выхода с ``` **Before (та же сессия, старый код):** контеншн = 30.0s → RuntimeError (замерено в Exp B); orphan-кейс не детектился (30s → RuntimeError); free ~9ms; stale ~33ms. -**Вердикт:** ПОДТВЕРЖДЕНА. orphan: 30000ms → 120ms (terminate+steal, вкл. TerminateProcess реального python + ретрай-unlink); healthy: 30000ms RuntimeError → 1512ms LockBusyError (wait=1.5 в бенче; прод-дефолт 8.0s); free/stale без изменений (7/31ms). Дополнительно verified: после TerminateProcess реального python'а venvlauncher-обёртка умирает сама (никаких висящих процессов), lock перезаписывается нашим PID. +**Вердикт (2026-08-08):** ПОДТВЕРЖДЕНА. **Статус публикации (2026-10-03):** `SUPERSEDED` — измерено на `3798d6a9`, а путь достижимости удалён по дизайну: ORPHAN-ветка вырезана из прод-MCP по R3TF (`7974d981`, `src/core/indexing/database_lock.py:81-84`). Сегодняшней командой не воспроизводится (`experiments/claims_audit/RESULTS.md` A10 = NOT REPRODUCED), поэтому число остаётся записью прогона, а не текущим фактом. Не переписывать. +orphan: 30000ms → 120ms (terminate+steal, вкл. TerminateProcess реального python + ретрай-unlink); healthy: 30000ms RuntimeError → 1512ms LockBusyError (wait=1.5 в бенче; прод-дефолт 8.0s); free/stale без изменений (7/31ms). Дополнительно verified: после TerminateProcess реального python'а venvlauncher-обёртка умирает сама (никаких висящих процессов), lock перезаписывается нашим PID. **Урок:** (1) TerminateProcess синхронный, но файловый дескриптор lock'а умирающего процесса даёт PermissionError на unlink → нужен _unlink_with_retry (иначе краш в кейсе «только что убитый holder»); (2) venvlauncher: lock пишет РЕАЛЬНЫЙ python (os.getpid() внутри скрипта), terminate по pid из lock убивает именно держателя, обёртка умирает следом — прод-механизм работоспособен. --- diff --git a/experiments/README.md b/experiments/README.md index cff653be..92d18e33 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -24,7 +24,7 @@ | Concurrency | Гонки при замене примитива (§2.3) | [`concurrency/`](concurrency/) | ✅ 2026-08-11 | «0 errors» ≠ верные данные — стресс-тест на корректность | | Evalmut | Mutation testing для eval-градеров | [`evalmut/`](evalmut/) | ✅ 2026-08-14 | validate_scores: mutation score 8% → 100% (P-006) | | Root Cause Eval | Аудит root-cause предсказаний | [`root_cause_eval/`](root_cause_eval/) | 🟡 2026-07-22 | датасет инцидентов + gold standard | -| Lock-zombie | PID-lock self-healing (WS9) | [`lock_zombie/`](lock_zombie/) | ✅ 2026-08-08 | orphan 30s→120ms | +| Lock-zombie | PID-lock self-healing (WS9) | [`lock_zombie/`](lock_zombie/) | ✅ 2026-08-08 | orphan 30s→120ms — ⚠️ `SUPERSEDED`: измерено на `3798d6a9`, путь удалён по дизайну (R3TF) | | Late Enrichment | Late code chunking (WS3) | [`late_enrichment/`](late_enrichment/) | 🟡 исследование | imports=0.0 — находка, KNOWN_ISSUES | | Benchmark D | Контекстный бенчмарк (12 задач L3-L5) | [`benchmark2/`](benchmark2/) | ✅ 2026-08-08 | runner.py + tasks.jsonl + README | | Probes | Одноразовые пробы (без отчётов) | [`misc_probes/`](misc_probes/) | — | см. README папки | diff --git a/experiments/claims_audit/RESULTS.md b/experiments/claims_audit/RESULTS.md index a93d8f83..223c1e9f 100644 --- a/experiments/claims_audit/RESULTS.md +++ b/experiments/claims_audit/RESULTS.md @@ -50,6 +50,18 @@ LockBusyError: PID lock still held by alive pid=548 after 8.0s подано как действующая практика — **это устаревшее утверждение, а не ошибка измерения**. Рекомендация: `SUPERSEDED` с ссылкой на R3TF, а не переписывать. +**Правка применена 2026-10-03** (закрывает T-17): + +| Файл:строка | Что стоит | +|---|---| +| `EXPERIMENTS_LOG.md:1320` | вердикт помечен `SUPERSEDED` + sha `3798d6a9` + ссылка на R3TF `7974d981` | +| `experiments/README.md:27` | та же метка в таблице экспериментов | +| `experiments/lock_zombie/README.md:9` | та же метка; сырой вывод прогона не тронут | + +**Осталось открытым (вне этого репо):** число публикуется также в портфолио (`exp-10`, +`MSPortfolio`) как действующая практика. Правка отсюда невозможна — портфолио живёт +в другом репозитории и отдаётся из задеплоенного снапшота. Статус: `OPEN`, не «сделано». + ### A11 — сканер вакуумных тестов сегодня молчит `exp_vacuous_scan.py:20` жёстко зашит на `experiments/tests` (каталога нет). Сегодня: diff --git a/experiments/lock_zombie/README.md b/experiments/lock_zombie/README.md index 7d5edce8..021f5110 100644 --- a/experiments/lock_zombie/README.md +++ b/experiments/lock_zombie/README.md @@ -6,7 +6,7 @@ **Статус:** ✅ Fixed 2026-08-08 (код+тесты; 1022 passed, ruff чист). **Артефакты:** -- `benchmark_selfhealing.py` — бенчмарк: orphan 30s→**120ms**, healthy 30s→1.5s soft, free/stale без изменений. +- `benchmark_selfhealing.py` — бенчмарк: orphan 30s→**120ms**, healthy 30s→1.5s soft, free/stale без изменений. ⚠️ **Статус числа (2026-10-03):** `SUPERSEDED` — измерено на `3798d6a9`; ORPHAN-ветка вырезана из прод-MCP по R3TF, поэтому прогон больше недостижим (`experiments/claims_audit/RESULTS.md` A10 = NOT REPRODUCED). Оставлено как запись прогона, не переписано. - `orphan_holder.py` / `spawn_orphan.py` / `zombie_probe.py` / `check_signals.py` / `probe_terminate.py` — пробы для live-теста Windows. - Фикс: `src/core/database_lock.py` — классификация holder'а (DEAD/HEALTHY/ORPHAN/AMBIGUOUS), TOCTOU-guard, retry-unlink; тесты `tests/test_database_lock_selfhealing.py` (+17). diff --git a/scripts/f5_judged_run.py b/scripts/f5_judged_run.py index 281e96bc..5e4ce504 100644 --- a/scripts/f5_judged_run.py +++ b/scripts/f5_judged_run.py @@ -245,24 +245,13 @@ def _arm_context(arm: str, results: list[dict], gold: str) -> str: raise SystemExit(f"unknown arm {arm}") -def _parse_verdict(text: str) -> str: - """Explicit-final contract (validated on 1014 judge sessions, 2026-09-27). - - A reasoning judge may hesitate mid-text ("looks incorrect ... actually - correct"). The FINAL decision is the LAST one stated: last JSON - "verdict" match wins; otherwise the last verdict-word mention wins. - Measured on judged_cot_backfill.json: last==final 842/1014 vs - first-match 820/1014; on 61 flip sessions last==final 53/61 (87%) - vs first==final 34/61 (56%). No explicit "final verdict:" marker - exists in the wild (0/61), so last-match IS the contract. - """ - matches = re.findall(r'"verdict"\s*:\s*"?(correct|incorrect|uncertain)"?', text or "", re.I) - if matches: - return matches[-1].lower() - hits = re.findall(r"\b(correct|incorrect|uncertain)\b", text or "", re.I) - if hits: - return hits[-1].lower() - return "uncertain" +try: # both invocation shapes: `python scripts/x.py` and import-by-path in tests + from scripts.judge_verdict import parse_verdict as _parse_verdict +except ImportError: # pragma: no cover + from judge_verdict import parse_verdict as _parse_verdict +# The explicit-final contract and its 1014-session validation now live in +# scripts/judge_verdict.py, shared with reconstruct_judge_cot.py, which used to +# carry a first-match copy of this parser. def main() -> int: diff --git a/scripts/judge_verdict.py b/scripts/judge_verdict.py new file mode 100644 index 00000000..61379b81 --- /dev/null +++ b/scripts/judge_verdict.py @@ -0,0 +1,42 @@ +"""One implementation of the judge's verdict contract. + +Why this file exists. Two scripts parsed the same judge's answer independently: + + - `scripts/f5_judged_run.py` took the LAST verdict word (fixed 2026-09-27); + - `scripts/reconstruct_judge_cot.py` took the FIRST, and its fallback tested + substrings with "incorrect" before "correct". So a self-correcting judge + ("looks incorrect ... actually correct, final answer: correct") was recorded + INVERTED, silently: every counter stayed green and `uncertain` was + unreachable whenever any verdict word appeared. + +The contract (measured on judged_cot_backfill.json, 1014 judge sessions): +the FINAL decision is the LAST one stated. Last JSON "verdict" match wins; +otherwise the last verdict-word mention wins. last==final 842/1014 vs +first-match 820/1014; on the 61 flip sessions last==final 53/61 (87%) vs +first==final 34/61 (56%). No explicit "final verdict:" marker exists in the +wild (0/61), so last-match IS the contract. + +Imported by both consumers so that a third copy cannot appear. +""" +from __future__ import annotations + +import re + +VERDICT_WORDS = ("correct", "incorrect", "uncertain") +VERDICT_ALT = "|".join(VERDICT_WORDS) + +# \b boundaries matter: "incorrect" contains "correct", and "correctly" is not a +# verdict. The old substring fallback could not tell them apart. +VERDICT_RE = re.compile(rf"\b({VERDICT_ALT})\b", re.I) +JSON_VERDICT_RE = re.compile(rf'"verdict"\s*:\s*"?({VERDICT_ALT})"?', re.I) + + +def parse_verdict(text: str) -> str: + """Return 'correct' | 'incorrect' | 'uncertain' from a judge answer.""" + matches = JSON_VERDICT_RE.findall(text or "") + if matches: + return matches[-1].lower() + hits = VERDICT_RE.findall(text or "") + if hits: + return hits[-1].lower() + return "uncertain" \ No newline at end of file diff --git a/scripts/reconstruct_judge_cot.py b/scripts/reconstruct_judge_cot.py index c65ea36e..ce96e44e 100644 --- a/scripts/reconstruct_judge_cot.py +++ b/scripts/reconstruct_judge_cot.py @@ -30,23 +30,19 @@ WORKDIR = "D:/Project/MSCodeBase/experiments/4A_unit_of_return/results/f5judged/work" FROZEN = ROOT / "experiments" / "4A_unit_of_return" / "frozen" / "f5" / "queries.jsonl" -VERDICT_RE = re.compile(r"\b(correct|incorrect|uncertain)\b", re.I) -JSON_VERDICT_RE = re.compile(r'"verdict"\s*:\s*"?(correct|incorrect|uncertain)"?', re.I) +try: # both invocation shapes: `python scripts/x.py` and import-by-path in tests + from scripts.judge_verdict import VERDICT_RE, parse_verdict as _parse_verdict +except ImportError: # pragma: no cover + from judge_verdict import VERDICT_RE, parse_verdict as _parse_verdict def _norm(s: str) -> str: return " ".join((s or "").split()) -def _parse_verdict(text: str) -> str: - m = JSON_VERDICT_RE.search(text or "") - if m: - return m.group(1).lower() - low = (text or "").lower() - for v in ("incorrect", "correct", "uncertain"): - if v in low: - return v - return "uncertain" +# Verdict parsing used to live here as a FIRST-match substring scan, which +# inverted self-correcting judges. The contract and its measurement now live in +# scripts/judge_verdict.py, shared with f5_judged_run.py. def _split_prompt(user_text: str) -> tuple[str, str]: diff --git a/scripts/ruff_gate.py b/scripts/ruff_gate.py index 567ef360..af74c628 100644 --- a/scripts/ruff_gate.py +++ b/scripts/ruff_gate.py @@ -14,6 +14,8 @@ from __future__ import annotations +import os +import shutil import subprocess import sys from pathlib import Path @@ -26,20 +28,59 @@ pass -def main() -> int: - project_root = Path(__file__).resolve().parent.parent +def ruff_cmd() -> list[str] | None: + """Resolve a ruff that actually runs. + + 2026-10-03: `python -m ruff` in this venv printed NOTHING and exited 1 — the + installed `ruff` package has no `__main__`. The gate called exactly that, so + it was permanently red and mute: it looked like "lint is broken", and the + only visible effect was that commits could not be made. + Order matters: availability is decided by importability first (no + subprocess at all), because a subprocess probe here would fire inside other + tests that stub `subprocess.Popen`. The dead module form is only used when + no console script exists, and it is then proven with `--version`. + """ try: import ruff # noqa: F401 except ImportError: - print(" ⚠️ ruff не установлен — пропуск (CI всё равно проверяет)") + return None + + exe = shutil.which("ruff") + if not exe: + # console scripts live next to the interpreter inside a venv + cand = Path(sys.executable).parent / ("ruff.exe" if os.name == "nt" else "ruff") + if cand.exists(): + exe = str(cand) + if exe: + return [exe, "check"] + + # No console script: the module form is the last resort, but only if it can + # actually report a version (this venv's copy cannot). + try: + probe = subprocess.run([sys.executable, "-m", "ruff", "--version"], + capture_output=True, encoding="utf-8", + errors="replace", timeout=60) + if probe.returncode == 0 and "ruff" in (probe.stdout or "").lower(): + return [sys.executable, "-m", "ruff", "check"] + except (OSError, subprocess.SubprocessError): + pass + return None + + +def main() -> int: + project_root = Path(__file__).resolve().parent.parent + + cmd = ruff_cmd() + if cmd is None: + print(" ⚠️ ruff не найден ни одним из способов — пропуск (CI всё равно проверяет)") return 0 proc = subprocess.Popen( - [sys.executable, "-m", "ruff", "check", "src/", "tests/"], + [*cmd, "src/", "tests/"], cwd=str(project_root), stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=subprocess.STDOUT, encoding="utf-8", errors="replace", creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), @@ -52,10 +93,15 @@ def main() -> int: return 1 if proc.returncode != 0: - print(f" ❌ ruff: exit {proc.returncode}") - if stdout: + print(f" ❌ ruff: exit {proc.returncode} ({' '.join(cmd)})") + if stdout and stdout.strip(): for line in stdout.splitlines()[-15:]: print(f" {line}") + else: + # A tool that fails without saying anything is not a lint verdict. + print(" ПУСТОЙ вывод ruff при ненулевом коде — это не lint-ошибка, " + "а сломанный запуск инструмента. Проверьте, что ruff вообще " + "исполняется: `ruff --version`.") return 1 print(" ✅ ruff: OK") return 0 diff --git a/scripts/verify_diary.py b/scripts/verify_diary.py index 7cd622e6..d07de335 100644 --- a/scripts/verify_diary.py +++ b/scripts/verify_diary.py @@ -470,14 +470,24 @@ def gate_zero_full_suite() -> Tuple[bool, str]: # 120s кап флаки при нагрузке (pytest ~108-130s) — 300s запас (2026-08-08); # 300→900 (2026-08-24): сюита выросла (1499+ live-sync/predict-наборы), # даже в CI clean-state pytest идёт ~171s — 300s флакал при параллельной нагрузке. - stdout, _ = proc.communicate(timeout=900) + # 2026-10-03: на этой машине полный прогон НЕ укладывается в 900s — измерено + # 36% за 600s, то есть ~1670s. Кап стал гарантированным таймаутом, из-за чего + # коммит нельзя было сделать ни через хук, ни честно. Бюджет сделан + # настраиваемым и печатается вместе с вердиктом; значение по умолчанию + # прежнее, чтобы CI-значение не подменялось молча. + budget = int(os.environ.get("MSCB_GATE_ZERO_TIMEOUT", "900")) + stdout, _ = proc.communicate(timeout=budget) output = stdout.decode("utf-8", errors="replace").strip() # Извлекаем итоговую строку lines = [l for l in output.split("\n") if "passed" in l or "failed" in l] summary = lines[-1] if lines else output[-200:] return proc.returncode == 0, summary except subprocess.TimeoutExpired: - return False, "TIMEOUT: pytest tests/ > 900s" + return False, ( + f"TIMEOUT: pytest tests/ > {budget}s — это не провал тестов, это нехватка бюджета. " + f"Либо поднимите MSCB_GATE_ZERO_TIMEOUT (измерено: нужно ~1700s), " + f"либо запустите с --skip-gate-zero и оставьте gate-zero за CI." + ) except Exception as e: return False, f"ERROR: {e}" diff --git a/tests/test_gates_kwarg_contract.py b/tests/test_gates_kwarg_contract.py new file mode 100644 index 00000000..49dc6500 --- /dev/null +++ b/tests/test_gates_kwarg_contract.py @@ -0,0 +1,94 @@ +"""Contract of the gates.py CLI: a superset of kwargs must reach a verdict. + +Reported defect (2026-10-03): the `gate` tool returned `GATE UNAVAILABLE (rc=2)` +for every input. Root cause: `run()` called `fn(**kw)` without selecting the +gate's own parameters, so the superset that one-tool-four-gates makes the caller +send naturally raised TypeError, which the top-level handler reported as an +unavailable gate instead of a verdict. + +These tests pin the fixed behaviour AND its failure mode: a key the caller +meant for this gate but misspelled must NOT become a silent pass. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +GATES = ROOT / "tools" / "verification" / "gates.py" + +sys.path.insert(0, str(ROOT)) +from tools.verification.gates import ALLOW, BLOCK, UNKNOWN, run # noqa: E402 + + +def _cli(gate: str, payload: dict) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-B", str(GATES), "--gate", gate, "--input", json.dumps(payload)], + capture_output=True, text=True, cwd=ROOT, timeout=120, + ) + + +def test_superset_kwargs_reach_a_verdict_instead_of_a_traceback(): + proc = _cli("population", {"population": 40, "computed_rate": 0.075, + "claim": "belongs to another gate", + "verdict": "CONFIRMED"}) + assert "Traceback" not in proc.stderr + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["verdict"] == ALLOW + assert out["ignored_kwargs"] == ["claim", "verdict"] + + +def test_dropped_kwargs_are_named_not_silently_swallowed(): + res, rc = run("population", population=1143, computed_rate=0.0, evidence="x") + assert res["verdict"] == ALLOW and rc == 0 + assert res["ignored_kwargs"] == ["evidence"] + + +def test_misspelled_key_does_not_become_a_silent_pass(): + """The dangerous half of filtering: swallow a typo and return ALLOW.""" + res, rc = run("population", populaton=40, computed_rate=0.075) + assert res["verdict"] == UNKNOWN and rc == 2 + assert "populaton" in res["ignored_kwargs"] + # the typo is caught as a MISSING required kwarg, which is louder than the + # gate's own "not supplied" path and names what the caller must supply + assert "population" in res["required_kwargs"] + assert "unusable input" in res["why"] + + +def test_exact_kwargs_carry_no_ignored_field(): + res, _ = run("population", population=0, computed_rate=0.0, label="vacuous scan") + assert res["verdict"] == BLOCK + assert "ignored_kwargs" not in res + + +def test_missing_required_kwarg_is_reported_not_raised(): + res, rc = run("population", population=40) # computed_rate absent + assert res["verdict"] == UNKNOWN and rc == 2 + assert "computed_rate" in res["required_kwargs"] + assert "unusable input" in res["why"] + + +@pytest.mark.parametrize("gate,payload", [ + ("population", {"population": 40, "computed_rate": 0.075}), + ("referent", {"claim": "valid 10/11 per EXPERIMENTS_LOG.md:537"}), + ("generalization", {"verdict": "CONFIRMED", "evidence": "one article only"}), + ("control", {"experiment": "e", "negative_control_shown_failing": True}), +]) +def test_every_gate_is_reachable_through_the_cli(gate, payload): + proc = _cli(gate, payload) + assert "Traceback" not in proc.stderr + out = json.loads(proc.stdout) + assert out["verdict"] in {ALLOW, BLOCK, UNKNOWN} + assert proc.returncode == {"ALLOW": 0, "BLOCK": 1, UNKNOWN: 2}[out["verdict"]] + + +def test_selftest_still_passes(): + proc = subprocess.run([sys.executable, "-B", str(GATES), "--selftest"], + capture_output=True, text=True, cwd=ROOT, timeout=180) + assert proc.returncode == 0, proc.stdout[-2000:] + assert "SELFTEST PASSED" in proc.stdout diff --git a/tests/test_precommit_hook_contract.py b/tests/test_precommit_hook_contract.py new file mode 100644 index 00000000..98784680 --- /dev/null +++ b/tests/test_precommit_hook_contract.py @@ -0,0 +1,144 @@ +"""The pre-commit hook must return a verdict for every gate, including a timeout. + +Observed 2026-10-03: the hook produced no output at all and had to be bypassed +with `--no-verify`. Two causes, both fixed here: + 1. nothing was printed before a gate ran, so a slow gate looked like a hang; + 2. `proc.communicate(timeout=...)` raised an UNHANDLED TimeoutExpired, so a + slow gate killed the hook with a traceback and no verdict — which reads + exactly like "the gate is broken". + +The negative control at the bottom runs the pre-fix hook from git and asserts it +raises instead of answering. If that assertion ever stops failing, the test has +stopped testing the fix. +""" +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +HOOK = ROOT / ".githooks" / "pre-commit" + + +def _load_hook(path: Path, timeout: int | None, name: str): + if timeout is None: + os.environ.pop("MSCB_PRECOMMIT_GATE_TIMEOUT", None) + else: + os.environ["MSCB_PRECOMMIT_GATE_TIMEOUT"] = str(timeout) + spec = importlib.util.spec_from_loader(name, importlib.machinery.SourceFileLoader(name, str(path))) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _gate(tmp_path: Path, body: str) -> str: + p = tmp_path / "gate.py" + p.write_text(body, encoding="utf-8") + return str(p) # absolute: `project_root / absolute` == absolute + + +def test_gate_that_passes(tmp_path): + mod = _load_hook(HOOK, 60, "hook_pass") + assert mod.run_script(_gate(tmp_path, "print('ok')"), "g") is True + + +def test_gate_that_fails(tmp_path): + mod = _load_hook(HOOK, 60, "hook_fail") + assert mod.run_script(_gate(tmp_path, "raise SystemExit(1)"), "g") is False + + +def test_missing_gate_is_skipped_not_failed(tmp_path): + mod = _load_hook(HOOK, 60, "hook_missing") + assert mod.run_script(str(tmp_path / "nope.py"), "g") is True + + +def test_timeout_returns_a_verdict_instead_of_raising(tmp_path, capsys): + mod = _load_hook(HOOK, 2, "hook_timeout") + slow = _gate(tmp_path, "import time; time.sleep(60)") + assert mod.run_script(slow, "slowgate") is False + out = capsys.readouterr().out + assert "TIMEOUT" in out and "slowgate" in out + assert "Traceback" not in out + + +def test_running_line_is_printed_before_the_gate_finishes(tmp_path, capsys): + """The reason the hook looked dead: nothing was printed until the end.""" + mod = _load_hook(HOOK, 60, "hook_progress") + mod.run_script(_gate(tmp_path, "print('ok')"), "progressgate") + out = capsys.readouterr().out + assert out.index("progressgate") < out.index("OK") + + +def test_gate_budget_is_configurable(): + """Without the env override the timeout path could not be tested at all.""" + mod = _load_hook(HOOK, 7, "hook_budget") + assert mod.GATE_TIMEOUT == 7 + os.environ.pop("MSCB_PRECOMMIT_GATE_TIMEOUT", None) + mod2 = _load_hook(HOOK, None, "hook_budget_default") + assert mod2.GATE_TIMEOUT == 900 + + +class _FakeProc: + """communicate() times out once, exactly like a gate that never answers.""" + + def __init__(self): + self.returncode = None + self.killed = False + self._raised = False + + def communicate(self, timeout=None): + if not self._raised: + self._raised = True + raise subprocess.TimeoutExpired(cmd="gate", timeout=timeout) + return "", None + + def kill(self): + self.killed = True + + +def _old_run_script(script_path, label, _Popen=None): + """The pre-fix hook body, verbatim in behaviour. + + Kept inline on purpose. Two earlier versions of this control were worse: + one copied the old hook into the repo (tripped + `test_no_tracked_file_mutation`), the other read it back with `git show HEAD` + — which silently stopped being a control the moment the fix was committed. + """ + proc = (_Popen or subprocess.Popen)( + [sys.executable, script_path], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + encoding="utf-8", errors="replace", + ) + stdout, _ = proc.communicate(timeout=900) # no try/except — that was the bug + if proc.returncode != 0: + print(f" вќЊ {label}: exit {proc.returncode}") + if stdout: + for line in stdout.splitlines()[-10:]: + print(f" {line}") + return False + print(f" вњ… {label}: OK") + return True + + +def test_prefix_hook_could_not_answer_a_timeout(tmp_path): + """NEGATIVE CONTROL: the pre-fix hook had no verdict for a slow gate.""" + gate = _gate(tmp_path, "pass") + with pytest.raises(subprocess.TimeoutExpired): + _old_run_script(gate, "slowgate", _Popen=lambda *a, **k: _FakeProc()) + + +def test_fixed_hook_answers_the_same_timeout(tmp_path, capsys): + mod = _load_hook(HOOK, 2, "hook_fixed_timeout") + proc = _FakeProc() + mod.Popen = lambda *a, **k: proc + assert mod.run_script(_gate(tmp_path, "pass"), "slowgate") is False + assert proc.killed, "the hung gate process was not killed" + out = capsys.readouterr().out + assert "TIMEOUT" in out and "slowgate" in out + assert "Traceback" not in out + diff --git a/tests/test_reconstruct_judge_verdict.py b/tests/test_reconstruct_judge_verdict.py new file mode 100644 index 00000000..9eba319b --- /dev/null +++ b/tests/test_reconstruct_judge_verdict.py @@ -0,0 +1,109 @@ +"""Verdict parsing in scripts/reconstruct_judge_cot.py — previously untested. + +KNOWN_ISSUES recorded this class as Fixed on 2026-09-27, but only for +scripts/f5_judged_run.py. reconstruct_judge_cot.py kept its own first-match +substring parser, so a self-correcting judge was recorded INVERTED and silently: +every counter stayed green. Two locations carried the logic; one was buggy. + +The control at the bottom re-implements the old parser and asserts it disagrees — +so this file cannot pass while the old behaviour is still in place. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +def _load(name: str): + spec = importlib.util.spec_from_file_location( + name, PROJECT_ROOT / "scripts" / f"{name}.py" + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +recon = _load("reconstruct_judge_cot") +f5 = _load("f5_judged_run") + +# The inversion case from KNOWN_ISSUES, verbatim in shape. +INVERSION = ( + "At first this looks incorrect, but checking the arithmetic again it is " + "actually correct, final answer: correct" +) + + +def test_self_correction_is_not_inverted(): + assert recon._parse_verdict(INVERSION) == "correct" + + +def test_last_json_verdict_wins(): + text = '{"verdict": "incorrect"}\nOn reflection: {"verdict": "correct"}' + assert recon._parse_verdict(text) == "correct" + text_rev = '{"verdict": "correct"}\nOn reflection: {"verdict": "incorrect"}' + assert recon._parse_verdict(text_rev) == "incorrect" + + +def test_no_verdict_word_is_uncertain(): + assert recon._parse_verdict("I don't know.") == "uncertain" + + +def test_empty_input_is_uncertain(): + assert recon._parse_verdict("") == "uncertain" + assert recon._parse_verdict(None) == "uncertain" + + +def test_substring_is_not_a_verdict(): + """'incorrectly'/'correctly' are not verdicts; the old scan could not tell.""" + assert recon._parse_verdict("the answer was stated incorrectly") == "uncertain" + assert recon._parse_verdict("he answered correctly") == "uncertain" + + +def test_plain_single_verdicts_unchanged(): + assert recon._parse_verdict("correct") == "correct" + assert recon._parse_verdict("incorrect") == "incorrect" + assert recon._parse_verdict("uncertain") == "uncertain" + + +def test_mentions_helper_still_exported(): + """reconstruct_judge_cot.py:133 uses VERDICT_RE to list the words mentioned.""" + text = "first incorrect, then correct" + assert sorted({w.lower() for w in recon.VERDICT_RE.findall(text)}) == ["correct", "incorrect"] + + +def test_both_scripts_share_one_implementation(): + """N of M: 2 of 2 locations carried this logic, so they must be one object.""" + assert recon._parse_verdict is f5._parse_verdict + + +@pytest.mark.parametrize("text,expected", [ + (INVERSION, "correct"), + ("looks correct at first ... should be uncertain", "uncertain"), + ("he answered correctly", "uncertain"), +]) +def test_old_first_match_parser_disagrees(text, expected): + """NEGATIVE CONTROL: the pre-fix parser must fail these, not agree.""" + import re + + json_re = re.compile(r'"verdict"\s*:\s*"?(correct|incorrect|uncertain)"?', re.I) + + def old_parse(t: str) -> str: + m = json_re.search(t or "") + if m: + return m.group(1).lower() + low = (t or "").lower() + for v in ("incorrect", "correct", "uncertain"): + if v in low: + return v + return "uncertain" + + assert old_parse(text) != expected + assert recon._parse_verdict(text) == expected diff --git a/tests/test_ruff_gate.py b/tests/test_ruff_gate.py index 2c241762..75fff439 100644 --- a/tests/test_ruff_gate.py +++ b/tests/test_ruff_gate.py @@ -49,3 +49,22 @@ def test_no_ruff_returns_zero(): with mock.patch.dict("sys.modules", real_modules, clear=True): with mock.patch("builtins.__import__", side_effect=ImportError("no ruff")): assert mod.main() == 0 + + +def test_silent_nonzero_exit_is_named_as_a_broken_runner(): + """2026-10-03: `python -m ruff` exits 1 with NO output. A bare "exit 1" + is indistinguishable from a lint error, so the gate must say which it is.""" + with mock.patch.object(subprocess, "Popen", return_value=_FakeProc(1, "")): + assert mod.main() == 1 + + +def test_console_script_is_preferred_over_the_dead_module_form(): + """The installed ruff package here has no __main__, so `-m ruff` cannot run.""" + with mock.patch.object(mod.shutil, "which", lambda _n: None), \ + mock.patch.object(Path, "exists", lambda _self: False), \ + mock.patch.object(subprocess, "run", + return_value=subprocess.CompletedProcess([], 1, "", "")): + assert mod.ruff_cmd() is None + with mock.patch.object(mod.shutil, "which", lambda _n: "/usr/bin/ruff"): + cmd = mod.ruff_cmd() + assert cmd is not None and "python" not in cmd[0] diff --git a/tools/knowledge/PATTERNS.md b/tools/knowledge/PATTERNS.md index 658000c9..e960786a 100644 --- a/tools/knowledge/PATTERNS.md +++ b/tools/knowledge/PATTERNS.md @@ -47,6 +47,7 @@ | **P-15** | Публикуемое число не воспроизводится сегодняшней командой: у нас 2 из 14; у коллеги 84 → 107 на неизменённом коммите | Число не привязано к существующей команде | `measured on , superseded by X`; различать «число ошибочно» и «число мертво (путь удалён по дизайну)» | §19.11 | `AGENT_DIARY.md:79-96` | | **P-16** | Аудит чужого/своего кода нашим же реестром даёт 5 совпадений из 5 — и обратный вывод в нашу пользу: **наш собственный код изначально был в том же состоянии**, мы вышли из него не знанием, а guard'ами | Реестр описывает прошлое, а не класс ошибок | Периодически применять реестр к постороннему проекту | §19.8 | `AGENT_DIARY.md` (guard-comparison 2026-09-30) | | **P-17** | Benchmark decay: опуликованное число разъезжается с реальностью молча (бейдж 1965 vs 2007, census intel 14 vs 20, tests 1180 vs 2007 — расхождения 42/6/827). Класс назван в литературе (GTM-Bench «Keeping a Benchmark Honest»), а не «наша ошибка в редактуре» | Число опубликовано без команды, которая его перевыводит; ручная правка без guard | tools/verification/verify_public_claims.py — каждое утверждение хранит свою команду; расхождение → rc=3; сравнение симметрично (ловит и завышение) | §19.11 | tools/knowledge/RESEARCH-benchmark-decay.md | +| **P-18** | **Отсутствие, выведенное из одного референта.** Дважды за одну сессию 2026-10-03: (1) «участника Muhammad Umair в треде нет» — выведено из поля `user.username` (`devomnitools`), не проверено `user.name` → ложный `REFUTED`; (2) «класс E3 не существует, 0%» — выведено из листинга директорий, без поиска по содержимому → нашлось за минуту в `KNOWN_ISSUES.md:179` и `experiments/2G_git_skill_vs_context/REPORT.md:60` | Отрицание неотличимо от «не нашёл в этом поле»; тихий ноль на входе ретривера | «X отсутствует» = ≥2 независимых источника отсутствия (разные поле / файл / формулировка запроса), иначе статус `NOT FOUND IN FIELD X`, а не `REFUTED`/`0%`. Guard: `scripts/verify_absence.py` (предложен) | §5 (Zero-Prompt: перед «не нашёл X» — ≥2 формулировки), §7.1, §19.4 | `experiments/audit_devto_judgements/HANDOFF.md`, эта сессия 03.10 | --- diff --git a/tools/knowledge/PENDING_LEDGER.md b/tools/knowledge/PENDING_LEDGER.md new file mode 100644 index 00000000..4e6d1c11 --- /dev/null +++ b/tools/knowledge/PENDING_LEDGER.md @@ -0,0 +1,139 @@ +# PENDING LEDGER — предложения, а не правки + +Создан 2026-10-03 сессией в worktree `D:\Project\wt-gate-fix` (ветка +`fix/gate-kwargs-and-claims-t17`). В `D:\Project\MSCodeBase` в это время работала +другая сессия (`.agent_task_state.md` от 03.10, незакоммиченный `KNOWN_ISSUES.md`). +По §21.3 реестры принадлежат одной сессии, поэтому здесь **предложения**, а не правки. + +## P-1 → `tools/knowledge/PATTERNS.md`: **P-18** (не P-16 — номер занят) + +**Класс ошибки, повторённый дважды за одну сессию:** утверждение об отсутствии, +выведенное из одного референта. + +- Случай 1: «Muhammad Umair отсутствует в треде» — выведено из поля `user.username` + (`devomnitools`), не проверено `user.name`. Ложное `REFUTED`. +- Случай 2: «E3 (last-message ≠ answer) — дефицит 0%, ничего нет» — выведено из + листинга директорий, без поиска по содержимому. Найдено за минуту: + `KNOWN_ISSUES.md:179` и `experiments/2G_git_skill_vs_context/REPORT.md:60`. + +**Правило:** «X отсутствует / не найдено» требует ≥2 независимых источника +отсутствия; иначе статус `NOT FOUND IN FIELD X`, а не `REFUTED` / `0%`. +Точное правило уже есть в AGENTS.md §5 (Zero-Prompt, MCP: «перед "не нашёл X" — +≥2 формулировки поиска») — P-18 это его механический guard. + +**Решено владельцем 2026-10-03:** писать в `tools/knowledge/PATTERNS.md` внутри +worktree, системный реестр `~/.config/opencode/` не трогать. +**Записано:** P-18 в `tools/knowledge/PATTERNS.md` (P-16 уже занят паттерном §19.8). + +**Guard (предложен, не реализован):** `scripts/verify_absence.py [...]` +— падает (`rc=2`), если референтов меньше двух, и печатает, чем они различаются +(поле / файл / формулировка). + +## P-2 → `KNOWN_ISSUES.md:179` (зонд в E3, не новый эксперимент) + +Запись уже есть и уже верна: судья разбирает вердикт по **первому** слову, поэтому +«actually correct, final answer: correct» инвертируется; `uncertain` недостижим. +Это зеркало инцидента из треда Tom Jones (там runner брал последнее сообщение вместо +ответа; здесь парсер берёт первое упоминание вместо финального решения). Второе место: +`experiments/2G_git_skill_vs_context/REPORT.md:60` — харнесс возвращает только +финальное сообщение, поэтому не может доказать B-агентов. Оба — долг с известным +адресом, а не дефицит. + +## P-3 → пересечение с задачей параллельной сессии + +Их запись `KNOWN_ISSUES.md` (2026-10-03, P1) предлагает третье состояние `UNSEEN` +для гейтов. Моя правка в `tools/verification/gates.py` — про другое: контракт входа +(kwargs), из-за которого MCP-тул `gate` отвечал `rc=2` на любой вход. +Пересечения по коду нет, но списки гейтов будут меняться в одном файле — стоит +согласовать порядок, чтобы не было двух правок одного файла в одном коммите. + +То же касается `tools/verification/verify_public_claims.py`: он уже в репо и это +именно тот T11-инструмент, который параллельная сессия начала делать раньше меня +(см. `AGENT_DIARY.md:866`). **Я не дублирую его работу** — ручные метки `SUPERSEDED` +(A10) сделаны потому, что инструмент сегодня проверяет только 4 числа (badge'и), +и A10 среди них не числится. Их задача — расширить его до всех публикуемых чисел. + +## P-6 → СВЕЖИЙ ИНСТАНС P-17 (новый паттерн не нужен): 2 из 4 чисел badge + +`tools/verification/verify_public_claims.py` запускается и падает: + +``` +[WRONG] readme.test_count_arch README.md states 2007; live now 2027 — understates by 20 +[WRONG] wisdom.test_count WISDOM.md states 2007; live now 2027 — understates by 20 +CLAIM CHECK FAILED: 2 of 4 published numbers contradict reality +``` + +(в выводе `readme.test_badge` числится 2001 — расхождение между двумя копиями одного +факта внутри самого README.) + +**Решение владельца 2026-10-03:** канонический источник — `verify_public_claims.py`; +**значения 2007 в `README.md` и `WISDOM.md` не трогать руками**. Это уже +зарегистрированный класс `P-17` (benchmark decay), новый паттерн не вводится. + +**Про вклад этой сессии:** мои `tests/test_gates_kwarg_contract.py` добавляют 10 тестов +и уже учтены в `live now 2027`. Если инструмент собирает `tests/`, то без моего файла +было бы 2017, и разрыв README был 16, а не 26. **Это вывод из арифметики, а не измеренная +команда** — перепроверьте, прежде чем считать 2027 эталоном. + +## P-4 → E2-guard (идея, не реализовано) + +«Правка matcher'а/гейта без приложенного replay-дифа = BLOCK» — блокирующего гейта +сейчас нет. Образец прогона уже существует: +`experiments/1V_memory_contamination/flip_ledger_REDTEAM_2026-08-16.json`. + +## P-5 → числа вне репо + +`experiments/claims_audit/RESULTS.md`: A10 (`orphan 30s→120ms`) помечен `SUPERSEDED` +в этом репо (3 файла), но всё ещё публикуется в портфолио (`exp-10`, репозиторий +`MSPortfolio`, отдаётся из задеплоенного снапшота). Правка отсюда невозможна. +## P-7 → `KNOWN_ISSUES.md`: запись о вердите судьи была неполной (предложение, файл занят) + +Запись `## 2026-09-27 — F5 judge verdict parsing takes first regex match` помечена +`✅ Fixed`, но фикс 2026-09-27 применён **только к `scripts/f5_judged_run.py`**. +Второе место той же логики — `scripts/reconstruct_judge_cot.py:41-49` — осталось +с first-match и подстрочным fallback, то есть **с ровно тем багом, который запись +описывает**, и без единого теста. + +Знаменатель: логика разбора вердикта живёт в **2 из 2** мест; баговая была **1 из 2**. +Уже исправлено в ветке `fix/gate-kwargs-and-claims-t17`: обе копии импортируют +`scripts/judge_verdict.py`; guard `tests/test_reconstruct_judge_verdict.py` (11 тестов). + +**Что добавить в запись (готовая формулировка):** + +> **Дополнение 2026-10-03:** фикс применён только к `f5_judged_run.py`; вторая копия +> (`reconstruct_judge_cot.py`) оставалась багованной и без тестов. Исправлено: один +> общий `scripts/judge_verdict.py`, last-match, тест 11. **Осторожно:** +> `test_real_backfill_agreement` сверяет исправленный парсер с `verdict_final`, +> сгенерированным **старым** парсером, — после починки эти числа нужно пересчитать. + +## P-8 → `KNOWN_ISSUES.md`: новая запись о том, что коммит был невозможен (предложение) + +Файл не менялся в этой ветке намеренно: он незакоммичен в `D:/Project/MSCodeBase` +(чужая сессия), и гейт `check_parallel_sessions` это заблокировал — правильно. + +**Готовая запись (сжатая под лимит 300 строк):** + +> ## 2026-10-03 — Коммит был невозможен: три независимые причины в гейтах (Fixed) +> - **Симптом:** `git commit` не проходит; хук молчит и выглядит «зависшим», поэтому +> коммиты уходят в `--no-verify`. Не deadlock, а три дефекта, каждый маскировал следующий. +> - **(1) Гейт без видимости и без вердикта:** результат печатался только после гейта, а +> необработанный `TimeoutExpired` убивал хук трейсбеком **без вердикта**. Теперь +> `⏳