dimhold.by
← Статьи

2 строки комментария превратили 62 находки в 0

Ревью кода у меня идёт в 3 стадии. N агентов читают файл и сообщают о дефектах, K скептиков пытаются опровергнуть каждое сообщение, один агент склеивает уцелевшее в список. Собрал я это так, потому что у claude CLI, которым я гоняю агентов, есть исследовательский воркфлоу, построенный ровно на этой форме. Его стадии лежат в бинарнике простыми строками: разослать поиски, вытащить опровержимые утверждения, 3 голоса адверсариальной проверки на утверждение при 2 опровержениях на убийство, потом склеить смысловые дубли. Я перенёс это на ревью кода, не спросив, нужно ли ревью кода такое. С тех пор я плачу за все 3 стадии и ни разу не выбирал N или K по замеру.

На этой неделе я этот замер сделал. 191 вызов модели и $11.76, считая контрольный заход, который я выбросил. Стадия, которую я защищал бы яростнее всего, взяла из этих денег $4.57 и ни разу не разошлась сама с собой. Чего я не ждал, так это того, как часто неправым оказывался мой собственный измерительный аппарат, а не агенты. Так вышло 5 раз. Худший из этих 5 чуть не ушёл заголовком этого поста.

Файл

clean.mjs это сборщик метрик того рода, который тут действительно нужен. 184 строки: семафор поверх очереди FIFO, ретраи с бэкоффом и джиттером, ключ кеша, перцентиль по ближайшему рангу и строка дневной сводки. Второй скрипт сажает в него 12 дефектов буквальной заменой строк. Файл, который читают агенты, выходит на 176 строк. Реестр записывает, куда лёг каждый дефект. На каждый дефект есть отдельный файл, несущий только его.

Дальше каждый дефект доказывается прогоном чистого файла против файла, который несёт только его. Доказывать против мутированного файла это очевидный ход и он даёт неверный ответ. Там один дефект умеет спрятаться за другим. Один из моих так и делает.

Каждая стадия работает из свежей временной папки. Никакого CLAUDE.md на пути и никаких инструментов. Это я проверил руками, а не поверил отчёту обвязки. Конфиг MCP пустой. Операционная инструкция этого репозитория это 889 строк правил, которые я написал сам себе. Она молча уходит в промпт, если рабочая папка это репозиторий. Node 22.23.1, claude CLI 2.1.235, модель claude-opus-5 на 4 ядрах.

Поиск

8 искателей получили один и тот же промпт, по 4 в полёте. Они вернули 108 находок за $0.76. Мой счётчик засчитывает находку дефекту, если названная строка попадает в посаженный отрезок, расширенный на 2 в каждую сторону. Он сказал, что средний искатель ловит 10.25 из 12 и что семафор не нашёл никто.

Я прочитал все 108 утверждений, потому что последняя часть выглядела неправдоподобно. Семафор нашли все 8. 6 из них назвали строку 25, 2 строку 26. Это тот единственный live++, который в файле остался. Второй я удалил на строке 18, а оценивал их по месту собственной правки. Окно заодно выдало 2 искателям зачёт за дефект 12. Их утверждения на этих строках про другое: тело ответа 200, которое не JSON и папка журнала, которую никто не создаёт.

Значит дефект 12 не нашёл никто и в этом файле до него не доходит ни один неуспешный статус. Дефект 12 убирает проверку статуса после цикла ретраев. Другой посаженный дефект убирает проверку, которая не даёт плохому статусу вообще попасть в ретрай. Цикл теперь либо возвращает хороший ответ, либо кидает исключение. Я прогнал через файл 9 неуспешных статусов и до тела ответа не дошёл ни один. Через файл, несущий только дефект 12, доходят 6.

сколько из 8 искателей нашли каждый посаженный дефект P1 P2 P3 P4 P5 P6 P7 P8 P9 P10 P11 P12 окно строк 0 8 8 8 8 8 8 8 8 8 8 2 после чтения 8 8 8 8 8 8 8 8 8 8 8 0 P1 L18 P12 L155
12 посаженных дефектов, посчитанных 2 способами. Окно строк засчитывает находку, если её строка попала в посаженный отрезок, расширенный на 2. Чтение самих утверждений переводит P1 с 0 на 8, а P12 с 2 на 0.

