- BrainTools - https://www.braintools.ru -

Нейросеть написала PR, тесты зелёные, а поведение изменилось. Как я научил CI это ловить

Отчёт confidence-scorer: найден контрпример, score 35/100

Отчёт confidence‑scorer: найден контрпример, score 35/100

Модель «упрощает» валидацию. Линтер молчит, тесты проходят, ревьюер видит аккуратный дифф в одну строку и жмёт Approve. Через неделю выясняется, что функция скидки начала принимать отрицательный процент.

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

Под катом расскажу, как устроен confidence‑scorer [1], на какие грабли я наступил по дороге и почему в инструменте для проверки кода от нейросетей, нейросеть всё‑таки есть, хотя решающего голоса у неё нет.

Содержание

  1. Проблема на одном примере [2]

  2. Идея: свойство «ведёт себя как раньше» [3]

  3. Как это устроено [4]

  4. Грабли и как я их обходил [5]

  5. Зачем тогда AI‑ревьюеры [6]

  6. Как три числа превращаются в один score [7]

  7. Локальная модель вместо платного API [8]

  8. GitHub Action и PR из форков [9]

  9. Ограничения [10]

  10. Про ИИ в работе над проектом [11]

Проблема на одном примере

Вот функция, которая была в проекте:

def apply_discount(price: float, percent: float) -> float:
    """Return the price after a percentage discount (0-100)."""
    if percent < 0 or percent > 100:
        raise ValueError("percent must be between 0 and 100")
    return price - (price * percent / 100)

А вот что пришло в PR с описанием «simplify apply_discount validation»:

def apply_discount(price: float, percent: float) -> float:
    """Return the price after a percentage discount (0-100)."""
    if percent > 100:
        raise ValueError("percent must be between 0 and 100")
    return price - (price * percent / 100)

Изменилась одна строка. Если в тестах нет случая с отрицательным процентом (а откуда ему взяться, если тесты дописывала та же модель), всё зелёное. На ревью такое пролетает легко, особенно когда PR от агента уже десятый за день.

confidence‑scorer на этом диффе выдаёт:

Confidence Score: 35/100
Confirmed behavior counterexample found: do not merge without a manual check

pricing.py::apply_discount: the old and the new version disagree on whether
they raise an exception (input={'price': '0.0', 'percent': '-0.5'},
before=raised ValueError('percent must be between 0 and 100'), after=0.0)

В отчёте конкретный вход и то, как вели себя на нём старая и новая версия. Exit code 1, мерж заблокирован.

Идея: свойство «ведёт себя как раньше»

Property‑based testing существует давно: Hypothesis [12] для Python, fast‑check [13] для JS. Вы описываете свойство, например «сортировка возвращает упорядоченный список той же длины», а библиотека генерирует сотни входов и ищет тот, на котором свойство нарушается. Беда в том, что свойство нужно придумать и написать руками, и на это зачастую нет времени.

Для проверки изменений есть свойство, которое придумывать не надо:

для любого входа новая версия функции ведёт себя так же, как старая.

Это differential testing. Оно одинаковое для всех функций, так что писать его для каждой не приходится. Остаётся достать из диффа обе версии, сгенерировать входы и сравнить.

Конечно, не каждое расхождение означает баг. Иногда PR и должен менять поведение [14]. Тогда в отчёте это видно явно: вот вход, вот что было, вот что стало, и решает человек.

Как это устроено

Нейросеть написала PR, тесты зелёные, а поведение изменилось. Как я научил CI это ловить - 2

Порядок работы такой.

  1. Сначала нужно понять, какие функции изменились. git diff даёт файлы, а не функции, поэтому старую и новую версию каждого файла я разбираю в AST (для JS/TS через Babel в отдельном Node‑процессе) и сравниваю функции верхнего уровня. Переименованная переменная внутри функции считается изменением, лишний пробел нет.

  2. Потом строятся генераторы входов. Если у параметров есть type hints (int, str, list[int], Optional[str], TS‑типы), генератор получается прямо из них. Если типов нет, генератор может предложить модель, об этом ниже.

  3. Обе версии загружаются в отдельный процесс, и на каждом входе сравниваются результат и факт исключения. Найденное расхождение Hypothesis или fast‑check сжимают до минимального примера.

  4. Три проверки дают по числу, и из них складывается итоговый score с вердиктом.

Запускается всё одной командой:

confidence-score run --base main --head HEAD

Грабли и как я их обходил

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

1. Случайный поиск не находит граничные баги

Если просто сгенерировать 50 случайных чисел для percent: float по всему диапазону float, шанс попасть в узкий промежуток [-1, 0) практически нулевой. Баг из примера выше так не найти.

