Release Drex v2.0 code and model card
Browse files- README.md +139 -63
- code/kev/__init__.py +0 -1
- code/kev/api.py +7 -2
- code/kev/checkpoint.py +49 -198
- code/kev/device.py +0 -24
- code/kev/mlx_model.py +0 -144
- code/kev/model.py +61 -30
- code/kev/serve.py +0 -183
- code/kev/serve_batch.py +0 -85
- code/pyproject.toml +0 -24
- examples/request.json +29 -0
- inference.py +122 -33
- requirements.txt +4 -0
- serve.py +107 -0
README.md
CHANGED
|
@@ -9,78 +9,56 @@ tags:
|
|
| 9 |
- kev
|
| 10 |
- drex
|
| 11 |
- moe
|
| 12 |
-
private: true
|
| 13 |
---
|
| 14 |
|
| 15 |
# Drex v2.0
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
-
pip install -e code
|
| 25 |
-
python inference.py --model NischayDnk/Drex-v2.0 # built-in example
|
| 26 |
-
python inference.py --model NischayDnk/Drex-v2.0 --request req.json # your own System One request
|
| 27 |
-
```
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
```bash
|
| 32 |
-
KEV_DTYPE=bf16 python -m kev.serve --run <folder with head.pt> --port 8000
|
| 33 |
-
# long documents: KEV_SERVE_MAX_STATE=32768 KEV_SERVE_MAX_BRANCH=32768
|
| 34 |
-
```
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 39 |
-
{"state": {"ticket": "I was charged twice for order A-104."},
|
| 40 |
-
"questions": {"team": {"type": "choice", "instructions": "Which team handles this?",
|
| 41 |
-
"criteria": {"billing": "Charges and refunds", "shipping": "Delivery", "other": null}},
|
| 42 |
-
"refund": {"type": "noul", "instructions": "Does the customer ask for a refund?"}}}
|
| 43 |
-
```
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
| 48 |
-
export VLLM_ENABLE_V1_MULTIPROCESSING=0
|
| 49 |
-
# LLM(..., runner="pooling", convert="embed", dtype="bfloat16",
|
| 50 |
-
# pooler_config={"task": "token_embed", "use_activation": False})
|
| 51 |
-
# PointerHead from head.pt: softmax(head(h[decide], h[opts]))
|
| 52 |
-
```
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
| **1** | **Drex v2.0** | **60.78** | **69.70** | 47.7 | **65.9** | 62.9 | **76.0** | **49.1** |
|
| 61 |
-
| 2 | Drex v1.5 | 58.28 | 67.80 | 44.2 | 62.5 | 62.0 | 75.3 | 45.1 |
|
| 62 |
-
| 3 | Jev 1.13.0 | 57.91 | 68.09 | **51.4** | 62.0 | 55.4 | 75.1 | 37.7 |
|
| 63 |
-
| 4 | Surogate Rune 26B-A4B v3 | 57.44 | 67.30 | 43.4 | 63.1 | **63.5** | 71.2 | 41.9 |
|
| 64 |
-
| 5 | Decider chat · Gemma-4-31B | 57.33 | 67.22 | 44.3 | 60.4 | 63.1 | 75.6 | 38.3 |
|
| 65 |
-
| 6 | AutoJev-27B | 56.40 | 66.89 | 40.9 | 63.5 | 54.9 | 79.3 | 39.4 |
|
| 66 |
-
|
| 67 |
-
Area columns are chance-corrected skill × 100. Drex v2.0 is ahead of Jev on 21 of the 38 index benchmarks and on 4 of 5 areas (Knowledge trails). Unanswered requests count as wrong.
|
| 68 |
-
|
| 69 |
-

|
| 70 |
-
|
| 71 |
-
### JevBench (231 public items)
|
| 72 |
|
| 73 |
| Model | Accuracy | Easy | Standard | Hard |
|
| 74 |
|---|---|---|---|---|
|
| 75 |
-
| Jev 1.13.0 | **86.6%** (200/231) | 100.0% | **98.6%** | 73.0% |
|
| 76 |
| **Drex v2.0** | 85.7% (198/231) | 100.0% | 95.8% | **73.0%** |
|
| 77 |
-
| Drex v1.5 | 85.7% (198/231) | 100.0% | 95.8% | 73.0% |
|
| 78 |
|
| 79 |
-
Choice = top option, yes/no = noul ≥ 0.5, ordinal = top level.
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
|
|
|
|
| 82 |
|
| 83 |
-
Published decision models only (chat LLMs omitted). Few-shot uses the benchmark
|
| 84 |
|
| 85 |
| Entry | Accuracy | Pair accuracy |
|
| 86 |
|---|---|---|
|
|
@@ -95,9 +73,10 @@ Published decision models only (chat LLMs omitted). Few-shot uses the benchmark
|
|
| 95 |
| Drex v1.5 | 90.0% | 80.5% |
|
| 96 |
| JevK5 v0.3 | 88.8% | 78.5% |
|
| 97 |
|
| 98 |
-
|
| 99 |
|
| 100 |
-
|
|
|
|
| 101 |
|
| 102 |
Held-out addresses. Score is AUC; caught @ 5% is recall at a 5% false-positive rate.
|
| 103 |
|
|
@@ -106,14 +85,17 @@ Held-out addresses. Score is AUC; caught @ 5% is recall at a 5% false-positive r
|
|
| 106 |
| AUC | **0.984** | 0.981 | 0.972 |
|
| 107 |
| Caught @ 5% FP | **89.4%** | 86.4% | 80.3% |
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
| | W – D – L | Score |
|
| 114 |
|---|---|---|
|
| 115 |
| **Drex v2.0 vs Jev** | **132 – 48 – 76** | **60.9%** |
|
| 116 |
-
| Drex v1.5 vs Jev
|
| 117 |
|
| 118 |
| Game | Drex v2.0 vs Jev | Drex v1.5 vs Jev |
|
| 119 |
|---|---|---|
|
|
@@ -126,12 +108,106 @@ Eight games, 32 matches each (16 openings × both colours). Score = (wins + ½ d
|
|
| 126 |
| Dots and Boxes | 15 – 0 – 17 (46.9%) | 18 – 0 – 14 (56.3%) |
|
| 127 |
| Nine Men's Morris | 15 – 13 – 4 (67.2%) | 5 – 19 – 8 (45.3%) |
|
| 128 |
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|---|---|
|
| 133 |
-
| `model-*.safetensors`, `config.json` | backbone (bf16) |
|
| 134 |
-
| `head.pt` | pointer head |
|
| 135 |
-
| `code/` | Kev package for loading and serving |
|
| 136 |
-
| `inference.py` | minimal scorer for System One requests |
|
| 137 |
-
| `decision_index.svg` | Decision Index 0.2.1 card |
|
|
|
|
| 9 |
- kev
|
| 10 |
- drex
|
| 11 |
- moe
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
# Drex v2.0
|
| 15 |
|
| 16 |
+
**Nace's flagship decision model: 61.25 on the public Decision Index 0.3.1, 3.3 points ahead of Jev 1.13.0 and 3.2 ahead of Drex v1.5.**
|
| 17 |
|
| 18 |
+
We introduce Drex v2.0 from [Nace.AI](https://www.nace.ai/), a decision model that answers typed questions about a given context. Pass the context as `state` ([request format](https://github.com/nace-ai/drex-decision-models/blob/main/docs/request-format.md)) with one or more named questions, and get a probability for every option back in one forward pass. Nothing is generated. Drex v2.0 beats Jev head to head in the eight-game arena (60.9%) and on email classification (AUC 0.984 against 0.981), and it runs on your own hardware with Python, llama.cpp or Ollama. It is built on [Qwen3.6-35B-A3B](https://huggingface.co/Qwen/Qwen3.6-35B-A3B), a mixture of experts with 35B parameters and about 3B active per token, with a [Kev](https://github.com/jaredpalmer/kev) pointer head, and is released under the Nace.AI Open RAIL-M license.
|
| 19 |
|
| 20 |
+
[Drex repository](https://github.com/nace-ai/drex-decision-models) · [Drex v1.5](https://huggingface.co/nace-ai/drex-v1.5) · [llama.cpp fork](https://github.com/nace-ai/llama.cpp) (branch `drex-v2.0`) · [Ollama fork](https://github.com/nace-ai/ollama) (branch `drex-v2.0`)
|
| 21 |
|
| 22 |
+
It runs with Python, llama.cpp or Ollama. The default limit is 16,384 tokens per request and it can be raised to 131,072 ([Context length](https://github.com/nace-ai/drex-decision-models/blob/main/docs/context-length.md)). The bf16 weights take about 70 GB and the Q8_0 GGUF about 37 GB.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
<details>
|
| 25 |
+
<summary>How it works</summary>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
The backbone is a mixture-of-experts `Qwen3_5MoeForConditionalGeneration` with 40 layers, 256 experts and 8 active per token: 35B parameters in total, about 3B active per token. Only its language model is used; the vision tower is dropped on load. Each question is scored in one forward pass over the state plus that question, and the pointer head in `head.pt` turns the result into probabilities. Nothing is generated, so the sampling values in `generation_config.json` do not apply.
|
| 28 |
|
| 29 |
+
</details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
## Performance
|
| 32 |
|
| 33 |
+
### Decision Index 0.3.1 (public only)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
| Model | Public index | Raw | Knowledge & Reasoning | Language | Retrieval | Tools | Arts |
|
| 36 |
+
|---|---|---|---|---|---|---|---|
|
| 37 |
+
| **Drex v2.0** (35B) | **61.25** | 70.55 | 47.9 | 65.3 | 63.0 | 76.5 | 53.9 |
|
| 38 |
+
| Drex v1.5 (9B) | 58.08 | 68.08 | 44.6 | 60.4 | 61.7 | 75.0 | 48.8 |
|
| 39 |
+
| Jev 1.13.0 | 57.96 | 68.55 | 53.9 | 59.2 | 55.4 | 75.1 | 39.1 |
|
| 40 |
+
| Bespoke Nimble 9B v3 | 57.19 | 68.38 | 41.0 | 60.7 | 55.5 | 84.2 | 44.0 |
|
| 41 |
+
| Cloudflare clef-flash | 56.15 | 66.53 | 53.0 | 47.2 | 52.2 | 81.9 | 48.4 |
|
| 42 |
+
| ezjev 4B s2 | 50.82 | 63.16 | 31.0 | 60.5 | 56.3 | 69.9 | 31.1 |
|
| 43 |
|
| 44 |
+
Public index only: the 37 public benchmarks in five areas, without the private tests. Area columns are chance-corrected skill × 100. The Drex rows are from our own runs of the official kit on the full public suite; the other rows are from the [leaderboard](https://huggingface.co/spaces/multimodalart/jev-decision-index).
|
| 45 |
|
| 46 |
+
<details>
|
| 47 |
+
<summary>JevBench (231 public items)</summary>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
| Model | Accuracy | Easy | Standard | Hard |
|
| 50 |
|---|---|---|---|---|
|
| 51 |
+
| Jev 1.13.0 | **86.6%** (200/231) | 100.0% | **98.6%** | **73.0%** |
|
| 52 |
| **Drex v2.0** | 85.7% (198/231) | 100.0% | 95.8% | **73.0%** |
|
|
|
|
| 53 |
|
| 54 |
+
Choice = top option, yes/no = noul ≥ 0.5, ordinal = top level.
|
| 55 |
+
|
| 56 |
+
</details>
|
| 57 |
|
| 58 |
+
<details>
|
| 59 |
+
<summary>DecideBench v1.1 (400 contrastive decisions)</summary>
|
| 60 |
|
| 61 |
+
Published decision models only (chat LLMs omitted). Few-shot uses the benchmark's examples-in-instructions protocol. Not an official leaderboard submission.
|
| 62 |
|
| 63 |
| Entry | Accuracy | Pair accuracy |
|
| 64 |
|---|---|---|
|
|
|
|
| 73 |
| Drex v1.5 | 90.0% | 80.5% |
|
| 74 |
| JevK5 v0.3 | 88.8% | 78.5% |
|
| 75 |
|
| 76 |
+
</details>
|
| 77 |
|
| 78 |
+
<details>
|
| 79 |
+
<summary>Email classification</summary>
|
| 80 |
|
| 81 |
Held-out addresses. Score is AUC; caught @ 5% is recall at a 5% false-positive rate.
|
| 82 |
|
|
|
|
| 85 |
| AUC | **0.984** | 0.981 | 0.972 |
|
| 86 |
| Caught @ 5% FP | **89.4%** | 86.4% | 80.3% |
|
| 87 |
|
| 88 |
+
</details>
|
| 89 |
|
| 90 |
+
<details>
|
| 91 |
+
<summary>Game arena against Jev</summary>
|
| 92 |
+
|
| 93 |
+
Eight OpenSpiel games, 32 matches each (16 openings × both colours). Score = (wins + ½ draws) ÷ matches.
|
| 94 |
|
| 95 |
| | W – D – L | Score |
|
| 96 |
|---|---|---|
|
| 97 |
| **Drex v2.0 vs Jev** | **132 – 48 – 76** | **60.9%** |
|
| 98 |
+
| Drex v1.5 vs Jev | 122 – 47 – 87 | 56.8% |
|
| 99 |
|
| 100 |
| Game | Drex v2.0 vs Jev | Drex v1.5 vs Jev |
|
| 101 |
|---|---|---|
|
|
|
|
| 108 |
| Dots and Boxes | 15 – 0 – 17 (46.9%) | 18 – 0 – 14 (56.3%) |
|
| 109 |
| Nine Men's Morris | 15 – 13 – 4 (67.2%) | 5 – 19 – 8 (45.3%) |
|
| 110 |
|
| 111 |
+
</details>
|
| 112 |
+
|
| 113 |
+
## Python
|
| 114 |
+
|
| 115 |
+
Needs a CUDA GPU; memory use grows with document length. The server answers `POST /v1/systemone`.
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
hf download nace-ai/drex-v2.0 --local-dir drex-v2.0
|
| 119 |
+
pip install -r drex-v2.0/requirements.txt
|
| 120 |
+
python drex-v2.0/inference.py --request drex-v2.0/examples/request.json
|
| 121 |
+
python drex-v2.0/serve.py --port 8000
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
To accept requests longer than 16,384 tokens, set `KEV_CONTEXT` (up to 131,072); see [Context length](https://github.com/nace-ai/drex-decision-models/blob/main/docs/context-length.md).
|
| 125 |
+
|
| 126 |
+
## llama.cpp
|
| 127 |
+
|
| 128 |
+
Runs on Apple silicon (Metal) and on the CPU. Run these from the repository root:
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
git clone --branch drex-v2.0 --single-branch https://github.com/nace-ai/llama.cpp.git
|
| 132 |
+
cmake -S llama.cpp -B llama.cpp/build
|
| 133 |
+
cmake --build llama.cpp/build -j --target llama-server
|
| 134 |
+
|
| 135 |
+
python llama.cpp/convert_hf_to_gguf.py drex-v2.0 --no-mtp --outtype q8_0 \
|
| 136 |
+
--outfile drex-v2.0/drex-v2.0-Q8_0.gguf
|
| 137 |
+
|
| 138 |
+
llama.cpp/build/bin/llama-server -m drex-v2.0/drex-v2.0-Q8_0.gguf \
|
| 139 |
+
--embedding --pooling none -np 2 -c 32768 -b 16384 -ub 2048 -ngl 28 --port 8000
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
The server answers `POST /v1/systemone` with the same request and response as the Python server. The Q8_0 GGUF is 36.9 GB, so on a 48 GB Mac `-ngl 28` offloads 28 layers to Metal and runs the rest on the CPU; `-ngl 0` runs entirely on the CPU. The conversion needs the bf16 weights, about 70 GB, on disk.
|
| 143 |
+
|
| 144 |
+
`-np 2` encodes the state once and shares it across questions. `-c` is split across the two slots, so this command accepts requests up to 16,384 tokens. For up to 131,072 tokens, set `SYSTEMONE_CONTEXT=131072` and use `-c 262144` ([Context length](https://github.com/nace-ai/drex-decision-models/blob/main/docs/context-length.md)).
|
| 145 |
+
|
| 146 |
+
<details>
|
| 147 |
+
<summary>Tested hardware</summary>
|
| 148 |
+
|
| 149 |
+
Apple M5 Pro (18-core CPU, 20-core GPU, 48 GB unified memory, macOS 26.5.2): the Q8_0 GGUF gives the same answers on Metal with `-ngl 28` and on the CPU, with probabilities within 0.03 of each other.
|
| 150 |
+
|
| 151 |
+
</details>
|
| 152 |
+
|
| 153 |
+
## Ollama
|
| 154 |
+
|
| 155 |
+
Use the Nace fork of Ollama with a `llama-server` built from the `drex-v2.0` branch above. Convert the GGUF first, as in the llama.cpp steps.
|
| 156 |
+
|
| 157 |
+
<details>
|
| 158 |
+
<summary>Build the fork</summary>
|
| 159 |
+
|
| 160 |
+
From the repository root:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
git clone --branch drex-v2.0 --single-branch https://github.com/nace-ai/ollama.git
|
| 164 |
+
cd ollama
|
| 165 |
+
export OLLAMA_LLAMA_CPP_SOURCE="$PWD/../llama.cpp"
|
| 166 |
+
cmake -S llama/server --preset darwin
|
| 167 |
+
cmake --build build/llama-server-darwin --target llama-server --parallel 8
|
| 168 |
+
GOTOOLCHAIN=auto go build -trimpath -o ollama .
|
| 169 |
+
cd ..
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
For the CPU use the `cpu` preset and `build/llama-server-cpu` in place of `darwin` and `build/llama-server-darwin` here and below.
|
| 173 |
+
|
| 174 |
+
</details>
|
| 175 |
+
|
| 176 |
+
Write a `Modelfile` next to the GGUF:
|
| 177 |
+
|
| 178 |
+
```bash
|
| 179 |
+
cat > drex-v2.0/Modelfile <<'EOF'
|
| 180 |
+
FROM ./drex-v2.0-Q8_0.gguf
|
| 181 |
+
CAPABILITY decision
|
| 182 |
+
PARAMETER num_ctx 16384
|
| 183 |
+
PARAMETER num_gpu 28
|
| 184 |
+
EOF
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
Start the daemon with the fork's binary and leave it running:
|
| 188 |
+
|
| 189 |
+
```bash
|
| 190 |
+
OLLAMA_HOST=127.0.0.1:11434 \
|
| 191 |
+
OLLAMA_LLAMA_SERVER="$PWD/ollama/build/llama-server-darwin/bin/llama-server" ollama/ollama serve
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
In another terminal, import and call the model:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
OLLAMA_HOST=127.0.0.1:11434 ollama/ollama create drex-v2.0 -f drex-v2.0/Modelfile
|
| 198 |
+
jq '. + {model: "drex-v2.0"}' req.json | curl http://127.0.0.1:11434/v1/systemone \
|
| 199 |
+
-H 'Content-Type: application/json' -d @-
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
Ollama needs `"model"` in the request, which the `jq` filter adds. `num_gpu 28` offloads 28 layers to Metal on a 48 GB Mac; use `PARAMETER num_gpu 0` for the CPU. The default is 16,384 tokens; for up to 131,072, set `PARAMETER num_ctx 131072`, import again, and start the daemon with `SYSTEMONE_CONTEXT=131072`.
|
| 203 |
+
|
| 204 |
+
<details>
|
| 205 |
+
<summary>Tested hardware</summary>
|
| 206 |
+
|
| 207 |
+
Through Ollama on the Apple M5 Pro above, the Q8_0 GGUF with `num_gpu 28` gives the same answers as `llama-server`.
|
| 208 |
+
|
| 209 |
+
</details>
|
| 210 |
+
|
| 211 |
+
## License
|
| 212 |
|
| 213 |
+
Nace.AI Open RAIL-M license, see [LICENSE](LICENSE).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/kev/__init__.py
CHANGED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Kev: load and serve a decision model on the TypeSafe System One API."""
|
|
|
|
|
|
code/kev/api.py
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""TypeSafe-compatible request/response shapes (POST /v1/systemone) mapped onto the single pointer primitive.
|
| 2 |
|
| 3 |
Noul -> 2 options [false, true]; answer = p(true)
|
|
@@ -42,7 +47,7 @@ Question = Union[Noul, Choice, Score]
|
|
| 42 |
|
| 43 |
class SystemOneRequest(BaseModel):
|
| 44 |
state: JSONContent
|
| 45 |
-
model: str = "
|
| 46 |
questions: dict[str, Question] = Field(min_length=1)
|
| 47 |
|
| 48 |
|
|
@@ -112,7 +117,7 @@ def to_record(req: SystemOneRequest):
|
|
| 112 |
opts = [option_text(k, v) for k, v in q.criteria.items()]
|
| 113 |
else:
|
| 114 |
opts = [render(x) for x in q.criteria]
|
| 115 |
-
m["legend"] =
|
| 116 |
qs.append({"instr": render(q.instructions), "options": opts, "label": 0}); meta.append(m)
|
| 117 |
return {"state": render(req.state), "questions": qs}, meta
|
| 118 |
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# Copyright 2026 Jared Palmer
|
| 3 |
+
# Derived from Kev (https://github.com/jaredpalmer/kev).
|
| 4 |
+
# Modified by Nace.AI for Drex: model default and ordered score legends.
|
| 5 |
+
# See NOTICE and docs/upstream.md for attribution and revision information.
|
| 6 |
"""TypeSafe-compatible request/response shapes (POST /v1/systemone) mapped onto the single pointer primitive.
|
| 7 |
|
| 8 |
Noul -> 2 options [false, true]; answer = p(true)
|
|
|
|
| 47 |
|
| 48 |
class SystemOneRequest(BaseModel):
|
| 49 |
state: JSONContent
|
| 50 |
+
model: str = "drex"
|
| 51 |
questions: dict[str, Question] = Field(min_length=1)
|
| 52 |
|
| 53 |
|
|
|
|
| 117 |
opts = [option_text(k, v) for k, v in q.criteria.items()]
|
| 118 |
else:
|
| 119 |
opts = [render(x) for x in q.criteria]
|
| 120 |
+
m["legend"] = opts
|
| 121 |
qs.append({"instr": render(q.instructions), "options": opts, "label": 0}); meta.append(m)
|
| 122 |
return {"state": render(req.state), "questions": qs}, meta
|
| 123 |
|
code/kev/checkpoint.py
CHANGED
|
@@ -1,209 +1,60 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
-
import datetime
|
| 7 |
-
import json
|
| 8 |
-
import os
|
| 9 |
-
import re
|
| 10 |
-
from dataclasses import dataclass, field
|
| 11 |
from pathlib import Path
|
|
|
|
| 12 |
|
| 13 |
import torch
|
| 14 |
|
| 15 |
-
from .model import DecisionModel,
|
| 16 |
-
|
| 17 |
-
HUB_ID = re.compile(r"[\w.-]+/[\w.-]+(@[\w.-]+)?")
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def is_hub_id(run):
|
| 21 |
-
return not os.path.isdir(run) and HUB_ID.fullmatch(str(run)) is not None
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def resolve_run(run):
|
| 25 |
-
"""Local run directory as given, or a Hub repo id like jaredpalmer/kev-4b, optionally pinned to a revision or tag
|
| 26 |
-
with `@` (jaredpalmer/kev-4b@qwen3), downloaded to the HF cache. Returns a str path."""
|
| 27 |
-
if os.path.isdir(run):
|
| 28 |
-
return str(run)
|
| 29 |
-
from huggingface_hub import snapshot_download
|
| 30 |
-
repo, _, revision = str(run).partition("@")
|
| 31 |
-
return snapshot_download(repo, revision=revision or None, allow_patterns=["*.json", "*.safetensors", "*.pt", "*.txt", "*.jinja"])
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
@dataclass
|
| 35 |
-
class Meta:
|
| 36 |
-
"""Contents of `head.pt`. Every reader gets the same defaults for fields older checkpoints did not write.
|
| 37 |
-
`extra` keeps any other keys written into `head.pt` so a read-modify-write round trip loses nothing."""
|
| 38 |
-
base: str
|
| 39 |
-
head: dict | None = None
|
| 40 |
-
base_revision: str | None = None
|
| 41 |
-
lora: int = 0
|
| 42 |
-
head_dim: int = 256
|
| 43 |
-
option_isolation: bool = False
|
| 44 |
-
special_embeddings: bool = False
|
| 45 |
-
weights_dtype: str = "fp32"
|
| 46 |
-
temperature: float = 1.0
|
| 47 |
-
holdout: list = field(default_factory=list)
|
| 48 |
-
state_bidir: bool = False
|
| 49 |
-
extra: dict = field(default_factory=dict)
|
| 50 |
-
|
| 51 |
-
KNOWN = ("base", "head", "base_revision", "lora", "head_dim", "option_isolation", "special_embeddings", "weights_dtype", "temperature", "holdout", "state_bidir")
|
| 52 |
-
|
| 53 |
-
@classmethod
|
| 54 |
-
def from_dict(cls, d):
|
| 55 |
-
return cls(**{k: d[k] for k in cls.KNOWN if k in d}, extra={k: v for k, v in d.items() if k not in cls.KNOWN})
|
| 56 |
-
|
| 57 |
-
def to_dict(self):
|
| 58 |
-
return {**self.extra, **{k: getattr(self, k) for k in self.KNOWN}} # known fields win over a stray key in extra
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def read_meta(run):
|
| 62 |
-
return Meta.from_dict(torch.load(f"{run}/head.pt", map_location="cpu"))
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def write_meta(run, meta):
|
| 66 |
-
torch.save(meta.to_dict(), f"{run}/head.pt")
|
| 67 |
-
|
| 68 |
|
| 69 |
-
@dataclass(frozen=True)
|
| 70 |
-
class LoadOptions:
|
| 71 |
-
"""How a checkpoint is turned into a model. Defaults are the exact path every reported number uses; the fields
|
| 72 |
-
are the same knobs the KEV_* environment variables expose to the command-line tools (see from_env).
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
L4 (Kev-4B: 209 -> 118 ms at 101 tokens, 850 -> 189 ms at 330 tokens), probabilities within ~0.01 and
|
| 77 |
-
the same argmax on the checks run so far. KEV_DTYPE=fp32 restores the exact path when serving.
|
| 78 |
-
merge fold the LoRA into the base weights in fp32 before any cast. Exact in fp32; in bf16 it is faster (~15%)
|
| 79 |
-
and closer to the fp32 numbers than the unmerged adapter (kev-4b, 24 dev records: max |dp| 0.017 vs
|
| 80 |
-
0.029, 0 vs 1 argmax flips). Ignored for adapters that carry trained token embeddings.
|
| 81 |
-
attn attention backend; None = the model default (SDPA on CUDA, eager elsewhere). "sdpa" on MPS measured
|
| 82 |
-
parity with eager and is a few percent faster.
|
| 83 |
-
lora_scale WiSE-FT-style interpolation between base (0) and fine-tuned weights (1), at inference.
|
| 84 |
-
temperature None = the temperature the checkpoint carries (fitted by scripts/calibrate_checkpoint.py); 1.0 = raw logits.
|
| 85 |
-
backend None = torch, the path every reported number uses. "mlx" = kev.mlx_model (Metal kernels for the hybrid
|
| 86 |
-
Qwen3.5 backbones through mlx-lm; the pointer head and encoder are shared; refused for attention-only
|
| 87 |
-
bases, which MPS already runs well). "auto" = mlx when the device is mps, the base is hybrid, mlx-lm is
|
| 88 |
-
installed and fp32 was not asked for (an explicit dtype=float32 means "the exact path"), else torch;
|
| 89 |
-
kev.serve uses auto. The MLX path always merges the adapter and ignores `attn` and `dtype` (the backbone
|
| 90 |
-
runs as stored, bf16).
|
| 91 |
-
"""
|
| 92 |
-
dtype: torch.dtype | None = None
|
| 93 |
-
merge: bool = True
|
| 94 |
-
attn: str | None = None
|
| 95 |
-
lora_scale: float = 1.0
|
| 96 |
-
temperature: float | None = None
|
| 97 |
-
backend: str | None = None
|
| 98 |
-
|
| 99 |
-
BACKENDS = (None, "torch", "mlx", "auto")
|
| 100 |
-
|
| 101 |
-
@classmethod
|
| 102 |
-
def from_env(cls, env=os.environ):
|
| 103 |
-
"""KEV_DTYPE=bf16|fp16|fp32, KEV_MERGE=0, KEV_ATTN=sdpa|eager, KEV_LORA_SCALE, KEV_TEMPERATURE, KEV_BACKEND=torch|mlx|auto.
|
| 104 |
-
For command-line entry points only; library code passes an explicit LoadOptions. Explicit values that equal a
|
| 105 |
-
library default are kept (fp32 as torch.float32, "torch" as a string) so a caller with its own default, like
|
| 106 |
-
kev.serve, can tell "asked for it" from "did not say"."""
|
| 107 |
-
backend = env.get("KEV_BACKEND") or None
|
| 108 |
-
if backend not in cls.BACKENDS: raise ValueError(f"KEV_BACKEND must be one of torch, mlx, auto; got {backend!r}")
|
| 109 |
-
return cls(dtype={"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}.get(env.get("KEV_DTYPE", "")),
|
| 110 |
-
merge=env.get("KEV_MERGE", "1") != "0", attn=env.get("KEV_ATTN") or None,
|
| 111 |
-
lora_scale=float(env.get("KEV_LORA_SCALE", "1")),
|
| 112 |
-
temperature=float(env["KEV_TEMPERATURE"]) if env.get("KEV_TEMPERATURE") else None, backend=backend)
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
def mlx_available():
|
| 116 |
try:
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
class Checkpoint:
|
| 124 |
-
def __init__(self, run):
|
| 125 |
-
self.requested = str(run) # what the caller asked for (a Hub id stays a Hub id in labels)
|
| 126 |
-
self.path = resolve_run(run)
|
| 127 |
-
self.meta = read_meta(self.path)
|
| 128 |
-
|
| 129 |
-
def file(self, name):
|
| 130 |
-
return Path(self.path) / name
|
| 131 |
|
| 132 |
-
def adapter_config(self):
|
| 133 |
-
return json.loads(self.file("adapter_config.json").read_text(encoding="utf-8"))
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
date offline), the time head.pt was written for a local run."""
|
| 138 |
-
if is_hub_id(self.requested):
|
| 139 |
-
from huggingface_hub import HfApi
|
| 140 |
-
repo, _, revision = self.requested.partition("@")
|
| 141 |
-
try:
|
| 142 |
-
return HfApi().model_info(repo, revision=revision or None).last_modified.date().isoformat()
|
| 143 |
-
except Exception:
|
| 144 |
-
pass
|
| 145 |
-
return datetime.date.fromtimestamp(self.file("head.pt").stat().st_mtime).isoformat()
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
tok
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
m = MLXDecisionModel(base_dir, pad_id(tok), head_dim=self.meta.head_dim)
|
| 177 |
-
merge_lora(m.lm, self.path, opts.lora_scale)
|
| 178 |
-
return m
|
| 179 |
-
|
| 180 |
-
def _standalone_weights(self):
|
| 181 |
-
return self.file("model.safetensors.index.json").exists() and not self.file("adapter_config.json").exists()
|
| 182 |
-
|
| 183 |
-
def _load_torch(self, tok, device, opts):
|
| 184 |
-
from peft import PeftModel
|
| 185 |
-
meta = self.meta
|
| 186 |
-
if self.file("MERGED").exists() or self._standalone_weights():
|
| 187 |
-
base = str(self.path) if self._standalone_weights() else meta.base
|
| 188 |
-
return DecisionModel(base, tok, device, lora=None, revision=None, head_dim=meta.head_dim,
|
| 189 |
-
option_isolation=meta.option_isolation, dtype=torch.bfloat16, attn=opts.attn)
|
| 190 |
-
dtype, merge = opts.dtype or torch.float32, opts.merge
|
| 191 |
-
if meta.weights_dtype == "bf16":
|
| 192 |
-
dtype, merge = torch.bfloat16, False
|
| 193 |
-
merge = merge and not self.adapter_config().get("trainable_token_indices")
|
| 194 |
-
m = DecisionModel(meta.base, tok, device, lora=None, revision=meta.base_revision, head_dim=meta.head_dim,
|
| 195 |
-
option_isolation=meta.option_isolation, dtype=torch.float32 if merge else dtype, attn=opts.attn)
|
| 196 |
-
m.lm = PeftModel.from_pretrained(m.lm, self.path, torch_device=str(device)).to(device)
|
| 197 |
-
if opts.lora_scale != 1:
|
| 198 |
-
for module in m.lm.modules():
|
| 199 |
-
if isinstance(getattr(module, "scaling", None), dict):
|
| 200 |
-
for k in module.scaling: module.scaling[k] *= opts.lora_scale
|
| 201 |
-
m.lora_scale = opts.lora_scale
|
| 202 |
-
if merge: m.lm = m.lm.merge_and_unload()
|
| 203 |
-
if dtype != torch.float32: m.lm = m.lm.to(dtype)
|
| 204 |
-
return m
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
def load(run, device, opts=LoadOptions()):
|
| 208 |
-
"""Convenience: Checkpoint(run).load(device, opts)."""
|
| 209 |
-
return Checkpoint(run).load(device, opts)
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# Copyright 2026 Jared Palmer
|
| 3 |
+
# Adapted from Kev (https://github.com/jaredpalmer/kev).
|
| 4 |
+
# Modified by Nace.AI for local merged Drex checkpoints and pointer heads.
|
| 5 |
+
# See NOTICE and docs/upstream.md for attribution and revision information.
|
| 6 |
+
"""Load a merged Drex checkpoint.
|
| 7 |
+
|
| 8 |
+
The backbone shards live next to ``head.pt``. There is no adapter to merge.
|
| 9 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
+
import pickle
|
| 12 |
|
| 13 |
import torch
|
| 14 |
|
| 15 |
+
from .model import DecisionModel, load_tokenizer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
def read_meta(path):
|
| 19 |
+
path = Path(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
try:
|
| 21 |
+
meta = torch.load(path / "head.pt", map_location="cpu", weights_only=True)
|
| 22 |
+
except pickle.UnpicklingError as exc:
|
| 23 |
+
raise ValueError("head.pt must contain only tensors and primitive metadata; unsafe pickle objects are rejected") from exc
|
| 24 |
+
if not isinstance(meta, dict) or not isinstance(meta.get("head"), dict):
|
| 25 |
+
raise ValueError("head.pt must be a metadata dictionary containing a head state dictionary")
|
| 26 |
+
return meta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
def load(path, device=None, dtype=None):
|
| 30 |
+
"""Return ``(tokenizer, model)`` in eval mode.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
``device`` defaults to CUDA, then Apple MPS, then CPU. ``dtype`` defaults
|
| 33 |
+
to bfloat16 on CUDA and MPS, and float32 on CPU.
|
| 34 |
+
"""
|
| 35 |
+
path = Path(path)
|
| 36 |
+
meta = read_meta(path)
|
| 37 |
+
if device is None:
|
| 38 |
+
if torch.cuda.is_available():
|
| 39 |
+
device = "cuda"
|
| 40 |
+
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
| 41 |
+
device = "mps"
|
| 42 |
+
else:
|
| 43 |
+
device = "cpu"
|
| 44 |
+
if dtype is None:
|
| 45 |
+
dtype = torch.bfloat16 if device in ("cuda", "mps") else torch.float32
|
| 46 |
+
tok = load_tokenizer(str(path))
|
| 47 |
+
model = DecisionModel(
|
| 48 |
+
str(path),
|
| 49 |
+
tok,
|
| 50 |
+
device,
|
| 51 |
+
lora=None,
|
| 52 |
+
head_dim=int(meta.get("head_dim", 256)),
|
| 53 |
+
option_isolation=bool(meta.get("option_isolation", False)),
|
| 54 |
+
dtype=dtype,
|
| 55 |
+
state_bidir=bool(meta.get("state_bidir", True)),
|
| 56 |
+
)
|
| 57 |
+
model.head.load_state_dict(meta["head"])
|
| 58 |
+
model.head.temperature = float(meta.get("temperature", 1.0))
|
| 59 |
+
model.eval()
|
| 60 |
+
return tok, model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/kev/device.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
"""The accelerator this process uses: cuda, then mps, then cpu."""
|
| 2 |
-
import torch
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def default_device():
|
| 6 |
-
return "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def sync(device):
|
| 10 |
-
"""Wait for queued kernels, so wall-clock timings around a forward pass are real."""
|
| 11 |
-
if device == "mps": torch.mps.synchronize()
|
| 12 |
-
elif device == "cuda": torch.cuda.synchronize()
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def empty_cache(device):
|
| 16 |
-
if device == "mps": torch.mps.empty_cache()
|
| 17 |
-
elif device == "cuda": torch.cuda.empty_cache()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def allocated_bytes(device):
|
| 21 |
-
"""Bytes currently allocated on the device (MPS) or the peak since the process started (CUDA); 0 on CPU."""
|
| 22 |
-
if device == "mps": return torch.mps.current_allocated_memory()
|
| 23 |
-
if device == "cuda": return torch.cuda.max_memory_allocated()
|
| 24 |
-
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/kev/mlx_model.py
DELETED
|
@@ -1,144 +0,0 @@
|
|
| 1 |
-
"""Apple Silicon backend for the Qwen3.5 checkpoints: mlx-lm's Metal implementation of the hybrid backbone under Kev's
|
| 2 |
-
own encoder and pointer head.
|
| 3 |
-
|
| 4 |
-
MPS has no Gated DeltaNet kernels, so the PyTorch path runs reference code there (Kev-4B ~0.8 s per request). This module
|
| 5 |
-
runs the same computation on Metal through mlx-lm and keeps everything Kev-specific unchanged: `kev.model.encode` builds the
|
| 6 |
-
tokens, `rows_of` splits them into one causal row per question (the hybrid form the torch path uses too), and the readout is
|
| 7 |
-
the very same `PointerHead` (fp32, with the checkpoint's temperature) applied to the branch hidden states.
|
| 8 |
-
|
| 9 |
-
Same contract as `DecisionModel` for serving and scoring: encode / forward / probs / probs_and_prefix / probs_with_prefix /
|
| 10 |
-
head / dtype. Selected by `LoadOptions(backend="mlx")` (or "auto" on Apple Silicon) in `kev.checkpoint`; never by the
|
| 11 |
-
benchmark, whose reported numbers stay on the fp32 torch path. Parity against that path is measured in
|
| 12 |
-
tests/test_mlx.py (max |dp| and argmax flips on development records, prefix vs full pass, one question vs several).
|
| 13 |
-
"""
|
| 14 |
-
import json
|
| 15 |
-
from pathlib import Path
|
| 16 |
-
|
| 17 |
-
import mlx.core as mx
|
| 18 |
-
import numpy as np
|
| 19 |
-
import torch
|
| 20 |
-
import torch.nn.functional as F
|
| 21 |
-
from mlx.utils import tree_flatten
|
| 22 |
-
from mlx_lm.models.cache import make_prompt_cache
|
| 23 |
-
from mlx_lm.utils import load_model
|
| 24 |
-
|
| 25 |
-
from .model import PointerHead, encode, rows_of, rows_per_pass
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def merge_lora(lm, adapter_dir, scale=1.0):
|
| 29 |
-
"""Fold a PEFT adapter into the mlx-lm model's weights the way the torch path does: W + (B @ A) * alpha / r in fp32,
|
| 30 |
-
rounded once to the backbone dtype. `scale` is LoadOptions.lora_scale (WiSE-FT interpolation). Returns the tensor count."""
|
| 31 |
-
adapter_dir = Path(adapter_dir)
|
| 32 |
-
cfg = json.loads((adapter_dir / "adapter_config.json").read_text(encoding="utf-8"))
|
| 33 |
-
if cfg.get("trainable_token_indices"):
|
| 34 |
-
raise ValueError("the MLX backend does not carry trained token embeddings (special_embeddings checkpoints); use backend=torch")
|
| 35 |
-
alpha = cfg["lora_alpha"] / (cfg["r"] ** 0.5 if cfg.get("use_rslora") else cfg["r"])
|
| 36 |
-
weights = mx.load(str(adapter_dir / "adapter_model.safetensors"))
|
| 37 |
-
params = dict(tree_flatten(lm.parameters()))
|
| 38 |
-
merged = {}
|
| 39 |
-
with mx.stream(mx.cpu): # the GPU's fp32 matmul is a reduced-precision fast path (~1e-3 relative on an M5); the merge is one-time and must be exact
|
| 40 |
-
for name, a in weights.items():
|
| 41 |
-
if not name.endswith(".lora_A.weight"):
|
| 42 |
-
continue
|
| 43 |
-
stem = name[: -len(".lora_A.weight")]
|
| 44 |
-
# peft names the wrapped text model `base_model.model.<layers...>`; mlx-lm nests it as `language_model.model.<layers...>`
|
| 45 |
-
target = stem.replace("base_model.model.", "language_model.model.", 1) + ".weight"
|
| 46 |
-
if target not in params:
|
| 47 |
-
raise ValueError(f"adapter tensor {stem} has no weight in the mlx-lm model (looked for {target})")
|
| 48 |
-
base = params[target]
|
| 49 |
-
delta = (weights[stem + ".lora_B.weight"].astype(mx.float32) @ a.astype(mx.float32)) * (alpha * scale)
|
| 50 |
-
merged[target] = (base.astype(mx.float32) + delta).astype(base.dtype)
|
| 51 |
-
mx.eval(list(merged.values()))
|
| 52 |
-
lm.load_weights(list(merged.items()), strict=False)
|
| 53 |
-
mx.eval(lm.parameters())
|
| 54 |
-
return len(merged)
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
class MLXDecisionModel:
|
| 58 |
-
"""Prefill-only scorer: hidden states from mlx-lm, logits from the shared torch PointerHead."""
|
| 59 |
-
backend, device, hybrid, option_isolation = "mlx", "mlx", True, False
|
| 60 |
-
prefix_min_tokens = 0 # kev.serve caches the state prefix for every request: on Metal the branch-only pass is always the cheaper one
|
| 61 |
-
|
| 62 |
-
def __init__(self, base_dir, pad_id, head_dim=256):
|
| 63 |
-
self.lm, _ = load_model(Path(base_dir)) # weights as stored (bf16 for the Qwen3.5 bases)
|
| 64 |
-
self.text = self.lm.language_model.model # Qwen3_5TextModel: embeddings -> layers -> final norm = `.model.last_hidden_state`
|
| 65 |
-
self.pad_id = pad_id
|
| 66 |
-
self.head = PointerHead(self.text.embed_tokens.weight.shape[1], dp=head_dim).eval()
|
| 67 |
-
|
| 68 |
-
@property
|
| 69 |
-
def dtype(self):
|
| 70 |
-
return str(self.text.embed_tokens.weight.dtype).removeprefix("mlx.core.")
|
| 71 |
-
|
| 72 |
-
def eval(self):
|
| 73 |
-
self.head.eval(); return self
|
| 74 |
-
|
| 75 |
-
def encode(self, tok, rec, **kw):
|
| 76 |
-
return encode(tok, rec, option_isolation=False, **kw)
|
| 77 |
-
|
| 78 |
-
def _hidden(self, rows, cache=None):
|
| 79 |
-
"""[N, L, d] hidden states of right-padded token rows. Pads sit after every real token and both layer kinds are
|
| 80 |
-
causal (attention: causal mask; DeltaNet: a left-to-right recurrence), so no real token sees a pad."""
|
| 81 |
-
L = max(len(r) for r in rows)
|
| 82 |
-
ids = mx.array([r + [self.pad_id] * (L - len(r)) for r in rows], dtype=mx.int32)
|
| 83 |
-
h = self.text(ids, cache=cache)
|
| 84 |
-
mx.eval(h)
|
| 85 |
-
return h
|
| 86 |
-
|
| 87 |
-
def _logits(self, h, decide, opts):
|
| 88 |
-
"""One question's logits through the fp32 pointer head (temperature included, eval mode)."""
|
| 89 |
-
idx = mx.array([decide, *opts], dtype=mx.int32)
|
| 90 |
-
picked = torch.from_numpy(np.asarray(h[idx].astype(mx.float32)))
|
| 91 |
-
with torch.no_grad():
|
| 92 |
-
return self.head(picked[0], picked[1:])
|
| 93 |
-
|
| 94 |
-
def forward_rows(self, enc):
|
| 95 |
-
"""Row form, as the torch path computes it: every question is one causal row of state + branch tokens, the state
|
| 96 |
-
recomputed per row. The reference the prefix form is checked against (tests/test_mlx.py); serving uses `forward`."""
|
| 97 |
-
S, _, rows = rows_of(enc)
|
| 98 |
-
chunk, out = rows_per_pass([S + r["ids"] for r in rows]), []
|
| 99 |
-
for start in range(0, len(rows), chunk):
|
| 100 |
-
part = rows[start:start + chunk]
|
| 101 |
-
h = self._hidden([S + r["ids"] for r in part])
|
| 102 |
-
out += [self._logits(h[i], len(S) + r["decide"], [len(S) + o for o in r["opts"]]) for i, r in enumerate(part)]
|
| 103 |
-
return out
|
| 104 |
-
|
| 105 |
-
# --- state prefix: the state runs once into an mlx-lm prompt cache (KV for the attention layers, conv + recurrent state
|
| 106 |
-
# for the DeltaNet layers); the branches run as one batch on a replicated copy, so the prefix stays pristine and can be
|
| 107 |
-
# reused by the next request with the same state. On Metal this is also the cheapest way to answer a single request
|
| 108 |
-
# (state once instead of once per question), so it is the only path `forward` / `probs` take.
|
| 109 |
-
|
| 110 |
-
def prefix(self, enc):
|
| 111 |
-
Ls = enc["seg"].count(0)
|
| 112 |
-
cache = make_prompt_cache(self.lm)
|
| 113 |
-
self._hidden([enc["ids"][:Ls]], cache)
|
| 114 |
-
return Ls, cache
|
| 115 |
-
|
| 116 |
-
def _branch_logits(self, enc, cache):
|
| 117 |
-
"""Branches as rows on a replicated copy of the state cache, rows_per_pass rows (and cache copies) at a time."""
|
| 118 |
-
_, _, rows = rows_of(enc)
|
| 119 |
-
chunk, out = rows_per_pass([r["ids"] for r in rows], enc["seg"].count(0)), []
|
| 120 |
-
for start in range(0, len(rows), chunk):
|
| 121 |
-
part = rows[start:start + chunk]
|
| 122 |
-
batch = [type(c).merge([c] * len(part)) for c in cache] # merge copies the arrays: `cache` is not mutated
|
| 123 |
-
h = self._hidden([r["ids"] for r in part], batch)
|
| 124 |
-
out += [self._logits(h[i], r["decide"], r["opts"]) for i, r in enumerate(part)]
|
| 125 |
-
return out
|
| 126 |
-
|
| 127 |
-
def _branch_probs(self, enc, cache):
|
| 128 |
-
return [F.softmax(z, -1) for z in self._branch_logits(enc, cache)]
|
| 129 |
-
|
| 130 |
-
def forward(self, enc):
|
| 131 |
-
"""List of logits tensors, one per question."""
|
| 132 |
-
return self._branch_logits(enc, self.prefix(enc)[1])
|
| 133 |
-
|
| 134 |
-
def probs(self, enc):
|
| 135 |
-
return self._branch_probs(enc, self.prefix(enc)[1])
|
| 136 |
-
|
| 137 |
-
def probs_and_prefix(self, enc):
|
| 138 |
-
prefix = self.prefix(enc)
|
| 139 |
-
return self._branch_probs(enc, prefix[1]), prefix
|
| 140 |
-
|
| 141 |
-
def probs_with_prefix(self, enc, prefix):
|
| 142 |
-
Ls, cache = prefix
|
| 143 |
-
if enc["seg"].count(0) != Ls: raise ValueError("prefix does not match this record's state")
|
| 144 |
-
return self._branch_probs(enc, cache)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/kev/model.py
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Decision model: causal LM backbone + block-causal branch mask + pointer readout."""
|
| 2 |
-
import copy, math, os, re
|
|
|
|
| 3 |
import torch
|
| 4 |
import torch.nn as nn
|
| 5 |
import torch.nn.functional as F
|
|
@@ -11,13 +18,33 @@ SPECIAL = ["<|fim_prefix|>", "<|fim_middle|>", "<|box_start|>", "<|box_end|>", "
|
|
| 11 |
# training context: state tokens, tokens per question branch, and the whole packed record. Frozen suites are admitted with
|
| 12 |
# this rule (kev.suite) and training applies it to records built on the fly, so train and eval see the same population.
|
| 13 |
MAX_STATE, MAX_BRANCH, MAX_PACKED = 384, 1024, 2048
|
| 14 |
-
#
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def training_context(max_state=MAX_STATE):
|
|
@@ -30,14 +57,12 @@ def training_context(max_state=MAX_STATE):
|
|
| 30 |
return {"max_state": max_state, "max_branch": MAX_BRANCH + extra, "max_packed": MAX_PACKED + extra}
|
| 31 |
|
| 32 |
|
| 33 |
-
def rows_per_pass(rows, prefix_len=0, budget=
|
| 34 |
"""How many causal rows one inference forward pass takes: as many as fit `budget` tokens counting the cached state
|
| 35 |
each row carries (prefix_len) plus its own tokens, at least one. Memory per pass is bounded by one maximal row however
|
| 36 |
many questions a request has, and the rows are independent, so the answers do not depend on the split. Kev-4B on MLX,
|
| 37 |
a 4.8k-token state with 64 questions: 24.7 GB peak in one pass, 8.9 GB one row at a time (and faster: the cost was
|
| 38 |
64 copies of the state cache)."""
|
| 39 |
-
if budget is None:
|
| 40 |
-
budget = int(os.environ.get("KEV_ROW_BUDGET", str(SERVE_MAX_PACKED)))
|
| 41 |
return max(1, budget // (prefix_len + max(len(r) for r in rows)))
|
| 42 |
|
| 43 |
|
|
@@ -47,7 +72,29 @@ class ContextOverflow(ValueError):
|
|
| 47 |
|
| 48 |
|
| 49 |
def load_tokenizer(name, revision=None):
|
| 50 |
-
return AutoTokenizer.from_pretrained(name, revision=revision)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def pad_id(tok):
|
|
@@ -200,20 +247,6 @@ SCORING_INTERFACE = ("encode", "forward", "probs", "probs_and_prefix", "probs_wi
|
|
| 200 |
"head", "backend", "dtype", "device", "hybrid", "option_isolation", "prefix_min_tokens")
|
| 201 |
|
| 202 |
|
| 203 |
-
def _load_backbone(name, revision, dtype, attn):
|
| 204 |
-
"""The text backbone. Qwen3.6-35B-A3B is a vision-language MoE checkpoint: its language model lives at
|
| 205 |
-
model.language_model, and the vision tower is not used for decisions."""
|
| 206 |
-
cfg = AutoConfig.from_pretrained(name, revision=revision)
|
| 207 |
-
if getattr(cfg, "model_type", None) == "qwen3_5_moe":
|
| 208 |
-
from transformers import Qwen3_5MoeForConditionalGeneration
|
| 209 |
-
full = Qwen3_5MoeForConditionalGeneration.from_pretrained(name, revision=revision, dtype=dtype, attn_implementation=attn)
|
| 210 |
-
lm = full.model.language_model
|
| 211 |
-
full.model.language_model = None
|
| 212 |
-
del full
|
| 213 |
-
return lm
|
| 214 |
-
return AutoModelForCausalLM.from_pretrained(name, revision=revision, dtype=dtype, attn_implementation=attn).model
|
| 215 |
-
|
| 216 |
-
|
| 217 |
class DecisionModel(nn.Module):
|
| 218 |
def __init__(self, name, tok, device, lora=None, revision=None, attn=None, head_dim=256, option_isolation=False, special_embeddings=False, lora_targets="all", dtype=torch.float32, state_bidir=False):
|
| 219 |
super().__init__()
|
|
@@ -221,7 +254,7 @@ class DecisionModel(nn.Module):
|
|
| 221 |
# eager on MPS/CPU (known-good with our float 4D mask); SDPA on CUDA (accepts arbitrary additive masks).
|
| 222 |
attn = attn or ("sdpa" if str(device).startswith("cuda") else "eager")
|
| 223 |
# dtype: fp32 for training and exact evaluation; bf16 is a serving option for large backbones (8B on a 32 GB Mac)
|
| 224 |
-
self.lm =
|
| 225 |
self.pad_id = pad_id(tok)
|
| 226 |
# hybrid backbones (Qwen3.5: Gated DeltaNet layers, recurrent) cannot honour the block-causal mask, so every
|
| 227 |
# question runs as its own causal row continuing from the state (rows_of). Attention-only backbones keep the
|
|
@@ -317,9 +350,7 @@ class DecisionModel(nn.Module):
|
|
| 317 |
rows_per_pass at a time; training keeps one batch (its batches are small and autograd needs the whole graph anyway).
|
| 318 |
With `cache`, the rows are branches continuing the cached state: the cache is replicated once per chunk (a copy,
|
| 319 |
so the caller's prefix stays pristine) and the cached tokens are marked real in the attention mask."""
|
| 320 |
-
|
| 321 |
-
# at once does not fit beside the frozen weights; rows are independent, so the split does not change the loss.
|
| 322 |
-
chunk = rows_per_pass([ids for ids, _ in rows], prefix_len)
|
| 323 |
out = []
|
| 324 |
for start in range(0, len(rows), chunk):
|
| 325 |
part = rows[start:start + chunk]
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# Copyright 2026 Jared Palmer
|
| 3 |
+
# Derived from Kev (https://github.com/jaredpalmer/kev).
|
| 4 |
+
# Modified by Nace.AI for Drex: bidirectional state
|
| 5 |
+
# attention, and configurable serving limits.
|
| 6 |
+
# See NOTICE and docs/upstream.md for attribution and revision information.
|
| 7 |
"""Decision model: causal LM backbone + block-causal branch mask + pointer readout."""
|
| 8 |
+
import copy, json, math, os, re
|
| 9 |
+
from pathlib import Path
|
| 10 |
import torch
|
| 11 |
import torch.nn as nn
|
| 12 |
import torch.nn.functional as F
|
|
|
|
| 18 |
# training context: state tokens, tokens per question branch, and the whole packed record. Frozen suites are admitted with
|
| 19 |
# this rule (kev.suite) and training applies it to records built on the fly, so train and eval see the same population.
|
| 20 |
MAX_STATE, MAX_BRANCH, MAX_PACKED = 384, 1024, 2048
|
| 21 |
+
# Position window of the weights. Serving recommends 16,384 and can be raised to this (32,768 for non-hybrid backbones such as DLM).
|
| 22 |
+
CONTEXT_LENGTH = 131072
|
| 23 |
+
NON_HYBRID_CONTEXT_LENGTH = 32768
|
| 24 |
+
RECOMMENDED_CONTEXT = 16384
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _serve_limit(name, default):
|
| 28 |
+
"""A serving cap from the environment. Unset keeps `default`. An invalid or too large value is an error."""
|
| 29 |
+
raw = os.environ.get(name)
|
| 30 |
+
if raw is None or raw == "":
|
| 31 |
+
return default
|
| 32 |
+
try:
|
| 33 |
+
value = int(raw)
|
| 34 |
+
except ValueError:
|
| 35 |
+
raise ValueError(f"{name}={raw!r} is not an integer") from None
|
| 36 |
+
if value < 1 or value > CONTEXT_LENGTH:
|
| 37 |
+
raise ValueError(f"{name}={value} must be between 1 and {CONTEXT_LENGTH}")
|
| 38 |
+
return value
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# KEV_CONTEXT selects the window. KEV_SERVE_MAX_STATE, KEV_SERVE_MAX_BRANCH, and KEV_SERVE_MAX_PACKED override one cap.
|
| 42 |
+
_context = _serve_limit("KEV_CONTEXT", RECOMMENDED_CONTEXT)
|
| 43 |
+
SERVE_MAX_STATE = _serve_limit("KEV_SERVE_MAX_STATE", _context)
|
| 44 |
+
SERVE_MAX_BRANCH = _serve_limit("KEV_SERVE_MAX_BRANCH", _context)
|
| 45 |
+
SERVE_MAX_PACKED = _serve_limit("KEV_SERVE_MAX_PACKED", _context)
|
| 46 |
+
# Training admission stays on the historical 8,192-token row, so a wider serving window does not widen training.
|
| 47 |
+
MAX_TRAIN_STATE = 8192 - (MAX_BRANCH - MAX_STATE)
|
| 48 |
|
| 49 |
|
| 50 |
def training_context(max_state=MAX_STATE):
|
|
|
|
| 57 |
return {"max_state": max_state, "max_branch": MAX_BRANCH + extra, "max_packed": MAX_PACKED + extra}
|
| 58 |
|
| 59 |
|
| 60 |
+
def rows_per_pass(rows, prefix_len=0, budget=SERVE_MAX_PACKED):
|
| 61 |
"""How many causal rows one inference forward pass takes: as many as fit `budget` tokens counting the cached state
|
| 62 |
each row carries (prefix_len) plus its own tokens, at least one. Memory per pass is bounded by one maximal row however
|
| 63 |
many questions a request has, and the rows are independent, so the answers do not depend on the split. Kev-4B on MLX,
|
| 64 |
a 4.8k-token state with 64 questions: 24.7 GB peak in one pass, 8.9 GB one row at a time (and faster: the cost was
|
| 65 |
64 copies of the state cache)."""
|
|
|
|
|
|
|
| 66 |
return max(1, budget // (prefix_len + max(len(r) for r in rows)))
|
| 67 |
|
| 68 |
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def load_tokenizer(name, revision=None):
|
| 75 |
+
return AutoTokenizer.from_pretrained(name, revision=revision, trust_remote_code=False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def load_backbone(name, revision, dtype, attn):
|
| 79 |
+
"""Load a local safetensors encoder without executing checkpoint Python code."""
|
| 80 |
+
cfg = AutoConfig.from_pretrained(name, revision=revision, trust_remote_code=False)
|
| 81 |
+
if getattr(cfg, "model_type", None) == "qwen3_5_moe":
|
| 82 |
+
# Qwen3.6-35B-A3B is a vision-language checkpoint: the language model sits at model.language_model and the
|
| 83 |
+
# vision tower is not used for decisions, so it is dropped before the weights move to the device.
|
| 84 |
+
from transformers import Qwen3_5MoeForConditionalGeneration
|
| 85 |
+
|
| 86 |
+
full = Qwen3_5MoeForConditionalGeneration.from_pretrained(
|
| 87 |
+
name, config=cfg, revision=revision, dtype=dtype, attn_implementation=attn,
|
| 88 |
+
trust_remote_code=False, use_safetensors=True, weights_only=True,
|
| 89 |
+
)
|
| 90 |
+
language_model = full.model.language_model
|
| 91 |
+
full.model.language_model = None
|
| 92 |
+
del full
|
| 93 |
+
return language_model
|
| 94 |
+
return AutoModelForCausalLM.from_pretrained(
|
| 95 |
+
name, config=cfg, revision=revision, dtype=dtype, attn_implementation=attn,
|
| 96 |
+
trust_remote_code=False, use_safetensors=True, weights_only=True,
|
| 97 |
+
).model
|
| 98 |
|
| 99 |
|
| 100 |
def pad_id(tok):
|
|
|
|
| 247 |
"head", "backend", "dtype", "device", "hybrid", "option_isolation", "prefix_min_tokens")
|
| 248 |
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
class DecisionModel(nn.Module):
|
| 251 |
def __init__(self, name, tok, device, lora=None, revision=None, attn=None, head_dim=256, option_isolation=False, special_embeddings=False, lora_targets="all", dtype=torch.float32, state_bidir=False):
|
| 252 |
super().__init__()
|
|
|
|
| 254 |
# eager on MPS/CPU (known-good with our float 4D mask); SDPA on CUDA (accepts arbitrary additive masks).
|
| 255 |
attn = attn or ("sdpa" if str(device).startswith("cuda") else "eager")
|
| 256 |
# dtype: fp32 for training and exact evaluation; bf16 is a serving option for large backbones (8B on a 32 GB Mac)
|
| 257 |
+
self.lm = load_backbone(name, revision, dtype, attn)
|
| 258 |
self.pad_id = pad_id(tok)
|
| 259 |
# hybrid backbones (Qwen3.5: Gated DeltaNet layers, recurrent) cannot honour the block-causal mask, so every
|
| 260 |
# question runs as its own causal row continuing from the state (rows_of). Attention-only backbones keep the
|
|
|
|
| 350 |
rows_per_pass at a time; training keeps one batch (its batches are small and autograd needs the whole graph anyway).
|
| 351 |
With `cache`, the rows are branches continuing the cached state: the cache is replicated once per chunk (a copy,
|
| 352 |
so the caller's prefix stays pristine) and the cached tokens are marked real in the attention mask."""
|
| 353 |
+
chunk = len(rows) if self.training else rows_per_pass([ids for ids, _ in rows], prefix_len)
|
|
|
|
|
|
|
| 354 |
out = []
|
| 355 |
for start in range(0, len(rows), chunk):
|
| 356 |
part = rows[start:start + chunk]
|
code/kev/serve.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
"""FastAPI sidecar for the playground: loads one checkpoint, exposes prefill-only decisions.
|
| 2 |
-
|
| 3 |
-
Run: uv run --extra serve python -m kev.serve --run runs/kev --port 8008
|
| 4 |
-
|
| 5 |
-
TypeSafe-compatible: POST /v1/systemone, GET /v1/models, the `x-typesafe-request-id` response header, and bearer auth
|
| 6 |
-
when KEV_API_KEY is set (unset = open server, the local default). Demo extras: POST /v1/systemone/permute (one Choice
|
| 7 |
-
under several option orders) and POST /v1/systemone/separate (each question in its own pass, for the packed-vs-separate
|
| 8 |
-
comparison). KEV_PREFIX_CACHE / KEV_PREFIX_MIN_TOKENS size the state-prefix cache; KEV_DATE_FACTS=1 opts into the
|
| 9 |
-
date preprocessing (api.with_date_facts). Backend and precision follow LoadOptions (KEV_BACKEND, KEV_DTYPE, ...): on Apple
|
| 10 |
-
Silicon the hybrid Qwen3.5 checkpoints run on MLX by default, elsewhere on torch in bf16.
|
| 11 |
-
"""
|
| 12 |
-
import argparse, hmac, os, random, threading, time, uuid
|
| 13 |
-
import torch
|
| 14 |
-
from dataclasses import dataclass, field, replace
|
| 15 |
-
from fastapi import FastAPI, HTTPException
|
| 16 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
-
from fastapi.responses import JSONResponse
|
| 18 |
-
from pydantic import BaseModel, Field
|
| 19 |
-
from .api import SystemOneRequest, to_record, to_answers, output_tokens, with_date_facts
|
| 20 |
-
from .checkpoint import Checkpoint, LoadOptions, is_hub_id
|
| 21 |
-
from .device import default_device, sync
|
| 22 |
-
from .model import SERVE_MAX_BRANCH, SERVE_MAX_STATE
|
| 23 |
-
|
| 24 |
-
PREFIX_CACHE_SIZE = int(os.environ.get("KEV_PREFIX_CACHE", "4")) # states kept (KV + hidden); 0 disables
|
| 25 |
-
PREFIX_MIN_TOKENS = os.environ.get("KEV_PREFIX_MIN_TOKENS") # states shorter than this are not cached; default = the model's prefix_min_tokens (0 for hybrid backbones and MLX, 384 for attention-only torch models)
|
| 26 |
-
DATE_FACTS = os.environ.get("KEV_DATE_FACTS", "0") == "1"
|
| 27 |
-
API_KEY = os.environ.get("KEV_API_KEY") # unset = open server; set = require Authorization: Bearer <key>, as the TypeSafe clients always send
|
| 28 |
-
MODEL_NAMES = ("kev-latest", "jev-latest") # both names serve this checkpoint; jev-latest is the TypeSafe SDK default model, so an unconfigured client works
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
@dataclass
|
| 32 |
-
class Server:
|
| 33 |
-
"""The loaded checkpoint and the state-prefix cache shared by every request (one model, one lock)."""
|
| 34 |
-
checkpoint: Checkpoint
|
| 35 |
-
tok: object
|
| 36 |
-
model: object
|
| 37 |
-
device: str
|
| 38 |
-
lock: threading.Lock = field(default_factory=threading.Lock)
|
| 39 |
-
prefix_cache: dict = field(default_factory=dict) # (state token ids, option_isolation) -> prefix, in LRU order
|
| 40 |
-
prefix_hits: int = 0
|
| 41 |
-
prefix_misses: int = 0
|
| 42 |
-
release_date: str = field(default="") # for the TypeSafe model card; resolved once (may ask the Hub)
|
| 43 |
-
|
| 44 |
-
def __post_init__(self):
|
| 45 |
-
self.release_date = self.release_date or self.checkpoint.release_date()
|
| 46 |
-
|
| 47 |
-
@property
|
| 48 |
-
def prefix_min_tokens(self):
|
| 49 |
-
return int(PREFIX_MIN_TOKENS) if PREFIX_MIN_TOKENS else self.model.prefix_min_tokens
|
| 50 |
-
|
| 51 |
-
def probs(self, rec):
|
| 52 |
-
"""One forward pass. The state prefix (tokens up to the first question) is cached across requests, so a repeated
|
| 53 |
-
state only pays for its question branches. Exact: the state's activations do not depend on the branches."""
|
| 54 |
-
try: enc = self.model.encode(self.tok, rec, max_state=SERVE_MAX_STATE, max_branch=SERVE_MAX_BRANCH)
|
| 55 |
-
except ValueError as e: raise HTTPException(422, str(e))
|
| 56 |
-
Ls = enc["seg"].count(0); key = (tuple(enc["ids"][:Ls]), bool(enc.get("option_isolation")))
|
| 57 |
-
cache, hit = self.prefix_cache, False
|
| 58 |
-
with self.lock:
|
| 59 |
-
sync(self.device); t = time.time()
|
| 60 |
-
eligible = PREFIX_CACHE_SIZE and Ls >= self.prefix_min_tokens
|
| 61 |
-
if eligible and key in cache:
|
| 62 |
-
prefix = cache.pop(key) # pop + reinsert = LRU order
|
| 63 |
-
ps = self.model.probs_with_prefix(enc, prefix); cache[key] = prefix
|
| 64 |
-
self.prefix_hits += 1; hit = True
|
| 65 |
-
elif eligible:
|
| 66 |
-
ps, prefix = self.model.probs_and_prefix(enc) # one pass, and the state prefix is kept for next time
|
| 67 |
-
cache[key] = prefix
|
| 68 |
-
while len(cache) > PREFIX_CACHE_SIZE: cache.pop(next(iter(cache)))
|
| 69 |
-
self.prefix_misses += 1
|
| 70 |
-
else:
|
| 71 |
-
ps = self.model.probs(enc)
|
| 72 |
-
sync(self.device); dt = time.time() - t
|
| 73 |
-
return [p.tolist() for p in ps], {"tokens": len(enc["ids"]), "state_tokens": Ls, "latency_ms": round(dt * 1000, 1), "prefix_cache_hit": hit}
|
| 74 |
-
|
| 75 |
-
def answer(self, req):
|
| 76 |
-
"""The /v1/systemone response body for one request."""
|
| 77 |
-
rec, meta = to_record(prepare(req))
|
| 78 |
-
ps, m = self.probs(rec)
|
| 79 |
-
answers = to_answers(ps, meta)
|
| 80 |
-
return {"model": req.model, "answers": answers, "usage": {"input_tokens": m["tokens"], "output_tokens": output_tokens(self.tok, answers)}, "latency_ms": m["latency_ms"]}
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def prepare(req):
|
| 84 |
-
"""Opt-in preprocessing applied to every request before the model sees it."""
|
| 85 |
-
return req.model_copy(update={"state": with_date_facts(req.state)}) if DATE_FACTS else req
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
app = FastAPI(title="kev")
|
| 89 |
-
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], expose_headers=["x-typesafe-request-id"])
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
@app.middleware("http")
|
| 93 |
-
async def typesafe(request, call_next):
|
| 94 |
-
"""Bearer auth (when API_KEY is set) and the request id every TypeSafe client reads off the response."""
|
| 95 |
-
if API_KEY and request.url.path.startswith("/v1") and not hmac.compare_digest(request.headers.get("authorization", ""), f"Bearer {API_KEY}"):
|
| 96 |
-
resp = JSONResponse({"detail": "missing or invalid API key; send Authorization: Bearer <KEV_API_KEY>"}, 401, {"www-authenticate": "Bearer"})
|
| 97 |
-
else:
|
| 98 |
-
resp = await call_next(request)
|
| 99 |
-
resp.headers["x-typesafe-request-id"] = request.headers.get("x-typesafe-request-id") or uuid.uuid4().hex
|
| 100 |
-
return resp
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def server() -> Server:
|
| 104 |
-
return app.state.server
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
@app.post("/v1/systemone")
|
| 108 |
-
def systemone(req: SystemOneRequest):
|
| 109 |
-
"""TypeSafe-compatible endpoint: typed questions in, typed answers out, one prefill pass."""
|
| 110 |
-
return server().answer(req)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
class PermuteSystemOne(BaseModel):
|
| 114 |
-
request: SystemOneRequest
|
| 115 |
-
question: str
|
| 116 |
-
n_perm: int = Field(default=6, ge=1, le=64) # each order is a forward pass; 0 divided by nothing, unbounded counts ran forever (#30)
|
| 117 |
-
seed: int = 0
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
@app.post("/v1/systemone/permute")
|
| 121 |
-
def systemone_permute(r: PermuteSystemOne):
|
| 122 |
-
"""Re-run one Choice question under n_perm option orders. Returns per-order probabilities keyed by option name."""
|
| 123 |
-
q = r.request.questions.get(r.question)
|
| 124 |
-
if q is None or q.type != "choice": raise HTTPException(422, "question must be an existing choice question")
|
| 125 |
-
rng = random.Random(r.seed); keys = list(q.criteria); runs = []
|
| 126 |
-
for i in range(r.n_perm):
|
| 127 |
-
order = list(keys)
|
| 128 |
-
if i > 0: rng.shuffle(order)
|
| 129 |
-
one = r.request.model_copy(update={"questions": {r.question: q.model_copy(update={"criteria": {k: q.criteria[k] for k in order}})}})
|
| 130 |
-
resp = server().answer(one); a = resp["answers"][r.question]
|
| 131 |
-
runs.append({"order": order, "probabilities": a["probabilities"], "choice": a["choice"], "latency_ms": resp["latency_ms"]})
|
| 132 |
-
spread = {k: max(x["probabilities"][k] for x in runs) - min(x["probabilities"][k] for x in runs) for k in keys}
|
| 133 |
-
return {"runs": runs, "argmax_stable": len({x["choice"] for x in runs}) == 1, "spread": spread}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
@app.post("/v1/systemone/separate")
|
| 137 |
-
def systemone_separate(req: SystemOneRequest):
|
| 138 |
-
"""Answer each question in its own request against the same state (N passes). For packed-vs-separate comparison."""
|
| 139 |
-
parts = [server().answer(req.model_copy(update={"questions": {qid: q}})) for qid, q in req.questions.items()]
|
| 140 |
-
answers = {qid: a for p in parts for qid, a in p["answers"].items()}
|
| 141 |
-
return {"model": req.model, "answers": answers,
|
| 142 |
-
"usage": {"input_tokens": sum(p["usage"]["input_tokens"] for p in parts), "output_tokens": output_tokens(server().tok, answers)},
|
| 143 |
-
"latency_ms": round(sum(p["latency_ms"] for p in parts), 1)}
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
@app.get("/v1/models")
|
| 147 |
-
def models():
|
| 148 |
-
"""One TypeSafe model card (name, description, release_date) per accepted model name, plus the Kev serving details
|
| 149 |
-
a client may ignore: the run, the base, the device, the backend and precision, the temperature, prefix-cache stats."""
|
| 150 |
-
s = server()
|
| 151 |
-
ck, meta = s.checkpoint, s.checkpoint.meta
|
| 152 |
-
card = {"description": f"Kev pointer head on {meta.base}, serving {ck.requested} at temperature {s.model.head.temperature:.2f}",
|
| 153 |
-
"release_date": s.release_date,
|
| 154 |
-
"run": ck.requested, "base": meta.base, "lora": meta.lora, "device": s.device, "backend": s.model.backend, "dtype": s.model.dtype,
|
| 155 |
-
"temperature": s.model.head.temperature,
|
| 156 |
-
"prefix_cache": {"size": PREFIX_CACHE_SIZE, "min_state_tokens": s.prefix_min_tokens, "hits": s.prefix_hits,
|
| 157 |
-
"misses": s.prefix_misses, "cached_states": len(s.prefix_cache)}}
|
| 158 |
-
return {"models": [{"name": name, **card} for name in MODEL_NAMES]}
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
def main():
|
| 162 |
-
ap = argparse.ArgumentParser()
|
| 163 |
-
ap.add_argument("--run", default="runs/kev")
|
| 164 |
-
ap.add_argument("--fallback", default="runs/smoke")
|
| 165 |
-
ap.add_argument("--port", type=int, default=8008)
|
| 166 |
-
a = ap.parse_args()
|
| 167 |
-
run = a.run if is_hub_id(a.run) or os.path.exists(f"{a.run}/head.pt") else a.fallback
|
| 168 |
-
if run != a.run: print(f"{a.run} not found, falling back to {run}")
|
| 169 |
-
dev = default_device()
|
| 170 |
-
opts = LoadOptions.from_env()
|
| 171 |
-
if dev == "mps" and opts.attn is None: opts = replace(opts, attn="sdpa") # serving default on Apple GPUs (parity measured)
|
| 172 |
-
if dev != "cpu" and opts.dtype is None: opts = replace(opts, dtype=torch.bfloat16) # serving default: 2-4.5x faster than fp32 on an L4, same answers (LoadOptions.dtype); KEV_DTYPE=fp32 for the exact path
|
| 173 |
-
if opts.backend is None: opts = replace(opts, backend="auto") # serving default: MLX for the hybrid Qwen3.5 checkpoints on Apple Silicon (LoadOptions.backend); KEV_BACKEND=torch to decline
|
| 174 |
-
ck = Checkpoint(run)
|
| 175 |
-
tok, model = ck.load(dev, opts)
|
| 176 |
-
app.state.server = Server(ck, tok, model, dev)
|
| 177 |
-
print(f"serving {ck.requested} ({ck.path}) on {dev} via {model.backend} ({model.dtype}) :{a.port}") # /v1/models reports the run as given, not the resolved cache path
|
| 178 |
-
import uvicorn
|
| 179 |
-
uvicorn.run(app, host="127.0.0.1", port=a.port)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
if __name__ == "__main__":
|
| 183 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/kev/serve_batch.py
DELETED
|
@@ -1,85 +0,0 @@
|
|
| 1 |
-
"""Same /v1/systemone server as kev.serve, but concurrent requests are batched into shared forward passes.
|
| 2 |
-
|
| 3 |
-
The reference server serializes requests behind one lock (one forward per request, ~100-250 ms of mostly Python/kernel-launch
|
| 4 |
-
overhead on the 35B-A3B MoE). Here a batcher thread gathers whatever arrived within a few ms, groups records of similar
|
| 5 |
-
length under a token budget and runs DecisionModel.forward_batch once per group (right-padded causal rows, so the answers match
|
| 6 |
-
the one-at-a-time path up to bf16 noise). Prefix cache is skipped (single-question records recompute nothing extra).
|
| 7 |
-
usage: python -m kev.serve_batch --run <merged ckpt> --port 8100 env: KEV_BATCH_WAIT_MS (4), KEV_BATCH_MAX (48), KEV_ROW_BUDGET (tokens/pass)
|
| 8 |
-
"""
|
| 9 |
-
import os, queue, threading, time
|
| 10 |
-
import torch
|
| 11 |
-
import torch.nn.functional as F
|
| 12 |
-
from fastapi import HTTPException
|
| 13 |
-
from . import serve as S
|
| 14 |
-
from .model import SERVE_MAX_BRANCH, SERVE_MAX_STATE
|
| 15 |
-
|
| 16 |
-
WAIT = float(os.environ.get("KEV_BATCH_WAIT_MS", "4")) / 1000
|
| 17 |
-
MAXB = int(os.environ.get("KEV_BATCH_MAX", "48"))
|
| 18 |
-
BUDGET = int(os.environ.get("KEV_ROW_BUDGET", "32768"))
|
| 19 |
-
Q = queue.Queue()
|
| 20 |
-
STATS = {"batches": 0, "records": 0, "max_batch": 0}
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _groups(items):
|
| 24 |
-
"""items sorted by token length; yield groups with n * max_len <= BUDGET (at least one item)."""
|
| 25 |
-
items = sorted(items, key=lambda it: len(it["enc"]["ids"]))
|
| 26 |
-
cur = []
|
| 27 |
-
for it in items:
|
| 28 |
-
L = len(it["enc"]["ids"])
|
| 29 |
-
if cur and (len(cur) + 1) * L > BUDGET: yield cur; cur = []
|
| 30 |
-
cur.append(it)
|
| 31 |
-
if cur: yield cur
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def _run(model, group):
|
| 35 |
-
encs = [it["enc"] for it in group]
|
| 36 |
-
try:
|
| 37 |
-
with torch.no_grad():
|
| 38 |
-
out = model.forward_batch(encs)
|
| 39 |
-
res = [[F.softmax(z, -1).cpu().tolist() for z in zs] for zs in out]
|
| 40 |
-
for it, r in zip(group, res): it["res"] = r
|
| 41 |
-
except Exception as e: # e.g. OOM on a big group: retry one by one so a single bad request cannot sink the batch
|
| 42 |
-
torch.cuda.empty_cache()
|
| 43 |
-
for it in group:
|
| 44 |
-
try:
|
| 45 |
-
with torch.no_grad(): it["res"] = [F.softmax(z, -1).cpu().tolist() for z in model.forward(it["enc"])]
|
| 46 |
-
except Exception as e2: it["err"] = e2
|
| 47 |
-
for it in group: it["ev"].set()
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def batcher(model):
|
| 51 |
-
while True:
|
| 52 |
-
first = Q.get(); items = [first]; t_end = time.time() + WAIT
|
| 53 |
-
while len(items) < MAXB:
|
| 54 |
-
left = t_end - time.time()
|
| 55 |
-
if left <= 0: break
|
| 56 |
-
try: items.append(Q.get(timeout=left))
|
| 57 |
-
except queue.Empty: break
|
| 58 |
-
STATS["batches"] += 1; STATS["records"] += len(items); STATS["max_batch"] = max(STATS["max_batch"], len(items))
|
| 59 |
-
for g in _groups(items): _run(model, g)
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def probs(self, rec):
|
| 63 |
-
try: enc = self.model.encode(self.tok, rec, max_state=SERVE_MAX_STATE, max_branch=SERVE_MAX_BRANCH)
|
| 64 |
-
except ValueError as e: raise HTTPException(422, str(e))
|
| 65 |
-
it = {"enc": enc, "ev": threading.Event()}; t = time.time(); Q.put(it); it["ev"].wait()
|
| 66 |
-
if "err" in it: raise HTTPException(500, f"{type(it['err']).__name__}: {str(it['err'])[:200]}")
|
| 67 |
-
return it["res"], {"tokens": len(enc["ids"]), "state_tokens": enc["seg"].count(0), "latency_ms": round((time.time() - t) * 1000, 1), "prefix_cache_hit": False}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
S.Server.probs = probs
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
@S.app.get("/batch_stats")
|
| 74 |
-
def batch_stats(): return STATS
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
@S.app.on_event("startup")
|
| 78 |
-
async def _startup():
|
| 79 |
-
import anyio
|
| 80 |
-
anyio.to_thread.current_default_thread_limiter().total_tokens = 512 # many concurrent clients -> many waiting handler threads
|
| 81 |
-
threading.Thread(target=batcher, args=(S.server().model,), daemon=True).start()
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
if __name__ == "__main__":
|
| 85 |
-
S.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code/pyproject.toml
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
[build-system]
|
| 2 |
-
requires = ["setuptools>=77"]
|
| 3 |
-
build-backend = "setuptools.build_meta"
|
| 4 |
-
|
| 5 |
-
[project]
|
| 6 |
-
name = "kev"
|
| 7 |
-
version = "0.1.0"
|
| 8 |
-
description = "Decision-model loader and TypeSafe-compatible /v1/systemone server"
|
| 9 |
-
requires-python = ">=3.12"
|
| 10 |
-
dependencies = [
|
| 11 |
-
"accelerate>=1.15.0",
|
| 12 |
-
"numpy>=2.0",
|
| 13 |
-
"peft>=0.21",
|
| 14 |
-
"pydantic>=2.9",
|
| 15 |
-
"torch>=2.6",
|
| 16 |
-
"transformers>=5.17,<6",
|
| 17 |
-
"huggingface_hub",
|
| 18 |
-
]
|
| 19 |
-
|
| 20 |
-
[project.optional-dependencies]
|
| 21 |
-
serve = ["fastapi>=0.115", "uvicorn>=0.30"]
|
| 22 |
-
|
| 23 |
-
[tool.setuptools]
|
| 24 |
-
packages = ["kev"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
examples/request.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"state": {
|
| 3 |
+
"ticket": "I was charged twice for the same order. Please refund the extra payment."
|
| 4 |
+
},
|
| 5 |
+
"questions": {
|
| 6 |
+
"team": {
|
| 7 |
+
"type": "choice",
|
| 8 |
+
"instructions": "Which team should handle this ticket?",
|
| 9 |
+
"criteria": {
|
| 10 |
+
"billing": "Payments, charges, and refunds",
|
| 11 |
+
"technical": "Bugs and outages",
|
| 12 |
+
"other": "Anything else"
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"refund": {
|
| 16 |
+
"type": "noul",
|
| 17 |
+
"instructions": "Does the customer explicitly ask for a refund?"
|
| 18 |
+
},
|
| 19 |
+
"urgency": {
|
| 20 |
+
"type": "score",
|
| 21 |
+
"instructions": "How urgent is this ticket?",
|
| 22 |
+
"criteria": [
|
| 23 |
+
"Routine",
|
| 24 |
+
"Soon",
|
| 25 |
+
"Urgent"
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
inference.py
CHANGED
|
@@ -1,61 +1,150 @@
|
|
| 1 |
-
"""Score System One
|
| 2 |
|
| 3 |
-
pip install -
|
| 4 |
-
python inference.py
|
| 5 |
-
python inference.py --
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
"""
|
| 10 |
import argparse
|
| 11 |
import json
|
|
|
|
| 12 |
import sys
|
|
|
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
import torch
|
| 16 |
|
| 17 |
sys.path.insert(0, str(Path(__file__).resolve().parent / "code"))
|
| 18 |
-
from kev.api import SystemOneRequest, to_answers, to_record # noqa: E402
|
| 19 |
-
from kev.checkpoint import
|
| 20 |
-
from kev.model import SERVE_MAX_BRANCH, SERVE_MAX_STATE # noqa: E402
|
| 21 |
|
| 22 |
EXAMPLE = {
|
| 23 |
-
"state": {
|
|
|
|
|
|
|
| 24 |
"questions": {
|
| 25 |
-
"
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
},
|
| 28 |
}
|
| 29 |
|
| 30 |
|
| 31 |
-
def load(
|
| 32 |
-
|
| 33 |
-
if not
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def decide(tok, model, request):
|
| 44 |
-
|
| 45 |
-
|
| 46 |
with torch.no_grad():
|
| 47 |
-
|
| 48 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
def main():
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
tok, model = load(
|
| 57 |
-
|
| 58 |
-
print(json.dumps(decide(tok, model,
|
| 59 |
|
| 60 |
|
| 61 |
if __name__ == "__main__":
|
|
|
|
| 1 |
+
"""Score a System One request with Drex v2.0.
|
| 2 |
|
| 3 |
+
pip install -r requirements.txt
|
| 4 |
+
python inference.py
|
| 5 |
+
python inference.py --request examples/request.json
|
| 6 |
|
| 7 |
+
The model returns a probability for every option in one forward pass.
|
| 8 |
+
A state or question past the context limit returns an error, so the score covers the whole document.
|
| 9 |
"""
|
| 10 |
import argparse
|
| 11 |
import json
|
| 12 |
+
import math
|
| 13 |
import sys
|
| 14 |
+
import time
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
import torch
|
| 18 |
|
| 19 |
sys.path.insert(0, str(Path(__file__).resolve().parent / "code"))
|
| 20 |
+
from kev.api import SystemOneRequest, output_tokens, to_answers, to_record # noqa: E402
|
| 21 |
+
from kev.checkpoint import load as _load # noqa: E402
|
| 22 |
+
from kev.model import NON_HYBRID_CONTEXT_LENGTH, SERVE_MAX_BRANCH, SERVE_MAX_PACKED, SERVE_MAX_STATE # noqa: E402
|
| 23 |
|
| 24 |
EXAMPLE = {
|
| 25 |
+
"state": {
|
| 26 |
+
"ticket": "I was charged twice for the same order. Please refund the extra payment.",
|
| 27 |
+
},
|
| 28 |
"questions": {
|
| 29 |
+
"team": {
|
| 30 |
+
"type": "choice",
|
| 31 |
+
"instructions": "Which team should handle this ticket?",
|
| 32 |
+
"criteria": {
|
| 33 |
+
"billing": "Payments, charges, and refunds",
|
| 34 |
+
"technical": "Bugs and outages",
|
| 35 |
+
"other": "Anything else",
|
| 36 |
+
},
|
| 37 |
+
},
|
| 38 |
+
"refund": {
|
| 39 |
+
"type": "noul",
|
| 40 |
+
"instructions": "Does the customer explicitly ask for a refund?",
|
| 41 |
+
},
|
| 42 |
+
"urgency": {
|
| 43 |
+
"type": "score",
|
| 44 |
+
"instructions": "How urgent is this ticket?",
|
| 45 |
+
"criteria": ["Routine", "Soon", "Urgent"],
|
| 46 |
+
},
|
| 47 |
},
|
| 48 |
}
|
| 49 |
|
| 50 |
|
| 51 |
+
def load(path):
|
| 52 |
+
tok, model = _load(path)
|
| 53 |
+
if not model.hybrid and max(SERVE_MAX_STATE, SERVE_MAX_BRANCH, SERVE_MAX_PACKED) > NON_HYBRID_CONTEXT_LENGTH:
|
| 54 |
+
raise ValueError(f"this model supports at most {NON_HYBRID_CONTEXT_LENGTH} tokens; lower KEV_CONTEXT")
|
| 55 |
+
return tok, model
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _encode(tok, model, request):
|
| 59 |
+
if not isinstance(request, dict) or "requests" in request:
|
| 60 |
+
raise ValueError("each decision must be an object with state and questions, without a requests wrapper")
|
| 61 |
+
rec, meta = to_record(SystemOneRequest.model_validate(request))
|
| 62 |
+
enc = model.encode(tok, rec, max_state=SERVE_MAX_STATE, max_branch=SERVE_MAX_BRANCH, strict=True)
|
| 63 |
+
return enc, meta
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _result(tok, enc, meta, probs, latency_ms):
|
| 67 |
+
if len(probs) != len(meta) or any(len(row) != len(question["keys"]) or
|
| 68 |
+
any(not math.isfinite(value) for value in row)
|
| 69 |
+
for row, question in zip(probs, meta)):
|
| 70 |
+
raise RuntimeError("model readout is incomplete or non-finite")
|
| 71 |
+
answers = to_answers(probs, meta)
|
| 72 |
+
return {
|
| 73 |
+
"answers": answers,
|
| 74 |
+
"usage": {"input_tokens": len(enc["ids"]), "output_tokens": _run_scorer(output_tokens, tok, answers)},
|
| 75 |
+
"latency_ms": latency_ms,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _run_scorer(fn, *args):
|
| 80 |
+
try:
|
| 81 |
+
return fn(*args)
|
| 82 |
+
except ValueError as exc:
|
| 83 |
+
raise RuntimeError("model scoring failed") from exc
|
| 84 |
|
| 85 |
|
| 86 |
def decide(tok, model, request):
|
| 87 |
+
enc, meta = _encode(tok, model, request)
|
| 88 |
+
started = time.perf_counter()
|
| 89 |
with torch.no_grad():
|
| 90 |
+
probs = [p.float().tolist() for p in _run_scorer(model.probs, enc)]
|
| 91 |
+
return _result(tok, enc, meta, probs, round((time.perf_counter() - started) * 1000, 1))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def decide_batch(tok, model, requests):
|
| 95 |
+
if not isinstance(requests, list) or not requests:
|
| 96 |
+
raise ValueError("requests must be a non-empty list")
|
| 97 |
+
if len(requests) > 1024:
|
| 98 |
+
raise ValueError("requests exceeds 1024 items")
|
| 99 |
+
prepared = [_encode(tok, model, request) for request in requests]
|
| 100 |
+
results = [None] * len(prepared)
|
| 101 |
+
index = 0
|
| 102 |
+
while index < len(prepared):
|
| 103 |
+
enc, _meta = prepared[index]
|
| 104 |
+
if len(enc["ids"]) > SERVE_MAX_PACKED:
|
| 105 |
+
started = time.perf_counter()
|
| 106 |
+
with torch.no_grad():
|
| 107 |
+
probs = [p.float().tolist() for p in _run_scorer(model.probs, enc)]
|
| 108 |
+
results[index] = _result(tok, enc, _meta, probs, round((time.perf_counter() - started) * 1000, 1))
|
| 109 |
+
index += 1
|
| 110 |
+
continue
|
| 111 |
+
end = index
|
| 112 |
+
longest = 0
|
| 113 |
+
# Bound padded tokens and the quadratic attention mask before batching.
|
| 114 |
+
while end < len(prepared) and end - index < 8:
|
| 115 |
+
size = len(prepared[end][0]["ids"])
|
| 116 |
+
if size > SERVE_MAX_PACKED:
|
| 117 |
+
break
|
| 118 |
+
padded = (max(longest, size) + 127) // 128 * 128
|
| 119 |
+
count = end - index + 1
|
| 120 |
+
if count * padded > SERVE_MAX_PACKED or count * padded * padded > SERVE_MAX_PACKED ** 2:
|
| 121 |
+
break
|
| 122 |
+
longest = max(longest, size)
|
| 123 |
+
end += 1
|
| 124 |
+
if end == index:
|
| 125 |
+
end = index + 1
|
| 126 |
+
group = prepared[index:end]
|
| 127 |
+
started = time.perf_counter()
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
logits = _run_scorer(model.forward_batch, [item[0] for item in group])
|
| 130 |
+
latency_ms = round((time.perf_counter() - started) * 1000, 1)
|
| 131 |
+
if len(logits) != len(group):
|
| 132 |
+
raise RuntimeError("batch readout did not return one result per request")
|
| 133 |
+
for offset, ((enc, meta), row) in enumerate(zip(group, logits)):
|
| 134 |
+
probs = [torch.softmax(item.float(), dim=-1).tolist() for item in row]
|
| 135 |
+
results[index + offset] = _result(tok, enc, meta, probs, latency_ms)
|
| 136 |
+
index = end
|
| 137 |
+
return results
|
| 138 |
|
| 139 |
|
| 140 |
def main():
|
| 141 |
+
parser = argparse.ArgumentParser(description="Score one System One request with a Drex model.")
|
| 142 |
+
parser.add_argument("--model", default=str(Path(__file__).resolve().parent), help="Checkpoint directory (default: this directory)")
|
| 143 |
+
parser.add_argument("--request", help="JSON file with one System One request")
|
| 144 |
+
args = parser.parse_args()
|
| 145 |
+
tok, model = load(args.model)
|
| 146 |
+
request = json.loads(Path(args.request).read_text()) if args.request else EXAMPLE
|
| 147 |
+
print(json.dumps(decide(tok, model, request), indent=2))
|
| 148 |
|
| 149 |
|
| 150 |
if __name__ == "__main__":
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.6,<2.9
|
| 2 |
+
transformers>=5.17,<6
|
| 3 |
+
pydantic>=2
|
| 4 |
+
huggingface_hub>=0.34
|
serve.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local System One server for Drex v2.0.
|
| 2 |
+
|
| 3 |
+
python serve.py --port 8000
|
| 4 |
+
|
| 5 |
+
POST /v1/systemone accepts one decision, or ``requests``: a list of decisions.
|
| 6 |
+
Several decisions that fit share one forward. This process scores the pointer head
|
| 7 |
+
of the checkpoint.
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import threading
|
| 12 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from inference import decide, decide_batch, load
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def reject_nonfinite_json(value):
|
| 19 |
+
raise ValueError(f"non-finite JSON number: {value}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def make_handler(tok, model, model_name):
|
| 23 |
+
lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
class Handler(BaseHTTPRequestHandler):
|
| 26 |
+
protocol_version = "HTTP/1.1"
|
| 27 |
+
|
| 28 |
+
def log_message(self, fmt, *args):
|
| 29 |
+
print("%s %s" % (self.address_string(), fmt % args))
|
| 30 |
+
|
| 31 |
+
def _send(self, status, payload):
|
| 32 |
+
body = json.dumps(payload).encode()
|
| 33 |
+
self.send_response(status)
|
| 34 |
+
self.send_header("Content-Type", "application/json")
|
| 35 |
+
self.send_header("Content-Length", str(len(body)))
|
| 36 |
+
if self.close_connection:
|
| 37 |
+
self.send_header("Connection", "close")
|
| 38 |
+
self.end_headers()
|
| 39 |
+
self.wfile.write(body)
|
| 40 |
+
|
| 41 |
+
def do_GET(self):
|
| 42 |
+
if self.path.rstrip("/") == "/health":
|
| 43 |
+
self._send(200, {"status": "ok", "model": model_name})
|
| 44 |
+
return
|
| 45 |
+
self._send(404, {"error": "not found"})
|
| 46 |
+
|
| 47 |
+
def do_POST(self):
|
| 48 |
+
if self.path.rstrip("/") != "/v1/systemone":
|
| 49 |
+
self._send(404, {"error": "not found"})
|
| 50 |
+
return
|
| 51 |
+
try:
|
| 52 |
+
length = int(self.headers.get("Content-Length", "0"))
|
| 53 |
+
except ValueError:
|
| 54 |
+
self.close_connection = True
|
| 55 |
+
self._send(400, {"error": "Content-Length must be an integer"})
|
| 56 |
+
return
|
| 57 |
+
if length <= 0 or length > 1_000_000:
|
| 58 |
+
self.close_connection = True
|
| 59 |
+
self._send(400, {"error": "request body must be between 1 byte and 1 MB"})
|
| 60 |
+
return
|
| 61 |
+
try:
|
| 62 |
+
request = json.loads(self.rfile.read(length), parse_constant=reject_nonfinite_json)
|
| 63 |
+
if not isinstance(request, dict):
|
| 64 |
+
raise ValueError("request must be an object")
|
| 65 |
+
if "model" in request and not isinstance(request["model"], str):
|
| 66 |
+
raise ValueError("model must be a string")
|
| 67 |
+
with lock:
|
| 68 |
+
if "requests" in request:
|
| 69 |
+
if "state" in request or "questions" in request:
|
| 70 |
+
raise ValueError("a batch cannot also contain state or questions")
|
| 71 |
+
items = request["requests"]
|
| 72 |
+
if not isinstance(items, list) or not items:
|
| 73 |
+
raise ValueError("requests must be a non-empty list")
|
| 74 |
+
if len(items) > 1024:
|
| 75 |
+
raise ValueError("requests exceeds 1024 items")
|
| 76 |
+
results = decide_batch(tok, model, items)
|
| 77 |
+
payload = {"model": request.get("model") or model_name, "results": results}
|
| 78 |
+
else:
|
| 79 |
+
payload = {"model": request.get("model") or model_name, **decide(tok, model, request)}
|
| 80 |
+
except ValueError as exc:
|
| 81 |
+
self._send(400, {"error": str(exc)})
|
| 82 |
+
return
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
self.log_error("System One scoring failed: %r", exc)
|
| 85 |
+
self._send(500, {"error": "internal scoring error"})
|
| 86 |
+
return
|
| 87 |
+
self._send(200, payload)
|
| 88 |
+
|
| 89 |
+
return Handler
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def main():
|
| 93 |
+
parser = argparse.ArgumentParser(description="Serve Drex v2.0 at POST /v1/systemone.")
|
| 94 |
+
parser.add_argument("--model", default=str(Path(__file__).resolve().parent), help="Checkpoint directory (default: this directory)")
|
| 95 |
+
parser.add_argument("--host", default="127.0.0.1")
|
| 96 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 97 |
+
parser.add_argument("--name", default="drex-v2.0")
|
| 98 |
+
args = parser.parse_args()
|
| 99 |
+
name = args.name
|
| 100 |
+
tok, model = load(args.model)
|
| 101 |
+
server = ThreadingHTTPServer((args.host, args.port), make_handler(tok, model, name))
|
| 102 |
+
print(f"{name} listening on http://{args.host}:{args.port}/v1/systemone")
|
| 103 |
+
server.serve_forever()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
main()
|