После правки чтением все 8 искателей нашли одни и те же 11 дефектов. На посаженных дефектах в этом заходе 7 из 8 не купили мне ничего. Оба способа счёта заканчиваются на 11 из 12. Дефекты внутри у них при этом разные.

Это один заход, поэтому я прогнал тех же 8 искателей по тому же файлу с тем же промптом ещё раз. 108 находок, ровно столько же. Это я считаю совпадением. Из 26 строк, названных хоть в одном заходе, в обоих названы лишь 18. Объединение снова 11 и те же самые 11, но один искатель вернулся с 10. Он пропустил политику ретраев, которая ретраит 400. Пропустил по-настоящему. Во всех его 14 находках про ретраи нет ничего. Промпт разрешает 15, так что вытеснить их было нечем.

Файл, в который не посажено ничего

Те же 8 искателей на исходном clean.mjs вернули 0 находок. 8 пустых списков из 8. Я записал это как частоту ложных срабатываний и пошёл дальше, довольный своим кодом.

clean.mjs открывается докблоком. Его строки с 4 по 6 говорят, что это чистая версия. Там же сказано, что дефекты в неё сажает другой скрипт. Строка 4 это пустая строка комментария. Оставшиеся 2 несут само утверждение. Скрипт мутации выбрасывает ровно эти строки, когда пишет файл, который читают искатели. То есть в своём контроле я сообщил 8 ревьюерам, первым же, что попалось им на глаза, что дефекты живут в каком-то другом файле.

Я вырезал эти строки, прогнал тех же 8 искателей и получил 74 находки. Ни одного пустого списка. Это выглядело как ответ, это чуть не стало заголовком поста и чистым всё равно не было. Если вырезать 3 строки, каждая строка ниже поднимается на 3. А промпт подаёт модели листинг с проставленными номерами. Искатели увидели 2 изменения там, где я хотел показать им одно.

Тогда я написал третий файл. Те 2 строки, которые несут утверждение, заменены на месте 2 строками про то, что код делает. Счётчики берутся по разу на пост и кешируются в журнале. Дневная сводка читает это обратно. 184 строки как в clean.mjs, каждый номер строки на своём месте, ни один байт кода не тронут. diff отдаёт эти 2 строки и больше ничего. 62 находки от тех же 8 искателей. 1 из 8 всё равно вернулся пустым. Был ещё более ранний заход по тому же файлу, который я выбросил из-за того, что 2 искателя упали на 429. Ответившие 6 принесли на всех 59 находок и ни одного пустого списка.

одни и те же 5 строк кода, прочитанные 4 раза докблок находки пометили незащищённый JSON.parse subject.mjs посажено дефектов: 12 строк: 176 108 7 / 8 clean.mjs не посажено ничего строк: 184 говорит, что файл чистый 0 0 / 8 clean-noheader.mjs не посажено ничего строк: 181 74 8 / 8 clean-filler.mjs не посажено ничего строк: 184 62 7 / 8
Один и тот же сборщик метрик, прочитанный 4 раза. Во втором файле не посажено ничего и докблок об этом говорит: только он из 4 вернулся пустым. Третий эти строки вырезает, четвёртый заменяет их на месте, поэтому в четвёртом снова 184 строки и JSON.parse стоит на своей исходной строке. Последний столбец это одна функция в 5 строк, побайтово одинаковая во всех 4 файлах.

Хороши ли эти находки, я могу решить только прогоном самого файла. Я свёл 74 к 16 различным утверждениям и решил каждое скриптом. Все 16 подтвердились и покрывают все 74. Одно из 16 решено прогоном лишь наполовину. К нему я вернусь в конце. Вот 5 из них. Потолок 0 вешает семафор намертво. Таймер отмены снимается до чтения тела, поэтому при таймауте в 40 миллисекунд чтение тела, занимающее 120 миллисекунд, доходит до конца. cacheKey("a", "b c", ["views"]) и cacheKey("a b", "c", ["views"]) оба возвращают 0f7d1e1c5f119f46. Один упавший пост заставляет Promise.all выбросить все уже собранные строки. А summaryLine строит префикс даты из имени файла, поэтому выдаёт человеку строку 2026-09-05.md: 12 posts, 400 views, p90 90. Мой чистый файл чистым не был.

