caiovicentino1 commited on
Commit
582ffb1
·
verified ·
1 Parent(s): 99336c2

serve v1.2: up to 588 options in one pass, per-model limit (max_one_pass in decision_config.json)

Browse files
Files changed (8) hide show
  1. README.md +4 -2
  2. SHA256SUMS +7 -7
  3. decision_config.json +3 -2
  4. decision_core.py +30 -6
  5. letter_adapter.py +76 -23
  6. mlx_decide.py +15 -12
  7. serve.py +15 -4
  8. 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 all`, `--logprobs-mode processed_logprobs --max-logprobs 32`,
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
- d62539a7029001cfd591cd48685eab7d498f2c9be87875b6ba3b7f027a00e9d7 README.md
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
- 76a131c48890046ce740880052d4c892bcae8bc3a837e64a337e2756765a2cb6 decision_config.json
14
- f5ab755a8d4a92fda2dc4bc2cccdab733a9658ded5f2de034c6cc39ae0652dac decision_core.py
15
- c51fb147cc1354d08186e8a561c3f5fae58e429db9aafa74681b04f960dad4ad letter_adapter.py
16
  af1c02a2f57fbd2f41e0c11734ff38a5617d1d9f1cc3124c352d1b2e1d1f8391 local_demo.py
17
  d2903ce6ddb498b437a818b9c0c79816d9b523858438e312d9095da31ae4b291 local_demo_cases.py
18
  a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d merges.txt
19
- de3d24440e60551e4b0acc05436d72b3617c5b1140ccfddc9d0344a235d9f569 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
- 782cfe061913bf495e411054daca87129534c865304457ceb06b49a89ccd056b serve.py
25
- 87071395bbc169e36ba7bb63195f12027d63f36fab62cb5a3480e0c55ba6cf90 serve_vllm.sh
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": LETTERS[i], "description": f"{shown(lab)}: {desc}"}
59
  for i, (lab, desc) in enumerate(opts)]}
60
  return json.dumps(payload, ensure_ascii=False)
61
- ol = "\n".join(f"{LETTERS[i]}. {lab}: {desc}" for i, (lab, desc) in enumerate(opts))
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 = 20, keep: int = 2):
81
- """More than 26 options: blocks of `chunk`; the top-`keep` of each block go to the final round (≤26)."""
82
- if len(opts) <= 26:
 
 
 
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 = dist_fn(finalists[:26])
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
- from decision_core import (LETTERS, PROMPT_VERSION, STYLE, SYSTEM, messages, options_of, # noqa: F401
 
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 letters = A..(n_max); logprobs already normalized
84
- over the letters only (server run with --logprobs-mode processed_logprobs). Returns (list of letter_logprobs,
85
- n_tok)."""
 
 
86
  import urllib.request
87
- url, _ = self._sglang()
88
  want = self._loaded[2][:n_max]
89
- body = {"model": "decider", "prompt": texts, "max_tokens": 1, "temperature": 0.0, "logprobs": n_max,
90
- "allowed_token_ids": want, "return_tokens_as_token_ids": True}
91
- req = urllib.request.Request(f"{url}/v1/completions", data=json.dumps(body).encode(),
92
- headers={"Content-Type": "application/json"})
93
- with urllib.request.urlopen(req, timeout=900) as r:
94
- d = json.loads(r.read())
 
 
 
 
 
 
 
 
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 LETTERS:
107
  t = tok.encode(L, add_special_tokens=False)
108
- assert len(t) == 1, f"letter {L} is not a single token: {t}"
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
- (lp,), n_tok = self._vllm_letters([text], len(opts))
 
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
- >26 options."""
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) > len(LETTERS) for _, o in items)):
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
- lps, n_tok = self._vllm_letters(texts, max(len(o) for _, o in items))
 
 
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 or any(len(o) > len(LETTERS) for _, o in items):
 
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=20, keep=2):
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). Questions with more than 26 options use the same multi-round tournament as the GPU backends
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 LETTERS, messages, options_of, temp_for, tournament
27
- from decision_core import LETTERS, messages, options_of, temp_for, tournament # noqa: F401 (after PROMPT_STYLE is set)
 
 
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 LETTERS:
35
  t = self.tok.encode(L, add_special_tokens=False)
36
- assert len(t) == 1, f"letter {L} is not a single token: {t}"
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, 26) over the letters only (tied weights: the embedding used as a linear layer)."""
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) > 26: # only 26 letters: blocks of 20, the top 2 of each block go to a final round
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 26 options go through dist (tournament), one at a time."""
82
- if any(len(o) > 26 for _, o in items):
83
- small = [(q, o) for q, o in items if len(o) <= 26]
84
  done = iter(self.dist_many_cached(state, small, chunk) if small else [])
85
- return [self.dist(state, q, o) if len(o) > 26 else next(done) for q, o in items]
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); >26 options via a tournament;
 
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 all \
15
- --logprobs-mode processed_logprobs --max-logprobs 32
 
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