Реальный баг — более честный бенчмарк, чем любая таблица лидеров
Каждая модель приходит со своей зачётной ведомостью: SWE-bench, HumanEval, рейтинг с соревнований. Эти цифры реальны, но у всех у них одна форма — замкнутая в себе головоломка, известный ответ, один заход, оценка по диффу. Они измеряют дедукцию в условиях определённости. И почти ничего не говорят о том, на что вы на самом деле полагаетесь в агенте: что делает модель, когда застряла на настоящей проблеме, внутри работающей системы, без ключа с ответами и с обратной связью, которая ей врёт.
Такое в таблицу лидеров не положишь, потому что в тот момент, когда задача становится достаточно чистой, чтобы её можно было оценивать автоматически, она перестаёт быть похожей на реальную работу. Так что по самому важному свойству мы по большей части летим вслепую.
Этот пост — про один раз, когда мы не летели вслепую. Приложение пользователя сломалось конкретным, воспроизводимым образом, и через model routing вышло так, что мы прогнали против него двух разных кодеров — один и тот же промпт, один и тот же сломанный файл, одно и то же живое приложение, один и тот же верификатор. Не синтетический eval. Естественный эксперимент. Одна модель потратила 51 шаг и весь свой бюджет и так и не довела фикс до конца; другая чисто справилась за 19. Интересен здесь не счёт. Интересно, почему вообще возник этот разрыв, — потому что баг оказался почти идеально сконструирован так, чтобы развести два типа моделей, а таблица лидеров поставила бы им почти равные оценки.
Чтобы понять, что именно он измерил, нужно разобраться в самом баге. Так что мы пройдём его как следует — реальная графика, реальные формулы — и затем прочитаем две трассы, сопоставляя их друг с другом.
Задача под тестом: один меш на чанк
Пользователь строил воксельный мир в стиле Minecraft на React Three Fiber. Первая генерация вышла хорошей — текстурированный, освещённый, играбельный мир. Затем настал черёд оптимизации, которая рано или поздно нужна любому воксельному движку, — и она превратила всю сцену в чёрный экран.
Наивный воксельный рендерер даёт каждому блоку собственный меш, так что GPU выполняет по одному draw call на блок — тысячи за кадр. Решение — объединённая геометрия чанка: упаковать каждую видимую грань чанка 16×16×N в единственный BufferGeometry и рисовать чанк одним вызовом. Вы вручную собираете три вершинных атрибута — position, normal, uv — плюс индексный буфер.
Именно эта ручная сборка — место, где корректность становится хрупкой, потому что освещённый материал показывает вам не позиции. Он показывает свет. А свет зависит от нормалей, а нормали — в наивном подходе — зависят от порядка обхода (winding) ваших треугольников. Вся история — в этой цепочке, и именно поэтому баг получился хорошим бенчмарком: тут нет ответа в замкнутой форме, который можно просто считать. Недостающий факт живёт в работающем пайплайне.
Математика шейдинга и откуда берётся чёрный
Блоки использовали MeshLambertMaterial — диффузный шейдинг по Ламберту. Для одного источника света исходящий цвет фрагмента равен:
C_out = C_tex · ( k_a·A + k_d · max(0, N̂ · L̂) )
└ ambient ┘ └─── diffuse ───┘
C_tex— тексель, сэмплированный из атласа по UV-координатам фрагментаN̂— нормаль поверхности (единичная),L̂— направление на источник света (единичное)k_a·A— слагаемое ambient,k_d·max(0, N̂·L̂)— слагаемое diffuse
Слагаемое diffuse — это зажатое (clamped) скалярное произведение. Зажим — это и есть ловушка: если N̂ указывает прочь от света — или, хуже того, внутрь блока — то N̂·L̂ ≤ 0, max(0, …) обнуляет его, и у вас остаётся только ambient:
N̂ pointing inward ⇒ N̂·L̂ ≤ 0 ⇒ C_out = C_tex · k_a · A
А ambient был крошечным (об этом ниже), так что C_out ≈ 0. Чёрный. Не отсутствующий, не нетекстурированный — целиком закрашенный шейдингом в чёрный, что выглядит как баг освещения, или баг текстуры, или как что угодно, что вы захотите в нём увидеть.
Итак: куда указывают нормали? В наивной сборке вы этого не говорите. Вы позволяете Three.js вывести их:
geo.setIndex([0,1,2, 1,3,2]) // two triangles per quad
geo.computeVertexNormals() // normals INFERRED from those triangles
А computeVertexNormals() вычисляет нормаль каждого треугольника из векторного произведения его рёбер в порядке индексов:
N = normalize( (v1 − v0) × (v2 − v0) )
Векторное произведение антисимметрично — a × b = −(b × a) — так что перестановка двух индексов меняет знак нормали. [0,1,2] и [0,2,1] описывают одни и те же три точки и противоположную нормаль. Порядок обхода вашего индексного буфера тихо и есть ваше освещение.
Вот другая сторона той же монеты. Отсечение задних граней (backface culling) в WebGL (side: FrontSide) оставляет треугольник, только когда он обходится против часовой стрелки с точки зрения камеры, — знак его проецированной площади в экранном пространстве. Тот же порядок индексов, что переворачивает нормаль, переворачивает и ориентацию грани. Так что один неправильный winding делает сразу две вещи: переворачивает нормаль (→ чёрный по Ламберту) и переворачивает ориентацию (→ грань отсекается, симптом «я вижу только тонкие/одиночные поверхности»). Одна корневая причина — два совершенно по-разному выглядящих бага.
Это цепочка вывода (inference chain): значение, которое вам нужно (освещённый цвет), выводится, звено за звеном, из значения, которое легко тонко испортить (winding), через шаг, который прячет ошибку (computeVertexNormals). Отладить это — значит вручную доказать корректность каждого звена для всех шести граней куба.
Почему этот баг — разделяющий бенчмарк
Хороший бенчмарк разделяет вещи, которые он тестирует. Большинство кодинговых eval'ов мало что разделяют — более сильный рассуждатель просто набирает больше очков по всем фронтам. Этот баг разделяет, и делает это благодаря двум свойствам, которые в нём по случайности совместились.
Первое: он переопределён — три независимые, каждая сама по себе достаточная причина быть чёрным. Любая из них в одиночку даёт ту же картинку.
| # | Причина | Физика | Фикс |
|---|---|---|---|
| 1 | Инвертированный winding | N̂·L̂ ≤ 0 → diffuse 0; плюс отсечение задней грани |
правильный winding или явные нормали |
| 2 | MeshStandardMaterial (PBR) |
metal-rough BRDF с roughness 0.85 и без environment map почти не даёт диффузной энергии при одном источнике — IBL делает работу, которой здесь не было |
использовать MeshLambertMaterial |
| 3 | Освещение на рассвете | суточный цикл стартовал в t=0 → солнце на горизонте |
стартовать в полдень / поднять уровень |
Причина 3 прячется на самом видном месте. Суточный цикл задавал высоту солнца как sin(t) при t = 0 на момент загрузки, так что направленный свет всходил на уровне max(0, sin 0)·3.5 + 0.2 = 0.2 — солнце ровно на горизонте — а дублирующий захардкоженный <ambientLight> воевал с циклом. Эффективная освещённость ≈ 0.05. Сцена загружалась на рассвете; даже идеальная геометрия рендерится почти чёрной при t = 0.
Переопределённость — самое жестокое свойство, какое может быть у бага, потому что она разрушает вашу петлю обратной связи. Чините winding → всё ещё чёрный (PBR + рассвет). Чините материал → всё ещё чёрный (winding + рассвет). Каждый правильный фикс выглядит как провалившийся. Нельзя подниматься по градиенту, когда каждый шаг читается как ноль. Модель, для которой отладка — это думать усерднее, а потом пропатчить, не получает никакого сигнала, что она продвигается.
Второе: несмотря на всё это, баг был дёшево изолируем — одна замена материала, как мы увидим, схлопывает весь поиск. Так что задача исподволь задаёт один вопрос: когда экран не даёт вам никакого градиента, вы продолжаете рассуждать или ставите эксперимент, чтобы произвести градиент? Чисто дедуктивная стратегия здесь проваливается; интерактивная проходит насквозь. Вот в чём разделитель. Таблица лидеров, собранная из головоломок «с одного захода», этот вопрос вообще задать не может.
Что на самом деле сделала первая модель
Понаблюдайте, как сильный open-weights кодер работает над этим, — и вы увидите острого инженера, пойманного ровно в этот нулевой градиент. Он пошёл прямиком на самое сложное звено цепочки — доказательство winding вручную — и раз за разом ошибался в знаке, в обе стороны, в разных попытках. Две настоящие цитаты из его трассы, разделённые часами:
…так что мой «фикс» на самом деле сделал хуже — исходный
0-1-2был правильным для верхней грани!
У всех 6 граней инвертированный winding. Фикс прост: поменять индексы с
[0,1,2],[1,3,2]на[0,2,1],[1,2,3].
Обе не могут быть истинны одновременно. Он вручную проследил векторное произведение для верхней грани — v0 = (−½,½,−½), v1 = (½,½,−½), v2 = (−½,½,½) — получил (v1−v0)×(v2−v0) = (0,−1,0), верно заметил, что это указывает вниз внутрь блока, а затем на следующем проходе вывел это в обратную сторону. Он гонял по кругу одни и те же теории — winding, потом «computeVertexNormals производит нормали нулевой длины на вырожденных треугольниках», потом «CanvasTexture не загружается в GPU», потом рассветное освещение, потом PBR, потом дублирующий ambient-свет, потом outputColorSpace, не пробрасываемый R3F, потом drei'евский <Sky>, перекрывающий фон. Несколько из этих под-диагнозов были верными. Он объявил «Теперь у меня полная картина» пять отдельных раз, каждый — другую картину, и исчерпал свой бюджет шагов — 51 шаг, budget-exhausted — так и не доведя фикс.
Он не был туп. Он занимался правильным делом (рассуждать о каждом звене) на проблеме, где это дело не может сойтись: переопределённый баг не даёт градиента, а конкретное звено, на котором он зациклился (winding ⇄ знак нормали), — это выкладка, которую делаешь вручную и которую он не мог удержать согласованной от попытки к попытке. Это в точности тот режим отказа, который таблица лидеров вскрыть не может, — на чистой головоломке с единственной причиной эта же модель великолепна.
Что вторая модель сделала иначе
Мы перенаправили слот кодера на GPT-5.3 Codex. Он не выиграл спор про winding. Он отказался вести этот спор. Из его трассы:
Я полностью устраню неоднозначность winding/нормалей, пересобрав грани чанка как неиндексированные треугольники с явными нормалями на каждую грань.
Это одно предложение и есть весь фикс, и это смена представления. Нормаль грани куба — не то, что нужно выводить: это известная константа: +Y для верха, −Z для задней грани, +X для правой. Так что запишите её, вместо того чтобы давать computeVertexNormals реконструировать её из winding, который может быть неверным.
Хрупкая сборка (у open-weights модели и в оригинале):
// 4 shared verts per quad, 2 triangles via an index buffer
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
geo.setIndex([0,1,2, 1,3,2]) // ← winding decides normals AND facing
geo.computeVertexNormals() // ← normals INFERRED; a winding bug silently corrupts them
const mat = new THREE.MeshStandardMaterial({ map: atlas, roughness: 0.85 }) // ← PBR → dark, cause #2
Надёжная сборка (у Codex):
// 6 verts per quad (two triangles, NON-indexed), explicit constant normal
const FACE_NORMAL = { top:[0,1,0], bottom:[0,-1,0], north:[0,0,-1],
south:[0,0,1], east:[1,0,0], west:[-1,0,0] }
for (const [a,b,c] of [[0,1,2],[0,2,3]]) { // one fixed CCW triangulation, written once
for (const i of [a,b,c]) {
pos.push(...quad[i])
nrm.push(...FACE_NORMAL[face]) // ← the normal, stated, not derived
uv.push(...cellUV[i])
}
}
geo.setAttribute('normal', new THREE.BufferAttribute(nrm, 3)) // explicit
const mat = new THREE.MeshLambertMaterial({ map: atlas }) // lit, cheap, correct given good normals
Посмотрите, что это убирает. Когда нормаль задана явно:
N̂ is a constant per face ⇒ N̂·L̂ is correct regardless of winding
⇒ the entire winding → normal → black chain is GONE
Теперь winding может влиять только на ориентацию (отсечение), но не на освещение — а отсечённая грань — это громкий, очевидный симптом, а не тихий тёмный. Неиндексированная раскладка (6 вершин, без общего индекса) также означает, что нормаль каждого треугольника независима — никакое усреднение по общей вершине не может «поделить разницу» между двумя гранями. Класс багов не исправлен; он сделан непредставимым.
Затем Codex сделал вторую вещь, которую первая модель так и не сделала, — он изолировал оставшуюся переменную. Из его трассы: «Я сейчас добавлю настоящий unlit-переключатель для отладки, чтобы отделить сэмплирование текстурного атласа от освещения.» В коде это одна замена материала:
// MeshBasicMaterial ignores N̂·L̂ entirely: C_out = C_tex
const debug = new THREE.MeshBasicMaterial({ map: atlas })
MeshBasicMaterial отбрасывает слагаемое diffuse — C_out = C_tex, без нормалей, без источников света. Так что:
- unlit показывает правильные текстуры → путь atlas/UV в порядке, баг в освещении/нормалях
- unlit тоже показывает неправильные цвета → баг в atlas/UV (ошибка
flipYили смещения ячейки)
Один переключатель схлопывает поиск по двум переменным (это геометрия или это свет?) в два наблюдения по одной переменной. Это самый базовый приём в отладке графики, и именно его буксующая модель ни разу не сделала, потому что была занята повторным выводом векторных произведений. И это, в точности, тот ход, которого таблица лидеров никогда не просит: тут нечего выводить, есть только что запустить.
Итоговая ведомость, один и тот же промпт и один и тот же сломанный файл для обоих:
| Кодер | Вызовов кодера | Результат |
|---|---|---|
| Open-weights кодер | 51 | budget-exhausted — фикс так и не доведён |
| GPT-5.3 Codex | 31 | all-subtasks-terminal на шаге 14, затем 19 — чисто |
Бенчмарк существовал только потому, что верификатор был слеп
Есть причина, по которой модели вообще пришлось отлаживать это вслепую, и она же делает баг таким чистым бенчмарком. Верификатор генерации в платформе намеренно объективен: он проверяет типы, резолвит импорты, прозванивает API, гоняет юнит-тесты и открывает страницу в настоящем Chromium, следя за выброшенными исключениями. Для этого приложения он возвращал, каждый раз:
verify_page /: renders OK (title "App")
Правда. Страница загрузилась. <canvas>, рисующий полностью чёрные пиксели, ничего не выбрасывает — это совершенно здоровый canvas, полный C_out ≈ 0. Каждый гейт был гейтом текста-и-структуры, а отрендеренная картинка — ни то ни другое. Так что единственная истина, способная отличить рабочую сборку от сломанной, жила в пикселях — что ровно и есть причина, по которой эта задача измеряет стратегию взаимодействия, а не дедукцию. Мы добавили проход vision-QA: сделать скриншот canvas, задать vision-модели один узкий вопрос — это объективно сломано? Он вернулся с предложением, которое ни одна текстовая проверка произвести не могла: «всё тёмно-серое в оттенках серого, без различимых цветов.» Это превратило барахтанье агента из слепого в просто трудное — и дало нам оценочную задачу с настоящим, привязанным к картинке ключом ответов.
Отпечаток цели
Обе модели знали одну и ту же графику — open-weights кодер верно назвал PBR-материал, рассветное освещение, дублирующий ambient. На бенчмарке знаний они набрали бы одинаково. Их развела стратегия поведения в тупике, и план-ведомость фиксирует эту стратегию в самой грубой возможной форме: имена подзадач, которые создала каждая модель.
След open-weights модели, по порядку:
fix-chunk-material → fix-merged-material → fix-normals-and-material
→ fix-winding-texture-normals → render-fix-v4-merged
Пять подряд идущих попыток fix- в одну и ту же цель: снова атаковать патч, снова вывести winding, попробовать ещё раз. Шага сбора информации в нём нет нигде. След Codex:
render-debug-isolation → render-fix-v5-rewrite
Первый — эксперимент — подзадача, цель которой узнать, а не отгрузить (переключатель на unlit MeshBasicMaterial). Второй — переписывание, а не патч. На все 21 подзадачу, связанную с рендером, за всю историю проекта слово «isolation» встречается ровно один раз, и оно от Codex. Как и «rewrite».
Цифры соглашаются ещё с двух ракурсов:
| Сигнал | Open-weights кодер | GPT-5.3 Codex |
|---|---|---|
| Шаги → исход | 51 → budget-exhausted (без фикса) |
14, затем 19 → all-subtasks-terminal (чисто) |
| Output-токенов на шаг | медиана 74, макс 6 400 (всплески повторного вывода) | медиана 257, макс 4 511 (ровно) |
| Первый ход в тупике | заново вывести векторное произведение | изолировать переменную |
Больше шагов, хуже результат. И форма диагностична: вывод open-weights был бимодальным — короткие тыки в инструменты, перемежаемые стенами анализа по 6 400 токенов, дважды приходящими к противоположным выводам на одной и той же выкладке для winding, — тогда как у Codex он был ровным, потому что он тратил токены на продвижение, а не на повторное вскрытие вопроса.
Прочтите эти три сигнала вместе — и они указывают на одно, на то, что настоящий, привязанный к окружению бенчмарк видит, а таблица лидеров нет. Есть проблемы, решаемые дедукцией, — доказательство, ошибка типа; больше размышления и есть путь — и проблемы, решаемые только взаимодействием, — пайплайн GPU с состоянием, где недостающий факт живёт в работающей системе и достижим только запуском эксперимента. Модель, дообученная на длинногоризонтных агентных задачах с обратной связью от исполнения, усваивает, что ценность действия — это неопределённость, которую оно убирает; она тянется к переключателю изоляции и к переписыванию представления. Модель, чья сила — рассуждение соревновательного уровня, приносит эту силу в каждую проблему — включая те, где вывести векторное произведение в шестой раз — это движение без прогресса. Воксельный баг — почти идеальный разделитель, потому что он одновременно переопределён (нет дедуктивного градиента) и дёшево изолируем (одна замена материала): он вознаграждает ровно ту стратегию, которую прививает агентное обучение, и наказывает ровно ту, которую прививает чистое рассуждение.
Честная оговорка, которую стоит держать в уме: это одна трасса, прочитанная снаружи. Сэмплирование, обвязка (harness), лимиты контекста и сырой масштаб модели — всё это вносит свой шум, и ничто из этого не является утверждением о весах. Один реальный баг — это размер выборки в единицу, что и есть цена за измерение свойства, которое таблицам лидеров недоступно. Но паттерн — отлаживать экспериментом, а не повторным выводом; перестраивать так, чтобы баг стал непредставимым, а не переспоривать его — воспроизводим, и это то, что предсказывает публичное направление обучения кодинг-агентов. Самый честный бенчмарк, который мы прогнали в этом месяце, вообще не был бенчмарком. Это был чёрный экран одного пользователя, и он сказал нам то, чего никогда не скажут зачётные ведомости: к какой модели тянуться, когда экран перестаёт давать ответы.