Hypothesis сам тяготеет к «интересным» значениям, но на 50 примерах на это нельзя полагаться. Поэтому к случайным входам я добавил детерминированный перебор граничных значений через декоратор @example: параметры по очереди получают значения из списка, пока остальные держатся нейтральными.

_INT_EDGE_CANDIDATES = [-1000, -100, -10, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 10, 42, 100, 1000]
_FLOAT_EDGE_CANDIDATES = [float(e) for e in _INT_EDGE_CANDIDATES] + [-0.5, -0.001, 0.001, 0.5]

Для строк в списке "", " ", "n", " a " и подобные. Пример со скидкой ловится как раз на -0.5.»

2. Функция портит свои же аргументы

def f(xs: list[int]) -> int:
    xs.append(1)
    return len(xs)

Старая версия получает список и дописывает в него элемент. Новая получает тот же список, уже с лишним элементом, и возвращает на единицу больше. Расхождение есть, а бага нет. Фиксим это тем, что каждая версия получает свою глубокую копию входа (copy.deepcopy).

3. NaN!= NaN

Если обе версии вернули nan, стандартное сравнение скажет, что результаты разные. Ещё бывает 0.1 + 0.2 против 0.3 после безобидной перестановки мест слагаемых в формуле. Пришлось написать своё сравнение. В нём NaN равен NaN, числа с плавающей точкой сравниваются через math.isclose, коллекции рекурсивно, а True не равен 1: в Python True == 1, но для поведения [15] функции это разные ответы.

4. Недетерминированные функции

def f(n: int) -> int:
    return n + random.randint(0, 10)

Любая правка такой функции даст «расхождение», даже если поведение не менялось. Определять недетерминированность заранее, по вызовам random, time, сети и так далее, значит вести бесконечный список исключений. Надёжнее проверять по факту. Когда контрпример найден, обе версии ещё раз запускаются на нём же, и если хоть одна на одном и том же входе отвечает по разному, функция получает статус skipped с объяснением вместо провала.

5. Таймаут на Windows

Новая версия функции может зависнуть. Это тоже изменение поведения, и инструмент не должен виснуть вместе с ней. На Linux хватает signal.alarm, а на Windows SIGALRM нет совсем.

Поэтому таймаут там в два слоя. Сначала срабатывает «мягкий» таймер через thread.interruptmain(), он прерывает обычный Python‑код. Если функция застряла в C‑коде и прерывание до неё не доходит, через 5 секунд срабатывает «жёсткий» таймер: процесс‑воркер записывает готовые результаты, помечает незавершённые функции как error: timeout и завершается через os._exit. Регрессию в этом месте видно только на Windows, так что CI гоняет тесты на матрице Linux и Windows.

С JS похожая история, только хуже: синхронный бесконечный цикл в Node нельзя прервать из того же потока. Там спасает таймаут на весь файл со стороны Python‑оркестратора.

6. Нельзя делать eval ответа модели

Для функций без type hints генератор входов может предложить модель. Самый короткий путь: попросить её написать код стратегии Hypothesis и выполнить его. Но пускать сгенерированный код на исполнение как раз не стоит.

Поэтому модель возвращает JSON‑спецификацию из фиксированного набора конструкций:

{"items": {"kind": "lists", "elements": {"kind": "integers"}, "max_size": 20},
 "email": {"kind": "text", "max_size": 50}}

Разрешены integers, floats, text, lists, dictionaries, one_of и ещё несколько kind. Всё, что не входит в список, игнорируется, а параметр пропускается, так что из ответа модели ничего не исполняется.

7. Технический сбой не должен считаться провалом

Модуль не импортировался, воркер упал, истёк таймаут на файл. Если считать это провалом проверки, любой сломанный импорт в окружении CI обнулит score. Если молча пропускать, теряется сигнал. В итоге такие функции получают статус error, не входят в знаменатель sub‑score и перечисляются в примечаниях отчёта.

8. Воспроизводимость

Без фиксированного seed Hypothesis на каждом прогоне ищет новые входы, и перезапуск CI может дать другой вердикт. Для merge gate это недопустимо. Теперь seed зафиксирован по умолчанию, и один и тот же PR получает один и тот же результат. Кому нужен свежий поиск на каждом прогоне, ставит seed: null.

Зачем тогда AI‑ревьюеры

Differential testing находит то, что можно найти входами. Некоторые изменения на входах не видны или видны слишком редко: поменялся контракт или документированное поведение, по‑другому обрабатываются ошибки [16] во внешних вызовах, появился побочный эффект вроде записи в файл. А регрессию вида if n == 987654: return 0 случайный поиск не найдёт никогда.

