Skip to content

About

Хакатон по разработке генератора интеграций с платёжными провайдерами: на вход подаётся OpenAPI-спецификация (например, provider_api.yaml), а решение должно автоматически создавать заготовку программной интеграции, документацию и тестовые примеры, адаптируясь к API разных провайдеров. На каждый трек отбирается 55 команд.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

70 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PayoutRouter — умный роутинг выплат

Решение задачи 2 хакатона Hack.Genesis 2026: распределение выплат между платёжными провайдерами с объяснением каждого решения, аналитикой качества роутинга и инструментами «что-если». Ruby 4.0, без нейросетей и проприетарных компонентов.

На вход — состояние провайдеров (providers.json), очередь заявок и история операций. На выход — routing_decisions.json (кому ушла каждая заявка, кого рассматривали, кого и почему отсеяли), routing_report.json (распределение, загрузка лимитов, причины отсева, рекомендации) и routing_report.html (дашборд одним файлом).

ТЗ: docs/tz.md · данные и их аудит: docs/data.md, docs/data_audit.md · архитектура: docs/architecture.md · питч: docs/pitch.md · план: docs/plan.md · перебор конфигураций и его выводы: docs/search.md · ручная проверка «что запустить и что должно получиться»: docs/manual_check.md · сверка с критериями трека: docs/criteria.md. Каталог analysis/ — офлайн-разведка данных на Python (polars), к решению не относится и в сборке ответа не участвует; scripts/history_stats.rb — калибровка по истории на Ruby.

Чем это решение отличается

Коротко, по одной строке на вещь; подробности — в разделах ниже.

Каждое решение объяснено числами В попытке лежит не только причина, но и разложение скора по целям и decisive vs — чем именно победитель обошёл конкретно второго. Вопрос «почему не payflow» закрывается командой explain op_105 --why-not payflow
Hard и soft разделены физически Правило допуска отвечает «можно ли» и возвращает код причины, цель — «насколько хотим» и возвращает 0..1. Смешать их нельзя по конструкции
Веса означают важность Оценки целей приводятся к разбросу среди кандидатов заявки (SAW). Без этого конверсия (0.79–0.91) весила бы в 22 раза меньше долей (0–0.9) при равных весах
Два способа сочетать стратегии Взвешенная сумма или цепочка ярусов с допуском: следующая стратегия решает, только если предыдущая не применима или дала равные оценки
Честная модель времени Отправка занимает реквизит и слот in-progress, ответ приходит через avg_latency_sec из очереди сеттлментов. Лимиты считаются по состоянию на момент заявки, а не «всё сразу обнулилось»
Заявленной конверсии не верим conversion_24h калибруется историей (усадка к приору), пара провайдер × банк учитывается от 5 наблюдений. Отчёт показывает дрейф: payflow заявляет 0.91 при наблюдаемых 0.47
Стратегия доказана аблацией На сценарии, где выбор есть в каждой заявке, цели по долям дают Σ|откл| 0.0 против 68.7 без них. На обычных данных так не проверишь: там у 98% заявок один кандидат, и это видно колонкой с_выбором%
Поведение при недостижимой цели Рядом с бумажной долей стоит достижимая (пропорциональный роутинг по допустимым), share_targets: attainable включает пересчёт прямо в скоринге, рекомендация называет число
Рекомендации с параметром 13 правил: не «обратите внимание», а «снизить traffic_percentage с 35 до 20 или поднять daily_amount_limit до 3 800 000 ₽»
Отчёт диагностирует сам скоринг goal_activity показывает, какие цели вообще различали кандидатов, и предлагает снять вес с мёртвой
Своя стратегия без правки ядра Плагином на Ruby (один метод) или декларативно в YAML вообще без кода
Знаем, насколько близки к пределу В каждом прогоне рядом считается точный оптимум очереди (min-cost flow) и кладётся в отчёт блоком optimality. Видно не только «сколько взяли от идеала» (91.0% на публичной очереди, 95.9% на тестовой из 90), но и разложение отставания: сколько стоит обязательство держать доли, а сколько — собственно онлайн-режим
Что-если до продакшена compare (11 политик на одной очереди), simulate (Monte-Carlo), tune (подбор весов), search (перебор 113 000 конфигураций, граница Парето), bound (та же таблица отдельной командой)
Стресс-прогон нашёл настоящие баги 17 сценариев, ~16 000 заявок. Нашёл перелив дневного лимита по формуле ТЗ (102.4%), перегрузку self-provider на залпе и уход счётчика in-progress в минус после внешнего снимка
Инварианты отделены от метрик Инварианты обязаны держаться при любом давлении и считаются заново по решениям и снимку, а не по счётчикам роутера. Каждая проверка проверена мутацией
Две модели таймаута cascade по букве ТЗ и hold — без переотправки и с удержанием ёмкости, единственная модель, которая не может привести к двойной выплате
Источник истины может быть снаружи POST /route принимает свежее состояние провайдеров вместе с заявкой — так отрабатывается «провайдер выключился посреди очереди»
Это прод-компонент HTTP-сервис с /metrics в формате Prometheus, Docker, CI с автопроверкой организаторов, 9 942 заявки/с в образе ruby:4.0
Ничего не теряем на битом входе --on-invalid skip уводит неразобранную заявку в карантин с предупреждением, остальные маршрутизируются; валидатор следом сверяет покрытие очереди

