haiodo.blog
В погоне за нейрослопным кодом. Поймал не то, за чем гнался

В погоне за нейрослопным кодом. Поймал не то, за чем гнался

Искал, чем машинный код отличается от человеческого: 33 репозитория, 7 языков, 587 тысяч функций, 13.5 млн строк. Детектора не нашёл, зато нашёл величину, которая не сдвинулась за 25 лет. Попутно опроверг три своих вывода из четырёх.

Как отличить слоп от кода


В прошлый раз я мерил текст: комментарии, коммиты, прозу. Вопрос напрашивался сам - а код? Если у машинной прозы есть измеримые приметы, должны быть и у машинного кода.

Примет я не нашёл. Нашёл другое, про что и пост.

Робот-сыщик с лупой идёт по следам, которые ведут к линейке

Повод дала статья Measuring the sloppiness of code. Там предлагают две метрики из SlopCodeBench, обе с красивым разделением:

Verbosity = |строки ast-grep ∪ строки клонов| / LOC
mass(f)   = CC(f) * SLOC(f)
Erosion   = Σ mass(f), где CC(f) > 10  /  Σ mass(f)

Erosion - это доля массы кода, сидящей в тяжёлых функциях. У живых репозиториев обещано 0.31 ± 0.17, у кода агентов 0.68 ± 0.20. Вдвое. Выглядит как готовый детектор.

Я написал считалку на go/ast и прогнал на Go stdlib.

erosion:
  go/src/go     0.908
  encoding      0.899
  net/http      0.779

Стандартная библиотека Go, которую полируют с 2009 года, - выше «агентского» уровня. Не на процент, а сильно.

Подумал, что дело в моём SLOC: я считал физические строки, а надо значащие. Пересчитал. Сдвинулось в третьем знаке: 0.775 против 0.769.

Дело не в моём коде. Порог CC > 10 абсолютный, а масса CC * SLOC усиливает большие функции квадратично. В stdlib кодеки и парсеры - это гигантские switch, и они правы. Позже я добавил coreutils (0.913) и sqlite (0.843) - код, у которого копирайты начинаются с восьмидесятых и с двухтысячных.

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


Тогда что вообще устойчиво

Раз готовая метрика не подошла, я стал искать величины, которые держатся сами - через языки, через десятилетия, через смену авторов. Взял четыре кандидата и начал добавлять корпуса.

К концу получилось 33 корпуса, 7 языков, 587 тысяч функций и тринадцать с половиной миллионов строк кода.

корпусязыкстрокфункцийистория
linux 5.10.269, fs kernel mmC2 439 21041 638
postgres src/backendC1 394 96016 42965 380 коммитов, 60 авторов, с 1996
sqlite 3.46.0C371 6283 187
coreutils 9.4 srcC125 1341 349
eclipse.platform.uiJava1 190 81271 30044 467 коммитов, 717 авторов, с 2001
eclipse.jdt.coreJava1 144 51286 10227 900 коммитов, 248 авторов, с 2001
eclipse.jdt.uiJava1 141 62667 65131 210 коммитов, 262 автора, с 2001
eclipse.platformJava933 76651 95645 381 коммит, 423 автора, с 2001
eclipse.platform.swtJava655 09433 22332 106 коммитов, 282 автора, с 2001
org.eclipse.rcpttJava781 58242 4762 218 коммитов, 40 авторов, с 2014
dltk.coreJava566 83027 9056 512 коммитов, 37 авторов, с 2007
dltk.tclJava139 7507 4292 856 коммитов, 13 авторов, с 2007
rcpmlJava17 0621 082CVS, ревизии 2005-2008
cpython LibPython428 04861 210
tokio 1.53.1Rust121 6367 157
ripgrep 15.2.0Rust68 8622 953
Go stdlib 1.27, go net/http encodingGo165 3984 539
TenniarbSwift45 9721 550374 коммита, с 2017
hum1izerGo22 544328

