RAGRAF: индекс кода, который знает, что он устарел¶
Как мы заставили LLM-агента перестать угадывать устройство проекта и посчитали, сколько это сэкономило
Оглавление¶
- Проблема: агент работает по памяти о коде, которого не видел
- Что такое RAGRAF
- Стек
- Архитектура
- Почему не свободный язык запросов к графу
- Контрольная сумма и сторож свежести
- Раздельный учёт разработок
- Статистика: что замерено
- Примеры: чем код с RAGRAF отличается от кода без него
- Экономия токенов
- Двадцать один дефект
- Побочный результат: оформленная страница входа
- Что не получилось и где границы
- Выводы
1. Проблема: агент работает по памяти о коде, которого не видел¶
Каждый, кто пробовал поручать LLM-агенту правку в существующем проекте, знает три симптома.
Первый: агент не находит место. Он уверенно отвечает про функцию, но при проверке выясняется, что функция в другом файле, а та, о которой он говорил, — одноимённая. На репозитории в 392 файла поиск grep по имени выдаёт десятки совпадений, и нужное среди них не первое.
Второй: агент не знает схемы. Он пишет запрос с колонкой AuthorUserID, потому что так названа переменная в другой части того же кода. Колонки с таким именем нет. Это выясняется при запуске, а не при чтении. В нашем случае это дало SQLSTATE[42S22] Unknown column на первом же сохранении.
Третий, самый неприятный: агент не знает, что устарел. Индекс был построен, потом код правили — и дальше агент получает факты о состоянии, которого уже нет. Отличить «такой функции нет» от «функция появилась после индексации» по ответу невозможно. Решение принимается по этому ответу, и оно неверно.
Индустрия уже прошла часть этого пути. В статье про графы в аудите кода из Positive Technologies приведены показательные числа исследования VulnGym: в задачах, где цепочка от источника до стока длиннее девяти шагов, лучший агент находил 22,58% уязвимостей, а в 56,5% случаев он вообще не находил нужный файл (26,8%) или находил файл, но не строку (29,7%). То есть больше половины отказов — не про анализ, а про навигацию. Именно эту часть мы и решили вынести из модели во внешний индекс.
Дополнительный аргумент дала работа LLMxCPG (USENIX Security '25): базовые модели генерируют корректный запрос к графу кода в 19–132 случаях из 1278. Язык запросов к графу — низкоресурсный, модели его не знают. Этот факт определил одно из главных архитектурных решений (глава 5).
2. Что такое RAGRAF¶
RAGRAF — вспомогательная база знаний о коде для LLM-агентов. Не чат, не ассистент, не «умный поиск». Его работа:
- Проиндексировать корпус проекта: файлы, символы, вызовы, SQL-запросы, схему базы, комментарии и docblock'и.
- Отдавать агенту факты со ссылкой
файл:строкачерез фиксированный набор операций. - Знать, что индекс устарел, и обновляться самому — до того, как агенту отдадут устаревший факт.
- Хранить разработки раздельно, с контрольной суммой по каждой.
Последние два пункта — то, что отличает RAGRAF от «просто индекса». Индекс без контроля свежести опаснее, чем отсутствие индекса: он даёт уверенный ответ о прошлом.
Ключевые принципы, заложенные в архитектуру:
| Принцип | Что это значит на практике |
|---|---|
| Индекс неизменяем | Ревизия (rev-NNNN) фиксируется; работа агента её не меняет. Отклонения копятся в отдельном журнале расхождений |
| У каждого факта есть адрес | Поле path:line в каждом результате. Факт без адреса бесполезен — его нельзя проверить |
| У каждого ответа есть конверт | index_rev, capability, drift, provenance, limits, freshness едут вместе с данными |
| Общий код — единственный источник | Одна и та же функция собирает строки для страницы и для поиска |
3. Стек¶
Выбор продиктован ограничениями среды: Windows без Docker, без PostgreSQL, без .NET SDK, без прав администратора.
| Слой | Технология | Почему так |
|---|---|---|
| Разбор кода | tree-sitter (грамматика PHP) | Не требует запускать PHP; даёт AST с диапазонами строк, устойчив к синтаксическим ошибкам в файле |
| Граф кода | SQLite | Единый файл, нет сервиса, работает без установки. На 123 файлах — 15,8 МБ |
| Межпроцедурные связи | Joern 4.0.99 + JDK 21 (Temurin) | Единственный доступный инструмент, который строит CPG для PHP и совместим с Windows |
| Лексический поиск | SQLite FTS5 (BM25) | Встроен в SQLite, не требует индекса-сервиса |
| Векторный поиск | sqlite-vec (vec0) |
Одно расширение вместо отдельной векторной СУБД. Milvus Lite на Windows не работает, Kuzu заархивирован 10.10.2025 |
| Эмбеддинги | sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 (384 измерения) | Мультиязычная: код и комментарии у нас на русском, документация тоже |
| Сериализация ответов | MCP (Model Context Protocol, JSON-RPC) | Штатный способ отдать инструменты агенту |
| CLI | PowerShell 7.6 | Среда Run; сложные блоки выносятся в файлы |
| Телеметрия | SQLite (таблица usage_call) |
Журнал обращений — источник всех чисел этой статьи |
| Целевые проекты | PHP 8.3/8.5 без фреймворка, MySQL 9.7.2 | Реальные корпуса: revoap.com (392 файла) и z.batteryservice.ru (123 файла) |
Общее число зависимостей Python — семь. Это сознательно: каждая зависимость в цепочке индексации — потенциальная причина, по которой индекс не соберётся на чужой машине.
4. Архитектура¶
flowchart TB
subgraph CORPUS["Корпус проекта (только чтение)"]
PHP["PHP-файлы<br/>123 файла"]
MD["Markdown / документация"]
SQL["SQL-миграции<br/>23 файла"]
end
subgraph INDEX["Индексация (команда index)"]
SCAN["scan<br/>обход + sha256"]
AST["ast<br/>tree-sitter: символы, вызовы, SQL"]
RESOLVE["resolve<br/>разрешение имён"]
SCHEMA["schema<br/>таблицы и колонки из миграций"]
CPG["cpg<br/>Joern: межпроцедурные вызовы"]
LEXVEC["lexvec<br/>FTS5 + vec0"]
end
subgraph STORE["Хранилище (каталог .ragraf)"]
GRAPH[("graph.sqlite<br/>узлы, рёбра, схема БД")]
LEX[("lexvec.sqlite<br/>чанки, векторы")]
CTRL[("control.sqlite<br/>ревизии, расхождения,<br/>журнал обращений")]
REV["rev.json<br/>текущая ревизия"]
end
subgraph GUARD["Сторож контрольных сумм"]
HASH["corpus_hash<br/>по файлам корпуса"]
CMP{"совпадает с<br/>проиндексированной?"}
REINDEX["автоматическая<br/>реиндексация"]
end
subgraph FACADE["Фасад: 38 операций"]
OPS["search, symbol, schema,<br/>write_check, impact, ..."]
ENV["Конверт ответа:<br/>index_rev, freshness,<br/>limits, stale_warnings"]
end
AGENT["LLM-агент"]
PHP --> SCAN
MD --> SCAN
SQL --> SCAN
SCAN --> AST --> RESOLVE --> SCHEMA --> CPG --> LEXVEC
AST --> GRAPH
RESOLVE --> GRAPH
SCHEMA --> GRAPH
CPG --> GRAPH
LEXVEC --> LEX
SCAN --> CTRL
LEXVEC --> CTRL
HASH --> CMP
SCAN -.->|"сумма файлов"| HASH
GRAPH -.->|"сумма при индексации"| CMP
CMP -->|"нет: код ушёл вперёд"| REINDEX
REINDEX -.->|"новый снимок"| STORE
CMP -->|"да"| FACADE
AGENT -->|"операция"| FACADE
FACADE --> GRAPH
FACADE --> LEX
FACADE --> CTRL
ENV --> AGENT
Разберём узлы, которые неочевидны.
Стадия schema читает SQL-миграции, а не базу. Это принципиально: индексация должна работать там, где нет доступа к СУБД. Из 23 файлов миграций разбираются определения таблиц и колонок — в нашем случае 44 таблицы и 453 колонки. Именно этот слой потом позволяет ответить на вопрос «а такая колонка существует?» до запуска кода.
cpg — самая дорогая стадия. Joern строит граф свойств кода: на 123 файлах это 26 560 вершин и 411 843 ребра, 14 секунд. Из них после фильтрации остаётся 540 подтверждённых межпроцедурных вызовов — их текстовый поиск найти не может.
Экспорт CPG — отдельная история. Штатный GraphSON падал с Required array length 2147483640 + 8 is too large даже при 18 ГБ памяти. Пришлось использовать neo4jcsv (132 файла, 223,8 МБ). Память при этом передаётся аргументом -J-Xmx18g: форма -J-Xmx=18g отвергается с Invalid maximum heap size, а JAVA_OPTS до JVM не доходит вообще.
Журнал расхождений — append-only. Записи не удаляются, только дополняются. Это защита от «переписывания истории»: если факт был помечен устаревшим, след остаётся.
5. Почему не свободный язык запросов к графу¶
Соблазн дать агенту Cypher или CPGQL понятен: запросы выразительнее фиксированных операций. Мы отказались, и на это были измерения.
В работе LLMxCPG (USENIX Security '25) базовые модели генерировали корректный запрос к графу кода так:
| Модель | Верных запросов |
|---|---|
| Base-модель без обучения | 19 из 1278 |
| Base-модель (другая конфигурация) | 132 из 1278 |
| Дообученная для генерации запросов | 1278 из 1278 |
Дообученная модель — 100%, потому что её учили именно этому. Но у нас нет задачи обучать модель: агент произвольный. Значит, честный выбор — не давать язык запросов вовсе.
Что вместо этого: 21 фиксированная операция. Агент выбирает не «как спросить», а «что спросить»:
| Вопрос агента | Операция | Что отвечает |
|---|---|---|
| Где определена функция | symbol |
Вид, файл, диапазон строк, сигнатура, контракт параметров |
| Где описано, как устроено | search |
Гибридный поиск: лексика + вектор, со ссылками |
| Кто вызывает | callers |
Доказанные и эвристические связи раздельно |
| Что затронет изменение | impact |
Файлы и символы, разделённые на «доказано графом» и «найдено текстом» |
| Что в таблице | schema |
Колонки, типы, ключи |
| Кто использует таблицу | table_usage |
Читатели и писатели со ссылками |
| Можно ли так записать | write_check |
Отсутствующие колонки с ближайшими существующими |
| Свежий ли индекс | freshness |
Совпадение контрольной суммы, факт реиндексации |
| Что проиндексировано | catalog |
Реестр разработок с суммами |
| Сколько было обращений | usage |
Статистика и оценка экономии |
Ограничение сознательное: операции не принимают произвольных запросов. Попытка передать MATCH ... RETURN отвергается явной проверкой, чтобы агент не пытался «пролезть» в граф в обход набора.
6. Контрольная сумма и сторож свежести¶
Это ключевая механическая часть. Требование звучало так: «при новых обращениях RAGRAF сначала сверял контрольную сумму, чтобы понять, были изменения или нет после последней индексации, и если были — делал реиндексацию».
Как считается сумма¶
Сумма корпуса — sha256 от отсортированного списка путь + sha256 файла:
@property
def corpus_hash(self) -> str:
"""Хэш корпуса: отсортированный список (путь, sha256) — признак «вход не менялся»."""
payload = "\n".join(f"{f.path}\t{f.sha256}" for f in self.files)
return util.sha256_text(payload)
Три свойства, каждое из которых обязательно:
- Считается по файлам корпуса, а не по индексу. Если бы сумма зависела от содержимого индекса, она менялась бы от самой индексации, и проверка «были изменения» теряла бы смысл.
- Служебный каталог
.ragrafисключён из корпуса. Иначе индекс менял бы вход сам для себя. - Не зависит от порядка обхода и времени — только от содержимого.
Проверяется это отдельным тестом: после индексации сумма обязана совпасть с той, что была до неё. Тест находит 8 проверок ровно на это свойство.
Как работает сторож¶
sequenceDiagram
participant A as LLM-агент
participant F as Фасад RAGRAF
participant W as Сторож
participant C as Корпус (файлы)
participant I as Индекс
A->>F: операция (например, symbol)
F->>W: check(auto_reindex=true)
W->>C: scan: пути + sha256
C-->>W: corpus_hash (сейчас)
W->>I: corpus_hash (при индексации)
alt суммы совпадают
W-->>F: changed=false, reindexed=false
else код изменился
W->>W: diff по файлам: сколько изменено/добавлено/удалено
W->>I: реиндексация (с блокировкой)
I-->>W: новая ревизия (rev-NNNN)
W-->>F: changed=true, reindexed=true, reindex_rev
end
F->>A: ответ + freshness в конверте
Результат сторожа попадает в каждый ответ, а не только в отдельную операцию:
{
"op": "symbol",
"index_rev": "rev-0016",
"freshness": {
"category": "z.batteryservice.ru",
"changed_since_index": false,
"index_present": true,
"corpus_hash_now": "64dd7ef128df6d6254ed520e5a48643e45ccf5e671d54ca9ee40512d152ddef6",
"corpus_hash_indexed": "64dd7ef128df6d6254ed520e5a48643e45ccf5e671d54ca9ee40512d152ddef6",
"config_changed": false,
"reindexed": false
},
"result": [ ... ]
}
Три решения, которые пришлось принять¶
Первое: кэш на 15 секунд. Сканирование 123 файлов — около 200 мс. При серии из десятков обращений это заметно, поэтому результат проверки живёт 15 секунд. Агент делает правку — следующая проверка её увидит. Явный запрос freshness --force кэш игнорирует.
Второе: сторож не ломает работу при сбое. Если проверка или реиндексация упала, операция всё равно отдаёт данные — но с полем stale: true и причиной. Молчаливая выдача устаревших фактов запрещена.
Третье: блокировка. Два одновременных обращения к одному корпусу иначе запустят две индексации и перезапишут показатели. Блокировка — файл с pid и временем; если процесс упал, старая блокировка считается мёртвой через 15 минут.
Два дефекта, найденных при проверке сторожа¶
Дефект первый — молчаливый пропуск каталогов. Сканирование тестового корпуса находило 0 файлов без единой ошибки. Причина: корень был задан коротким 8.3-путём (C:/Users/MVD~1.AMD/AppData/Local/Temp), а Path(dirpath).resolve() возвращал длинный путь. relative_to() выбрасывал ValueError, который глушился, и каждый каталог пропускался.
Это ровно тот класс дефектов, против которого RAGRAF и создавался: команда не упала, а сработала не так. Отличить «в корпусе нет файлов» от «путь не совпал» было невозможно. Исправление — приведение всех путей к одной форме через os.path.realpath и подсчёт пропущенных каталогов вместо молчания.
Дефект второй — неопределённый порядок ревизий. Выбор последней ревизии шёл через ORDER BY created_at DESC, а метка времени хранится с точностью до секунды. Две ревизии в одну секунду — порядок неопределён. Тест поймал это так: после реиндексации бралась прежняя ревизия, и сумма «не совпадала». Исправление — сортировка по rev_id, который монотонен (rev-0008 < rev-0009).
7. Раздельный учёт разработок¶
Второе требование: «чтобы RAGRAF хранил информацию о разработках отдельно друг от друга и хранил контрольные суммы по каждой».
Разделение обеспечено физически: у каждого корпуса свой каталог <корень>/.ragraf с индексом, ревизиями, журналом расхождений и телеметрией. Смешать данные двух проектов технически невозможно.
Поверх этого — сводный реестр catalog/catalog.json:
{
"version": 1,
"entries": {
"revoap.com": {
"root": "C:/AutoClaw/revoap.com",
"rev_id": "rev-0025",
"corpus_hash": "d6edc0172b055acd4b5d44d76caa5b8788919000ce9d2ca711263f8f63ac0a64",
"file_count": 392,
"indexed_at": "2026-10-05T05:04:11+03:00",
"runs": [ { "trigger": "register", "seconds": 0.0 } ]
},
"z.batteryservice.ru": {
"root": "C:/AutoClaw/z.batteryservice.ru",
"rev_id": "rev-0016",
"corpus_hash": "64dd7ef128df6d6254ed520e5a48643e45ccf5e671d54ca9ee40512d152ddef6",
"file_count": 123,
"indexed_at": "2026-10-05T05:04:10+03:00",
"runs": [ ... ]
}
}
}
Вывод команды catalog показывает состояние каждой разработки отдельно:
разработок в реестре: 2
revoap.com (revoap.com)
ревизия: rev-0025
сумма: d6edc0172b055acd4b5d44d76caa5b8788919000ce9d2ca711263f8f63ac0a64
размер: файлов 392, узлов 0, рёбер 0
состояние: индекс актуален (сумма совпадает)
z.batteryservice.ru (z.batteryservice.ru)
ревизия: rev-0016
сумма: 64dd7ef128df6d6254ed520e5a48643e45ccf5e671d54ca9ee40512d152ddef6
размер: файлов 123, узлов 0, рёбер 0
состояние: индекс актуален (сумма совпадает)
Контрольная сумма у разработок разная — и это проверяется тестом: правка в одном корпусе не должна считаться изменением в другом.
Отдельно фиксируется признак config_matches: если правили конфигурацию корпуса (маски файлов, модель эмбеддингов, правила разбора), старая ревизия несопоставима с новой, даже когда файлы кода не менялись. Это отличие важно: «код тот же» и «индекс сопоставим» — разные утверждения.
8. Статистика: что замерено¶
Все числа ниже получены из журнала обращений (usage_call) и из замеров на стенде. Оценки помечены как оценки.
8.1 Объём корпусов¶
| Показатель | revoap.com (пилот) |
z.batteryservice.ru (полигон) |
|---|---|---|
| Файлов | 392 | 123 |
| Строк кода | 126 386 | ~34 000 |
| Символов | — | 155 689 в ответах индекса |
| Узлов графа | — | 487 |
| Рёбер графа | — | 7 292 |
| Чанков | 3 120 | 457 |
| Векторов | 3 000 | 456 |
| Полная индексация (с CPG) | 71 с | 21,5 с |
| Индексация без CPG | 8,8 с | ~4 с |
| Размер индекса | — | 27,5 МБ |
8.2 Замер «без индекса / с индексом» по 14 задачам навигации¶
Методика: для каждой задачи считается минимальное достаточное число шагов. Без индекса это поиски rg плюс открытия файлов; с индексом — вызовы операций фасада.
| Показатель | Без RAGRAF | С RAGRAF | Отношение |
|---|---|---|---|
| Вызовов инструментов | 28 (реалистично 43) | 14 | 2,0× / 3,1× меньше |
| Среднее на задачу | 2,0 / 3,1 | 1,0 | — |
| Кандидатов к просмотру | до 34 файлов | 1–5 точных | — |
| Файлов читается целиком | 14 (минимум) | 0 | индекс отдаёт структуру |
| Попадание в файл-цель | — | 12 из 13 (92%) | — |
Факты со ссылкой path:line |
— | 100% | — |
| Время на 14 задач | 0,32 с (только rg) |
1,40 с | +1,1 с — плата за структуру |
Замер baseline сделан в пользу отказа от индекса: 28 вызовов — это если агент с первого поиска сразу открывает нужный файл. Реалистичная оценка (просмотр до 5 кандидатов) — 43 вызова.
При этом rg находил файлы, но не локализовал: кандидатов было 34 (SuperAdmin), 44 (z_e(), 64 (namespace BatteryService). Найти файл легко, найти место в файле — нет.
8.3 История улучшения показателей¶
| Показатель | До улучшений | После |
|---|---|---|
| Попадание в файл-цель | 46% | 92% |
| Проверяемость фактов | 30% | 100% |
| Крупнейший чанк | 11 331 токен | 5 543 токена |
| Таблиц в слое схемы | 0 | 44 (453 колонки) |
| Операций для LLM | 17 | 21 |
| Ошибки схемы видны до запуска | нет | да |
8.4 Тесты¶
| Набор | Проверок |
|---|---|
| Ядро подсистемы без БД | 70 |
| Ядро подсистемы с БД | 59 |
| Полный цикл создания записи | 29 |
| Область видимости «свои записи» | 18 |
| Полный цикл согласования | 57 |
| Связность конфигураций со схемой | 56 |
| Страницы раздела через HTTP | 47 |
| Страница входа через HTTP | 26 |
| Сторож контрольных сумм (временный корпус) | 31 |
| Сторож на живом корпусе | 24 |
| Дрейф и MCP в самом RAGRAF | — |
| Итого | 429 проверок, 0 провалов |
Прогон каждого набора выполнен дважды подряд — так проверяется идемпотентность уборки тестовых данных.
8.5 Стоимость операций¶
| Операция | Среднее время |
|---|---|
schema |
0,5 мс |
symbol |
0,7 мс |
table_usage |
0,9 мс |
write_check |
3,3 мс |
search (гибридный) |
до 1 с (эмбеддинги) |
Гибридный поиск в тысячу раз дороже остальных операций, потому что считает эмбеддинг запроса. Это заметно на длинных сериях — вероятное направление оптимизации: кэш эмбеддингов частых запросов.
9. Примеры: чем код с RAGRAF отличается от кода без него¶
Здесь самое важное. Ниже — четыре реальных случая из разработки раздела /agreements/ (документооборот: журналы платежей, договоров, отпусков).
9.1 Схема базы: write_check вместо «узнаем при запуске»¶
Что случилось без проверки. Код создавал запись в журнале платежей:
$cols = ['No', 'Status', 'AuthorUserID', 'CreatedAt', 'UpdatedAt', 'UpdatedBy'];
$vals = [$no, 'draft', $uid, $now, $now, $uid];
Первое сохранение дало:
Оказалось, в таблицах журналов колонка называется InitiatorID, а UpdatedBy есть только у одной таблицы (DocDepartment) — но не у журналов. Ошибка проявилась только при запуске, то есть после написания кода.
Как это выглядит с write_check. Операция добавлена именно после этого случая. Запрос:
python tools/ragraf.py -p config/z-batteryservice.yaml query write_check DocPayments `
--columns "No,Status,AuthorUserID,UpdatedBy,Purpose"
Ответ:
{
"op": "write_check",
"found": true,
"problem": "колонки отсутствуют в таблице: AuthorUserID, UpdatedBy",
"result": {
"table": "DocPayments",
"table_found": true,
"unknown_columns": [
{ "name": "AuthorUserID", "nearest": ["ID"] },
{ "name": "UpdatedBy", "nearest": ["UpdatedAt", "DeletedBy"] }
],
"insert_required": [],
"primary_key": ["ID"],
"column_count": 32,
"source": "migration"
}
}
Разница не в том, что «ошибку нашли раньше». Разница в том, что ошибки не было вовсе: ближайшее имя UpdatedBy → UpdatedAt сразу показывает правильный вариант, и код пишется верно с первого раза.
Отдельно проверяется опечатка в имени таблицы:
{
"similar_tables": ["DocPayments", "DocComment", "DocDepartment", "DocAttachment", "DocConsent"],
"note": "таблица не найдена в схеме из миграций"
}
Источник схемы — 23 файла миграций, а не подключение к базе. Индексация обязана работать там, где СУБД недоступна.
9.2 Контракт параметров: array $data бесполезен без состава ключей¶
Что случилось. Функция аудита вызывалась так:
Ответ:
Почему это не помогла бы просто сигнатура. Операция symbol и раньше отдавала сигнатуру:
Сигнатура полная и точная — и бесполезная для построения вызова. Тип array не сообщает, какие ключи обязательны.
Ключевая находка. Точный контракт уже лежал в индексе — в docblock'е над функцией:
/**
* @param array{action:string, user_id?:int, target_user_id?:int, field?:string,
* from?:?string, to:?string, reason?:string, actor_id?:int} $data
*/
function z_agr_acl_audit(array $data): void
Но docblock был привязан к узлу файла, а не функции: по корпусу 371 docblock относились к файлам, 32 — к классам, и ни один к функции. Достичь его по имени функции было нечем.
Что сделано. Привязка docblock по позиции (последний блок, заканчивающийся не позже начала символа) плюс разбор формы array{ключ:тип, ключ?:тип}, где ? означает необязательность. Теперь symbol отдаёт:
{
"signature": "function z_agr_acl_audit(array $data): void",
"array_shape": {
"data": {
"action": { "type": "string", "required": true },
"user_id": { "type": "int", "required": false },
"target_user_id": { "type": "int", "required": false },
"field": { "type": "string", "required": false },
"from": { "type": "?string","required": false },
"to": { "type": "?string","required": false },
"reason": { "type": "string", "required": false },
"actor_id": { "type": "int", "required": false }
}
}
}
Теперь агент видит, что обязателен только action, и строит вызов правильно:
z_agr_acl_audit([
'action' => 'create',
'target_user_id' => $uid,
'field' => 'No',
'to' => $no,
'actor_id' => $uid,
]);
Если описания в docblock'е нет, добавляется поле params_hint с прямым указанием, что состав массива в индексе отсутствует. Молчаливая неполнота недопустима: агент должен знать, чего он не знает.
9.3 Права: матрица §5.8 как данные, а не как ветвления¶
Что случилось. Проверка под реальной ролью сотрудника на стенде показала, что роль employee (область «свои записи») получает права approve, mark_paid, sign и archive. Причина — в коде:
case 'approve':
case 'mark_paid':
case 'sign':
case 'archive':
// Проверка выполняется в Workflow.php по конкретному шагу и состоянию
return true; // ← право получал ЛЮБОЙ, кому выдан журнал
Последствие, если бы не нашли: любой сотрудник мог отметить платёж оплаченным и подписать документ.
Что сделано. Матрица прав вынесена из логики в данные — отдельный модуль AccessMatrix.php:
function z_agr_matrix(): array
{
return [
'create' => ['admin', 'director', 'opr_director', 'accountant', 'head', 'employee'],
'approve' => ['admin', 'accountant', 'head', 'director', 'opr_director', 'employee'],
'mark_paid' => ['admin', 'accountant'], // ← только двое
'sign' => ['admin', 'director', 'opr_director', 'head'],
'archive' => ['admin', 'director', 'opr_director'],
'delete' => ['admin', 'employee'],
'restore' => ['admin'],
'integrity_check' => ['admin'],
// ...
];
}
Неназванное действие права не получает:
function z_agr_can_act_by_matrix(string $action, string $journal): bool
{
if (in_array($action, ['approve', 'reject', 'skip'], true)) {
// Роли согласования берутся из МАРШРУТА журнала (DocRoute),
// а не из статического списка: маршрут — источник истины.
$routeRoles = z_agr_route_roles($code);
if ($routeRoles === []) {
return false; // маршрут пуст — согласовывать некому
}
return z_agr_role_in($journal, $routeRoles);
}
$roles = z_agr_matrix_roles($action);
if ($roles === []) {
return false; // действие не описано матрицей — отказ
}
return z_agr_role_in($journal, $roles);
}
Ключевое: return false для действия, которого нет в матрице. Именно отсутствие этой строки и было дефектом: раньше «действие не описано» означало «разрешить».
Проверка на стенде подтвердила результат:
| Роль | create | update | delete | approve | mark_paid |
|---|---|---|---|---|---|
admin |
да | да | да | да | да |
employee (область self) |
да | да | нет | нет | нет |
9.4 Согласование: три дефекта, найденных проверкой полного цикла¶
Проверка маршрута согласования (57 проверок) нашла три дефекта, не видимых ни в коде, ни в документации. Все три — класса «работает, но не так»: страница открывалась, ошибок не было.
Дефект первый: отклонённая запись — тупик.
'transitions' => [
'draft' => ['submit', 'update', 'delete', 'copy', 'cancel'],
'rework' => ['submit', 'update', 'cancel', 'copy'],
'rejected' => ['copy'], // ← из состояния выйти нельзя
// ...
],
Из состояния «Отклонён» не было ни одного действия, кроме копирования. Автор не мог ни исправить, ни отправить заново — маршрут обрывался. Как в зрелых системах: отклонённый документ возвращается автору.
Исправление:
плюс z_agr_submit() теперь принимает статус rejected как допустимый для повторной отправки.
Дефект второй: шаг с альтернативными ролями нельзя согласовать.
Маршрут платежа: шаг 1 — руководитель, шаг 2 — бухгалтер, шаг 3 — «генеральный или операционный директор». Шаг с альтернативой хранится одной строкой с RoleKey='director,opr_director' и UserID = NULL (назначение идёт по роли, а не конкретному человеку).
Функция проверки назначения смотрела только UserID:
// БЫЛО: шаг с альтернативой не признавался ничьим
$st = z_db()->prepare(
'SELECT 1 FROM DocApproval WHERE Journal = :j AND RefID = :r AND UserID = :u LIMIT 1'
);
Результат: маршрут обрывался на третьем шаге — согласовать его не мог никто.
// СТАЛО: проверяются ОБА способа назначения
foreach ($rows as $row) {
$assigned = $row['UserID'] === null ? 0 : (int)$row['UserID'];
if ($assigned > 0) {
if ($assigned === $userId) {
return true; // назначен персонально
}
continue;
}
// Шаг назначен роли: подходит любой носитель роли из списка.
foreach (explode(',', (string)$row['RoleKey']) as $role) {
$role = trim($role);
if ($role === '' || !in_array($role, $myRoles, true)) {
continue;
}
if ($role === 'head') {
// Руководитель — только в своём подразделении.
$depId = (int)($record['DepartmentID'] ?? 0);
if ($depId > 0 && !z_agr_dep_in_subtree($depId, $userId)) {
continue;
}
}
return true;
}
}
Здесь же видно решение второго архитектурного вопроса: право на конкретный шаг даёт назначение в экземпляре маршрута, а матрица отвечает только за общий запрет. Так устроено в 1С:Документооборот, Directum, ELMA и BPMN 2.0 (candidateUsers / candidateGroups): роли формируют назначение, но решает тот, кому задача назначена.
Дефект третий: защита полей §5.7 не работала.
По требованиям после согласования защищённые поля (сумма, контрагент, назначение платежа) правятся только с причиной. Механика: при финальном согласовании записывается временная метка ProtectedAt и снимок значений ProtectedJson, по которому потом определяется факт изменения.
Код записывал метку — и не заполнял снимок:
// БЫЛО: защита полей не действовала
if (z_agr_table_has_column($table, 'ProtectedAt') && $stateAfter === 'approved') {
$sets[] = 'ProtectedAt = :pat';
$sets[] = 'ProtectedBy = :pby';
// ProtectedJson — не заполнялся
}
Функция z_agr_field_locked() работала верно, но получала пустой снимок и всегда отвечала «поле свободно». Согласованную сумму можно было изменить без следа.
// СТАЛО
if (z_agr_table_has_column($table, 'ProtectedJson')) {
$sets[] = 'ProtectedJson = :pjs';
$params[':pjs'] = z_agr_protected_snapshot($code, $row);
}
Снимок берётся из конфигурации журнала (список защищённых полей), с запасным набором на случай недоступности конфига — чтобы запись никогда не оставалась согласованной без защиты.
10. Экономия токенов¶
10.1 Прямые измерения¶
| Показатель | Значение | Как получено |
|---|---|---|
| Обращений к операциям за сессию разработки | 46 | измерено (журнал) |
| Ошибок выполнения | 0 | измерено |
| Объём ответов индекса | 155 689 символов | измерено |
| Оценка токенов | ~38 900 | оценка (4 символа на токен) |
| Ответы, заменяющие чтение файлов | 20 897 символов | измерено (по видам операций) |
| Токены, не потраченные на чтение файлов | ~5 200 | оценка |
| Строк кода, которые не пришлось читать | ~464 | оценка (45 символов на строку) |
Почему оценка, а не измерение. Точное число токенов известно только модели при инференсе. Здесь оно считается по объёму символов с коэффициентом 4 и по средней длине строки 45. Оба коэффициента замерены на двух корпусах, и везде, где выводятся, помечены словом «оценка» — чтобы не выдавать расчёт за факт.
10.2 Сравнение с работой по файлам¶
| Подход | Что читает агент | Оценка объёма |
|---|---|---|
Поиск rg + открытие файлов |
14 файлов целиком (по одному на задачу) | ~392 000 символов на 28 вызовов |
| Операции RAGRAF | фрагменты по 1–120 строк со ссылками | 20 897 символов, заменяющих чтение |
Соотношение по объёму — примерно 19:1 в пользу индекса. Но здесь важна честность: это не та экономия, за которой стоит гнаться. Основная выгода в другом — в главах 11 и 12 видно, что индекс дал не «меньше токенов», а меньше переделок.
10.3 Где экономия реально ощутима¶
| Тип задачи | Без индекса | С индексом | Что экономится |
|---|---|---|---|
| «Где определена функция» | rg + открытие файла: 2–3 вызова, до 4 кандидатов |
symbol: 1 вызов |
2 вызова × контекст файла |
| «Кто использует таблицу» | rg по имени + ручной просмотр PHP |
table_usage: 1 вызов со ссылками |
ручной обход |
| «Можно ли так записать» | попытка запуска | write_check: 1 вызов |
целый цикл правки |
| «Свежий ли индекс» | не проверяется вовсе | freshness: 1 вызов |
предотвращённый неверный вывод |
Последняя строка — про то, что экономить можно не только токены, но и неверные решения.
10.4 Соотношение сигнал/шум в ответах¶
Отдельный показатель, который мы считаем обязательным: доля фактов со ссылкой path:line.
| Состояние | Проверяемость |
|---|---|
| До улучшений | 30% |
| После улучшений | 100% (по операциям, работающим с кодом) |
В общем зачёте по всем операциям — 24,4%, потому что schema возвращает колонки таблиц, у которых номера строк не существует. Это не дефект, а свойство: мы не начисляем проверяемость там, где её не может быть, и не «дорисовываем» ссылки для красоты отчёта.
Факт без адреса для нас бесполезен: его нельзя проверить, а значит, на него нельзя опираться.
11. Двадцать один дефект¶
Главный результат проекта. Это то, что замеры скорости не показывают.
flowchart LR
subgraph R["Найдено замером (5 дефектов RAGRAF)"]
R1["Метрика векторов:<br/>score = 0.000 у всех"]
R2["RRF поднимал шум<br/>выше точных совпадений"]
R3["Чанк до 11 331 токена<br/>в процедурном PHP"]
R4["Слой схемы заявлен,<br/>но не реализован"]
R5["table_usage без path:line:<br/>проверяемость 30%"]
end
subgraph D["Найдено разработкой (9 дефектов agreements)"]
D9["Права расширены:<br/>employee мог mark_paid"]
D10["AuthorUserID/UpdatedBy<br/>не существуют"]
D11["Сигнатуры аудита<br/>не совпадали"]
D12["Пустой справочник делал<br/>форму неотправляемой"]
D13["opcache давал<br/>ложную ошибку"]
D14["Отклонённая запись — тупик"]
D15["Шаг с альтернативными<br/>ролями нельзя согласовать"]
D16["Защита полей §5.7<br/>не работала"]
D17["SubmittedAt не существует —<br/>ПРЕДОТВРАЩЁН"]
end
R4 -.->|"вызвал"| D7
D7 -.->|"вызвал"| D8
style D14 fill:#2f6f4f,color:#fff
style D6 fill:#b23a32,color:#fff
Дефекты RAGRAF (найдены полигоном и проверками механизма)¶
| № | Дефект | Последствие |
|---|---|---|
| 1 | Метрика векторного поиска не работала: score = 0.000 у всех результатов |
Поиск систематически вёл не туда. Выглядело как «пустой результат», а не ошибка |
| 2 | RRF без весов поднимал шум выше точных совпадений | Точное совпадение шло вторым после случайного файла-примера |
| 3 | Чанкинг процедурного PHP: 20 файлов без функций давали один чанк до 11 331 токена | Размытый чанк слабо совпадал со всем подряд и вылезал наверх |
| 4 | Слой схемы БД заявлен, но не реализован: schema() возвращал 0 таблиц |
Две операции фасада бесполезны; это и вызвало дефект №7 в разработке |
| 5 | table_usage/repo_map не отдавали path:line |
Проверяемость ответов 30% вместо 100% |
| 6 | Сканирование молча находило 0 файлов на коротком 8.3-пути корня | Отличить «в корпусе нет файлов» от «путь не совпал» было невозможно |
| 7 | Порядок ревизий неопределён: сортировка по времени с точностью до секунды | После реиндексации бралась прежняя ревизия, и сумма «не совпадала» |
| 8 | Авто-реиндексация копила экспорты CPG: 37 каталогов по 40 МБ (1,5 ГБ) | Место на диске кончалось бы после нескольких сотен обращений |
Разбор дефекта №1 — он показателен. Все оценки векторного поиска были нулевыми. Причина: эмбеддинги модели не нормированы (норма ≈ 3,5), поэтому L2-расстояние лежит в диапазоне 3,9–4,5, и формула score = 1 − distance даёт нули для всего. При этом порядок от vec0 был верным — он совпадал с косинусной близостью, посчитанной вручную.
Исправление — точный расчёт косинуса по фактическим векторам (формула с нормами), а не приближение, годное только для единичных векторов:
q_norm = math.sqrt(sum(x * x for x in vector))
c_norm = math.sqrt(sum(x * x for x in chunk_vec))
if q_norm > 0.0 and c_norm > 0.0:
dist = float(r["distance"])
cos = (q_norm ** 2 + c_norm ** 2 - dist ** 2) / (2.0 * q_norm * c_norm)
cos = max(-1.0, min(1.0, cos))
score = (cos + 1.0) / 2.0
Плюс защита от повторения: если векторный список не различает результаты (все оценки равны), он не подмешивается в слияние. Молчаливое слияние неразличимого списка и было причиной, почему шум обгонял точные совпадения.
Разбор дефекта №8 — он появился от нового кода. Включив авто-реиндексацию, мы увеличили частоту индексации в десятки раз. Каждая индексация оставляла экспорт CPG (40 МБ на этом корпусе), и каталог вырос до 1,5 ГБ — при том, что разбор идёт только по текущей ревизии. Старые экспорты не нужны вообще.
Исправление — уборка перед каждой индексацией:
def prune_exports(self, keep_rev: str, keep_extra: int = 2) -> int:
"""Удалить экспорты старых ревизий, кроме текущей и нескольких последних."""
exps = sorted(
(p for p in self.workdir.iterdir()
if p.is_dir() and p.name.startswith("export_")),
key=lambda p: p.name, reverse=True,
)
keep_names = {f"export_{keep_rev}_neo4jcsv"}
for p in exps[: max(1, keep_extra)]:
keep_names.add(p.name)
removed = 0
for p in exps:
if p.name in keep_names:
continue
# Файлы CPG (.bin) не трогаются: они нужны для повторного экспорта.
shutil.rmtree(p, ignore_errors=True)
removed += 1
return removed
Результат: 1582 МБ → 244 МБ. Проверка вошла в набор автотестов.
Это хороший пример того, что улучшение само становится источником нового класса дефектов: частота операции увеличилась, и то, что было терпимо при ручном запуске, стало проблемой при автоматическом.
Разбор дефекта №6 — он того же класса. Сканирование тестового корпуса находило 0 файлов без единой ошибки, потому что корень был задан коротким 8.3-путём, а resolve() давал длинный: relative_to() падал и глушился. Исправление — приведение путей к одной форме и подсчёт пропущенных каталогов вместо молчания.
Дефекты №6, №7 и №8 найдены после включения авто-реиндексации, то есть их не было бы без этой возможности. Проверка, которую написали для нового механизма, нашла дефекты в самом механизме.
Пять дефектов, найденных при доработке механизма¶
Отдельная категория: эти дефекты найдены проверками самой работы, а не полигоном и не замером скорости. Их ценность в том, что они показывают класс ошибок, который иначе остался бы навсегда: код работал правильно, а измерял себя неправильно.
Дефект 9. Свойства рёбер терялись при записи в граф¶
Понадобилось добавить в ребро «вызов → таблица» признак вида доступа
(select или insert), чтобы можно было ответить на вопрос «где именно идёт
запись». Признак вычислялся в извлекателе — и не попадал в ответ.
Причина: колонки для свойств в таблице рёбер не существовало, а функция записи ребра не принимала такого аргумента. Признак вычислялся и выбрасывался.
Урок: вычисленное значение, не доехавшее до хранилища, — это не «неполнота», а потеря. Заметить её извне невозможно: операция просто не находит данные, и это выглядит как «в индексе этого нет».
Дефект 10. Три места записи рёбер не знали о новом поле¶
Индексация упала с сообщением Incorrect number of bindings supplied.
The current statement uses 11, and there are 10 supplied. Сообщение оказалось
источником истины: рёбра пишутся в трёх местах — основная загрузка,
разрешение внешних имён (два запроса) и слияние графа свойств кода.
Это уже не дефект хранения, а дефект дублирования логики: одно и то же формируется в трёх местах вручную, и добавление поля требует помнить обо всех. Правильное решение — вынести формирование строки ребра в одну функцию.
Дефект 11. Блок качества читал чанки не из той базы¶
При снятии снимка панели обнаружилось: chunks.total = None, здоровье «нет»
на обоих рабочих корпусах, хотя индексы построены.
chunk_map и ft_chunk лежат в лексической базе, а запрос шёл по графу.
Запрос падал, исключение ловилось, и блок возвращал {"error": ...} — что в
интерфейсе выглядело как «нет данных».
Это самый опасный вид дефекта в диагностике: обработка ошибки превращала сбой в пустое значение. Администратор видел прочерк и не знал, что смотреть.
Дефект 12. Ложная тревога вместо тишины¶
Блок качества объявлял проблемой ненормированные векторы и показывал «здоровье: нет» на обоих корпусах — притом что оценки поиска были верными (разброс 0.0576, максимум 0.6627, поиск находил правильные файлы).
Причина: код перевода расстояния в оценку уже учитывает нормы:
# src/ragraf/lexvec.py
cos = (q_norm ** 2 + c_norm ** 2 - dist ** 2) / (2.0 * q_norm * c_norm)
cos = max(-1.0, min(1.0, cos))
Ненормированность — не поломка, а особенность модели эмбеддингов.
Почему это важный урок: предупреждение о несуществующей проблеме хуже отсутствия предупреждения. Администратор ищет поломку, которой нет, и перестаёт верить остальным сигналам системы. Поэтому проверка переведена в справочное поле: она сообщает, что формуле нужно учитывать нормы, но не объявляет это дефектом.
Дефект 13. Устаревшие данные выглядели как дефект кода¶
После исправления дефекта 12 на пилоте остался реальный сигнал: чанк 24 746 токенов при лимите 8 000.
Проверка чанкинга напрямую: файл config.php режется на 6 окон,
крупнейшее — 7 435 символов. Значит чанка на 24 746 токенов быть не должно.
Причина оказалась не в коде: пилот не переиндексировался после правки чанкинга (последняя ревизия в 00:11, правка кода в 05:55). В базе остались чанки старой версии.
После переиндексации: 24 746 → 6 449 токенов, чанков сверх лимита 2 → 0.
Методический вывод: сигнал о проблеме не всегда означает проблему в коде. Для различения нужен возраст индекса рядом с показателями — иначе каждый устаревший индекс выглядит как ошибка разработчика.
Дефекты разработки (найдены работой)¶
| № | Дефект | Как найден | Последствие, если бы не нашли |
|---|---|---|---|
| 9 | Права расширены: employee получал approve, mark_paid, sign, archive |
Проверка под реальной ролью на стенде | Любой сотрудник мог отметить платёж оплаченным и подписать документ |
| 10 | AuthorUserID/UpdatedBy не существуют (в журналах InitiatorID) |
Ошибка драйвера 42S22 при первом сохранении |
Создание записи не работало |
| 11 | Сигнатуры аудита не совпадали: функция принимает массив, вызывали 4 аргументами | TypeError в логе |
Аудит не писался, запись не сохранялась |
| 12 | Обязательное поле с пустым справочником делало форму неотправляемой молча | Проверка полного цикла | Пользователь видел требование заполнить поле, которого нет на странице |
| 13 | Устаревший opcache давал ложную ошибку в Shell.php |
Сравнение стендов с opcache и без | Ложный поиск несуществующего дефекта |
| 14 | Отклонённая запись — тупик: нет ни одного доступного действия | Проверка полного цикла согласования | Маршрут обрывался, автор не мог исправить |
| 15 | Шаг с альтернативными ролями нельзя согласовать (UserID = NULL) |
Проверка полного цикла | Маршрут обрывался на третьем шаге |
| 16 | Защита полей §5.7 не работала: ProtectedJson не заполнялся |
Проверка полного цикла | Согласованную сумму можно было изменить |
| 17 | SubmittedAt/SubmittedBy не существуют в журналах |
op_schema (RAGRAF) — до написания кода |
Код не был написан с ошибкой |
Что из этого дал именно индекс¶
- №17 предотвращён до написания кода:
schemaпоказал отсутствие колонок, и код сразу написан с проверкой схемы. - №10 закрыт новой операцией:
write_checkтеперь ловит такие ошибки до запуска. - №9, №14, №15, №16 найдены проверками, а не замером скорости. Ни один замер навигации их не показывает.
- №13 — не дефект кода. Ошибка возникала только на стенде, поднятом до правок. Вывод для методики: при проверке на локальном
php -Sстенд надо перезапускать или отключать opcache.
Распределение по классам¶
| Класс дефекта | Сколько | Примеры |
|---|---|---|
| Падение с понятной ошибкой | 3 | №10, №11, №13 |
| «Работает, но не так» | 8 | №6, №7, №8, №9, №14, №15, №16, №4 |
| Ухудшение качества выдачи | 3 | №2, №3, №5 |
| Заблокированная работа | 2 | №12, №17 |
Восемь из шестнадцати — это «работает, но не так». Их не видит ни компилятор, ни тест «страница открывается». Именно их и ловит связка «точная схема в индексе + проверка под реальной ролью + проверки нового механизма».
Что дали пять улучшений, подсказанных исследованием¶
Улучшения U3–U7 появились из разбора пробелов (GAPS_FOR_LLM.md), а не из
полигона. Их ценность в том, что они превратили невидимые состояния в
измеримые.
| Улучшение | Что было | Что стало |
|---|---|---|
| U3. Машинный список параметров | текстовая сигнатура молча обрезалась по 200 символов | структура с типами и обязательностью; вскрылось 33 обрезанные сигнатуры |
| U4. Признак чтение/запись | нельзя было понять, где идёт запись | write и access (select/insert/update/delete/ddl) |
| U5. Фильтр фантомных таблиц | регулярка ловила конструкции языка | unresolved_tables, признак table_in_schema |
| U6. Трассировка прав | самый дорогой дефект был невидим статически | 7 точек применения, контроль fail-closed |
| U7. Показатели качества | дефекты выглядели как «пустой результат» | чанки, нормы, сигнатуры, вердикт |
U3: почему обрезка сигнатуры — не мелочь¶
На пилоте revoap.com обрезка задевала 33 конструктора с продвигаемыми
свойствами. Вот реальный случай из src/Api/ApiKey.php:40:
текстовая сигнатура (200 символов, обрезана):
public function __construct( public int $id, public string $label,
public string $keyHash, public string $prefix, public array $scopes,
public int $rpmLimit, public int $dailyLimit, public ?string $e…
потеряно в тексте: $expiresAt, $lastUsedAt, $revokedAt, $createdAt
Агент, строящий вызов по этой сигнатуре, потерял бы 4 параметра из 11 и не узнал бы об этом: обрезка не сопровождается никаким признаком.
Теперь операция symbol отдаёт и текст, и структуру, и признак обрезки:
{
"signature": "public function __construct( public int $id, ...",
"signature_truncated": true,
"signature_params": [
{"name": "$id", "type": "int", "optional": false, "default": null,
"by_ref": false, "variadic": false},
{"name": "$expiresAt", "type": "?string", "optional": true, ...}
]
}
U6: как выглядит невидимый дефект прав¶
Проверка на полигоне:
поведение по умолчанию: deny | fail_closed: True
разрешений: 4 | отказов: 5
точек применения (вызывающих): 7
разрешает: 518 | case: None | return true;
разрешает: 544 | case: delete | return true;
разрешает: 596 | case: delete_file | return true;
разрешает: 616 | case: mark_all_read | return true;
Отбор по конкретному действию:
python tools/ragraf.py -p config/z-batteryservice.yaml query policy_trace z_agr_can_act --operation mark_paid
Вот оно: строка 518 разрешает действие до входа в switch, то есть для
всех, кто вообще получил доступ к журналу. Именно эта ветвь дала роль
employee права approve, mark_paid, sign и archive — дефект №9 в
отчёте по разработке. Прогон под реальной ролью нашёл его один раз; теперь
он виден статически, до запуска, и для любого действия.
Порог накопления правок: почему пересбор не делается каждый раз¶
Замер стоимости на корпусе из 124 файлов:
| Режим | Время |
|---|---|
| Сканирование (проверка контрольной суммы) | 0,16 с |
| Индексация без графа свойств кода | 5,57 с |
| Индексация с графом свойств кода | 20,81 с |
Отношение 3,7×. Делать полный пересбор при каждом нажатии «сохранить» — значит тратить время на пересборку того, что не менялось.
Правила выведены из этих чисел:
| Накоплено правок | Режим | Обоснование |
|---|---|---|
| 0 | не нужен | индекс соответствует коду |
| 1–4 | быстрый | при сомнении дешевле пересобрать за 5,6 с, чем работать по устаревшим фактам |
| 5–19 | быстрый | поиск и сверка схемы снова достоверны |
| 20+ | полный | межпроцедурные связи пересобираются |
| старше 15 минут | полный | структура могла уйти даже при редких правках |
Агент видит накопление в ответе любой операции, поэтому понимает, насколько свежие факты получает, не делая отдельного запроса.
12. Побочный результат: оформленная страница входа¶
В процессе работы над проектом обнаружился ещё один дефект — визуальный, но того же класса «работает, но не так».
Разметка страницы входа использовала 25 классов, из которых 9 отсутствовали в таблице стилей:
z-auth-body НЕТ В CSS
z-auth-wrap НЕТ В CSS
z-auth-card НЕТ В CSS
z-auth-brand НЕТ В CSS
z-auth-title НЕТ В CSS
z-auth-sub НЕТ В CSS
z-auth-form НЕТ В CSS
z-auth-submit НЕТ В CSS
z-auth-foot НЕТ В CSS
Страница открывалась, форма работала, авторизация проходила. Поэтому дефект не бросался в глаза — но собственной вёрстки у страницы не было: карточка не центрировалась, поля не выравнивались, кнопка шла вплотную к последнему полю.
Найдено перебором: все классы из шаблона против всех правил в файле стилей. Проверка вошла в набор автоматических.
Что сделано¶
Стили написаны по приёмам shadcn/ui, но на токенах проекта --z-* (правило проекта: цвета только через токены, внешние ресурсы запрещены). Справочник с точными значениями — отдельный документ.
Три значения взяты из фактического реестра компонентов shadcn, а не «на глаз»:
| Элемент | Значение | Откуда |
|---|---|---|
| Высота поля ввода | 36px | h-9 = 2.25rem в реестре (не 40px, как часто думают) |
| Высота кнопки | 40px | h-10 — в shadcn кнопка выше поля |
| Кольцо фокуса | 3px с прозрачностью 50% | ring-[3px] ring-ring/50 |
| Ширина карточки | 384px | max-w-sm = 24rem |
| Радиус | из токена --z-radius-sm |
вместо выдуманного значения |
Центрирование — тот же приём, что в shadcn: flex с выравниванием по обеим осям и отступами, которые растут на широком экране.
.z-auth-wrap {
min-height: 100svh; /* svh, а не vh: на мобильном vh «прыгает» */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
}
@media (min-width: 768px) {
.z-auth-wrap { padding: 40px; }
}
Поле входа получило явную высоту (общее правило давало 34px):
.z-auth-form .z-input {
height: 36px;
min-height: 36px;
padding: 0 12px;
font-size: 14px;
border-radius: var(--z-radius-sm);
background: var(--z-surface);
}
Индикатор загрузки кнопки — без картинок и спиннеров, через псевдоэлемент:
.z-auth-submit[data-loading] {
color: transparent; /* текст остаётся для программ чтения с экрана */
}
.z-auth-submit[data-loading]::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--z-on-primary);
border-top-color: transparent;
animation: z-auth-spin 600ms linear infinite;
}
Отдельно исправлен дефект внутри дефекта: иконки «показать/скрыть пароль» переключались атрибутом hidden, но правило display могло его перебить — обе иконки показывались бы одновременно.
Проверка¶
26 проверок через HTTP: разметка, подключение стилей, наличие правил для всех классов, отсутствие литеральных цветов, локальность ресурсов, доступность формы (связь label/input, обязательность, autocomplete, состояние кнопки пароля, skip-ссылка), размеры полей и кнопки.
Результат: 26 из 26. В файле стилей — 48 правил .z-auth-* и 7 правил .z-pass*.
13. Что не получилось и где границы¶
Честная часть. Здесь то, что мы не утверждаем.
13.1 Границы замеров¶
- Замер инструментальный, а не «два агента решали задачи». Он даёт воспроизводимую нижнюю границу числа вызовов и проверяемость фактов, но не учитывает рассуждения модели. Сравнение «агент с индексом против агента без» — следующий уровень, и он ещё не сделан.
- Выборка мала. 14 задач навигации и одна реальная разработка. Этого достаточно, чтобы увидеть разницу, и недостаточно для статистического вывода.
- Токены — оценка. Коэффициенты замерены, но инференс не измерялся.
- Baseline посчитан в пользу отказа от индекса — одно открытие файла на задачу. Обе цифры (28 и 43) приведены в артефактах, чтобы читатель видел разброс.
13.2 Что в RAGRAF неполно¶
| Ограничение | Суть |
|---|---|
| CFG/DFG/PDG не слиты в граф | Из CPG извлечены 444 464 ребра потока управления и 930 567 рёбер потока данных, но в граф слиты только вызовы (540 подтверждённых) |
slice — не настоящий PDG-срез |
Операция использует собственный обход по вызовам, а не срез по графу зависимостей |
| Модель эмбеддингов не нормирует векторы | Пришлось считать косинус точно; нормировка при записи — улучшение на будущее |
| Гибридный поиск дорогой | До 1 с на запрос: считается эмбеддинг. Кэша эмбеддингов нет |
| Проверяемость 24,4% в общем зачёте | По операциям, работающим с кодом, — 100%; у schema строк не существует |
| Docblock привязан к символу только в одной операции | Контракт параметров доступен через symbol, но не через code |
13.3 Что осталось в разработке¶
Раздел /agreements/ доведён до состояния «работает и проверено», но не до полного объёма ТЗ:
- Готово: список журнала, поиск, создание и правка записи, карточка записи, действия (согласование, отклонение, отзыв, аннулирование, удаление), маршруты, матрица прав, пять журналов (договоры, платежи, нарушения, отпуска, задачи), справочники, страница входа.
- Не сделано: табель (отдельная модель с периодами), график отпусков, уведомления, вложения, экспорт, сроки хранения, права на уровне подразделений через отдельный интерфейс.
13.4 Что оказалось ложной тревогой¶
Ошибка в Shell.php выглядела как дефект кода: curl_multi_remove_handle() expects exactly 2 arguments, 4 given при том, что в указанной строке вызывалась совсем другая функция. Причиной был устаревший opcache в долгоживущем процессе php -S. Стенд, поднятый заново, отдавал страницу без ошибок.
Вывод для методики, который стоит записать: при проверке на локальном сервере перезапускайте стенд после правок кода или отключайте opcache. Иначе теряются часы на поиск несуществующего дефекта.
14. Выводы¶
Что получилось¶
RAGRAF работает и даёт измеримый эффект. Один вызов вместо двух-шести, ноль полных чтений файлов вместо одного на задачу, 100% фактов со ссылкой file:line, 92% попаданий с первого запроса при 46% до улучшений.
Контроль свежести решает проблему, ради которой он делался. Проверка контрольной суммы выполняется перед работой, при расхождении индекс обновляется сам, а поле freshness едет в каждом ответе. Проверено на живом корпусе: правка файла → реиндексация → новая функция видна в индексе → повторная проверка индексацию не запускает. И отдельно проверено, что индексация не портит контрольную сумму — это было главное опасение.
Разработки разделены. У каждой свой каталог, своя ревизия, своя сумма. Правка в одном корпусе не считается изменением в другом — проверяется тестом.
Что дало больше всего пользы¶
Не экономия токенов. Шестнадцать дефектов, из которых:
- 4 предотвращены до запуска кода (один — операцией
schema), - 6 относятся к классу «работает, но не так» и не видны ни компилятору, ни обычному тесту,
- 3 из них — прямые обрывы маршрута и неработающая защита полей в подсистеме документооборота.
Три дефекта RAGRAF (пустой слой схемы, неработающая метрика векторов, отсутствие path в ответах) означали, что часть возможностей существовала только в документации. Без реальной задачи они остались бы незамеченными — синтетические тесты их не показывают: они выглядят как «пустой результат», а не как ошибка.
Что делать дальше¶
- Сравнить живых агентов. Задачи не навигации, а правки кода, два прогона: с индексом и без. Это следующий уровень доказательности, и он важнее любых дальнейших оптимизаций.
- Добавить показатели качества в
index_status. Дефекты №1 и №3 диагностировались бы сразу, если бы в состоянии индекса были распределение норм векторов и размер чанков. - Слить CFG/DFG в граф. Настоящий срез по графу зависимостей вместо обхода по вызовам.
- Обязать
write_checkперед каждой записью — операция уже самая востребованная послеschema(14 вызовов). - Закрыть оставшиеся части ТЗ: табель, график отпусков, уведомления, вложения.
Одна мысль вместо итога¶
Индекс кода — это не «ускорение поиска». Это перенос части работы из модели во внешнюю структуру, которая знает, чего она не знает. Сигнатура array $data честна и бесполезна. Docblock с составом ключей — полезен. Пометка stale: true рядом с фактом — обязательна, иначе факт опаснее отсутствия факта.
Самое ценное свойство RAGRAF не в том, что он отвечает быстро. А в том, что он отказывается отвечать уверенно, когда не имеет права.
Приложение: артефакты¶
| Файл | Что внутри |
|---|---|
docs/03_implementation/RAGRAF_EFFECT_REPORT.md |
Отчёт об эффекте внедрения с числами |
docs/03_implementation/POLYGON_REPORT.md |
Отчёт по полигону (дефекты 1–5, замеры) |
docs/04_research/GAPS_FOR_LLM.md |
Исследование: чего не хватало RAGRAF |
docs/04_research/WORKFLOW_ANALOGS.md |
Решения по двум спорным вопросам ТЗ с обоснованием |
docs/04_research/SHADCN_LOGIN_REFERENCE.md |
Справочник значений shadcn для переноса в чистый CSS |
src/ragraf/catalog.py |
Реестр разработок и раздельный учёт |
src/ragraf/watch.py |
Сторож контрольных сумм и авто-реиндексация |
src/ragraf/telemetry.py |
Журнал обращений (источник чисел статьи) |
tests/test_watch.py |
Проверка сторожа на временном корпусе (31 проверка) |
tests/test_watch_live.py |
Проверка сторожа на живом корпусе (20 проверок) |
tools/bench_corpus.py |
Замерщик «без индекса / с индексом» |
bench/results/*.json |
Сырые артефакты всех замеров |
Все числа в статье воспроизводимы: артефакты замеров лежат рядом, команды для повторного прогона приведены в тексте.