Задача простыми словами

Компания платит деньги людям — выплаты. Отправляет она их не сама, а через нескольких партнёров-посредников: vipay, payflow, quickpay, плюс собственный запасной канал spacepayments. На каждую выплату нужно выбрать одного партнёра, и выбор несвободный.

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

Договорённости — «этому лучше отдать, чем тому»: vipay обещали 40% заявок, payflow — 35%, quickpay — 25%; по деньгам доли другие; payflow обязались прогонять не меньше 2 млн ₽ в сутки; у кого-то выплаты чаще проходят; есть согласованный порядок обхода.

Договорённости конфликтуют между собой: выдержишь долю vipay — потеряешь на проходимости, дотянешь оборот payflow — сломаешь доли. Универсально правильного ответа нет, есть размен, и выбирать его должен бизнес, а не программист — поэтому весь размен вынесен в YAML.

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

Запуск

Нужен Ruby 4.0 (эксперты подтвердили: 4.0 подходит; проверено на 4.0.6, чистая копия — в Docker). Windows: winget install --id RubyInstallerTeam.RubyWithDevKit.4.0 -e; Linux/macOS: rbenv install 4.0.6 или образ ruby:4.0.

bundle install
bundle exec rake            # тесты + линтер
bundle exec rake validate   # роутинг публичной очереди + автопроверка организаторов
bundle exec rake stress     # 17 сценариев давления, инварианты и метрики

Без установки Ruby — в Docker

Три команды, которые ничего не требуют от машины, кроме Docker:

docker build -t payout_router .
docker run --rm payout_router route --out /tmp/o          # роутинг + отчёт + рекомендации
docker run --rm payout_router stress --out /tmp/o         # 17 сценариев давления

Чтобы забрать файлы наружу, примонтируйте каталог: docker run --rm -v "$PWD/out:/app/out" payout_router route --out out. Сервис: docker run --rm -p 8080:8080 payout_router serve --host 0.0.0.0.

Проверено на чистой копии в Docker (06.09.2026)

Образ ruby:4.0-slim, ничего с хоста не переиспользуется. Всё воспроизводится командами выше.

Что Результат
ruby -v, YJIT 4.0.6, YJIT доступен и включается сам
Тесты и линтер (bundle exec rake) 254 примера, 0 падений; покрытие строк 96.20%; rubocop — 166 файлов, 0 замечаний
Автопроверка организаторов (scripts/validate_10.rb) ✅ 29 пройдено, ❌ 0 ошибок, ⚠️ 0 предупреждений
Наш валидатор (validate --reference) 30 проверок, 0 ошибок, 0 предупреждений
route четыре файла: решения, отчёт JSON, отчёт HTML, демонстрация каскада
stress 17 сценариев, 15 990 заявок, 0 нарушений инвариантов
bench 50 000 / 200 000 9 942 и 9 399 заявок/с — деградации на объёме нет

Тесты внутри образа требуют dev-зависимостей, которых в рабочем образе нет по замыслу. Прогнать их на чистой копии можно так:

printf 'FROM ruby:4.0-slim\nRUN apt-get update -qq && apt-get install -y -qq --no-install-recommends build-essential git\nWORKDIR /app\nCOPY Gemfile Gemfile.lock ./\nRUN bundle install --jobs 4\nCOPY . .\nCMD ["bundle", "exec", "rake"]\n' | docker build -f - -t payout_router_dev .
docker run --rm payout_router_dev

Прогон итоговой очереди (сдача)