Окно строк говорит, что 60 из 62 находок третьего файла лежат в пределах 2 строк от того, что эти 16 утверждений уже решили. На этот раз я ему не поверил. При чтении оказывается, что 4 находки говорят про запросы в полёте, которые никто не дедуплицирует. Окно кладёт их под утверждение о том, что при попадании в журнал любого возраста ответ отдаётся без запроса. Решает же их другое утверждение, что 2 поста с одним и тем же ключом оба мимо кеша и оба идут в сеть. Ещё 4 находки говорят про fields, который не доезжает до запроса. Окно раскидывает их по 2 другим утверждениям, тогда как решает их то, где сказано, что fields меняет ключ кеша и больше ничего. 16 утверждений действительно решают все 8 этих находок. Окно ошибается в том, какое утверждение решает какую находку.

К чему я возвращаюсь снова и снова, так это к readJournal. 5 строк, незащищённый JSON.parse, побайтово одинаков во всех 4 файлах. В мутированном файле его пометили 7 искателей из 8 и снова 7 из 8 в повторном заходе. А в clean.mjs, где он стоит на строке 83, его не пометил ни один из 8. Зато в третьем файле, где он стоит на той же строке 83, его пометили 7 из 8. Для первой пары чисел у меня было готово объяснение, что-то про то, как остальной файл меняет то, что выглядит подозрительно. Ту пару оно переживает, потому что мутированный файл и правда несёт 12 дефектов. С последними 2 из них оно не делает ничего. Те же 184 строки, та же строка 83, разница в 2 строки комментария. Ответ сдвинула фраза, которую я написал про собственный код.

Проверка

Для второй стадии мне понадобились ложные утверждения, потому что почти всё сказанное искателями было правдой. Я написал 12 штук в их же регистре и каждое опроверг прогоном кода. Очередь работает как LIFO, перцентиль сортирует на месте и ещё 10 таких. Против них стоят 11 истинных утверждений словами самих искателей, без правки. Дальше по 3 независимых верификатора на утверждение. И так дважды. Адверсариальный промпт велит опровергать и при сомнении считать опровергнутым. Нейтральный только спрашивает, истинно ли утверждение.

Вышло 138 вызовов и $4.57.

во что обошлась каждая стадия ревью и что она изменила поиск 8 вызовов $0.76 найдено 11 из 12 проверка 138 вызовов $4.57 сдвинуто 0 из 23 вердиктов синтез 3 вызова $0.68 16 записей
149 вызовов модели на 3 стадии одного ревью. Столбик это то, во что обошлась стадия, строка под ним это то, что вышло. Времени на диаграмме нет: проверка заняла 219.7 секунды при 5 в полёте против 70.4 у поиска при 4.

Все 3 верификатора сошлись на всех 23 утверждениях в обоих режимах. 2 промпта дали одинаковые вердикты на каждом утверждении. Голосование не купило ничего. Адверсариальная формулировка тоже.

Результат этот узкий. Таким его сделал мой же способ построить проверку. Мои 12 ложных утверждений опровергаются чтением 3-5 строк файла. Утверждение, для которого надо держать в голове сразу 2 функции, это как раз то место, где второе мнение должно окупаться. Вот его я и не померил. Сами 11 истинных утверждений собраны по одному на посаженный дефект, поэтому 18 находок, легших мимо посаженных строк, до верификатора не дошли вообще. Именно они и были самыми вероятными кандидатами в ложные. А что почти всё сказанное искателями оказалось правдой, это моё впечатление от чтения, а не число. Число у меня есть только по контрольному файлу.

Синтез

