Metadata-Version: 2.4
Name: decision-cascade
Version: 1.4.0
Summary: Локальный движок типизированных калибруемых решений (Noul/Choice/Score) с каскадом и протоколом SystemOne.
Author-email: slavabars <slavabars@users.noreply.github.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/slavabars/decision-cascade
Project-URL: Repository, https://github.com/slavabars/decision-cascade
Project-URL: Issues, https://github.com/slavabars/decision-cascade/issues
Project-URL: Changelog, https://github.com/slavabars/decision-cascade/blob/main/CHANGELOG.md
Project-URL: Releases, https://github.com/slavabars/decision-cascade/releases
Keywords: decision,calibration,routing,cascade,llm,systemone
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: torch>=2.0
Requires-Dist: transformers>=4.40
Requires-Dist: Pillow>=10
Requires-Dist: pydantic>=2.0
Requires-Dist: fastapi>=0.110
Requires-Dist: uvicorn[standard]>=0.29
Requires-Dist: prometheus-client>=0.20
Requires-Dist: httpx>=0.27
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.12
Requires-Dist: trl>=0.9
Requires-Dist: accelerate>=0.30
Requires-Dist: datasets>=2.18
Requires-Dist: pandas>=2.0
Requires-Dist: peft>=0.10
Requires-Dist: scikit-learn>=1.4
Requires-Dist: matplotlib>=3.8
Requires-Dist: pytest>=8.0
Requires-Dist: pytest-cov>=4.0
Requires-Dist: pre-commit>=3.6
Requires-Dist: ruff>=0.3
Provides-Extra: games
Requires-Dist: chess>=1.10; extra == "games"
Dynamic: license-file

# decision-cascade

