🪜 Рейтинговый лэддер и webhook — план выполнения

Что это

Исполняемый план (задачи PR-уровня, каждую можно отдать отдельному агенту) для двух целей: (1) бот-vs-бот рейтинговый лэддер, чтобы выяснить, какой алгоритм сильнее; (2) позже — serverless-подключение сторонних ботов через webhook. Это companion к стратегическому ревью 07-Revyu-bot-platformy-2026-07-blokery-i-plan (обоснование, экономика хостингов, AGPL) и к 01-Dorozhnaya-karta (продуктовые фазы 1–6). Здесь — что именно делать и в каком порядке. Все привязки к коду перепроверены аудитом от 2026-07-14.

Обновление 2026-07-17 — фазы B–E выполнены; F.2 и сайт документации готовы

Лэддер работает end-to-end: шедулер (#102) → CRN-пары (#101) → game_results (#98) → Glicko-2 батч (play-api#119, PR #122) → публичное API лидерборда (play-api#103, PR #123) → страница /leaderboard и сквозной BOT-бейдж в SPA (play#107, PR #115). Включение на проде — за оператором: LADDER_INTERVAL_SECONDS + RATING_INTERVAL_SECONDS на play-api (+ redeploy). Ранее принят ADR-0011: ладдерные данные живут в play-БД; analytics — source-agnostic корпус; B.3 отменена, D.1/E.1 переехали из analytics в play-api. Спасённый из B.3 фикс дрейфа types.ts тоже сделан (play#113). E.1 — отчёт силы (GSPRT на парах + Bradley-Terry, mise run ladder:report) сдан (play-api#120, PR #124). F.1ADR-0013 принят; F.2 — синхронная доставка webhook сдана (play-api#104, PR #125; опция WEBHOOK_TIMEOUT_SECONDS). Сайт документации для авторов ботов (Astro + Starlight, ADR-0012) — сдан (play-api#121, PR #126), живёт на https://jc.id.lv/dicechess-play-api/ (переезд bot-api.md в навигируемые страницы + новая англоязычная страница верификации честности костей; CD на GitHub Pages, Dependabot). F.3 (play-api#105) — сдано полностью: OpenAPI 3.1 + AsyncAPI 3.1 спеки + /api раздел на сайте (PR #128) и два MIT-стартера «Use this template» (dicechess-bot-python, dicechess-bot-typescript), в каждом poll-бот (live-проверен) и serverless webhook-хендлер. Вся фаза F закрыта. В плане остался только A.1 (an#245). Секции ниже сохранены как исходный план с пометками — актуальная разбивка задач в эпике play-api#106.


0. Что изменилось с ревью 07

Три из «четырёх блокеров» §3 ревью 07-Revyu-bot-platformy-2026-07-blokery-i-plan с тех пор реализованы — DX-фундамент (Этап 1 в 07) по большей части готов, поэтому работа по лэддеру и webhook подтягивается ближе.

Блокер §3 (стр. 07)Статус сейчасКод
1. Легальные ходы не на wireна wirePublicGameState.legalMoves inline + GET /games/{id}/moves (PlayRoutes.scala:100-115)
2. Discovery только live-стримоместь pollingGET /bot/games (BotRoutes.scala:196-210); poll-only режим задокументирован в bot-api.md
3. Нет durable self-service identityестьтаблица bots (V3__play_bots.sql:4-14), POST /bot/register, ротация POST /bot/token
4. Human-vs-bot не существует⚠️ частичноботы садятся по Principal без WS; серверного пейринга всё ещё нет — его закрывает лэддер (Фаза C)

Следствие

«Фундамент DX» больше не блокер. Остаётся серверный пейринг (единственная непокрытая часть §3.4) и рейтинговая машинерия — ровно то, что ниже.


Порядок фаз

flowchart TB
    A["Фаза A · Первый ответ<br/>ранкинг из имеющихся данных · analytics · 0 схемы"]
    B["Фаза B · Фундамент данных ✅<br/>rated · game_results (seed+pairing внутри play-БД)"]
    C["Фаза C · Лэддер ✅<br/>рейтинг-состояние · CRN-пары · server-chosen пейринг"]
    D["Фаза D · Рейтинг + витрина ✅<br/>Glicko-2 батч в play-api · лидерборд · BOT-бейдж"]
    E["Фаза E · Строгий вердикт<br/>SPRT + WHR/Ordo · пентаномиал на CRN · play-api"]
    F["Фаза F · Serverless<br/>webhook ADR → delivery → OpenAPI/SDK"]
    A -.->|первый сигнал, валидация пайплайна| D
    B --> C --> D --> F
    B --> E
    C --> E

Соответствие существующим документам. Фазы A–D ≈ Этап 3 «Экосистема» (07-Revyu-bot-platformy-2026-07-blokery-i-plan §7) и Фаза 5 «Рейтинги» (01-Dorozhnaya-karta); E–F ≈ Этап 4 «Киллер-фичи». Лестница из 05-Bot-platforma-anonimnyy-Bot-API-dizayn сохранена.

Сверка: где считается Glicko-2 — пересмотрено ADR-0011

01-Dorozhnaya-karta и 07 клали Glicko-2 офлайн-батчем в analytics («ноль нагрузки на игровой JVM»), и исходная версия этого плана следовала тому решению. ADR-0011 (2026-07-16) его пересмотрел: рейтинг считает батч на стороне play (по game_results, вне игрового write-path — так что «ноль нагрузки на игровой поток» сохраняется), потому что и входы (game_results.rated/result/pairing_id), и выход (bots.glicko_*), и лидерборд (D.2) живут в одной play-БД — а вариант с analytics требовал расширения ingest-контракта (B.3) и несуществующего обратного потока analytics→play. Живой пересчёт в игровом сервисе по-прежнему не делаем.


Глобальные правила для агентов

  1. Сначала прочитать AGENTS.md соответствующего репозитория — он приоритетнее этой страницы по стилю/командам.
  2. Никогда не коммитить в main. Ветка <type>/<short-desc>; в теле PR Closes #<id>, если есть issue.
  3. Стейджить файлы по имени (git add -A/. запрещены). Английский в коммитах/PR. Conventional commits.
  4. Мигелирования: писать, но не запускать — прогон против общей БД делает только оператор.
  5. DoD = mise run check + mise run test зелёные в затронутом репо. Не пропускать вывод тестов через grep/head.
  6. Кросс-репо задачи (⚠КОНТРАКТ) правят все зеркала контракта в одном наборе изменений.
  7. sbt/npm install требуют токена GitHub Packages (движок) — gh auth login / NODE_AUTH_TOKEN до установки.

Репозитории: play-api, analytics, play (SPA), house-bots, docs.


Фаза A — Первый ответ из имеющихся данных

Цель: получить грубый ответ «какой алгоритм сильнее» сейчас, без изменений схемы, и заодно провалидировать пайплайн до постройки машинерии. analytics уже пишет обе идентичности ботов + результат + цвет.

A.0 — Сгенерировать корпус бот-vs-бот · house-bots (+оператор) · Routine

  • Использовать существующий механизм челленджей — без серверного кода: настроить oracle-ботов челленджить друг друга (BOT_CHALLENGE=team|name, см. docker-compose.yaml / Config.scala) или крошечный скрипт, логинящийся одним токеном и шлющий POST /bot/challenge остальным по кругу.
  • Убедиться, что готовые партии доходят до analytics (POST /api/games).
  • Готово когда: ≥ несколько сотен решённых партий по ≥3 идентичностям bot:*, оба цвета; отчёт по парам.

A.1 — Провизорный ранкинг алгоритмов · analytics · Mid

  • Новый standalone IOApp в maintenance/ (паттерн EvaluateMonteCarloApp.scala); обернуть mise-таском.
  • Фильтр: решённые бот-vs-бот (termination != 'unknown', оба player_type='bot').
  • На идентичность bot:*: партии, W/D/L, win-rate с биномиальным 95% CI (Wilson), разбивка по цвету. Переиспользовать запросы PlayersRepository.scala:159,280 и формулу api/Protocol.scala:89.
  • Батч-Elo / Bradley-Terry (итеративный MLE, ~50 строк) → ранжированная таблица с колонкой неопределённости.
  • Проверка цветового перекоса (win-rate белых vs 50%) — сколько удачи/первого хода в данных.
  • Готово когда: mise run <task> печатает ранжированную таблицу с CI; стабильно между прогонами; README-заметка о методе и его границах (unpaired, уровень алгоритма).

Фаза B — Фундамент данных для строгого измерения

Кладём rated, dice-seed и pairing_id до шедулера, чтобы партии лэддера сразу несли правильные измерения.

B.1 — Флаг rated на партии · play-api · Mid

  • Сейчас различия rated/casual нет нигде (GameSnapshot store/GameStore.scala:26-43, seeks, challenges, миграции — отсутствует).
  • Добавить rated: Boolean в GameSnapshot (Circe + persist), решается в момент создания, неизменяемо.
  • Политика (чистая функция isRated(white, black, requested)): false, если хоть один участник анонимный (team == "anon") или Guest; иначе — по запросу (дефолт false).
  • Протянуть через GameRegistry.create(...) (новый параметр, дефолт false).
  • Готово когда: новые партии персистят rated; anon/guest всегда rated=false; тесты матрицы политики; check/test зелёные.
flowchart TD
    Q{"Оба участника<br/>зарегистрированы<br/>и не анонимны?"}
    Q -->|нет| N["rated = false<br/>(в рейтинг не идёт)"]
    Q -->|да| R{"Партия создана<br/>как рейтинговая?"}
    R -->|нет| N
    R -->|да| Y["rated = true<br/>(фиксируется на старте)"]

B.2 — Queryable-проекшн game_results · play-api · Frontier (схема)

  • Данные игр — непрозрачный JSONB snapshot (запрашиваемый только status). Лэддеру/рейтингу нужен перечислимый список партий по участнику/результату/rated/паре.
  • Миграция Vn__game_results.sql: game_id uuid PK, white_external_id, black_external_id, result smallint (POV белых 1/-1/0/null), termination, rated bool, time_control, server_seed, pairing_id uuid null, finished_at. Индексы (rated, finished_at), по участникам.
  • Заполнять в той же транзакции, что и терминальную запись снапшота (PgGameStore.scala:33-46).
  • Готово когда: финиш игры вставляет ровно одну строку; тест на testcontainers; миграция только пишется, не прогоняется.

B.3 — ⚠КОНТРАКТ: dice-seed + pairing_id в ingestОТМЕНЕНА (ADR-0011)

Отменена 2026-07-16, issue play-api#99 закрыт

Ingest-контракт не расширяется. Всё, что эта задача собиралась вывозить в analytics, уже лежит дома: B.2 положила server_seed и pairing_id в play.game_results. Три причины отмены: (1) read API analytics публичный — отправка общего сида CRN-пары при завершении первой партии воспроизвела бы утечку #115 на его стороне; (2) схема analytics декларативно source-agnostic — колонки под механику одного источника противоречат её дизайну; (3) у исходного плана не было спроектированного обратного потока analytics→play для лидерборда — расчёт дома замыкает цикл в одной БД. Спасённая часть — фикс уже существовавшего дрейфа verbatim-копии src/lib/ingest/types.ts в SPA (отсутствуют white/black_money_delta, thinking_time_ms, fen_after, clock_white/black_ms) — вынесена в отдельный issue play#113.


Фаза C — Лэддер (server-orchestrated)

C.1 — Рейтинг-состояние бота + on-ladder + owner · play-api · Frontier (схема)

  • bots (V3__play_bots.sql:4-14) сейчас без рейтинга и без владельца.
  • Миграция Vn__bot_rating.sql: glicko_rating double precision default 1500, glicko_rd default 350, glicko_vol default 0.06, on_ladder bool default false, owner_external_id text null (owner закладываем заранее — под будущие человеческие аккаунты).
  • BotStore read/write; путь opt-in on_ladder.
  • Готово когда: регистрация → дефолтный провизорный рейтинг; переключение on_ladder персистит; check/test зелёные.

C.2 — CRN: создание зеркальной пары · play-api · Frontier (кости/авторитет)

Снижение дисперсии: каждый матч лэддера = две зеркальные партии (те же кости, цвета наоборот) → удача сокращается.

flowchart LR
    S["1 serverSeed +<br/>(clientSeedW, clientSeedB)<br/>фиксируются сервером"]
    S --> G1["Игра 1<br/>Бот A = White · Бот B = Black"]
    S --> G2["Игра 2 — зеркало<br/>Бот B = White · Бот A = Black"]
    G1 --> R["Идентичная<br/>последовательность костей<br/>по ply → удача нейтрализуется"]
    G2 --> R
    R --> P["общий pairing_id →<br/>пентаномиальный скоринг (Фаза E)"]
  • Механизм: roll(ply, clientSeedW, clientSeedB) = HMAC-SHA256(serverSeed, …), позиционно-независим (DiceSource.scala:41-53,71-100; коммент :13-18 прямо про зеркальные пары). Seed инъектируется: GameRoom.create(...) принимает DiceSource (GameRoom.scala:615-617), сборка DiceSource.fromHexSeed (:66-69).
  • Подводный камень: клиентские seed привязаны к участнику (fallback seedFor = principal.externalId, GameRoom.scala:582-587). При обмене цветов это меняет, какой seed достаётся белым/чёрным → кости разъедутся. Для игр лэддера нужно зафиксировать клиентские seed по цвету (в обход SubmitSeed/fallback).
  • Добавить внутренний путь createMirroredPair(botA, botB, tc): один serverSeed + одна пара (clientSeedWhite, clientSeedBlack), две игры со свапом цветов, общий pairing_id (пишется через B.2). Только серверная сторона (server authority). Не менять алгоритм DiceSource — иначе включается требование golden-vectors + правка docs/bot-api.md.
  • Готово когда: тест создаёт пару и проверяет идентичную последовательность костей по ply при свопнутых цветах; общий pairing_id; check/test зелёные.

C.3 — Пейринг-шедулер · play-api · Frontier (конкурентность)

  • Net-new: серверного пейринга нет (единственный примитив GameRegistry.create(white, black, tc) GameRegistry.scala:41-57; все вызовы accept-driven). Пары выбирает сервер — анти-фарм: бот не может выбрать соперника, владелец не накрутит рейтинг двумя марионетками (согласуется с §7 стр. 07: «same-team пары не рейтингуются»).
  • Supervised фоновый fiber (паттерн sweeper’ов Lobby.scala:100-106): по интервалу выбирает пары из пула on_ladder и зовёт createMirroredPair, троттлинг по max-concurrent-games.
  • MVP-пейринг: рандом, избегать частого повтора одной пары; тяготеть к высокому RD/близкому рейтингу (Glicko-aware — опционально).
  • Контроль времени: часы enforced (07 §6 + аудит; коммент Protocol.scala:22-31 устарел). Можно взять короткий Fischer/PerMove для throughput; безопасная альтернатива — Unlimited + ~120с idle-cap GameRoom. Агент подтверждает enforcement перед тем, как полагаться на часы.
  • Готово когда: при ≥2 on-ladder ботах шедулер запускает зеркальные пары с нужной частотой, уважает кап, чисто гасится на shutdown; интеграционный тест на один тик; check/test зелёные.

Фаза D — Рейтинг + витрина

D.1 — Glicko-2 батч по game_results · play-api (было: analytics, пересмотрено ADR-0011) · Frontier · ✅

Issue: play-api#119 (заменил analytics#246), выполнено — PR play-api#122. Вне игрового write-path — «ноль нагрузки на игровой поток» сохраняется.

  • Реализовать Glicko-2 (rating/RD/volatility) чистой функцией + тесты на эталонном примере Гликмана.
  • Периодический батч (фоновый fiber по образцу LadderScheduler, env-gated): читает рейтинговые партии через GameResultsStore.finishedRatedSince с персистентным курсором (+ небольшой overlap назад и дедуп по game_id — у finished_at задокументирована гонка порядка коммитов). Пишет play.bots.glicko_* транзакционно, в той же БД.
  • Провизорные (RD > порога, напр. 110) считаются, но скрыты из публичного лидерборда до сходимости. Короткие рейтинг-периоды/сезоны (задокументировать выбор).
  • Готово когда: юнит-тест воспроизводит числа Гликмана; рейтинговая партия сдвигает оба рейтинга и ужимает RD; casual/aborted — без изменений; курсор переживает рестарт; check/test зелёные.

D.2 — Leaderboard API · play-api · Mid · ✅

Issue: play-api#103, выполнено — PR play-api#123. После ADR-0011 шаг «снапшот из analytics» исчез: D.1 пишет bots.glicko_* в той же БД — это чистое read API.

  • GET /leaderboard (rated, непровизорные, по рейтингу, с RD/партиями/W-D-L) и GET /bots/{team}/{name} (профиль: сводка рейтинга + недавние результаты из game_results). Публичные. Обновить docs/bot-api.md.
  • Готово когда: эндпоинты возвращают верные формы; провизорные скрыты; check/test зелёные.

D.3 — Страница лидерборда + BOT-бейдж · play (SPA) · Routine/Mid · ✅

Issue: play#107, выполнено — PR play#115 (плюс #116: освежён Layout в README).

  • Прозрачность: человек всегда видит, что играет против бота (устраняет находку 07 §6 «боты невидимы как боты»).
  • Роут /leaderboard (за VITE_PLAY_API_URL); компонент BOT-бейджа везде, где показывается имя бота (доска, лидерборд, история) — сохранить ARIA/семантику (Svelte 5 runes, Tailwind 4, дом-конвенции AGENTS.md SPA).
  • Готово когда: страница рендерит ранкинг; бейдж на именах ботов; npm run check+test зелёные; проверено в dev-сервере (hard-refresh мимо PWA).

Фаза E — Строгий вердикт

E.1 — SPRT (парный) + WHR/Ordo (пул) на CRN-парах · play-api (было: analytics, пересмотрено ADR-0011) · Frontier · ✅

Issue: play-api#120 (заменил analytics#247). pairing_id живёт в play.game_results и не покидает play-сервис. Точный инструмент «B сильнее A» (как Fishtest у Stockfish) + пул-ранкинг с CI — на variance-reduced CRN-парах.

  • Парный SPRT: по двум идентичностям и их head-to-head — последовательный log-likelihood ratio для H0 (Δelo ≤ elo0) vs H1 (Δelo ≥ elo1) с конфигурируемыми α/β; вердикт accept/reject/continue и число партий. Пары с pairing_id скорить вместе как пентаномиал (5 исходов на пару) — та самая редукция дисперсии, ради которой seed.
  • Пул-ранкинг: WHR (Coulom) или Ordo/Bradley-Terry батчем с 95% CI и LOS между соседями.
  • Owner-facing report-runner в play-api (IOApp через sbt runMain + mise-обёртка — паттерн maintenance-раннеров analytics: логика в отдельно тестируемом классе), не публичный эндпоинт.
  • Готово когда: SPRT воспроизводит синтетику (явный выигрыш → ранний accept; проигрыш → ранний reject); пул-ранкинг гоняется на реальном game_results и печатает CI; заметка о методе; check/test зелёные.

Фаза F — Serverless для сторонних (после того как лэддер докажет ценность)

Что уже работает без изменений сервера

Poll-only serverless играбелен сегодня (bot-api.md описывает «cron-triggered cloud function», есть random_bot.py) для длинных/Unlimited контролей. Фаза F добавляет webhook-push — делает serverless первоклассным (любые контроли, честный zero-infra). Не начинать до лэддера. Экономику хостингов и AGPL-стартеры см. в 07-Revyu-bot-platformy-2026-07-blokery-i-plan §5, §4.

F.1 — ADR по webhook (только дизайн) · docs · Frontier · ✅ ADR-0013 принят

ADR готов: ADR-0013 (статус «предложено» — F.2 стартует после ревью владельцем, issue dc#17).

  • ADR в разделе Play Site: решение (синхронный request-reply webhook), контракт эндпоинтов (регистрация URL; payload = существующие DiceRolled/PublicGameState; тело ответа = ход, применяемый как SubmitTurn), надёжность (опираемся на существующий per-turn deadline — dead-letter для MVP не нужен), и требования безопасности (ниже). Диаграмма последовательности.
  • Готово когда: ADR ревьюнут владельцем до старта F.2.
sequenceDiagram
    participant GR as GameRoom (play-api)
    participant WH as Webhook-dispatcher
    participant Fn as Бот (serverless-функция)
    GR->>WH: DiceRolled («твой ход») для сиденья
    WH->>Fn: POST callback-URL {gameId, dfen, dice, clocks} + HMAC
    Fn->>Fn: посчитать ход (cold start + inference)
    Fn-->>WH: 200 {moves:[...]}
    WH->>GR: SubmitTurn (как команда)
    Note over GR,Fn: нет ответа за min(бюджет, ~10–30с) → тикают часы (форфейт по клоку)

F.2 — Синхронная доставка webhook · play-api · Frontier (безопасность + конкурентность) · ✅

  • Регистрация callback-URL на зарегистрированном боте (перк регистрации; anon — нет), с верификацией владения (сервер POST-ит nonce, бот эхом возвращает) до отправки игровых данных.
  • На «твой ход» внутренний подписчик GameRoom POST-ит событие и ждёт ответ; тело ответа применяется как GameCommand.SubmitTurn. Таймаут = min(бюджет, конфиг ~10–30с); на таймаут/ошибку — ничего (клок/deadline разрулят). Доступность спящего бота для лэддера уже решена (пары выбирает сервер, Фаза C).
  • Безопасность (всё обязательно): SSRF-guard (блок RFC1918/loopback/link-local 169.254.169.254, только HTTPS, лимит редиректов/размера); HMAC-подпись (секрет на бота) для проверки подлинности; rate-limit доставки.
  • Уважать single-writer GameRoom (подписчик подаёт команду, не второй писатель). Диспетчер может переиспользовать outbox/backoff IngestDeliverer (07 §5).
  • Готово когда: тест-бот с локальным HTTP-эндпоинтом играет партию через webhook; SSRF-кейсы отклонены; HMAC проверяется; мёртвый эндпоинт форфейтит по клоку без зависания комнаты; check/test зелёные.
  • Позже (отдельная задача): async-вариант (событие + отдельный POST /move) с retry/health для долгодумающих ботов и высокой конкурентности.

F.3 — DX: OpenAPI/AsyncAPI + SDK + serverless-темплейты · play-api + новый репо · Mid · ✅

  • OpenAPI для REST Bot API + AsyncAPI для ndjson-стрима; тонкие клиенты (Python + TS) поверх auth и режимов; темплейты «бот за 5 минут»: timer-функция (poll-only, работает сегодня) и позже HTTP-функция (webhook, после F.2). Стартеры под MIT/Apache (растворяет AGPL, 07 §4).
  • Готово когда: сгенерированные клиенты компилируются и играют против house-спарринг-бота; темплейты задокументированы.
  • Сделано (play-api#105): OpenAPI 3.1 + AsyncAPI 3.1 спеки, отдаются для codegen и рендерятся нативным разделом /api/** на сайте доков (PR #128); два MIT-стартера «Use this template», каждый live-проверен против house/greedydicechess-bot-python (stdlib) и dicechess-bot-typescript (встроенный fetch). Находка: CDN блокирует дефолтный Python-urllib UA (Cloudflare 1010) — клиенты шлют осмысленный User-Agent.
  • Webhook-режим добавлен в оба стартера: stateless serverless-хендлер (проверка HMAC-подписи ±5 мин, эхо nonce хендшейка, ответ ходом; fallback на публичный GET /games/{id}/moves), one-time register, герметичные тесты + CI. Для доставки нужен только webhook-секрет, не токен.

Решения, зафиксированные в дизайн-обсуждении

  • Идентичность: структура владелец→бот (≈ существующий team/name); бот рождается ботом (без конверсии человек→бот); структурного поля версии нет — версия кодируется в имени, сравнение версий = эфемерные name-encoded участники + парный SPRT (E.1), не публичный лэддер.
  • Рейтинговая политика: нет рейтинга, если хоть один игрок анонимный; с аккаунтами — только если оба зарегистрированы и включили рейтинг; провизорные (высокий RD) считаются, но скрыты до сходимости; rated фиксируется на старте; пары рейтингового лэддера выбирает сервер (анти-фарм).
  • Два инструмента измерения: Glicko-2 лэддер (публичный лидерборд + грубый ранкинг) vs Fishtest-стиль парный SPRT / WHR-пул (точный вердикт). CRN-зеркальные пары (общий seed, свапнутые цвета) → пентаномиальный SPRT.
  • Границы данных (ADR-0011, 2026-07-16): play-БД — system of record для всего ладдерного/специфичного (game_results с seed/pairing, bots.glicko_*); analytics получает партии без playsite-специфичных полей (сегодняшний wire, без изменений) и остаётся source-agnostic корпусом позиционной аналитики. B.3 отменена; D.1/E.1 живут в play-api.

🔗 Связанное