📖 Книга дебютов: как она устроена на самом деле

О чём это

Проектные соображения — в «Книга дебютов для роботов». Здесь только то, что построено и измерено (07.08.2026): реальный формат, точная семантика фильтров по коду и по данным, и замеренное поведение на живой лестнице. Первый потребитель — azure/hunter-book (см. раннбук).

Формат и ключ

TSV, одна строка на запись, разделитель — табуляция (мигрировали с JSON в engine#573):

rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - BPR	e2e4,f1c4

Ключ — позиция плюс бросок, а не просто позиция:

<placement> <active-color> <castling> <en-passant> <dice>
  • Первые четыре поля — канонический normalized_fen (Dfen.normalizedFen), байт-в-байт тот же, что колонка normalized_fen в аналитике. Счётчики полуходов и ходов намеренно отброшены: книга от них не зависит, а без них позиции, различающиеся только счётчиком, делят одну запись. En passant канонизирован — оставлен, только если реально бьётся.
  • <dice> — буквы выпавших фигур (p n b r q k), отсортированные, в регистре стороны хода: заглавные у белых, строчные у чёрных. Совпадает с колонкой dice_sorted. Пример для белых с слоном+пешкой+ладьёй: BPR.

Значение — один ход как список микро-ходов через запятую. OpeningBookBot сопоставляет его с легальными путями по мультимножеству, поэтому порядок в файле не важен, а нереализуемая запись (устаревшая или битая) молча игнорируется в пользу поиска, а не играется нелегально.

Любое изменение формата — кросс-репозиторный слом

Ключ производят два места независимо: OpeningBook.key в движке (потребитель) и экспортёр аналитики (производитель). Расхождение в один байт означает книгу, которая никогда не срабатывает, — и ничего при этом не падает.

Как экспортируется

ExportOpeningBookApp [minGames] [minRating] [outputPath]
# по умолчанию: 100, 2000, opening_book.tsv

Прогон, из которого собрана боевая книга: minGames=100, minRating=2000 → 335 записей, 23 КБ.

Что означают пороги — по коду, не по описанию

minRating=2000 — оба игрока, и NULL отсекается заодно. В SQL буквально:

g.white_rating >= 2000 AND g.black_rating >= 2000

Не «хотя бы один» и не «средний». Побочный эффект, который стоит держать в голове: NULL >= 2000 в SQL даёт NULL, а не true, поэтому партии без записанного рейтинга отсекаются тем же условием.

minGames=100 — это порог на связку «позиция + конкретный бросок», не на позицию. GROUP BY normalized_fen, dice_sorted, position_after HAVING count(*) >= 100. С учётом того, что на позицию приходится 56 возможных мультимножеств костей, это гораздо более жёсткое условие, чем кажется, — и главная причина, почему книга такая маленькая.

Отбор хода внутри ключа: maxBy (winRate, число партий) — берётся линия с лучшим win-rate, при равенстве более сыгранная. Форсированные пасы (played_moves пуст) отброшены: пас не бронируемый ход.

Сколько корпуса реально участвует

партийдоля
Всего в базе1 456 549100%
Оба рейтинга проставлены1 122 35477%
Оба ≥ 2000 — статистическая часть книги214 10714.7%
Для сравнения, оба ≥ 1800271 87618.7%

Кто эти игроки — партии, прошедшие фильтр, по типам:

dicechess.com  human vs human   213 675   (99.8%)
dicechess.com  bot   vs human       420
dicechess.com  bot   vs bot          14

Практически чистая выборка игр людей против людей. Ровно то, ради чего порог и ставился: книга отражает игру сильных людей, а не то, как боты играют друг с другом на лестнице. Заодно это автоматически исключает самоподкрепление — собственные ладдерные партии наших ботов в книгу не попадают, потому что у них либо нет рейтинга, либо он ниже 2000.

Но 54 записи из 335 фильтр вообще не проходили

favoritesEntries — это SELECT ... FROM opening_book_favorites WHERE cardinality(played_moves) > 0, без minRating и без minGames. Все курируемые записи попадают в книгу безусловно и переопределяют статистический выбор для того же ключа:

statistical.filterNot(e => curatedKeys(...)) ++ curated

Значит из 335 записей боевой книги: 281 статистическая (люди 2000+, ≥100 партий на связку) и 54 курируемые (ручной выбор, любые пороги обойдены). Это ~16% книги. Проверено на экспортированном файле: курируемые ключи вроде … w KQkq - NNN → b1a3,a3b5,b5d6 в нём присутствуют.

Само по себе это фича, а не баг — курирование для того и заведено. Но фраза «книга собрана из партий игроков 2000+» верна только для 84% записей, и об этом легко забыть.

Замерено на живой лестнице

azure/hunter-book, первый день:

первые 2 часанакопительно
Ходов всего60432
Из книги532
Доля попаданий8.3%7.4%
Посчитано поиском55400
Ошибок / усечений по дедлайну00

Как это считается без отдельной телеметрии. Книжный ход не вызывает поиск, поэтому diagnosticsSink не срабатывает, и строка лога печатает legalTurns=n/a. То есть доля = отношение grep -c "legalTurns=n/a" к общему числу строк [hunter] turn. Это и было целью рефакторинга на sink (dicechess-hunter#23): отличать «решила книга» от «решил поиск» без единой новой строки кода.

7.4% — это примерно 0.4 книжных хода на партию, то есть книга срабатывает где-то в каждой третьей партии. Тоньше, чем предполагала проектная заметка («первые 1–3 хода главных линий»). На 432 ходах оценка уже устойчивая — за первые два часа было 8.3%, разница в пределах шума малой выборки.

Причина — ветвление по костям. Ключ включает бросок, на позицию их 56 вариантов, и порог «100 партий на связку» проходит совсем небольшое их число. Растить тут надо не корпус, а снижать пороги.

Рычаги, если нужно больше покрытия

Прогнано на текущем корпусе (07.08.2026, 1 456 549 партий):

ПорогиЗаписейПротив боевых
g100 / r2000 (боевые)335—
g100 / r1800422+26%
g50 / r2000735+119%
g50 / r1800906+170%

Заметно, что порог по числу партий двигает покрытие сильнее, чем порог по рейтингу: снижение minGames со 100 до 50 даёт +119%, а снижение рейтинга с 2000 до 1800 — только +26%. Логично: узкое место — ветвление по костям, а не дефицит сильных игроков.

Обмен честный и его стоит назвать вслух: вдвое больше покрытие, но за каждым ходом вдвое меньше партий. Что из этого лучше играет — не измерено; чтобы узнать, нужен парный прогон двух книг, а не выбор на глаз.

Числа порогов устаревают вместе с корпусом

В июле те же пороги давали 245 / 304 / 580 / 686 — корпус с тех пор вырос, и все четыре выросли вместе с ним. Если сверяешься с этой таблицей — сначала посмотри на дату.

Что из проекта дошло, а что нет

Сверка с проектной заметкой — полезна тем, что показывает, где реализация разошлась с замыслом:

Проектное предложениеКак вышло
Ключ = позиция × бросок✅ ровно так
min_rating ≥ 2000, оба игрока✅ ровно так
Явно исключить партии ботов⚠️ отдельного фильтра нет — цель достигнута побочно, через рейтинг (99.8% людей)
Хранить топ-K с win% и числом партий❌ в книге бота один ход на ключ; статистика остаётся в каталоге, но до бота не доезжает
Немного рандома среди близких линий❌ не сделано — бот детерминирован в дебюте и в принципе выучиваем
JSON❌ TSV (engine#573)
Валидация ареной (с книгой против без)✅ сделана 10.08.2026, 12 000 партий — см. ниже

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

Замерено: с книгой против без

Парная дуэль hunter-all против себя же: одна сторона с книгой, вторая без, зеркальные кости, контроль 3+2.

Партий12 000 (6 000 зеркальных пар)
W/L/D6104/5873/23
Win rate со стороны книги50.96%
ci95±0.73 pp → [50.23%, 51.69%]
Поражений по времени0 / 0

Книга даёт +0.96 pp, то есть примерно +7 elo. Оценка сошлась: на 7 000 партий было 51.11%, на 9 000 — 51.12%, на 12 000 — 50.96%.

Это ровно тот масштаб, которого стоило ожидать от замеренных выше 7.4% попаданий (~0.4 книжных хода на партию). Книга работает, но рычагом не является — ширина поиска двигает силу на порядок сильнее.

Итог честен только вместе с разбивкой по половинам

Шарды стояли на разных архитектурах, а таймированные замеры машинно-зависимы:

партийwin rateci95сам по себе
x86 (D2as_v5)4 00050.60%±1.27 ppне значим
ARM (D2ps_v5)3 00051.80%±1.47 ppзначим

Половины расходятся на 1.20 pp при стандартной ошибке разности 0.99 pp (p ≈ 0.23) — то есть не противоречат друг другу, и объединять законно. Но общий вердикт опирается в основном на ARM-половину, а бо́льшая из двух значимости сама не достигает.

Объединять через две архитектуры корректно здесь только потому, что сравнение внутри пары всегда шло на одной машине: оба контейнера (с книгой и без) жили на одном боксе и играли друг с другом, поэтому скорость машины внутри пары сокращается. Складывать так две ОТДЕЛЬНЫЕ модели, замеренные на разных боксах, по-прежнему нельзя.

Побочная гипотеза, не находка: больший эффект показал более медленный бокс (ARM, чанк ~11.5 ч против ~8.8 ч). Механизм правдоподобен — книжный ход минует поиск целиком, значит стоит тем больше, чем хуже поиск успевает. Но разница незначима, так что это повод для проверки, а не вывод.

Сырые чанки, агрегатор и скрипт прогона — в приватном dicechess-ev, logs/timed-duel-book-2026-08-10/.

Где используется

БотКнига
azure/hunter-book✅ монтируется файлом, HUNTER_BOOK_PATH
azure/onnx3-k48❌ намеренно выключена — иначе размыла бы измеряемый эффект candidateLimit
house-боты (oracle-*)❌ ORACLE_BOOK не задан
cloudflare/scala-aggressive-book✅ своя копия, устаревшая (JSON-эпоха)

Регенерация не автоматизирована

Книга обновляется вручную: прогнать ExportOpeningBookApp, скопировать файл, перезапустить контейнер (шаги — в раннбуке). Каденс не задан. Пока боевая книга собрана 07.08.2026 на корпусе в 1.46 млн партий.

Связано: opening-book-for-bots · azure-bot-box-runbook · candidate-limit-48-vs-24-timed-duel