tausik graph — что меняется вместе с чем, и на каком основании
Граф артефактов отвечает на вопрос, который агент задаёт себе перед каждой правкой: что ещё придётся тронуть. Отвечает не догадкой, а двумя видами свидетельств, и всегда говорит, каким именно.
EN mirror: /docs/graph.md.
Три команды
.tausik/tausik graph build # наполнить: индекс + оба слоя рёбер
.tausik/tausik graph show <путь> # что связано с этим файлом и почему
.tausik/tausik graph status # сколько хранится, что протухло, какие корниДвойник MCP — tausik_graph с аргументом command (build, show, status). Один инструмент на три подкоманды, а не три: поверхность MCP оплачивается на каждом ходу, и три имени стоили бы втрое за ту же досягаемость.
Два слоя, и они не смешиваются
| Слой | Откуда | Уверенность |
|---|---|---|
git_cochange | файлы попадали в один коммит | растёт с числом наблюдений, никогда не достигает 1.0 |
declared_relevant_files | relevant_files задачи | 1.0 — это чьё-то УТВЕРЖДЕНИЕ, не догадка |
declared_scope_paths | ACL области задачи | 1.0 |
declared_crosscutting | CROSSCUTTING_SCOPE в тесте | 1.0 |
Разделение — весь смысл. «Наблюдалось вместе двенадцать раз» и «человек объявил их одной задачей» суть разные заявления, и свести их в одно число значит превратить догадку в факт. Ребро без происхождения схема хранить отказывается — ограничением базы, а не проверкой в коде.
Слой со-изменения не знает языков: он читает историю git, поэтому одинаково работает для кода, документации, конфигурации и картинок. Проверено прогоном на python, terraform и markdown — по одному ребру на стек, одинаковые relation, layer и уверенность.
Корни берутся у ПРОЕКТА
Три источника, в порядке убывания права голоса, и ответ всегда называет, который сработал:
source_rootsв.tausik/config.json— слово проекта старше любого нашего вывода.- Отслеживаемые git файлы — каталоги верхнего уровня, где лежит исходник. Именно отслеживаемые:
node_modules/иbuild/лежат на диске и исходником не являются, а git уже знает разницу, потому что кто-то записал её в.gitignore. - Обход диска — для дерева, которое ещё не репозиторий. Назван отдельно, потому что его ответ слабее, и читатель вправе это знать.
Четвёртого источника нет. Дерево, в котором исходник найти не удалось, даёт ПУСТОЙ список и говорит об этом, а не уверенный ответ про чужие каталоги.
Почему это важнее, чем кажется. До версии 1.9 корни были константой
("scripts", "bootstrap", "tests", "harness")— именами каталогов ЭТОГО репозитория, разосланными во все остальные. Замер на проекте-потребителе: символьный индекс находил НОЛЬ объявлений при живом коде вbackend/. И это хуже пустого ответа, потому что каталогscripts/у потребителя обычно ЕСТЬ — со своими скриптами развёртывания, — так что индекс возвращал срез чужого и выглядел работающим.
Что граф говорит про ваш стек
Виды артефактов покрывают 25 объявленных стеков: исходник императивных языков даёт code, декларативных (terraform, helm, kubernetes, ansible) — config, проза — doc, данные — data. Тест опознаётся в конвенции ЛЮБОГО из них: _test.go, spec/, __tests__/, *.spec.ts, test_*.py.
Вид other остаётся честным ответом для суффикса, которого фреймворк не знает. Он означает «прочее», а не «мы не посмотрели».
Символы: только Python, и это сказано вслух
graph build наполняет artifact_symbols определениями, которые находит разбор ast. Извлекателя для других языков в 1.9 нет, и вывод команды НАЗЫВАЕТ число файлов, которые он не смог прочитать:
symbols: 13312 from files this framework can parse
37 source file(s) have NO symbol extractor here — Python only in 1.9.
Their co-change and declared edges are built as usual.Молчание здесь было бы дефектом: проект на Go прочитал бы «0 символов» как «у моего кода нет определений», а не как «этот фреймворк пока не умеет их читать». Отсутствие — не ноль.
Свежесть считается на КАЖДЫЙ запрос
graph show пересчитывает отпечаток каждого участвующего файла с диска и называет те, что разошлись с индексом:
scripts/symbol_index.py (indexed 2026-09-08T12:47:57Z)
STALE — these have changed since indexing: scripts/symbol_answer.pyДешевле было бы довериться сохранённым хешам. Это же сделало бы протухший ответ неотличимым от свежего — единственный исход, которого граф обязан не допускать.
graph show заодно печатает, что файл ОПРЕДЕЛЯЕТ, читая записанные символы:
scripts/source_roots.py (indexed 2026-09-08T12:49:28Z)
defines: _roots_from_disk:180, _tracked_files:141, declared_roots:125, resolve:205Показ ограничен двенадцатью именами, и остаток называется числом — по той же причине, что и у tausik symbol: дальше ответ перестаёт быть дешевле открытия файла. Для файла, у которого извлекателя нет, секция МОЛЧИТ, а не печатает пустой заголовок: пустой заголовок читался бы как «определений нет», тогда как верно «их некому прочитать».
Навигация: что прочитать, чтобы изменить файл
.tausik/tausik graph read <путь> # что прочитать, чтобы изменить
.tausik/tausik graph read <путь> --affects # что должно покраснеть от правкиОтвет ранжирован и обрезан, а не полон, и это не осторожность, а арифметика. Замер на живом графе: неранжированный список соседей называет 42 файла для scripts/verify_scope_honesty.py общим объёмом 2881 КБ, 38 файлов и 2779 КБ для gate_test_resolver.py, 29 и 2612 КБ для service_artifact_graph.py. Агент, получивший такой ответ, прочитает его целиком и потратит БОЛЬШЕ, чем на греп, который граф должен был заменить. Полнота здесь не цель, а способ отказа.
Те же три вопроса после ранжирования: 927, 945 и 916 байт — примерно одна трёхтысячная от чтения соседей.
Ранг выводится из происхождения. Что наблюдал ПРОГОН, сильнее того, что объявил человек; объявленное сильнее того, что git заметил меняющимся вместе. Внутри слоя решает число наблюдений. Каждая строка несёт ПРИЧИНУ — «прогон дошёл до него x36», «работали вместе в одной задаче», — иначе ранг неотличим от произвола и читатель не может остановиться раньше, а именно в этом весь выигрыш.
Два вопроса, и они не зеркальны. У графа нет направления зависимости: со-изменение симметрично, git не знает, кто за кем последовал. Поэтому read объединяет ОБА направления — хранение симметричного ребра выбирает сторону произвольно, и спросить одну значило бы потерять большую часть ответа (на живом дереве одно направление дало 2 соседа из 42). А --affects берёт единственное направленное отношение, covers: какие тесты покроют этот файл, то есть что покраснеет.
Незнание видно. Путь, которого в графе нет, получает «НЕ в графе — это "неизвестно", а не "не связано"» и команду, которая это чинит. Пустой список читался бы как «связей нет», а это уверенный неверный ответ.
Чем это отличается от RAG
В проекте есть codebase-rag на 16 850 чанков, и третий способ искать код рядом с двумя без разграничения сделал бы хуже. Разграничение такое:
| Вопрос | Чем отвечать |
|---|---|
| Что связано с этим файлом, что покроет правку, что покраснеет | граф |
| Где про это написано, как это называется, есть ли похожее | RAG |
| Где определён этот символ и кто его зовёт | tausik symbol |
| Где встречается эта строка | grep |
Граф отвечает на СТРУКТУРНЫЕ вопросы — про связи и последствия. RAG отвечает на СМЫСЛОВЫЕ — про содержание. Они не заменяют друг друга и не конкурируют.
Индекс поправляется на каждой записи
Каждая запись файла агентом переиндексирует ровно этот файл — внутри хука auto_format, который и так висит на PostToolUse для Write и Edit. Новый хук для этого НЕ заводится, и вот почему числами: работа переиндексации одного файла — 0.47 мс, а запуск отдельного процесса хука — 54 мс, то есть накладные в тридцать раз больше полезного.
Переиндексация идёт ПОСЛЕ форматирования: отпечаток, снятый до, разошёлся бы с диском в тот же миг, а это ложь, которая хуже отставания.
Тихо на записи, громко на запросе. Переиндексация не блокирует и ничего не печатает. Если она не прошла — база недоступна, файл изменён извне, git pull — артефакт просто сохраняет прежний отпечаток, а graph show пересчитывает отпечатки с диска при КАЖДОМ обращении и называет его протухшим. Блокирующий гейт на вторичном индексе останавливал бы первичную работу, а ложный блок на рутине учит обходу и стоит дороже пропуска.
Переиндексация никогда не ДОБАВЛЯЕТ артефакт. Файл, о котором граф не знает, остаётся неизвестным до graph build: иначе запись одного файла тихо расширяла бы граф мимо сборки, и число строк перестало бы совпадать с тем, что сборка напечатала.
Что стоила эта свежесть
| Состояние хука записи | Медиана |
|---|---|
| до задачи | 68 мс |
| с переиндексацией через полный бэкенд | 301 мс |
с прямым sqlite3 | 279 мс |
| после удаления мёртвого журналирования | 50 мс |
Первая версия почти учетверила стоимость записи, потому что открытие бэкенда зовёт init_schema, а закрытие — wal_checkpoint(TRUNCATE) по базе в 66 МБ. Оба уместны для долгоживущего процесса и оба напрасны для одного UPDATE из процесса, который сразу завершится.
Остаток разницы дало другое: попутно нашлась пофайловая запись «Modified: путь» в журнал задачи, которая годами не работала на Windows — хук звал POSIX-обёртку .tausik/tausik, что на этом хосте даёт OSError, а объемлющий except его проглатывал. Оживление стоило бы 190 мс на каждую запись, поэтому она удалена, а не починена: git фиксирует изменённый файл точнее, а машинная строка в журнале разбавляет записи, которые агент делает нарочно.
Стоимость
Полная сборка на этом репозитории: 4226 артефактов, 13 312 символов, 17 644 ребра — около 9 секунд.
Первая рабочая сборка занимала 2 минуты 27 секунд. Разница не в графе: база работает в WAL с synchronous=FULL, и каждый автоматически зафиксированный оператор платит fsync — около 6.7 мс на строку при двадцати двух тысячах строк. Сборка целиком выполняется одной транзакцией, и этого хватило. Сборка, которую не станут запускать дважды, — это сборка, которую не запускают, и тогда фреймворк везде поставляет пустой граф.
Слой 2: что тест ДЕЙСТВИТЕЛЬНО тронул
Выбор тестов до 1.9 сопоставлял scripts/foo.py с tests/test_foo.py ПО ИМЕНИ. Именно поэтому существует CROSSCUTTING_SCOPE — рукописная заплатка для случаев, где имена не совпадают. Имена не видят динамическую диспетчеризацию, монкейпатчинг и локальные импорты внутри тел функций, которыми этот код полон. Прогон видит.
TAUSIK_OBSERVE_COVERAGE=1 pytest tests/ # записать наблюдение
.tausik/tausik graph build --layer observed # внести его в графРебро идёт от файла теста к файлу, до которого он дошёл; отношение covers, слой observed_coverage, уверенность 1.0 — и здесь это не поблажка: тест действительно выполнялся и действительно туда дошёл. observations считает, сколько тестов этого файла его коснулись.
Почему не coverage. Он не установлен и установлен быть не может: проект держит жёсткое ограничение stdlib-only. Наблюдатель построен на sys.setprofile — гранулярность функции, а не строки, чего и достаточно: вопрос «до какого ФАЙЛА дошёл тест», а не «до какой строки».
Почему плагин, а не глобальный хук. Установленный до pytest.main профилировщик в тесты не переживает — замерено, он сообщал ноль файлов. Хук вокруг каждого теста заодно отвечает на нужный вопрос: КАКОЙ тест дошёл, а не просто «кто-то дошёл».
Выключен по умолчанию. Обычный прогон не платит за граф, который не строит.
Цена наблюдения, замеренная
| Состояние | 22 теста одного файла |
|---|---|
| без наблюдения | 5.5 с |
| наблюдение, первая версия | 14.5 с |
| наблюдение с кэшем решений | 7.7 с |
Первая версия звала os.path.relpath на КАЖДОМ событии вызова — миллионы раз за прогон, — и один тест ушёл в пятиминутный таймаут внутри ntpath.relpath, уронив воркер xdist. Решение «наш это файл или нет» зависит только от имени файла, а разных имён сотни против миллионов событий, поэтому оно принимается один раз и кладётся в словарь.
Чего наблюдение НЕ видит, и это названо, а не замаскировано
Профилировщик работает В ТОМ ЖЕ процессе, поэтому тест, который гоняет код ПОДПРОЦЕССОМ — а в этом проекте так проверяются хуки, CLI и гейты, — оставляет наблюдению только собственные строки, но не то, до чего дошёл запущенный им процесс. Замер на живом дереве после первого полного наблюдаемого прогона: у scripts/service_doctor_hooks.py наблюдённых тестов четыре против двадцати четырёх, найденных по именам, импортам и объявленной области.
Это не повод считать наблюдение слабым: четыре найденных наблюдением теста — test_doctor_commit_hooks, test_doctor_trust_tier_weakening и ещё два — по именам не находятся ВООБЩЕ. Слои дополняют друг друга, и потому выбор их складывает, а не выбирает между ними.
Выбор даёт НАДМНОЖЕСТВО, и это арифметика, а не осторожность
Наблюдённое ребро ДОБАВЛЯЕТСЯ к прежним трём (имя, импорт, объявленная область), а не заменяет их. Неполный граф с точным выбором есть машина ложного зелёного: пропущенный тест выглядит пройденным, а лишний стоит секунды. Пока наблюдений нет, выбор работает ровно как раньше — отсутствие наблюдения не есть утверждение, что файл ничем не покрыт.
Полный прогон остаётся обязательным перед тегом и перед коммитом партии. Выбор ускоряет цикл разработки, а не заменяет проверку перед выпуском.
Гейт покрытия: что мы поставляем, то и описано
doc_coverage — блокирующий гейт на task done и commit. Он проверяет одно: каждое имя, которое фреймворк ПОСТАВЛЯЕТ, названо в том документе, куда пойдёт читатель.
Сейчас покрыты две пары, и добавление третьей — это строка в gate_doc_coverage.COVERED, а не новый файл теста:
| Что поставляется | Где читатель ищет | Как узнаётся упоминание |
|---|---|---|
| команды CLI (53, из парсера) | docs/{ru,en}/cli.md | в начале строки или после tausik |
проверки doctor (21, из исходников) | docs/{ru,en}/doctor.md | как фраза, с учётом синонимов |
Замер при заведении: из 53 команд четырнадцать не были названы ни в docs/ru/cli.md, ни в docs/en/cli.md. Среди них graph и symbol — обе поставлены этим же релизом, обе со своими страницами документации, и обе так и не попали в справочник команд. Команда, о которой агент не может узнать, — это команда, которой никто не пользуется: прошлый релиз замерил это как 2 применения против 226 обращений grep.
Граница честности. Гейт видит, НАЗВАНО ли имя, а не верно ли написанное о нём. Фраза, правильная по всем именам и ложная по сути, его пройдёт. Обещать большее значило бы повторить ровно ту ошибку, против которой он заведён.
Чем он НЕ является. Не сверкой имён в обратных кавычках с символами. Такой механизм пробовали и опровергли замером в той же смене (тупик #663): обратные кавычки в этом коде означают «имя в системе» — ключ конфига, значение статуса, колонку, подкоманду или НАМЕРЕННОЕ упоминание удалённого. Из 2689 упоминаний кандидатов набралось пять, и все пять оказались умышленными.
Снимки: что изменилось В СВЯЗЯХ с прошлого релиза
.tausik/tausik graph snapshot v1.9.0 # запомнить связи под меткой
.tausik/tausik graph diff v1.8.0 v1.9.0 # что появилось и что исчезло
.tausik/tausik graph diff v1.9.0 now # против живого графаЭто вопрос, которого не задаёт больше ничто: «требование #12 больше не покрыто ни одним тестом» не выражается сравнением ТЕКСТОВ при какой угодно аккуратности формулировок — это утверждение о РЁБРАХ.
Хранится целиком и сжатым, дельт нет. Замер: 23 695 рёбер это 2103 КБ сырьём — сопоставимо со всем scripts/*.py (3683 КБ) — и 150 КБ сжатыми; живой снимок вышел 154 КБ. Цепочка дельт требует базы и целостности каждого звена, а порванное звено обесценивает всё последующее. При 154 КБ на релиз эта хрупкость не окупается.
Снимок несёт свою ПОЛНОТУ, и это решает судьбу отчётов. Вместе с рёбрами сохраняется, чем граф был в момент снятия: число артефактов и состав слоёв с числом рёбер в каждом. Два снимка, снятые при разной полноте — один до того, как прогон тестов вообще наблюдали, — отличаются на 6051 ребро слоя observed_coverage, которых не было в ИНДЕКСЕ, а не в коде. Отчёт называет это ПЕРВОЙ строкой, до всякой разницы:
! layer 'observed_coverage' is present in the earlier snapshot (6051 edges) and
ABSENT from the later one — its edges below are a difference in what was
INDEXED, not in the codeПервый же отчёт, наполненный ложными «исчезнувшими покрытиями», убивает доверие к механизму навсегда, и второй раз его не прочитают.
Изменение числа наблюдений — не структурный дрейф. Ребро то же, изменилось лишь то, как часто его видели; считать это дрейфом значило бы залить каждый отчёт шумом обычной работы.
Как это стоит рядом с детекторами дрейфа RENAR
Дополняет их и не заменяет ни одного. drift-1 перепроверяет сохранённые строки против межполевых инвариантов, которые CHECK выразить не может, — это вопрос о ВАЛИДНОСТИ ДАННЫХ, и сравнение снимков его не задаёт. drift-7 ловит задачу, закрытую против требования, отредактированного ПОСЛЕ того, как связь была проведена, — это отношение ВО ВРЕМЕНИ, а рёбра графа времени не несут. Три механизма, три вопроса; свести их в один значит потерять два.
Чего граф не выражает
Перечислено намеренно, чтобы отсутствие этих связей впредь не считали дефектом данных:
- направление зависимости в слое со-изменения: связь симметрична, «A меняется вместе с B» из истории выводится, «A зависит от B» — нет;
- ребро символ-символ: символы хранятся, рёбра — только между файлами;
- историю уверенности: у ребра одно текущее число, «связь была и распалась» непредставима;
- покрытие тестом: ребра нет, оно заведено отдельной задачей;
- межстековую связь иначе как через со-изменение.