108 находок на 23 различных строках, 6 из них видел только один искатель. Агент синтеза получает отчёты. Сам файл он не видит никогда. 3 прогона дали по 16 записей, по $0.227 за прогон. После того как я рассказал счётчику про 2 слияния ниже, оказалось, что прогоны ничего не потеряли и ничего не выдумали. Промпт прямо говорит агенту синтеза не выбрасывать дефект из-за того, что его видел только один ревьюер, так что этого свойства я попросил, а не обнаружил.

строк названо 23, записей после синтеза 16 названо 23 склеено 16 158 82 160 152 строки с 25 по 160 видел один искатель, 6
Где 8 искателей что-то нашли и куда это положил синтез. 2 кривые это слияния, которых счёт по расстоянию между строками увидеть не может.

Он ещё и слил 2 находки, разнесённые на 76 строк: неатомарную запись журнала и отсутствующую папку. В слитой записи он назвал обоих ревьюеров. Мой счётчик насчитал тут 2 неверных авторства. Второе такое же слияние он счёл потерянной находкой. Оба вердикта держались, пока я не прочитал записи и не рассказал ему про них. Счётчик сравнивает только номера строк. Про одно слияние в записи сказано прямо, во второй записи названы только оба пути.

Вот и пятый. Окно строк ошиблось в 2 дефектах, причём в разные стороны. Оно же ошиблось второй раз, в том, какое утверждение покрывает какую находку в контроле. Докблок в моём контроле оказался неправ насчёт всего контроля. Моя правка этого докблока сдвинула 178 номеров строк, которые я двигать не собирался. Счётчик синтеза ошибся насчёт обоих слияний. Все 5 это промахи моего аппарата. Каждый раз, когда мой аппарат расходился с агентом, неправым оказывался аппарат.

Чего я не проверял

Один файл, дефекты посажены заменой от 1 до 6 строк каждый. Настоящая работа ревью это дифф по нескольким файлам и дефект там живёт во взаимодействии. Стадия поиска прогнана дважды, синтез 3 раза. Проверка прогнана по одному разу в каждом из 2 режимов промпта. Каждый вариант докблока получил один заход, поэтому 62 против 0 это одна пара заходов, а не распределение. Ложные утверждения для верификаторов мои и они лёгкие. Из 16 контрольных утверждений одно решено прогоном лишь наполовину. Что запись журнала не создаёт свою папку, я показал. Отсутствие переименования я взял чтением. И как раз про переименование говорили все 8 находок на той строке. В третьем контрольном заходе 1 искатель из 8 вернул пустой список. Почему именно он, я не установил. Те 2 строки, которые я поставил вместо выдающей строки, это не пустое место. Они описывают журнал и кеш, а там живёт изрядная часть 62 находок. Значит сравнил я автора, который говорит, что файл чистый, с автором, который описывает кеширование. Выдающую строку я сравнил не с пустым местом. И убранные 2 строки несли сразу 2 сигнала, отбой и указатель на другой файл. Убрал я их вместе. Какой из 2 сработал, я не разделил. Цена вызова искателя в $0.094, на которой стоит $2.07, взята с мутированного файла. Чистый файл вытягивал более длинные ответы и доходил до $0.29 за вызов, так что $2.07 это дешёвый край того, что я видел.

Из 191 вызова 138 это верификаторы и 48 это искатели за 6 заходов. 3 ушли на синтез, а последние 2 это повторы после 429. $4.57 против $0.76 выглядит как цена в 6 раз выше за стадию, которая ничего не изменила. Большая часть этого разрыва это второй режим промпта и 12 утверждений, которые я написал сам. Ревью не делает ни того, ни другого. Если считать сопоставимо, одно ревью этого файла стоит $2.07. Поиск это $0.76 из этого, синтез $0.23, а проверка 11 утверждений, переживших дедупликацию, $1.09. Эти 3 числа в сумме дают на цент больше итога, потому что каждое округлено. На 2 искателях и 1 верификаторе то же ревью стоит $0.78. Я перехожу на это, но второго искателя оставляю из-за того захода, который вернулся с 10.

152 из 190 файлов на JavaScript и TypeScript в этом репозитории открываются докблоком с рассказом, для чего файл. Большую часть писал руками я. Померить дальше я хочу дефект, который живёт сразу в 2 файлах.