Lie24 commited on
Commit
3e2e456
·
verified ·
1 Parent(s): 05f0041

Read images and prompts up to 262,144 tokens

Browse files
README.md CHANGED
@@ -12,7 +12,8 @@ tags:
12
 
13
  <p align="center"><img src="assets/hero.png" alt="StartLux-Decision: a probability for every option" width="100%"></p>
14
 
15
- StartLux-Decision-2B answers typed questions about a state: pick one of several options, yes or no, or a rating on a scale. Every
 
16
  question comes back with a probability for each option. Requests and responses use the TypeSafe `/v1/systemone`
17
  format, so clients written for Jev work unchanged.
18
 
@@ -30,6 +31,7 @@ Sizes: [0.8B](https://huggingface.co/startlux-models/StartLux-Decision-0.8B) ·
30
  | Intern-Decision, average accuracy over seven suites | 88.46 |
31
  | Latency, one request with three questions | 15.5 ms |
32
  | Latency, one yes/no question | 9.6 ms |
 
33
 
34
  </div>
35
 
@@ -82,6 +84,15 @@ The folder ships its own inference package, `startlux_decision/`, which is the f
82
  - For bulk work, `decide_batch` batches the questions of many requests together, which is several times faster than
83
  sending them one at a time.
84
 
 
 
 
 
 
 
 
 
 
85
  ## Usage
86
 
87
  ```bash
@@ -117,6 +128,14 @@ answers, usage = m.decide(state, questions)
117
  many = m.decide_batch([(state, questions), ...])
118
  ```
119
 
 
 
 
 
 
 
 
 
120
  ## License
121
 
122
  The model weights are released under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): free for
 
12
 
13
  <p align="center"><img src="assets/hero.png" alt="StartLux-Decision: a probability for every option" width="100%"></p>
14
 
15
+ StartLux-Decision-2B answers typed questions about a state: pick one of several options, yes or no, or a rating on a scale. The
16
+ state can be text, JSON or images, up to 262,144 tokens (256K). Every
17
  question comes back with a probability for each option. Requests and responses use the TypeSafe `/v1/systemone`
18
  format, so clients written for Jev work unchanged.
19
 
 
31
  | Intern-Decision, average accuracy over seven suites | 88.46 |
32
  | Latency, one request with three questions | 15.5 ms |
33
  | Latency, one yes/no question | 9.6 ms |
34
+ | Input | text, JSON or images, up to 262,144 tokens |
35
 
36
  </div>
37
 
 
84
  - For bulk work, `decide_batch` batches the questions of many requests together, which is several times faster than
85
  sending them one at a time.
86
 
87
+ ## Images and long inputs
88
+
89
+ The weights include a vision tower, and the inference package uses it: a request can carry images as part of its
90
+ evidence, `images=[...]` in Python (PIL images, file paths, encoded bytes, base64 strings or data URIs) or
91
+ `"images": [...]` over HTTP (base64 strings or data URIs). `<image>` in a string state marks where each image goes.
92
+ Prompts can run to 262,144 tokens (256K), the model's native context: a long state is read once, in chunks, and every
93
+ question of the request branches off it. The MLX backend and the GGUF files read text only. `confidence` follows
94
+ TypeSafe's definitions (for a choice, (p_max − 1/n) / (1 − 1/n)); the top probability is in `probabilities`.
95
+
96
  ## Usage
97
 
98
  ```bash
 
128
  many = m.decide_batch([(state, questions), ...])
129
  ```
130
 
131
+ With images, from the same folder:
132
+
133
+ ```python
134
+ answers, usage = m.decide("Photo taken at delivery: <image>",
135
+ {"damaged": {"type": "noul", "instructions": "Is the parcel damaged?"}},
136
+ images=["parcel.jpg"])
137
+ ```
138
+
139
  ## License
140
 
141
  The model weights are released under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): free for
requirements.txt CHANGED
@@ -3,9 +3,14 @@ torch>=2.8
3
  transformers>=5.8
4
  huggingface_hub>=0.34
5
  safetensors>=0.4
 
 
 
6
  # required on CUDA: without them transformers silently runs a plain torch path (>10x slower)
7
- flash-linear-attention>=0.5.2
8
- causal-conv1d>=1.7 # CUDA build; if pip compiles it, add --no-build-isolation
 
 
9
  # fine-tuning
10
  peft>=0.20
11
  # evaluation (Typed Decisions parquet); the Decision Index kit is installed from its own repository
 
3
  transformers>=5.8
4
  huggingface_hub>=0.34
5
  safetensors>=0.4
6
+ # image input: the checkpoints' image processor uses torchvision; install the build that matches your torch
7
+ pillow>=10
8
+ torchvision>=0.23
9
  # required on CUDA: without them transformers silently runs a plain torch path (>10x slower)
10
+ flash-linear-attention>=0.5.2; sys_platform == "linux"
11
+ causal-conv1d>=1.7; sys_platform == "linux" # CUDA build; if pip compiles it, add --no-build-isolation
12
+ # Apple Silicon: the forward pass runs in MLX instead (startlux_decision/mlx_model.py)
13
+ mlx-lm>=0.31.3; sys_platform == "darwin" and platform_machine == "arm64"
14
  # fine-tuning
15
  peft>=0.20
16
  # evaluation (Typed Decisions parquet); the Decision Index kit is installed from its own repository
startlux_decision/check.py CHANGED
@@ -3,18 +3,34 @@
3
  python -m startlux_decision.check /path/to/StartLux-Decision-4B
4
 
5
  Exits with status 1 when flash-linear-attention or causal-conv1d is missing or not importable; transformers would then
6
- fall back to a plain torch path that is more than ten times slower.
 
7
  """
 
 
8
  import sys
9
 
10
  from .model import fast_kernels_active
11
 
12
 
 
 
 
 
 
 
 
 
13
  def main():
14
  if len(sys.argv) != 2:
15
  sys.exit(__doc__.strip())
 
 
 
 
16
  ok = fast_kernels_active(sys.argv[1])
17
  print("fast kernels: " + ("active" if ok else "NOT active, pip install flash-linear-attention causal-conv1d"))
 
18
  sys.exit(0 if ok else 1)
19
 
20
 
 
3
  python -m startlux_decision.check /path/to/StartLux-Decision-4B
4
 
5
  Exits with status 1 when flash-linear-attention or causal-conv1d is missing or not importable; transformers would then
6
+ fall back to a plain torch path that is more than ten times slower. On Apple Silicon with mlx-lm installed the server
7
+ runs the model with MLX instead, which needs neither. Also says whether image input will work (torch backend only).
8
  """
9
+ import importlib.util
10
+ import os
11
  import sys
12
 
13
  from .model import fast_kernels_active
14
 
15
 
16
+ def images_ready(path):
17
+ """"yes", or what image input still needs for the model in `path`."""
18
+ if not os.path.exists(os.path.join(path, "preprocessor_config.json")):
19
+ return "no, the model folder has no preprocessor_config.json"
20
+ missing = [m for m in ("PIL", "torchvision") if importlib.util.find_spec(m) is None]
21
+ return f"no, pip install {' '.join('pillow' if m == 'PIL' else m for m in missing)}" if missing else "yes"
22
+
23
+
24
  def main():
25
  if len(sys.argv) != 2:
26
  sys.exit(__doc__.strip())
27
+ from .server import mlx_available
28
+ if mlx_available(): # Apple Silicon: mlx-lm has its own kernels for these layers
29
+ print("Apple Silicon: the server runs the model with MLX (text only); nothing else to install")
30
+ sys.exit(0)
31
  ok = fast_kernels_active(sys.argv[1])
32
  print("fast kernels: " + ("active" if ok else "NOT active, pip install flash-linear-attention causal-conv1d"))
33
+ print("images: " + images_ready(sys.argv[1]))
34
  sys.exit(0 if ok else 1)
35
 
36
 
