👑 Баг king-ring: считался только первый тип атакующих

О чём это

Эвристика «давление на кольцо короля» в Evaluator.evaluateAggressive с момента появления (30.05.2026, #270) и до исправления 03.08.2026 считала не то, что декларировала: вместо числа атакующих фигур она брала число фигур одного типа — первого найденного. Здесь разобрано, что именно шло не так, на каких позициях это меняло оценку и на сколько, и почему исправление (engine#513, PR #547) при этом не сделало бота сильнее. Цифры получены прогоном на движке 2026-08-03, не выведены на бумаге.

Связанные: Оптимизация KingCaptureProbability · Обзор ботов и алгоритмов · Бот не глупый, а медленный


1. Что делает эвристика

evaluateAggressive — оценщик бота aggressive (difficulty 5, и он же гейт-соперник для новых ботов). К обычной оценке (материал + безопасность короля) он добавляет три термина:

ТерминФормула
Пешечный штурмпродвижение × 15 за каждую свою пешку
Близость к королю(8 − расстояние_Чебышева) × вес, вес 40 ферзь / 25 ладья / 15 лёгкая
Давление на кольцочисло_атакующих × 25 за каждую из ≤8 клеток вокруг чужого короля

Третий и есть предмет разбора.

2. В чём была ошибка

Код читал число атакующих так:

val friendlyAttackers = MoveGenerator.isSquareAttacked(state, targetSq, color)
if !friendlyAttackers.isEmpty then kingRingPressure += friendlyAttackers.count * 25

isSquareAttacked не возвращает всех атакующих. Она пробует типы фигур по очереди и останавливается на первом попавшемся:

пешки → кони → диагональные (слоны и ферзь) → ортогональные (ладьи и ферзь) → король

Это её задокументированный контракт и то, ради чего она быстрая: для проверки легальности достаточно узнать, атакована клетка или нет. Но .count у результата — это число фигур одного типа, а не число атакующих.

Пешка маскирует всё остальное

Пешки пробуются первыми. Значит любая своя пешка, атакующая клетку кольца, обрубает подсчёт на себе: клетка, которую бьют пешка, конь, слон и ладья, оценивалась ровно как клетка, которую бьёт одна пешка.

3. Насколько это меняло оценку

Позиция: чёрный король h8 (кольцо — g7, g8, h7), белые пешка h6, конь e6, слон b2, ладья g1. Все четверо бьют g7. Прогон на движке:

g7  old=1 [h6]              ->  25 cp     new=4 [g1,b2,e6,h6]  -> 100 cp
g8  old=1 [g1]              ->  25 cp     new=1 [g1]           ->  25 cp
h7  old=0 []                ->   0 cp     new=0 []             ->   0 cp

Термин целиком: 50 cp вместо 125 cp. Разница в 75 сантипешек — три четверти пешки, потерянные на одной клетке.

Инверсия ранжирования — самое неприятное

Ошибка не просто занижала. Она занижала неравномерно, потому что величина зависела от того, какой тип попался первым:

Что атакует g7Старая оценкаНовая
пешка + конь + слон + ладья25 cp100 cp
два коня и больше ничего50 cp50 cp

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

4. Когда это срабатывало, а когда нет

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

Ошибки не было в трёх частых случаях:

  • клетку бьёт одна фигура — count = 1 в обоих вариантах;
  • клетку бьют только фигуры одного типа — ровно тот случай, где старый подсчёт случайно верен;
  • клетку не бьёт никто.

Именно поэтому баг прожил долго: в типичной позиции 1-ply поиска клетки кольца чаще атакованы одной фигурой, чем четырьмя разными.

5. Почему исправление не усилило бота

Это главный результат и он отрицательный. Замеры (исправленная версия против прежней, одинаковое железо, один процесс):

ЗамерnСчёт исправленной
первая дуэль40047.7% ← ложная тревога
свип весов 6…252000 каждый50.6–51.9%
прямая дуэль w10 и w12 против w25300050.9%

Первая дуэль показала «регрессию», которой нет: при n=400 интервал ±4.9 пп. На 2000 партиях исправленная версия не хуже ни при одном весе, но и не лучше значимо.

Причины, почему реальный баг даёт нулевой прирост силы:

  1. Симметрия. Обе стороны считались одинаково неправильно.
  2. Масштаб. 25–100 cp на клетку против штрафа за подставленного короля в 2000 cp и против близости, где ферзь рядом с королём даёт до 280 cp. Кольцо редко решает выбор хода.
  3. Редкость срабатывания — см. раздел 4.
  4. Меняется порядок, а не набор. Термин влияет лишь тогда, когда два хода стоят близко по остальным критериям.

Вес не трогали, и это осознанно

Свип показывал монотонный тренд «меньше вес — лучше», и снижение 25 → 10 выглядело обоснованным. Прямая дуэль дала +0.9 пп при интервале ±1.8 — внутри шума. Сравнение двух кандидатов через третью сторону — ровно то место, где такой тренд оказывается артефактом. Вес остался 25; подгонять его под 0.9 пп значило бы тюнить шум. Тот же урок в кампании по hunter (раннбук): скрининг на 1000 партий дважды за один день выдал «значимую» находку, которую подтверждение на 3000 переворачивало по знаку.

6. Что осталось в коде

  • Подсчёт идёт через MoveGenerator.allAttackers (добавлена в #509 как раз под такие задачи — полное объединение битбордов, без раннего выхода).
  • Вес вынесен в именованную Evaluator.RingWeight, в докстринге записано, что 25 — измеренное значение, а не унаследованное.
  • Регрессионный сьют KingRingPressureSuite, два теста, проверены мутацией — падают на старом пробнике. Каждый держит материал, пешечную структуру и дистанции до короля одинаковыми в сравниваемой паре, двигая одну фигуру между атакующей и безобидной клеткой: иначе разница в материале выдала бы себя за проверяемый сигнал.

7. Урок, который шире этого бага

Функция делала ровно то, что обещала её собственная документация, — но вызывающий код прочитал её название (isSquareAttacked) как «дай атакующих», а не «скажи, атаковано ли». .count у возвращаемого битборда выглядел как ответ на первый вопрос, хотя отвечал на второй.

Это тот же класс ошибки, что и неверный докстринг LeafKey (engine#514, PR #548), утверждавший, будто GameState не может быть ключом хеша: утверждение о поведении, которое никто не проверил, потому что проверить утверждение в комментарии нечем. Лечится одинаково — превращением утверждения в тест.