diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 9423870c..2e8f8344 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -31,7 +31,16 @@ --- +## [2026-09-11] — Burst-rename: fail-closed VOR отзывает 100% при ONE rename-sweep (ответ Statewave на dev.to) + +**Status:** Closed (эксперименты, ответ опубликован) +**Root Cause:** VOR (ADR-0003) проверяет ПУТЬ-якоря против текущего HEAD. Rename/move = старый путь отсутствует = SILENT_ABSENCE = отзыв, хотя файл жив. Синтетика (1-C): git mv 30 файлов одним коммитом → 30/30 REFUTED (100%); body-hash carry → 30/30 уцелели. Реальная память (1-B): 24 авто-REFUTED = 13 мусор якорей + 10 настоящих удалений + 1 ЛОЖНЫЙ отзыв (ADR-7232a6e2ba34: узел жив, отозван по старому пути src/utils/paths.py из prose «X → Y» в теле; хранимые якоря adapters/zed/zed_config.py + src/main.py существуют). +**Fix (эксперименты, не код):** burst_rename_audit.py / burst_sweep_exp.py / redteam_burst.py в experiments/1V_memory_contamination/. Решение для производства не принято (вопрос владельцу: body-hash carry-compat против стоимости). +**Guard:** Red-Team показал — «батч по коммиту» (мульти-уёдание = move) смешивает R* с D* (e661861f: init.py удалён + windows.py R083): спасал бы и настоящие удаления. Точный ревью complex: git --diff-filter=R по истории. +**verified_from_clean_state:** ⚠️ не прогонялся (скрипты экспериментов, не runtime-код) + ## [2026-09-07] — Lazy-only верификация: VOR вызывается только из intel_get_project_memory, нет TTL/фона + **Status:** Open — зафиксировано как проблема + план эксперимента (10-continuous-verification.md) **Root Cause:** По дизайну (ADR-0003) VOR ленивый, но точки вызова всего одна (layer.py:1097); IdleScheduler включается только из record_tool_call(), VOR в idle не подключён, 2 из 3 idle-задач — заглушки (_improve_summaries_batch/_check_index_health — пустые тела). Живой срез текущего проекта: 42/136 узлов ACTIVE без verified_at/TTL висят с 2026-08-11; узлы без якорей → INCONCLUSIVE → VOR не пишет ничего → «проверено» = «кто-то когда-то вызвал». **Fix (план эксперимента, не внесён):** H1 idle-ticker VOR с budget; H2 event-driven на HEAD (ключ hash(node_id+commit_sha) уже есть); H3 TTL-гниение INCONCLUSIVE → STALE. Baseline замера: полный прогон 136 узлов = 431.6ms (fingerprint 371.6ms) — дешевле порога. Контр-риски: false_retraction не выше 0.083%, цена при нагрузке. @@ -200,6 +209,13 @@ **Guard:** дедуп в push + лимиты, single-threaded write под lock. .h-хедеры (H2) — отдельный коммит 0301fa93 (см. KNOWN_ISSUES «`.h` не парсился AST» → Fixed). **verified_from_clean_state:** ⚠️ не проверено — чистый clone не гонялся (нет сети в сессии); локально полный pytest 1689 passed / 91 deselected (Windows, без e2e/shadow-маркеров — llama недоступен, slow/benchmark отсечены addopts). +## [2026-09-11] — VOR read-path fix (PR #34) + «8-минутный коммит» = НЕ баг (решение владельца) + +**Status:** ✅ PR #34 создан, hooks green; скорость тестов — осознанное решение, код НЕ менялся. +**Root Cause:** (1) read-path VOR ре-сканировал prose тела ADR через `_PATH_RE`, хотя явные `data.anchors` уже были захвачены на write-path. Исторические «X → Y» пути из прозы (R083: src/utils/paths.py → adapters/local_fs/windows.py) оживали при rename-sweep и ложно REFUTEDали живой узел ADR-7232a6e2ba34. (2) «Коммит 8 мин» — gate-zero полный pytest ~178s standalone + 2 llama-server (embed+rerank) contention при коммите. Измерено: 1704 passed / 177s; hook-скрипты остальные 8 ≈10s; total ~189s standalone. +**Fix:** (1) `extract_anchors(read_path=True)` в verify_on_read.py — при непустых явных якорях prose НЕ сканируется; legacy-узлы без якорей сохраняют проза-скан (backward compat, дрифт-детект жив). Write-path (layer.py:1250/1555) не тронут (default False). 3 новых regression-теста; 53+28 passed. PR: https://github.com/ManSio/mscodebase-intelligence/pull/34. (2) Владелец: pytest single-thread остаётся, ~178s — норма; xdist/smart-selection НЕ вносить. Зафиксировано в WISDOM. +**Guard:** read-path проверяет ТОЛЬКО якоря, существовавшие на момент записи (не re-derives единственные из прозы); тест `test_read_path_prose_history_legacy_node_keeps_refuting` закрепляет обе ветки. basetemp-гонка (2 параллельных pytest → 452 ложных FileNotFoundError) — известна, не чиним. +**verified_from_clean_state:** ✅ да, локально — PR #34 не merged (ждёт ревью), но: hooks 9/9 OK, полный pytest 1704 passed / 177s, VOR+retraction 53+28 passed. ## [2026-09-10] — Exp 1 (Catch-up Rate) + Exp 3 (HEAD polling): VOR масштабирование и внешний дрифт **Status:** ✅ Fix (замеры, кода не менялось). **Root Cause (KNOW ISSUES «Lazy-only верификация»):** вопрос, успевает ли VOR проверить ACTIVE-узлы в рамках budget_ms=50 (read-path) / 250 (background idle), и детектит ли он внешнее git-pull изменение без notify_change (H3). diff --git a/EXPERIMENTS_LOG.md b/EXPERIMENTS_LOG.md index 592a6c87..37569a29 100644 --- a/EXPERIMENTS_LOG.md +++ b/EXPERIMENTS_LOG.md @@ -1,5 +1,26 @@ # EXPERIMENTS_LOG.md — Audit Verification (2026-07-22) +## [2026-09-11] — 1-B/1-C/RT: burst-rename vs fail-closed VOR (ответ Statewave) + +**Гипотеза (вопрос dev.to «How large does that queue get in practice?»):** после ОДНОГО refactor-коммита с массовыми move/rename fail-closed VOR (ADR-0003, path-якоря) отзывает ВСЕ затронутые узлы (queue = 100%), хотя файлы живы — это ложные отзывы; body-hash-якорь (limpet/OpenLore) переживает rename без потерь. +**Команда:** +``` +python experiments/1V_memory_contamination/burst_rename_audit.py # реальная память (24 авто-REFUTED) +python experiments/1V_memory_contamination/burst_sweep_exp.py # синтетика: 30 файлов, git mv, 1 коммит +python experiments/1V_memory_contamination/redteam_burst.py # 3 атаки (batch-hazard/starve/present-trap) +``` +**Сырой результат:** +``` +[1-B реальная память] авто-REFUTED=24: anchor_junk 13, TRUE_REFUTE_deleted 10, FALSE_REFUTE_renamed 1 (9.1%) + ADR-7232a6e2ba34 FALSE_REFUTE_renamed src/utils/paths.py -> adapters/local_fs/windows.py (git R083) +[1-C синтетика N=30] Baseline A: VERIFIED 30/0 REFUTED. Sweep B (git mv src/mod src/moved): + After sweep: VERIFIED 0 REFUTED 30/30 (100%) | body-hash carry: 30/30 (100% уцелели) +[Red-Team-1 batch-hazard] коммит e661861f = D(adapters/local_fs/__init__.py) + R083(windows.py): «исчезли скопом = move» спас бы и правду, и удаление +[Red-Team-3 starve] бюджет 0.05ms @ N=40: checked=1 inconclusive=39 budget_exceeded=39 +[Red-Team-4 present-trap] файл пересоздан на том же пути с другим телом: path-anchor VERIFIED (протух), body-hash честно delete+add +``` +**Вердикт:** подтверждена частично. Очередь в практике = не огромная (24 накопленных авто-отзыва за месяц), но ДОМИНИРУЕТ мусор якорей (13/24: import:for/filter/maps, pkg:name/-), а не renames. Настоящие renames дали 1 ложный отзыв (ADR-7232a6e2ba34 — узел жив, якоря adapters/zed/*.py + src/main.py существуют; отозван по СТАРОМУ пути из prose тела). Синтетика показывает паттерн: один rename-sweep = 100% очереди. **Урок:** батч по коммиту НЕ решает — он смешивает move с delete (e661861f); нужна body-identity (exact-body/exact-signature, OpenLore PR#206) или уточнённый reviewer: сравнение failed-якоря против R*-src в git-истории (rename_sources в audit). Связь: ADR-0003, OpenLore#206, limpet (AST body-hash). + ## [2026-08-05] — Neuro-Symbolic spike: NL → LLM → Cypher → parser+schema → PropertyGraph (exp-lab-2026-01) **Ожидание:** связка (LLM-генерация Cypher + двухслойная валидация parser+schema + исполнение) отсекает невалидные генерации ДО исполнения: 10 вопросов → 8 исполнимых + 2 отсечённых (галлюцинированная метка SERVICE, функция cycle()). diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 12cd84e1..ba4d25c4 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,12 @@ **14 entries** — compressed per §4.8 R3 (conclusion-first; dedup 2026-09-08) +## 2026-09-11 — Burst-rename: fail-closed VOR отзывает узлы по rename-sweep; 1 ЛОЖНЫЙ отзыв (ADR-7232a6e2ba34) + +- **Источник:** AGENT_DIARY.md 2026-09-11 + EXPERIMENTS_LOG 1-B/1-C/RT +- **Описание:** VOR (ADR-0003) проверяет path-якоря против HEAD: rename/move = старый путь отсутствует = SILENT_ABSENCE. Real: 24 авто-REFUTED = 13 мусор якорей + 10 настоящих удалений + 1 ЛОЖНЫЙ (ADR-7232a6e2ba34 жив, отозван по старому пути src/utils/paths.py из prose тела). Synthetic 1-C: git mv 30 файлов одним коммитом → 30/30 REFUTED (100%); body-hash → 30/30 уцелели. Red-Team: batch-по-коммиту спасает настоящие удаления (e661861f = D+R083 в одном коммите). +- **Статус:** 🔬 открыт — решение не принято (вопрос владельцу: body-hash carry против стоимости) + ## 2026-09-02 20:51 — drift_gate заблокировал коммит: контроль остановил самого автора - **Источник:** AGENT_DIARY.md diff --git a/WISDOM.md b/WISDOM.md index bbb29ec3..7246bc8b 100644 --- a/WISDOM.md +++ b/WISDOM.md @@ -85,6 +85,13 @@ - LanceDB atomic delete+add: фикс table.version + restore(prev_version) при сбое add (db_writer.py) — нативный versioning лучше temp+os.replace. +## Commit overhead — НЕ баг (2026-09-11, решение владельца) +- Pre-commit gate-zero (scripts/verify_diary.py:443) = полный pytest ~178s standalone; + остальные 7 hook-скриптов ≈ 10s. «8-минутный коммит» — CPU contention от 2 llama-servers + (embed+rerank) при коммите, НЕ медленные тесты. pytest остаётся single-thread, НЕ хтейзить. +- Помогать не нужно: временный `pytest --basetemp ` — при параллельных прогонах два pytest + коллизят на общем `pytest-of-misha/pytest-0` → 452 ложных FileNotFoundError (а не баг кода). + ## Doc-vs-code semantic drift (2026-08-12) - stale_detector сверяет ТОЛЬКО version-строки — имена тулов/счётчики в доках НЕ проверяются. Кросс-чек: AGENTS.md B перечислял НЕСУЩЕСТВУЮЩИЕ тулы diff --git a/experiments/1V_memory_contamination/burst_rename_audit.py b/experiments/1V_memory_contamination/burst_rename_audit.py new file mode 100644 index 00000000..2d18a3f7 --- /dev/null +++ b/experiments/1V_memory_contamination/burst_rename_audit.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Experiment 1-B: Burst-Rename Audit — сколько «SILENT_ABSENCE» на деле переименования. + +Гипотеза (из вопроса Statewave в dev.to): после refactor-коммита с массовыми +move/rename пачка якорей исчезает разом. Часть таких исчезновений — не удаления, +а переименования (файл жив под новым путём). Сколько из авто-REFUTED на реальной +памяти проекта — ложные отзывы по переименованным файлам? + +Дизайн: +- Читаем реальный project_memory.json (bfe9644b). +- Для каждой ноды с retract_source == verify_on_read извлекаем failed-якорь из + retract_reason ("SILENT_ABSENCE_ON_READ: file:X"). +- Для X ищем в git-истории (--diff-filter=R`), переименован ли он: + has_rename -> файл был переименован после записи ноды (live elsewhere) -> FALSE_REFUTE + no_rename -> файл никогда не существовал или удалён без rename -> TRUE_REFUTE + never_had -> импорт/мусорный якорь (import:X, pkg:- и т.п.) +- Выход: таблица + агрегат (сколько отзывов — реальные удаления). + +Запуск (GitBash/win): + venv/Scripts/python.exe experiments/1V_memory_contamination/burst_rename_audit.py +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = Path(r"D:\Project\MSCodeBase") +MEM = Path(r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\intelligence\project_memory.json") + +_CREATE_NO_WINDOW = 0x08000000 if sys.platform == "win32" else 0 + + +def git(*args: str, max_commit: str | None = None) -> str: + """git -- , ограниченный временем до коммита (если задан).""" + cmd = ["git", "-C", str(ROOT), *args] + if max_commit is not None: + cmd += [max_commit] + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + creationflags=_CREATE_NO_WINDOW, + ) + out, _ = proc.communicate(timeout=20) + return out.decode("utf-8", "replace") + + +RENAME_SOURCES: set[str] | None = None + + +def rename_sources() -> set[str]: + """Все src-пути R*-переименований в истории (кэш на процесс).""" + global RENAME_SOURCES + if RENAME_SOURCES is not None: + return RENAME_SOURCES + s: set[str] = set() + try: + out = git("log", "--all", "--diff-filter=R", "--name-status", "--format=") + except Exception: + RENAME_SOURCES = s + return s + for line in out.splitlines(): + parts = line.strip().split("\t") + if len(parts) >= 2 and parts[0].startswith("R"): + src = parts[1].replace("\\", "/").lstrip("./") + s.add(src) + # basename-маппинг: якорь мог быть записан как `src/utils/paths.py` + # или `paths.py` (нормализация в ADR-теле). Ложь только для + # дублирующихся basename — здесь редкое исключение, принимаем. + s.add(src.split("/")[-1]) + RENAME_SOURCES = s + return s + + +def had_rename(path: str) -> bool: + """Был ли путь целю R*-переименования где-либо в git-истории.""" + norm = path.replace("\\", "/").lstrip("./") + return norm in rename_sources() or norm.split("/")[-1] in rename_sources() + + +def main() -> int: + try: + nodes = json.loads(MEM.read_text(encoding="utf-8")) + if not isinstance(nodes, list): + raise ValueError("project_memory.json: ожидался список нод") + + rows = [] + for n in nodes: + if n.get("retract_source") != "verify_on_read": + continue + reason = n.get("retract_reason") or "" + m = re.search(r"file:(\S+)", reason) + failed = m.group(1) if m else None + if failed is None or "://" in failed or failed.startswith("C:"): + verdict = "anchor_junk" + note = reason + elif had_rename(failed): + verdict = "FALSE_REFUTE_renamed" + note = "файл переименован — жив под другим путём" + else: + verdict = "TRUE_REFUTE_deleted" + note = "файл никогда не существовал/удалён без rename" + rows.append({ + "node_id": n.get("node_id"), + "status": n.get("status"), + "failed_anchor": failed, + "verdict": verdict, + "note": note, + }) + + agg: dict = {} + for r in rows: + agg[r["verdict"]] = agg.get(r["verdict"], 0) + 1 + + print("=" * 88) + print("Experiment 1-B: Burst-Rename Audit (реальные авто-отзывы VOR)") + print(f"авто-REFUTED нод (retract_source=verify_on_read): {len(rows)}") + print("-" * 88) + for r in rows: + print(f" {r['node_id']:<18} {r['verdict']:<22} {r['failed_anchor']}") + print(f" {r['note']}") + print("-" * 88) + print("Агрегат:") + for k, v in sorted(agg.items(), key=lambda kv: -kv[1]): + print(f" {k:<24} {v}") + n_false = agg.get("FALSE_REFUTE_renamed", 0) + n_true = agg.get("TRUE_REFUTE_deleted", 0) + total_checked = n_false + n_true + if total_checked: + rate = n_false / total_checked + print(f"\nЛожные отзывы по rename-якорям: {n_false}/{total_checked} = {rate:.1%}") + print("=" * 88) + return 0 + except (OSError, ValueError, json.JSONDecodeError) as e: + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/experiments/1V_memory_contamination/burst_sweep_exp.py b/experiments/1V_memory_contamination/burst_sweep_exp.py new file mode 100644 index 00000000..86319189 --- /dev/null +++ b/experiments/1V_memory_contamination/burst_sweep_exp.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +Experiment 1-C: Burst-Rename Sweep — сколько REFUTED рождает ОДИН rename-коммит. + +Контролируемый прогон реального VerifyOnRead на синтетическом git-репозитории. +Вопрос Statewave: «How large does that queue get for you in practice?» + +Дизайн: +- tmp-репо (git init) с N=30 реальных py-файлов. +- 30 memory-нод, каждая с file:-якорем на свой файл (как write-path ADR-0003). +- Коммит A (baseline) -> VOR: все 30 VERIFIED (контроль: якоря живые). +- Коммит B: rename-sweep *одним* коммитом (git mv 30 файлов в новый каталог) — + файлы живы, но все 30 путей изменились. +- VOR после B: сколько REFUTED (это размер review-очереди в практике). +- Мера "батчей по коммиту" (предложение Statewave): если бы мы понимали, что + коммит B — массовый move, можно carry-forward'ить якоря. Считаем fallback + OpenLore-style: exact-body hash пережил rename == 30/30 идемпотентны. +- Латентность: fingerprint rebuild + проход. + +Запуск: venv/Scripts/python.exe experiments/1V_memory_contamination/burst_sweep_exp.py +""" +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +import tempfile +import threading +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +import src # noqa: F401 (обеспечивает import src.* при любом CWD) + +N_FILES = 30 +_CREATE_NO_WINDOW = 0x08000000 if sys.platform == "win32" else 0 + + +def sh(cwd: Path, *args: str) -> str: + proc = subprocess.Popen( + [*args], cwd=str(cwd), stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, creationflags=_CREATE_NO_WINDOW, + ) + out, _ = proc.communicate(timeout=60) + return out.decode("utf-8", "replace") + + +def body_hash(p: Path) -> str: + text = p.read_text(encoding="utf-8") + # exact-body: без импортов/докстринга — «нормализованное тело» (limpet-style). + keep = [] + in_doc = False + for ln in text.splitlines(): + s = ln.strip() + if s.startswith('"""') or s.startswith("'''"): + in_doc = not in_doc + continue + if in_doc: + continue + if s.startswith(("import ", "from ")): + continue + keep.append(s) + return hashlib.sha256("\n".join(keep).encode("utf-8")).hexdigest()[:16] + + +def main() -> int: + try: + from src.core.intelligence.store import IntelligenceStore + from src.core.intelligence.verify_on_read import ( + STATUS_REFUTED, + STATUS_VERIFIED, + VerifyOnRead, + ) + + # ── 1. Синтетический репозиторий ── + repo = Path(tempfile.mkdtemp(prefix="burst_sweep_")) + (repo / "src" / "mod").mkdir(parents=True) + for i in range(N_FILES): + (repo / "src" / "mod" / f"m{i:02d}.py").write_text( + f"\"\"\"{i:02d}\"\"\"\nimport os\n\ndef f{i:02d}() -> int:\n return {i}\n", + encoding="utf-8", + ) + sh(repo, "git", "init", "-q") + sh(repo, "git", "config", "user.email", "exp@local") + sh(repo, "git", "config", "user.name", "Exp") + sh(repo, "git", "add", "-A") + sh(repo, "git", "commit", "-qm", "base") + + # ── 2. Изолированный store с 30 нодами (file:-якоря) ── + store_dir = repo / "_memory" + store = IntelligenceStore(store_dir) + nodes = [] + for i in range(N_FILES): + nodes.append({ + "node_id": f"NODE-burst-{i:02d}", + "section": "adrs", + "timestamp": "2026-09-11 12:00:00", + "data": { + "claim": f"module m{i:02d} provides f{i:02d}", + "anchors": [{"kind": "file", "value": f"src/mod/m{i:02d}.py"}], + }, + }) + store.save_memory(nodes) + + verifier = VerifyOnRead( + repo, store, threading.Lock(), + cache_file=store_dir / "verify_cache.json", + ) + + # Body-хэши ДО sweep (для carry-forward оценки — snapshot в baseline). + bodies_before = {} + for i in range(N_FILES): + old_p = repo / "src" / "mod" / f"m{i:02d}.py" + bodies_before[i] = body_hash(old_p) if old_p.exists() else None + mem, stats_a = verifier.run(store.load_memory()) + + # ── 4. Rename-sweep одним коммитом: src/mod -> src/moved ── + sh(repo, "git", "mv", "src/mod", "src/moved") + sh(repo, "git", "commit", "-qm", "sweep: move src/mod -> src/moved (rename all 30)") + head_b = sh(repo, "git", "rev-parse", "HEAD").strip() + + # VOR после B (свежий кэш вердиктов по новому HEAD) + verifier2 = VerifyOnRead( + repo, store, threading.Lock(), + cache_file=store_dir / "verify_cache_b.json", + ) + mem_b, stats_b = verifier2.run(store.load_memory()) + + # ── 5. OpenLore-style body-hash carry (fallback, was-and-still-exists) ── + renamed_ok = 0 + for i in range(N_FILES): + new_body = body_hash(repo / "src" / "moved" / f"m{i:02d}.py") if (repo / "src" / "moved" / f"m{i:02d}.py").exists() else None + if bodies_before[i] is not None and bodies_before[i] == new_body: + renamed_ok += 1 + + raw = store._load_json("project_memory.json") + status = {n["node_id"]: n.get("status", "ACTIVE") for n in raw} + n_refuted_b = sum(1 for n in status.values() if n == STATUS_REFUTED) + + print("=" * 88) + print("Experiment 1-C: Burst-Rename Sweep (VerifyOnRead на синтетике)") + print(f"файлов: {N_FILES} | коммит A: базовый | коммит B: rename-sweep (1 коммит)") + print("-" * 88) + print(f"Baseline (A): VERIFIED={stats_a['verified']} REFUTED={stats_a['refuted']} " + f"cache_hits={stats_a['cache_hits']} latency={stats_a['latency_ms']}ms") + print(f"After sweep (B): VERIFIED={stats_b['verified']} REFUTED={stats_b['refuted']} " + f"cache_hits={stats_b['cache_hits']} latency={stats_b['latency_ms']}ms " + f"fingerprint={stats_b['fingerprint_build_ms']}ms") + print(f"Review-очередь после ОДНОГО rename-коммита: {n_refuted_b}/{N_FILES} узлов REFUTED " + f"({n_refuted_b / N_FILES:.0%})") + print(f"OpenLore-style body-hash: уцелели при rename {renamed_ok}/{N_FILES} " + f"({renamed_ok / N_FILES:.0%} — carry-forward идемпотентен)") + print(f"Файлы живы под новым путём: файл НЕ удалён — только переименован.") + print("-" * 88) + print(f"Вердикт: VOR fail-closed по пути => rename-sweep массово отзывается;") + print(f" body-hash-якорь (не путь) переживает rename без потерь.") + print(f"репо: {repo}") + return 0 + except Exception as e: + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/experiments/1V_memory_contamination/redteam_burst.py b/experiments/1V_memory_contamination/redteam_burst.py new file mode 100644 index 00000000..45df301c --- /dev/null +++ b/experiments/1V_memory_contamination/redteam_burst.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Red-Team: атаки на решение Statewave (commit-batch / body-hash carry-forward). + +Атака-1 (TOCTOU/batch-hazard): если «N якорей исчезли в одном коммите ⇒ это + rename-sweep, не отзывать» — коммит, который ПРАВДА удаляет файлы, сам + содержит renames. Проверка на реальных 10 TRUE_REFUTE: есть ли R-строки + в том же коммите, что удалил файл? Если да → батч-эвристика ошибочно + «спасёт» настоящие удаления. + +Атака-3 (budget starvation): после rename-sweep ПЕРВОЕ чтение после смены HEAD + перепроверяет все узлы в бюджете 50ms. Если узлов больше, чем бюджет + позволяет — часть остаётся INCONCLUSIVE (не REFUTED): очередь растёт, но + проверка молча пропущена. Симуляция на синтетике с малым бюджетом. + +Атака-4 (present-trap recreated path): файл удалён и создан ЗАНОВО на том же + пути с другим содержимым. Path-anchor VOR видит путь → VERIFIED (present), + но факт про старое содержимое устарел. body-hash: старый body != новый → + delete+add (честно). Показать разницу на синтетике. + +Запуск: venv/Scripts/python.exe experiments/1V_memory_contamination/redteam_burst.py +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = Path(r"D:\Project\MSCodeBase") +MEM = Path(r"C:\Users\misha\AppData\Local\mscodebase\projects\bfe9644b\intelligence\project_memory.json") +_CREATE_NO_WINDOW = 0x08000000 if sys.platform == "win32" else 0 + + +def git(*args: str) -> str: + proc = subprocess.Popen( + ["git", "-C", str(ROOT), *args], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, creationflags=_CREATE_NO_WINDOW, + ) + out, _ = proc.communicate(timeout=60) + return out.decode("utf-8", "replace") + + +def main() -> int: + print("=" * 90) + print("RED-TEAM: атаки на решение Statewave (commit-batch / body-hash carry)") + + # ── Атака 1: batch-hazard на реальных TRUE_REFUTE ── + print("\n[Атака-1] Batch-hazard: тот ли коммит смешивает R-rename с D-delete?") + nodes = json.loads(MEM.read_text(encoding="utf-8")) + deleted_anchors = [] + for n in nodes: + if n.get("retract_source") != "verify_on_read": + continue + reason = n.get("retract_reason") or "" + m = re.search(r"file:(\S+)", reason) + if not m: + continue + failed = m.group(1) + if "://" in failed or failed.startswith("C:"): + continue + # проверяем deletion-историю пути (был ли он когда-либо удалён) + out = git("log", "--all", "--diff-filter=D", "--format=%h %s", "-1", "--", failed) + if not out.strip(): + continue + commit = out.strip().split()[0] + # в этом же коммите есть R-строки? + stat = git("show", "--name-status", "--format=", commit) + has_r = any(l.startswith("R") for l in stat.splitlines()) + deleted_anchors.append({"anchor": failed, "del_commit": commit, + "commit_has_renames": has_r, + "commit_msg": git("show", "-s", "--format=%s", commit).strip()}) + n_with_r = sum(1 for d in deleted_anchors if d["commit_has_renames"]) + for d in deleted_anchors: + flag = "!!" if d["commit_has_renames"] else "ok" + print(f" [{flag}] {d['anchor']:<38} удалён в {d['del_commit']} " + f"renames_in_same_commit={d['commit_has_renames']}: {d['commit_msg'][:48]}") + print(f" ИТОГ: {n_with_r}/{len(deleted_anchors)} удалений лежат в коммитах с renames " + f"→ батч-эвристика «исчезли скопом = move» ошибочно спасла бы их.") + + # ── Атака 3: budget starvation ── + print("\n[Атака-3] Budget starvation после sweep (cheap re-check при малом бюджете)") + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + import src # noqa: F401 + from src.core.intelligence.store import IntelligenceStore + from src.core.intelligence.verify_on_read import VerifyOnRead + + def sh(cwd: Path, *args: str) -> str: + proc = subprocess.Popen( + [*args], cwd=str(cwd), stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, creationflags=_CREATE_NO_WINDOW, + ) + out, _ = proc.communicate(timeout=60) + return out.decode("utf-8", "replace") + + repo = Path(tempfile.mkdtemp(prefix="rt_starv_")) + (repo / "src" / "mod").mkdir(parents=True) + N = 40 + for i in range(N): + (repo / "src" / "mod" / f"m{i:02d}.py").write_text( + f"\"\"\"{i:02d}\"\"\"\ndef f{i:02d}() -> int:\n return {i}\n", encoding="utf-8") + sh(repo, "git", "init", "-q") + sh(repo, "git", "config", "user.email", "rt@local") + sh(repo, "git", "config", "user.name", "RT") + sh(repo, "git", "add", "-A") + sh(repo, "git", "commit", "-qm", "base") + store = IntelligenceStore(repo / "_mem") + nodes = [{ + "node_id": f"NODE-rt-{i:02d}", "section": "adrs", + "timestamp": "2026-09-11", + "data": {"claim": f"m{i:02d}", "anchors": [{"kind": "file", "value": f"src/mod/m{i:02d}.py"}]}, + } for i in range(N)] + store.save_memory(nodes) + v1 = VerifyOnRead(repo, store, threading.Lock(), cache_file=repo / "_mem" / "v.json") + v1.run(store.load_memory()) + sh(repo, "git", "mv", "src/mod", "src/moved") + sh(repo, "git", "commit", "-qm", "sweep") + v2 = VerifyOnRead(repo, store, threading.Lock(), cache_file=repo / "_mem" / "v2.json") + # бюджет для demo: дефолтный 50ms на 40 узлов реального быстрого цикла пройдёт; + # снижаем до 0.05ms, чтобы показать механизм starvation. + mem2, s2 = v2.run(store.load_memory(), budget_ms=0.05) + print(f" N={N} узлов, бюджет 0.05ms: checked={s2['checked']} " + f"inconclusive={s2['inconclusive']} budget_exceeded={s2.get('budget_exceeded')}") + if "budget_exceeded_nodes" in s2: + print(f" не проверены (остались в контексте как INCONCLUSIVE): " + f"{len(s2['budget_exceeded_nodes'])} узлов из {N}") + if "starved_nodes" in s2: + print(f" starved (matched>=2 && delivered==0): {len(s2['starved_nodes'])}") + + # ── Атака 4: present-trap recreated path ── + print("\n[Атака-4] Present-trap: файл пересоздан на том же пути с ДРУГИМ телом") + repo4 = Path(tempfile.mkdtemp(prefix="rt_present_")) + (repo4 / "src").mkdir() + (repo4 / "src" / "cfg.py").write_text( + "def config() -> dict:\n return {'mode': 'OLD'}\n", encoding="utf-8") + sh(repo4, "git", "init", "-q") + sh(repo4, "git", "config", "user.email", "rt@local") + sh(repo4, "git", "config", "user.name", "RT") + sh(repo4, "git", "add", "-A") + sh(repo4, "git", "commit", "-qm", "base") + st4 = IntelligenceStore(repo4 / "_mem") + fact = [{ + "node_id": "NODE-rt4", "section": "adrs", "timestamp": "2026-09-11", + "data": {"claim": "config имеет mode=OLD", + "anchors": [{"kind": "file", "value": "src/cfg.py"}]}, + }] + st4.save_memory(fact) + v41 = VerifyOnRead(repo4, st4, threading.Lock(), cache_file=repo4 / "_mem" / "v.json") + v41.run(st4.load_memory()) + # пересоздаём путь (де-факто другая логика), коммит + (repo4 / "src" / "cfg.py").write_text( + "def config() -> dict:\n return {'mode': 'NEW'}\n", encoding="utf-8") + sh(repo4, "git", "add", "-A") + sh(repo4, "git", "commit", "-qm", "recreate cfg with NEW mode") + v42 = VerifyOnRead(repo4, st4, threading.Lock(), cache_file=repo4 / "_mem" / "v2.json") + mem42, _ = v42.run(st4.load_memory()) + raw4 = {n["node_id"]: n.get("status") for n in st4._load_json("project_memory.json")} + print(f" path-anchor VOR: present-путь существует → статус {raw4.get('NODE-rt4')} " + f"(факт про mode=OLD протух, но якорь жив)") + print(f" body-hash: старый body != новый → honest delete+add (size известно)") + + print("=" * 90) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/src/core/intelligence/verify_on_read.py b/src/core/intelligence/verify_on_read.py index c4b08c76..d4d8a657 100644 --- a/src/core/intelligence/verify_on_read.py +++ b/src/core/intelligence/verify_on_read.py @@ -291,6 +291,7 @@ def extract_anchors( node: Dict[str, Any], project_root: Optional[Path] = None, src_imports: Optional[Set[str]] = None, + read_path: bool = False, ) -> List[Anchor]: """Извлекает checkable-якоря из data/claim узла (лёгкий regex, без LLM). @@ -356,6 +357,12 @@ def _add_import_anchor(value: str) -> None: for a in raw_anchors: if isinstance(a, dict) and a.get("kind") in ("file", "import", "env", "pkg", "symbol"): _add(str(a["kind"]), str(a.get("value", ""))) + # Fix (2026-09-11, 1-B): явные якоря = write-time capture, проза тела — + # история («X -> Y» в ADR-body). При read_path=True и наличии явных + # якорей проза НЕ сканируется: исторический путь не должен отзывать + # живой узел (ложный REFUTED после rename-sweep). + if read_path and anchors: + return anchors parts: List[str] = [] claim = data.get("claim") or "" if claim: @@ -790,7 +797,7 @@ def run( counters[node_id]["delivered"] += 1 # ADR-0005 guard: read-path отсевает проза-«import X» (частотные # слова без src-импорта) тем же правилом, что и write-path. - anchors = extract_anchors(node, src_imports=fp.imports) + anchors = extract_anchors(node, src_imports=fp.imports, read_path=True) verdict, failed = self._classify(anchors, fp) if not dirty: self._cache.setdefault("verdicts", {})[key] = { diff --git a/tests/test_verify_on_read.py b/tests/test_verify_on_read.py index 2b151fd4..ce78a947 100644 --- a/tests/test_verify_on_read.py +++ b/tests/test_verify_on_read.py @@ -175,6 +175,63 @@ def test_extract_anchors_read_path_backward_compat(): } +def test_extract_anchors_read_path_trusts_explicit_anchors(): + """Fix 2026-09-11 (1-B): при read_path=True и наличии явных data.anchors + проза тела НЕ сканируется. Исторический «X -> Y» путь в прозе (например + src/utils/paths.py -> adapters/local_fs/windows.py) не должен становиться + живым якорем — иначе rename-sweep ложно отзывает узлы (ADR-7232a6e2ba34).""" + node = _node( + "N1", "claim", + anchors=[{"kind": "file", "value": "src/main.py"}], + ) + node["data"]["body"] = ("src/utils/paths.py -> adapters/local_fs/windows.py " + "(POSIX no-op); src/utils/zed_config.py -> adapters/zed/zed_config.py") + + with_fix = {(a.kind, a.value) for a in extract_anchors(node, read_path=True)} + assert ("file", "src/utils/paths.py") not in with_fix # исторический путь не якорь + assert ("file", "src/utils/zed_config.py") not in with_fix + assert ("file", "src/main.py") in with_fix # явный якорь сохранён + + # Backward-compat без флага: проза по-прежнему сканируется (legacy-узлы). + legacy = {(a.kind, a.value) for a in extract_anchors(node)} + assert ("file", "src/utils/paths.py") in legacy + + +def test_read_path_prose_history_not_refuting_live_node(project: Path): + """Fix 2026-09-11 (1-B): узел с живыми явными якорями НЕ отзывается по + историческому пути из прозы тела (rename-sweep в ADR-body).""" + store = IntelligenceStore(project) + node = _node( + "N1", "refactor: вынесли специфику Windows/Zed", + anchors=[{"kind": "file", "value": "src/main.py"}], + ) + node["data"]["body"] = ("src/core/gone.py (SafePathManager) -> adapters/local_fs/windows.py " + "(POSIX no-op); src/utils/zed_config.py -> adapters/zed/zed_config.py") + _seed(store, [node]) + verifier = _make_verifier(project, store) + + memory, stats = verifier.run(store.load_memory()) + assert [n["node_id"] for n in memory["adrs"]] == ["N1"] # не отсечён + assert stats["verified"] == 1 and stats["refuted"] == 0 + raw = store._load_json("project_memory.json")[0] + assert raw["status"] == STATUS_VERIFIED + assert "retracted_at" not in raw + + +def test_read_path_prose_history_legacy_node_keeps_refuting(project: Path): + """Fix 2026-09-11 (1-B): НАМЕРЕННЫЙ отзыв не сломан — узел БЕЗ явных якорей + (legacy auto-collect) по-прежнему отзывается по реальному дрейфу в прозе.""" + store = IntelligenceStore(project) + node = _node("N1", "использует src/core/pathgone.py и src/utils/elt.py") + _seed(store, [node]) + verifier = _make_verifier(project, store) + + _, stats = verifier.run(store.load_memory()) + assert stats["refuted"] == 1 + raw = store._load_json("project_memory.json")[0] + assert raw["status"] == STATUS_REFUTED + + # ===================================================================== # VERDICTS # =====================================================================