startlux_decision/gguf_server.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A /v1/systemone server for a StartLux-Decision GGUF file run by llama.cpp.
2
+
3
+ llama-server -m StartLux-Decision-4B-Q8_0.gguf -ngl 99 -c 16384 --parallel 4 --port 8081
4
+ python -m startlux_decision.gguf_server --model-dir StartLux-Decision-4B-GGUF --llama http://127.0.0.1:8081 --port 8090
5
+
6
+ It is StartLuxDecision with only the forward pass replaced by a llama-server call: prompt rendering, the option-letter
7
+ readout, the per-type temperatures and wide choices are the package's own code, so the answers match the original
8
+ model question by question up to the numerics of the GGUF file. MODEL_DIR holds the tokenizer files, config.json and
9
+ decision_config.json (the GGUF repositories ship them next to the .gguf files). llama-server returns the
10
+ log-probabilities of the whole vocabulary at the answer position; dividing them by the temperature and normalising over
11
+ the listed options is the same as doing it on the logits."""
12
+ import argparse
13
+ import json
14
+ import os
15
+ import urllib.request
16
+ import uuid
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+
20
+ import torch
21
+
22
+ from . import jevfmt as J
23
+ from .model import StartLuxDecision
24
+
25
+
26
+ class GGUFDecision(StartLuxDecision):
27
+ def __init__(self, path, llama, workers): # no torch model: llama-server runs the weights
28
+ from transformers import AutoTokenizer
29
+ cfg = json.load(open(os.path.join(path, "decision_config.json")))
30
+ self.tok = AutoTokenizer.from_pretrained(path)
31
+ self.letters = J.check_tokenizer(self.tok)
32
+ if self.letters != cfg["letter_token_ids"]:
33
+ raise ValueError("tokenizer letter ids differ from decision_config.json")
34
+ self.temperature = {k: float(v) for k, v in cfg["temperature_by_type"].items()}
35
+ wide = cfg.get("wide_choice", {})
36
+ self.group, self.keep, self.residual = wide.get("group", 25), wide.get("keep", 3), wide.get("residual", 1e-3)
37
+ self.max_length, self.graphs = 65536, {}
38
+ mc = json.load(open(os.path.join(path, "config.json")))
39
+ self.vocab = int(mc.get("text_config", mc).get("vocab_size", 262144))
40
+ self.url = llama.rstrip("/") + "/completion"
41
+ self.pool = ThreadPoolExecutor(workers)
42
+
43
+ def _letter_logprobs(self, ids, count):
44
+ """Next-token log-probabilities of the option letters at the last prompt position (full-vocabulary softmax;
45
+ dividing by the temperature and normalising over the options afterwards equals doing it on the logits)."""
46
+ targets = self.letters[:count]
47
+ for n in (256, self.vocab):
48
+ body = json.dumps({"prompt": ids, "n_predict": 1, "temperature": -1, "n_probs": n, "cache_prompt": False}).encode()
49
+ with urllib.request.urlopen(urllib.request.Request(self.url, body, {"Content-Type": "application/json"}), timeout=900) as r:
50
+ out = json.loads(r.read())
51
+ rows = out.get("completion_probabilities", out.get("probs"))
52
+ got = {t["id"]: t["logprob"] for t in rows[0]["top_logprobs"]}
53
+ if all(t in got for t in targets):
54
+ return [got[t] for t in targets]
55
+ raise RuntimeError("option letters missing from llama-server's log-probabilities")
56
+
57
+ def _logits(self, rows):
58
+ jobs = []
59
+ for r in rows:
60
+ order = [o["id"] for o in r["options"]]
61
+ ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
62
+ jobs.append((ids, len(order)))
63
+ outs = list(self.pool.map(lambda j: self._letter_logprobs(*j), jobs))
64
+ return [torch.tensor(o, dtype=torch.float32) for o in outs], sum(len(i) for i, _ in jobs)
65
+
66
+
67
+ def main():
68
+ ap = argparse.ArgumentParser()
69
+ ap.add_argument("--model-dir", required=True)
70
+ ap.add_argument("--llama", required=True)
71
+ ap.add_argument("--port", type=int, required=True)
72
+ ap.add_argument("--workers", type=int, default=16)
73
+ ap.add_argument("--name", help="model name reported in responses (default: the GGUF directory name)")
74
+ a = ap.parse_args()
75
+ engine = GGUFDecision(a.model_dir, a.llama, a.workers)
76
+ a.name = a.name or os.path.basename(os.path.abspath(a.model_dir))
77
+
78
+ class Handler(BaseHTTPRequestHandler):
79
+ def _send(self, code, obj):
80
+ data = json.dumps(obj).encode()
81
+ self.send_response(code)
82
+ self.send_header("Content-Type", "application/json")
83
+ self.send_header("Content-Length", str(len(data)))
84
+ self.send_header("x-typesafe-request-id", uuid.uuid4().hex)
85
+ self.end_headers()
86
+ self.wfile.write(data)
87
+
88
+ def do_GET(self):
89
+ if self.path.rstrip("/") == "/v1/models":
90
+ return self._send(200, {"models": [{"name": a.name, "description": "StartLux-Decision typed decision model "
91
+ "(GGUF)", "release_date": "2026-10-01"}]})
92
+ self._send(200, {"status": "ok", "model": a.name})
93
+
94
+ def do_POST(self):
95
+ try:
96
+ req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
97
+ answers, usage = engine.decide(req.get("state"), req["questions"])
98
+ self._send(200, {"answers": answers, "usage": usage, "model": a.name})
99
+ except (ValueError, KeyError) as e: # malformed request or one the model cannot answer
100
+ self._send(422, {"error": str(e), "detail": [{"loc": ["body"], "msg": str(e), "type": "value_error"}]})
101
+ except Exception as e: # surfaced to the client; suites.py stops on it
102
+ self._send(500, {"error": repr(e)})
103
+
104
+ def log_message(self, *args):
105
+ pass
106
+
107
+ class Server(ThreadingHTTPServer):
108
+ request_queue_size = 1024 # the default backlog of 5 resets connections under load
109
+ daemon_threads = True
110
+
111
+ Server(("127.0.0.1", a.port), Handler).serve_forever()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
startlux_decision/mlx_int8.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """int8 matmuls for MLXDecision(int8=True) on the neural accelerators of M5 and later GPUs.
2
+
3
+ The large projections (MLP, attention and linear-attention in/out projections) run as int8 x int8 -> int32 through Metal
4
+ 4's MetalPerformancePrimitives matmul2d on the M5's neural accelerators. Activations are quantized per token on every
5
+ call, weights per output channel once at load. SmoothQuant (alpha 0.75) first moves
6
+ activation outliers into the weights, using activation ranges from the built-in CALIBRATION requests, which contain no
7
+ benchmark items. The embedding, the output head and projections too small for the kernel stay as they are.
8
+
9
+ The quantize and matmul kernels are adapted from JetBrains/mlx-vlm (branch feature/int8-prefill, mlx_vlm/int8_prefill.py),
10
+ released under the MIT License:
11
+
12
+ Copyright © 2025 Prince Canuma
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
15
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
16
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
17
+ persons to whom the Software is furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
20
+ Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
23
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
24
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
25
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+ """
27
+ import re
28
+
29
+ import mlx.core as mx
30
+ import mlx.nn as nn
31
+
32
+ ALPHA = 0.75 # SmoothQuant migration strength
33
+ TILE = (64, 128, 4) # output tile rows, columns and simdgroups; requests are tens to hundreds of tokens
34
+ MIN_DIM = 1024 # projections smaller than this in either dimension stay as they are
35
+
36
+ _HEADER = """
37
+ #include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>
38
+ using namespace mpp::tensor_ops;
39
+ using namespace metal;
40
+ """
41
+
42
+ # One threadgroup per row: absmax of x * inv_s over the row, then symmetric int8 with that row's scale.
43
+ _QUANT_SRC = """
44
+ constexpr int K = {K};
45
+ constexpr int NTH = 256;
46
+ uint row = threadgroup_position_in_grid.x;
47
+ uint tid = thread_position_in_threadgroup.x;
48
+ uint lane = tid % 32;
49
+ uint sg = tid / 32;
50
+ const device {T}* xrow = x + size_t(row) * K;
51
+ float amax = 0.0f;
52
+ for (int i = tid; i < K; i += NTH) amax = max(amax, fabs(float(xrow[i]) * inv_s[i]));
53
+ amax = simd_max(amax);
54
+ threadgroup float tg_max[NTH / 32];
55
+ if (lane == 0) tg_max[sg] = amax;
56
+ threadgroup_barrier(mem_flags::mem_threadgroup);
57
+ amax = tg_max[lane % (NTH / 32)];
58
+ amax = simd_max(amax);
59
+ float scale = max(amax, 1e-8f) / 127.0f;
60
+ float inv = 1.0f / scale;
61
+ if (tid == 0) xs[row] = scale;
62
+ device int8_t* qrow = xq + size_t(row) * K;
63
+ for (int i = tid; i < K; i += NTH) qrow[i] = int8_t(clamp(rint(float(xrow[i]) * inv_s[i] * inv), -127.0f, 127.0f));
64
+ """
65
+
66
+ # One threadgroup per TM x TN output tile; matmul2d loops over K. Edge rows are bounds-checked, so any M works.
67
+ _GEMM_SRC = """
68
+ constexpr int N = {N};
69
+ constexpr int K = {K};
70
+ constexpr int TM = {TM};
71
+ constexpr int TN = {TN};
72
+ uint2 tgid = threadgroup_position_in_grid.xy;
73
+ const int M = m_dim[0];
74
+ constexpr auto desc = matmul2d_descriptor(TM, TN, static_cast<int>(dynamic_extent), false, true, true,
75
+ matmul2d_descriptor::mode::multiply_accumulate);
76
+ matmul2d<desc, execution_simdgroups<{NS}>> op;
77
+ auto A = tensor<device int8_t, dextents<int32_t, 2>, tensor_inline>((device int8_t*)xq, dextents<int32_t, 2>(K, M));
78
+ auto B = tensor<device int8_t, dextents<int32_t, 2>, tensor_inline>((device int8_t*)wq, dextents<int32_t, 2>(K, N));
79
+ auto tA = A.slice(0, int(tgid.y) * TM);
80
+ auto tB = B.slice(0, int(tgid.x) * TN);
81
+ auto cT = op.get_destination_cooperative_tensor<decltype(tA), decltype(tB), int32_t>();
82
+ #pragma unroll
83
+ for (uint16_t i = 0; i < cT.get_capacity(); ++i) if (cT.is_valid_element(i)) cT[i] = 0;
84
+ op.run(tA, tB, cT);
85
+ #pragma unroll
86
+ for (uint16_t i = 0; i < cT.get_capacity(); ++i) {{
87
+ if (cT.is_valid_element(i)) {{
88
+ auto idx = cT.get_multidimensional_index(i);
89
+ int n = int(tgid.x) * TN + idx[0];
90
+ int m = int(tgid.y) * TM + idx[1];
91
+ if (m < M && n < N) out[size_t(m) * N + n] = bfloat(float(cT[i]) * xs[m] * ws[n]);
92
+ }}
93
+ }}
94
+ """
95
+
96
+ _kernels = {}
97
+
98
+
99
+ def supported():
100
+ """True on GPUs with neural accelerators (Apple GPU family 17, the M5, and later)."""
101
+ info = mx.device_info() if hasattr(mx, "device_info") else mx.metal.device_info()
102
+ m = re.match(r"applegpu_g(\d+)", info.get("architecture", ""))
103
+ return bool(m) and int(m.group(1)) >= 17
104
+
105
+
106
+ def _quantize_rows(x, inv_s):
107
+ M, K = x.shape
108
+ key = ("q", K)
109
+ if key not in _kernels:
110
+ _kernels[key] = mx.fast.metal_kernel(name=f"sld_quant_{K}", input_names=["x", "inv_s"], output_names=["xq", "xs"],
111
+ source=_QUANT_SRC.format(K=K, T="bfloat"), header=_HEADER)
112
+ return _kernels[key](inputs=[x, inv_s], output_shapes=[(M, K), (M,)], output_dtypes=[mx.int8, mx.float32],
113
+ grid=(M * 256, 1, 1), threadgroup=(256, 1, 1))
114
+
115
+
116
+ def _matmul(xq, xs, wq, ws):
117
+ TM, TN, NS = TILE
118
+ M, K = xq.shape
119
+ N = wq.shape[0]
120
+ key = ("g", N, K)
121
+ if key not in _kernels:
122
+ _kernels[key] = mx.fast.metal_kernel(name=f"sld_i8mm_{N}_{K}", input_names=["xq", "xs", "wq", "ws", "m_dim"],
123
+ output_names=["out"], header=_HEADER,
124
+ source=_GEMM_SRC.format(N=N, K=K, TM=TM, TN=TN, NS=NS))
125
+ out, = _kernels[key](inputs=[xq, xs, wq, ws, mx.array([M], dtype=mx.int32)], output_shapes=[(M, N)],
126
+ output_dtypes=[mx.bfloat16], grid=((N // TN) * 32 * NS, (M + TM - 1) // TM, 1),
127
+ threadgroup=(32 * NS, 1, 1))
128
+ return out
129
+
130
+
131
+ class Int8Linear(nn.Module):
132
+ """nn.Linear (or an mlx-lm QuantizedLinear) as a W8A8 matmul with SmoothQuant scales."""
133
+
134
+ def __init__(self, linear, act_max):
135
+ super().__init__()
136
+ w = linear.weight
137
+ if hasattr(linear, "scales"):
138
+ w = mx.dequantize(w, linear.scales, linear.biases, linear.group_size, linear.bits)
139
+ w = w.astype(mx.float32)
140
+ s = mx.clip(mx.power(mx.maximum(act_max, 1e-5), ALPHA) / mx.power(mx.maximum(mx.abs(w).max(axis=0), 1e-5), 1 - ALPHA),
141
+ 1e-3, 1e3)
142
+ w = w * s[None, :]
143
+ self.inv_s = 1.0 / s
144
+ self.ws = mx.maximum(mx.abs(w).max(axis=1), 1e-8) / 127.0
145
+ self.wq = mx.clip(mx.round(w / self.ws[:, None]), -127, 127).astype(mx.int8)
146
+ self.bias = linear.bias if "bias" in linear else None
147
+ self.freeze()
148
+
149
+ def __call__(self, x):
150
+ shape = x.shape
151
+ x2 = x.reshape(-1, shape[-1]).astype(mx.bfloat16)
152
+ y = _matmul(*_quantize_rows(x2, self.inv_s), self.wq, self.ws)
153
+ if self.bias is not None:
154
+ y = y + self.bias
155
+ return y.reshape(*shape[:-1], self.wq.shape[0])
156
+
157
+
158
+ def _eligible(layer):
159
+ n, k = layer.weight.shape
160
+ k = k * 32 // layer.bits if hasattr(layer, "scales") else k
161
+ return n % TILE[1] == 0 and k % 32 == 0 and min(n, k) >= MIN_DIM
162
+
163
+
164
+ def _projections(body):
165
+ return [(name, mod, cname, child) for name, mod in body.named_modules() if not isinstance(mod, _Recorder)
166
+ for cname, child in mod.children().items()
167
+ if isinstance(child, (nn.Linear, nn.QuantizedLinear)) and _eligible(child)]
168
+
169
+
170
+ class _Recorder(nn.Module):
171
+ def __init__(self, inner):
172
+ super().__init__()
173
+ self.inner, self.amax = inner, None
174
+
175
+ def __call__(self, x):
176
+ m = mx.abs(x.reshape(-1, x.shape[-1]).astype(mx.float32)).max(axis=0)
177
+ self.amax = m if self.amax is None else mx.maximum(self.amax, m)
178
+ return self.inner(x)
179
+
180
+
181
+ def convert(body, sequences):
182
+ """Replace the eligible projections of `body` by Int8Linear, calibrated on `sequences` (token id lists); returns
183
+ how many were replaced. Layers are converted one at a time, so a lazily loaded model never holds two copies."""
184
+ slots = [(mod, cname, _Recorder(child)) for _, mod, cname, child in _projections(body)]
185
+ for mod, cname, rec in slots:
186
+ setattr(mod, cname, rec)
187
+ for ids in sequences:
188
+ body(mx.array([ids]))
189
+ mx.eval([rec.amax for _, _, rec in slots])
190
+ for i, (mod, cname, rec) in enumerate(slots):
191
+ new = Int8Linear(rec.inner, rec.amax)
192
+ mx.eval(new.parameters())
193
+ setattr(mod, cname, new)
194
+ if i % 16 == 15:
195
+ mx.clear_cache()
196
+ mx.clear_cache()
197
+ return len(slots)
198
+
199
+
200
+ CALIBRATION = [] # (state, questions) pairs of the kinds of requests the models serve
201
+ for _t in ("My parcel was marked delivered but never arrived.", "The app crashes when I open settings on Android 15.",
202
+ "I want a refund for the duplicate charge on invoice 7781.", "Can I change the shipping address after ordering?",
203
+ "Password reset emails never arrive.", "The discount code SPRING20 is rejected at checkout."):
204
+ CALIBRATION.append(({"ticket": _t}, {
205
+ "team": {"type": "choice", "instructions": "Which team should handle this ticket?",
206
+ "criteria": {"billing": "Payments, refunds and invoices", "shipping": "Delivery and tracking",
207
+ "technical": "App, login and account problems"}},
208
+ "urgent": {"type": "noul", "instructions": "Should this ticket be answered today?"},
209
+ "severity": {"type": "score", "instructions": "How severe is the impact?",
210
+ "criteria": ["cosmetic", "annoying", "blocks the customer"]}}))
211
+ for _n in (5, 20, 60):
212
+ CALIBRATION.append(({"page": " ".join(f"Item {i}: {['laptop', 'desk lamp', 'usb cable', 'office chair'][i % 4]} "
213
+ f"${(i * 37) % 900}.99, {'in stock' if i % 3 else 'backorder'}." for i in range(_n)),
214
+ "goal": "Buy the cheapest in-stock chair."},
215
+ {"next": {"type": "choice", "instructions": "Which item should be clicked next?",
216
+ "criteria": {f"item{i}": f"Item {i}" for i in range(min(_n, 12))}},
217
+ "done": {"type": "noul", "instructions": "Is the goal already met?"}}))
218
+ for _r in ("The hotel was spotless and the staff helpful.", "Food arrived cold and two items were missing.",
219
+ "Average experience, nothing special.", "Absolutely terrible support, never again!"):
220
+ CALIBRATION.append(({"review": _r}, {
221
+ "stars": {"type": "score", "instructions": "How satisfied is the customer?", "criteria": ["1", "2", "3", "4", "5"]},
222
+ "topic": {"type": "choice", "instructions": "What is the review mainly about?",
223
+ "criteria": {"service": "staff and support", "product": "food or room", "price": "cost",
224
+ "delivery": "delivery"}}}))
225
+ for _q, _o in (("Which planet is known as the Red Planet?", ["Venus", "Mars", "Jupiter", "Saturn"]),
226
+ ("What is 17 * 3?", ["41", "51", "57", "61"]), ("Who wrote Hamlet?", ["Dickens", "Shakespeare", "Austen", "Twain"])):
227
+ CALIBRATION.append(({"quiz": _q}, {"answer": {"type": "choice", "instructions": _q, "criteria": {o.lower(): o for o in _o}}}))
228
+ CALIBRATION.append(({"log": "\n".join(f"2026-09-30 12:{i:02d} {'ERROR' if i % 7 == 0 else 'INFO'} worker-{i % 3} step {i}"
229
+ for i in range(40))},
230
+ {"failing": {"type": "noul", "instructions": "Is any worker failing repeatedly?"},
231
+ "worker": {"type": "choice", "instructions": "Which worker logs the most errors?",
232
+ "criteria": {"w0": "worker-0", "w1": "worker-1", "w2": "worker-2"}}}))
startlux_decision/mlx_model.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """StartLux-Decision on Apple Silicon: the forward pass in MLX, everything else unchanged.
2
+
3
+ python -m startlux_decision.server --model StartLux-Decision-4B # --backend auto picks MLX on a Mac
4
+ python -m startlux_decision.server --model StartLux-Decision-4B --int8 # M5 and later: int8 matmuls
5
+
6
+ MLXDecision is StartLuxDecision with the forward pass run by mlx-lm's Qwen3.5 implementation, which has Metal kernels
7
+ for the linear-attention layers (PyTorch on MPS runs reference code there). As in GGUFDecision, prompt rendering, the
8
+ option-letter readout, the per-type temperatures and wide choices are the package's own code.
9
+
10
+ All questions of a request run in one forward pass, one right-padded row per question. Every prompt starts with the
11
+ same system text: it runs once at start-up and its cache (attention keys and values, linear-attention conv and recurrent
12
+ states) is copied to every row. When the questions of a request share a long piece of evidence, the evidence runs once
13
+ too and is kept, so asking about the same evidence again runs only the questions. int8=True moves the large
14
+ projections to int8 matmuls on the GPU's neural accelerators (mlx_int8.py).
15
+ """
16
+ import json
17
+ import os
18
+
19
+ import mlx.core as mx
20
+ import numpy as np
21
+ import torch
22
+
23
+ from . import jevfmt as J
24
+ from .model import StartLuxDecision
25
+
26
+ CACHE_LIMIT = 1 << 30 # MLX keeps freed buffers for reuse; unbounded, the cache grows with every new request shape
27
+ SHARE_MIN = 192 # evidence tokens a shared prefix saves, above which running it separately pays off
28
+ MAX_BATCH_TOKENS = 16384 # padded tokens per forward pass when many rows arrive at once (decide_batch)
29
+
30
+
31
+ class MLXDecision(StartLuxDecision):
32
+ backend = "mlx"
33
+
34
+ def __init__(self, path, int8=False, max_length=65536): # no torch model: MLX runs the weights
35
+ from mlx_lm import load
36
+ from transformers import AutoTokenizer
37
+
38
+ config = json.load(open(os.path.join(path, "config.json")))
39
+ if config.get("text_config", config).get("num_experts"):
40
+ raise NotImplementedError("mixture-of-experts checkpoints such as StartLux-Decision-35B-A3B run on the torch "
41
+ "backend: start the server with --backend torch")
42
+ cfg = json.load(open(os.path.join(path, "decision_config.json")))
43
+ self.tok = AutoTokenizer.from_pretrained(path)
44
+ self.letters = J.check_tokenizer(self.tok)
45
+ if self.letters != cfg["letter_token_ids"]:
46
+ raise ValueError("tokenizer letter ids differ from decision_config.json")
47
+ self.temperature = {k: float(v) for k, v in cfg["temperature_by_type"].items()}
48
+ wide = cfg.get("wide_choice", {})
49
+ self.group, self.keep, self.residual = wide.get("group", 25), wide.get("keep", 3), wide.get("residual", 1e-3)
50
+ self.max_length, self.graphs, self.fast_kernels = int(max_length), {}, True
51
+ self.pad = self.tok.pad_token_id if self.tok.pad_token_id is not None else 0
52
+ mx.set_cache_limit(CACHE_LIMIT)
53
+ self.model, _ = load(path, lazy=int8)
54
+ lm = getattr(self.model, "language_model", self.model)
55
+ self.body = lm.model
56
+ self.int8 = 0
57
+ if int8:
58
+ from . import mlx_int8
59
+ if not mlx_int8.supported():
60
+ raise RuntimeError("int8 needs the neural accelerators of an M5 or later GPU; run without --int8")
61
+ rows = [r for s, q in mlx_int8.CALIBRATION for _, r in self._split(s, q)[1]]
62
+ self.int8 = mlx_int8.convert(self.body, [t for t, _ in self._encode(rows)])
63
+ mx.eval(self.model.parameters())
64
+ head = lm.lm_head if hasattr(lm, "lm_head") else self.body.embed_tokens
65
+ idx = mx.array(self.letters)
66
+ w = head.weight[idx]
67
+ if hasattr(head, "scales"): # a quantized build: dequantize the letter rows only
68
+ w = mx.dequantize(w, head.scales[idx], head.biases[idx], head.group_size, head.bits)
69
+ self.letter_rows = w.astype(mx.float32)
70
+ self.snapshots = {} # token prefix -> prompt cache right after it
71
+ probes = (({"x": "a"}, {"q": {"type": "noul", "instructions": "?"}}),
72
+ ({"y": 1}, {"q": {"type": "noul", "instructions": "!"}}))
73
+ a, b = (self._encode([r for _, r in self._split(s, q)[1]])[0][0] for s, q in probes)
74
+ n = 0
75
+ while n < min(len(a), len(b)) and a[n] == b[n]:
76
+ n += 1
77
+ self.preamble = tuple(a[:n]) # the system text every prompt starts with
78
+ self._prefill(self.preamble)
79
+
80
+ def warm_up(self):
81
+ """Run a few request shapes once, so Metal compiles their kernels before the first real request."""
82
+ page = lambda n: {"page": " ".join(f"Row {i}: item {i}, qty {i % 7}." for i in range(n))}
83
+ yes_no = {"q": {"type": "noul", "instructions": "Is the item in stock?"}}
84
+ three = dict(yes_no,
85
+ c={"type": "choice", "instructions": "Which item?", "criteria": {"a": "first", "b": "second"}},
86
+ s={"type": "score", "instructions": "How full is the page?", "criteria": ["empty", "some", "full"]})
87
+ for n in (4, 40, 160):
88
+ self.decide(page(n), yes_no)
89
+ self.decide(page(n), three)
90
+
91
+ def _encode(self, rows):
92
+ out = []
93
+ for r in rows:
94
+ order = [o["id"] for o in r["options"]]
95
+ ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
96
+ out.append((ids, len(order)))
97
+ return out
98
+
99
+ def _forward(self, ids, last, cache=None):
100
+ h = self.body(ids, cache=cache)
101
+ return h[mx.arange(ids.shape[0]), last].astype(mx.float32) @ self.letter_rows.T
102
+
103
+ @staticmethod
104
+ def _copy(cache, rows):
105
+ """`cache` repeated to `rows` rows; the stored cache itself is never written to."""
106
+ from mlx_lm.models.cache import ArraysCache, KVCache
107
+ out = []
108
+ for c in cache:
109
+ if isinstance(c, KVCache):
110
+ n = KVCache()
111
+ if c.keys is not None:
112
+ n.keys = mx.repeat(c.keys[..., :c.offset, :], rows, axis=0)
113
+ n.values = mx.repeat(c.values[..., :c.offset, :], rows, axis=0)
114
+ n.offset = c.offset
115
+ else:
116
+ n = ArraysCache(len(c.cache))
117
+ n.cache = [None if a is None else mx.repeat(a, rows, axis=0) for a in c.cache]
118
+ out.append(n)
119
+ return out
120
+
121
+ def _prefill(self, prefix):
122
+ """The prompt cache right after `prefix`, resumed from the longest stored prefix it starts with, and stored."""
123
+ from mlx_lm.models.cache import make_prompt_cache
124
+ best = max((s for s in self.snapshots if prefix[:len(s)] == s), key=len, default=())
125
+ cache = self._copy(self.snapshots[best], 1) if best else make_prompt_cache(self.model)
126
+ if len(prefix) > len(best):
127
+ self.body(mx.array([prefix[len(best):]]), cache=cache)
128
+ mx.eval([c.state for c in cache])
129
+ self.snapshots[prefix] = cache
130
+ return cache
131
+
132
+ def _logits(self, rows):
133
+ enc = self._encode(rows)
134
+ toks = [t for t, _ in enc]
135
+ c = 0 # common prefix, leaving every row one token
136
+ while c < min(len(t) for t in toks) - 1 and all(t[c] == toks[0][c] for t in toks):
137
+ c += 1
138
+ prefix = tuple(toks[0][:c])
139
+ pre = list(self.preamble)
140
+ if prefix != self.preamble and prefix in self.snapshots: # the same evidence as before: questions only
141
+ groups = [(list(range(len(toks))), c, self.snapshots[prefix])]
142
+ elif (len(toks) - 1) * (c - len(pre)) >= SHARE_MIN: # long shared evidence: run it once, keep it
143
+ for s in [s for s in self.snapshots if s != self.preamble]:
144
+ del self.snapshots[s]
145
+ groups = [(list(range(len(toks))), c, self._prefill(prefix))]
146
+ else: # one pass from the stored system text
147
+ start = len(pre) if all(t[:len(pre)] == pre for t in toks) else 0
148
+ base = self.snapshots[self.preamble] if start else None
149
+ groups, batch = [], []
150
+ for j in sorted(range(len(toks)), key=lambda j: -len(toks[j])):
151
+ if batch and (len(batch) + 1) * (len(toks[batch[0]]) - start) > MAX_BATCH_TOKENS:
152
+ groups.append((batch, start, base))
153
+ batch = []
154
+ batch.append(j)
155
+ groups.append((batch, start, base))
156
+ out = [None] * len(toks)
157
+ for idx, start, cache in groups:
158
+ n = max(len(toks[j]) - start for j in idx)
159
+ ids = np.full((len(idx), n), self.pad, dtype=np.int32)
160
+ for r, j in enumerate(idx):
161
+ ids[r, :len(toks[j]) - start] = toks[j][start:]
162
+ z = np.array(self._forward(mx.array(ids), mx.array([len(toks[j]) - start - 1 for j in idx]),
163
+ self._copy(cache, len(idx)) if cache is not None else None))
164
+ for r, j in enumerate(idx):
165
+ out[j] = torch.from_numpy(z[r, :enc[j][1]].copy())
166
+ return out, sum(len(t) for t in toks)
startlux_decision/model.py CHANGED
@@ -10,17 +10,41 @@ to four questions of up to 1024 tokens each. Replaying a graph removes the per-
10
  the JevK5 runtime, Apache-2.0). Inputs are right-padded; every layer is causal and rows never mix, so padding after the
11
  last prompt token never reaches the position that is read. Other requests run eagerly as one padded batch (inputs
12
  padded to one of PAD_LENGTHS); decide_batch() batches questions across many requests.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  """
 
 
 
 
