Внутри движка Nardex: архитектурный разбор
Это последняя статья нашей ML-серии. Предыдущие четыре становились всё более конкретными (теория RL → стохастические игры → 30-летняя история → пайплайн обучения). Эта — самая конкретная: архитектурный разбор движка Nardex и окружающей системы. Конкретные пути к файлам, конкретные решения, конкретные компромиссы.
Если вы пришли за маркетингом — тарифами, заголовочными бенчмарками — вы их здесь не найдёте. Это пойдёт на страницу /developers, когда мы выйдем из private beta. Эта статья для инженеров, которые хотят знать, как эта штука собрана.
Структура репозитория
Вся система — это один Cargo workspace:
crates/engine/ — игровой движок: представление позиций, легальные ходы, оценщик
crates/coach/ — генерация обучающих данных, self-play, rollout, турнир
crates/logic/ — общая игровая логика (кубы, вероятности, общие типы)
crates/server/ — Axum HTTP + WebSocket-сервер
gui/frontend/ — SvelteKit + Tailwind веб-UI (этот сайт)
docs/dev/ — архитектурная документация и исследовательские заметки Пять Rust-крейтов плюс один фронтенд. Сборка выдаёт горстку бинарников: nardex-server для HTTP-сервиса, narde-pipeline и компанию для цикла обучения, narde-position-export для контентного тулинга.
Жёсткое архитектурное правило: движок ничего не знает про HTTP, сервер ничего не знает про ONNX. Каждый слой касается только абстракций нижнего слоя. Это звучит очевидно, но соблазн срезать углы реален, и именно срезание углов делает движки нередеплоиваемыми за годы.
Crate движка (crates/engine/)
У движка три обязанности: представлять позиции, генерировать легальные ходы, оценивать позиции.
Представление позиции. Bit-packed для быстрого хеширования и копирования. Позиция в коротких нардах кодируется в 10 байт; позиция в длинных нардах — тоже в 10 байт, той же bit-packed схемой. Позиции можно сериализовать в base64-без-padding строки, например JMC/MAWQCkHwHw для mid-game позиции в длинных нардах. Функция Position::from_id(&id) в crates/engine/src/narde/position/conversion.rs:18 декодирует эти строки обратно в объекты позиций.
Генерация легальных ходов. По заданной позиции и кубам перечисляются все легальные последовательности ходов. У коротких и длинных нард разные правила легальности (нет ударов в длинных нардах, правило головы, 6-в-ряд), но сигнатуры функций симметричны. Две игры живут в параллельных модулях (crates/engine/src/backgammon/ и crates/engine/src/narde/) с одинаковой поверхностью — adapter::Adapter, move::* — так что верхние слои могут быть variant-agnostic.
Оценка. ONNX-сети загружаются через Rust-крейт ort. У каждого варианта несколько файлов сетей (по одному на фазу для длинных нард; contact + race для коротких). Пути к моделям приходят из БД (активный профиль) или из переменных окружения (NARDE_MODELS_DIR, BACKGAMMON_MODELS_DIR).
Выход оценщика — сырой вектор вероятностей сети. Для коротких нард: 6 выходов, покрывающих normal/gammon/backgammon × win/lose. Для длинных нард: 6 выходов, структурированных как вложенные ординалы (P(win), P(win ≥ mars), P(win ≥ koks), и симметричная тройка для проигрывающей стороны). Расчёт equity происходит ниже по потоку от вектора вероятностей.
Движок cubeless на уровне оценщика — он возвращает вероятности, но не вычисляет take/drop-решения. Логика куба живёт в слое сервера/игры, потому что зависит от счёта матча и таблиц match-equity, о которых движок намеренно не знает.
Inference: ort, execution provider’ы, GPU
ONNX Runtime поддерживает несколько «execution provider’ов» — бэкендов, которые реально запускают модель. За одним и тем же ONNX-интерфейсом можно использовать:
- CUDA — продакшн-дефолт. NVIDIA GPU. Сабмиллисекундный inference на позицию.
- OpenVINO — Intel CPU и NPU. Полезно для edge-деплоев.
- TensorRT — NVIDIA, более агрессивная оптимизация, чем чистый CUDA. Небольшой риск качества из-за FP16-обрезания.
- CPU — фолбэк. Используется в тестах и в окружениях без GPU.
Мы компилируем движок со всеми четырьмя как build features. В продакшне выбирается CUDA в рантайме; тесты гоняются на CPU. Execution provider выбирается при инициализации оценщика; переключение — это однострочное изменение конфига.
Конкретное следствие: можно гонять движок полностью на CPU без пересборки. Это важно для тестов и для окружений, где нет GPU — например, CI-пайплайнов, ноутбуков разработчиков без NVIDIA-железа.
Мультифазные сети
Для длинных нард позиция проходит через структурно различные фазы:
- Blocking — у обеих сторон ещё есть шашки в стартовой четверти, контакт возможен.
- X escaped — белые освободили стартовую четверть, чёрные нет (или наоборот). Асимметрично.
- O escaped — симметрично, противоположный игрок.
- Race — контакт невозможен, обе стороны бегут к выбрасыванию.
Одна сеть с трудом учит все четыре фазы хорошо — race и blocking-позиции выглядят почти как разные игры. Поэтому мы обучаем по одной сети на фазу плюс унифицированную сеть для ранних позиций.
Определение фазы происходит за O(1) от количества шашек (crates/engine/src/narde/...phase). На момент оценки:
let phase = detect_phase(&position);
let network = networks_by_phase[phase];
let probs = network.eval(&inputs); Короткие нарды используют ту же идею, но с меньшим числом фаз (contact + race). Стоимость — ~2MB дополнительных весов модели и ветка определения фазы — обе пренебрежимо малы по сравнению с приростом точности на фазно-специфичных позициях.
Сервер
Сервер (crates/server/) — это Axum + Tokio + SeaORM + Redis + JWT. Он хостит:
- Real-time игровой WebSocket — для активных игр player-vs-player и player-vs-AI.
- HTTP API анализа матчей —
/api/v1/positions/analyzeдля оценки на позицию. - Auth и лобби — JWT (HS256), сессии в Redis.
- Админка и observability — daemon-дашборды, лидерборды, история.
Оценки движка — это прямые вызовы функций в crate движка — тот же workspace, тот же бинарник, никакого IPC. Это критическое решение по производительности: если бы мы поставили движок за ещё одну сервисную границу, каждый вызов анализа платил бы за маршалинг и сетевую стоимость. Держа движок и сервер в одном процессе, мы получаем сабмиллисекундную задержку inference end-to-end.
Обратная сторона: серверный бинарник толстый. В нём вся логика движка, все ONNX-зависимости, все веса моделей. Деплой — это один бинарник плюс директория с .onnx-файлами. Мы доставляем через Docker-образы.
Фронтенд
gui/frontend/ — это SvelteKit (Svelte 5 runes) + Tailwind CSS 4. Это интерактивный UI для человеческой игры, аналитические дашборды и этот контент-сайт, который вы читаете.
Две архитектурные заметки:
Статический prerender для маркетинга. Маршруты вроде /learn/*, /blog/*, /faq, /developers/* пререндерятся во время сборки и отдаются как статический HTML. Это то, что вы сейчас читаете — нет серверного roundtrip’а, чтобы отрисовать эту статью, сборка выдала готовый HTML-файл.
Client-only для интерактива. Маршруты вроде /game/[id], /local, /admin ставят ssr=false; prerender=false, потому что им нужны real-time данные, browser-only сторы или auth-состояние. Конфиг SSR на маршрут в SvelteKit делает это аккуратно.
Два режима делят одну библиотеку компонентов, одну i18n-машинерию и один backend API. Мы не форкаем кодобазу, чтобы выкатить маркетинговый сайт отдельно.
Инфраструктура обучения
Подробно разобрана в предыдущей статье. Кратко: обучение гоняется на том же железе (одна GPU непрерывно) с бинарниками вроде narde-pipeline, train, find-best-net, reclassify-positions, cleanup-models, compare-evaluators. Модели хранятся в PostgreSQL через ту же схему SeaORM, которую использует сервер; промоут — это запись в БД, которую сервер читает при следующем рестарте (или hot-reload, в зависимости от деплоя).
Позиция с провенансом: narde_positions.source ∈ {random, expert, selfplay, game} — game покрывает позиции, извлечённые из реальных партий игроков (live-play запись плюс импорт матчей). Source сохраняется через UPSERT-операции, так что мы можем аудитить качество данных постфактум (например, «не из-за чрезмерной ли опоры на selfplay недавняя регрессия?»).
Логика куба и матчевая игра
Решения по кубу живут в модуле game/ сервера, не в движке. Причина — в слоении: решения по кубу зависят от состояния матча (счёт, длина, статус Crawford, текущее значение куба) — информации, которой у движка намеренно нет. Помещение логики куба в движок нарушило бы правило «движок ничего не знает про HTTP/матч».
Пороги бот-удвоения, take-пороги и поправки лежат в game/bot_doubling.rs. Money offer threshold = 0.66 equity, accept threshold = 0.25; trailing/leading-поправки — ±0.05–0.06 в зависимости от стороны. Они получены из анализа стандартных таблиц куба для коротких нард и тюнинга против rollout’ов; применяются и к коротким, и к длинным нардам, потому что математика куба идентична.
Куб реализован для длинных нард в матчах длиной ≥ 3 (соответствует NAR-52 и follow-up PR’ам #7–#11 в нашей git-истории). Crawford-партия, post-Crawford recube’ы и потолок 64 работают как ожидается. Beaver, raccoon и Jacoby не реализованы — это турнирные варианты правил, которые нам не понадобились.
Чего нет в архитектуре (пока)
Вещи, намеренно не построенные, перечислены для полноты:
- WASM-inference на стороне браузера. Фазные сети достаточно малы (~2MB каждая), чтобы их можно было поставлять в wasm-бандле для офлайн-игры. Помогла бы квантизация. В роадмапе.
- Интеграция MCTS. Тестировали экспериментально, не побила обычный network+ply-2 search достаточно, чтобы оправдать продакшн-сложность.
- Распределённое обучение. Одной GPU пока хватает. Когда время обучения станет узким местом, добавим multi-GPU поддержку; сейчас мы data-bound, не compute-bound.
- Тонко настроенные таблицы match-equity. Используем дефолтную post-Crawford-safe MET. Лучшая таблица, кастомизированная под нашу equity-поверхность, в роадмапе.
Что прочно стоит
Архитектурные решения, которые хорошо состарились:
- Variant adapters. Добавление длинных нард (нетривиальной новой игры) стоило недель, а не месяцев, потому что паттерн адаптера сделал движок variant-agnostic с самого начала.
- Движок в одном workspace с сервером. Прямые вызовы функций вместо subprocess/IPC отбили вложения колоссально, когда понадобился сабсекундный анализ в масштабе.
- Мультифазные сети. Разделение по фазам дало больше прироста точности на инженерный час, чем любое другое изменение, которое мы пробовали.
- ONNX как рантайм-формат. Обучающий фреймворк отвязан от inference. Легко поменять PyTorch на TF или наоборот, не трогая сервер.
Если вы строите что-то похожее — движок настольной игры с сетями, хостящий анализ и real-time игру, — это те дизайн-решения, которые положительно компаундятся со временем.
С чего начать как разработчику
Если хотите реально пользоваться движком (а не просто читать про него):
- API quickstart — minimum-viable analyze-запрос меньше чем за 5 минут.
- Reading Analysis Output — поле-за-полем референс ответа.
- Developers — форма доступа к бете.
- Сравнение с gnubg и XG — чем мы являемся и не пытаемся быть.
Если хотите исторический и теоретический контекст:
- RL for Board Games: A Primer
- Stochastic Games and RL
- TD-Gammon to Modern AI
- Training a Backgammon NN
На этом ML-серия завершена. Будем добавлять новое по мере развития движка.