Поэтому рядом работают две AI‑проверки.

  1. Semantic diff отправляет отдельный запрос на каждую изменённую функцию со старым и новым кодом. Модель перечисляет поведенческие изменения с severity и оценивает риск от 0 до 100.

  2. Второй ревьюер получает один запрос на весь дифф. Промпт прямо говорит, что код мог написать AI, что его никто не проверял и что к нему стоит относиться скептически. Выводы других проверок ревьюер не видит, чтобы не подстраиваться под них.

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

Модель, которая спорит сама с собой

В одном из живых прогонов второй ревьюер нашёл удалённую проверку границы, поставил ей severity high и в том же ответе выставил confidence 100, то есть «можно мержить».

После этого оценка модели ограничивается её же худшей находкой: при high не выше 40, при medium не выше 75. Исходное значение остаётся в JSON‑отчёте, а в примечаниях написано, почему его понизили.

Как три числа превращаются в один score

Базовая формула простая:

overall = Σ (sub_score × вес) по проверкам, которые смогли отработать

Веса по умолчанию: property‑тесты 0.40, semantic diff 0.25, второй ревьюер 0.35. Если проверка не отработала (нет ключа или нет подходящих функций), её вес делится между остальными.

Здесь легко ошибиться в дизайне. Без AI‑ключей работают только property‑тесты. Допустим, в диффе одна тривиальная функция, тест проходит, sub‑score равен 100, и весь вес достаётся ему. Итог: 100/100 и «можно мержить», хотя отработала одна проверка из трёх.

Поэтому поверх взвешивания есть потолок по покрытию:

coverage = Σ (вес проверки × её полнота) / (сумма всех весов)
cap      = min_cap + (100 − min_cap) × coverage        # min_cap = 50
overall  = min(overall, cap)

С одними property‑тестами (вес 0.40) потолок получается 70, и вердикт будет по типу «рекомендуется ревью». Отчёт всегда показывает покрытие и сработавший потолок, чтобы было понятно, откуда взялось число.

Последнее правило: подтверждённый контрпример для публичной функции ограничивает score значением 35, что бы ни сказали модели. В примере со скидкой semantic diff дал 70, ревьюер 75, взвешенная сумма вышла около 44, и контрпример срезал её до 35. Если код реально ведёт себя иначе, мнение модели «выглядит нормально» не должно это перевешивать.

Confidence-score doctor: какие проверки заработают с текущими ключами

Confidence‑score doctor: какие проверки заработают с текущими ключами

Команда confidence-score doctor заранее показывает, какие проверки заработают с текущими ключами и какой будет потолок. В API она ничего не отправляет.

Локальная модель вместо платного API

Все AI‑проверки можно отдать локальной модели через Ollama: это бесплатно, и код никуда не уходит. Я тестирую на qwen2.5-coder:7b, ей хватает видеокарты на 8 ГБ.

# confidence.yml
providers:
  strategy_generation: { provider: ollama, model: qwen2.5-coder:7b }
  semantic_diff:       { provider: ollama, model: qwen2.5-coder:7b }
  second_reviewer:     { provider: ollama, model: qwen2.5-coder:7b }
limits:
  max_parallel_ai_calls: 1

С Ollama связана самая неочевидная ловушка во всём проекте. По умолчанию она использует контекст 4096 токенов и молча обрезает длинный промпт с начала. А в начале как раз лежит дифф. Модель получала хвост промпта с инструкцией «оцени этот код» и уверенно оценивала код, которого не видела, без единой ошибки или предупреждения.

Помогли три вещи:

  • провайдер ходит в родной API Ollama (/api/chat, а не в OpenAI‑совместимый) и явно запрашивает окно 32 768 токенов;

  • после ответа проверяется, сколько токенов промпта модель реально обработала. Если на один токен приходится больше 8 символов, промпт почти наверняка обрезан, и ответ отклоняется с причиной в отчёте;

  • temperature: 0 и фиксированный seed дают воспроизводимые оценки и у локальной модели.

Первый запрос занимает около 45 секунд, пока модель загружается в видеопамять. Дальше небольшой PR проверяется за 4–7 секунд, а повторный прогон того же PR берётся из дискового кэша за секунду.

Безопасный рефакторинг, score 91/100

Безопасный рефакторинг, score 91/100

Качество у 7B‑модели заметно хуже, чем у больших облачных. На безопасном рефакторинге с картинки выше она «нашла» изменение, которого нет: " ".join(text.split()) тоже обрезал пробелы по краям. Но property‑тест подтвердил эквивалентность на десятках входов, и итоговая оценка осталась высокой. Для этого основная проверка и сделана независимой от модели. »