14
  import importlib
 
15
  import json
16
  import os
 
 
17
  import warnings
18
 
19
  import torch
 
20
 
21
  from . import jevfmt as J
22
 
23
- __all__ = ["StartLuxDecision", "load_model", "fast_kernels_active"]
24
 
25
  GRAPH_LENGTHS = (128, 192, 256, 320, 384, 512, 640, 768, 1024, 1536, 2048, 3072, 4096)
26
  GRAPH_ROWS = (1, 2, 3, 4) # questions per request replayed as one graph
@@ -32,6 +56,64 @@ PAD_LENGTHS = (128, 192, 256, 320, 384, 448, 512, 640, 768, 896, 1024, 1280, 153
32
  5120, 6144, 7168, 8192, 10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768, 40960, 49152, 57344, 65536)
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def padded_length(n):
36
  """The length a batch whose longest input has n tokens is padded to."""
37
  for length in PAD_LENGTHS:
@@ -40,6 +122,125 @@ def padded_length(n):
40
  return -(-n // 8192) * 8192
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def fast_kernels_active(path):
44
  """True when transformers will use the fla / causal-conv1d kernels for the linear-attention layers of the model
45
  in `path`."""
@@ -55,18 +256,26 @@ def fast_kernels_active(path):
55
 
56
  def load_model(path, device):
57
  """The checkpoint's own transformers class in bf16 on `device`, and its text decoder (the stack without the
58
- output head; checkpoints that also carry other towers keep the text decoder under .language_model)."""
 
 
59
  import transformers
60
 
61
- arch = transformers.AutoConfig.from_pretrained(path).architectures[0]
62
- model = getattr(transformers, arch).from_pretrained(path, dtype=torch.bfloat16, device_map={"": device})
 
 
63
  return model, getattr(model.model, "language_model", model.model)
64
 
65
 
66
  class StartLuxDecision:
67
- """decide(state, questions) -> (answers, usage), answers in the TypeSafe /v1/systemone format."""
68
 
69
- def __init__(self, path, device=None, max_length=65536, max_batch_tokens=65536, graphs=True):
 
 
 
 
70
  from transformers import AutoTokenizer
71
 
72
  if not os.path.isdir(path):
@@ -92,9 +301,26 @@ class StartLuxDecision:
92
  self.body = body.eval()
93
  head = model.get_output_embeddings().weight
94
  self.letter_rows = head.index_select(0, torch.tensor(self.letters, device=head.device)).float()
95
- del model # only the decoder and the letter rows of the output head are used
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  self.pad = self.tok.pad_token_id
97
  self.max_length, self.max_batch_tokens = int(max_length), int(max_batch_tokens)
 
 
 
98
  self.graphs = {}
99
  if graphs and self.device.type == "cuda" and os.environ.get("STARTLUX_GRAPHS", "1") != "0":
100
  self._capture()
@@ -143,6 +369,87 @@ class StartLuxDecision:
143
  res = out.cpu()
144
  return [res[j, :c].clone() for j, (_, _, c) in enumerate(enc)]
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  # ---- readout
147
  @torch.no_grad()
148
  def _logits(self, rows):
@@ -150,15 +457,19 @@ class StartLuxDecision:
150
  enc = []
151
  for i, r in enumerate(rows):
152
  order = [o["id"] for o in r["options"]]
153
- ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
154
- enc.append((i, ids, len(order)))
155
  out, tokens = [None] * len(rows), 0
156
- n = self._graph_length(enc) if self.graphs and enc else None
157
  if n is not None: # every question of the request in one replay
158
  for (i, t, _), z in zip(enc, self._graph_logits(enc, n)):
159
  out[i] = z
160
  tokens += len(t)
161
  return out, tokens
 
 
 
 
 
162
  enc.sort(key=lambda x: -len(x[1]))
163
  start = 0
164
  while start < len(enc):
@@ -167,21 +478,125 @@ class StartLuxDecision:
167
  batch = enc[start:start + n]
168
  start += n
169
  ids = torch.full((len(batch), longest), self.pad, dtype=torch.long)
170
- attn = torch.zeros_like(ids)
171
  for j, (_, t, _) in enumerate(batch):
172
  ids[j, :len(t)] = torch.tensor(t)
173
- attn[j, :len(t)] = 1
174
  tokens += len(t)
175
- ids, attn = ids.to(self.device), attn.to(self.device)
176
- with torch.autocast(self.device.type, dtype=torch.bfloat16):
177
- hidden = self.body(input_ids=ids, attention_mask=attn, use_cache=False, return_dict=True).last_hidden_state
178
- pos = attn.sum(1) - 1
179
  h = hidden[torch.arange(len(batch), device=self.device), pos].float()
180
  logits = h @ self.letter_rows.T
181
  for j, (i, _, c) in enumerate(batch):
182
  out[i] = logits[j, :c].cpu()
183
  return out, tokens
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  def _probs(self, rows):
186
  logits, tokens = self._logits(rows)
187
  return [torch.softmax(z / self.temperature.get(r["type"], 1.0), -1).tolist() for r, z in zip(rows, logits)], tokens
@@ -196,7 +611,7 @@ class StartLuxDecision:
196
  try:
197
  eager, _ = self._probs(rows)
198
  finally:
199
- self.graphs = graphs
200
  return max(abs(a - b) for pg, pe in zip(graphed, eager) for a, b in zip(pg, pe))
201
 
202
  def _wide(self, state, spec):
@@ -234,13 +649,13 @@ class StartLuxDecision:
234
  return {"type": "noul", "noul": p[ids.index("true")]}
235
  if row["type"] == "score":
236
  levels = question.get("criteria") or []
237
- levels = list(levels.values()) if isinstance(levels, dict) else list(levels)
238
- return {"type": "score", "score": sum(i * v for i, v in enumerate(p)), "confidence": max(p),
239
  "legend": {str(i): (levels[i] if i < len(levels) else str(i)) for i in range(len(p))},
240
  "probabilities": {str(i): v for i, v in enumerate(p)}}
241
  dist = dict(zip(ids, p))
242
  best = max(dist, key=dist.get)
243
- return {"type": "choice", "choice": best, "confidence": dist[best], "probabilities": dist}
244
 
245
  def _split(self, state, questions):
246
  """-> (answers decided without the model, [(key, rendered row)], tokens spent on wide lists)"""
@@ -255,27 +670,42 @@ class StartLuxDecision:
255
  p, n = self._wide(state, q)
256
  tokens += n
257
  best = max(p, key=p.get)
258
- answers[k] = {"type": "choice", "choice": best, "confidence": p[best], "probabilities": p}
 
259
  else:
260
  rows.append((k, J.from_systemone(state, q)))
261
  return answers, rows, tokens
262
 
263
- def decide(self, state, questions):
264
- """One request -> (answers, usage), answers in the TypeSafe /v1/systemone format."""
265
- answers, rows, tokens = self._split(state, questions)
266
- if rows:
267
- probs, n = self._probs([r for _, r in rows])
268
- tokens += n
269
- for (k, row), p in zip(rows, probs):
270
- answers[k] = self._answer(row, p, questions[k])
 
 
 
 
 
 
 
 
 
 
271
  return answers, {"input_tokens": tokens, "output_tokens": 0}
272
 
273
  def decide_batch(self, requests):
274
- """[(state, questions), ...] -> [answers, ...]. Every question of every request goes through one length-sorted
275
- set of padded forward passes (up to max_batch_tokens each), which is much faster than calling decide() in a loop
276
- when the requests are short. Answers are the same as decide() up to bf16 rounding."""
 
277
  parts, flat = [], []
278
- for state, questions in requests:
 
 
 
279
  answers, rows, _ = self._split(state, questions)
280
  parts.append((answers, rows, questions))
281
  flat.extend(r for _, r in rows)
@@ -283,7 +713,7 @@ class StartLuxDecision:
283
  try:
284
  probs, _ = self._probs(flat) if flat else ([], 0)
285
  finally:
286
- self.graphs = graphs
287
  out, i = [], 0
288
  for answers, rows, questions in parts:
289
  for k, row in rows:
 
10
  the JevK5 runtime, Apache-2.0). Inputs are right-padded; every layer is causal and rows never mix, so padding after the
