ADR 0123 — Golden-set регрессионных случаев для семантического суждения агента QA Automação
- Статус: принят
- Дата: 2026-08-30
- Ссылается на (без редактирования): ADR 0020 (находку, которую этот закрывает, — его собственный критерий приёмки документирует семантический шаг gate QA как недетерминированный с локальной моделью и называет направление исправления, которое строит этот ADR: «направить на модель через API, а не надеяться»).
Контекст
ADR 0020 закрыл критерий приёмки gates PR, но с оговоркой,
записанной в самом заголовке раздела: ЗАКРЫТ, но НЕ ДЕТЕРМИНИРОВАН.
С локальным qwen2.5-coder:7b критерий — QA сопоставляет бизнес-правило
с тестом, дважды в одном PR — закрылся только на 10-м из 11
прогонов. 11-й, запущенный сразу после без изменений кода, не закрылся:
модель завершила цикл, не вызвав emit_qa_verdict. ADR 0020 явно называет это
так: семантический шаг — «роль, которая хуже всего ложится на маленькую
модель», и объявляет направление исправления, не строя его: «направить
DEMO_QA_MODEL на модель через API».
Это направление так и не стало механизмом — оно осталось задокументированным, но не построенным. Внешнее code review работы этой сессии классифицировало 15 находок по приоритету; 14 из 15 закрылись в ходе сессии. 15-я и последняя — эта, предложенная без связанной стоимости построения: «golden-set (5-10 случаев, свободный ожидаемый вывод, фиксированная модель, допустимые падения для начала) превратил бы „демо зависит от суждения 7B“ в сигнал тенденции».
Три факта, подтверждённые чтением кода, а не предположенные, определили, что можно было построить сегодня:
- Суждение можно вызвать изолированно, без всей машины
состояний.
Engine.Gates.QaAutomacaoAgent.run/5уже вызывается так вqa_automacao_agent_test.exs— безQaLeadServer, безGateState, без PR, без git-репозитория, без backlog. Единственное, что отделяет этот существующий тест от настоящего golden-set, — замена фальшивого клиента LLM на настоящий. - Настоящий клиент требует реально запущенной и доступной api.
Engine.Sessions.EngineApiClient.Live.llm_turn/5читаетApplication.fetch_env!(:engine, :api_url), заполняемый толькоconfig/runtime.exs(черезAPI_URL) — никогдаconfig/test.exs. Это не модульный тест в обычном смысле: на практике это «прогон против dev-стека», та же позиция, что уже уdemo-pr-gates.ts. - CI сегодня невозможен — подтверждено, а не предположено. Ноль упоминаний
ollamaв.github/workflows/; сервис compose стоит заprofiles: ["local-llm"]и никогда не запускаетсяdocker/smoke.sh. Ноль секретов LLM в репозитории. И сама культура CI отвергает молчаливо допустимые падения:pr-police.yml— единственное место в репозитории, использующееcontinue-on-error, и даже там ошибка повторно выбрасывается на финальном шаге. Сегодня нет проверки, которая могла бы стать красной без последствий. Подключение этого к CI требует секрета LLM через API или новой инфраструктуры (runner с GPU, шаг pull из Ollama) — решение человека, а не то, что строится по выбору.
Решение
Где живёт. apps/engine/test/engine/gates/qa_automacao_agent_golden_test.exs,
use Engine.DataCase, с переиспользованием той же конструкции
dev_state/dev_context из qa_automacao_agent_test.exs. Это ExUnit, а не новая
Mix task: сегодня нет собственной Mix.Task нигде в
apps/engine/lib, а изолированный тест уже является естественной формой этого кода для
этого класса вещей — в отличие от стороны api, где demo-pr-gates.ts
— скрипт, потому что там нет эквивалента «вызвать функцию напрямую».
Постоянный тег исключения, никогда не обнаружение доступности.
Шаблон test_helper.exs для gitleaks/hadolint/yamllint/actionlint
автоматически включает помеченный тест, когда бинарник найден на машине, —
верно для бесплатного детерминированного инструмента и неверно здесь. На этой
машине разработчика Ollama уже работает постоянно; автоматическое
включение по доступности заставило бы golden-set срабатывать внутри
любого mix test, тратя токены без предупреждения и внося реальную
нестабильность в набор тестов, который сегодня на 100% детерминирован.
ExUnit.start(exclude: [:golden_set_qa | binary_exclusions])
Его запускает только mix test --only golden_set_qa, плюс alias mix golden_set.qa
для находимости — та же дисциплина, которую demo-pr-gates.ts уже объявляет
в собственном заголовочном комментарии: «NOT deterministic ... run deliberately,
not in CI.» setup_all пропускает (через возврат {:skip, reason} ExUnit в
теле теста, а не через выброшенный сбой), когда api недоступна по
API_URL, — это предварительное условие окружения, а не дефект кода.
Заполнение внешним TS-скриптом, вызываемым через System.cmd. Engine
не может создать реальные строки (проект, сессия, binding модели), не
дублируя бизнес-правила, живущие в самих use cases api, — и
в любом случае нет сырого пути Ecto, достигающего нужной базы. Это стоимость
обвязки, которую этот ADR объявляет открыто, а не прячет:
apps/api/scripts/seed-golden-set-qa.ts — это межъязыковой
System.cmd, новый для этого репозитория (ноль прецедентов теста на Elixir,
вызывающего pnpm/ts-node через shell).
Вторая, совсем не очевидная причина, по которой это должно быть так: golden-тест
использует Engine.DataCase, чей sandbox указывает на изолированную базу engine_test
(то же соглашение, которое уже использует qa_automacao_agent_test.exs). Скрипт
заполнения работает внутри собственного процесса api, записывая в реальную dev-базу
(DATABASE_URL), которую работающие серверы api и engine
действительно используют. Это две разные базы — ничто, что Engine.Repo читает в
golden-тесте, не увидит строк, записанных скриптом, и наоборот. Как
следствие, Engine.Actions.Workspace/Engine.Dev.WorktreeManager (которые
читают project_repositories через Engine.Repo) не могут материализовать
worktree случаев этого теста: скрипт заполнения сам делает
git checkout (простой git clone bare-репозитория, который он только что
подготовил) и возвращает путь готового worktree в JSON-выводе. Golden-тест
только потребляет этот JSON — никогда не обращается к Postgres, чтобы что-то найти.
Скрипт заполнения:
- Создаёт пользователя + workspace и по проекту на каждый случай (подготовленный
локальный git-репозиторий соотносится с проектом 1:1 —
ProjectRepository.remoto_de_trabalho/1ищет единственную строку поproject_id). - Разрешает или создаёт строку
modelsдляGOLDEN_SET_QA_MODEL(по умолчаниюqwen2.5-coder:latest) — в отличие от более старых демо, которые бросают ошибку, если модели ещё нет в фиксированном каталоге заполнения (MODEL_SEEDSвseed.ts). Golden-set по замыслу нацелен на произвольные локальные модели Ollama, и привязка к фиксированному каталогу означала бы редактированиеseed.tsкаждый раз, когда кто-то захочет измерить новую. - Активирует модель для workspace (
SetModelsActiveUseCase) — шаг, который более старые демо-скрипты пропускают и отсутствие которого сегодня заставляетSetModelBindingUseCaseбросатьModelNotBindableError('inativo')(отсутствие вworkspace_modelsозначает «выключено», RN-043/ADR 0049). Это похоже на дрейф в старых демо, а не на то, что этот ADR должен молча исправить там, — они вне объёма. - Делает binding модели в
chaveDeAgente(project.id, 'qa-automacao')—'qa-automacao', никогда не'qa': начиная с Фазы 8b реально работающий субагент — этоqa-automacao/qa-performance-seguranca; binding в'qa'не имеет эффекта (каскад падает на binding проекта). Скопировано изdemo-pr-gates-area-qa.ts:281, а не из более старогоdemo-pr-gates.ts, который всё ещё использует старый ключ. - Вызывает
SeedAgentAreasUseCase.execute(project.id)— без этогоagent_areasостаётся пустой для проекта, созданного сырым insert (а не черезCreateProjectUseCase), и первый ход LLM, который делает агент QA, падает с 404 отRecordLlmUsageUseCase(AgentAreaRepository.incrementSpentтребует существования строки — metering обязателен, RN-036, никогда не «по возможности»). Найдено эмпирически: первый реальный прогон этого harness вернулblocked(infra)во всех шести случаях до добавления этого вызова. - Подготавливает репозиторий, коммитит каркас случая, а затем материализует
worktree внутри
projectScopeRoot(project)(git clone, до записиpermissions.json— клонирование требует пустого целевого каталога). Этот порядок и это место несущие, а не случайные: потолок области пути ADR 0055 вdecide()отказывается автоматически одобрять любое действиеterminal, чейcwdвыходит за корень области самого проекта, как бы хорошо команда ни совпадала с шаблономallow. Первая версия этого скрипта клонировала в отдельный черновой каталог подos.tmpdir()— каждый случай навсегда возвращалawaiting_approval, потому чтоnpm testникогда не проходил проверку области. Найдено эмпирически, так же как пробелagent_areasвыше. - Добавляет
DEV_TERMINAL_ALLOW_PATTERNSвpermissions.jsonпроекта (тот же механизм, что уже используетActivateExecutionUseCase) — без этого значение по умолчаниюdecide()—require_approvalдля каждого вызоваterminal, иemit_qa_verdictникогда не может увидеть в историиterminalс выходом 0. - Создаёт и активирует по сессии на случай (
CreateSessionUseCase+TransitionSessionUseCase). - Печатает JSON-объект в stdout —
{"model": "...", "cases": [{"id", "projectId", "sessionId", "worktreePath", "story", "task", "expectedVerdict"}, ...]}.
Без очистки — та же позиция, что у demo-pr-gates.ts (суффикс с отметкой времени,
никогда не удаляется).
Шесть случаев, каждый зондирует отдельную таблицу суждения, в которой LLM-оценщик действительно может ошибиться, — а не шесть вариаций одного теста:
rf-covered— RF_1 изdemo-pr-gates.ts, дословно (покрыта) →approved. Положительный контроль, уже доказанный от начала до конца.rf-uncovered— RF_2 изdemo-pr-gates.ts, дословно (явно без покрытия) →changes_requested. Отрицательный контроль, уже доказанный.rf-single-clean— RF_1 изdemo-pr-gates-area-qa.ts(единственное правило, покрыто, чистое одобрение) →approved.rf-mismatched-filename— правило оenviar(), покрытое тестом, который живёт вtest/outro-modulo.test.js(неenviar.test.js) →approved. Зондирует, читает ли QA содержимое теста, а не только соглашение об именах файлов.rf-partial-coverage— правило, называющее счастливый путь и явный случай отказа (cancelar(pedido)отмечает статус и бросает ошибку, когда уже доставлено); тест есть только у счастливого пути →changes_requested. Зондирует, штампует ли QA частичное покрытие как полное.rf-skipped-test— единственный тест, «покрывающий» правило, — этоtest.skip(...)→changes_requested. Зондирует, засчитывает ли QA отключённый тест как покрытие.
Два почти-попадания, которые ADR 0020 уже документирует (галлюцинация вызова
инструмента, QA, пытающийся исправить проверяемый код), намеренно оставлены за
бортом — это уже детерминированная и дешёвая регрессия в
apps/engine/test/engine/harness/tool_call_recovery_test.exs:79, без участия
реальной модели. Добавление их сюда дублировало бы это покрытие и размыло бы
то, что этот harness реально измеряет: суждение правило-против-теста, а не общее плохое
поведение QA.
Порог, а не бинарный gate. Та же философия храповика, что у
verificarPiso в scripts/ci/coverage-floor.ts (>=, никогда >;
файл редактирует только человек; скрипт никогда его не переписывает) — что
ровно совпадает с тем, о чём уже просили «свободный ожидаемый вывод, допустимые падения для начала».
apps/engine/test/fixtures/golden_set_qa/floor.json имеет ключ по
модели (смена модели законно меняет достижимый порог) и хранит
количество плюс итог ({"passRate": N, "of": 6}, а не
процент) — чтобы у golden-set, который позже вырастет за пределы шести случаев,
старая запись не читалась молча неверно. Нет записи для
используемой модели → тест падает, сообщая наблюдаемое число
попаданий и прося человека добавить запись, — никогда не записывается
автоматически, то же непреложное правило, которое уже установил coverage-floor.ts.
Один тест, а не шесть. Шесть случаев выполняются внутри единственного блока test
(помеченного @moduletag :golden_set_qa), а не в шести отдельных. mix test
не гарантирует порядок между тестами модуля; разбиение на шесть рисковало бы тем, что
утверждение о пороге прочитает неполное подмножество, если предполагаемый «седьмой тест-
агрегатор» запустится раньше, чем остальные закончат заполнять контекст. Прогон
всех шести с агрегацией в конце — это также то, что позволяет человеку прочитать шесть
исходов вместе для отладки, напечатанными в stdout.
ownership_timeout пришлось сделать настраиваемым в Engine.DataCase.
Найдено эмпирически при прогоне против gpt-oss:20b: реальный ход LLM
против большой модели, загружающейся в первый раз, может минутами не
обращаться к Postgres. ownership_timeout по умолчанию 60 с у
Ecto.Adapters.SQL.Sandbox посреди вызова забрал соединение, которое
golden-тест одолжил; следующий запрос через Engine.Repo — файлы
инструкций внутри ToolLoop.Default.init/1, через который проходит каждый
агент gate, персонализирует он инструкции агента или нет, —
умер с «owner process exited», выдав характеристику
скорости модели за сбой инфраструктуры.
Engine.DataCase.setup_sandbox/1 теперь читает необязательный tags[:ownership_timeout]
(nil сохраняет существующее значение по умолчанию 60 с для любого другого теста
набора); golden-тест помечает себя ownership_timeout: :infinity.
Последствия
Подключение к CI — объявленный TODO(humano), не построенный. Оно требует
секрета LLM через API или новой инфраструктуры (runner с GPU, шаг
pull модели из Ollama) — это не выбор, который может сделать эта сессия;
нужно решение человека и, в случае инфраструктуры, бюджет. Зафиксировано в
разделе «Pendências com dono humano» в CLAUDE.md, там же, где этот
класс блокировок уже живёт.
Измеренные реальные доли попаданий (2026-08-30, нативные процессы на хосте — api
через pnpm --filter api start:dev, engine через mix phx.server, оба
направлены на локальный Postgres и на собственный Ollama машины на
localhost:11434, без Docker Compose в этом конкретном прогоне):
| модель | прогоны (попаданий/6) |
|---|---|
qwen2.5-coder:latest | 1, 3, 2, 4, 5 |
gpt-oss:20b | 5, 4 |
qwen2.5-coder:latest заметно нестабилен — преобладающий режим отказа
между прогонами состоял в том, что модель вызывала инструмент terminal с
пустым аргументом command, который decide() правильно отказывается
автоматически одобрять (ни один шаблон не совпадает с пустой строкой), оставляя случай навсегда в
awaiting_approval. Это ровно тот класс нестабильности вызова
инструментов, который ADR 0020 уже нашёл в этом семействе моделей, —
воспроизведённый здесь как измеренная тенденция, а не единичный анекдот.
gpt-oss:20b заметно надёжнее (4-5/6 в двух прогонах, ~в 4 раза
медленнее на случай). Его единственная повторяющаяся ошибка — rf-partial-coverage
— штамп approved на правиле со случаем отказа без теста, — а это
ровно тот пробел суждения, который этот случай призван выявить, а не
дефект фикстуры.
С учётом этого gpt-oss:20b — рекомендуемое значение по умолчанию отныне для
любого потока, зависящего от надёжности семантического шага gate QA
— тот же вывод, который ADR 0020 предсказал, не имея возможности измерить.
GOLDEN_SET_QA_MODEL/значение по умолчанию скрипта заполнения пока остаётся
qwen2.5-coder:latest (смена значения по умолчанию демо/harness — отдельное
решение от фиксации измеренного); две записи файла порога
делают реальные потолки двух моделей видимыми рядом для того, кто будет принимать
это решение.
Зарегистрированный порог (floor.json) намеренно ниже лучшего
наблюдаемого прогона каждой модели — храповик, а не стремление:
qwen2.5-coder:latest на 1/6, gpt-oss:20b на 4/6.
Механизм доказан, но обвязка скрипта заполнения теперь — новая
несущая поверхность. Два пробела на стороне api, найденные
эмпирическими прогонами этого ADR, — agent_areas, не заполненная
для проекта, созданного сырым insert, и worktree, который должен жить внутри
projectScopeRoot, чтобы потолок области пути в decide() вообще
одобрил хоть что-то автоматически, — не являются дефектами gates или decide(); это
ровно тот тип пробела «демо забыло шаг, о котором
ActivateExecutionUseCase обычно заботится», который сам ADR 0020 находил
снова и снова. Зафиксированы здесь, а не спрятаны, так же как
ADR 0020 зафиксировал девять неудачных прогонов до того, как десятый закрылся.