cp <скачанный>/operations_queue_test.json data/operations_queue_test.json
# если организаторы дали свои providers.json и историю — положить их в data/ теми же именами
bundle exec rake submit

rake submit кладёт в корень репозитория routing_decisions_test.json и routing_report_test.json (обязательные файлы), рядом — routing_report_test.html и routing_cascade_demo_test.json, и сразу прогоняет наш валидатор по сданному файлу. Что читать в выводе: ошибок 0, WARN в карантине N заявок (если появилось — разобрать эти строки руками), число решений равно числу заявок в очереди. Полный чек-лист со сроками — docs/checkpoint.md §7.

Команды

ruby -Ilib bin/payout_router <команда> --help показывает опции. Общие: --providers, --history, --policy.

Команда Что делает
route --queue Q --out DIR распределяет очередь → routing_decisions.json, routing_report.json, routing_report.html
route --simulation conversion --seed 7 исход каждой отправки разыгрывается по conversion_24h: виден каскад «отказ → следующий → fallback»
explain op_101 --simulation conversion --seed 6 --timeout hold вторая модель таймаута: без статуса от провайдера заявку не переотправляем, ёмкость держим
explain OP_ID [--why-not P] разбор одной заявки: кого рассмотрели, кого выбрали, разложение скора по целям
validate FILE --reference data/reference_decisions.json проверка файла решений: структура, покрытие, hard-правила, эталоны жюри
backtest история организаторов через наш роутер: ожидаемые одобрения нашего роутинга против фактического
compare [--policies a.yml b.yml] [--synthetic 200] одна очередь, несколько политик: доли, отклонение, fallback, ожидаемые одобрения и маржа
route --on-invalid skip неразобранная заявка не валит прогон, а уходит в карантин с предупреждением в stderr; тот же флаг есть у validate, чтобы проверка читала очередь так же, как прогон, и печатала WARN со списком заявок в карантине
simulate --runs 200 Monte-Carlo по отказам: перцентили одобрений, fallback и долей
tune --synthetic 200 подбор весов целей под бизнес-цель, результат в policy_tuned.yml
search [--samples N] [--grid …] перебор конфигураций политики через настоящий роутер: структурные варианты × веса → граница Парето и лидеры по каждой метрике (docs/search.md)
bound [--synthetic N] эталонное распределение очереди (точный min-cost flow): сколько одобрений можно было взять, зная очередь заранее, и сколько взяли мы
serve --port 8080 HTTP-сервис: POST /route, GET /report, /state, /metrics (Prometheus), /health
strategies справочник: hard-правила, цели (встроенные, плагины, декларативные), пресеты политик
stress [--scenario flood] 17 сценариев давления на роутер: измеренные метрики и проверка инвариантов
history, bench показатели истории; 50 000 синтетических заявок через полный конвейер

Сдача итоговых файлов: положить operations_queue_test.json в data/, выполнить bundle exec rake submit, закоммитить routing_decisions_test.json и routing_report_test.json из корня в main. Рядом с ними появляются routing_report_test.html (тот же отчёт страницей) и routing_cascade_demo_test.json — та же очередь с отказами по conversion_24h в формате решений: пример «отказ → следующий провайдер → fallback» для проверяющего, который читает только JSON решений.

Кто ведёт состояние провайдеров

Роутер считает загрузку, оборот и интенсивность сам — этого достаточно и для батча, и для сервиса. Но источником истины может быть и вызывающая система: POST /route принимает конверт, где вместе с заявкой приходит свежее состояние (формат — как в providers.json либо «провайдер → поля»). Присланные счётчики перетирают наши, остальные поля обновляют конфигурацию провайдера — так отрабатывается и «провайдер выключился посреди очереди».

{ "operation": { "operation_id": "op_1", "amount": 15000, "bank": "sberbank" },
  "providers": [{ "payment_system": "vipay", "in_progress_count": 9, "status": "inactive" }] }

Стресс-прогон: что происходит под давлением

payout_router stress гоняет 17 сценариев (≈16 000 заявок) через тот же конвейер, что и сдача, и печатает измеренные числа. Смысл не в галочке «прошло», а в том, чтобы видеть картину:

сценарий              заявок  с_выбором%  без_маршрута  fallback%  Σ|откл|  дневной_лимит%  перегруз_self  инварианты
burst                    500         2.2             0       95.0     95.0            99.3            467  ок
flood                   3000         1.7             0       93.2     93.2           102.4              3  ок
reserved_limits         3000         1.6             0       93.4     93.4            99.4              3  ок
provider_dies            400        32.5             0        6.5     71.1             1.8              0  ок
counter_snapshot         400        31.8             0        6.5     72.6             1.8              0  ок
no_route                 100         0.0           100        0.0    100.0            96.7              0  ок
held_timeouts            400        12.8             0       65.8     65.8           100.2              7  ок
open_field              2000       100.0             0        0.0      0.0             5.9              0  ок
open_field_no_shares    2000       100.0             0        0.0     68.7             7.5              0  ок

Проверки делятся на два вида, и это принципиально:

  • Инварианты — то, что обязано держаться при любом давлении: ни одной потерянной заявки, ровно одна selected-попытка на заявку, ёмкость сходится (каждая отправка закрыта ответом, всё занятое возвращено), выбранный провайдер действительно принимает эту заявку, пики in-progress и интенсивности не превышают лимиты. Нарушение — баг, а не «стратегия сработала плохо». Допустимость и пик интенсивности считаются заново по решениям и снимку: проверка, читающая счётчики роутера, подтверждала бы его бухгалтерию этой же бухгалтерией.
  • Метрики — измеренные числа без порогов. Колонка с_выбором% показывает, в скольких заявках после hard-правил осталось больше одного кандидата. Там, где она близка к нулю, Σ|откл| говорит не о стратегии, а о том, что выбора не было вовсе.

Каждый тест проверен мутацией: в код вносилась настоящая ошибка (не возвращать слот при отказе, снять сортировку очереди по времени, сдвинуть границу limit_amount_max на 1 ₽) — тест обязан был упасть. Проверки, которые мутацию не ловили, переписаны.