11
  last prompt token never reaches the position that is read. Other requests run eagerly as one padded batch (inputs
12
  padded to one of PAD_LENGTHS); decide_batch() batches questions across many requests.
13
+
14
+ Long prompts. The questions of one request repeat the same evidence, so their prompts begin with the same tokens. When
15
+ that shared beginning is long it runs once: the decoder reads it in chunks of prefill_chunk tokens and keeps what each
16
+ layer carries forward (the keys and values of the attention layers, the convolution and recurrent state of the
17
+ linear-attention layers), then the rest of every question runs as one batch on top of it. Chunks bound the memory for
18
+ activations, so a single question over 128K tokens runs the same way and needs little more than the keys and values of
19
+ its prompt. A chunk attends to the keys before it without a mask and to its own keys causally, two fused attention
20
+ calls whose outputs are combined by their log-sum-exp; the questions attend to the shared keys the same way, all rows
21
+ against the one stored copy. Prompts can be as long as the checkpoints' native context, 262,144 tokens.
22
+
23
+ Images. A request can carry images as part of its evidence. The checkpoint's image processor resizes each to at most
24
+ max_pixels pixels (1M by default) and cuts it into patches, the vision tower turns every 2x2 patches into one
25
+ embedding, and these take the places of the image tokens in each question's prompt, with the multimodal positions the
26
+ checkpoints use. The vision tower runs once per request; the questions then take the same paths as text questions
27
+ (one padded batch, or the shared beginning in chunks), without the graphs. In a string state "<image>" marks where each
28
+ image goes; without marks the images come first, then the state.
29
  """
30
+ import base64
31
+ import binascii
32
+ import contextlib
33
+ import functools
34
  import importlib
35
+ import io
36
  import json
37
  import os
38
+ import re
39
+ import threading
40
  import warnings
41
 
42
  import torch
43
+ import torch.nn.functional as F
44
 
45
  from . import jevfmt as J
46
 
47
+ __all__ = ["StartLuxDecision", "load_model", "load_image", "fast_kernels_active", "choice_confidence", "score_confidence"]
48
 
49
  GRAPH_LENGTHS = (128, 192, 256, 320, 384, 512, 640, 768, 1024, 1536, 2048, 3072, 4096)
50
  GRAPH_ROWS = (1, 2, 3, 4) # questions per request replayed as one graph
 
56
  5120, 6144, 7168, 8192, 10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768, 40960, 49152, 57344, 65536)
57
 
58
 
59
+ SHARE_MIN = 4096 # a shared beginning this long runs once for all questions
60
+ SHARE_STEP = 1024 # the shared part is cut to a multiple of this; the tokens after it run with each question
61
+ PREFILL_CHUNK = 32768 # default chunk: activations stay a few GiB, so the 35B-A3B reads 128K tokens on one 80 GB GPU
62
+ CAUSAL = "startlux_causal"
63
+ _SHARED = threading.local() # .kv while questions run on a shared part: {attention layer index: (keys, values)}
64
+ _LSE_KERNEL = [] # the fused attention here that also returns the log-sum-exp: "cudnn", "flash" or None
65
+ IMAGE = "<image>" # in a string state: where the next image goes
66
+ _SLOT = "\x00IMAGE\x00" # an image's place in a rendered prompt
67
+
68
+
69
+ def load_image(x, paths=True):
70
+ """A PIL image, encoded image bytes, a base64 string or data URI, or (paths=True) a file path -> RGB PIL image."""
71
+ from PIL import Image, UnidentifiedImageError
72
+
73
+ if isinstance(x, Image.Image):
74
+ return x.convert("RGB")
75
+ raw = x
76
+ if isinstance(x, str):
77
+ if paths and not x.startswith("data:") and os.path.isfile(x):
78
+ with open(x, "rb") as f:
79
+ raw = f.read()
80
+ else:
81
+ try:
82
+ raw = base64.b64decode(re.sub(r"\s+", "", x.split(",", 1)[1] if x.startswith("data:") else x),
83
+ validate=True)
84
+ except (binascii.Error, IndexError):
85
+ raise ValueError("an image must be base64 or a data URI" + (", a file path" if paths else "") +
86
+ " or bytes") from None
87
+ if not isinstance(raw, (bytes, bytearray)):
88
+ raise ValueError(f"unsupported image value of type {type(x).__name__}")
89
+ try:
90
+ return Image.open(io.BytesIO(raw)).convert("RGB")
91
+ except (UnidentifiedImageError, OSError) as e:
92
+ raise ValueError(f"not a readable image: {e}") from None
93
+
94
+
95
+ class _Row(list):
96
+ """The tokens of one prompt in a request with images, with each token's multimodal position (.pos, 3 x n) and the
97
+ index of the vision embedding each image token takes (.feature, -1 for text tokens)."""
98
+
99
+
100
+ def choice_confidence(p):
101
+ """TypeSafe's confidence of a Choice answer: how far the top probability sits above an even split, from 0 (even) to
102
+ 1 (all on one option), (p_max - 1/n) / (1 - 1/n)."""
103
+ n = len(p)
104
+ return 1.0 if n < 2 else min(1.0, max(0.0, (n * max(p) - 1) / (n - 1)))
105
+
106
+
107
+ def score_confidence(p):
108
+ """TypeSafe's confidence of a Score answer: 1 minus the probability-weighted distance in levels from the most likely
109
+ level, relative to the same distance for an even spread measured from the middle level, floored at 0."""
110
+ n = len(p)
111
+ m = max(range(n), key=p.__getitem__)
112
+ spread = sum(v * abs(i - m) for i, v in enumerate(p))
113
+ even = sum(abs(i - (n - 1) / 2) for i in range(n)) / n
114
+ return max(0.0, 1 - spread / even) if even else 1.0
115
+
116
+
117
  def padded_length(n):
118
  """The length a batch whose longest input has n tokens is padded to."""
119
  for length in PAD_LENGTHS:
 
122
  return -(-n // 8192) * 8192
123
 
124
 
125
+ def _attend(query, key, value, causal, scale):
126
+ """softmax(q k^T) v and the log-sum-exp of each query's scores, from cuDNN or else the flash kernel; None when
127
+ neither runs here."""
128
+ if not _LSE_KERNEL:
129
+ for kind in ("cudnn", "flash", None):
130
+ try:
131
+ out = _attend_with(kind, query, key, value, causal, scale)
132
+ except RuntimeError:
133
+ continue
134
+ _LSE_KERNEL.append(kind)
135
+ return out
136
+ return _attend_with(_LSE_KERNEL[0], query, key, value, causal, scale)
137
+
138
+
139
+ def _attend_with(kind, query, key, value, causal, scale):
140
+ if kind is None:
141
+ return None
142
+ if kind == "cudnn":
143
+ out = torch.ops.aten._scaled_dot_product_cudnn_attention(query, key, value, None, True, 0.0, causal, False,
144
+ scale=scale)
145
+ else:
146
+ groups = query.shape[1] // key.shape[1]
147
+ if groups > 1:
148
+ key, value = key.repeat_interleave(groups, 1), value.repeat_interleave(groups, 1)
149
+ out = torch.ops.aten._scaled_dot_product_flash_attention(query, key, value, 0.0, causal, False, scale=scale)
150
+ return out[0], out[1].reshape(query.shape[:3])
151
+
152
+
153
+ def _merge(a, b):
154
+ """Attention over two disjoint key sets, from each part's output and log-sum-exp."""
155
+ (out_a, lse_a), (out_b, lse_b) = a, b
156
+ top = torch.maximum(lse_a, lse_b)
157
+ wa, wb = (lse_a - top).exp().unsqueeze(-1), (lse_b - top).exp().unsqueeze(-1)
158
+ return ((out_a.float() * wa + out_b.float() * wb) / (wa + wb)).to(out_a.dtype)
159
+
160
+
161
+ def _causal_attention(module, query, key, value, attention_mask, dropout=0.0, scaling=None, **kwargs):
162
+ """Causal attention for queries at the end of the keys, without a mask: inputs on this path are right-padded, so
163
+ causality is the only mask needed. The first chunk is one causal call. A later chunk attends to the keys before
164
+ it without a mask and to its own keys causally; questions on a shared part attend to its stored keys (all rows
165
+ against the one copy) and to their own keys causally."""
166
+ from torch.nn.attention.bias import causal_lower_right
167
+
168
+ rows, heads, q, dim = query.shape
169
+ shared = getattr(_SHARED, "kv", None)
170
+ shared = shared.get(module.layer_idx) if shared else None
171
+ if shared is not None:
172
+ folded = query.transpose(0, 1).reshape(1, heads, rows * q, dim)
173
+ part = _attend(folded, shared[0], shared[1], False, scaling)
174
+ if part is not None:
175
+ out_a = part[0].reshape(heads, rows, q, dim).transpose(0, 1)
176
+ lse_a = part[1].reshape(heads, rows, q).transpose(0, 1)
177
+ out = _merge((out_a, lse_a), _attend(query, key, value, True, scaling))
178
+ return out.transpose(1, 2).contiguous(), None
179
+ key = torch.cat([shared[0].expand(rows, -1, -1, -1), key], dim=-2)
180
+ value = torch.cat([shared[1].expand(rows, -1, -1, -1), value], dim=-2)
181
+ k = key.shape[2]
182
+ if q == k:
183
+ out = F.scaled_dot_product_attention(query, key, value, is_causal=q > 1, scale=scaling, enable_gqa=True)
184
+ else:
185
+ before = _attend(query, key[:, :, :k - q], value[:, :, :k - q], False, scaling)
186
+ if before is not None:
187
+ out = _merge(before, _attend(query, key[:, :, k - q:], value[:, :, k - q:], True, scaling))
188
+ else:
189
+ out = F.scaled_dot_product_attention(query, key, value, attn_mask=causal_lower_right(q, k), scale=scaling,
190
+ enable_gqa=True)
191
+ return out.transpose(1, 2).contiguous(), None
192
+
193
+
194
+ def _register_causal_attention():
195
+ from transformers import AttentionInterface
196
+ from transformers.masking_utils import AttentionMaskInterface
197
+
198
+ AttentionInterface.register(CAUSAL, _causal_attention)
199
+ AttentionMaskInterface.register(CAUSAL, lambda *args, **kwargs: None)
200
+
201
+
202
+ def _common_length(a, b):
203
+ n = min(len(a), len(b))
204
+ diff = (torch.tensor(a[:n]) != torch.tensor(b[:n])).nonzero()
205
+ return int(diff[0, 0]) if len(diff) else n
206
+
207
+
208
+ @functools.cache
209
+ def _shared_kv_layer():
210
+ from transformers.cache_utils import DynamicLayer
211
+
212
+ class SharedKV(DynamicLayer):
213
+ """Keys and values of one attention layer over the shared tokens. The chunks of the shared part fill a buffer
214
+ sized for all of them; once frozen, nothing more is stored and a pass over the questions reads them from that
215
+ one copy."""
216
+
217
+ def __init__(self, length):
218
+ super().__init__()
219
+ self.length, self.end, self.frozen = length, 0, False
220
+
221
+ def update(self, key_states, value_states, *args, **kwargs):
222
+ if self.frozen: # the attention reads the shared keys through _SHARED
223
+ return key_states, value_states
224
+ if not self.is_initialized:
225
+ b, h, _, d = key_states.shape
226
+ self.keys = key_states.new_empty((b, h, self.length, d))
227
+ self.values = value_states.new_empty((b, h, self.length, d))
228
+ self.dtype, self.device, self.is_initialized = key_states.dtype, key_states.device, True
229
+ n = key_states.shape[-2]
230
+ self.keys[:, :, self.end:self.end + n] = key_states
231
+ self.values[:, :, self.end:self.end + n] = value_states
232
+ self.end += n
233
+ return self.keys[:, :, :self.end], self.values[:, :, :self.end]
234
+
235
+ def get_seq_length(self):
236
+ return self.end
237
+
238
+ def get_mask_sizes(self, query_length):
239
+ return self.end + query_length, 0
240
+
241
+ return SharedKV
242
+
243
+
244
  def fast_kernels_active(path):