GitHub Action и PR из форков

Инструмент есть в GitHub Marketplace [17]:

- uses: actions/checkout@v7
  with:
    fetch-depth: 0 

- uses: Kakadu525/confidence-scorer@v1
  with:
    anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}

Action выставляет outputs score, verdict, gate и падает, если сработал merge gate, поэтому его можно сделать обязательной проверкой.

Отдельная история с PR из форков. В них GitHub не даёт ни секретов, ни прав на запись, так что Action не может оставить комментарий с отчётом. Первое, что находится в поиске: заменить pull_request на pull_request_target. Делать этого нельзя, потому что так код из чужого форка выполнится с доступом к вашим секретам, а confidence‑scorer как раз выполняет код из PR.

Правильная схема состоит из двух workflow. Первый считает score в безопасном контексте и сохраняет отчёт как артефакт. Второй запускается по событию workflow_run уже в контексте вашего репозитория, код PR не скачивает и только публикует готовый отчёт.

Ограничения

Лучше сказать о них заранее, чем в комментариях.

  • Методы классов в differential testing не участвуют, только функции верхнего уровня. Чтобы вызвать метод, нужен экземпляр класса, а собрать его автоматически, когда у конструктора произвольные зависимости, намного сложнее. Методы проверяют только AI‑проверки.

  • args, *kwargs и rest‑параметры генератор не поддерживает. Такие функции помечаются skipped и не пропадают из отчёта молча.

  • Расхождение может найтись на вырожденном входе, который нарушает неявное предусловие, например lo > hi для функции, которая рассчитывает на lo <= hi. Поведение действительно изменилось, а важно ли это, решает человек.

  • Код из PR выполняется, а полноценной песочницы нет. Поэтому проверку стоит запускать в CI на одноразовом раннере. Для недоверенного окружения есть execute_changed_code: false, тогда остаются только AI‑проверки.

  • AI‑проверки вероятностные. Они дают оценку и формальной верификацией не являются.

Про ИИ в работе над проектом

Было бы странно писать статью про проверку кода от нейросетей и умолчать, что я и сам с ними работал.

Архитектуру, логику [18] проверок и формулу score я проектировал и писал сам, и решения из раздела про грабли появились из моей отладки на реальных прогонах. Claude от Anthropic я использовал как напарника: обсуждал с ним спорные места дизайна, просил ревью и отдавал ему часть рутины вроде тестов на граничные случаи, перевода документации и вывода на английский, оформления README. Каждое такое изменение я читал и проверял, то есть делал с его кодом ровно то, что предлагаю делать в этой статье.

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

Итого

Differential testing не требует писать свойства: «ведёт себя как раньше» подходит для любой изменённой функции. Самой сложной частью оказалась не идея, а ложные срабатывания: мутация аргументов, NaN, недетерминированность, таймауты на Windows.

AI‑проверки полезны как дополнение, но итоговую оценку должен ограничивать факт. Отсюда hard fail по контрпримеру, потолок по покрытию и потолок по severity. И всё это можно запустить бесплатно на локальной модели.

Буду рад, если попробуете его на своих PR и расскажете, где он ошибается. Особенно интересны ложные срабатывания differential testing на реальном коде.

А как вы проверяете PR, написанные агентами? Доверяете их тестам или пишете свои?

Автор: MedSurg

Источник [19]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/36285

URLs in this post:

[1] confidence‑scorer: https://github.com/Kakadu525/confidence-scorer

[2] Проблема на одном примере: #first

[3] Идея: свойство «ведёт себя как раньше»: #idea

[4] Как это устроено: #how

[5] Грабли и как я их обходил: #obhod

[6] Зачем тогда AI‑ревьюеры: #AIrev

[7] Как три числа превращаются в один score: #3num

[8] Локальная модель вместо платного API: #local

[9] GitHub Action и PR из форков: #git

[10] Ограничения: #limits

[11] Про ИИ в работе над проектом: #project

[12] Hypothesis: https://hypothesis.readthedocs.io/

[13] fast‑check: https://fast-check.dev/

[14] поведение: http://www.braintools.ru/article/9372

[15] поведения: http://www.braintools.ru/article/5593

[16] ошибки: http://www.braintools.ru/article/4192

[17] GitHub Marketplace: https://github.com/marketplace/actions/confidence-scorer

[18] логику: http://www.braintools.ru/article/7640

[19] Источник: https://habr.com/ru/articles/1088564/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1088564

www.BrainTools.ru

Rambler's Top100