Text Classification
Transformers
Safetensors
English
Portuguese
qwen3_5
image-text-to-text
decision-making
calibration
finance
trading
trade-finance
rules
distillation
typed-decisions
Instructions to use caiovicentino1/Eikos-4B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use caiovicentino1/Eikos-4B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="caiovicentino1/Eikos-4B")# pip install -U transformers accelerate # Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("caiovicentino1/Eikos-4B") model = AutoModelForMultimodalLM.from_pretrained("caiovicentino1/Eikos-4B", device_map="auto") - Notebooks
- Google Colab
- Kaggle
serve v1.2: up to 588 options in one pass, per-model limit (max_one_pass in decision_config.json)
Browse files- README.md +4 -2
- SHA256SUMS +7 -7
- decision_config.json +3 -2
- decision_core.py +30 -6
- letter_adapter.py +76 -23
- mlx_decide.py +15 -12
- serve.py +15 -4
- serve_vllm.sh +4 -2
README.md
CHANGED
|
@@ -56,7 +56,9 @@ probabilities** over a bounded set of options. It does not generate text at deci
|
|
| 56 |
1. **Prompt.** The state (evidence), the question (criterion) and the lettered options go into a structured
|
| 57 |
prompt, following the SemIf format.
|
| 58 |
2. **Readout.** The model reads the next-token logits **only over the option letters**. A softmax with a
|
| 59 |
-
fixed temperature gives the distribution.
|
|
|
|
|
|
|
| 60 |
3. **Calibration.** Temperature T = 1 ships by default (see *Calibration*).
|
| 61 |
4. **Training.** Distillation from a strong teacher (GLM-5.3-Flash, maximum reasoning effort) on:
|
| 62 |
- generated and blindly labeled decision items;
|
|
@@ -88,7 +90,7 @@ python serve.py --model <MODEL_DIR> --vllm-url http://127.0.0.1:8001 --port 8000
|
|
| 88 |
```
|
| 89 |
|
| 90 |
`serve_vllm.sh` runs `vllm serve` with the flags the readout needs: `--served-model-name decider`,
|
| 91 |
-
`--enable-prefix-caching --mamba-cache-mode
|
| 92 |
`--dtype bfloat16` and `--max-model-len 16384`.
|
| 93 |
|
| 94 |
### HTTP API (TypeSafe-compatible)
|
|
|
|
| 56 |
1. **Prompt.** The state (evidence), the question (criterion) and the lettered options go into a structured
|
| 57 |
prompt, following the SemIf format.
|
| 58 |
2. **Readout.** The model reads the next-token logits **only over the option letters**. A softmax with a
|
| 59 |
+
fixed temperature gives the distribution. Options are labelled A–Z and then AA, AB, … (each label a single
|
| 60 |
+
token): up to 100 options are read in the same forward pass (serve v1.2, `max_one_pass` in
|
| 61 |
+
`decision_config.json`; beyond ~100 the 4B does better through a tournament whose final round is one pass).
|
| 62 |
3. **Calibration.** Temperature T = 1 ships by default (see *Calibration*).
|
| 63 |
4. **Training.** Distillation from a strong teacher (GLM-5.3-Flash, maximum reasoning effort) on:
|
| 64 |
- generated and blindly labeled decision items;
|
|
|
|
| 90 |
```
|
| 91 |
|
| 92 |
`serve_vllm.sh` runs `vllm serve` with the flags the readout needs: `--served-model-name decider`,
|
| 93 |
+
`--enable-prefix-caching --mamba-cache-mode align`, `--logprobs-mode processed_logprobs --max-logprobs 600`,
|
| 94 |
`--dtype bfloat16` and `--max-model-len 16384`.
|
| 95 |
|
| 96 |
### HTTP API (TypeSafe-compatible)
|
SHA256SUMS
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
2cf8c18cf63fd0e31addba3775a2bbec6f58aa31bb5bdfb549c640bd9b676611 LICENSE
|
| 2 |
bbedc3fda3305820b977265f01b8619d87570a6739de3a5582c3464840f1e57a LICENSE-Qwen
|
| 3 |
281b5c258f113cf0fd53df3bd20868eb8b3c63aef37e6496d1c172e468b3a073 NOTICE
|
| 4 |
-
|
| 5 |
08cbc176f11afd74bfd87ae095ad68665d659ad67d06c85782ffd4d77169f8b4 assets/chart_calibration.png
|
| 6 |
f75c9334eff9de54322e655b0e6c22b1fcb776154ac0fc417ed87700f388a164 assets/chart_confidence.png
|
| 7 |
f1cbab3e2e80d733ed844884eaddfdcde7779c67cbc9ca43fad3b50484780549 assets/chart_head_to_head.png
|
|
@@ -10,19 +10,19 @@ c6074e2c06ed7c2bde4b65d3292109b92f7e267800b22d40e755fb3fa9757234 assets/chart_l
|
|
| 10 |
2f1645391d393a8fdf77661f765640ecb0f850fd1a16025ade22ea737a7b2686 calib.json
|
| 11 |
a4aee8afcf2e0711942cf848899be66016f8d14a889ff9ede07bca099c28f715 chat_template.jinja
|
| 12 |
ddc63e1c717afa86c865bb5e01313d89d72bb53b97ad4a8a03ba8510c0621670 config.json
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
af1c02a2f57fbd2f41e0c11734ff38a5617d1d9f1cc3124c352d1b2e1d1f8391 local_demo.py
|
| 17 |
d2903ce6ddb498b437a818b9c0c79816d9b523858438e312d9095da31ae4b291 local_demo_cases.py
|
| 18 |
a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d merges.txt
|
| 19 |
-
|
| 20 |
81703b1d1c19b3a38c62b6269276787695643280055b768ed8f257a538db0c62 model.safetensors-00001-of-00002.safetensors
|
| 21 |
3d34f2498429c7a1ccfe3f5c907c68cbd326569a2efdf26fd4ad5c2644423b45 model.safetensors-00002-of-00002.safetensors
|
| 22 |
db0ac759fa4eb09c659191f75bc4122f3837168b4273802d0d15ac1138919dfd model.safetensors.index.json
|
| 23 |
27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 preprocessor_config.json
|
| 24 |
-
|
| 25 |
-
|
| 26 |
5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42 tokenizer.json
|
| 27 |
316230d6a809701f4db5ea8f8fc862bc3a6f3229c937c174e674ff3ca0a64ac8 tokenizer_config.json
|
| 28 |
7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13 video_preprocessor_config.json
|
|
|
|
| 1 |
2cf8c18cf63fd0e31addba3775a2bbec6f58aa31bb5bdfb549c640bd9b676611 LICENSE
|
| 2 |
bbedc3fda3305820b977265f01b8619d87570a6739de3a5582c3464840f1e57a LICENSE-Qwen
|
| 3 |
281b5c258f113cf0fd53df3bd20868eb8b3c63aef37e6496d1c172e468b3a073 NOTICE
|
| 4 |
+
a657b2ff561a167d9652523606b6afc7fbe0b3b702d8563ce0e8e74682b55ef5 README.md
|
| 5 |
08cbc176f11afd74bfd87ae095ad68665d659ad67d06c85782ffd4d77169f8b4 assets/chart_calibration.png
|
| 6 |
f75c9334eff9de54322e655b0e6c22b1fcb776154ac0fc417ed87700f388a164 assets/chart_confidence.png
|
| 7 |
f1cbab3e2e80d733ed844884eaddfdcde7779c67cbc9ca43fad3b50484780549 assets/chart_head_to_head.png
|
|
|
|
| 10 |
2f1645391d393a8fdf77661f765640ecb0f850fd1a16025ade22ea737a7b2686 calib.json
|
| 11 |
a4aee8afcf2e0711942cf848899be66016f8d14a889ff9ede07bca099c28f715 chat_template.jinja
|
| 12 |
ddc63e1c717afa86c865bb5e01313d89d72bb53b97ad4a8a03ba8510c0621670 config.json
|
| 13 |
+
0facac31afd9c70af05b635f7f1bf41ae4940bd42262f5d13b93597375d15224 decision_config.json
|
| 14 |
+
9ff8d754ce99c6539fc7f5bd88c10b196357c70124f59ed3d1bd1a0d3fdbb7d0 decision_core.py
|
| 15 |
+
f39939e54c73b10933f81133971ba398f851cf623438abf4a54c12f2ad473b58 letter_adapter.py
|
| 16 |
af1c02a2f57fbd2f41e0c11734ff38a5617d1d9f1cc3124c352d1b2e1d1f8391 local_demo.py
|
| 17 |
d2903ce6ddb498b437a818b9c0c79816d9b523858438e312d9095da31ae4b291 local_demo_cases.py
|
| 18 |
a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d merges.txt
|
| 19 |
+
b6b54867eaa2a9faf8d7f2e1d66fb2f720b11eceab1baf3a18dc23f9d5eb8564 mlx_decide.py
|
| 20 |
81703b1d1c19b3a38c62b6269276787695643280055b768ed8f257a538db0c62 model.safetensors-00001-of-00002.safetensors
|
| 21 |
3d34f2498429c7a1ccfe3f5c907c68cbd326569a2efdf26fd4ad5c2644423b45 model.safetensors-00002-of-00002.safetensors
|
| 22 |
db0ac759fa4eb09c659191f75bc4122f3837168b4273802d0d15ac1138919dfd model.safetensors.index.json
|
| 23 |
27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 preprocessor_config.json
|
| 24 |
+
0756bad2d64736b830b6908c23f2c20a830c16e60560d88a918cb9d6505dd966 serve.py
|
| 25 |
+
9e8e362398735664f468bf7014f588f07a1e9abb3d369be2c9d8cd0200cb39a0 serve_vllm.sh
|
| 26 |
5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42 tokenizer.json
|
| 27 |
316230d6a809701f4db5ea8f8fc862bc3a6f3229c937c174e674ff3ca0a64ac8 tokenizer_config.json
|
| 28 |
7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13 video_preprocessor_config.json
|
decision_config.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"prompt_version": "letter-v1-semif",
|
| 3 |
"readout": "letter-logit",
|
| 4 |
-
"calib": "calib.json"
|
| 5 |
-
|
|
|
|
|
|
| 1 |
{
|
| 2 |
"prompt_version": "letter-v1-semif",
|
| 3 |
"readout": "letter-logit",
|
| 4 |
+
"calib": "calib.json",
|
| 5 |
+
"max_one_pass": 100
|
| 6 |
+
}
|
decision_core.py
CHANGED
|
@@ -6,6 +6,8 @@ trained and evaluated.
|
|
| 6 |
the SemIf format, MIT) or "ours" (plain text).
|
| 7 |
- Temperature: fixed, or conditioned on observable signals (calib_fit.py).
|
| 8 |
- Verify mode (optional): short reasoning with a token budget before reading the letter.
|
|
|
|
|
|
|
| 9 |
License: MIT.
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
|
@@ -15,6 +17,22 @@ import math
|
|
| 15 |
import os
|
| 16 |
|
| 17 |
LETTERS = [chr(65 + i) for i in range(26)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
STYLE = os.environ.get("PROMPT_STYLE", "semif")
|
| 19 |
if STYLE == "semif":
|
| 20 |
SYSTEM = ("Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
|
|
@@ -55,10 +73,10 @@ def render_user(state, question: dict, opts: list[tuple[str, str]]) -> str:
|
|
| 55 |
noul = question.get("type") in ("noul", "boolean")
|
| 56 |
shown = (lambda lab: {"yes": "true", "no": "false"}.get(lab, lab)) if noul else (lambda lab: lab)
|
| 57 |
payload = {"evidence": state, "criterion": question.get("instructions", ""),
|
| 58 |
-
"options": [{"letter":
|
| 59 |
for i, (lab, desc) in enumerate(opts)]}
|
| 60 |
return json.dumps(payload, ensure_ascii=False)
|
| 61 |
-
ol = "\n".join(f"{
|
| 62 |
return (f"State:\n{state_text(state)}\n\nQuestion: {question.get('instructions', '')}\n\n"
|
| 63 |
f"Options:\n{ol}\n\nAnswer with the letter of the correct option.")
|
| 64 |
|
|
@@ -77,17 +95,23 @@ def temp_for(calib: dict | None, temp: float, n_tok: int, n_opts: int, qtype: st
|
|
| 77 |
return math.exp(calib["b"] + sum(w * x for w, x in zip(calib["w"], f)))
|
| 78 |
|
| 79 |
|
| 80 |
-
def tournament(dist_fn, opts, chunk: int =
|
| 81 |
-
"""More than
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
return dist_fn(opts)
|
|
|
|
|
|
|
|
|
|
| 84 |
finalists, n_tok = [], 0
|
| 85 |
for i in range(0, len(opts), chunk):
|
| 86 |
block = opts[i:i + chunk]
|
| 87 |
p, n = dist_fn(block)
|
| 88 |
n_tok += n
|
| 89 |
finalists += sorted(block, key=lambda o: -p[o[0]])[:keep]
|
| 90 |
-
pf, n =
|
| 91 |
probs = {lab: 1e-4 for lab, _ in opts}
|
| 92 |
probs.update(pf)
|
| 93 |
z = sum(probs.values())
|
|
|
|
| 6 |
the SemIf format, MIT) or "ours" (plain text).
|
| 7 |
- Temperature: fixed, or conditioned on observable signals (calib_fit.py).
|
| 8 |
- Verify mode (optional): short reasoning with a token budget before reading the letter.
|
| 9 |
+
- Option labels: A..Z, then the two-letter codes AA, AB, ... that are a single token (588 in all), so every option of
|
| 10 |
+
a question is read in the same forward pass. Up to 26 options the prompt is byte-identical to earlier versions.
|
| 11 |
License: MIT.
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
|
|
|
| 17 |
import os
|
| 18 |
|
| 19 |
LETTERS = [chr(65 + i) for i in range(26)]
|
| 20 |
+
# Two-letter codes that the Qwen3.5 tokenizer (27B and 4B) splits into two tokens: skipped, because a label must be one
|
| 21 |
+
# token for its probability to be read in the same forward pass as the others. letter_adapter checks this at load time.
|
| 22 |
+
_SPLIT = frozenset(
|
| 23 |
+
"BQ BZ CJ CQ CZ DQ DZ EJ EY FJ FQ FV FZ GJ GK GQ GZ HJ IY JF JG JH JL JN JQ JW JX JY JZ KJ KQ KX KZ LH LJ LQ LW LX "
|
| 24 |
+
"LZ OJ OQ OY OZ PQ PZ QD QF QI QJ QK QO QV QW QX QY QZ RQ RZ TJ TQ UJ UO UQ UW VH VJ VQ VU VW VX VY VZ WJ WQ WU WV "
|
| 25 |
+
"WY WZ XG XJ XK XN XO XQ XU XV XW XZ YB YD YF YH YI YJ YK YQ YR YU YV YX ZB ZC ZD ZG ZJ ZK ZL ZM ZP ZQ ZS ZT ZU ZV"
|
| 26 |
+
.split())
|
| 27 |
+
LABELS = LETTERS + [a + b for a in LETTERS for b in LETTERS if a + b not in _SPLIT] # 588 labels, one token each
|
| 28 |
+
# Most options read in one pass. Each model sets it in decision_config.json ("max_one_pass"): the one-pass readout was
|
| 29 |
+
# measured against the tournament up to 151 options (27B better throughout; 4B better up to ~100, worse from ~120).
|
| 30 |
+
MAX_ONE_PASS = len(LABELS)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def set_max_one_pass(n: int | None) -> None:
|
| 34 |
+
global MAX_ONE_PASS
|
| 35 |
+
MAX_ONE_PASS = max(26, min(int(n), len(LABELS))) if n else len(LABELS)
|
| 36 |
STYLE = os.environ.get("PROMPT_STYLE", "semif")
|
| 37 |
if STYLE == "semif":
|
| 38 |
SYSTEM = ("Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. "
|
|
|
|
| 73 |
noul = question.get("type") in ("noul", "boolean")
|
| 74 |
shown = (lambda lab: {"yes": "true", "no": "false"}.get(lab, lab)) if noul else (lambda lab: lab)
|
| 75 |
payload = {"evidence": state, "criterion": question.get("instructions", ""),
|
| 76 |
+
"options": [{"letter": LABELS[i], "description": f"{shown(lab)}: {desc}"}
|
| 77 |
for i, (lab, desc) in enumerate(opts)]}
|
| 78 |
return json.dumps(payload, ensure_ascii=False)
|
| 79 |
+
ol = "\n".join(f"{LABELS[i]}. {lab}: {desc}" for i, (lab, desc) in enumerate(opts))
|
| 80 |
return (f"State:\n{state_text(state)}\n\nQuestion: {question.get('instructions', '')}\n\n"
|
| 81 |
f"Options:\n{ol}\n\nAnswer with the letter of the correct option.")
|
| 82 |
|
|
|
|
| 95 |
return math.exp(calib["b"] + sum(w * x for w, x in zip(calib["w"], f)))
|
| 96 |
|
| 97 |
|
| 98 |
+
def tournament(dist_fn, opts, chunk: int | None = None, keep: int | None = None, limit: int | None = None):
|
| 99 |
+
"""More options than one pass reads (`limit`, default MAX_ONE_PASS): blocks of `chunk` (default `limit`); the best
|
| 100 |
+
`keep` of each block (default: as many as fit in one final pass) go on to the next round.
|
| 101 |
+
v1.0/v1.1 behaviour: chunk=20, keep=2, limit=26."""
|
| 102 |
+
limit = limit or MAX_ONE_PASS
|
| 103 |
+
if len(opts) <= limit:
|
| 104 |
return dist_fn(opts)
|
| 105 |
+
chunk = min(chunk or limit, limit)
|
| 106 |
+
n_blocks = math.ceil(len(opts) / chunk)
|
| 107 |
+
keep = keep or max(1, min(chunk // 2, limit // n_blocks))
|
| 108 |
finalists, n_tok = [], 0
|
| 109 |
for i in range(0, len(opts), chunk):
|
| 110 |
block = opts[i:i + chunk]
|
| 111 |
p, n = dist_fn(block)
|
| 112 |
n_tok += n
|
| 113 |
finalists += sorted(block, key=lambda o: -p[o[0]])[:keep]
|
| 114 |
+
pf, n = tournament(dist_fn, finalists, chunk, keep, limit) # the finalists fit in one pass, or play another round
|
| 115 |
probs = {lab: 1e-4 for lab, _ in opts}
|
| 116 |
probs.update(pf)
|
| 117 |
z = sum(probs.values())
|
letter_adapter.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Decision readout from letter logits (one forward pass per decision), used in evaluation (the JevBench harness and
|
| 2 |
-
the evaluation suites) and by the server. Format/prompt come from decision_core (same as in training).
|
|
|
|
| 3 |
|
| 4 |
Modes:
|
| 5 |
- local (transformers): model_path = model path/repo (+ optional LoRA adapter);
|
|
@@ -13,13 +14,15 @@ import contextlib
|
|
| 13 |
import copy
|
| 14 |
import json
|
| 15 |
import math
|
|
|
|
| 16 |
import time
|
| 17 |
from dataclasses import dataclass, field
|
| 18 |
from typing import Any, Optional
|
| 19 |
|
| 20 |
import torch
|
| 21 |
|
| 22 |
-
|
|
|
|
| 23 |
render_user, state_text, temp_for, tournament)
|
| 24 |
|
| 25 |
import os
|
|
@@ -69,43 +72,89 @@ class LetterAdapter:
|
|
| 69 |
self.mix_w = mix_w if mix_w is not None else float(os.environ.get("MIX_W", "0.5"))
|
| 70 |
self._jhead = None
|
| 71 |
self._loaded = None
|
|
|
|
|
|
|
| 72 |
|
| 73 |
# ---------------- loading ----------------
|
| 74 |
-
def _sglang(self): # "sglang:<url>|<tokenizer>" or "vllm:<url>|<tokenizer>"
|
| 75 |
url, tok_path = self.model_path.split(":", 1)[1].split("|", 1)
|
| 76 |
-
return url.rstrip("/"), tok_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
@property
|
| 79 |
def _is_vllm(self):
|
| 80 |
return self.model_path.startswith("vllm:")
|
| 81 |
|
| 82 |
-
def _vllm_letters(self, texts, n_max):
|
| 83 |
-
"""One /v1/completions call with all the prompts; allowed
|
| 84 |
-
over the
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
import urllib.request
|
| 87 |
-
url
|
| 88 |
want = self._loaded[2][:n_max]
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
out = [None] * len(texts)
|
| 96 |
for ch in d["choices"]:
|
| 97 |
top = ch["logprobs"]["top_logprobs"][0]
|
| 98 |
lp = {int(k.split(":", 1)[1]) if k.startswith("token_id:") else k: v for k, v in top.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
out[ch["index"]] = lp
|
| 100 |
n_tok = d.get("usage", {}).get("prompt_tokens", 0) // max(1, len(texts))
|
| 101 |
return out, n_tok
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
@staticmethod
|
| 104 |
def _letter_ids(tok):
|
| 105 |
ids = []
|
| 106 |
-
for L in
|
| 107 |
t = tok.encode(L, add_special_tokens=False)
|
| 108 |
-
assert len(t) == 1, f"
|
| 109 |
ids.append(t[0])
|
| 110 |
assert len(set(ids)) == len(ids)
|
| 111 |
return ids
|
|
@@ -197,7 +246,8 @@ class LetterAdapter:
|
|
| 197 |
add_generation_prompt=True, enable_thinking=True)
|
| 198 |
text = text + thought.split("</think>")[0].rstrip() + "\n</think>\n\n"
|
| 199 |
if model is None and self._is_vllm:
|
| 200 |
-
|
|
|
|
| 201 |
lg = torch.tensor([lp.get(i, -1e9) for i in want])
|
| 202 |
return self._probs(lg, n_tok, opts, question), n_tok
|
| 203 |
if model is None:
|
|
@@ -242,15 +292,17 @@ class LetterAdapter:
|
|
| 242 |
"""Parallel: several questions about the same state in a single GPU pass (batch, right-padding).
|
| 243 |
items = [(question, opts), ...]. With vLLM or SGLang, all prompts go in one call and the server's prefix cache
|
| 244 |
reuses the state. Falls back to the sequential path for the energy readout, verify mode, a single question or
|
| 245 |
-
|
| 246 |
model, tok, let_ids = self.load()
|
| 247 |
if (self._jhead is not None or self.verify_budget > 0 or len(items) == 1
|
| 248 |
-
or any(len(o) >
|
| 249 |
return [self.dist_any(state, q, o) for q, o in items]
|
| 250 |
if model is None and self._is_vllm: # vLLM: one call; the server's prefix cache reuses the state
|
| 251 |
texts = [tok.apply_chat_template(messages(state, q, o), tokenize=False, add_generation_prompt=True,
|
| 252 |
enable_thinking=False) for q, o in items]
|
| 253 |
-
|
|
|
|
|
|
|
| 254 |
return [(self._probs(torch.tensor([lp.get(i, -1e9) for i in let_ids[:len(o)]]), n_tok, o, q), n_tok)
|
| 255 |
for (q, o), lp in zip(items, lps)]
|
| 256 |
if model is None: # SGLang: one call with the list of prompts; the prefix cache processes the state once
|
|
@@ -311,7 +363,8 @@ class LetterAdapter:
|
|
| 311 |
and the suffixes of the N questions run together in one batch. Same result as the full prompt."""
|
| 312 |
import time as _t
|
| 313 |
model, tok, let_ids = self.load()
|
| 314 |
-
if model is None or self._jhead is not None or self.verify_budget > 0
|
|
|
|
| 315 |
return self.dist_many(state, items)
|
| 316 |
texts = [tok.apply_chat_template(messages(state, q, o), tokenize=False, add_generation_prompt=True,
|
| 317 |
enable_thinking=False) for q, o in items]
|
|
@@ -355,7 +408,7 @@ class LetterAdapter:
|
|
| 355 |
"total_ms": round((_t.perf_counter() - t0) * 1000, 1)}
|
| 356 |
return out
|
| 357 |
|
| 358 |
-
def dist_any(self, state, question, opts, chunk=
|
| 359 |
return tournament(lambda o: self.dist(state, question, o), opts, chunk, keep)
|
| 360 |
|
| 361 |
# ---------------- harness ----------------
|
|
|
|
| 1 |
"""Decision readout from letter logits (one forward pass per decision), used in evaluation (the JevBench harness and
|
| 2 |
+
the evaluation suites) and by the server. Format/prompt come from decision_core (same as in training). Options are
|
| 3 |
+
labelled A..Z, AA, AB, ... (decision_core.LABELS, one token each): up to 588 options are read in one pass.
|
| 4 |
|
| 5 |
Modes:
|
| 6 |
- local (transformers): model_path = model path/repo (+ optional LoRA adapter);
|
|
|
|
| 14 |
import copy
|
| 15 |
import json
|
| 16 |
import math
|
| 17 |
+
import threading
|
| 18 |
import time
|
| 19 |
from dataclasses import dataclass, field
|
| 20 |
from typing import Any, Optional
|
| 21 |
|
| 22 |
import torch
|
| 23 |
|
| 24 |
+
import decision_core
|
| 25 |
+
from decision_core import (LABELS, LETTERS, PROMPT_VERSION, STYLE, SYSTEM, messages, options_of, # noqa: F401
|
| 26 |
render_user, state_text, temp_for, tournament)
|
| 27 |
|
| 28 |
import os
|
|
|
|
| 72 |
self.mix_w = mix_w if mix_w is not None else float(os.environ.get("MIX_W", "0.5"))
|
| 73 |
self._jhead = None
|
| 74 |
self._loaded = None
|
| 75 |
+
self._lp_cap = None # --max-logprobs of the vLLM server, learned from its first refusal (None: not limited)
|
| 76 |
+
self._inflight, self._rlock = {}, threading.Lock() # requests in flight per vLLM server
|
| 77 |
|
| 78 |
# ---------------- loading ----------------
|
| 79 |
+
def _sglang(self): # "sglang:<url>|<tokenizer>" or "vllm:<url>[,<url>...]|<tokenizer>"
|
| 80 |
url, tok_path = self.model_path.split(":", 1)[1].split("|", 1)
|
| 81 |
+
return url.split(",")[0].strip().rstrip("/"), tok_path
|
| 82 |
+
|
| 83 |
+
@contextlib.contextmanager
|
| 84 |
+
def _replica(self):
|
| 85 |
+
"""Several vLLM servers (e.g. one per GPU, comma-separated URLs): each request goes to the one with the fewest
|
| 86 |
+
requests in flight, and all of its calls stay there (its prefix cache holds the state)."""
|
| 87 |
+
urls = [u.strip().rstrip("/") for u in self.model_path.split(":", 1)[1].split("|", 1)[0].split(",")]
|
| 88 |
+
with self._rlock:
|
| 89 |
+
url = min(urls, key=lambda u: self._inflight.get(u, 0))
|
| 90 |
+
self._inflight[url] = self._inflight.get(url, 0) + 1
|
| 91 |
+
try:
|
| 92 |
+
yield url
|
| 93 |
+
finally:
|
| 94 |
+
with self._rlock:
|
| 95 |
+
self._inflight[url] -= 1
|
| 96 |
|
| 97 |
@property
|
| 98 |
def _is_vllm(self):
|
| 99 |
return self.model_path.startswith("vllm:")
|
| 100 |
|
| 101 |
+
def _vllm_letters(self, texts, n_max, url=None):
|
| 102 |
+
"""One /v1/completions call with all the prompts; allowed labels = the first n_max; logprobs already
|
| 103 |
+
normalized over the allowed labels (server run with --logprobs-mode processed_logprobs). A server started with
|
| 104 |
+
a lower --max-logprobs (v1.0 and v1.1 used 32) only returns its top labels: the labels it leaves out share the
|
| 105 |
+
remaining probability evenly. Returns (list of label_logprobs, n_tok)."""
|
| 106 |
+
import urllib.error
|
| 107 |
import urllib.request
|
| 108 |
+
url = url or self._sglang()[0]
|
| 109 |
want = self._loaded[2][:n_max]
|
| 110 |
+
while True:
|
| 111 |
+
n_lp = n_max if self._lp_cap is None else min(n_max, self._lp_cap)
|
| 112 |
+
body = {"model": "decider", "prompt": texts, "max_tokens": 1, "temperature": 0.0, "logprobs": n_lp,
|
| 113 |
+
"allowed_token_ids": want, "return_tokens_as_token_ids": True}
|
| 114 |
+
req = urllib.request.Request(f"{url}/v1/completions", data=json.dumps(body).encode(),
|
| 115 |
+
headers={"Content-Type": "application/json"})
|
| 116 |
+
try:
|
| 117 |
+
with urllib.request.urlopen(req, timeout=900) as r:
|
| 118 |
+
d = json.loads(r.read())
|
| 119 |
+
break
|
| 120 |
+
except urllib.error.HTTPError as e:
|
| 121 |
+
if e.code != 400 or n_lp <= 32 or "logprobs" not in e.read().decode(errors="replace"):
|
| 122 |
+
raise
|
| 123 |
+
self._lp_cap = 32 # older server: read the top 32 labels and spread the rest
|
| 124 |
out = [None] * len(texts)
|
| 125 |
for ch in d["choices"]:
|
| 126 |
top = ch["logprobs"]["top_logprobs"][0]
|
| 127 |
lp = {int(k.split(":", 1)[1]) if k.startswith("token_id:") else k: v for k, v in top.items()}
|
| 128 |
+
rest = [i for i in want if i not in lp]
|
| 129 |
+
if rest:
|
| 130 |
+
tail = max(1.0 - sum(math.exp(v) for v in lp.values()), 1e-12)
|
| 131 |
+
lp.update({i: math.log(tail / len(rest)) for i in rest})
|
| 132 |
out[ch["index"]] = lp
|
| 133 |
n_tok = d.get("usage", {}).get("prompt_tokens", 0) // max(1, len(texts))
|
| 134 |
return out, n_tok
|
| 135 |
|
| 136 |
+
def _warm_prefix(self, texts, url):
|
| 137 |
+
"""vLLM does not share a prefix between the prompts of one call while it is being computed, so N questions about
|
| 138 |
+
one state would compute that state N times. One short call with their common prefix first puts its full cache
|
| 139 |
+
blocks in the prefix cache, and the N questions then read them. EIKOS_SHARED_PREFIX=0 turns this off."""
|
| 140 |
+
if len(texts) < 2 or os.environ.get("EIKOS_SHARED_PREFIX", "1") != "1":
|
| 141 |
+
return
|
| 142 |
+
pre = os.path.commonprefix(texts)
|
| 143 |
+
if len(pre) < int(os.environ.get("EIKOS_SHARED_MIN_CHARS", "3000")): # less than ~1 cache block: nothing to gain
|
| 144 |
+
return
|
| 145 |
+
import urllib.request
|
| 146 |
+
body = {"model": "decider", "prompt": pre, "max_tokens": 1, "temperature": 0.0}
|
| 147 |
+
req = urllib.request.Request(f"{url}/v1/completions", data=json.dumps(body).encode(),
|
| 148 |
+
headers={"Content-Type": "application/json"})
|
| 149 |
+
with urllib.request.urlopen(req, timeout=900) as r:
|
| 150 |
+
r.read()
|
| 151 |
+
|
| 152 |
@staticmethod
|
| 153 |
def _letter_ids(tok):
|
| 154 |
ids = []
|
| 155 |
+
for L in LABELS:
|
| 156 |
t = tok.encode(L, add_special_tokens=False)
|
| 157 |
+
assert len(t) == 1, f"label {L} is not a single token: {t}"
|
| 158 |
ids.append(t[0])
|
| 159 |
assert len(set(ids)) == len(ids)
|
| 160 |
return ids
|
|
|
|
| 246 |
add_generation_prompt=True, enable_thinking=True)
|
| 247 |
text = text + thought.split("</think>")[0].rstrip() + "\n</think>\n\n"
|
| 248 |
if model is None and self._is_vllm:
|
| 249 |
+
with self._replica() as url:
|
| 250 |
+
(lp,), n_tok = self._vllm_letters([text], len(opts), url)
|
| 251 |
lg = torch.tensor([lp.get(i, -1e9) for i in want])
|
| 252 |
return self._probs(lg, n_tok, opts, question), n_tok
|
| 253 |
if model is None:
|
|
|
|
| 292 |
"""Parallel: several questions about the same state in a single GPU pass (batch, right-padding).
|
| 293 |
items = [(question, opts), ...]. With vLLM or SGLang, all prompts go in one call and the server's prefix cache
|
| 294 |
reuses the state. Falls back to the sequential path for the energy readout, verify mode, a single question or
|
| 295 |
+
more options than one pass reads (decision_core.MAX_ONE_PASS)."""
|
| 296 |
model, tok, let_ids = self.load()
|
| 297 |
if (self._jhead is not None or self.verify_budget > 0 or len(items) == 1
|
| 298 |
+
or any(len(o) > decision_core.MAX_ONE_PASS for _, o in items)):
|
| 299 |
return [self.dist_any(state, q, o) for q, o in items]
|
| 300 |
if model is None and self._is_vllm: # vLLM: one call; the server's prefix cache reuses the state
|
| 301 |
texts = [tok.apply_chat_template(messages(state, q, o), tokenize=False, add_generation_prompt=True,
|
| 302 |
enable_thinking=False) for q, o in items]
|
| 303 |
+
with self._replica() as url:
|
| 304 |
+
self._warm_prefix(texts, url)
|
| 305 |
+
lps, n_tok = self._vllm_letters(texts, max(len(o) for _, o in items), url)
|
| 306 |
return [(self._probs(torch.tensor([lp.get(i, -1e9) for i in let_ids[:len(o)]]), n_tok, o, q), n_tok)
|
| 307 |
for (q, o), lp in zip(items, lps)]
|
| 308 |
if model is None: # SGLang: one call with the list of prompts; the prefix cache processes the state once
|
|
|
|
| 363 |
and the suffixes of the N questions run together in one batch. Same result as the full prompt."""
|
| 364 |
import time as _t
|
| 365 |
model, tok, let_ids = self.load()
|
| 366 |
+
if (model is None or self._jhead is not None or self.verify_budget > 0
|
| 367 |
+
or any(len(o) > decision_core.MAX_ONE_PASS for _, o in items)):
|
| 368 |
return self.dist_many(state, items)
|
| 369 |
texts = [tok.apply_chat_template(messages(state, q, o), tokenize=False, add_generation_prompt=True,
|
| 370 |
enable_thinking=False) for q, o in items]
|
|
|
|
| 408 |
"total_ms": round((_t.perf_counter() - t0) * 1000, 1)}
|
| 409 |
return out
|
| 410 |
|
| 411 |
+
def dist_any(self, state, question, opts, chunk=None, keep=None):
|
| 412 |
return tournament(lambda o: self.dist(state, question, o), opts, chunk, keep)
|
| 413 |
|
| 414 |
# ---------------- harness ----------------
|
mlx_decide.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
| 2 |
PyTorch backend (letter_adapter), with no LoRA, no API and no internet. The common prefix (system + state) goes
|
| 3 |
through the model once; the hybrid cache (attention KV + Gated DeltaNet states) is replicated and the N questions run
|
| 4 |
together in one right-padded batch (real positions never see the padding: causal attention and the recurrence only
|
| 5 |
-
look back).
|
| 6 |
-
(decision_core.tournament).
|
| 7 |
usage: python mlx_decide.py <model_dir> (demo; compares against PyTorch if COMPARE_TORCH=1)"""
|
| 8 |
import copy
|
| 9 |
import json
|
|
@@ -23,18 +23,21 @@ class MLXDecider:
|
|
| 23 |
def __init__(self, path):
|
| 24 |
cfg = json.load(open(os.path.join(path, "decision_config.json")))
|
| 25 |
os.environ["PROMPT_STYLE"] = cfg["prompt_version"].rsplit("-", 1)[-1]
|
| 26 |
-
global
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
cp = os.path.join(path, cfg.get("calib") or "calib.json")
|
| 29 |
self.calib = json.load(open(cp)) if os.path.exists(cp) else None
|
| 30 |
self.model, self.tok = mlx_load(path)
|
| 31 |
lm = self.model.language_model if hasattr(self.model, "language_model") else self.model
|
| 32 |
self.inner, self.lm = lm.model, lm
|
| 33 |
self.let = []
|
| 34 |
-
for L in
|
| 35 |
t = self.tok.encode(L, add_special_tokens=False)
|
| 36 |
-
assert len(t) == 1, f"
|
| 37 |
self.let.append(t[0])
|
|
|
|
| 38 |
self.let_idx = mx.array(self.let)
|
| 39 |
|
| 40 |
def _ids(self, state, q, opts):
|
|
@@ -43,7 +46,7 @@ class MLXDecider:
|
|
| 43 |
return self.tok.encode(text, add_special_tokens=False)
|
| 44 |
|
| 45 |
def _letter_logits(self, h):
|
| 46 |
-
"""h: (B, d) → logits (B,
|
| 47 |
if getattr(self.lm.args, "tie_word_embeddings", False):
|
| 48 |
out = self.inner.embed_tokens.as_linear(h)
|
| 49 |
else:
|
|
@@ -57,7 +60,7 @@ class MLXDecider:
|
|
| 57 |
return {lab: p[i] for i, (lab, _) in enumerate(opts)}
|
| 58 |
|
| 59 |
def dist(self, state, q, opts):
|
| 60 |
-
if len(opts) >
|
| 61 |
return tournament(lambda o: self.dist(state, q, o), opts)
|
| 62 |
ids = self._ids(state, q, opts)
|
| 63 |
cache = self.lm.make_cache()
|
|
@@ -78,11 +81,11 @@ class MLXDecider:
|
|
| 78 |
|
| 79 |
def dist_many_cached(self, state, items, chunk=32):
|
| 80 |
"""Several questions about the same state: the prefix once, then the questions in a batch from the cache.
|
| 81 |
-
Questions with more than
|
| 82 |
-
if any(len(o) >
|
| 83 |
-
small = [(q, o) for q, o in items if len(o) <=
|
| 84 |
done = iter(self.dist_many_cached(state, small, chunk) if small else [])
|
| 85 |
-
return [self.dist(state, q, o) if len(o) >
|
| 86 |
seqs = [self._ids(state, q, o) for q, o in items]
|
| 87 |
P = 0
|
| 88 |
while all(len(x) > P + 1 for x in seqs) and len({x[P] for x in seqs}) == 1:
|
|
|
|
| 2 |
PyTorch backend (letter_adapter), with no LoRA, no API and no internet. The common prefix (system + state) goes
|
| 3 |
through the model once; the hybrid cache (attention KV + Gated DeltaNet states) is replicated and the N questions run
|
| 4 |
together in one right-padded batch (real positions never see the padding: causal attention and the recurrence only
|
| 5 |
+
look back). Options are labelled A..Z, AA, AB, ... (decision_core.LABELS, one token each), so up to 588 options are read
|
| 6 |
+
in one pass; beyond that, the same tournament as the GPU backends (decision_core.tournament).
|
| 7 |
usage: python mlx_decide.py <model_dir> (demo; compares against PyTorch if COMPARE_TORCH=1)"""
|
| 8 |
import copy
|
| 9 |
import json
|
|
|
|
| 23 |
def __init__(self, path):
|
| 24 |
cfg = json.load(open(os.path.join(path, "decision_config.json")))
|
| 25 |
os.environ["PROMPT_STYLE"] = cfg["prompt_version"].rsplit("-", 1)[-1]
|
| 26 |
+
global LABELS, messages, options_of, temp_for, tournament, dc
|
| 27 |
+
import decision_core as dc # after PROMPT_STYLE is set
|
| 28 |
+
from decision_core import LABELS, messages, options_of, temp_for, tournament # noqa: F401
|
| 29 |
+
dc.set_max_one_pass(cfg.get("max_one_pass"))
|
| 30 |
cp = os.path.join(path, cfg.get("calib") or "calib.json")
|
| 31 |
self.calib = json.load(open(cp)) if os.path.exists(cp) else None
|
| 32 |
self.model, self.tok = mlx_load(path)
|
| 33 |
lm = self.model.language_model if hasattr(self.model, "language_model") else self.model
|
| 34 |
self.inner, self.lm = lm.model, lm
|
| 35 |
self.let = []
|
| 36 |
+
for L in LABELS:
|
| 37 |
t = self.tok.encode(L, add_special_tokens=False)
|
| 38 |
+
assert len(t) == 1, f"label {L} is not a single token: {t}"
|
| 39 |
self.let.append(t[0])
|
| 40 |
+
assert len(set(self.let)) == len(self.let)
|
| 41 |
self.let_idx = mx.array(self.let)
|
| 42 |
|
| 43 |
def _ids(self, state, q, opts):
|
|
|
|
| 46 |
return self.tok.encode(text, add_special_tokens=False)
|
| 47 |
|
| 48 |
def _letter_logits(self, h):
|
| 49 |
+
"""h: (B, d) → logits (B, 588) over the labels only (tied weights: the embedding used as a linear layer)."""
|
| 50 |
if getattr(self.lm.args, "tie_word_embeddings", False):
|
| 51 |
out = self.inner.embed_tokens.as_linear(h)
|
| 52 |
else:
|
|
|
|
| 60 |
return {lab: p[i] for i, (lab, _) in enumerate(opts)}
|
| 61 |
|
| 62 |
def dist(self, state, q, opts):
|
| 63 |
+
if len(opts) > dc.MAX_ONE_PASS: # more than one pass reads: blocks, the best of each go to a final round
|
| 64 |
return tournament(lambda o: self.dist(state, q, o), opts)
|
| 65 |
ids = self._ids(state, q, opts)
|
| 66 |
cache = self.lm.make_cache()
|
|
|
|
| 81 |
|
| 82 |
def dist_many_cached(self, state, items, chunk=32):
|
| 83 |
"""Several questions about the same state: the prefix once, then the questions in a batch from the cache.
|
| 84 |
+
Questions with more options than one pass reads go through dist (tournament), one at a time."""
|
| 85 |
+
if any(len(o) > dc.MAX_ONE_PASS for _, o in items):
|
| 86 |
+
small = [(q, o) for q, o in items if len(o) <= dc.MAX_ONE_PASS]
|
| 87 |
done = iter(self.dist_many_cached(state, small, chunk) if small else [])
|
| 88 |
+
return [self.dist(state, q, o) if len(o) > dc.MAX_ONE_PASS else next(done) for q, o in items]
|
| 89 |
seqs = [self._ids(state, q, o) for q, o in items]
|
| 90 |
P = 0
|
| 91 |
while all(len(x) > P + 1 for x in seqs) and len({x[P] for x in seqs}) == 1:
|
serve.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""Typed-decision server compatible with the TypeSafe API (POST /v1/systemone and /v1/evaluate).
|
| 2 |
|
| 3 |
Uses the same core as training and evaluation (decision_core + letter_adapter): same prompt, same
|
| 4 |
-
letter readout, same calibration. All questions in a request go in one pass (batch)
|
|
|
|
| 5 |
System One: by default every question is answered in one pass, without generating text. Optional (off by
|
| 6 |
default, not used for the reported results): "mode": "verify" per question turns on a short reasoning step before
|
| 7 |
the letter (--verify-budget N).
|
|
@@ -27,6 +28,9 @@ import time
|
|
| 27 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 28 |
from threading import Lock
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
class Decider:
|
| 31 |
def __init__(self, a):
|
| 32 |
from letter_adapter import LetterAdapter
|
|
@@ -130,7 +134,7 @@ def make_handler(dec: Decider):
|
|
| 130 |
|
| 131 |
def do_GET(self):
|
| 132 |
if self.path in ("/health", "/v1/health"):
|
| 133 |
-
self._send(200, {"ok": True, "model": dec.name})
|
| 134 |
else:
|
| 135 |
self._send(404, {"error": "not found"})
|
| 136 |
|
|
@@ -211,18 +215,25 @@ def main():
|
|
| 211 |
ap.add_argument("--sym", action="store_true")
|
| 212 |
ap.add_argument("--max-tokens", type=int, default=16000)
|
| 213 |
ap.add_argument("--verify-budget", type=int, default=0, help="optional: enables 'mode': 'verify' (short reasoning)")
|
| 214 |
-
ap.add_argument("--vllm-url", default=None, help="production backend: vLLM URL (serve_vllm.sh)"
|
|
|
|
| 215 |
ap.add_argument("--sglang-url", default=None, help="production backend (parallel): URL of the SGLang server")
|
|
|
|
|
|
|
| 216 |
a = ap.parse_args()
|
| 217 |
cfg_path = os.path.join(a.model, "decision_config.json")
|
|
|
|
| 218 |
if os.path.exists(cfg_path): # exported model: the configuration ships with the weights
|
| 219 |
cfg = json.load(open(cfg_path))
|
| 220 |
os.environ.setdefault("PROMPT_STYLE", cfg.get("prompt_version", "letter-v1-semif").rsplit("-", 1)[-1])
|
| 221 |
if a.calib is None and cfg.get("calib"):
|
| 222 |
a.calib = os.path.join(a.model, cfg["calib"])
|
|
|
|
|
|
|
| 223 |
dec = Decider(a)
|
| 224 |
dec.decide("warm-up", {"type": "noul", "instructions": "Is this a warm-up?", "criteria": {"true": "yes", "false": "no"}})
|
| 225 |
-
print(f"ready at http://{a.host}:{a.port} (calib={bool(a.calib)}, sym={a.sym}, backend={'vllm' if a.vllm_url else 'sglang' if a.sglang_url else 'pytorch'}, verify={a.verify_budget})", flush=True)
|
|
|
|
| 226 |
ThreadingHTTPServer((a.host, a.port), make_handler(dec)).serve_forever()
|
| 227 |
|
| 228 |
|
|
|
|
| 1 |
"""Typed-decision server compatible with the TypeSafe API (POST /v1/systemone and /v1/evaluate).
|
| 2 |
|
| 3 |
Uses the same core as training and evaluation (decision_core + letter_adapter): same prompt, same
|
| 4 |
+
letter readout, same calibration. All questions in a request go in one pass (batch), each with up to 588 options
|
| 5 |
+
(labels A..Z, AA, AB, ...; start vLLM with serve_vllm.sh, --max-logprobs 600); more options than that via a tournament;
|
| 6 |
System One: by default every question is answered in one pass, without generating text. Optional (off by
|
| 7 |
default, not used for the reported results): "mode": "verify" per question turns on a short reasoning step before
|
| 8 |
the letter (--verify-budget N).
|
|
|
|
| 28 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 29 |
from threading import Lock
|
| 30 |
|
| 31 |
+
VERSION = "1.2"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
class Decider:
|
| 35 |
def __init__(self, a):
|
| 36 |
from letter_adapter import LetterAdapter
|
|
|
|
| 134 |
|
| 135 |
def do_GET(self):
|
| 136 |
if self.path in ("/health", "/v1/health"):
|
| 137 |
+
self._send(200, {"ok": True, "model": dec.name, "version": VERSION})
|
| 138 |
else:
|
| 139 |
self._send(404, {"error": "not found"})
|
| 140 |
|
|
|
|
| 215 |
ap.add_argument("--sym", action="store_true")
|
| 216 |
ap.add_argument("--max-tokens", type=int, default=16000)
|
| 217 |
ap.add_argument("--verify-budget", type=int, default=0, help="optional: enables 'mode': 'verify' (short reasoning)")
|
| 218 |
+
ap.add_argument("--vllm-url", default=None, help="production backend: vLLM URL (serve_vllm.sh); several "
|
| 219 |
+
"comma-separated URLs (e.g. one server per GPU) spread the requests, least busy first")
|
| 220 |
ap.add_argument("--sglang-url", default=None, help="production backend (parallel): URL of the SGLang server")
|
| 221 |
+
ap.add_argument("--max-one-pass", type=int, default=None,
|
| 222 |
+
help="most options read in one pass (default: the model's decision_config.json, else 588)")
|
| 223 |
a = ap.parse_args()
|
| 224 |
cfg_path = os.path.join(a.model, "decision_config.json")
|
| 225 |
+
cfg = {}
|
| 226 |
if os.path.exists(cfg_path): # exported model: the configuration ships with the weights
|
| 227 |
cfg = json.load(open(cfg_path))
|
| 228 |
os.environ.setdefault("PROMPT_STYLE", cfg.get("prompt_version", "letter-v1-semif").rsplit("-", 1)[-1])
|
| 229 |
if a.calib is None and cfg.get("calib"):
|
| 230 |
a.calib = os.path.join(a.model, cfg["calib"])
|
| 231 |
+
import decision_core # after PROMPT_STYLE is set
|
| 232 |
+
decision_core.set_max_one_pass(a.max_one_pass or cfg.get("max_one_pass"))
|
| 233 |
dec = Decider(a)
|
| 234 |
dec.decide("warm-up", {"type": "noul", "instructions": "Is this a warm-up?", "criteria": {"true": "yes", "false": "no"}})
|
| 235 |
+
print(f"serve.py {VERSION} ready at http://{a.host}:{a.port} (calib={bool(a.calib)}, sym={a.sym}, backend={'vllm' if a.vllm_url else 'sglang' if a.sglang_url else 'pytorch'}, verify={a.verify_budget}, one pass up to {decision_core.MAX_ONE_PASS} options)", flush=True)
|
| 236 |
+
ThreadingHTTPServer.request_queue_size = 1024 # the default (5) drops connections when many clients arrive at once
|
| 237 |
ThreadingHTTPServer((a.host, a.port), make_handler(dec)).serve_forever()
|
| 238 |
|
| 239 |
|
serve_vllm.sh
CHANGED
|
@@ -10,6 +10,8 @@ from packaging.version import Version
|
|
| 10 |
if Version(vllm.__version__.split("+")[0]) < Version("0.30.0"):
|
| 11 |
sys.exit(f"vLLM {vllm.__version__} is too old for this model: please install vllm>=0.30.0")
|
| 12 |
PY
|
|
|
|
|
|
|
| 13 |
exec vllm serve "$1" --host 127.0.0.1 --port "$2" --served-model-name decider --dtype bfloat16 \
|
| 14 |
-
--gpu-memory-utilization "${3:-0.85}" --max-model-len 16384 --enable-prefix-caching --mamba-cache-mode
|
| 15 |
-
--logprobs-mode processed_logprobs --max-logprobs
|
|
|
|
| 10 |
if Version(vllm.__version__.split("+")[0]) < Version("0.30.0"):
|
| 11 |
sys.exit(f"vLLM {vllm.__version__} is too old for this model: please install vllm>=0.30.0")
|
| 12 |
PY
|
| 13 |
+
# --max-logprobs 600: every one of up to 588 option labels (A..Z, AA, AB, ...) is read in the same pass.
|
| 14 |
+
# --mamba-cache-mode align: what vLLM 0.30 uses for this hybrid model anyway ("all" falls back to it with a warning).
|
| 15 |
exec vllm serve "$1" --host 127.0.0.1 --port "$2" --served-model-name decider --dtype bfloat16 \
|
| 16 |
+
--gpu-memory-utilization "${3:-0.85}" --max-model-len 16384 --enable-prefix-caching --mamba-cache-mode align \
|
| 17 |
+
--logprobs-mode processed_logprobs --max-logprobs 600
|