Что этот прогон показал (и то, ради чего он и писался):

  • Цели по долям действительно решают — но увидеть это можно только там, где есть выбор. На open_field (все три провайдера допустимы в каждой заявке) роутер попадает в целевые доли точно: Σ|откл| = 0.0. Тот же прогон с весом 0 у traffic_share и volume_share — 68.7 п.п. На обычных данных аблация ничего бы не показала: там у большинства заявок единственный допустимый провайдер, и распределение определяют hard-правила, а не скоринг.

  • Формула дневного лимита из ТЗ переливает через лимит. daily_approved_amount + amount <= daily_amount_limit считает только подтверждённый оборот, а он растёт лишь когда провайдер ответил. Пока он молчит, следующие заявки проходят проверку по устаревшему числу: сценарий flood даёт 102.4% дневного лимита. Правило daily_limit_reserved считает по «оборот + in-progress» — тот же поток даёт 99.4% (сценарий reserved_limits, политика config/policies/strict_limits.yml). По умолчанию оставлена формула ТЗ: автопроверка организаторов считает допустимость по ней, и со строгим правилом наш пул стал бы её подмножеством — strict_limits на публичной очереди выбивает payflow целиком.

  • Залп в одну секунду self-provider принимает с перегрузкой. Ёмкость его не ограничивает по замыслу (иначе заявка осталась бы без маршрута), и на 500 заявок он берёт на 467 больше, чем у него реквизитов. Это цена того, что мы никого не теряем, и её видно числом.

  • Внешний снимок счётчиков мог увести in-progress в минус. Если система присылает свежие счётчики (Ledger#sync!), а ответы на отправленные до снимка заявки продолжают вычитать, счётчик уходит ниже нуля. Освобождение ёмкости теперь не опускается ниже нуля; без sync! ограничение не срабатывает никогда, потому что каждая отправка закрывается ровно одним ответом.

Когда цель недостижима

Целевая доля 35% у провайдера, который проходит hard-правила лишь в трети заявок, невыполнима ни при какой стратегии. Решение отвечает на это в двух местах.

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

провайдер  заявок  доля   цель  откл.  дост.цель  откл.дост
vipay           4  40.0%  40%   +0.0   22.5%      +17.5
payflow         3  30.0%  35%   -5.0   25.2%      +4.8
quickpay        3  30.0%  25%   +5.0   52.4%      -22.4

Отсюда же рекомендация: traffic_percentage предлагается снизить не «на глазок», а до достижимого значения. Правило share_shortfall в recommendations пишет и причину недостижимости, и параметр.

В скоринге это включается политикой: share_targets: attainable (по умолчанию absolute) пересчитывает целевой процент на допустимых кандидатов каждой заявки — долю выбывшего получают оставшиеся пропорционально своим целям. Готовый пресет — config/policies/attainable_shares.yml:

payout_router compare --synthetic 200 --policies config/policy.yml config/policies/attainable_shares.yml

Таймаут: две модели

simulation.timeout (или --timeout) выбирает, что значит истёкшая попытка:

  • cascade — как написано в ТЗ: таймаут приравнен к отказу, провайдер выбывает из пула, заявка идёт к следующему по рангу. Это режим по умолчанию и режим сдачи;
  • hold — статуса от провайдера не пришло, значит выплата могла уйти: заявка остаётся за ним (simulated_result: expired), повторной отправки нет, а слот in-progress и реквизит не освобождаются, потому что освобождать их можно только по полученному статусу. В дневной оборот сумма не попадает — подтверждения не было, а in_progress_amount её и так держит.

Разница видна на одной заявке: explain op_101 --simulation conversion --seed 6 --timeout cascade уводит её на payflow, --timeout hold оставляет на vipay.

Как принимается решение

providers.json ─┐
queue.json ─────┼─► Inputs ─► для каждой заявки (по времени):
history.csv ────┘              1. Constraints  hard-правила: можно ли вообще отдать заявку провайдеру
policy.yml ────────────────►   2. Scoring      soft-goals: кого из допустимых предпочесть (взвешенный скор)
                               3. Routing      отправка лучшему; отказ/таймаут → следующий; пусто → fallback
                               4. State        оборот, in-progress, реквизиты, интенсивность, предохранитель
                             ─► routing_decisions.json + routing_report.json + routing_report.html

Hard-constraints (config/policy.yml → hard_constraints), 14 правил, каждое отдельным классом: статус, валюта (заявка в USD не уйдёт в RUB-шлюз), traffic_percentage > 0, диапазон суммы, дневной лимит, in-progress по количеству и сумме, реквизиты, маржа, фильтр банков, интенсивность (заявок в минуту), максимум оборота по обязательству, предохранитель (серия отказов подряд → карантин) и строгий дневной лимит с резервированием под in-progress (daily_limit_reserved, включается политикой вместо daily_limit — см. «Стресс-прогон»). Провайдер, не прошедший хотя бы одно, попадает в attempts как skipped с кодом причины и деталями ("150000 > limit_amount_max 100000").

Soft-goals (goals), 13 целей с весами. Оценки разных целей живут в разных диапазонах (конверсии 0.79–0.91, доли 0–0.9), поэтому перед взвешиванием каждая цель приводится к разбросу среди кандидатов заявки (selection.normalization: pool): лучший — 1, худший — 0, остальные пропорционально, все равны — 0.5. Тогда вес означает ровно то, что написано: важность цели. Итоговый скор = Σ(вес × оценка по пулу) / Σ весов; режим absolute оставляет оценки стратегий как есть.

Стратегия из ТЗ Цель в политике Как считается
1. Доля по количеству заявок traffic_share 0.5 + (цель − факт)/100: недобор поднимает, перебор опускает
2. Доля по объёму volume_share то же по рублям; база — оборот дня из снимка + заявки сессии
3. Очередь в каскаде cascade_priority первый по priority — 1.0, последний — 0.0
4. По сумме чека amount_band предпочтительный провайдер диапазона — 1.0, остальные — 0.0
5. По конверсии conversion conversion_24h, откалиброванная по истории: (одобрено + 10·заявленная)/(n + 10)
6. По интенсивности rate_headroom запас по requests_per_minute_limit
7. По фин. обязательствам turnover_min (+ hard daily_turnover_max) недобор до daily_turnover_min поднимает
— загрузка load 1 − худшая из загрузок лимитов (день, in-progress)
— сродство к банку bank_affinity конверсия пары провайдер × банк из истории (от 5 наблюдений), усаженная к провайдеру
— ожидаемая маржа expected_value конверсия × (маржа мерчанта − маржа провайдера)
— скорость, стоимость latency, margin быстрее и дешевле — выше

Два способа сочетать стратегии (selection.mode):

  • weighted — все цели одновременно, итог = взвешенная сумма; чистый каскад — cascade_priority: 1.0, остальное 0.
  • chain — стратегии по очереди: следующая решает, только если предыдущая не применима к заявке (нет диапазона суммы, нет обязательства, нет истории) или дала равные оценки (в пределах tolerance). Шаг цепочки — одна стратегия или взвешенная группа. Победитель всегда лучший по решающему шагу, ранжирование полное, поэтому при отказе провайдера роутер идёт дальше по тому же списку. В breakdown каждого решения видно, какой шаг решил, какие «уступили», какие не консультировались.
selection:
  mode: chain
  chain:
    - strategy: amount_band              # 1. по диапазону суммы
    - strategy: turnover_min             # 2. обязательство «не менее X ₽/сутки»
    - strategy: traffic_share            # 3. целевая доля; до 3 п.п. считаем равенством
      tolerance: 0.03
    - goals: { conversion: 0.5, bank_affinity: 0.5 }   # 4. кто вероятнее одобрит
    - strategy: cascade_priority         # 5. каскад

Пресеты в config/policies/: cascade, conversion_first, profit_first, volume_balance, turnover_pressure (конфликт целей из ТЗ: максимум оборота одного против минимума другого), strategy_chain, custom_strategy; compare прогоняет их все на одной очереди. При равном скоре порядок задают tie_breakers. Если цель недостижима (провайдер с долей 40% не проходит фильтр банков), скорятся только допустимые, недобор копится и отыгрывается на следующих заявках, а отчёт показывает достижимость цели и причину. Отчёт также показывает, какие цели вообще различали кандидатов (goal_activity), и предлагает снять вес с мёртвой.

Своя стратегия добавляется без правки ядра, двумя способами:

  1. Плагин на Ruby: файл с наследником PayoutRouter::Strategies::Base (один метод evaluate), строка plugins: [config/plugins/my_strategy.rb] в политике. Регистрируется автоматически, дальше используется в goals или в цепочке по ключу класса. Пример: config/plugins/requisites_headroom.rb.
  2. Декларативная цель в YAML без кода (custom_goals): field (нормированное поле провайдера, higher/lower), table (оценки по провайдерам), bank_table (оценки провайдер × банк). Пример: config/policies/custom_strategy.yml.

Справочник всех правил, целей, декларативных типов и пресетов: ruby -Ilib bin/payout_router strategies.

Fallback. Отказ или таймаут провайдера — попытка помечается skipped с причиной provider_rejected / provider_timeout, заявка уходит следующему по скору. Когда внешних не осталось — self-provider (spacepayments). К нему применяются только статические правила допуска (статус, валюта, диапазон суммы, маржа, банк; fallback_constraints в политике задаёт список явно, пустой — fallback безусловный): реквизиты, in-progress и интенсивность self-provider не ограничивают, иначе плотная очередь оставила бы заявки без маршрута. selected_provider: null возможен только если недоступен и fallback — наш validate считает это ошибкой, как и скрипт организаторов. Три отказа подряд размыкают предохранитель: провайдер выбывает на cooldown_sec.

Что-если и аналитика

  • Бэктест на истории. 100 заявок организаторов прогоняются через роутер; вероятность одобрения — из ApprovalModel (заявленная конверсия → история провайдера → пара провайдер × банк, каждый уровень усаживает предыдущий) с leave-one-out: заявка не участвует в оценке самой себя. Политика conversion_first даёт +4.7% ожидаемых одобрений к фактическому роутингу; balanced — −5.0%: она держит доли партнёров (Σ|отклонение| 10 п.п. против 70 у conversion_first), и это цена обязательств, а не ошибка — compare показывает обе точки.
  • Сравнение политик, Monte-Carlo, подбор весов — compare, simulate, tune (см. таблицу команд).
  • Разобранные примеры (examples) — по просьбе экспертов на чекпоинте 2: четыре характерных случая (выбор среди нескольких, единственный допустимый, отказ → следующий, fallback на self-provider). У каждого — кого выбрали, почему отсеяли остальных и source с файлом и методом, где это решается.
  • Рекомендации с конкретным параметром и значением: 13 правил (дневной лимит, недостижимая доля, переток, fallback, разрыв по сумме, низкая конверсия, дрейф conversion_24h против истории, обязательство по обороту, интенсивность, лимиты обработки, таймауты, срабатывания предохранителя, мёртвая цель в скоринге).

Формат результата

routing_decisions.json — массив решений; обязательные поля организаторов плюс объяснение:

{
  "operation_id": "op_101", "selected_provider": "vipay", "reason": "best_score",
  "attempts": [
    { "provider": "vipay", "decision": "selected", "reason": "best_score",
      "details": "score 0.6833; decisive: traffic_share (+0.27), conversion (+0.131)",
      "score": 0.6833, "breakdown": { "traffic_share": { "score": 0.9, "weight": 0.3, "weighted": 0.27,
      "note": "count share 0.0% vs target 40.0% (+40.0 pp)" }, "...": "..." },
      "simulated_result": "approved", "latency_sec": 38 },
    { "provider": "payflow", "decision": "skipped", "reason": "lower_score", "details": "score 0.6625 < 0.6833 (vipay)", "...": "..." },
    { "provider": "quickpay", "decision": "skipped", "reason": "amount_below_minimum", "details": "800 < limit_amount_min 1000" }
  ],
  "simulated_result": "approved", "latency_sec": 38, "amount": 15000, "bank": "sberbank",
  "created_at": "2026-07-30T09:05:00+03:00", "retries": 0, "fallback_used": false
}

routing_report.json — period, total_operations, distribution (доля/цель/отклонение по количеству и объёму, достижимая цель), skip_reasons, projected_daily_utilization (ровно как в образце ТЗ: только провайдеры с дневным лимитом, только числа; полная картина по всем, включая self-provider, — в provider_capacity), recommendations — плюс results (исходы и конверсия по провайдерам, срабатывания предохранителя), attempts, target_attainability, goal_activity, optimality (сколько роутинг взял от точного оптимума очереди и из чего сложилось отставание), history_analysis (включая конверсию по банкам и дрейф заявленной конверсии) и recommendation_details.

cascade_demonstration появляется в отчёте, когда прогон шёл в optimistic: в самих решениях тогда отказов нет по построению, поэтому отчёт несёт ту же очередь с исходами по conversion_24h (seed записан) и настоящие трейсы каскада — payflow: provider_rejected → quickpay: fallback_after_failure. Полные решения этого прогона route кладёт рядом в routing_cascade_demo*.json (формат routing_decisions плюс пояснение и seed). Так каскад виден и тому, кто читает только JSON.

Качество

  • bundle exec rspec — 254 примера, покрытие строк 96.2% (ветви 80.3%); интеграционные тесты гоняют скрипт организаторов, сверяют формат обоих файлов с образцом ТЗ поле за полем и пул допустимых с эталоном, регресс-тест на плотную очередь (все заявки в одну секунду) проверяет, что ни одна не остаётся без маршрута.

  • bundle exec rubocop — без замечаний (Ruby 4.0, rubocop 1.90 + rubocop-rspec).

  • rake bench — полный конвейер с симуляцией отказов и построением объяснений. Замеры 06.09.2026:

    Заявок Docker ruby:4.0 (YJIT) Windows, RubyInstaller (без YJIT)
    5 000 — 6 418 /с
    50 000 9 942 /с (5.0 с) 6 150 /с
    200 000 9 399 /с (21.3 с) 6 020 /с

    Время растёт линейно, деградации на объёме нет: 200 000 заявок идут почти с той же скоростью, что 5 000. bin/payout_router включает YJIT по факту наличия — в официальном образе он есть, в сборке RubyInstaller под Windows нет.

  • CI (GitHub Actions): тесты, линтер, автопроверка организаторов, бэктест.

  • Насколько это близко к пределу — блок optimality в каждом отчёте (и команда bound отдельно): эталонное распределение очереди, посчитанное точным min-cost flow. Оно знает всю очередь заранее и не считает ёмкостные лимиты, и всё равно наш онлайновый роутер берёт 91.0% на публичной очереди из 10 заявок, 93.6% на 200 и 96.4% на 1000. Наше распределение допустимо в задаче эталона, поэтому это настоящая верхняя граница, а не подобранное число. Отставание раскладывается на два слагаемых: approvals_lost_to_share_targets — цена обязательства держать целевые доли, approvals_lost_to_online_decisions — цена решений по одной заявке без знания следующих. На публичной очереди это 1.09 и 0.18: пять шестых отставания стоят сами целевые доли, а не качество роутинга. Отрицательное второе слагаемое (на 200 заявках −5.97) означает, что роутер сознательно отступил от долей ради одобрений — они у него мягкая цель, а не жёсткое ограничение.

Сверка с критериями трека

Каждый подкритерий из ТЗ — против конкретного артефакта и команды, которой это проверяется за минуту; полная таблица со всеми баллами и честно названными рисками — docs/criteria.md. Коротко, чем закрыт каждый блок:

Критерий Чем закрыт Чем проверить
Hard-constraints и soft-goals при выборе два физически разных слоя: 14 правил допуска и 13 целей скоринга payout_router explain op_101, strategies
Переход при отказе и fallback try_ranked → fallback на self-provider, коды provider_rejected / fallback_after_failure / fallback_self_provider explain op_107 --simulation conversion --seed 46; routing_cascade_demo.json
Несколько способов распределения и их совместный учёт все 7 стратегий ТЗ как цели с весами плюс load, bank_affinity, expected_value, margin, latency, share_deficit; веса или цепочка ярусов compare — 11 политик на одной очереди дают 11 распределений
Независимость от конкретных данных провайдер — запись в providers.json; правила, цели, веса, диапазоны — в YAML; своя цель плагином или декларативно дописать четвёртого провайдера в копию providers.json и передать --providers
Согласование целей и поведение при невыполнимой цели веса + tie_breakers + decisive vs; достижимая доля рядом с бумажной, share_targets: attainable, рекомендация share_shortfall routing_report.json → target_attainability
Объяснимость выбора, исключения и последовательности попыток reason + score + breakdown + violations с деталями; attempts, retries, fallback_used; разобранные examples со ссылкой на метод в коде explain op_105 --why-not payflow
Аналитика: доли, отклонения, успешность, лимиты, выводы distribution, results, skip_reasons, projected_daily_utilization, 13 правил recommendations с параметром и значением routing_report.html
Обновление состояния после операций State::Ledger с виртуальными часами: отправка занимает ёмкость, ответ приходит через latency_sec serve + GET /state дважды
Качество реализации, ошибки и границы слои и реестры вместо case; проверка полей с адресом ошибки, код выхода 2, --on-invalid skip, 17 стресс-сценариев rake, rake stress, route --queue nope.json
Обязательные файлы в корне main rake submit кладёт оба файла и сам прогоняет валидатор rake submit

Если что-то не так

Симптом Что делать
Bundler::RubyVersionMismatch: Your Ruby version is 3.4.x в PATH другой Ruby; на Windows — $env:PATH = "C:\Ruby40-x64\bin;" + $env:PATH
ruby.exe падает на api-ms-win-crt-*.dll запуск из MSYS/Git Bash; запускать из PowerShell или cmd
запрос к serve не устанавливает соединение сервис слушает 127.0.0.1; на Windows localhost сначала резолвится в IPv6 ::1
одна неразобранная заявка валит весь прогон route --on-invalid skip — остальные отроутятся, битые уйдут в карантин с предупреждением
нужен прогон без установленного Ruby docker build -t payout_router . && docker run --rm payout_router route

Структура

bin/payout_router          CLI (Thor)
config/policy.yml          политика по умолчанию; config/policies/ — пресеты; config/plugins/ — пример своей стратегии
data/                      вводные организаторов
lib/payout_router/
  domain/                  Provider, Operation, Policy, Snapshot, HistoryRecord (неизменяемые Data)
  inputs/                  загрузчики JSON/CSV/YAML с проверкой полей
  constraints/             hard-правила (Base + 14 классов, Registry, Pipeline)
  strategies/              soft-goals (Base + 13 классов, Registry с плагинами, custom/ — декларативные цели)
  scoring/                 CompositeScorer (веса), ChainScorer (цепочка), TieBreaker, Score
  state/                   ProviderState, Ledger, SettlementQueue (виртуальные часы, предохранитель)
  routing/                 Router, BatchRouter, Attempt, Decision, Reasons
  simulation/              Optimistic, Conversion, Outcome
  analytics/               RoutingStats, HistoryStats, ApprovalModel, Backtest, PolicyComparison,
                           MonteCarlo, WeightTuner, ReportBuilder, recommendations/*,
                           AssignmentBound + MinCostFlow (эталонное распределение очереди)
  search/                  перебор конфигураций: Battery, Space, Front, Engine, Metrics, Outcome
  validation/              DecisionsValidator (как у жюри + инварианты)
  output/                  JSONWriter, YAMLWriter, HtmlReport, Summary, Explanation, Tables
  server.rb                HTTP-сервис (WEBrick): /route, /report, /state, /metrics
  bench/                   генератор очереди и нагрузочный прогон
  stress/                  Catalog (17 сценариев), Invariants, Oracles, Suite, Outcome
scripts/validate_10.rb     автопроверка организаторов
spec/                      RSpec

About

Хакатон по разработке генератора интеграций с платёжными провайдерами: на вход подаётся OpenAPI-спецификация (например, provider_api.yaml), а решение должно автоматически создавать заготовку программной интеграции, документацию и тестовые примеры, адаптируясь к API разных провайдеров. На каждый трек отбирается 55 команд.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages