Machine-native система принятия решений в духе Jev (TypeSafe AI), но с открытым исходным кодом, локальным исполнением и полным циклом «обучение → калибровка → оценка → деплой».
Вместо того чтобы заставлять LLM генерировать текст ответа, модель решает: state-состояние превращается в признаки, а головы решений возвращают типизированные, вероятностные и калибруемые ответы — Noul (да/нет), Choice (выбор из опций) и Score (оценка на шкале) — без единого токена генерации.
Обычный 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 (платный 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 и закладывайте время на первый (холодный) инференс.
Честное ограничение: это открытая beta для контроля и прозрачности, а не готовый 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. Чтобы не
создавать впечатление аффилированности, проект носит собственное имя, но
совместим с этим протоколом и умеет проксировать к нему.
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.txtbootstrap-user.sh работает без sudo: ставит uv/pyenv в ~/.local и
~/.pyenv, создаёт .venv, ставит зависимости и кладёт gh в ~/.local/bin.
Проект упакован как дистрибутив decision-cascade (pyproject.toml), поэтому
его можно поставить и редактируемо:
uv pip install --python .venv/bin/python -e ..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 по порту — см. «Остановка».
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_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_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 8000auto выбирает 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Подстройка под новый вопрос (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 свободен"Есть три эндпоинта с одной и той же моделью решений, справочный 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 /info— конфигурация сервиса: бэкенд, модальности, устройство и статус загруженных голов (trained).
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
означает, что под эту схему вопросов нет обученного чекпоинта.
Версии — <major>[.<minor>], совместимость — по major (см.
src/versioning.py): клиент, ожидающий 1.x, принимает любой 1.y, но не 2.*.
minor — аддитивные изменения (необязательные поля/вопросы), major —
ломающие (удаление/переименование полей, смена семантики) с миграцией. Тот же
принцип применяется к чекпоинтам голов: payload содержит schema_version
(CHECKPOINT_VERSION), legacy-файлы без версии читаются как 1, а более новый
major отклоняется явной ошибкой, а не молчаливым чтением неизвестного формата.
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 -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": ["спокоен", "недоволен", "крайне"]}
}
}'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; большинство
запускается без сервера и без скачивания моделей.
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 может использоваться и
поверх него.
Вероятность «в среднем оправдывается»? Для этого есть 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/.
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).
Кроме синтетики есть адаптер публичного датасета 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.
Второй публичный адаптер — 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), а не как доказательство высокого качества. Детали —
в 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/competitor-benchmark.md (RouterBench,
--sweep и замеры self-hosted живого конкурента — там же). Ключевой вывод:
каскад «local-first» сохраняет качество локальной модели, обращаясь к внешнему
сервису лишь за единицы процентов решений (0.913 против 0.915 у local при 1.25%
эскалаций).
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-сплите и сохраняет чекпоинт вместе с метаданными (энкодер, размерность, хэш датасета, метрики по сплитам):
# сгенерировать данные и обучить головы
.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: он сохраняет чекпоинт под подписью
схемы вопросов, поэтому сервер подхватывает его автоматически.
Матрица «несколько моделей × несколько датасетов» обучается одной командой
(training/recipe/train_weight_packs.sh); каждый пак — отдельный каталог
checkpoints/packs/<dataset>-<model>/, пригодный как DECISION_CHECKPOINT_DIR:
# {support, public} x {gpt2, distilgpt2, gpt2-medium}. Переопределяется окружением:
# SUPPORT_N/SUPPORT_STEPS/SUPPORT_RLCD, PUBLIC_LIMIT/PUBLIC_STEPS/PUBLIC_RLCD,
# 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.jsonRLCD-калибровка 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Релиз 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 |
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/) и, если для схемы есть чекпоинт с подходящей размерностью,
загружает веса. Это делает качество воспроизводимым между перезапусками, а
латентность — без повторного прогона энкодера по неизменному состоянию (кэш
признаков по промпту).
Если под новую схему вопросов чекпоинта нет, головы создаются со случайной
инициализацией — их confidence ничего не значит, и отдавать по ним решения
(тем более эскалировать в каскаде) нельзя. Поэтому сервер явно сообщает статус
(trained: false) и управляется переменной DECISION_UNTRAINED:
auto(по умолчанию) —refuse, еслиDECISION_CHECKPOINT_DIRзадан явно (обученные головы ожидаются), иначеallow(демо-режим);allow— отдаёт решения и помечает ответtrained: false;refuse— отклоняет запрос:409с кодомUNTRAINED_HEADS;fallback— сигналит каскаду эскалировать во внешнюю модель; без каскада ведёт себя какrefuse.
Текущий статус голов виден в GET /info.
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 # 347 passed, 10 skipped
.venv/bin/python -m ruff check src api eval training tests examples scripts
.venv/bin/python -m pytest --cov=src --cov=api --cov=eval --cov=training
.venv/bin/python -m pytest -m gpu # GPU-тесты (auto-skip без CUDA)
shellcheck -x deploy/lib/common.sh deploy/ubuntu/install.sh deploy/centos/install.shТесты на реальной модели — 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.
sudo bash deploy/ubuntu/install.sh # Ubuntu — apt, venv, systemd-юнит
sudo bash deploy/centos/install.sh # CentOS/RHEL — dnf, venv, systemd-юнит
systemctl status decision-api@api.serviceОбе сборки используют общий deploy/lib/common.sh: детект дистрибутива по
/etc/os-release, установка пакетов, ensure_venv, установка юнита
decision-api@.service (uvicorn, порт 8080, автозапуск).
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-набор (419 тестов + opt-in GPU/модель)
data/ — сгенерированные датасеты и отчёты бенчмарков (в .gitignore)
scripts/ — bootstrap окружения (bootstrap-user.sh: uv/pyenv/venv/gh)
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
- Качество по умолчанию: головы по умолчанию случайные; осмысленные решения
требуют 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(матрица).
Apache-2.0 — см. LICENSE и NOTICE. Copyright 2026 slavabars.
Сервер не содержит встроенной аутентификации/CORS/rate-limit и рассчитан на
локальный запуск за доверенным периметром (reverse-proxy с TLS). Как сообщить об
уязвимости — см. SECURITY.md.