245
  """True when transformers will use the fla / causal-conv1d kernels for the linear-attention layers of the model
246
  in `path`."""
 
256
 
257
  def load_model(path, device):
258
  """The checkpoint's own transformers class in bf16 on `device`, and its text decoder (the stack without the
259
+ output head; checkpoints that also carry other towers keep the text decoder under .language_model). The experts
260
+ of a mixture-of-experts checkpoint run as grouped matrix multiplications: one kernel per projection for all
261
+ experts and no host synchronisation, so the CUDA graphs can record them."""
262
  import transformers
263
 
264
+ config = transformers.AutoConfig.from_pretrained(path)
265
+ extra = {"experts_implementation": "grouped_mm"} if getattr(config.get_text_config(), "num_experts", 0) else {}
266
+ model = getattr(transformers, config.architectures[0]).from_pretrained(path, dtype=torch.bfloat16,
267
+ device_map={"": device}, **extra)
268
  return model, getattr(model.model, "language_model", model.model)
269
 
270
 
271
  class StartLuxDecision:
272
+ """decide(state, questions, images=None) -> (answers, usage), answers in the TypeSafe /v1/systemone format.
273
 
274
+ images=False leaves the vision tower out (about 0.2 to 0.9 GB less GPU memory); max_pixels bounds the size of
275
+ each image after resizing."""
276
+
277
+ def __init__(self, path, device=None, max_length=262144, max_batch_tokens=65536, graphs=True,
278
+ prefill_chunk=PREFILL_CHUNK, images=True, max_pixels=1 << 20, min_pixels=65536):
279
  from transformers import AutoTokenizer