[![CI](https://github.com/slavabars/decision-cascade/actions/workflows/ci.yml/badge.svg)](https://github.com/slavabars/decision-cascade/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/decision-cascade)](https://pypi.org/project/decision-cascade/)
[![Python](https://img.shields.io/pypi/pyversions/decision-cascade)](https://pypi.org/project/decision-cascade/)
[![Coverage gate](https://img.shields.io/badge/coverage_gate-%E2%89%A590%25-brightgreen)](https://github.com/slavabars/decision-cascade/blob/main/pyproject.toml)
[![License](https://img.shields.io/pypi/l/decision-cascade)](https://github.com/slavabars/decision-cascade/blob/main/LICENSE)

**Русский** · [English](https://github.com/slavabars/decision-cascade/blob/main/README.en.md)

Machine-native система принятия решений в духе **Jev (TypeSafe AI)**, но с открытым исходным кодом, локальным исполнением и полным циклом «обучение → калибровка → оценка → деплой».

Вместо того чтобы заставлять LLM генерировать текст ответа, модель **решает**: state-состояние превращается в признаки, а головы решений возвращают **типизированные, вероятностные и калибруемые** ответы — Noul (да/нет), Choice (выбор из опций) и Score (оценка на шкале) — без единого токена генерации.

**Статус: stable (1.4.0).** Контракт API (`schema_version = "1"`), формат паков и смысл переменных `DECISION_*` не ломаются: изменения — только в major, с окном депрекации в две минорные версии. Что именно обещает `1.x`, какие поверхности остаются beta и как объявляется депрекация — в [`docs/stability.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/stability.md).

## Демо: две игры

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

| Шахматы против Stockfish | Лабиринт в духе Pac-Man |
| :---: | :---: |
| [![Шахматы: голова решает, когда думать глубже](https://raw.githubusercontent.com/slavabars/decision-cascade/main/examples/games/chess/demo.gif)](https://github.com/slavabars/decision-cascade/blob/main/examples/games/chess/README.md) | [![Лабиринт: голова решает, когда жать на осторожность](https://raw.githubusercontent.com/slavabars/decision-cascade/main/examples/games/maze/demo.gif)](https://github.com/slavabars/decision-cascade/blob/main/examples/games/maze/README.md) |
| 19% эскалаций закрывают половину разрыва до «всегда глубоко» — при 38% цены полного просчёта | 94% очков осторожной политики при 33% эскалаций; вдвое больше, чем жадная |
| [код, данные и числа](https://github.com/slavabars/decision-cascade/blob/main/examples/games/chess/README.md) | [код, данные и числа](https://github.com/slavabars/decision-cascade/blob/main/examples/games/maze/README.md) |

Обе игры — наши: карта, среда и правила написаны в этом репозитории, чужих ассетов нет. Головы учатся на исходах, а не на подсказках: в шахматах — когда быстрому поиску движка нельзя верить, в лабиринте — когда жадный шаг к точке закончится потерей жизни.

## Быстрый старт (2 минуты)

Нужен Python 3.11+ и интернет только для установки пакета: модель не скачивается, GPU и обученные веса не нужны — сервер стартует на `hash`-энкодере.

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install decision-cascade
python -m uvicorn api.server.main:app --port 8000
```

Во втором терминале — один запрос с тремя типами вопросов:

```bash
curl -sS -X POST http://127.0.0.1:8000/decisions \
  -H 'content-type: application/json' \
  -d '{
    "state": "Stripe checkout has been failing for 3 days, we are losing revenue",
    "questions": {
      "urgent": {"type": "noul", "instructions": "Is the customer reporting urgency?"},
      "team": {"type": "choice", "instructions": "Which team should take it?",
               "options": ["billing", "integration", "sales", "support"]},
      "severity": {"type": "score", "instructions": "How upset is the customer?",
                   "levels": ["calm", "mild", "angry", "furious"]}
    }
  }'
```

Ответ — типизированные решения с вероятностями (реальный вывод, без правок):

```json
{
  "schema_version": "1",
  "decisions": {
    "urgent": {"type": "noul", "noul": 0.5031822323799133, "confidence": 0.00636446475982666},
    "team": {
      "type": "choice",
      "choice": "support",
      "probabilities": {"billing": 0.251, "integration": 0.211, "sales": 0.248, "support": 0.290},
      "confidence": 0.29038938879966736
    },
    "severity": {
      "type": "score",
      "score": 1.486896499991417,
      "probabilities": {"calm": 0.255, "mild": 0.240, "angry": 0.268, "furious": 0.237},
      "confidence": 0.26786985993385315,
      "legend": ["calm", "mild", "angry", "furious"]
    }
  },
  "trained": false
}
```

Вероятности почти равномерные и `trained: false` — это честно: энкодер `hash`, головы не обучены, шаг нужен, чтобы увидеть форму ответа и то, что всё работает локально и детерминированно. Осмысленные числа — с обученным паком (веса из релиза, ~0.5 ГБ модели):

```bash
gh release download v1.4.0 --pattern '*-weights.tar.gz' \
  --repo slavabars/decision-cascade
tar -xzf v1.4.0-weights.tar.gz

DECISION_CHECKPOINT_DIR=v1.4.0-weights/public-gpt2 \
DECISION_MODEL_ID=openai-community/gpt2 DECISION_FEATURE_DIM=768 \
  python -m uvicorn api.server.main:app --port 8000
```

`public-gpt2` — головы на AG News: 0.880 на настоящем test-сплите (см. таблицу ниже). Дальше: [SDK и справочник API](#использование-справочник-api), [обучение своих голов](#обучение-голов), [деплой](#deploy).

## Бенчмарки: публичные наборы

Один и тот же набор вопросов, один прогон, n=200, одна GPU 16 ГБ. Наши — 124M (gpt2 + головы под домен), конкуренты — настоящие релизы: `Clef-Flash` 9.4B (GGUF Q4_K_M в `llama-server`) и `Laya` 421M (ModernBERT-large).

| Публичный набор | decision-cascade 124M | Clef-Flash 9.4B | Laya 421M |
| --- | ---: | ---: | ---: |
| AG News — accuracy | **0.873** | 0.855 | 0.662 |
| AG News — p50 | **10 мс** | 130 мс | 16 мс |
| RouterBench — accuracy | **0.542** | 0.442 | 0.312 |
| RouterBench — p50 | **21 мс** | 170 мс | 27 мс |

На AG News локальные головы повторяют качество модели, которая в 75 раз больше, при латентности в 13 раз ниже. На RouterBench отрыв меньше: набор контрфактический, и абсолютные числа низкие у всех (тривиальная константа «всегда да» даёт 0.775), поэтому смотреть надо на разницу между участниками, а не на абсолют.

Ещё две вещи, которые обычно спрашивают:

- **каскад**: +3.4 п.п. на `support` и +3.7 п.п. на AG News при 11–18% эскалаций; на RouterBench политика по выгоде сама отказывается платить за модель, которая слабее локальной;
- **калибровка**: температура снижает ECE с 0.144 до 0.024 на AG News и с 0.547 до 0.024 на RouterBench — без изменения accuracy; на русском наборе с 0.176 до 0.022.

Синтетический домен `support` (0.915 против 0.667 и 0.500), три политики эскалации, reliability-диаграммы и оговорки — в [`docs/benchmarks.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/benchmarks.md); методика и `--sweep` — в [`docs/competitor-benchmark.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/competitor-benchmark.md). Воспроизведение одной командой: `eval/competitors/reproduce_real.sh`; сырые JSON — в `*-benchmarks.tar.gz` каждого релиза.

<details>
<summary><b>Оглавление</b></summary>

- [Демо: две игры](#демо-две-игры) · [Быстрый старт](#быстрый-старт-2-минуты) · [Бенчмарки](#бенчмарки-публичные-наборы)
- [Что это за подход](#что-это-за-подход) · [Позиционирование](#позиционирование)
- [Установка](#установка) · [Установка окружения](#установка-окружения) · [Запуск сервера](#запуск-сервера) · [Остановка сервера](#остановка-сервера)
- [Как менять модель](#как-менять-модель) (минимальный набор переменных — в начале раздела)
- [Использование: справочник API](#использование-справочник-api)
- [Оценка качества](#оценка-качества) · [Тесты, линт, покрытие](#тесты-линт-покрытие)
- [Обучение голов](#обучение-голов) · [Deploy](#deploy) · [Наблюдаемость](#наблюдаемость)
- [Структура репозитория](#структура-репозитория) · [Ограничения и планы](#ограничения-и-планы)
- [Лицензия](#лицензия) · [Безопасность](#безопасность)

</details>

## Что это за подход

### Машинные решения, а не генерация текста

Обычный 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`):

```bash
python3 -m venv .venv && . .venv/bin/activate
pip install decision-cascade
```

Пакет ставит библиотеку (`src`, `api`, `eval`, `training`) и сервер; запуск — как обычно через uvicorn:

```bash
python -m uvicorn api.server.main:app --host 127.0.0.1 --port 8000
```

Для разработки — из исходников, с `uv` и зафиксированным `uv.lock` (ниже).

## Установка окружения

```bash
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`), поэтому его можно поставить и редактируемо:

```bash
uv pip install --python .venv/bin/python -e .
```

Процесс релиза (версия → тег → PyPI → артефакты) — в [`docs/release.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/release.md).

## Запуск сервера

### Вариант 1 — без сети / для мгновенной проверки (hash-энкодер)

```bash
.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)

```bash
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)

```bash
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-энкодером.

## Как менять модель

### Минимальный набор

Чтобы получить работающий сервер, не нужно **ни одной** переменной: дефолты дают `hash`-энкодер, локальный бэкенд и `checkpoints`. Меняют обычно пять:

| Переменная | Зачем | По умолчанию |
|------------|-------|--------------|
| `DECISION_MODEL_ID` | энкодер с HF Hub (`openai-community/gpt2`, `cointegrated/rubert-tiny2`, …) | — (`hash`) |
| `DECISION_DEVICE` | `auto`, `cpu`, `cuda`, `cuda:N`, `mps` | `auto` |
| `DECISION_DTYPE` | `float32`, `float16`, `bfloat16` | `float32` |
| `DECISION_MAX_LENGTH` | обрезка входа, токенов (`none` — без обрезки) | `256` |
| `DECISION_CHECKPOINT_DIR` | каталог паков голов | `checkpoints` |

Готовый production-набор с комментариями — в [`.env.example`](https://github.com/slavabars/decision-cascade/blob/main/.env.example) (`cp .env.example .env`, дальше `set -a; . ./.env; set +a`). Что реально применилось — с учётом дефолтов и разбора значений — видно в `GET /info`, блок `config`: энкодер, устройство, dtype, обрезка, батч, каталог паков.

> Обрезка входа и dtype — самые дешёвые рычаги латентности: `256 → 64` экономит
> 2.3×, `bfloat16` на GPU — ещё 2.5×. Замеры — в [`docs/latency.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/latency.md).

### Все переменные

Выбор модели — **через переменные окружения** при старте сервера (`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` с диска | — (любой путь) |

Фактическое устройство и тип можно задать при запуске:

```bash
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:

```bash
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` отклонит ровно те модальности, что не поддержаны.

```bash
# 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`.

```python
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 и т.п.:

```bash
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` на текстовом локальном бэкенде), сервис отвечает структурированной ошибкой, не пытаясь тихо деградировать:

```json
{"error": {"code": "MODALITY_NOT_SUPPORTED",
           "supported": ["text"], "requested": ["text", "image"]}}
```

#### Каскад: локальные головы + внешняя модель (опционально)

Режим `DECISION_BACKEND=cascade` запускает **local-first** схему: сначала отвечают локальные головы, а во внешнюю модель эскалируются только вопросы с `confidence` ниже порога (или неподдерживаемую модальность — целиком). Так внешний вызов платится лишь за действительно неясные случаи:

```bash
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(ошибка)` локального решения — тогда вы задаёте **бюджет ошибок**, который готовы принять локально, а не абстрактный порог:

```bash
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`:

```bash
# обучить оценщик риска и сравнить каскады на публичном наборе
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, где считается выигрыш:

```bash
# точность внешней модели по вопросам на 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
```

```bash
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`. Каскад помнит отпечатки решений, которые ушли наружу, и учитывает фидбэк только по ним (ответы локальных голов качество внешней модели не характеризуют). Оценка сдвигается плавно:

```text
q_new = (q_prior * W + верных) / (W + всего),  W = DECISION_CASCADE_FEEDBACK_SHRINKAGE (50)
```

```bash
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), каскад переобучает модель и применяет её к этому вопросу:

```bash
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` включает голову, которая оценивает вариант общим скорером по его текстовому эмбеддингу — число параметров не зависит от числа вариантов, а набор вариантов можно подменить без переобучения:

```bash
DECISION_CHOICE_HEAD=option_scoring uvicorn api.server.main:app --port 8000
```

Перенос работает на вариантах, семантически близких к обученным, и не работает на произвольных новых словах или когда рядом есть знакомые варианты (они «перебивают» новые). Честные числа и границы — в `docs/option-scoring.md`:

```bash
# замер переноса вариантов (обучение на 3, подмена набора на инференсе)
python -m eval.option_transfer.run --encoder openai-community/gpt2 --dim 768 --seeds 0 1 2
```

Указывать размерность необязательно: головы строятся по фактической размерности выхода энкодера (`features.shape[-1]`), а если она отличается от 768 — вход проектируется автоматически.

```bash
# 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):

```python
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 это важно для памяти при обучении на больших наборах.

## Остановка сервера

```bash
# по 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`).

```bash
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, границы соблюдены, ключи — ровно перечисленные опции/уровни. Внешний конверт ответа:

```json
{
  "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`)

```bash
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"]}
        }
      }'
```

Ответ:

```json
{
  "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

```bash
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)

```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 — проверен по схеме типов и распределений
```

### Готовый русский пример

```bash
.venv/bin/python examples/quickstart/http_client.py
```

Разбор обращения в техподдержку: шесть вопросов всех трёх типов одним запросом с человекочитаемым выводом вероятностей и уверенности по каждому.

Полный каталог готовых скриптов (батчи, протокол SystemOne, каскады, vision, боты и вебхуки, эксплуатация и паки весов) — в `examples/README.md`; большинство запускается без сервера и без скачивания моделей.

### Интеграция в чат-агента (SDK + каскад)

`api/sdk/agent.py` превращает типизированные решения в действие агента (команда, приоритет, эскалация, тон ответа) чистой функцией `route_ticket` — без сети, поэтому легко тестируется. Готовый пример:

```bash
# сервер должен быть поднят; при --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.

```bash
.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 мс)

```python
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`):

```bash
# 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

# бюджет батчинга, длины входа и dtype
.venv/bin/python -m eval.latency.sweep   --encoder openai-community/gpt2 --dim 768 --kind causal --device cpu
```

Числа по батчингу и обрезке входа, а также замер int8/float16 — в [`docs/latency.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/latency.md).

Фаза `encode` замеряется прямым прогоном энкодера (в обход кэша признаков), а `decide` — полным путём сервиса. На повторяющемся `state` `decide` попадает в кэш и потому быстрее `encode`; это наглядно показывает вклад кэша.

### Датасеты и офлайн-бенчмарк голов

Для воспроизводимой проверки качества есть генератор синтетических данных и офлайн-бенчмарк — без сервера и внешних моделей.

```bash
# текст (тиккеты с метками) + изображения (фигуры) в ./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`).


Русскоязычный набор — `eval.datasets.ru_sentiment` (тональность: choice + два noul). Числа, baseline и команды — в [`docs/ru-benchmark.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/ru-benchmark.md).


### Публичная оценка (AG News): untrained vs trained

Кроме синтетики есть адаптер публичного датасета `fancyzhx/ag_news` (4 темы: World/Sports/Business/Sci-Tech) и отчёт, который честно сравнивает необученные и обученные головы на отложенном test и рисует reliability-диаграммы:

```bash
# 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).

```bash
.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 и долю эскалаций.

```bash
# воспроизводимо и без сети (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`](https://github.com/slavabars/decision-cascade/blob/main/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 — учим на размеченных данных

```python
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-сплите и сохраняет чекпоинт вместе с метаданными (энкодер, размерность, хэш датасета, метрики по сплитам):

```bash
# сгенерировать данные и обучить головы
.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`](https://github.com/slavabars/decision-cascade/blob/main/docs/weak-labeling.md).

### Дрейф домена: дообучение на обратной связи

Когда домен уезжает, головы дообучаются на свежих размеченных примерах с replay старого датасета — иначе они забывают то, что умели:

```bash
.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`](https://github.com/slavabars/decision-cascade/blob/main/docs/drift.md).

Матрица «несколько моделей × несколько датасетов» обучается одной командой (`training/recipe/train_weight_packs.sh`); каждый пак — отдельный каталог `checkpoints/packs/<dataset>-<model>/`, пригодный как `DECISION_CHECKPOINT_DIR`:

```bash
# {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) — либо один пак, либо вся матрица одним архивом + манифестом:

```bash
training/recipe/publish_release.sh v1.4.0 checkpoints/support       # один пак
training/recipe/publish_weight_packs.sh v1.4.0 checkpoints/packs    # матрица + manifest.json
```

RLCD-калибровка `noul`-голов на реальных признаках энкодера принимает изменение только если оно улучшает калибровку на **отложенном** val (гейт «не навреди»):

```bash
.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)

Релиз [`v1.4.0`](https://github.com/slavabars/decision-cascade/releases/tag/v1.4.0) содержит архив `v1.4.0-weights.tar.gz` (и `v1.4.0-manifest.json`) с семью обученными паками: `{support, public} × {gpt2, distilgpt2, gpt2-medium}` плюс `routerbench-gpt2`. Все числа — на честном 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`). На нём снято сравнение с 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`), и каскад не эскалирует вовсе.

```bash
.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`).

```bash
tar -xzf v1.4.0-weights.tar.gz               # -> v1.4.0-weights/<pack>/
DECISION_CHECKPOINT_DIR=v1.4.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`) — их нужно подавать сервису вместе с этим набором вопросов.

### Чекпоинты голов и переиспользование

Головы решений создаются один раз на набор вопросов и переиспользуются между запросами (кэш по подписи схемы). Обученные веса сохраняются и подгружаются:

```python
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. Прямо оптимизирует правдоподобие вероятностей.

```python
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.

## Тесты, линт, покрытие

```bash
.venv/bin/python -m pytest                          # 838 passed, 14 skipped (без GPU)
.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

```bash
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`](https://github.com/slavabars/decision-cascade/blob/main/docs/deploy.md). Типовые ошибки (`UNTRAINED_HEADS`, `MODALITY_NOT_SUPPORTED`, `/ready` 503) — [`docs/troubleshooting.md`](https://github.com/slavabars/decision-cascade/blob/main/docs/troubleshooting.md).

## Наблюдаемость

`GET /metrics` отдаёт метрики Prometheus (`decision_http_requests_total`, `decision_http_request_duration_seconds`, `decision_decisions_total`, `decision_ready`, `decision_heads_loaded`). Путь в метках — шаблон маршрута, поэтому кардинальность не растёт с трафиком.

```bash
curl -fsS http://127.0.0.1:8080/metrics | grep decision_
```

Каждый ответ содержит `X-Request-ID` (клиентский сохраняется, если он безопасен); тот же id есть в строке доступа `decision.access`. Для Loki/ELK:

```bash
DECISION_LOG_FORMAT=json DECISION_LOG_LEVEL=INFO \
  .venv/bin/python -m uvicorn api.server.main:app --port 8000
```

Тела запросов (`state`, изображения, фидбэк) в метрики и логи не попадают. Скрейп, алерты и формат JSON-лога — [`docs/observability.md`](https://github.com/slavabars/decision-cascade/blob/main/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-набор (852 теста + 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 на вашей разметке. Есть рецепты и обученные паки весов (`v1.4.0-weights.tar.gz` в GitHub Release: `support-*`/`public-*` × gpt2-family плюс `routerbench-gpt2`); в 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`](https://github.com/slavabars/decision-cascade/blob/main/docs/stability.md).

## Лицензия

Apache-2.0 — см. `LICENSE` и `NOTICE`. Copyright 2026 slavabars.

## Безопасность

Сервер не содержит встроенной аутентификации/CORS/rate-limit и рассчитан на локальный запуск за доверенным периметром (reverse-proxy с TLS). Как сообщить об уязвимости — см. `SECURITY.md`.
