Решение задачи 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 сценариев давления, инварианты и метрикиТри команды, которые ничего не требуют от машины, кроме 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.
Образ 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 ошибок, |
Наш валидатор (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_devcp <скачанный>/operations_queue_test.json data/operations_queue_test.json
# если организаторы дали свои providers.json и историю — положить их в data/ теми же именами
bundle exec rake submitrake 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), и предлагает снять вес с мёртвой.
Своя стратегия добавляется без правки ядра, двумя способами:
- Плагин на Ruby: файл с наследником
PayoutRouter::Strategies::Base(один методevaluate), строкаplugins: [config/plugins/my_strategy.rb]в политике. Регистрируется автоматически, дальше используется вgoalsили в цепочке по ключу класса. Пример:config/plugins/requisites_headroom.rb. - Декларативная цель в 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