📓 Журнал ML Lab

Правила журнала

Новые записи — сверху. Каждая запись: сделано / решено (с «почему») / открытия / следующий шаг. Это точка входа «на чём мы остановились» для обоих участников; план со статусами живёт в roadmap-value-model.

📍 Где мы остановились — на 12.08.2026

Блок датирован намеренно

Если дата выше сильно отстала от сегодняшней — доверять записям ниже, а не этой сводке.

Аналитическая ветка ДОСТАВЛЕНА конец-в-конец: модель → эндпоинт (analytics v0.6.0, прод на aurora, EV_MODEL_PATH) → обе панели UI (analytics-ui#204). MC удалён из продукта. Открытых PR нет.

Артефакты (всё в dicechess-ev, теперь ВЕРСИОНИРУЮТСЯ с манифестами — ev#24):

ЧтоГде
полный KCP-датасет, 19 991 736 строкtraining_data_kcp_full_20260809.csv.gz (gitignored)
прод-модель аналитикиmodels/kcp_nodice_serving_20260809.onnx — 13 фич, MAE ~0.9 пп на пробах
roll-aware серверная (ждёт per-roll эндпоинта)models/kcpdice_serving_20260809.onnx — 19 фич
кандидат-эвалуатор бота (не задеплоен)models/rich_bot_20260809.txt
харнесс бенчмаркаsrc/dicechess_ev/brier_bench.py

Следующее действие: шаг 3(c), NNUE-прототип — единственный оставшийся рычаг силы бота. Перед любым замером силы — шаг 1 (протокол стенда).

Фоновых арен больше нет — оба замера закрыты 12.08 (см. запись ниже). Ветка raw-board закрыта как игрок; книга дебютов даёт +0.96 пп и рычагом не является. Обе арены в arena-rg погашены, данные версионируются в dicechess-ev/logs/.

Ярлыки арен в записи от 09.08 устарели

Таблица ВМ там подписывает две машины arena-rg как «k48-long» и «K=all», но seed’ы (3000 и 502000) и проценты — от книжной дуэли: боксы перепрофилировали 07.08, а подписи не обновили. История остаётся как есть, но сверяться по ней при инвентаризации нельзя — только по duel-chunks.sh на самой машине.

Три вывода, которые стоит помнить, прежде чем предлагать что-то новое:

  1. Данные исчерпаны как рычаг — +17% корпуса дали −0.002 AUC; ×15 для rich-9 дало 0,7% по Brier.
  2. Дистилляция больше не работает — июльские +2.2 пп были эффектом малых данных.
  3. Эмпирика слабее модели даже в голове распределения — и MC теперь удалён из всех путей продукта.

2026-08-12 (вечер) — анализ партий спроектирован; корпус опроверг настройки менеджера времени

Два сюжета: продуктовый (что делать с уже готовыми моделями) и неожиданный (что данные говорят о часах).

Анализ партий — спроектирован и заведён

Обе serving-модели получают продуктовое применение: шкала оценки по ходу партии, разложение свинга на удачу и решение и оценка каждого хода против выбора модели. Полный дизайн — game-analysis-design; issues: analytics#276 (roll-aware эндпоинт), analytics#277 (грейдер ходов), analytics-ui#206 (три фазы UI).

Ключ, объясняющий, почему это вообще возможно: экономика перевернулась. Та же цена оценки (~40–60 мс), что закрыла модель для живого бота, для анализа безобидна — там она платится один раз на позицию и кэшируется навсегда. А luck = P(поз, кости) − P(поз) даёт фичу, которой у шахматных баз нет по природе игры.

Отдельно зафиксирована честная поправка про NNUE: её главная суперсила — скорость внутри поиска — для анализа не нужна. Настоящий аргумент в её пользу другой: она открывает глубину, а глубина у нас измерена как рычаг того же порядка, что и фичи (1→2 плая на rich: +9,8 пп; KCP вместо rich на 1 плае: +12,8 пп). Комбинация «KCP-качество на двух плаях» никогда не измерялась, потому что вычислительно невозможна — вот её NNUE и открывает.

Часы: корпус опроверг настройки TimeManager

Наблюдение владельца («партии короткие, времени остаётся много») проверено по базе и подтвердилось — с уточнением масштаба. Все числа вынесены в отдельную справочную заметку: corpus-time-and-game-length.

Коротко: медиана партии 12 ходов (среднее 14,7, p99 = 49), а TimeManager.BaseMovesToGo = 30 — то есть на первом ходу бот делит часы примерно на вдвое большее число ходов, чем сыграет. Условное ожидание «сколько ещё осталось, если мы на ходу N» начинается с 14 и — неожиданно — не монотонно: проседает до 8,7 к 20-му ходу и снова растёт к 10,9 на 40-м, потому что дожившая до сорокового хода партия по отбору эндшпильная. Линейный taper не описывает ни то, ни другое. Заведено как engine#602.

Подтверждения запаса: у людей доля флагов перестаёт улучшаться после 300 с (2,63% против 2,65%) — лишнее время просто не тратится; у ботов счётчики поражений по времени 0/0 и в 7 400-партийной дуэли, и в 12 000-партийной книжной.

🔑 Практическое следствие, меняющее прошлые выводы. На 3+2 реально доступно ~14 с/ход, а KCP-модель с K=48 стоит ~2,4 с. Значит «дорогая» модель в живую игру влезает с многократным запасом — дорогой её делал не бюджет, а консервативная раздача бюджета. Это стоит держать в голове, возвращаясь к шагу 4.

Дырка в данных

turns.thinking_time_ms заполнена в 0 строк из 22 млн, хотя колонка, поле контракта и запись в репозитории существуют — все производители шлют null, и у play-api это прямо зафиксировано в коде, хотя часы он ведёт сам. Пока это не починено, расход времени измеряется только косвенно. Заведено: play-api#316.

Следующее действие не изменилось: шаг 3(c), NNUE-прототип. Но появился более дешёвый кандидат вперёд него — калибровка TimeManager: ноль обучения, ноль данных, и эффект, который в отличие от последних экспериментов может оказаться измеримым.

2026-08-12 — оба замера закрыты: raw-board отпадает, книга даёт +1 пп

Две timed-дуэли, шедшие фоном, дошли до заданной точности и обе дали отрицательный для своей гипотезы ответ.

Raw-board против oracle-3 — решающий замер ev#16

Обе стороны 2-ply expectimax при candidateLimit 24, контроль 3+2, зеркальные кости, одна машина. Равная ширина поиска намеренна: она и делает результат утверждением об оценщиках, а не о поиске.

Партий7 400 (3 700 пар, 148 чанков)
Win rate raw-board46.37%
ci95±0.99 пп → [45.38%, 47.37%]
Поражений по времени0 / 0

Около −25 elo.

🔑 Решает не win-rate, а счётчики флагов. Вся ставка raw-board строилась на том, что дешевизна за позицию купит глубину. Если бы сеть проигрывала из-за того, что не влезает в часы, это увидели бы счётчики — они 0/0 на каждом промежуточном срезе, от 100 партий до 7 400. Значит дефицит в оценке, а не в бюджете, и untimed-провал гейта 3 (57.3% против 60.8% у учителя) timed-замером подтверждён, а не опровергнут — это и был открытый вопрос #16. Согласуется с более ранней поправкой: rawboard оказался ~5× дороже rich за оценку, а не дешевле.

Устойчивость: половины дают 46.64% и 46.03% (обе ±1.41 пп) — дрейфа нет.

🔑 Урок про промежуточные срезы. Ранние чтения гуляли сильно: 38.0% на 100 партиях, 47.0% на 5 000. Это были вложенные (кумулятивные) взгляды на одно бегущее среднее, поэтому плавный подъём — ожидаемая форма, а не тренд. Проверять дрейф нужно разбиением на независимые половины; я сперва подал этот подъём как «оценка не устоялась» и был неправ.

Книга дебютов — с книгой против без

hunter-all против себя же, 12 000 партий: 50.96%, ci95 ±0.73 пп → около +7 elo, 0/0 по времени. Книга работает, но рычагом не является — ровно масштаб, который предполагала замеренная доля попаданий 7.4%. Подробности и разбивка по архитектурам — в opening-book-as-built.

Инфраструктурный урок, который стоил 6 ч 43 мин

Шард raw-board простоял почти семь часов, рапортуя об успехах. Причины две, обе мои:

  1. Цикл писал echo "chunk done" сразу после команды — без проверки кода возврата и наличия файла. 27 провалов выглядели 27 успехами, progress-лог стал источником лжи.
  2. Watchdog по процессам сработал все 27 раз и не вылечил ничего: он убивал зависший клиент sbtn, а сервера для каталога уже не было — висела попытка его поднять (server=[none] в каждой записи). Автоматика превратила вечное зависание в самоподдерживающийся 15-минутный цикл.

Починено по причине, а не по симптому: sbt убран из схемы совсем (java -cp напрямую по classpath, снятому с известного-рабочего форка), условие готовности чанка = существование выходного файла, а монитор следит за появлением файлов, а не за живостью процессов. После замены — 2.5 суток, 0 провалов, 0 предупреждений.

Следующий шаг: шаг 3(c), NNUE-прототип — единственный оставшийся рычаг силы бота. Арены погашены, кредит больше на них не тратится.


2026-08-10 — модель дошла до пользователя: обе панели UI, Монте-Карло удалён

Цикл, начатый вопросом «чем считать вероятности, когда партий мало», замкнулся на пользователе: модель теперь отвечает в обеих панелях аналитики, а Монте-Карло удалён из продукта целиком (analytics-ui#204; эндпоинт — analytics#273, прод v0.6.0 на aurora).

Что изменилось для пользователя

Просмотрщик партий (PositionEquity): при выборке меньше порога вместо MC-роллаутов в браузере — серверная модель с бейджем 🧠 Model. Точечная оценка без CI-полосы (это не выборочная оценка), вердикт по кубу разрешён сразу — модель откалибрована. Деплой без приватных весов (503) отображается как «estimate unavailable», не как ошибка.

Openings Explorer — получил оценку, которой у него никогда не было, и потерял ложную уверенность, которая была: пустая выборка рендерилась как 0.0% · 95% CI ±0.0 · You're behind — defend — конкретный игровой совет, выведенный из нуля наблюдений. Теперь: эмпирика только когда партии есть, модель в заголовке когда их нет, строка «🧠 Model: N%» рядом с эмпирикой когда есть обе, и вердикт только от источника, который его тянет.

Почему MC именно удалён, а не оставлен фолбэком

Смещение MC сидело ровно под вердиктами по кубу. (MC−50) = 0.434·(DB−50) в переводе на пороги удвоения:

Истинная вероятностьMC показывалВердикт былДолжен быть
70%58.7%HoldOffer the double
80%63.0%HoldToo good
90%67.4%OfferToo good

«Предлагай удвоение» было практически недостижимо на позициях без статистики — то есть совет по ключевому навыку x2-игр был систематически труслив именно там, где игроку больше не на что опереться. Честно названная цена удаления: MC был единственным оценщиком без бэкенда; деплой без модели теперь говорит «unavailable» там, где раньше стояло число. Отсутствие числа лучше смещённого числа, поданного с уверенностью.

Бонус сноса: из UI ушла целая подсистема — Web Worker, WASM-копирование с pre-хуками сборки, зависимость dicechess-engine-wasm, прогрессивные состояния «уточняется». Заметка rao-blackwellized-monte-carlo с этого дня — историческая справка, а не описание работающей фичи.

Урок дня: ревью поймало то, что не ловится глазами

CodeRabbit нашёл настоящий баг в кэше промисов: кэшировался сырой промис запроса, а превращение 503→null жило в catch только у создателя. Конкурентный вызывающий — ровно тот сценарий, ради которого кэш промисов и заводят — получал reject вместо обещанного контрактом null.

Поймать это в браузере было почти невозможно: обе панели на 503 показывают «unavailable» что при null, что при reject — визуальная проверка прошла бы всегда. Разница существует только на уровне контракта функции. Вывод в копилку процесса: у публичной функции с обещанием в docstring должен быть тест на само обещание, а @coderabbitai review на содержательных PR — не ритуал.

Верификация

Гейт зелёный (eslint, prettier, svelte-check — 0 ошибок), 103 юнит-теста (6 новых на fetchWinProbability, включая конкурентный 503), прод-сборка без WASM-хуков, Playwright E2E в CI. Вживую — на локальном бэкенде с настоящей моделью (aurora в момент проверки была недоступна из сети): записанный путь (154k партий, CI-полоса) и модельный путь (🧠, «Only 1 recorded game», ≈24.1% на глубокой позиции — число осмысленное: чёрные без двух фигур, но белый король под ударом ладьи, партия так и закончилась) отрендерились корректно.

2026-08-09 (поздний вечер) — модель бота уперлась в фичи: дистилляция дала ноль

Вопрос был простой: у бота модель от июля, обученная на ~1,3 млн строк, а корпус вырос до 20 млн — не пора ли переобучить? Проверили. Ответ: пора, но выигрыш микроскопический, и это третий за день замер, показывающий одно и то же.

Ловушка разных тестовых выборок — второй раз за день

Новый rich на своём тесте дал AUC 0.6894 против июльских 0.7023, то есть «15× данных сделали хуже». Неправда: июльский корпус был нефильтрованный, сегодняшний — только king_captured, это разные популяции. На одном холдауте порядок обратный:

МодельBrier ↓log-loss ↓
df_rich (июль, простая dice-free rich-9, 1,31 млн строк)0.22630.6454
rich_bot_20260809 (новая, простая, 20 млн строк)0.22470.6392

Сравнение вышло чище, чем задумывалось: один и тот же рецепт на разных корпусах, отличается только объём данных.

Поправка к первой версии этой записи: df_rich — не дистиллированная

Сначала я записал её как «июльская дистиллированная», выведя это из префикса df. Проверка при заведении манифестов опровергла: df_base.txt побайтово идентичен baseline_nodice.txt, а wd_base.txt — baseline.txt. Значит префиксы означают dice-free и with-dice, а не «distilled»; df_rich — простая rich-9 без учителя (9 фич против 15 у wd_rich, сверено).

Вывод «простая обошла дистиллированную», который я из этого вывел, не подтверждён и снят. Сам замер и все выводы ниже не затронуты: дистилляцию мерили отдельным свипом, а не сравнением с df_rich.

Это ровно та археология, из-за которой в ev теперь версионируются модели с манифестами (ev#24): происхождение, выведенное из имени файла, оказалось неверным.

Дистилляция: сигнал учителя больше ничего не добавляет

Свип по lambda (учитель — kcp_nodice_serving на 20 млн; lambda=0 — честный контроль, чистый rich на том же сплите):

lambdalog-lossAUC
0.00 (контроль)0.63040.6897
0.250.63040.6896
0.500.63040.6896
0.750.63040.6895
1.00 (только учитель)0.63050.6894

Разброс по AUC — 0.0003. Лучшая точка — сам контроль. В июле та же процедура давала +2.2 пп.

Почему приём перестал работать — и что это значит

Дистилляция ценна, когда данных мало относительно ёмкости модели: мягкие метки учителя несут информацию, которую ученик не может извлечь из редких сырых исходов. На 1,3 млн строк это работало. На 20 млн rich-9 насыщен данными — девять признаков и пятьсот деревьев уже выбрали из корпуса всё, что могли, и подсказка учителя ложится поверх того, что ученик и так знает.

То есть июльские +2.2 пп были эффектом малых данных, а не свойством метода. Приём не сломался — исчезли условия, в которых он помогал.

Три независимых замера за день, один вывод

РычагЧто дал
корпус +17% (17,1 → 20 млн)−0.002 AUC (шум)
корпус ×15 для rich-9 (1,3 → 20 млн)Brier −0.0016 (0,7% относительно)
дистилляция KCP-учителя0.0003 AUC (ничего)
KCP-фичи вместо richBrier −0.0117 (5%)

Данные и пост-обработка исчерпаны, ограничивают фичи. Рычаг ровно один: затащить KCP-качество в бюджет листа поиска — то есть NNUE (шаг 3c), а не выжимать доли процента из rich.

Решение: арену на rich-ретрейн не тратим

Отрыв 0,7% по Brier почти наверняка неразличим на арене — сегодняшние дуэли не смогли отделить от нуля эффект в 1,3 пп даже на 4000 партиях. Ставить сутки арены ради заведомо неизмеримой разницы — потратить единственный дефицитный ресурс на заведомо пустой результат. Модель rich_bot_20260809 сохраняется как готовый кандидат, но замер откладывается до появления соперника с ожидаемо большим отрывом.

2026-08-09 (ночь) — шаг 0 закрыт: Brier-бенч опроверг два пункта собственного плана

Собран бенчмарк оценщиков — тот самый гейт шага 0. Итог: две из трёх идей архитектуры от 08.08 не подтвердились замером, и это лучший исход, чем если бы мы их построили.

Сначала — измерение, которое переопределило постановку

Прежде чем сравнивать оценщики «в среднем», померили, как часто позиции вообще повторяются:

ходов всего22 263 480
различных позиций15 720 253
встречаются ровно один раз15 259 961 (97,1%)
встречаются ≥10 раз41 536 (0,26%)
…но покрывают ходов5 749 200 (25,8%)

Распределение крайне головастое: несколько десятков тысяч дебютных позиций несут четверть трафика, дальше — почти уникальный хвост. Значит формулировка плана «эмпирика для частых, prior для редких» переставлена задом наперёд: модель — основной оценщик для ~74% ходов и единственный для 97% позиций. Поэтому все метрики считаются по сегментам частоты, иначе одна усреднённая цифра спрячет единственный интересный вопрос.

Конструкция бенча (без утечек)

Холдаут — партии, пришедшие после снапшота 02.08 (2 864 168 ходов, 193 949 партий), модель kcpdice_kc_20260809 обучена ровно на снапшоте и их не видела. Эмпирические счётчики накапливаются только по train. Изотоника и α настраиваются на 30% холдаута (по партиям), отчёт — на остальных 70% (2 004 751 ход).

Харнесс живёт в репозитории, а не в черновиках: dicechess_ev/brier_bench.py (ev#21) — замер воспроизводится одной командой. Из инженерного там стоит запомнить двухпроходную экономию памяти: сначала собираются ключи холдаута, и только потом стримится train, накапливая счётчики исключительно для запрошенных ключей — иначе словарь на 13 млн позиций вместо 3 млн. Запуск:

uv run python -m dicechess_ev.brier_bench <train.csv.gz> <model.txt> <holdout.csv.gz>...

Результаты

ОценщикBrier ↓log-loss ↓
константа 0.50.25000.6931
эмпирика (позиция)0.25261.0256
эмпирика (позиция+бросок)0.25321.0300
модель kcpdice0.21320.6122
модель + изотоника0.21320.6124
смесь α=2000.21360.6134

По сегментам (Brier, чем меньше тем лучше):

СегментХодовконстантаэмпирикаэмпирика+костимодель
не встречалась (n=0)1 433 5370.25000.25000.25000.2040
n=132 2720.25000.44800.25420.2235
n=2–958 0590.25000.29200.26610.2258
n≥10480 8830.25000.24260.26130.2386

Сегменты нельзя сравнивать между собой

Модель «хуже» в голове (0.2386) чем в хвосте (0.2040) не потому, что там работает хуже, а потому что частые позиции — дебютные, около-равные и объективно менее предсказуемые. Корректно сравнивать только столбцы внутри строки.

Что опровергнуто

Изотоника не нужна. 0.2132 против 0.2132, по log-loss даже на 0.0002 хуже. Модель уже откалибрована, а недоуверенные края несут слишком малую долю массы. Пункт закрыт как «померено, не требуется» — дешевле, чем тащить лишний слой в прод.

Сглаживание (w + α·prior)/(n + α) не нужно. Оно было ядром предложенной архитектуры — и проиграло чистой модели везде. Показательно, что α подобралось в максимум сетки (200): тюнер просил «доверяй prior’у ещё сильнее, эмпирику почти игнорируй». Модель обходит эмпирику даже там, где данных много (n≥10: 0.2386 против 0.2426).

Эмпирика по (позиция+бросок) хуже, чем по позиции — 0.2613 против 0.2426 в голове. Измеренное подтверждение опасения из плана: ключ с броском в 56 раз разреженнее, и условие на бросок съедает выборку быстрее, чем добавляет информации.

Монте-Карло: приговор с числом

EvaluateMonteCarloApp, топ-12 позиций (28 тыс. – 1,6 млн партий каждая, эмпирика там фактически истина), 200 роллаутов:

  • MAE 4,50 пп, максимум 9,12 пп;
  • регрессия через ноль: (MC−50) = 0.434 × (DB−50) — MC сохраняет лишь 43% сигнала, сжимая 57% к 50%;
  • у 2 позиций из 12 знак перевёрнут (MC считает сторону хуже там, где она реально лучше);
  • цена: 304 с на 12 позиций ≈ 25 с/позицию при жалких 200 роллаутах.

Это ровно то смещение, которое предсказывалось 08.08 из теории: случайная политика роллаутов размывает преимущество. И раз это смещение, а не дисперсия — роллаутами оно не лечится. Против модели, которая отвечает за микросекунды и точнее, MC не имеет ни одного аргумента.

Итоговая архитектура (пересмотренная замером)

Вероятность считает модель — одна, без смеси и без пост-калибровки. Эмпирика остаётся в explorer’е как описательные данные для головы распределения («так реально играли N партий»), но не подмешивается в оценку. MC уходит из горячего пути окончательно, оставаясь регрессионным инструментом.

Гейт шага 0 взят. Следующий рычаг — не данные и не пост-обработка, а фичи и класс модели (шаг 3).


2026-08-09 (вечер) — дельта досчитана, флагман переобучен на полном корпусе

KCP-обогащение дельты завершено. Итоги прогона: 1 766 328 строк за 120 минут, 8 строк в карантине; вместе со спасённым после дневного обрыва — 2 738 404 из 2 738 412 строк дельты (99,9997%). Отдельно досчитаны отложенные beturanga-строки: 125 764, из них в карантине 0.

Карантин подтвердил «гнездовую» природу яда: 8 строк принадлежат всего 4 партиям, все с повёрнутой доской, все с dicechess.com. То есть отравлены не отдельные ходы, а целые кривые партии — и их единицы.

Слияние в полный свежий датасет (17,1 млн со снапшота 02.08 + 972 076 спасённых + 1 766 328 досчитанных + 125 764 beturanga):

MERGED 19 991 736 rows, 27 columns, 0 duplicates

Ноль дублей — не удача, а проверка: шарды непересекающиеся по построению, а единственный рискованный стык (спасённое ↔ досчитанное) проверялся явным сравнением ключей game_id,turn_number. Расхождение со свежим экспортом — 991 строка (0,005%): 8 карантинных плюс ~983 из партий, переконвертированных между экспортами.

Новый флагман: kcpdice_full_20260809

Тот же рецепт (LightGBM xentropy, rich+KCP, с костями, сплит по партиям), новый корпус:

МодельСтрокlog-loss ↓AUC ↑acc
kcpdice_kc_20260809 (снапшот 02.08)17,1 млн0.60210.72800.659
kcpdice_full_20260809 (полный, 09.08)20,0 млн0.60320.72600.657

Данные больше не являются ограничением — ограничивают фичи

Прирост корпуса на +17% сдвинул метрику на −0.002 AUC, то есть в пределах межвыборочного шума (тестовые выборки разные, это сравнение между датасетами, а не контролируемый A/B). Ровно тот же результат дал rich неделей раньше: 0.7063 → 0.7008. Два независимых замера на разных наборах фич говорят одно: на этом классе моделей объём данных исчерпан как рычаг.

Практический вывод для роадмапа: не гнаться за ещё бо́льшим корпусом, а двигать фичи и класс модели (шаг 3: дистилляция, raw-board, NNUE). Регулярный до-экспорт нужен для актуальности, а не ради метрики.

Важность фич — сильный аргумент за KCP. Четыре из семи верхних позиций теперь KCP-шные, а king_capture_attack вышел на второе место (7,8 млн gain), обойдя material_diff:

mobility_diff 11.6M · king_capture_attack 7.8M · material_diff 4.2M
king_capture_danger 2.6M · queen_capture_danger 1.1M · dice_q 0.8M · queen_capture_attack 0.7M

Это согласуется с ареной, где 1-ply KCP (60.8%) обходил 2-ply rich (57.8%): прямой сигнал «могу взять короля» ценнее и глубины, и материала.

Калибровка — как и раньше: середина шкалы почти идеальна (расхождение ≤0.002 в бинах 3–7), края недоуверены (бин 9: 0.938 против 0.954 фактических). Изотоника поверх остаётся в плане.

Следующий шаг: изотоническая пост-калибровка + сборка Brier-бенчмарка (эмпирика / MC / kcpdice / rich / rawboard) — и на этом шаг 0 закрыт.


2026-08-09 (день) — «ядовитые» позиции: расследование и его настоящая причина

Днём обогащение KCP-фичами падало снова и снова с StackOverflowError в KingCaptureProbability.captureDFS. Расследование дало результат намного важнее самой поломки.

Хроника ошибок (и чему они учат)

  1. Первое падение (ночь, Azure). Списали на маленький стек: sbt незаметно даёт JVM -Xss4M, а докерный лаунчер — дефолтный 1 МБ. Перезапустили с -Xss8M.
  2. Второе падение (мак, под sbt с 4 МБ). Опровергло версию «мало стека»: рекурсия бесконечная, а не глубокая. Гипотеза сменилась на «ядовитые данные с beturanga.com» (движок не поддерживает рокировку Фишера, а beturanga играет в неё).
  3. Третье падение — без единой beturanga-строки. Гипотеза опровергнута окончательно. Отсюда два вывода: чинить не «данные», а устойчивость пайплайна, и искать настоящую причину.

Урок процесса

Две первые версии были правдоподобны и обе неверны. Помогло не рассуждение, а дешёвый эксперимент, который их различал (прогон без подозреваемого источника). Правило на будущее: гипотезу о причине проверять отдельным дешёвым прогоном, а не закладывать в следующую большую попытку.

Инженерный ответ: пайплайн, который нельзя убить одной строкой

Написан chunk-раннер с автобисектом: файл жуётся кусками по 50k строк в отдельной JVM, упавший кусок рекурсивно делится пополам до строки-виновницы, она уходит в карантин (kcp_bad_rows.csv), остальное обогащается и склеивается по порядку. Худший случай теперь — минус несколько строк из 20 млн, а не потерянный прогон.

Настоящая причина (и она наша)

Dice Chess заканчивается взятием короля — хода, которого нет в обычных шахматах. Position.updatedCastlingRights снимает права рокировки в трёх случаях: взята ладья на своём углу, пошёл король, пошла ладья с домашнего поля. Случая «короля взяли» там нет — поэтому в терминальной позиции остаются буквы kq у стороны, у которой короля уже нет.

Поправка от 09.08 (вечер): связь с зацикливанием была переоценена

Здесь сначала было записано, что позиция «права есть, короля нет» и есть та, на которой captureDFS зацикливается. Замер при работе над #591 это опроверг: MoveGenerator выдаёт рокировку изнутри цикла по королям (activePieces = myPieces & state.kings), поэтому стороне, у которой короля на доске нет, рокировка не предлагается вообще — протухшие права ни при чём. Четыре разные позиции «король взят, kq остались» возвращают значение мгновенно; зависает только фикстура #549, где король на доске, но не на домашнем поле.

То есть зацикливает другой подкласс — те самые 490 «повёрнутых» позиций. Состав карантина это подтвердил независимо: все 8 отравленных строк пришли из 4 партий с повёрнутой доской, а из 125 764 beturanga-строк не отравилась ни одна.

Урок тот же, что и с двумя дневными гипотезами: правдоподобный механизм — ещё не измеренный механизм. Формулировка в issue #591 исправлена комментарием; сам фикс остаётся нужным (терминальные FEN должны быть самосогласованными), но как гигиена данных, а не как лекарство от зависания.

Замер по базе (20 785 801 позиция, ~1,5 млн партий):

КлассПозиций
права рокировки противоречат расстановке215 179 (1,03%)
⤷ из них: король уже взят214 714 (99,8%)
⤷ из них: король на доске, но не на домашнем поле490 (0,2%)

Решающий факт: из 271 715 партий с такой финальной позицией 18 328 — с нашего play-сайта, то есть прошедшие реплей этим же движком. Значит, это не легаси-скрейп, а сегодняшний вывод движка.

Что из этого следует для данных

  • Партии не «плохие», чистить базу не нужно. Все прошли валидацию реплеем; исходы настоящие. Это баг потребителя данных, а не грязь в данных.
  • Влияние на обучение микроскопическое: 599 ходов из ~20 млн (0,003%) используют такую позицию как исходную. Позиции со взятым королём терминальны и в фичи не попадают по построению.
  • Отдельный класс — 490 позиций: несколько десятков старых партий, записанных с доской, повёрнутой на 180° (короли на d1/d8). Тоже не «мусор», но геометрия у них чужая.
  • Проверку на ingest добавлять не нужно: гонять KCP на приёме — это десятки миллисекунд на позицию ради защиты от чужого бага; ingest уже делает правильную проверку (полный реплей движком).

Заведён движковый issue #591 — снимать права при взятии короля; фикс в PR #593 (диспетчер по типу взятой фигуры + тесты), CI зелёный, ждёт ревью. Собственно зацикливание чинит #549 — PR #592, смёржен. После бампа версии движка в analytics карантин (8 строк) можно пересчитать.

Вопрос «менять ли нормализацию FEN, чтобы 214 714 позиций слились с чистыми двойниками» сознательно отложен: выгода — дедуп 1% таблицы позиций, цена — data-repair миграция с пересчётом fen_hash и перевешиванием внешних ключей (прецедент — каноникализация en passant). При влиянии в 599 строк спешить не с чем.


2026-08-09 — старт шага 0: ревизия облака, фильтры, экспорт

Ревизия Azure перед стартом (задача «погасить отработавшие арены»):

ВМЧто делаетСтатус
dicechess-arena-x86 (rawboard-rg)timed-дуэль rawboard-1 vs oracle-3, K=24, 3+2 — решающий замер ev#16🔥 оба ядра 99.5%, работает
dicechess-arena-arm (arena-rg)дуэль k48-long чанками по 1000 партий; последний чанк 52.8% (527/472/1), пошёл seed 502000⚙️ считает, прогресс есть
dicechess-arena-x86 (arena-rg)дуэль K=all чанками; последний чанк 52.0% (519/479/2), пошёл seed 3000⚙️ считает, прогресс есть
dicechess-bot-k48 (bot-rg)живой бот лестницы (рваные 30–43% CPU = игровой трафик)🌐 живёт, не арена

Вывод: гасить нечего — все три арены честно прогрессируют (чанки завершаются, счётчики растут, таймаутов 0/0). Ровные 50% на арене — это одно занятое ядро из двух, а не зависание. Расход ~€9.4/день продолжается осознанно: кредит и задуман под замеры.

Решение — фильтр обучающих меток: только termination = king_captured. Одним условием отсекаются все четыре источника грязи:

  1. проигрыши по времени — исход не отражает качество позиции;
  2. double_declined — отказ от удвоения = поражение без доигрывания, позиция могла быть любой;
  3. ранние договорные ничьи турниров (0.5 на первом-втором ходу);
  4. частичные финальные ходы — по контракту ingest они бывают только у timeout/draw_agreement/resign, значит king_captured-партии всегда полные.

Рейтинг на экспорте не фильтруем: train.py в ev уже взвешивает строки по доверию к метке (бот-партии 0.3, unrated 0.5, рампа 0.6–1.5 по рейтингу), а срезы «только сильные» будем делать на этапе holdout.

Гоча ночного запуска: prod-конфиг экспорта целится в staging-копию БД (dexus), а она ночью спит → Connection refused. Экспорт запущен напрямую с прод-БД (read-only, батчи по 2000 партий с паузами 250 мс — нагрузка размазана).

Открытие, ускоряющее план: полные экспорты уже существуют — training_data_full_20260802.csv.gz (465 МБ) и training_data_kcp_full_20260803.csv.gz (700 МБ, KCP-разметка всей базы!). То есть половина шага 2 (учитель размечает) была сделана ещё неделю назад, и бенчмарк можно начинать не дожидаясь свежего экспорта.

Запущено этой ночью:

  • экспорт training_data_kc_20260809.csv.gz с прод-БД (king_captured; ~15 ходов/партию по первым батчам);
  • стрим-фильтрация kcp_full_20260803 → king_captured-подмножество (для немедленного переобучения на чистых метках).

Следующий шаг: переобучить LightGBM (kcp-фичи) на отфильтрованном датасете, снять log-loss + калибровку, сравнить с моделью от 03.08 — первая строка таблицы бенчмарка шага 0.

Продолжение той же ночи — первые числа бенчмарка

Экспорт завершён за 12 мин 17 с: training_data_kc_20260809.csv.gz = 1 330 746 партий / 19 992 727 строк (479 МБ). Это на ~194 тыс. чистых партий больше снапшота 02.08. Запущено rich-обогащение свежего файла.

Три модели на чистых метках (один и тот же датасет kcp_full_20260803_kc, 17,1 млн строк, одинаковый сплит по партиям, test-метрики):

МодельФичиКостиВесаlog-loss ↓AUC ↑acc
rich_kc_20260809rich✅—0.61940.70630.643
kcpdice_kc_20260809rich+KCP✅—0.60210.72800.659
kcp_nodice_w_kc_20260809rich+KCP❌✅0.61680.71000.647

Выводы:

  1. KCP-фичи дают чистый прирост при прочих равных (строки 1↔2, менялись только фичи): AUC +2.2 пп, log-loss −0.017. Сигнал «могу взять короля» работает и на 17 млн строк.
  2. Знание броска — ценная информация (строки 2↔3): roll-aware модель заметно сильнее dice-free. Для explorer-prior’а по ключу (позиция, бросок) нужен именно roll-aware вариант; dice-free — вектор для листьев поиска.
  3. Калибровка флагмана хорошая из коробки: в средних бинах расхождение ≤0.005, на краях модель чуть недотягивает уверенность (0.937 pred vs 0.949 actual) — лёгкая изотоника поверх закроет.
  4. Гоча тренера: --kcp и --rating-weights — опт-ин флаги; первый прогон случайно вышел rich-бейзлайном (артефакт переименован честно).

Следующий шаг: переобучить флагман на свежем датасете 20 млн строк (после rich-обогащения; для KCP-колонок дельты понадобится Azure-прогон), затем собрать в бенч эмпирику/MC/rawboard.

Закрытие ночной смены

Rich-обогащение свежего экспорта: 20 млн строк за 8 мин (12 потоков). Переобучение rich на свежем полном датасете (1 330 746 партий): test AUC 0.7008, log-loss 0.6227 — против 0.7063/0.6194 на снапшоте 02.08. Разница в пределах межвыборочного шума (другие партии, другой сплит): rich стабильно держит ~0.70 на обоих снапшотах. Главный сигнал ночи остаётся за KCP-фичами: +2.2 пп AUC при прочих равных.

Итог смены — шаг 0 пройден больше чем наполовину за один вечер: чистые метки решены и применены, свежий экспорт снят и обогащён, четыре модели переобучены, флагман определён (kcpdice, AUC 0.728, калибровка почти готовая). Из вычислений осталось: KCP-дельта на Azure, изотоника, бенч-хазнес для эмпирики/MC/rawboard.

Артефакты ночи: models/rich_kc_20260809.txt, models/kcpdice_kc_20260809.txt, models/kcp_nodice_w_kc_20260809.txt, models/rich_kc_fresh_20260809.txt (ev); training_data_kc_20260809[_rich].csv.gz (analytics, gitignored); training_data_kcp_full_20260803_kc.csv.gz (ev).


2026-08-08 — от Монте-Карло к prior + сглаживание; план зафиксирован

  • Обсудили, почему Монте-Карло как оценка вероятностей для редких позиций плох принципиально: случайная политика роллаутов даёт смещение, которое не лечится количеством роллаутов, а цена одной оценки — минуты. Полный разбор: win-probability-for-sparse-positions.
  • Архитектура-замена: калиброванный prior от модели + empirical-Bayes сглаживание с иерархией (позиция, бросок) → позиция → prior; MC остаётся офлайн-валидатором.
  • Написана объяснялка what-is-nnue — кандидат на «KCP-качество за микросекунды» для live-ботов.
  • Новые вводные: в базе уже 1,5 млн партий (DiceChess.com + play.jc.id.lv + немного Beturanga); Azure-кредит €150 / ~20 календарных дней как вычислительное окно.
  • Пятишаговый план «учитель → студент → поиск» согласован и зафиксирован в roadmap-value-model.