🪜 Рейтинговый лэддер и 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.


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 в ingest"]
    C["Фаза C · Лэддер<br/>рейтинг-состояние · CRN-пары · server-chosen пейринг"]
    D["Фаза D · Рейтинг + витрина<br/>Glicko-2 офлайн · лидерборд · BOT-бейдж"]
    E["Фаза E · Строгий вердикт<br/>SPRT + WHR/Ordo · пентаномиал на CRN"]
    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

01-Dorozhnaya-karta и 07 кладут Glicko-2 офлайн-батчем в analytics («ноль нагрузки на игровой JVM»). Этот план следует тому же решению: authoritative-рейтинг считает analytics (Фаза D/E), а play-api лишь хранит и отдаёт снапшот рейтинга бота для лидерборда и матчмейкинга. Живой пересчёт в игровом сервисе не делаем.


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

  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 · play-api + analytics + play(SPA) · Frontier

  • Для строгого CRN-анализа (Фаза E) нужен seed и связка «какие две партии — зеркальная пара». Сегодня на wire только выпавшие кости, без seed и пары (контракт IngestProtocol.scala:51-71). Это verbatim-контракт — analytics реплеит партии и отвечает 422 на расхождение.
  • Добавить в GameIngest опциональные поля: server_seed, client_seed_white, client_seed_black, pairing_id (опциональность = обратная совместимость, старые payload’ы валидны).
  • Обновить: analytics (декод + миграция + индекс по pairing_id); эмиттер play-api (ingest/PlaysiteIngest.scala, сейчас :95 хардкодит rating → null); verbatim-копию SPA src/lib/ingest/types.ts (заодно устранить уже существующий дрейф: отсутствуют white/black_money_delta, thinking_time_ms, fen_after, clock_white/black_ms).
  • Версию НЕ добавлять: версия кодируется в имени бота (external_id), переименование = отдельная идентичность в analytics.
  • Готово когда: старые payload’ы ингестятся (201/200); новые персистят seed+pairing; round-trip тест; check+test зелёные в обоих Scala-репо, npm run check зелёный в SPA.

Фаза 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 офлайн-батч (authoritative) · analytics · Frontier

  • Реализовать Glicko-2 (rating/RD/volatility) чистой функцией + тесты на эталонном примере Гликмана.
  • Батч по рейтинговым партиям (rated=true, оба зарегистрированы; anon и same-team — исключены). Короткие рейтинг-периоды/сезоны (задокументировать). Провизорные (RD > порога, напр. 110) считаются, но скрыты из публичного лидерборда до сходимости.
  • Новый IOApp в maintenance/, mise-таск (паттерн Фазы A).
  • Готово когда: юнит-тест воспроизводит числа Гликмана; рейтинговая партия сдвигает оба рейтинга и ужимает RD; casual/anon — без изменений; check/test зелёные.

D.2 — Снапшот рейтинга в play-api + Leaderboard API · play-api · Mid

  • play-api хранит последний рейтинг-снапшот бота (заполняется из analytics-батча) и отдаёт его.
  • 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

  • Прозрачность: человек всегда видит, что играет против бота (устраняет находку 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-парах · analytics · Frontier

Точный инструмент «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 между соседями.
  • Новые IOApp в maintenance/, mise-таски.
  • Готово когда: SPRT воспроизводит синтетику (явный выигрыш → ранний accept; проигрыш → ранний reject); пул-ранкинг гоняется на реальном корпусе и печатает 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 в разделе 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-спарринг-бота; темплейты задокументированы.

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

  • Идентичность: структура владелец→бот (≈ существующий team/name); бот рождается ботом (без конверсии человек→бот); структурного поля версии нет — версия кодируется в имени, сравнение версий = эфемерные name-encoded участники + парный SPRT (E.1), не публичный лэддер.
  • Рейтинговая политика: нет рейтинга, если хоть один игрок анонимный; с аккаунтами — только если оба зарегистрированы и включили рейтинг; провизорные (высокий RD) считаются, но скрыты до сходимости; rated фиксируется на старте; пары рейтингового лэддера выбирает сервер (анти-фарм).
  • Два инструмента измерения: Glicko-2 лэддер (публичный лидерборд + грубый ранкинг) vs Fishtest-стиль парный SPRT / WHR-пул (точный вердикт). CRN-зеркальные пары (общий seed, свапнутые цвета) → пентаномиальный SPRT.

🔗 Связанное