👑 Баг 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 * 25isSquareAttacked не возвращает всех атакующих. Она пробует типы фигур по очереди и останавливается на первом попавшемся:
пешки → кони → диагональные (слоны и ферзь) → ортогональные (ладьи и ферзь) → корольЭто её задокументированный контракт и то, ради чего она быстрая: для проверки легальности достаточно узнать, атакована клетка или нет. Но .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 cp | 100 cp |
| два коня и больше ничего | 50 cp | 50 cp |
Узкая однотипная атака оценивалась вдвое выше широкой разнотипной. То есть эвристика ранжировала «навалиться на короля всем, чем можно» ниже, чем «подвести две одинаковые фигуры» — практически противоположно тому, что «давление на кольцо» должно поощрять. И собственный докстринг обещал обратное: «бонус за свои фигуры, атакующие клетки вокруг чужого короля».
4. Когда это срабатывало, а когда нет
Ошибка проявлялась, когда клетку кольца атакуют фигуры разных типов — тем сильнее, чем разнообразнее атака и чем «раньше» в порядке проб стоит самый многочисленный тип.
Ошибки не было в трёх частых случаях:
- клетку бьёт одна фигура —
count = 1в обоих вариантах; - клетку бьют только фигуры одного типа — ровно тот случай, где старый подсчёт случайно верен;
- клетку не бьёт никто.
Именно поэтому баг прожил долго: в типичной позиции 1-ply поиска клетки кольца чаще атакованы одной фигурой, чем четырьмя разными.
5. Почему исправление не усилило бота
Это главный результат и он отрицательный. Замеры (исправленная версия против прежней, одинаковое железо, один процесс):
| Замер | n | Счёт исправленной |
|---|---|---|
| первая дуэль | 400 | 47.7% ← ложная тревога |
| свип весов 6…25 | 2000 каждый | 50.6–51.9% |
| прямая дуэль w10 и w12 против w25 | 3000 | 50.9% |
Первая дуэль показала «регрессию», которой нет: при n=400 интервал ±4.9 пп. На 2000 партиях исправленная версия не хуже ни при одном весе, но и не лучше значимо.
Причины, почему реальный баг даёт нулевой прирост силы:
- Симметрия. Обе стороны считались одинаково неправильно.
- Масштаб. 25–100 cp на клетку против штрафа за подставленного короля в 2000 cp и против близости, где ферзь рядом с королём даёт до 280 cp. Кольцо редко решает выбор хода.
- Редкость срабатывания — см. раздел 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 не может быть ключом хеша: утверждение о поведении, которое никто не проверил, потому что проверить утверждение в комментарии нечем. Лечится одинаково — превращением утверждения в тест.