280
 
281
  if not os.path.isdir(path):
 
301
  self.body = body.eval()
302
  head = model.get_output_embeddings().weight
303
  self.letter_rows = head.index_select(0, torch.tensor(self.letters, device=head.device)).float()
304
+ self.vision, self.image_processor, self.vision_note = None, None, "the checkpoint has no vision tower"
305
+ if not images:
306
+ self.vision_note = "the model was loaded with images=False"
307
+ elif getattr(model.model, "visual", None) is not None:
308
+ try:
309
+ from transformers import AutoImageProcessor
310
+
311
+ self.image_processor = AutoImageProcessor.from_pretrained(path)
312
+ self.image_processor.size = {"longest_edge": int(max_pixels), "shortest_edge": int(min_pixels)}
313
+ self.vision = model.model.eval() # vision tower, multimodal positions; its decoder is self.body
314
+ self.image_token = model.config.image_token_id
315
+ except (OSError, ImportError, ValueError) as e:
316
+ self.vision_note = f"no image processor ({str(e).splitlines()[0][:200]})"
317
+ del model # the decoder, the letter rows of the output head and the vision tower are used
318
+ self._media = None # the vision embeddings of the request being decided, when it has images
319
  self.pad = self.tok.pad_token_id
320
  self.max_length, self.max_batch_tokens = int(max_length), int(max_batch_tokens)
321
+ self.share, self.prefill_chunk = True, int(prefill_chunk)
322
+ self._kept = None # (tokens, cache) of the last shared part, for the rest of one request
323
+ _register_causal_attention()
324
  self.graphs = {}
325
  if graphs and self.device.type == "cuda" and os.environ.get("STARTLUX_GRAPHS", "1") != "0":
326
  self._capture()
 
369
  res = out.cpu()
370
  return [res[j, :c].clone() for j, (_, _, c) in enumerate(enc)]
371
 
