ozyman commited on
Commit
a21d4da
·
verified ·
1 Parent(s): 62c444e

Release Drex v2.0 code and model card

Browse files
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
- A decision model: it reads a state (text or JSON) and typed questions (`choice`, `noul`, ordinal `score`) and returns a probability for every option in one forward pass per question. Nothing is generated. It serves the TypeSafe-compatible `/v1/systemone` API through the bundled Kev code.
18
 
19
- Mixture-of-experts backbone (Qwen3.6-35B-A3B, 3B active) plus a pointer head.
20
 
21
- ## Inference
22
 
23
- ```bash
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
- HTTP server (`POST /v1/systemone`):
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
- Request shape:
37
 
38
- ```json
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
- Fast scoring (vLLM pooling, pointer head in-process — do not use generate):
46
 
47
- ```bash
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
- ## Benchmarks
 
 
 
 
 
 
 
55
 
56
- ### Decision Index 0.2.1 (official kit, full suite, 150,317 requests)
57
 
58
- | Rank | Model | Index | Raw | Knowledge & Reasoning | Language | Retrieval | Tools | Arts |
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
- ![Decision Index 0.2.1](decision_index.svg)
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. Public items only.
 
 
80
 
81
- ### DecideBench v1.1 (400 contrastive decisions)
 
82
 
83
- Published decision models only (chat LLMs omitted). Few-shot uses the benchmark’s examples-in-instructions protocol.
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
- Not an official leaderboard submission.
99
 
100
- ### Email classification
 
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
- ### Game arena against Jev 1.13.0
110
 
111
- Eight games, 32 matches each (16 openings × both colours). Score = (wins + ½ draws) ÷ games.
 
 
 
112
 
113
  | | W – D – L | Score |
114
  |---|---|---|
115
  | **Drex v2.0 vs Jev** | **132 – 48 – 76** | **60.9%** |
116
- | Drex v1.5 vs Jev (published) | 122 – 47 – 87 | 56.8% |
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
- ## Files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "kev-latest"
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"] = dict(zip(m["keys"], opts))
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
- """Load a decision-model checkpoint (a folder or Hub repo with `head.pt` and the backbone).
2
-
3
- ck = Checkpoint("NischayDnk/Drex-v2.0")
4
- tok, model = ck.load("cuda", LoadOptions.from_env())
 
 
 
 
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, is_hybrid, load_tokenizer, pad_id
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
- dtype None = fp32, the exact path every reported number uses (bf16 when the checkpoint was trained with a bf16
75
- backbone). kev.serve defaults to bf16 on CUDA and MPS instead: half the memory, 2-4.5x lower latency on an
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
- import mlx_lm # noqa: F401
118
- return True
119
- except ImportError:
120
- return False
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
- def release_date(self):
136
- """ISO date for the TypeSafe model card: the Hub commit date for a Hub checkpoint (falls back to the cached file's
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
- def hybrid_base(self):
148
- """Whether the base has Gated DeltaNet layers (Qwen3.5), read from its config without loading weights."""
149
- from transformers import AutoConfig
150
- return is_hybrid(AutoConfig.from_pretrained(self.meta.base, revision=self.meta.base_revision).get_text_config())
151
-
152
- def backend(self, device, opts=LoadOptions()):
153
- """The backend `load` will use: LoadOptions.backend resolved ("auto" -> mlx only where it pays and is installed)."""
154
- if opts.backend not in LoadOptions.BACKENDS: raise ValueError(f"unknown backend {opts.backend!r}")
155
- if opts.backend != "auto": return opts.backend or "torch"
156
- exact = opts.dtype is torch.float32 # KEV_DTYPE=fp32: the caller wants the reported-numbers path, not a faster one
157
- return "mlx" if str(device) == "mps" and not exact and mlx_available() and self.hybrid_base() else "torch"
158
-
159
- def load(self, device, opts=LoadOptions()):
160
- """-> (tokenizer, model) in eval mode with the LoRA applied and the pointer head loaded. The model is a
161
- DecisionModel (torch) or an MLXDecisionModel (backend mlx); both expose the same scoring interface."""
162
- meta = self.meta
163
- tok_src = self.path if self._standalone_weights() else meta.base
164
- tok = load_tokenizer(tok_src, revision=None if self._standalone_weights() else meta.base_revision)
165
- m = self._load_mlx(tok, opts) if self.backend(device, opts) == "mlx" else self._load_torch(tok, device, opts)
166
- m.head.load_state_dict(meta.head); m.eval()
167
- m.head.temperature = meta.temperature if opts.temperature is None else opts.temperature
168
- return tok, m
169
-
170
- def _load_mlx(self, tok, opts):
171
- from .mlx_model import MLXDecisionModel, merge_lora
172
- if not opts.merge: raise ValueError("the MLX backend always merges the adapter (KEV_MERGE=0 needs backend=torch)")
173
- if self.meta.option_isolation: raise ValueError("option_isolation needs the packed mask; not available on the MLX backend")
174
- if not self.hybrid_base(): raise ValueError(f"the MLX backend is for the hybrid (Qwen3.5) bases; {self.meta.base} is attention-only and runs on MPS with backend=torch")
175
- base_dir = resolve_run(f"{self.meta.base}@{self.meta.base_revision or ''}") # the base snapshot the torch path already cached
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
- # serving context (kev.serve): per-branch cap mirrors Jev's ~32k, bounded by the base model window; longer than training, so untested there
15
- SERVE_MAX_STATE = int(os.environ.get("KEV_SERVE_MAX_STATE", "8192"))
16
- SERVE_MAX_BRANCH = int(os.environ.get("KEV_SERVE_MAX_BRANCH", "8192"))
17
- SERVE_MAX_PACKED = SERVE_MAX_STATE + SERVE_MAX_BRANCH # one row at most: a packed request longer than this runs in the row form (the block-causal mask is L x L)
18
- # the longest state a checkpoint may be trained on (kev.train --max_state) and still leave every question its training
19
- # branch budget when served: serving's row limit is SERVE_MAX_BRANCH = state + branch
20
- MAX_TRAIN_STATE = SERVE_MAX_BRANCH - (MAX_BRANCH - MAX_STATE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=None):
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 = _load_backbone(name, revision, dtype, attn)
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
- # One pass is bounded by the serving row budget in training too. A 35B MoE forward of every question
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 decisions with Drex v2.0.
2
 
3
- pip install -e code
4
- python inference.py --model . # built-in example
5
- python inference.py --model NischayDnk/Drex-v2.0 --request req.json
6
 
7
- A request is TypeSafe System One JSON: {"state": ..., "questions": {qid: {"type": "noul"|"choice"|"score", ...}}}.
8
- The model returns a probability for every option in one forward pass per question; nothing is generated.
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 Checkpoint, LoadOptions # noqa: E402
20
- from kev.model import SERVE_MAX_BRANCH, SERVE_MAX_STATE # noqa: E402
21
 
22
  EXAMPLE = {
23
- "state": {"premise": "The lawyer and the judge supported the students .", "hypothesis": "The judge supported the students ."},
 
 
24
  "questions": {
25
- "entails": {"type": "noul", "instructions": "If the premise is true, must the hypothesis also be true?"},
26
- "topic": {"type": "choice", "instructions": "What is the setting?", "criteria": {"court": "A legal setting", "sports": "A sports event", "kitchen": "Cooking"}},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  },
28
  }
29
 
30
 
31
- def load(model):
32
- path = Path(model)
33
- if not path.exists():
34
- from huggingface_hub import snapshot_download
35
- path = Path(snapshot_download(model))
36
- ck = Checkpoint(str(path))
37
- ck.meta.base = str(path)
38
- device = "cuda" if torch.cuda.is_available() else "cpu"
39
- tok, m = ck.load(device, LoadOptions(dtype=torch.bfloat16 if device == "cuda" else None))
40
- return tok, m.eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
  def decide(tok, model, request):
44
- rec, meta = to_record(SystemOneRequest(state=request["state"], questions=request["questions"]))
45
- enc = model.encode(tok, rec, max_state=SERVE_MAX_STATE, max_branch=SERVE_MAX_BRANCH)
46
  with torch.no_grad():
47
- ps = [p.float().tolist() for p in model.probs(enc)]
48
- return to_answers(ps, meta)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  def main():
52
- ap = argparse.ArgumentParser()
53
- ap.add_argument("--model", default=str(Path(__file__).resolve().parent))
54
- ap.add_argument("--request", help="JSON file with one System One request (default: a built-in example)")
55
- a = ap.parse_args()
56
- tok, model = load(a.model)
57
- req = json.load(open(a.request)) if a.request else EXAMPLE
58
- print(json.dumps(decide(tok, model, req), indent=2))
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()