Skip to content

tausik graph — что меняется вместе с чем, и на каком основании ​

Граф артефактов отвечает на вопрос, который агент задаёт себе перед каждой правкой: что ещё придётся тронуть. Отвечает не догадкой, а двумя видами свидетельств, и всегда говорит, каким именно.

EN mirror: /docs/graph.md.

Три команды ​

bash
.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_filesrelevant_files задачи1.0 — это чьё-то УТВЕРЖДЕНИЕ, не догадка
declared_scope_pathsACL области задачи1.0
declared_crosscuttingCROSSCUTTING_SCOPE в тесте1.0

Разделение — весь смысл. «Наблюдалось вместе двенадцать раз» и «человек объявил их одной задачей» суть разные заявления, и свести их в одно число значит превратить догадку в факт. Ребро без происхождения схема хранить отказывается — ограничением базы, а не проверкой в коде.

Слой со-изменения не знает языков: он читает историю git, поэтому одинаково работает для кода, документации, конфигурации и картинок. Проверено прогоном на python, terraform и markdown — по одному ребру на стек, одинаковые relation, layer и уверенность.

Корни берутся у ПРОЕКТА ​

Три источника, в порядке убывания права голоса, и ответ всегда называет, который сработал:

  1. source_roots в .tausik/config.json — слово проекта старше любого нашего вывода.
  2. Отслеживаемые git файлы — каталоги верхнего уровня, где лежит исходник. Именно отслеживаемые: node_modules/ и build/ лежат на диске и исходником не являются, а git уже знает разницу, потому что кто-то записал её в .gitignore.
  3. Обход диска — для дерева, которое ещё не репозиторий. Назван отдельно, потому что его ответ слабее, и читатель вправе это знать.

Четвёртого источника нет. Дерево, в котором исходник найти не удалось, даёт ПУСТОЙ список и говорит об этом, а не уверенный ответ про чужие каталоги.

Почему это важнее, чем кажется. До версии 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: дальше ответ перестаёт быть дешевле открытия файла. Для файла, у которого извлекателя нет, секция МОЛЧИТ, а не печатает пустой заголовок: пустой заголовок читался бы как «определений нет», тогда как верно «их некому прочитать».

Навигация: что прочитать, чтобы изменить файл ​

bash
.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 мс
с прямым sqlite3279 мс
после удаления мёртвого журналирования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 — рукописная заплатка для случаев, где имена не совпадают. Имена не видят динамическую диспетчеризацию, монкейпатчинг и локальные импорты внутри тел функций, которыми этот код полон. Прогон видит.

bash
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 упоминаний кандидатов набралось пять, и все пять оказались умышленными.

Снимки: что изменилось В СВЯЗЯХ с прошлого релиза ​

bash
.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» — нет;
  • ребро символ-символ: символы хранятся, рёбра — только между файлами;
  • историю уверенности: у ребра одно текущее число, «связь была и распалась» непредставима;
  • покрытие тестом: ребра нет, оно заведено отдельной задачей;
  • межстековую связь иначе как через со-изменение.