372
+ # ---- images
373
+ def _media_state(self, state, n):
374
+ """The state as text with the places of n images: at its "<image>" marks, else first."""
375
+ text = J.state_text(state)
376
+ marks = text.count(IMAGE)
377
+ if marks == n:
378
+ return text.replace(IMAGE, _SLOT)
379
+ if marks:
380
+ raise ValueError(f"the state has {marks} {IMAGE} marks for {n} images")
381
+ head = _SLOT if n == 1 else "\n".join(f"Image {k + 1}: {_SLOT}" for k in range(n))
382
+ return head + ("\n\n" + text if text.strip() else "")
383
+
384
+ @torch.no_grad()
385
+ def _encode_images(self, images):
386
+ """-> the vision embeddings of a request's images, and per image its (t, h, w) patch grid and token count."""
387
+ if getattr(self, "vision", None) is None: # also the MLX and GGUF backends, which read text only
388
+ raise ValueError(f"this model takes no images: {getattr(self, 'vision_note', 'this backend reads text only')}")
389
+ enc = self.image_processor(images=[load_image(x) for x in images], return_tensors="pt")
390
+ grid = enc["image_grid_thw"]
391
+ with torch.autocast(self.device.type, dtype=torch.bfloat16):
392
+ out = self.vision.get_image_features(enc["pixel_values"].to(self.device), grid.to(self.device),
393
+ return_dict=True)
394
+ merge = self.vision.visual.spatial_merge_size
395
+ return {"features": torch.cat(out.pooler_output, 0), "grid": grid, "tokens": (grid.prod(-1) // merge ** 2).tolist()}
396
+
397
+ def _render(self, row, order):
398
+ """A rendered record -> its prompt tokens; in a request with images, each image's slot becomes its tokens."""
399
+ if self._media is None:
400
+ return J.render_ids(row, self.tok, order, max_length=self.max_length)[0]
401
+ msgs, _ = J.messages(row, order)
402
+ content, parts = [], msgs[1]["content"].split(_SLOT)
403
+ for k, part in enumerate(parts):
404
+ if part:
405
+ content.append({"type": "text", "text": part})
406
+ if k < len(parts) - 1:
407
+ content.append({"type": "image"})
408
+ chat = [{"role": "system", "content": [{"type": "text", "text": msgs[0]["content"]}]},
409
+ {"role": "user", "content": content}]
410
+ text = self.tok.apply_chat_template(chat, tokenize=False, add_generation_prompt=True, enable_thinking=False)
411
+ if not text.endswith(J.THINK_OFF_SUFFIX):
412
+ raise ValueError("chat template lacks the thinking-off assistant prefix")
413
+ counts, ids, k = self._media["tokens"], _Row(), 0
414
+ for t in self.tok.encode(text, add_special_tokens=False):
415
+ if t == self.image_token:
416
+ if k == len(counts):
417
+ raise ValueError("more image places than images")
418
+ ids.extend([t] * counts[k])
419
+ k += 1
420
+ else:
421
+ ids.append(t)
422
+ if k != len(counts):
423
+ raise ValueError("fewer image places than images")
424
+ if len(ids) > self.max_length:
425
+ raise ValueError(f"length {len(ids)} > {self.max_length}")
426
+ t = torch.tensor(ids)
427
+ image = t == self.image_token
428
+ pos, _ = self.vision.get_rope_index(t[None], mm_token_type_ids=image.int()[None],
429
+ image_grid_thw=self._media["grid"])
430
+ ids.pos = pos[:, 0]
431
+ ids.feature = torch.where(image, image.long().cumsum(0) - 1, -1)
432
+ return ids
433
+
434
+ def _forward(self, ids, rows, offset=0, **kwargs):
435
+ """The decoder over ids (right-padded rows: tokens offset.. of each prompt in rows). In a request with images
436
+ the image tokens take their vision embeddings and every token its multimodal position."""
437
+ if self._media is None:
438
+ return self.body(input_ids=ids.to(self.device), **kwargs)
439
+ ids = ids.to(self.device)
440
+ embeds = self.body.get_input_embeddings()(ids)
441
+ features, n = self._media["features"], ids.shape[1]
442
+ pos = torch.empty((3, ids.shape[0], n), dtype=torch.long)
443
+ for j, t in enumerate(rows):
444
+ p = t.pos[:, offset:offset + n]
445
+ pos[:, j, :p.shape[1]] = p
446
+ pos[:, j, p.shape[1]:] = p[:, -1:] + torch.arange(1, n - p.shape[1] + 1)
447
+ f = t.feature[offset:offset + n]
448
+ at = (f >= 0).nonzero().squeeze(1)
449
+ if len(at):
450
+ embeds[j, at.to(self.device)] = features[f[at].to(self.device)].to(embeds.dtype)
451
+ return self.body(inputs_embeds=embeds, position_ids=pos.to(self.device), **kwargs)
452
+
453
  # ---- readout
454
  @torch.no_grad()
455
  def _logits(self, rows):
 
457
  enc = []
458
  for i, r in enumerate(rows):
459
  order = [o["id"] for o in r["options"]]
460
+ enc.append((i, self._render(r, order), len(order)))
 
461
  out, tokens = [None] * len(rows), 0
462
+ n = self._graph_length(enc) if self.graphs and enc and self._media is None else None
463
  if n is not None: # every question of the request in one replay
464
  for (i, t, _), z in zip(enc, self._graph_logits(enc, n)):
465
  out[i] = z
466
  tokens += len(t)
467
  return out, tokens
468
+ groups, enc = self._shared_groups(enc) if self.share else ([], enc)
469
+ for c, group in groups: # long prompts with a shared beginning
470
+ for (i, t, _), z in zip(group, self._shared_logits(group, c)):
471
+ out[i] = z
472
+ tokens += len(t)
473
  enc.sort(key=lambda x: -len(x[1]))
474
  start = 0
475
  while start < len(enc):
 
478
  batch = enc[start:start + n]
479
  start += n
480
  ids = torch.full((len(batch), longest), self.pad, dtype=torch.long)
 
481
  for j, (_, t, _) in enumerate(batch):
482
  ids[j, :len(t)] = torch.tensor(t)
 
483
  tokens += len(t)
484
+ with torch.autocast(self.device.type, dtype=torch.bfloat16), self._causal():
485
+ hidden = self._forward(ids, [t for _, t, _ in batch], use_cache=False, return_dict=True).last_hidden_state
486
+ pos = torch.tensor([len(t) - 1 for _, t, _ in batch], device=self.device)
 
487
  h = hidden[torch.arange(len(batch), device=self.device), pos].float()
488
  logits = h @ self.letter_rows.T
489
  for j, (i, _, c) in enumerate(batch):
490
  out[i] = logits[j, :c].cpu()
491
  return out, tokens
492
 
493
+ @contextlib.contextmanager
494
+ def _causal(self):
495
+ """Attention through _causal_attention: right-padded rows, causality as the only mask, no mask tensor."""
496
+ config = self.body.config
497
+ before, config._attn_implementation = config._attn_implementation, CAUSAL
498
+ try:
499
+ yield
500
+ finally:
501
+ config._attn_implementation = before
502
+
503
+ # ---- long prompts: the shared beginning once, in chunks, then the questions on top of it
504
+ def _shared_groups(self, enc):
505
+ """-> ([(c, rows that share their first c tokens)], other rows). Rows are grouped by their first SHARE_MIN
506
+ tokens; c is cut to a multiple of SHARE_STEP and leaves every row at least one token of its own. One row
507
+ alone is a group when it is longer than one chunk."""
508
+ by_start, rest = {}, []
509
+ for e in enc:
510
+ if len(e[1]) > SHARE_MIN:
511
+ by_start.setdefault(tuple(e[1][:SHARE_MIN]), []).append(e)
512
+ else:
513
+ rest.append(e)
514
+ groups = []
515
+ for group in by_start.values():
516
+ first = group[0][1]
517
+ c = min(len(t) for _, t, _ in group) - 1
518
+ for _, t, _ in group[1:]:
519
+ c = min(c, _common_length(first, t))
520
+ c = c // SHARE_STEP * SHARE_STEP
521
+ kept = self._kept[0] if self._kept else ()
522
+ if (kept and all(len(t) > len(kept) and tuple(t[:len(kept)]) == kept for _, t, _ in group)
523
+ and max(len(t) for _, t, _ in group) - len(kept) <= self.prefill_chunk):
524
+ c = len(kept) # a later round of the same request (wide choices) reuses it
525
+ if c >= SHARE_MIN and (len(group) > 1 or len(first) > self.prefill_chunk):
526
+ groups.append((c, group))
527
+ else:
528
+ rest.extend(group)
529
+ return groups, rest
530
+
531
+ def _rows_cache(self, shared, rows):
532
+ """A cache for one pass of `rows` questions on top of the shared part: the attention layers read the shared
533
+ keys and values (one copy); each linear-attention layer starts every row from a copy of the shared state."""
534
+ from transformers import DynamicCache
535
+ from transformers.cache_utils import LinearAttentionLayer
536
+
537
+ cache = DynamicCache(config=self.body.config)
538
+ for i, layer in enumerate(shared.layers):
539
+ if not isinstance(layer, LinearAttentionLayer):
540
+ cache.layers[i] = layer
541
+ continue
542
+ copy = LinearAttentionLayer()
543
+ for name in ("conv_states", "recurrent_states"):
544
+ x = getattr(layer, name)
545
+ setattr(copy, name, x.repeat(rows, *[1] * (x.dim() - 1)))
546
+ copy.dtype, copy.device = layer.dtype, layer.device
547
+ copy.max_batch_size, copy.conv_kernel_size = rows, layer.conv_kernel_size
548
+ copy.is_conv_states_initialized = copy.is_recurrent_states_initialized = copy.has_previous_state = True
549
+ cache.layers[i] = copy
550
+ return cache
551
+
552
+ @torch.no_grad()
553
+ def _shared_logits(self, group, c):
554
+ """Letter logits for rows (rendered prompts) that share their first c tokens."""
555
+ from transformers import DynamicCache
556
+ from transformers.cache_utils import LinearAttentionLayer
557
+
558
+ SharedKV = _shared_kv_layer()
559
+ prefix = tuple(group[0][1][:c])
560
+ out = []
561
+ try:
562
+ with torch.autocast(self.device.type, dtype=torch.bfloat16), self._causal():
563
+ if self._kept and self._kept[0] == prefix:
564
+ shared = self._kept[1]
565
+ else:
566
+ self._kept = None
567
+ shared = DynamicCache(config=self.body.config)
568
+ for i, layer in enumerate(shared.layers):
569
+ if not isinstance(layer, LinearAttentionLayer):
570
+ shared.layers[i] = SharedKV(c)
571
+ ids = torch.tensor([prefix])
572
+ for start in range(0, c, self.prefill_chunk):
573
+ self._forward(ids[:, start:start + self.prefill_chunk], [group[0][1]], start,
574
+ past_key_values=shared, use_cache=True)
575
+ del ids
576
+ self._kept = (prefix, shared)
577
+ _SHARED.kv = {}
578
+ for i, layer in enumerate(shared.layers):
579
+ if isinstance(layer, SharedKV):
580
+ layer.frozen = True
581
+ _SHARED.kv[i] = (layer.keys[:, :, :layer.end], layer.values[:, :, :layer.end])
582
+ tails = [(t, k) for _, t, k in group] # whole prompts; their tokens from c on run here
583
+ per_pass = max(1, self.max_batch_tokens // padded_length(max(len(t) - c for t, _ in tails)))
584
+ for start in range(0, len(tails), per_pass):
585
+ batch = tails[start:start + per_pass]
586
+ n = padded_length(max(len(t) - c for t, _ in batch))
587
+ ids = torch.full((len(batch), n), self.pad, dtype=torch.long)
588
+ for j, (t, _) in enumerate(batch):
589
+ ids[j, :len(t) - c] = torch.tensor(t[c:])
590
+ cache = self._rows_cache(shared, len(batch))
591
+ hidden = self._forward(ids, [t for t, _ in batch], c, past_key_values=cache,
592
+ use_cache=True).last_hidden_state
593
+ last = torch.tensor([len(t) - c - 1 for t, _ in batch], device=self.device)
594
+ logits = hidden[torch.arange(len(batch), device=self.device), last].float() @ self.letter_rows.T
595
+ out.extend(logits[j, :k].cpu() for j, (_, k) in enumerate(batch))
596
+ finally:
597
+ _SHARED.kv = None
598
+ return out
599
+
600
  def _probs(self, rows):
601
  logits, tokens = self._logits(rows)
602
  return [torch.softmax(z / self.temperature.get(r["type"], 1.0), -1).tolist() for r, z in zip(rows, logits)], tokens
 
611
  try:
612
  eager, _ = self._probs(rows)
613
  finally:
614
+ self.graphs, self._kept = graphs, None
615
  return max(abs(a - b) for pg, pe in zip(graphed, eager) for a, b in zip(pg, pe))
616
 
617
  def _wide(self, state, spec):
 
649
  return {"type": "noul", "noul": p[ids.index("true")]}
650
  if row["type"] == "score":
651
  levels = question.get("criteria") or []
652
+ levels = [levels[k] for k in J.score_keys(levels)] if isinstance(levels, dict) else list(levels)
653
+ return {"type": "score", "score": sum(i * v for i, v in enumerate(p)), "confidence": score_confidence(p),
654
  "legend": {str(i): (levels[i] if i < len(levels) else str(i)) for i in range(len(p))},
655
  "probabilities": {str(i): v for i, v in enumerate(p)}}
656
  dist = dict(zip(ids, p))
657
  best = max(dist, key=dist.get)
658
+ return {"type": "choice", "choice": best, "confidence": choice_confidence(p), "probabilities": dist}
659
 
660
  def _split(self, state, questions):
661
  """-> (answers decided without the model, [(key, rendered row)], tokens spent on wide lists)"""
 
670
  p, n = self._wide(state, q)
671
  tokens += n
672
  best = max(p, key=p.get)
673
+ answers[k] = {"type": "choice", "choice": best, "confidence": choice_confidence(list(p.values())),
674
+ "probabilities": p}
675
  else:
676
  rows.append((k, J.from_systemone(state, q)))
677
  return answers, rows, tokens
678
 
679
+ def decide(self, state, questions, images=None):
680
+ """One request -> (answers, usage), answers in the TypeSafe /v1/systemone format. images: the request's
681
+ evidence images (PIL images, file paths, encoded bytes, base64 strings or data URIs), placed at the "<image>"
682
+ marks of a string state, or before the state."""
683
+ if images is not None and not isinstance(images, (list, tuple)):
684
+ images = [images]
685
+ try:
686
+ if images:
687
+ self._media = self._encode_images(images)
688
+ state = self._media_state(state, len(images))
689
+ answers, rows, tokens = self._split(state, questions)
690
+ if rows:
691
+ probs, n = self._probs([r for _, r in rows])
692
+ tokens += n
693
+ for (k, row), p in zip(rows, probs):
694
+ answers[k] = self._answer(row, p, questions[k])
695
+ finally:
696
+ self._kept = self._media = None
697
  return answers, {"input_tokens": tokens, "output_tokens": 0}
698
 
699
  def decide_batch(self, requests):
700
+ """[(state, questions) or (state, questions, images), ...] -> [answers, ...]. Every question of every text
701
+ request goes through one length-sorted set of padded forward passes (up to max_batch_tokens each), which is
702
+ much faster than calling decide() in a loop when the requests are short; requests with images run one at a time.
703
+ Answers are the same as decide() up to bf16 rounding."""
704
  parts, flat = [], []
705
+ for state, questions, *images in requests:
706
+ if images and images[0]:
707
+ parts.append((self.decide(state, questions, images[0])[0], [], questions))
708
+ continue
709
  answers, rows, _ = self._split(state, questions)
710
  parts.append((answers, rows, questions))
711
  flat.extend(r for _, r in rows)
 
713
  try:
714
  probs, _ = self._probs(flat) if flat else ([], 0)
715
  finally:
716
+ self.graphs, self._kept = graphs, None
717
  out, i = [], 0
718
  for answers, rows, questions in parts:
719
  for k, row in rows:
startlux_decision/server.py CHANGED
@@ -2,32 +2,64 @@
2
 
3
  python -m startlux_decision.server --model /path/to/StartLux-Decision-4B --port 8090
4
 
5
- POST /v1/systemone {"state": ..., "questions": {key: {"type", "instructions", "criteria"}}}
6
  -> {"answers": {key: answer}, "usage": {"input_tokens", "output_tokens"}, "model": ...}
7
- GET /health {"status", "model", "fast_kernels", "cuda_graphs"}
8
- GET /v1/models
 
 
 
 
9
  Requests are served one at a time on one GPU. At start-up one request runs through both the CUDA-graph path and the
10
  eager path; if their probabilities differ by more than 0.02 the graphs are dropped.
 
 
 
11
  """
12
  import argparse
13
  import json
14
  import os
15
  import threading
16
  import time
 
17
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
 
19
 
 
 
 
 
 
 
 
 
 
 
20
  def main():
21
  ap = argparse.ArgumentParser()
22
  ap.add_argument("--model", required=True, help="local StartLux-Decision directory")
23
  ap.add_argument("--host", default="127.0.0.1")
24
  ap.add_argument("--port", type=int, default=8090)
25
- ap.add_argument("--device", help="cuda or cpu (default: cuda when available)")
 
 
 
26
  ap.add_argument("--name", help="model name reported in responses (default: the directory name)")
 
 
 
27
  a = ap.parse_args()
28
- from .model import StartLuxDecision
29
-
30
- engine = StartLuxDecision(a.model, device=a.device)
 
 
 
 
 
 
 
 
31
  demo_state = {"ticket": "I was charged twice for order #4411 and the app still shows it as unpaid."}
32
  demo_questions = {
33
  "team": {"type": "choice", "instructions": "Which team should handle this ticket?",
@@ -46,6 +78,10 @@ def main():
46
  engine.graphs = {}
47
  lock = threading.Lock()
48
  name = a.name or os.path.basename(os.path.normpath(a.model))
 
 
 
 
49
 
50
  class Handler(BaseHTTPRequestHandler):
51
  def _send(self, code, obj):
@@ -53,42 +89,60 @@ def main():
53
  self.send_response(code)
54
  self.send_header("Content-Type", "application/json")
55
  self.send_header("Content-Length", str(len(data)))
 
56
  self.end_headers()
57
  self.wfile.write(data)
58
 
 
 
 
59
  def do_GET(self):
60
  path = self.path.rstrip("/")
61
  if path in ("/health", "/v1/health"):
62
- return self._send(200, {"status": "ok", "model": name, "fast_kernels": engine.fast_kernels,
63
- "cuda_graphs": len(engine.graphs)})
 
64
  if path == "/v1/models":
65
- return self._send(200, {"models": [{"name": name, "description": "StartLux-Decision typed decision model"}]})
66
- self._send(404, {"error": "not found"})
 
67
 
68
  def do_POST(self):
69
  if self.path.rstrip("/") != "/v1/systemone":
70
- return self._send(404, {"error": "not found"})
71
  try:
72
  body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
73
  questions = body.get("questions")
74
  if not isinstance(questions, dict) or not questions:
75
  raise ValueError("questions must be a non-empty object")
 
 
 
 
 
 
 
 
76
  except ValueError as e:
77
- return self._send(400, {"error": str(e)})
78
  t = time.perf_counter()
79
  try:
80
  with lock:
81
- answers, usage = engine.decide(body.get("state"), questions)
 
82
  except ValueError as e: # unknown question type, prompt over the context limit
83
- return self._send(422, {"error": str(e)})
84
  self._send(200, {"answers": answers, "usage": usage, "model": name,
85
  "latency_ms": round(1000 * (time.perf_counter() - t), 2)})
86
 
87
  def log_message(self, *args):
88
  pass
89
 
90
- print(f"{name} serving on http://{a.host}:{a.port}/v1/systemone (fast kernels: {engine.fast_kernels}, "
91
- f"cuda graphs: {len(engine.graphs)})", flush=True)
 
 
 
92
  ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
93
 
94
 
 
2
 
3
  python -m startlux_decision.server --model /path/to/StartLux-Decision-4B --port 8090
4
 
5
+ POST /v1/systemone {"state": ..., "questions": {key: {"type", "instructions", "criteria"}}, "images": [...]}
6
  -> {"answers": {key: answer}, "usage": {"input_tokens", "output_tokens"}, "model": ...}
7
+ "images" is optional: base64 strings or data URIs, part of the evidence ("<image>" in a string state marks where
8
+ each goes, otherwise they come first); the torch backend only.
9
+ GET /health {"status", "model", "backend", "fast_kernels", "cuda_graphs", "images", "max_length"}
10
+ GET /v1/models {"models": [{"name", "description", "release_date"}]}
11
+ Every response carries an x-typesafe-request-id header; errors are {"error": message, "detail": [{"loc", "msg", "type"}]}
12
+ (400 for a malformed body, 422 for a request the model cannot answer). confidence follows TypeSafe's definitions.
13
  Requests are served one at a time on one GPU. At start-up one request runs through both the CUDA-graph path and the
14
  eager path; if their probabilities differ by more than 0.02 the graphs are dropped.
15
+
16
+ --backend auto (the default) runs the model with MLX on Apple Silicon when mlx-lm is installed (mlx_model.py) and with
17
+ PyTorch everywhere else; --int8 adds int8 matmuls on M5 and later Macs (mlx_int8.py).
18
  """
19
  import argparse
20
  import json
21
  import os
22
  import threading
23
  import time
24
+ import uuid
25
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26
 
27
 
28
+ def mlx_available():
29
+ """True on Apple Silicon with mlx-lm installed (requirements.txt installs it there)."""
30
+ try:
31
+ import mlx.core as mx
32
+ import mlx_lm # noqa: F401
33
+ return mx.metal.is_available()
34
+ except ImportError:
35
+ return False
36
+
37
+
38
  def main():
39
  ap = argparse.ArgumentParser()
40
  ap.add_argument("--model", required=True, help="local StartLux-Decision directory")
41
  ap.add_argument("--host", default="127.0.0.1")
42
  ap.add_argument("--port", type=int, default=8090)
43
+ ap.add_argument("--device", help="cuda or cpu for the torch backend (default: cuda when available)")
44
+ ap.add_argument("--backend", choices=("auto", "torch", "mlx"), default="auto",
45
+ help="auto: MLX on Apple Silicon when mlx-lm is installed, else torch")
46
+ ap.add_argument("--int8", action="store_true", help="MLX on M5 and later: int8 matmuls on the neural accelerators")
47
  ap.add_argument("--name", help="model name reported in responses (default: the directory name)")
48
+ ap.add_argument("--max-length", type=int, default=262144, help="longest prompt in tokens (torch backend)")
49
+ ap.add_argument("--max-pixels", type=int, default=1 << 20, help="pixels per image after resizing (torch backend)")
50
+ ap.add_argument("--no-images", action="store_true", help="leave the vision tower out (torch backend)")
51
  a = ap.parse_args()
52
+ backend = a.backend
53
+ if backend == "auto":
54
+ backend = "mlx" if a.device is None and mlx_available() else "torch"
55
+ if backend == "mlx":
56
+ from .mlx_model import MLXDecision
57
+ engine = MLXDecision(a.model, int8=a.int8)
58
+ engine.warm_up()
59
+ else:
60
+ from .model import StartLuxDecision
61
+ engine = StartLuxDecision(a.model, device=a.device, max_length=a.max_length, images=not a.no_images,
62
+ max_pixels=a.max_pixels)
63
  demo_state = {"ticket": "I was charged twice for order #4411 and the app still shows it as unpaid."}
64
  demo_questions = {
65
  "team": {"type": "choice", "instructions": "Which team should handle this ticket?",
 
78
  engine.graphs = {}
79
  lock = threading.Lock()
80
  name = a.name or os.path.basename(os.path.normpath(a.model))
81
+ try:
82
+ released = json.load(open(os.path.join(a.model, "decision_config.json"))).get("release_date", "2026-10-01")
83
+ except (OSError, ValueError):
84
+ released = "2026-10-01"
85
 
86
  class Handler(BaseHTTPRequestHandler):
87
  def _send(self, code, obj):
 
89
  self.send_response(code)
90
  self.send_header("Content-Type", "application/json")
91
  self.send_header("Content-Length", str(len(data)))
92
+ self.send_header("x-typesafe-request-id", uuid.uuid4().hex)
93
  self.end_headers()
94
  self.wfile.write(data)
95
 
96
+ def _error(self, code, message):
97
+ self._send(code, {"error": message, "detail": [{"loc": ["body"], "msg": message, "type": "value_error"}]})
98
+
99
  def do_GET(self):
100
  path = self.path.rstrip("/")
101
  if path in ("/health", "/v1/health"):
102
+ return self._send(200, {"status": "ok", "model": name, "backend": backend, "fast_kernels": engine.fast_kernels,
103
+ "cuda_graphs": len(engine.graphs), "images": getattr(engine, "vision", None) is not None,
104
+ "max_length": getattr(engine, "max_length", None)})
105
  if path == "/v1/models":
106
+ return self._send(200, {"models": [{"name": name, "description": "StartLux-Decision typed decision model",
107
+ "release_date": released}]})
108
+ self._error(404, "not found")
109
 
110
  def do_POST(self):
111
  if self.path.rstrip("/") != "/v1/systemone":
112
+ return self._error(404, "not found")
113
  try:
114
  body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
115
  questions = body.get("questions")
116
  if not isinstance(questions, dict) or not questions:
117
  raise ValueError("questions must be a non-empty object")
118
+ images = body.get("images") or None
119
+ if images is not None:
120
+ if not isinstance(images, list) or not all(isinstance(x, str) for x in images):
121
+ raise ValueError("images must be a list of base64 strings or data URIs")
122
+ if backend != "torch":
123
+ raise ValueError("images need the torch backend")
124
+ from .model import load_image
125
+ images = [load_image(x, paths=False) for x in images]
126
  except ValueError as e:
127
+ return self._error(400, str(e))
128
  t = time.perf_counter()
129
  try:
130
  with lock:
131
+ answers, usage = (engine.decide(body.get("state"), questions, images=images) if images else
132
+ engine.decide(body.get("state"), questions))
133
  except ValueError as e: # unknown question type, prompt over the context limit
134
+ return self._error(422, str(e))
135
  self._send(200, {"answers": answers, "usage": usage, "model": name,
136
  "latency_ms": round(1000 * (time.perf_counter() - t), 2)})
137
 
138
  def log_message(self, *args):
139
  pass
140
 
141
+ detail = (f"MLX, int8 projections: {engine.int8}" if backend == "mlx" else
142
+ f"fast kernels: {engine.fast_kernels}, cuda graphs: {len(engine.graphs)}, "
143
+ f"images: {'yes' if engine.vision is not None else 'no, ' + engine.vision_note}, "
144
+ f"max length: {engine.max_length}")
145
+ print(f"{name} serving on http://{a.host}:{a.port}/v1/systemone ({detail})", flush=True)
146
  ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
147
 
148