decision-cascade
Machine-native система принятия решений в духе Jev (TypeSafe AI), но с открытым исходным кодом, локальным исполнением и полным циклом «обучение → калибровка → оценка → деплой».
Вместо того чтобы заставлять LLM генерировать текст ответа, модель решает: state-состояние превращается в признаки, а головы решений возвращают типизированные, вероятностные и калибруемые ответы — Noul (да/нет), Choice (выбор из опций) и Score (оценка на шкале) — без единого токена генерации.
Статус: stable (1.1.0). Контракт API (schema_version = "1"), формат
паков и смысл переменных DECISION_* не ломаются: изменения — только в major,
с окном депрекации в две минорные версии. Что именно обещает 1.x, какие
поверхности остаются beta и как объявляется депрекация —
в docs/stability.md. Замеры и сравнение с конкурентами —
в docs/benchmarks.md.
Что это за подход
Машинные решения, а не генерация текста
Обычный LLM-бот «думает» свободным текстом и выдаёт ответ естественным языком. Такой ответ невозможно:
- проверять на корректность (нет машиночитаемого контракта);
- калибровать (вероятность в тексте не измерить);
- агрегировать (парсинг выводов хрупок).
Движок принимает решения как функция состояния → вероятность:
state (строка-описание) ──► StateEncoder ──► features [batch, dim]
│
┌────────────────────────┴─────────────────────────┐
▼ ▼ ▼
NoulHead ChoiceHead ScoreHead
(sigmoid) (softmax) (softmax·mean)
▼ ▼ ▼
NoulResult (p) ChoiceResult (профайл) ScoreResult (балл)
Результат — строгий датакласс с confidence, валидируемым распределением
(сумма = 1.0 ± 1e-6) и версией схемы. Это «System 1»-мышление: быстрое,
дискретное, вероятностное решение по состоянию — без глубокого рассуждения.
Почему это надёжнее
- Типизированные выходы — каждый вопрос имеет заявленный тип и схему
(Noul/Choice/Score), клиент проверяет ответ по контракту (
SCHEMA_VERSION = "1"). - Вероятности, а не слова — модель обязана выдать распределение; его можно мерять на калибровку (Expected Calibration Error, Brier score) и править.
- Параллельная оценка — все вопросы обрабатываются одним прогоном: один forward энкодера + N голов. Низкая задержка и дешёвая инференция.
- Без авторегрессии — нет цикла генерации токенов, поэтому latency детерминированна и мала (на CPU см. раздел «Оценка»).
- Обучаемые головы — головы можно дообучать под свои домены: SFT на разметке и RLCD (reinforcement learning for calibrated decisions, GRPO-подобный) для прямой оптимизации калибровки.
Позиционирование
Хостед Jev против локального decision-cascade
| Критерий | Jev (платный SaaS) | decision-cascade |
|---|---|---|
| Цена | Подписка/кредиты API | Бесплатно, открытый исходный код |
| Где работают данные | Облако вендора | Локально: ваш сервер, ваши данные |
| Модель | Закрытая, фиксированная | Выбирается вами: от gpt2 (CPU) до gemma-4-12B (GPU) |
| Выходной контракт | API вендора | Ваш: типизированный SDK, версиируемая схема |
| Приватность | Обращения уходят вовне | Ничего не покидает машину (после дистанционной загрузки весов) |
| Vendor lock-in | Есть | Нет: деплой через простые shell/systemd |
| Калибровка | Чёрный ящик | Встроенные метрики ECE/Brier + CLI |
| Обучение под домен | Нет | SFT + RLCD прямо в репозитории |
| Задержка | Сетевая (RTT + инференс вендора) | Локальная, настраиваемая (обрезка входа DECISION_MAX_LENGTH); см. «Оценка» |
| Внедрение | Только через их API | HTTP + Python SDK в вашем стеке |
Латентность измеряется прямо в репозитории (eval/latency) и сильно зависит
от модели, железа и длины входа: ограничивайте длину входа через
DECISION_MAX_LENGTH и закладывайте время на первый (холодный) инференс.
Честное ограничение: это открытый движок для контроля и прозрачности, а не готовый SaaS с UI. Головы «из коробки» случайны — значимое качество требует вашей разметки (SFT/RLCD) или скачивания пака весов. Если нужна «кнопка под ключ» — Jev удобнее; если нужны контроль, приватность, цена и калибруемость — этот проект.
Открытые альтернативы и совместимость
Тот же класс задач решают открытые проекты: Cloudflare Clef и
Clef-Flash (открытые веса для Workers AI / SGLang / vLLM),
Perplexity pplx-decider-v1-27b и ConvAI Laya. У них общий де-факто
протокол POST /v1/systemone: model, state, questions с
type/instructions/criteria и необязательными images/videos, ответ —
answers плюс usage.
Этот проект говорит на том же протоколе (/v1/systemone) и умеет
выступать шлюзом к ним (DECISION_BACKEND=http). Их не нужно выбирать
«вместо»: можно держать один локальный слой решений и при необходимости
проксировать к внешней модели.
Что здесь уникально
- Полный цикл в одном репозитории: обучение (SFT) → RLCD-калибровка → метрики (ECE/Brier) → latency-профилирование → деплой.
- Контракт-first: типизированные результаты с версией схемы, а не свободный
текст; клиент проверяет сумму распределения и
SCHEMA_VERSION. - Честная мультимодальность: сервис объявляет поддерживаемые модальности и отклоняет остальные типизированной ошибкой вместо тихой деградации.
- Прозрачная задержка: пофазный замер
encode/heads/decideиз коробки. - Протокол + локальное исполнение: совместимость с экосистемой Jev/SystemOne без облачной зависимости.
О названии
Имя проекта — decision-cascade: оно про механизм (последовательное принятие решений с эскалацией «дешёвый → сильный»), а не про вендора.
Раньше проект назывался «System One Decision Model», однако SystemOne — это
внешний протокол POST /v1/systemone экосистемы Jev/TypeSafe AI. Чтобы не
создавать впечатление аффилированности, проект носит собственное имя, но
совместим с этим протоколом и умеет проксировать к нему.
Установка
Стабильный пакет опубликован на PyPI (линейка 1.x):
python3 -m venv .venv && . .venv/bin/activate
pip install decision-cascade
Пакет ставит библиотеку (src, api, eval, training) и сервер; запуск —
как обычно через uvicorn:
python -m uvicorn api.server.main:app --host 127.0.0.1 --port 8000
Для разработки — из исходников, с uv и зафиксированным uv.lock (ниже).
Установка окружения
bash scripts/bootstrap-user.sh # uv, pyenv, venv, зависимости, gh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
# или через uv (быстрее):
uv pip install --python .venv/bin/python -r requirements.txt
Версии зависимостей зафиксированы в uv.lock: uv sync --frozen ставит ровно
их, а CI проверяет актуальность lockfile через uv lock --check.
bootstrap-user.sh работает без sudo: ставит uv/pyenv в ~/.local и
~/.pyenv, создаёт .venv, ставит зависимости и кладёт gh в ~/.local/bin.
Проект упакован как дистрибутив decision-cascade (pyproject.toml), поэтому
его можно поставить и редактируемо:
uv pip install --python .venv/bin/python -e .
Процесс релиза (версия → тег → PyPI → артефакты) — в
docs/release.md.
Запуск сервера
Вариант 1 — без сети / для мгновенной проверки (hash-энкодер)
.venv/bin/python -m uvicorn api.server.main:app --host 127.0.0.1 --port 8000
Энкодер — детерминированный HashStateEncoder: состояние превращается в
64-размерный вектор по хэшам токенов. Ничего не скачивается, всё работает офлайн.
Вариант 2 — лёгкая модель gpt2 (~0.5 ГБ, hidden 768, CPU)
DECISION_MODEL_ID=openai-community/gpt2 \
.venv/bin/python -m uvicorn api.server.main:app --host 127.0.0.1 --port 8000
При первом старте веса скачаются в ~/.cache/huggingface (заложите время на
медленных каналах). Останавливается сервер командой kill по порту — см. «Остановка».
Вариант 3 — большая модель (gemma-4-12B, GPU)
DECISION_MODEL_ID=google/gemma-4-12B \
.venv/bin/python -m uvicorn api.server.main:app --host 127.0.0.1 --port 8000
Требует ~24 ГБ и видеокарту с CUDA. Без явного DECISION_MODEL_ID сервер
стартует с hash-энкодером.
Как менять модель
Выбор модели — через переменные окружения при старте сервера
(api/server/main.py → create_service):
| Переменная | Назначение | По умолчанию |
|---|---|---|
DECISION_MODEL_ID |
имя модели на HF Hub | — (hash-энкодер) |
DECISION_FEATURE_DIM |
размерность признаков | 768 |
DECISION_MAX_LENGTH |
обрезка входа энкодера, токенов (none — без обрезки) |
256 |
DECISION_ENCODE_BATCH |
размер батча кодирования состояний (1–4096) | 16 |
DECISION_ENCODER_KIND |
вид текстового энкодера: causal (LM) или encoder (BERT-like) |
causal |
DECISION_DEVICE |
устройство энкодера: auto, cpu, cuda, cuda:N, mps |
auto |
DECISION_DTYPE |
тип весов энкодера: float32, float16, bfloat16 |
float32 |
DECISION_CHECKPOINT_DIR |
каталог чекпоинтов голов | checkpoints |
DECISION_CHOICE_HEAD |
голова выбора: linear или option_scoring (общий скорер по эмбеддингам вариантов) |
linear |
DECISION_UNTRAINED |
поведение без обученных голов: allow, refuse, fallback, auto |
auto |
DECISION_BACKEND |
исполнитель решений: local, http или cascade |
local |
DECISION_BACKEND_URL |
базовый URL внешнего SystemOne-сервиса | — |
DECISION_BACKEND_MODEL |
имя внешней модели (поле model) |
remote |
DECISION_BACKEND_MODALITIES |
модальности внешнего бэкенда | text |
DECISION_BACKEND_TIMEOUT |
бюджет одного внешнего вызова, секунды (все retry внутри) | 60 |
DECISION_BACKEND_RETRIES |
повторы внешнего вызова при сети/429/5xx (0 — без повторов) | 1 |
DECISION_WARMUP |
загружать паки голов при старте (нужно для честного /ready) |
1 |
DECISION_LOG_FORMAT |
формат логов: text, json или пусто (не менять) |
пусто |
DECISION_LOG_LEVEL |
уровень логов (DEBUG, INFO, WARNING, …) |
INFO |
DECISION_CASCADE_FALLBACK_URL |
URL fallback для cascade |
— (обязателен) |
DECISION_CASCADE_FALLBACK_MODEL |
имя fallback-модели | remote |
DECISION_CASCADE_FALLBACK_MODALITIES |
модальности fallback | text |
DECISION_CASCADE_THRESHOLD |
порог уверенности для эскалации (0–1) | 0.6 |
DECISION_CASCADE_RISK_BUDGET |
бюджет ошибок для risk-aware каскада (0–1); включает режим | — (порог) |
DECISION_RISK_MODEL_PATH |
файл обученного оценщика P(ошибка) |
<checkpoint>/risk.json |
DECISION_RISK_FEW_SHOT_N |
примеров до переобучения риск-модели под вопрос (>= 2; 0/1 — выключено) |
10 |
DECISION_RISK_GATE_DELTA |
допуск к сертификату hard gate (> 0 ослабляет, < 0 ужесточает) |
0.0 |
DECISION_VISION_MODEL_ID |
CLIP/SigLIP для локальных images |
— (без vision) |
DECISION_VISION_DIM |
размерность признаков vision-энкодера (1–8192) | 512 |
DECISION_MEDIA_ROOT |
allow-list каталог для чтения images/videos с диска |
— (любой путь) |
Фактическое устройство и тип можно задать при запуске:
DECISION_MODEL_ID=openai-community/gpt2 \
DECISION_DEVICE=cuda DECISION_DTYPE=bfloat16 \
.venv/bin/python -m uvicorn api.server.main:app --port 8000
auto выбирает CUDA при её наличии, иначе CPU. Недоступное устройство — явная
ошибка (без тихого отката), а float16 на CPU запрещён. Наружу энкодер всегда
отдаёт CPU-тензоры, поэтому головы и кэш признаков работают одинаково на любом
устройстве.
По умолчанию текстовый энкодер — causal LM (GemmaStateEncoder,
AutoModelForCausalLM). Для двунаправленных encoder-only моделей (BERT-like)
задайте DECISION_ENCODER_KIND=encoder — тогда используется
TransformerStateEncoder (AutoModel) с тем же masked mean-pooling:
DECISION_MODEL_ID=bert-base-uncased DECISION_ENCODER_KIND=encoder \
DECISION_DEVICE=cuda \
.venv/bin/python -m uvicorn api.server.main:app --port 8000
Локальная мультимодальность
Локальный бэкенд умеет принимать images/videos, если у сервиса есть
vision-энкодер. Каждый энкодер сам объявляет свои модальности
(VisionEncoder.modalities), а сервис агрегирует их в Capabilities, поэтому
/v1/systemone отклонит ровно те модальности, что не поддержаны.
# CLIP/SigLIP включается переменной окружения: любая contrastive vision-модель
# с get_image_features (например, google/siglip-base-patch16-224). Только
# изображения — видео требует отдельного декодирования кадров.
DECISION_VISION_MODEL_ID=openai/clip-vit-base-patch32 \
uvicorn api.server.main:app --port 8000
Признаки состояния и медиа сливаются: вектор модальности усредняется и
проецируется в размерность текстовых признаков, после чего добавляется к
признакам каждого вопроса (вес — media_kwargs.media_weight, по умолчанию
0.5; max_frames управляет числом кадров видео). Для офлайн-тестов и
сервисов, где достаточно детерминированного сигнала, есть HashVisionEncoder.
from api.server.service import DecisionService
from src.encoder.vision import HashVisionEncoder
svc = DecisionService(seed=0, vision_encoder=HashVisionEncoder(dim=64))
svc.modalities # frozenset({'text', 'image', 'video'})
svc.decide("state", QUESTIONS, images=["shot.png"], videos=["clip.mp4"])
Бэкенды: локальные головы или внешняя модель
/v1/systemone обслуживается «бэкендом». По умолчанию это локальные головы
(local, только текст). Если задать DECISION_BACKEND=http и
DECISION_BACKEND_URL, сервис становится прокси к любому внешнему
SystemOne-совместимому сервису — Cloudflare Clef / Clef-Flash (Workers AI,
SGLang, vLLM), Perplexity Decider и т.п.:
DECISION_BACKEND=http \
DECISION_BACKEND_URL=http://127.0.0.1:30000 \
DECISION_BACKEND_MODEL=clef-flash \
DECISION_BACKEND_MODALITIES=text,image,video \
uvicorn api.server.main:app --port 8000
Бэкенд объявляет поддерживаемые модальности (Capabilities). Если запрос
содержит неподдерживаемую модальность (например, images на текстовом
локальном бэкенде), сервис отвечает структурированной ошибкой, не пытаясь
тихо деградировать:
{"error": {"code": "MODALITY_NOT_SUPPORTED",
"supported": ["text"], "requested": ["text", "image"]}}
Каскад: локальные головы + внешняя модель (опционально)
Режим DECISION_BACKEND=cascade запускает local-first схему: сначала отвечают
локальные головы, а во внешнюю модель эскалируются только вопросы с
confidence ниже порога (или неподдерживаемую модальность — целиком). Так
внешний вызов платится лишь за действительно неясные случаи:
DECISION_BACKEND=cascade \
DECISION_CASCADE_FALLBACK_URL=http://127.0.0.1:30000 \
DECISION_CASCADE_FALLBACK_MODEL=clef-flash \
DECISION_CASCADE_THRESHOLD=0.6 \
uvicorn api.server.main:app --port 8000
В usage возвращается escalated — число эскалированных вопросов, и fallback
с расходом внешнего бэкенда. Модальности каскада — объединение модальностей
primary и fallback.
Risk-aware эскалация (бюджет ошибок). Порог на «сырой» уверенности не равен
вероятности ошибки: confidence = 0.6 не значит «60% верно». Поэтому каскад
умеет эскалировать по оценке P(ошибка) локального решения — тогда вы задаёте
бюджет ошибок, который готовы принять локально, а не абстрактный порог:
DECISION_BACKEND=cascade \
DECISION_CASCADE_RISK_BUDGET=0.05 \
DECISION_RISK_MODEL_PATH=checkpoints/risk.json \
DECISION_CASCADE_FALLBACK_URL=http://127.0.0.1:30000 \
uvicorn api.server.main:app --port 8000
Оценщик P(ошибка) — логистическая регрессия на признаках решения
(уверенность, энтропия, отрыв лучшего класса, разрыв логитов двух лучших
исходов, число исходов и знаковый логит noul); обучается на отложенном наборе и
калибруется честно. Выигрыш модели — подстройка под известный набор вопросов;
на новом вопросе её нужно переобучать (честные числа и оговорки —
docs/risk-aware-cascade.md). Если модель не найдена, сервер падает с понятной
ошибкой, а не молча деградирует. Заключение замера едет вместе с моделью: если
риск-вариант измерен и оказался не лучше порога уверенности, каскад сам
фолбэчит на него — usage.reason = "risk_gate", GET /info → engine.risk_gate = "rejected", в лог пишется предупреждение. Бюджет виден в GET /info; когда
модель принимается, причина эскалации — usage.reason = "risk".
Методика и честные числа — в docs/risk-aware-cascade.md:
# обучить оценщик риска и сравнить каскады на публичном наборе
python -m eval.risk.run --specs routerbench --encoder openai-community/gpt2 \
--dim 768 --train-steps 500 --limit 4000 --out-dir data/risk_routerbench
Benefit-aware эскалация (цена вызова). Порог уверенности и бюджет ошибок
отвечают на вопрос «насколько модель уверена / какова вероятность ошибки», но не
на вопрос «стоит ли платить за внешнюю модель». Benefit-политика сравнивает
ожидаемую полезность двух действий: при p — вероятности, что локальное решение
верно, q — точности внешней модели на этом вопросе, D — цене ошибки и C —
цене эскалации (в единицах «верный ответ = 1») эскалация выгодна, когда
p < q - C / (1 + D). Порог получается из измеренной точности внешней модели, а
не из константы; если внешняя модель на вопросе слабее локальной, каскад не
эскалирует вовсе.
q меряют на отложенном срезе домена, а не на том же test, где считается
выигрыш:
# точность внешней модели по вопросам на train-срезе (n=200)
python -m eval.competitors.fit_remote_accuracy --specs public --limit 200 \
--remote clef-flash=http://127.0.0.1:8091 \
--out data/competitors_real/public_clef-flash_accuracy.json
DECISION_BACKEND=cascade \
DECISION_CASCADE_POLICY=benefit \
DECISION_CASCADE_REMOTE_ACCURACY=data/competitors_real/public_clef-flash_accuracy.json \
DECISION_CASCADE_REMOTE_ACCURACY_BOUND=lower \
DECISION_CASCADE_ESCALATION_COST=0.05 \
DECISION_CASCADE_ERROR_COST=1.0 \
DECISION_CASCADE_FALLBACK_URL=http://127.0.0.1:30000 \
uvicorn api.server.main:app --port 8000
REMOTE_ACCURACY_BOUND=lower берёт нижнюю границу Уилсона: на срезе в 200
примеров точечная точность гуляет на ±0.07, и завышенная q гонит каскад в
эскалации, которые ухудшают ответ (см. docs/competitor-benchmark.md).
Причина эскалации в usage — "benefit".
Обновление q по обратной связи. Срез, снятый при развёртывании, стареет,
поэтому q уточняется реальными ответами внешней модели через тот же
POST /v1/feedback. Каскад помнит отпечатки решений, которые ушли наружу, и
учитывает фидбэк только по ним (ответы локальных голов качество внешней модели не
характеризуют). Оценка сдвигается плавно:
q_new = (q_prior * W + верных) / (W + всего), W = DECISION_CASCADE_FEEDBACK_SHRINKAGE (50)
DECISION_BACKEND=cascade DECISION_CASCADE_POLICY=benefit DECISION_CASCADE_REMOTE_ACCURACY=data/public_laya_accuracy.json DECISION_CASCADE_FEEDBACK_SHRINKAGE=50 DECISION_CASCADE_FALLBACK_URL=http://127.0.0.1:30000 uvicorn api.server.main:app --port 8000
curl -s -X POST http://127.0.0.1:8000/v1/feedback -H 'Content-Type: application/json' \
-d '{"examples": [{"question": "topic",
"decision": {"type": "choice", "choice": "Business",
"probabilities": {"World": 0.02, "Sports": 0.01,
"Business": 0.93, "Sci/Tech": 0.04},
"confidence": 0.93},
"correct": true}]}'
# -> {"questions": [{"question": "topic", "examples": 1,
# "retrained": false, "remote_accuracy": 0.9577, ...}]}
retrained = false — модель не переобучается, меняется только оценка качества
внешней модели; новое q сразу применяется к следующим решениям. W — это
сколько наблюдений нужно, чтобы сдвинуть оценку: 50 (по умолчанию) — поток
фидбэка меняет политику за десятки ответов, пара случайных — нет.
Подстройка под новый вопрос (POST /v1/feedback). На новом вопросе
коэффициенты, снятые с других вопросов, работают хуже — модель нужно переобучать
на примерах этого вопроса. Сервис принимает обратную связь: вы повторяете решение,
которое вам отдали, и говорите, верным ли оно было; набрав
DECISION_RISK_FEW_SHOT_N примеров (по умолчанию 10), каскад переобучает модель
и применяет её к этому вопросу:
curl -s -X POST http://127.0.0.1:8000/v1/feedback \
-H 'Content-Type: application/json' \
-d '{
"examples": [
{"question": "команда",
"decision": {"type": "choice", "choice": "биллинг",
"probabilities": {"биллинг": 0.55, "интеграция": 0.45},
"confidence": 0.55},
"correct": false}
]
}'
В ответе по каждому вопросу — сколько примеров накоплено (examples) и сколько
чужих строк (примеров других вопросов) ушло в обучение (reference): пока чужих
примеров нет, модель учится только на своих, а замер показывает, что это хуже,
пока своих немного. Поэтому примеры выгоднее собирать по нескольким вопросам,
а не только по целевому. GET /info → accepts_feedback показывает, что сервис
принимает обратную связь (это возможно только в risk-aware каскаде); без неё
эндпоинт отвечает 409 FEEDBACK_UNSUPPORTED. Подстройка не ослабляет hard gate:
если риск-вариант измерен и оказался не лучше порога уверенности, примеры его не
спасают. Что лечат примеры и сколько их нужно — docs/risk-aware-cascade.md.
Голова выбора по эмбеддингам вариантов. Обычная голова выбора — линейный слой
с отдельным весом на каждый вариант: набор вариантов зашит на обучении. Режим
DECISION_CHOICE_HEAD=option_scoring включает голову, которая оценивает вариант
общим скорером по его текстовому эмбеддингу — число параметров не зависит от
числа вариантов, а набор вариантов можно подменить без переобучения:
DECISION_CHOICE_HEAD=option_scoring uvicorn api.server.main:app --port 8000
Перенос работает на вариантах, семантически близких к обученным, и не работает на
произвольных новых словах или когда рядом есть знакомые варианты (они
«перебивают» новые). Честные числа и границы — в docs/option-scoring.md:
# замер переноса вариантов (обучение на 3, подмена набора на инференсе)
python -m eval.option_transfer.run --encoder openai-community/gpt2 --dim 768 --seeds 0 1 2
Указывать размерность необязательно: головы строятся по фактической размерности
выхода энкодера (features.shape[-1]), а если она отличается от 768 — вход
проектируется автоматически.
# gpt2 (hidden 768) — без проекции
DECISION_MODEL_ID=openai-community/gpt2 uvicorn …
# gpt2-medium (hidden 1024) — автоматическая проекция в 768
DECISION_MODEL_ID=openai-community/gpt2-medium uvicorn …
# любая другая causal-модель
DECISION_MODEL_ID=microsoft/phi-2 uvicorn …
Программный способ (без HTTP):
import torch
from src.encoder.gemma import GemmaStateEncoder
from api.server.service import DecisionService
encoder = GemmaStateEncoder(
model_id="openai-community/gpt2", feature_dim=768,
dtype=torch.float32, device="cpu",
).load()
svc = DecisionService(encoder=encoder, dim=encoder.feature_dim)
results = svc.decide("текст состояния", QUESTIONS)
Состояния кодируются батчами по DECISION_ENCODE_BATCH (по умолчанию 16), а не
всем набором сразу — на CPU это важно для памяти при обучении на больших наборах.
Остановка сервера
# по PID, слушающему порт 8000
kill $(ss -ltnp | grep ':8000 ' | grep -oP 'pid=\K[0-9]+' | head -1)
# или по имени процесса
pkill -f "uvicorn.*api.server.main"
# проверка, что порт освобождён
ss -ltn | grep ':8000 ' || echo "порт 8000 свободен"
Использование: справочник API
Есть три эндпоинта с одной и той же моделью решений, справочный GET /info и
эндпоинт обратной связи:
POST /decisions— собственный типизированный API (обёртка соschema_version), удобен для SDK и русскоязычных примеров;POST /decisions/batch— тот же контракт для списка состояний (states): все промпты кодируются одним вызовом (внутри — батчами поDECISION_ENCODE_BATCH), ответ — списокresults;POST /v1/systemone— стандартный протокол Jev/SystemOne, совместимый с Clef/Clef-Flash/Perplexity Decider (полеmodel, ответы вanswers,usage, необязательныеimages/videos);POST /v1/feedback— обратная связь: размеченные примеры решений, которые сервис применяет для подстройки под новый вопрос (см. «Подстройка под новый вопрос»);GET /health— liveness: процесс жив и отвечает; зависимости не проверяются;GET /ready— readiness: энкодер загружен, головы читаемы (приDECISION_UNTRAINED=refuse— есть хотя бы один пак); у каскада недоступный fallback виден какdegraded, но инстанс остаётся готовым.503, пока сервис не готов, — для балансировщиков и systemd;GET /info— конфигурация сервиса: бэкенд, модальности, устройство и статус загруженных голов (trained,source);GET /metrics— метрики Prometheus: запросы, латентность, решения, готовность (docs/observability.md). Каждый ответ несётX-Request-ID;DECISION_LOG_FORMAT=jsonвключает структурированные логи.
Внешний бэкенд (http/cascade) ограничен бюджетом DECISION_BACKEND_TIMEOUT
на весь вызов: таймауты и сетевые ошибки повторяются один раз
(DECISION_BACKEND_RETRIES), после чего API отвечает 502 BACKEND_UNAVAILABLE.
Тело запроса (state, images) в логи и текст ошибки не попадает.
POST /decisions и POST /decisions/batch возвращают поле trained: true,
если решения посчитаны обученными головами, false — если весов под эту схему
нет и отвечают случайно инициализированные головы (см. DECISION_UNTRAINED).
curl -s -X POST http://127.0.0.1:8000/decisions/batch \
-H 'Content-Type: application/json' \
-d '{
"states": ["Stripe недоступен, теряем заказы", "Как сменить тариф?"],
"questions": {
"срочность": {"type": "noul", "instructions": "Срочно?"},
"команда": {"type": "choice", "instructions": "Кто?", "options": ["биллинг", "интеграция"]}
}
}'
Формат вопросов
| Тип | Использование | Запрос |
|---|---|---|
noul |
Бинарный вопрос «да/нет» (вероятность) | {"type": "noul", "instructions": "…", "criteria": ["что значит «да»", "что значит «нет»"]} |
choice |
Выбор одной из опций | {"type": "choice", "instructions": "…", "options": ["…", "…"], "criteria": {"…": "описание опции"}} |
score |
Оценка по шкале уровней | {"type": "score", "instructions": "…", "levels": ["…", "…"], "criteria": ["описание уровня 0", "описание уровня 1"]} |
instructions и criteria входят в промпт энкодера вместе с state — то есть
модель отвечает именно на заданный вопрос, а не просто классифицирует состояние.
criteria необязательны и нужны для более точного разграничения опций.
Формат ответа
Каждый результат — типизированный датакласс:
- NoulResult:
pв [0, 1] — вероятность «да»;confidence = 2·|p − 0.5|; - ChoiceResult: выбранная опция (
argmax) и распределение по всем опциям; - ScoreResult: взвешенное среднее индексов уровней (
score) и распределение.
Все распределения валидируются: сумма = 1.0 ± 1e-6, границы соблюдены, ключи — ровно перечисленные опции/уровни. Внешний конверт ответа:
{
"schema_version": "1",
"trained": true,
"decisions": {
"имя_вопроса": { "type": "…", "…": "…" }
}
}
trained — необязательное поле статуса (см. DECISION_UNTRAINED): false
означает, что под эту схему вопросов нет обученного чекпоинта.
Версионирование схемы (schema_version)
Версии — <major>[.<minor>], совместимость — по major (см.
src/versioning.py): клиент, ожидающий 1.x, принимает любой 1.y, но не 2.*.
minor — аддитивные изменения (необязательные поля/вопросы), major —
ломающие (удаление/переименование полей, смена семантики) с миграцией. Тот же
принцип применяется к чекпоинтам голов: payload содержит schema_version
(CHECKPOINT_VERSION), legacy-файлы без версии читаются как 1, а более новый
major отклоняется явной ошибкой, а не молчаливым чтением неизвестного формата.
Протокол Jev/SystemOne (/v1/systemone)
curl -s -X POST http://127.0.0.1:8000/v1/systemone \
-H 'Content-Type: application/json' \
-d '{
"model": "local",
"state": "Stripe failing for 3 days, losing sales",
"questions": {
"urgent": {"type": "noul", "instructions": "Is this urgent?"},
"team": {"type": "choice", "criteria": {"billing": "money", "technical": "errors"}},
"risk": {"type": "score", "criteria": ["low", "medium", "high"]}
}
}'
Ответ:
{
"model": "local",
"answers": {
"urgent": {"type": "noul", "probability": 0.72, "confidence": 0.44},
"team": {"type": "choice", "choice": "technical",
"confidence": 0.61, "probabilities": {"billing": 0.39, "technical": 0.61}},
"risk": {"type": "score", "score": 1.4, "confidence": 0.52,
"probabilities": {"low": 0.2, "medium": 0.4, "high": 0.4},
"legend": ["low", "medium", "high"]}
},
"usage": {"questions": 3}
}
Особенности протокола: instructions необязателен (при отсутствии берётся id
вопроса), а для choice/score допустимые варианты можно задавать только через
criteria (ключи/элементы становятся опциями). Поля images, videos,
media_kwargs принимаются; если бэкенд их не поддерживает — возвращается ошибка
MODALITY_NOT_SUPPORTED.
curl
curl -s -X POST http://127.0.0.1:8000/decisions \
-H 'Content-Type: application/json' \
-d '{
"state": "Stripe failing for 3 days, losing sales",
"questions": {
"urgent": {"type": "noul", "instructions": "Обращение срочное?"},
"team": {"type": "choice", "instructions": "Кто должен взять?",
"options": ["биллинг", "интеграция", "продажи"]},
"anger": {"type": "score", "instructions": "Уровень недовольства",
"levels": ["спокоен", "недоволен", "крайне"]}
}
}'
SDK (Python)
from api.sdk.client import DecisionClient
with DecisionClient(base_url="http://localhost:8000") as client:
result = client.decisions(
"Уже третьи сутки не работают платежи, теряем заказы",
{
"срочность": {"type": "noul", "instructions": "Срочно?"},
"причина": {"type": "choice", "instructions": "Причина?",
"options": ["биллинг", "интеграция", "продажи"]},
},
)
# result — проверен по схеме типов и распределений
Готовый русский пример
.venv/bin/python examples/quickstart/http_client.py
Разбор обращения в техподдержку: шесть вопросов всех трёх типов одним запросом с человекочитаемым выводом вероятностей и уверенности по каждому.
Полный каталог готовых скриптов (батчи, протокол SystemOne, каскады, vision,
боты и вебхуки, эксплуатация и паки весов) — в examples/README.md; большинство
запускается без сервера и без скачивания моделей.
Интеграция в чат-агента (SDK + каскад)
api/sdk/agent.py превращает типизированные решения в действие агента
(команда, приоритет, эскалация, тон ответа) чистой функцией route_ticket —
без сети, поэтому легко тестируется. Готовый пример:
# сервер должен быть поднят; при --fallback низкоуверенное обращение
# переспрашивает внешний SystemOne-сервис (клиентский каскад)
.venv/bin/python examples/integrations/chat_agent.py --fallback http://remote:8080
На стороне сервера каскад (DECISION_BACKEND=cascade) эскалирует низкоуверенные
решения сам — см. раздел «Каскад» выше; route_ticket может использоваться и
поверх него.
Оценка качества
Калибровка (ECE / Brier)
Вероятность «в среднем оправдывается»? Для этого есть eval/calibration:
ECE (взвешенное отклонение accuracy от confidence по бинам), Brier score,
reliability diagram.
.venv/bin/python eval/calibration/run.py \
--probs probs.npy --outcomes outcomes.npy --bins 10
Классы NoulHead «калибруются» через RLCD; метрики выводятся как metrics.json
и reliability.png в eval/calibration/out/.
Латентность (бюджет < 500 мс)
import httpx
from eval.latency.benchmark import LatencyBenchmark
with httpx.Client(base_url="http://127.0.0.1:8000", timeout=60) as c:
print(LatencyBenchmark(
callable=lambda: c.post("/decisions", json=PAYLOAD)).run(warmup=2, n=5))
Замеры зависят от модели, железа и длины входа. Пример (8 vCPU, без GPU, gpt2, вход обрезан до 256 токенов): первый запрос по новому состоянию ≈ 9 с; повторные запросы с тем же состоянием — единицы-десятки миллисекунд за счёт кэша признаков. На GPU и на коротких состояниях значения в разы ниже. Именно отсутствие токен-генерации делает путь детерминированным и позволяет уложить тёплый инференс в бюджет < 500 мс.
Готовый CLI с разбивкой по фазам (encode / heads / decide):
# in-process, без HTTP и без внешней модели (hash-энкодер по умолчанию)
.venv/bin/python -m eval.latency.run --n 30 --warmup 3
# через запущенный сервер (свой /decisions или внешний SystemOne)
.venv/bin/python -m eval.latency.run --http http://127.0.0.1:8000/decisions --n 30
Фаза encode замеряется прямым прогоном энкодера (в обход кэша признаков), а
decide — полным путём сервиса. На повторяющемся state decide попадает в
кэш и потому быстрее encode; это наглядно показывает вклад кэша.
Датасеты и офлайн-бенчмарк голов
Для воспроизводимой проверки качества есть генератор синтетических данных и офлайн-бенчмарк — без сервера и внешних моделей.
# текст (тиккеты с метками) + изображения (фигуры) в ./data
.venv/bin/python -m eval.datasets.run --text-n 1000 --image-n 500 --out-dir data
# бенчмарк: обучение голов на train и метрики на отложенных val/test
.venv/bin/python -m eval.benchmark.run \
--dataset data/support_tickets.jsonl --encoder hash --train-steps 200 \
--val-fraction 0.2 --test-fraction 0.2 --out data/report.json
Генератор строит состояние из латентного сценария и вплетает в текст ключевые слова, коррелирующие с меткой, — так головы действительно могут выучить соответствие, а бенчмарк измерить его. Каждый вопрос кодируется своим промптом (как в рантайме), поэтому головы не смешивают цели.
--val-fraction/--test-fraction включают честный train/val/test-сплит: головы
обучаются только на train, а метрики считаются на отложенных наборах
(report["splits"]), при этом поля report["questions"]/overall_accuracy
указывают на test. Без сплитов обучение и оценка идут на одних данных.
Честная оговорка. Синтетические метки линейно отделимы по ключевым словам,
поэтому даже hash-энкодер с линейной головой после 200 шагов SFT даёт
overall_accuracy = 1.000 не только на train, но и на отложенных val/test
(по 200 примеров каждый). Это метрика на синтетике, и она доказывает
работоспособность конвейера, а не качество на реальных данных: без обучения та
же схема даёт ≈ 0.276 (уровень случайного угадывания). На реальном энкодере
та же задача решается уже неидеально: рецепт на gpt2 (малый прогон,
--generate 60 --train-steps 60) даёт train ≈ 0.69, val ≈ 0.47, test ≈ 0.51 —
то есть цифра 1.000 про hash-энкодер отражает лёгкость синтетики, а не
универсальное качество. Для noul дополнительно считаются Brier и ECE.
Датасеты и отчёты складываются в data/ (в .gitignore).
Публичная оценка (AG News): untrained vs trained
Кроме синтетики есть адаптер публичного датасета fancyzhx/ag_news (4 темы:
World/Sports/Business/Sci-Tech) и отчёт, который честно сравнивает необученные и
обученные головы на отложенном test и рисует reliability-диаграммы:
# hash-энкодер (офлайн, быстро)
.venv/bin/python -m eval.report.run --encoder hash --dim 64 --out-dir data/report
# реальный gpt2 на GPU
DECISION_DEVICE=cuda .venv/bin/python -m eval.report.run \
--encoder openai-community/gpt2 --dim 768 --out-dir data/report
Отчёт кладёт metrics.json и reliability_*.png в --out-dir. Для choice/score
считаются top-1 accuracy, multi-class Brier и top-label ECE. Референс на AG News
(gpt2, GPU): overall accuracy 0.425 → 0.884, ECE по темам 0.725 → 0.151.
Публичная оценка (RouterBench): маршрутизация LLM
Второй публичный адаптер — withmartian/routerbench (Hu et al., 2024,
arXiv:2403.12031): ~36k промптов из MMLU/GSM-8K/HellaSwag/... с оценками качества
и стоимостью ответов 11 моделей. Это настоящая decision-задача — «стоит ли
эскалировать на более сильную модель», а не классификация тем. Из оценок трёх
уровней (mistral-7b → mixtral-8x7b → gpt-4) выводятся три типизированных
вопроса: escalate (noul, триггер каскада), route (choice) и benefit
(score).
.venv/bin/python -m eval.datasets.routerbench --out-dir data/routerbench --limit 6000
DECISION_MAX_LENGTH=256 .venv/bin/python -m eval.report.run --specs routerbench \
--encoder openai-community/gpt2 --dim 768 --train-steps 600 \
--device cuda --limit 4000 --out-dir data/report_routerbench
Референс (gpt2, GPU): overall accuracy 0.356 → 0.593; escalate 0.214 → 0.787
(ECE 0.786 → 0.213). Честно: маршрутизация трудна, и заморожённый gpt2 даёт
умеренный прирост — escalate выходит лишь на базовую частоту класса (~0.79), а
route/benefit ненамного лучше majority. Набор ценен как честный публичный
бенчмарк (accuracy < 1.0), а не как доказательство высокого качества.
Что с этим делать — измерено (5 сидов на конфигурацию, test n=1000): смена
замороженного энкодера (gpt2 → bert-base) ничего не даёт (0.551 против 0.554,
разница внутри разброса), а дообучение энкодера поднимает качество до
0.601 ± 0.019 и вдвое снижает разброс по сидам. Прирост дают route и
benefit, escalate остаётся около базовой частоты — метка контрфактическая.
На AG News дообучение тоже помогает (0.880 → 0.900). Детали, команды и оговорки —
в docs/routerbench.md.
Бенчмарк против конкурентов
eval.competitors сравнивает локальные головы, каскад и внешние
SystemOne-совместимые сервисы (Clef/Clef-Flash, Laya, Perplexity Decider) на одном
наборе и одном наборе вопросов: качество (accuracy/Brier/ECE), латентность
p50/p95/p99 и долю эскалаций.
# воспроизводимо и без сети (mock-конкурент)
.venv/bin/python -m eval.competitors.run --specs support --limit 200 \
--mock --mock-latency-ms 5 --out data/competitors.json
# реальные сервисы
.venv/bin/python -m eval.competitors.run --specs public --limit 200 \
--remote clef-flash=http://127.0.0.1:8000 --out data/competitors.json
# self-hosted «живой» конкурент: локальная causal-LM под /v1/systemone
.venv/bin/python -m eval.competitors.serve \
--model HuggingFaceTB/SmolLM2-135M-Instruct --port 8090 --device cuda
.venv/bin/python -m eval.competitors.run --specs public --limit 800 \
--remote live=http://127.0.0.1:8090 --out data/competitors_live.json
# публичный decision-датасет RouterBench + кривая «качество/цена» (--sweep)
.venv/bin/python -m eval.competitors.run --specs routerbench --limit 500 \
--mock --mock-latency-ms 2 --sweep --out data/competitors_routerbench.json
Сводка результатов и воспроизведение — в docs/benchmarks.md;
методика, --sweep и оговорки — в docs/competitor-benchmark.md. Коротко:
обученные локальные головы (124M) дают 0.915 / 0.873 / 0.542 на support /
AG News / RouterBench против 0.667 / 0.855 / 0.442 у Clef-Flash (9.4B) и
0.500 / 0.662 / 0.312 у Laya, при p50 10–46 мс против 130–310 мс. Каскад
добавляет +3.4 п.п. на support и +3.7 п.п. на AG News, а на RouterBench
политика по выгоде отказывается платить за модель, которая слабее локальной.
Обучение голов
SFT — учим на размеченных данных
from src.decision_head.heads import DecisionHeads
from training.sft.train import SFTTrainer
heads = DecisionHeads(dim=768, questions={…})
# признаки подаются по-вопросно (как в рантайме): своя матрица на каждый вопрос,
# тогда каждая голова учится только на своих промптах и цели не смешиваются.
SFTTrainer(heads, lr=0.1).fit(
features={"срочность": feats_noul, "команда": feats_choice, …},
targets={"срочность": y_noul, "команда": y_choice, …},
steps=80, batch_size=32,
)
# target: "choice"/"score" — классы-индексы, "noul" — float-цели в [0,1].
# Для одного вопроса допустим единый тензор features.
Рецепт обучения голов (train/val/test)
Готовый рецепт обучает головы на train-сплите и сохраняет чекпоинт вместе с метаданными (энкодер, размерность, хэш датасета, метрики по сплитам):
# сгенерировать данные и обучить головы
.venv/bin/python -m training.recipe.train_heads --generate 1000 \
--out-dir checkpoints/support --encoder hash --dim 64 --train-steps 300
# или на своём датасете
.venv/bin/python -m training.recipe.train_heads \
--dataset data/support_tickets.jsonl --out-dir checkpoints/support \
--encoder openai-community/gpt2 --dim 768 --train-steps 300
На выходе checkpoints/support/heads.pt и metadata.json. Готовый end-to-end
рецепт (gpt2 + SFT + RLCD-гейт, train/val/test) — в
training/recipe/train_reference_model.sh: он сохраняет чекпоинт под подписью
схемы вопросов, поэтому сервер подхватывает его автоматически.
Если ручной разметки нет, датасет можно собрать из ответов сильной модели по
протоколу SystemOne — python -m training.labeling.run (слабая разметка).
Проверять такую голову нужно на настоящей разметке: метки учителя — не ground
truth. Подробности и подводные камни — docs/weak-labeling.md.
Дрейф домена: дообучение на обратной связи
Когда домен уезжает, головы дообучаются на свежих размеченных примерах с replay старого датасета — иначе они забывают то, что умели:
.venv/bin/python -m training.feedback.run \
--pack checkpoints/packs/public-gpt2 \
--feedback data/feedback.jsonl \
--out checkpoints/packs/public-gpt2-v2 --replay 0.5 --steps 100
Пак принимается только если на отложенной части новых примеров стало не
хуже порога, а старый домен не просел больше 1 п.п.; иначе остаются прежние
головы, а в отчёте видно, почему. Сколько примеров нужно и что бывает без
replay — docs/drift.md.
Матрица «несколько моделей × несколько датасетов» обучается одной командой
(training/recipe/train_weight_packs.sh); каждый пак — отдельный каталог
checkpoints/packs/<dataset>-<model>/, пригодный как DECISION_CHECKPOINT_DIR:
# {support, public, routerbench} x {gpt2, distilgpt2, gpt2-medium}. Переопределяется
# окружением: SUPPORT_N/SUPPORT_STEPS/SUPPORT_RLCD, PUBLIC_LIMIT/PUBLIC_STEPS/PUBLIC_RLCD,
# ROUTERBENCH_LIMIT/ROUTERBENCH_STEPS/ROUTERBENCH_RLCD/ROUTERBENCH_SHOT,
# PACKS_ROOT, DECISION_DEVICE, DECISION_DTYPE.
bash training/recipe/train_weight_packs.sh
Обученные веса весят мало и публикуются как артефакт GitHub Release (а не в git) — либо один пак, либо вся матрица одним архивом + манифестом:
training/recipe/publish_release.sh v0.2.0 checkpoints/support # один пак
training/recipe/publish_weight_packs.sh v0.2.0 checkpoints/packs # матрица + manifest.json
RLCD-калибровка noul-голов на реальных признаках энкодера принимает изменение
только если оно улучшает калибровку на отложенном val (гейт «не навреди»):
.venv/bin/python -m training.recipe.calibrate_rlcd --specs public \
--encoder openai-community/gpt2 --dim 768 --limit 4000 \
--checkpoint-dir checkpoints/packs/public-gpt2 --device cuda
Готовые паки весов (release)
Релиз v0.2.0
содержит архив v0.2.0-weights.tar.gz (и v0.2.0-manifest.json) с шестью
обученными паками: {support, public} × {gpt2, distilgpt2, gpt2-medium}. Все числа
— на честном train/val/test (для public — настоящий test-сплит AG News):
| Пак | Энкодер | dim | Датасет | test acc |
|---|---|---|---|---|
support-gpt2 |
gpt2 | 768 | синтетика (саппорт), n=1000 | 0.926 |
support-gpt2-medium |
gpt2-medium | 1024 | синтетика (саппорт), n=1000 | 0.914 |
support-distilgpt2 |
distilgpt2 | 768 | синтетика (саппорт), n=1000 | 0.868 |
public-gpt2 |
gpt2 | 768 | AG News, n=4000 | 0.880 |
public-distilgpt2 |
distilgpt2 | 768 | AG News, n=4000 | 0.879 |
public-gpt2-medium |
gpt2-medium | 1024 | AG News, n=4000 | 0.873 |
routerbench-gpt2 |
gpt2 | 768 | RouterBench 0-shot, n=4000 | 0.548 |
Пак routerbench-gpt2 собирается тем же рецептом
(--specs routerbench --limit 4000 --train-steps 500 --rlcd-steps 200) и в
архив v0.2.0 не входит — это следующий релиз. На нём же снято сравнение с
Clef-Flash и Laya: 0.542 против 0.442 и 0.312 (n=200, см.
docs/competitor-benchmark.md). Метка escalate в RouterBench
контрфактическая, поэтому абсолютные числа низкие у всех участников: 0.775 —
тривиальная константа «всегда да».
После обучения пак калибруется по температуре (--specs и тот же
--checkpoint-dir): T подбирается по NLL на val, делит логиты перед
softmax/sigmoid и не меняет argmax, то есть accuracy остаётся прежней, а
вероятности становятся честными. Без этого головы переуверены (confidence ≈ 1.0), и каскад не эскалирует вовсе.
.venv/bin/python -m training.recipe.calibrate_temperature --specs routerbench \
--encoder openai-community/gpt2 --dim 768 --limit 4000 \
--checkpoint-dir checkpoints/packs/routerbench-gpt2
# -> temperature.json + heads.pt с полем temperature
Температура хранится в чекпоинте ("temperature" рядом с state_dict),
поэтому сервер и замерный харнесс применяют её автоматически; старые
чекпоинты без этого поля читаются как T = 1.0. Значение никогда не
опускается ниже 1.0 — калибровка только смягчает вероятности. Перед сохранением
работает гейт «не навреди»: T применяется, только если ECE на val улучшился и
Brier не ухудшился, иначе остаётся T = 1.0 (так business в public-паках
остаётся неоткалиброванным). На
routerbench-gpt2 это снижает ECE с 0.547 до 0.024 (route), на
public-gpt2 — с 0.144 до 0.024 (topic); после неё каскад даёт +3.4 п.п. на
support и +1.7 п.п. на public (подробности — в
docs/competitor-benchmark.md).
tar -xzf v0.2.0-weights.tar.gz # -> v0.2.0-weights/<pack>/
DECISION_CHECKPOINT_DIR=v0.2.0-weights/support-gpt2 \
DECISION_MODEL_ID=openai-community/gpt2 DECISION_FEATURE_DIM=768 \
.venv/bin/python -m uvicorn api.server.main:app --port 8000
Паки support-* используют тот же набор вопросов, что сервис по умолчанию, и
подхватываются автоматически. Паки public-* соответствуют вопросам AG News
(PUBLIC_QUESTIONS) — их нужно подавать сервису вместе с этим набором вопросов.
Чекпоинты голов и переиспользование
Головы решений создаются один раз на набор вопросов и переиспользуются между запросами (кэш по подписи схемы). Обученные веса сохраняются и подгружаются:
svc.save_heads(dim=768, questions=QUESTIONS) # -> checkpoints/<schema>.pt
При старте сервер читает каталог из DECISION_CHECKPOINT_DIR (по умолчанию
checkpoints/) и, если для схемы есть чекпоинт с подходящей размерностью,
загружает веса. Это делает качество воспроизводимым между перезапусками, а
латентность — без повторного прогона энкодера по неизменному состоянию (кэш
признаков по промпту).
Несколько доменов в одном процессе. Помимо плоской раскладки
<dir>/<signature>.pt, сервер понимает каталоги паков
<dir>/<домен>/heads.pt + metadata.json (так их публикует
training/recipe/publish_weight_packs.sh). Пак выбирается по
questions_signature из metadata.json, а не по имени каталога, поэтому
DECISION_CHECKPOINT_DIR=checkpoints/packs обслуживает сразу все домены:
запрос с вопросами поддержки получает support-*, запрос с вопросами
маршрутизации — routerbench-*. Если два пака объявляют одну подпись, побеждает
первый по сортировке, а в лог пишется WARNING — дубли не подменяют друг друга
молча. Источник каждой загруженной головы виден в GET /info → engine.heads[].source.
Если под новую схему вопросов чекпоинта нет, головы создаются со случайной
инициализацией — их confidence ничего не значит, и отдавать по ним решения
(тем более эскалировать в каскаде) нельзя. Поэтому сервер явно сообщает статус
(trained: false) и управляется переменной DECISION_UNTRAINED:
auto(по умолчанию) —refuse, еслиDECISION_CHECKPOINT_DIRзадан явно (обученные головы ожидаются), иначеallow(демо-режим);allow— отдаёт решения и помечает ответtrained: false;refuse— отклоняет запрос:409с кодомUNTRAINED_HEADS;fallback— сигналит каскаду эскалировать во внешнюю модель; без каскада ведёт себя какrefuse.
Текущий статус голов виден в GET /info.
RLCD — учим калибровке
RLCD (reinforcement learning for calibrated decisions): reward −|p − outcome|,
GRPO-группы из нескольких выборок, policy gradient. Прямо оптимизирует
правдоподобие вероятностей.
from training.rlcd.train import RLCDNoulTrainer
trainer = RLCDNoulTrainer(dim=2, lr=0.2)
trainer.train(features, outcomes, steps=300)
В рецепте калибровка идёт через calibrate_noul_heads: веса применяются, только
если RLCD улучшает среднюю абсолютную ошибку (гейт «не навреди»), иначе голова
откатывается к состоянию после SFT. Гейт считается на отложенном val-сплите
(gate_features/gate_targets); без него — на train.
Тесты, линт, покрытие
.venv/bin/python -m pytest # 770 passed, 14 skipped
.venv/bin/python -m ruff check src api eval training tests examples scripts
.venv/bin/python -m pytest --cov=src --cov=api # гейт 90% из pyproject.toml
.venv/bin/python -m pytest -m gpu # GPU-тесты (auto-skip без CUDA)
shellcheck -x $(git ls-files '*.sh')
uv lock --check # lockfile соответствует зависимостям
uv build --wheel && scripts/smoke_wheel.sh # smoke установленного wheel
Тесты на реальной модели — opt-in (по умолчанию пропускаются, чтобы pytest не
качал веса): DECISION_RUN_MODEL_TESTS=1 .venv/bin/python -m pytest -m model —
скачивает лёгкую openai/clip-vit-base-patch32 (~0.6 ГБ) и проверяет
ClipVisionEncoder на реальных изображениях (форма, детерминизм, инвариантность к
батчу, проекция размерности, путь через DecisionService).
Совет: на машинах с малым объёмом RAM полный
pytest --covв одном процессе может упасть (SIGILL на latency-тесте из-за кумулятивных torch-аллокаций). Обход: два прогона coverage с--parallel-modeиcoverage combine.
Deploy
sudo bash deploy/ubuntu/install.sh # Ubuntu — apt, venv, systemd-юнит
sudo bash deploy/centos/install.sh # CentOS/RHEL — dnf, venv, systemd-юнит
sudo bash deploy/upgrade.sh /opt/decision-model # обновление с авто-откатом
sudo bash deploy/backup.sh /opt/decision-model # .env + паки + risk.json
systemctl status decision-api@api.service
Обе сборки используют общий deploy/lib/common.sh: детект дистрибутива по
/etc/os-release, установка пакетов, ensure_venv, рендер юнита
decision-api@.service под фактический каталог и пользователя (uvicorn, порт
8080, автозапуск, graceful shutdown по SIGTERM). Установщик разворачивает
закоммиченное состояние в /opt/decision-model (DECISION_INSTALL_DIR) и
создаёт системного пользователя decision (DECISION_SERVICE_USER).
Обновление, откат и состав бэкапа — docs/deploy.md.
Типовые ошибки (UNTRAINED_HEADS, MODALITY_NOT_SUPPORTED, /ready 503) —
docs/troubleshooting.md.
Наблюдаемость
GET /metrics отдаёт метрики Prometheus (decision_http_requests_total,
decision_http_request_duration_seconds, decision_decisions_total,
decision_ready, decision_heads_loaded). Путь в метках — шаблон маршрута,
поэтому кардинальность не растёт с трафиком.
curl -fsS http://127.0.0.1:8080/metrics | grep decision_
Каждый ответ содержит X-Request-ID (клиентский сохраняется, если он безопасен);
тот же id есть в строке доступа decision.access. Для Loki/ELK:
DECISION_LOG_FORMAT=json DECISION_LOG_LEVEL=INFO \
.venv/bin/python -m uvicorn api.server.main:app --port 8000
Тела запросов (state, изображения, фидбэк) в метрики и логи не попадают.
Скрейп, алерты и формат JSON-лога — docs/observability.md.
Структура репозитория
src/ — модель: encoder (+device.py), decision heads, sampler, оценка
риска (risk.py), бэкенды (backends/), схемы (schemas.py)
api/ — FastAPI-сервер (server/) и Python SDK (sdk/: client.py, agent.py)
training/ — SFT (sft/), RLCD (rlcd/) и рецепт обучения голов (recipe/)
eval/ — калибровка (calibration/, вкл. selective-метрики), latency
(latency/), датасеты (datasets/, вкл. публичные AG News и
RouterBench), офлайн-бенчмарк (benchmark/), отчёт
untrained-vs-trained (report/), сравнение каскадов (risk/),
перенос вариантов выбора (option_transfer/) и
конкурентов (competitors/)
deploy/ — Ubuntu/CentOS + systemd
docs/ — методика бенчмарков (competitor-benchmark.md, routerbench.md,
risk-aware-cascade.md, option-scoring.md)
examples/ — готовые примеры (см. examples/README.md: quickstart, батчи,
SystemOne, каскады, vision, интеграции, эксплуатация)
tests/ — pytest-набор (604 теста + opt-in GPU/модель)
data/ — сгенерированные датасеты и отчёты бенчмарков (в .gitignore)
scripts/ — bootstrap окружения (bootstrap-user.sh) и smoke wheel
README.md — этот файл
requirements.txt
pytest.ini
pyproject.toml — упаковка (дистрибутив decision-cascade)
LICENSE, NOTICE — лицензия Apache-2.0
CHANGELOG.md, SECURITY.md
.github/workflows/ci.yml — CI: ruff, shellcheck, pytest с гейтом покрытия,
uv lock --check, сборка wheel и smoke
Ограничения и планы
- Качество по умолчанию: головы по умолчанию случайные; осмысленные решения
требуют SFT+RLCD на вашей разметке. Есть рецепты и обученные паки весов
(
v0.2.0-weights.tar.gzв GitHub Release:support-*/public-*× gpt2-family); в git веса не кладутся, поэтому после чистого клона «из коробки» — случайные головы, пока не скачаете пак или не обучите свои. - Устройство:
DECISION_DEVICE/DECISION_DTYPEвыбирают CPU/GPU и тип; gemma-4-12B требует ~24 ГБ и CUDA, на CPU используйте gpt2. - Мультимодальность: локальная CLIP поддерживает только изображения; видео требует отдельного декодирования кадров.
- Честная оценка: синтетические сплиты тривиально разделимы (
1.000— это про конвейер, а не про домен). Публичные числа — на AG News (accuracy 0.425 → 0.88) и в бенчмарке против конкурентов; для своего домена нужна своя разметка. - Публикация весов: релизом через
training/recipe/publish_release.sh(один пак) илиtraining/recipe/publish_weight_packs.sh(матрица). - Выход из беты: критерии
1.0(контракт, покрытие, эксплуатация, политика депрекации) — вdocs/stability.md.
Лицензия
Apache-2.0 — см. LICENSE и NOTICE. Copyright 2026 slavabars.
Безопасность
Сервер не содержит встроенной аутентификации/CORS/rate-limit и рассчитан на
локальный запуск за доверенным периметром (reverse-proxy с TLS). Как сообщить об
уязвимости — см. SECURITY.md.
Metadata
Release files for decision-cascade 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| decision_cascade-1.1.0.tar.gz | 376.4 kB | Without provenance | Details for decision_cascade-1.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| decision_cascade-1.1.0-py3-none-any.whl | Python 3 | none | any | Details for decision_cascade-1.1.0-py3-none-any.whl |
Total release size: 670.4 kB
Release files / decision_cascade-1.1.0.tar.gz
| Download URL | decision_cascade-1.1.0.tar.gz |
|---|---|
| Size | 376.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
25aa4b249397700e6b03b6c718bd31eb0f9b840d7ebe88714737e5d063db0597
|
|
BLAKE2b-256 checksum How to use checksums |
09751afb678eb655c662f6a2f68a5c671db4b0be78f44a73271c0c11ebfaeb36
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / decision_cascade-1.1.0-py3-none-any.whl
| Download URL | decision_cascade-1.1.0-py3-none-any.whl |
|---|---|
| Size | 294.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d45484193604c0f1769e41c9770853fddde9ad62e268b7e43faaa0ab6b750f0d
|
|
BLAKE2b-256 checksum How to use checksums |
984cb9078f41eff1e7c369988ddd5f796d81b08bf480bab92037b8a9e61be122
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for decision-cascade 1.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.