Плюс четыре проекта с заявленным машинным происхождением и четыре моих рабочих - о них ниже. Полная таблица с версиями и источниками лежит в репозитории.

Вот итог по четырём кандидатам.

кандидатчто вышло
глубина вложенностиустоял
плотность функцийопровергнут
доля тривиальных функцийопровергнут
медиана цикломатикидержится, но бесполезна

Три из четырёх я опроверг сам, пока добавлял корпуса. По порядку.


Опровержение первое: подпись, которой нет

У меня в ~/.claude/CLAUDE.md лежат правила для агента: не плоди абстракции, не делай хелпер для одноразовой операции, три похожих строки лучше преждевременного обобщения.

Сравнил два своих проекта, написанных под этими правилами, со stdlib:

корпусфункций до 3 строкмедиана SLOCмедиана CC
go/src/go32.3%72
net/http24.2%82
hum1izer7.9%134

Картина читалась сама: правила убрали однострочные обёртки, функции стали плотнее. Отпечаток инструкций в цифрах. Я это записал и почти поверил.

Потом добавил ещё два своих проекта - того же автора, того же происхождения, под тем же глобальным файлом.

проектфункций до 3 строкмедиана SLOCCC>10
веб-бэкенд на Go + Svelte26.1%73.8%
веб-сервис, Go + TS10.8%136.5%
CLI с TUI18.3%1212.2%
hum1izer: парсер и TUI7.9%1316.5%

Разброс внутри группы - втрое по обёрткам и вчетверо по CC>10. Он полностью перекрывает диапазон stdlib: веб-бэкенд вышел мелкогранулярнее net/http, а hum1izer плотнее всех корпусов замера.

Зато видно, что форму задаёт на самом деле. Слоистый бэкенд - это тонкие методы репозитория и хендлеры по три строки. Парсер с экраном разбора - это алгоритмы, которые не режутся на обёртки. Одни и те же правила, один и тот же автор, разница втрое - и объясняется она задачей, а не инструкциями агенту.

Добил это мой собственный код двадцатилетней давности. RCPML, Java, ревизии с 2005 по 2008, лежит в CVS-архиве. Писался за тринадцать лет до первого копилота. 57% функций короче трёх строк.

Мой вывод строился на том, что у hum1izer их 7.9%, а у stdlib 21-32%. Реальный диапазон по всем корпусам - от 4.4% до 57%, и решает язык со своими идиомами: в Java половина функций это геттеры и делегаты. Тот же я, тот же подход, разница втрое - просто язык другой.

На двух проектах видно ровно то, что ищешь.


Опровержение второе: плотность функций

Считал функции на тысячу значащих строк. По восьми Go-корпусам получалось 38-56, и это выглядело законом: независимо от возраста, автора и задачи.

Потом пришла Java: 57.3, 60.7, 63.4, 69.3, 102.5.

Два почти непересекающихся диапазона, и причина та же - геттеры. Величина описывает язык, а не дисциплину.

Опровержение третье: медиана цикломатики

«От 2 до 4 во всех корпусах без исключения» - записал я, имея восемь корпусов. На тринадцатом один из проектов дал 1, потом оба Java-корпуса тоже дали 1.

Величина и правда устойчивая, диапазон 1-4. Только порога из неё не сделать: разница между «медиана 1» и «медиана 4» - это разница между Java с геттерами и Go с обработкой ошибок, а не между хорошим и плохим кодом.


Что устояло

Глубина вложенности. Максимум уровней if/for/while/switch внутри одной функции.

Медиана равна 1 во всех тридцати трёх корпусах. Во всех семи языках. В коде, которому и год, и тридцать лет. p95 равен 2 или 3 в двадцати семи корпусах из тридцати двух, где я мерил разбором дерева.

Доля функций глубже четырёх уровней:

корпусязыкdepth>4
tokioRust0.1%
ripgrepRust0.4%
linux/kernelC0.4%
cpython/LibPython0.5%
eclipse.platform.uiJava0.7%
org.eclipse.rcpttJava0.7%
eclipse.platformJava1.1%
eclipse.jdt.uiJava1.2%
dltk.coreJava1.5%
eclipse.jdt.coreJava1.6%
tenniarbSwift2.1%
go/src/goGo2.3%
postgresC2.8%
coreutilsC7.3%
sqliteC7.3%

Разброс - в семьдесят раз между краями, но двадцать девять корпусов из тридцати двух сидят ниже трёх процентов. Выбиваются sqlite, coreutils и один маленький проект на TypeScript.

Для сравнения, обычный порог CC > 10 по тем же корпусам гуляет от 0.6% до 21.5%. И упирается он не в неряшливость: go/src/go и encoding дают по 11.9%, PostgreSQL 13.0%. Критерий, который срабатывает на каждой девятой функции стандартной библиотеки, агент научится игнорировать первым же.


Шесть корпусов из этих тридцати трёх писал я сам

Вышло случайно: под руку попались проекты, в которых я участвовал, причём в разные годы.

проектмои коммитыдолягоды
rcpml1067 ревизий CVS85%2005-2008
dltk.tcl120342%2007-2013
dltk.core152723%2007-2016
org.eclipse.rcptt1818%2015-2022
Tenniarb37399.7%2017-2026
Intabia Platform274128%2021-2026

Это 110 тысяч функций из 587, пятая часть замера. Двадцать лет, пять языков. Глубина у всех шести в норме: rcpml 0.9%, rcptt 0.7%, dltk.core 1.5%, платформа 1.9%, Tenniarb 2.1%.

Против гипотезы про «подпись правил в коде» это работает лучше остального. Правилам для агента у меня меньше года, а глубина в моём коде была такой же в 2005, когда я писал Java руками в Eclipse 3.1.


И она не двигается со временем

У части репозиториев есть история на двадцать пять лет, и грех было не посмотреть. Я снял состояние на конец каждого года через git archive и померил ряды.

eclipse.platform, 25 лет, 423 автора, 45 тысяч коммитов:

годфункцийdepth>4CC>10SLOC>60
200115371.11%0.91%0.26%
2005128181.93%2.71%0.98%
201383581.56%2.09%0.74%
2021134691.46%1.69%0.99%
2026519561.09%1.80%1.04%

Кодовая база выросла в тридцать четыре раза и вернулась ровно туда, откуда начала. При этом цикломатика за те же годы удвоилась, а доля длинных функций выросла вчетверо.

PostgreSQL, 30 лет: depth p95 равен 4 с 1997 года. Двадцать девять лет подряд одно и то же число. Всё остальное выросло вдвое за первое десятилетие и встало на плато.

DLTK за 18 лет: depth>4 1.69% → 1.61%. RCPTT за 11 лет: 0.75% → 0.78%.

Похоже, это потолок, через который просто не лезут. Код стареет, обрастает, цикломатика и длина функций ползут вверх - а вложенность остаётся где была.

Разрастающийся цех под неизменным низким потолком

Почему нельзя брать цикломатику

Цикломатику функции понижают ровно одним способом: выделяют кусок в отдельную функцию. А у меня в правилах для агента записано «не делай хелпер для одноразовой операции».

Два требования, которые нельзя выполнить одновременно.

Глубина таким свойством не обладает. Она падает от раннего возврата и guard clause - то есть от удаления кода, а не от добавления функций. Ни с одним правилом про абстракции это не конфликтует.

Отсюда же понятно, почему depth > 4 - лучший критерий, чем CC > 10, хотя они связаны. Функций глубоких, но простых, почти нет: 0.0-0.8% в тех семи корпусах, где я считал пересечение. Плоских, но сложных - от 3.7% до 16%. Плоский switch на сорок веток читается нормально, цикломатика его валит, глубина - нет.


Где правило реально работает

Внутри C цифры разошлись на две группы.

проектправило записаноdepth>4
linux/kernelда0.4%
linux/mmда0.4%
linux/fsда0.9%
PostgreSQLнет2.8%
sqliteнет7.3%
coreutilsнет7.3%

В Linux CodingStyle написано прямым текстом: «if you need more than 3 levels of indentation, you’re screwed anyway, and should fix your program». В PostgreSQL такого нет - и там 2.8% при тридцати годах истории, 65 тысячах коммитов и шестидесяти коммиттерах. Review там жёстче, чем почти везде в моей выборке. Не помогло.

А в Java, Go, Rust и Python величина держится в 0.1-2.3% вообще без писаных правил. eclipse.platform с его 1.1% отсылает к конвенциям Oracle на 37 строк, где про вложенность ни слова.

Похоже, дело в языке. В C вложенность растёт сама: исключений нет, ранний выход идёт через goto, проверка ошибок ветвится на каждом вызове. Там, где язык не сопротивляется, хватает общей культуры. Там, где сопротивляется, работает только записанное правило.


А слоп-то она различает?

Нет.

Я нашёл на GitHub репозитории, где авторы сами пишут в описании «100% AI-generated» и «written entirely by Claude Code». Сравнивать нужно внутри языка - я уже обжёгся на том, что метрики зависят от языка сильнее, чем от автора.

Rust дал единственную пару с приличными выборками:

корпускто писалфункцийSLOC p95SLOC>60depth>4
tokioчеловек7157290.5%0.1%
ripgrepчеловек2953341.2%0.4%
sloposагент17540452.3%0.3%
codeprismагент4376503.3%1.4%

Машинный код отличается длиной функций: p95 45-50 против 29-34, длинных втрое больше. А глубина у одного из них - 0.3%, ровно человеческая норма.

Go и C вообще сигнала не дали: агентский Go-проект оказался чище моего, агентский C - в десять раз чище coreutils.

Тут до меня дошло, что я мерил не то, что искал. Глубина говорит про читаемость, а не про то, кто писал. Плоский код одинаково читается независимо от автора, так что отличать машину от человека она и не обязана. Для этого работает объём, про что исходная статья и пишет.


Чем мерил и где оно врёт

Взял tree-sitter: один обход дерева, восемь грамматик, CGO меня не пугал. Сверил с эталоном: на своём проекте tree-sitter и go/ast дали одинаковые 328 функций, тот же топ, расхождение цикломатики в пределах ±3.

А потом оказалось, что на C он разваливается.

файлов с ошибкой разбора:
  sqlite          75.0%
  PostgreSQL      56.8%
  linux/fs        54.6%
  coreutils       36.2%
  ---
  Java            0.0%
  Go              0.0%

Препроцессор. #if внутри тела функции ломает дерево, и функция «съедает» остаток файла. winWrite в sqlite3.c намерилась в 19703 строки при настоящих 104 - я полез проверять руками и нашёл её границы на строках 49451-49555.

Лечится отбраковкой функций с узлами ERROR внутри. Теряется 8-34% выборки, и потеря не случайная: выбывают места с самым плотным препроцессором, а они обычно и самые запутанные.

Отдельно: из шести языков, которые мне были нужны, два не завелись вовсе. Kotlin отдаёт биндинги только для c, node и rust. Swift не держит сгенерированный парсер в репозитории.

Для Swift пришлось считать глубину по отступам, без парсера. Дальше выяснилось, что парсер для неё вообще не нужен.


Парсер, которого не нужно

Цикломатику без разбора кода не посчитать. А глубину видно по отступам.

корпусдеревоотступы
go/src/go1/3/4/5, 2.3%1/3/4/6, 2.7%
net/http1/2/3/4, 0.5%1/3/3/4, 0.4%
hum1izer1/3/3/4, 0.3%2/3/3/4, 1.0%

Медиана, p90 и p95 совпадают. Хвост завышен примерно вдвое - gofmt даёт switch два уровня отступа там, где дерево видит один. Лечится порогом 5 вместо 4.

То есть единственная метрика, пережившая весь замер, считается без CGO, без грамматик и на любом языке с отступами. AST был нужен ровно для того, от чего замер велел отказаться.

Первая версия считалки по отступам выдала на одном проекте depth>4 = 13.3%. Дерево на том же проекте показывало 0.0%, поэтому я полез смотреть, что она там насчитала.

Шаг отступа я определял как минимальную разницу между уровнями. А в коде есть перенос аргументов функции, и выравнивание по открывающей скобке даёт разницу в один-два пробела. Минимум подхватывал её и делил на неё всё остальное.

Заменил на самую частую разницу вместо самой маленькой - стало 0.0%. Если бы я не знал заранее правильного ответа, 13.3% выглядели бы вполне правдоподобно.


Что я забрал себе

Метрика, которая не воспроизвелась на вашем коде, - не ваша метрика. Erosion обещала 0.31 у живых репозиториев. Go stdlib дал 0.908, coreutils 0.913. Я перепроверил свой счётчик, пересчитал SLOC, сверился с эталонным парсером - цифры не сдвинулись. Красивая формула из статьи может просто не работать на вашем языковом миксе.

Вывод на двух проектах - это не вывод. «Подпись правил в коде» держалась ровно до третьего корпуса и рассыпалась на четвёртом. Мой же код из 2006 года добил её окончательно. Если гипотеза подтверждается на всей выборке, которая у вас есть, - значит выборка мала.

Форму кода задаёт задача, а не инструкции. Веб-бэкенд со слоями даёт 26% функций в три строки, парсер с TUI - 7.9%. Один автор, одни правила, разница втрое. Прежде чем приписывать разброс дисциплине, посмотрите, не объясняется ли он жанром.

Сравнивать метрики между языками бессмысленно. Доля коротких функций: 4.4% в ядре Linux, 57% в старой Java. Плотность функций: Go 38-56, Java 57-102. Две величины, которые я принял за свойства дисциплины, оказались счётчиками геттеров.

Порог, который нарушает каждая десятая функция хорошего кода, - плохой порог. CC > 10 в stdlib Go даёт 11.9%, в PostgreSQL 13.0%. depth > 4 там же - 2.3% и 2.8%, а в двадцати девяти корпусах из тридцати двух ниже трёх процентов. Первый агент научится игнорировать, второй заметит.

Проверяйте, не конфликтует ли новое правило со старыми. Цикломатику понижают выделением функции. У меня же записано «не плоди хелперы». Я чуть не выдал агенту два взаимоисключающих требования и не понял бы, почему он мечется.

Ищите то, что не меняется со временем. eclipse.platform вырос в 34 раза, цикломатика удвоилась, длина функций выросла вчетверо, глубина вернулась к исходной. Величина, которая двадцать пять лет стоит на месте, - готовый кандидат в пороги. Та, что ползёт, будет требовать пересмотра каждые пару лет.

Правило нужно там, где язык сопротивляется. В Java и Rust вложенность держится сама. В C - нет, и там записанная строчка в CodingStyle даёт разницу в разы, которую не даёт даже тридцатилетний review PostgreSQL.

Сверяйте новый счётчик с чужим. Ошибка в определении шага отступа дала 13.3% вместо 0.0%. Она ничего не ломала и не ругалась - просто выдавала правдоподобные проценты. Поймал я её только потому, что знал правильный ответ из другой реализации.


Инструменты замера лежат в haiodo/hum1izer под support-tools: считалка через tree-sitter на восемь языков, глубина по отступам без парсера, срез репозитория по годам, реализация метрик из исходной статьи. Методика там же: что считается, в каком порядке, какие поправки нужны для C и для смешанных репозиториев, чего этими метриками измерить нельзя. Данные - список корпусов с версиями и объёмом, ряды по годам для PostgreSQL за 30 лет и Eclipse за 25.

В сам инструмент из всего замера попала одна проверка: если комментарий длинный, а функция под ним вложена глубже пяти уровней, совет меняется с «уложись в две строки» на «чини функцию, а не текст». Потому что в такой ситуации предыдущий совет был просто неверным.

Этот пост тоже прогнан через hum1izer. Замечания были, все мои, все исправлены.