alexwengg commited on
Commit
7334413
·
verified ·
1 Parent(s): 6a545c7

Shared Kai/Eos runtime, release parity report, link to Eos

Browse files
README.md CHANGED
@@ -52,9 +52,9 @@ Against the upstream runtime (fp32, Transformers 5.18) on the **typed-decisions
52
 
53
  | | Upstream | Core ML fp16 |
54
  |---|---:|---:|
55
- | Choice accuracy (600) | 0.423 | 0.420 |
56
  | Yes/No accuracy (600) | 0.628 | 0.628 |
57
- | Score accuracy (800) | 0.398 | 0.396 |
58
  | Answers that differ from upstream | — | 5 / 2,000 (all near-ties: upstream top-2 margin ≤ 0.009) |
59
  | Max probability difference | — | 0.0082 |
60
 
@@ -67,7 +67,7 @@ comparison with upstream: they are not the model card's benchmark protocol.
67
  |---|---:|---:|---:|
68
  | Card quickstart, 3 questions | 263 ms | 89 ms | **28 ms** |
69
  | typed-decisions request, 5 questions (median) | — | 418 ms | **61 ms** |
70
- | One email, 64 questions | 9,514 ms | 3,650 ms | **437 ms** (7 calls) |
71
 
72
  Times are full requests (tokenize, pack, predict, answer) and exclude model loading. Per call: L256 14 ms, L512 28 ms,
73
  L1024 58 ms, L2048 142 ms. The upstream runtime on a Mac has none of its GPU fast paths (HIP graphs, fused kernels),
@@ -81,6 +81,8 @@ See `coreml_config.json`. `input_ids`, `position_ids` [1, L] int32; `mask` [1, 1
81
 
82
  ## Conversion
83
 
 
 
84
  `conversion/`: `kai_graph.py` (traceable Qwen3 backbone + decision head, loads the source safetensors),
85
  `convert.py L N` (torch 2.7.0, coremltools 9.0), `combine.py` (multifunction package), `pack.py` + `typed_eval.py` +
86
  `typed_ref.py` (parity against the upstream runtime; `pack.py` imports the source repo's vendored encoder from `kai/`).
 
52
 
53
  | | Upstream | Core ML fp16 |
54
  |---|---:|---:|
55
+ | Choice accuracy (600) | 0.423 | 0.422 |
56
  | Yes/No accuracy (600) | 0.628 | 0.628 |
57
+ | Score accuracy (800) | 0.398 | 0.398 |
58
  | Answers that differ from upstream | — | 5 / 2,000 (all near-ties: upstream top-2 margin ≤ 0.009) |
59
  | Max probability difference | — | 0.0082 |
60
 
 
67
  |---|---:|---:|---:|
68
  | Card quickstart, 3 questions | 263 ms | 89 ms | **28 ms** |
69
  | typed-decisions request, 5 questions (median) | — | 418 ms | **61 ms** |
70
+ | One email, 64 questions | 9,514 ms | 3,650 ms | **442 ms** (7 calls) |
71
 
72
  Times are full requests (tokenize, pack, predict, answer) and exclude model loading. Per call: L256 14 ms, L512 28 ms,
73
  L1024 58 ms, L2048 142 ms. The upstream runtime on a Mac has none of its GPU fast paths (HIP graphs, fused kernels),
 
81
 
82
  ## Conversion
83
 
84
+ Decision-2.0-Eos-0.8B (Qwen3.5 hybrid) is at [FluidInference/decision-2.0-eos-coreml](https://huggingface.co/FluidInference/decision-2.0-eos-coreml), with the same runtime.
85
+
86
  `conversion/`: `kai_graph.py` (traceable Qwen3 backbone + decision head, loads the source safetensors),
87
  `convert.py L N` (torch 2.7.0, coremltools 9.0), `combine.py` (multifunction package), `pack.py` + `typed_eval.py` +
88
  `typed_ref.py` (parity against the upstream runtime; `pack.py` imports the source repo's vendored encoder from `kai/`).
conversion/check_release.py CHANGED
@@ -1,15 +1,17 @@
1
  import json, sys, time
2
  import numpy as np, pandas as pd
3
- sys.path.insert(0, "release")
4
  from decision2_coreml import Decision2CoreML, encode as r_encode, question_to_row as r_row
5
  from transformers import AutoTokenizer
6
  import pack, q64ctx
7
- ref_root, refpath = sys.argv[1], sys.argv[2]
8
  tok = AutoTokenizer.from_pretrained(ref_root)
9
- m = Decision2CoreML("release")
10
  d = pd.read_parquet("data/typed/all/test-00000-of-00001.parquet")
11
  ref = {j["id"]: j["answers"] for j in map(json.loads, open(refpath))}
12
  def lab(a): return ("true" if a["noul"] >= .5 else "false") if a["type"] == "noul" else max(a["probabilities"], key=a["probabilities"].get)
 
 
13
  tokdiff = flips = n = 0; worst = 0; ts = []
14
  for r in d.itertuples():
15
  st, qs = json.loads(r.state), json.loads(r.questions)
@@ -20,10 +22,16 @@ for r in d.itertuples():
20
  for q in qs:
21
  rq, aq = ref[r.id][q], a[q]; n += 1; flips += lab(rq) != lab(aq)
22
  pr = rq.get("probabilities", {"true": rq.get("noul")}); pa = aq.get("probabilities", {"true": aq.get("noul")})
 
 
 
 
23
  worst = max(worst, max(abs(pr[k] - pa[k]) for k in pr))
24
  for f in ("confidence", "score"):
25
  if f in rq: assert abs(rq[f] - aq[f]) < 0.05, (r.id, q, f)
26
  assert set(rq) == set(aq), (set(rq) ^ set(aq))
 
 
27
  print(f"typed: token mismatches {tokdiff}, {n} decisions, flips {flips}, max|dp| {worst:.4f}, request p50 {np.median(ts):.1f} ms")
28
  t = time.time(); a = m.system_one(state=q64ctx.state, questions=q64ctx.Q); m.system_one(state=q64ctx.state, questions=q64ctx.Q)
29
  ts = []
 
1
  import json, sys, time
2
  import numpy as np, pandas as pd
3
+ sys.path.insert(0, sys.argv[3])
4
  from decision2_coreml import Decision2CoreML, encode as r_encode, question_to_row as r_row
5
  from transformers import AutoTokenizer
6
  import pack, q64ctx
7
+ ref_root, refpath, rel = sys.argv[1], sys.argv[2], sys.argv[3]
8
  tok = AutoTokenizer.from_pretrained(ref_root)
9
+ m = Decision2CoreML(rel)
10
  d = pd.read_parquet("data/typed/all/test-00000-of-00001.parquet")
11
  ref = {j["id"]: j["answers"] for j in map(json.loads, open(refpath))}
12
  def lab(a): return ("true" if a["noul"] >= .5 else "false") if a["type"] == "noul" else max(a["probabilities"], key=a["probabilities"].get)
13
+ from collections import defaultdict
14
+ acc = defaultdict(lambda: [0, 0, 0]); margins = []
15
  tokdiff = flips = n = 0; worst = 0; ts = []
16
  for r in d.itertuples():
17
  st, qs = json.loads(r.state), json.loads(r.questions)
 
22
  for q in qs:
23
  rq, aq = ref[r.id][q], a[q]; n += 1; flips += lab(rq) != lab(aq)
24
  pr = rq.get("probabilities", {"true": rq.get("noul")}); pa = aq.get("probabilities", {"true": aq.get("noul")})
25
+ g = json.loads(r.gold)[q]["label"]; s_ = acc[rq["type"]]; s_[0] += 1; s_[1] += lab(rq) == g; s_[2] += lab(aq) == g
26
+ if lab(rq) != lab(aq):
27
+ ps = sorted(pr.values(), reverse=True) if len(pr) > 1 else sorted([pr["true"], 1 - pr["true"]], reverse=True)
28
+ margins.append(round(ps[0] - ps[1], 4))
29
  worst = max(worst, max(abs(pr[k] - pa[k]) for k in pr))
30
  for f in ("confidence", "score"):
31
  if f in rq: assert abs(rq[f] - aq[f]) < 0.05, (r.id, q, f)
32
  assert set(rq) == set(aq), (set(rq) ^ set(aq))
33
+ print("flip margins (upstream top-2)", margins)
34
+ for k, (c, h1, h2) in sorted(acc.items()): print(f" {k:6s} n={c} acc upstream {h1/c:.3f} coreml {h2/c:.3f}")
35
  print(f"typed: token mismatches {tokdiff}, {n} decisions, flips {flips}, max|dp| {worst:.4f}, request p50 {np.median(ts):.1f} ms")
36
  t = time.time(); a = m.system_one(state=q64ctx.state, questions=q64ctx.Q); m.system_one(state=q64ctx.state, questions=q64ctx.Q)
37
  ts = []
decision2_coreml.py CHANGED
@@ -1,5 +1,7 @@
1
  """Decision 2.0 System One on Core ML: every question of a request in one packed call.
2
 
 
 
3
  Needs only numpy, tokenizers and coremltools. Prompt rendering, option endpoints, Score
4
  offsets and answer normalization follow the upstream runtime (vllm-sr Decision 2.0,
5
  Apache-2.0: decision2/_vendor/dev2model/{decision_model,infer,score_bias}.py).
@@ -117,6 +119,65 @@ def pack(encoded: list[dict[str, Any]], L: int, N: int, pad_id: int) -> tuple[di
117
  }, owner
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def answer(kind: str, keys: list[str], logits: list[float], descriptions: list[Any]) -> dict[str, Any]:
121
  top = max(logits)
122
  exps = [math.exp(v - top) for v in logits]
@@ -134,6 +195,10 @@ def answer(kind: str, keys: list[str], logits: list[float], descriptions: list[A
134
  return {"type": "choice", "choice": keys[probs.index(max(probs))], "probabilities": pmap, "confidence": confidence}
135
 
136
 
 
 
 
 
137
  class Decision2CoreML:
138
  def __init__(self, root: str | Path = ".", package: str | None = None,
139
  compute_units: ct.ComputeUnit = ct.ComputeUnit.CPU_AND_GPU):
@@ -141,50 +206,56 @@ class Decision2CoreML:
141
  self.config = json.loads((root / "coreml_config.json").read_text())
142
  self.tokenizer = Tokenizer.from_file(str(root / "tokenizer.json"))
143
  self.pad_id = self.config["pad_token_id"]
144
- bias = json.loads((root / "score_bias.json").read_text())["offsets"]
145
- self.score_bias = {int(k): v for k, v in bias.items()}
 
 
146
  path = str(root / (package or self.config["package"]))
147
  self.cost_ms = self.config["functions"]
148
- self.buckets = [] # (L, N, model), smallest first
149
- for name in self.config["functions"]:
150
- L, N = (int(x[1:]) for x in name.split("_"))
151
- self.buckets.append((L, N, ct.models.MLModel(path, function_name=name, compute_units=compute_units)))
152
 
153
- def _calls(self, encoded: list[dict[str, Any]]) -> list[tuple[int, int, Any, list[int]]]:
 
 
 
 
 
 
 
 
 
 
 
 
154
  """Fewest estimated milliseconds: one call in the smallest bucket that fits, or greedy chunks."""
155
  best = None
156
- for L, N, m in self.buckets:
157
  groups, group = [], []
158
  for j in range(len(encoded)):
159
- T, C = packed_size([encoded[i] for i in group + [j]])
160
- if T > L or C > N:
161
- if not group:
162
  break # this question alone does not fit this bucket
163
  groups.append(group)
164
  group = []
165
- T, C = packed_size([encoded[j]])
166
- if T > L or C > N:
167
- break
168
  group.append(j)
169
  else:
170
  groups.append(group)
171
- cost = len(groups) * self.cost_ms[f"L{L}_N{N}"]
172
  if best is None or cost < best[0]:
173
- best = (cost, [(L, N, m, g) for g in groups])
174
  if best is None:
175
- L, N, _ = self.buckets[-1]
176
- raise ValueError(f"a question exceeds the largest bucket L{L}_N{N}")
177
  return best[1]
178
 
179
  def logits(self, encoded: list[dict[str, Any]]) -> list[list[float]]:
180
  per: list[list[float]] = [[] for _ in encoded]
181
- for L, N, m, group in self._calls(encoded):
182
- x, owner = pack([encoded[i] for i in group], L, N, self.pad_id)
183
  out = m.predict(x)["logits"]
184
  for k, o in enumerate(owner):
185
  per[group[o]].append(float(out[k]))
186
  return per
187
-
188
  def system_one(self, state: Any, questions: dict[str, dict[str, Any]]) -> dict[str, Any]:
189
  rows, encoded, answers = {}, {}, {}
190
  for qid, q in questions.items():
 
1
  """Decision 2.0 System One on Core ML: every question of a request in one packed call.
2
 
3
+ Supports the Qwen3 package (Kai, `L*_N*` functions) and the Qwen3.5 hybrid package (Eos, `S*_C*_N*` functions).
4
+
5
  Needs only numpy, tokenizers and coremltools. Prompt rendering, option endpoints, Score
6
  offsets and answer normalization follow the upstream runtime (vllm-sr Decision 2.0,
7
  Apache-2.0: decision2/_vendor/dev2model/{decision_model,infer,score_bias}.py).
 
119
  }, owner
120
 
121
 
122
+ def pack_hybrid(encoded: list[dict[str, Any]], S: int, P: int, N: int, pad_id: int, rope: dict[str, Any],
123
+ lags: int = 3, chunk: int = 64) -> tuple[dict[str, np.ndarray], list[int]]:
124
+ """Qwen3.5 (Gated DeltaNet hybrid) graph inputs: the shared prefix right-padded to S, the questions' suffixes
125
+ packed in P, plus the masks that make every suffix restart from the prefix's state (see README)."""
126
+ pre = _prefix(encoded)
127
+ total = sum(len(e["ids"]) - pre for e in encoded)
128
+ options = sum(len(e["keys"]) for e in encoded)
129
+ if not 1 <= pre <= S or total > P or options > N:
130
+ raise ValueError(f"prefix {pre} / {total} question tokens / {options} options exceed S{S}_C{P}_N{N}")
131
+ ids = np.full(S + P, pad_id, dtype=np.int32)
132
+ pos = np.zeros(S + P, dtype=np.float64)
133
+ ids[:pre] = encoded[0]["ids"][:pre]
134
+ pos[:pre] = np.arange(pre)
135
+ pos[pre:] = pre
136
+ valid = np.zeros(S, dtype=np.float16)
137
+ valid[:pre] = 1
138
+ tail = np.zeros((lags, S), dtype=np.float16)
139
+ for i in range(lags):
140
+ if pre - lags + i >= 0:
141
+ tail[i, pre - lags + i] = 1
142
+ segment = np.eye(P, dtype=np.float16)
143
+ keep = np.zeros((lags, P), dtype=np.float16)
144
+ lag_tail = np.zeros((lags, P, lags), dtype=np.float16)
145
+ cand, qry, owner = [], [], []
146
+ start = 0
147
+ for j, e in enumerate(encoded):
148
+ n = len(e["ids"]) - pre
149
+ ids[S + start : S + start + n] = e["ids"][pre:]
150
+ pos[S + start : S + start + n] = np.arange(pre, pre + n)
151
+ segment[start : start + n, start : start + n] = np.tril(np.ones((n, n)))
152
+ for p in range(n):
153
+ for s in range(1, lags + 1):
154
+ if p >= s:
155
+ keep[s - 1, start + p] = 1
156
+ else:
157
+ lag_tail[s - 1, start + p, lags + p - s] = 1
158
+ for c in e["candidate_positions"]:
159
+ cand.append(start + c - pre)
160
+ qry.append(start + e["query_position"] - pre)
161
+ owner.append(j)
162
+ start += n
163
+ dim = rope["rotary_dim"]
164
+ inv_freq = 1.0 / (rope["rope_theta"] ** (np.arange(0, dim, 2, dtype=np.float64) / dim))
165
+ freqs = pos[:, None] * inv_freq[None, :] # text-only M-RoPE: all three axes share the position
166
+ emb = np.concatenate([freqs, freqs], axis=-1)
167
+ M = P // chunk
168
+ starts = np.argmax(segment > 0, axis=1)
169
+ pad = N - len(cand)
170
+ return {
171
+ "input_ids": ids[None], "cos": np.cos(emb).astype(np.float16), "sin": np.sin(emb).astype(np.float16),
172
+ "valid": valid, "tail_onehot": tail, "segment": segment, "lag_keep": keep, "lag_tail": lag_tail,
173
+ "seg_chunks": np.stack([segment[i * chunk : (i + 1) * chunk, i * chunk : (i + 1) * chunk] for i in range(M)]),
174
+ "cont": (starts < (np.arange(P) // chunk) * chunk).astype(np.float16),
175
+ "last_seg": np.stack([segment[(i + 1) * chunk - 1, i * chunk : (i + 1) * chunk] for i in range(M)]),
176
+ "cand_idx": np.array(cand + [0] * pad, dtype=np.int32),
177
+ "query_idx": np.array(qry + [0] * pad, dtype=np.int32),
178
+ }, owner
179
+
180
+
181
  def answer(kind: str, keys: list[str], logits: list[float], descriptions: list[Any]) -> dict[str, Any]:
182
  top = max(logits)
183
  exps = [math.exp(v - top) for v in logits]
 
195
  return {"type": "choice", "choice": keys[probs.index(max(probs))], "probabilities": pmap, "confidence": confidence}
196
 
197
 
198
+ def _dims(name: str) -> dict[str, int]:
199
+ return {part[0]: int(part[1:]) for part in name.split("_")}
200
+
201
+
202
  class Decision2CoreML:
203
  def __init__(self, root: str | Path = ".", package: str | None = None,
204
  compute_units: ct.ComputeUnit = ct.ComputeUnit.CPU_AND_GPU):
 
206
  self.config = json.loads((root / "coreml_config.json").read_text())
207
  self.tokenizer = Tokenizer.from_file(str(root / "tokenizer.json"))
208
  self.pad_id = self.config["pad_token_id"]
209
+ self.hybrid = self.config.get("backbone", "qwen3") == "qwen3_5"
210
+ bias = root / "score_bias.json"
211
+ offsets = json.loads(bias.read_text())["offsets"] if bias.exists() else {}
212
+ self.score_bias = {int(k): v for k, v in offsets.items()}
213
  path = str(root / (package or self.config["package"]))
214
  self.cost_ms = self.config["functions"]
215
+ self.buckets = [(name, _dims(name), ct.models.MLModel(path, function_name=name, compute_units=compute_units))
216
+ for name in self.config["functions"]] # smallest first
 
 
217
 
218
+ def _fits(self, d: dict[str, int], encoded: list[dict[str, Any]]) -> bool:
219
+ options = sum(len(e["keys"]) for e in encoded)
220
+ if self.hybrid:
221
+ pre = _prefix(encoded)
222
+ return pre <= d["S"] and sum(len(e["ids"]) - pre for e in encoded) <= d["C"] and options <= d["N"]
223
+ return packed_size(encoded)[0] <= d["L"] and options <= d["N"]
224
+
225
+ def _inputs(self, d: dict[str, int], encoded: list[dict[str, Any]]):
226
+ if self.hybrid:
227
+ return pack_hybrid(encoded, d["S"], d["C"], d["N"], self.pad_id, self.config["rope"])
228
+ return pack(encoded, d["L"], d["N"], self.pad_id)
229
+
230
+ def _calls(self, encoded: list[dict[str, Any]]) -> list[tuple[Any, dict[str, int], list[int]]]:
231
  """Fewest estimated milliseconds: one call in the smallest bucket that fits, or greedy chunks."""
232
  best = None
233
+ for name, d, m in self.buckets:
234
  groups, group = [], []
235
  for j in range(len(encoded)):
236
+ if not self._fits(d, [encoded[i] for i in group + [j]]):
237
+ if not group or not self._fits(d, [encoded[j]]):
 
238
  break # this question alone does not fit this bucket
239
  groups.append(group)
240
  group = []
 
 
 
241
  group.append(j)
242
  else:
243
  groups.append(group)
244
+ cost = len(groups) * self.cost_ms[name]
245
  if best is None or cost < best[0]:
246
+ best = (cost, [(m, d, g) for g in groups])
247
  if best is None:
248
+ raise ValueError(f"a question exceeds the largest function {self.buckets[-1][0]}")
 
249
  return best[1]
250
 
251
  def logits(self, encoded: list[dict[str, Any]]) -> list[list[float]]:
252
  per: list[list[float]] = [[] for _ in encoded]
253
+ for m, d, group in self._calls(encoded):
254
+ x, owner = self._inputs(d, [encoded[i] for i in group])
255
  out = m.predict(x)["logits"]
256
  for k, o in enumerate(owner):
257
  per[group[o]].append(float(out[k]))
258
  return per
 
259
  def system_one(self, state: Any, questions: dict[str, dict[str, Any]]) -> dict[str, Any]:
260
  rows, encoded, answers = {}, {}, {}
261
  for qid, q in questions.items():
reports/typed-decisions-test.txt CHANGED
@@ -1,10 +1,6 @@
1
- flip agent_trace_observability_000049/outcome choice upstream success coreml partial upstream top-2 margin 0.0004
2
- flip agent_trace_observability_000087/urgency score upstream 0 coreml 1 upstream top-2 margin 0.0007
3
- flip customer_service_000075/action choice upstream escalate_to_human coreml request_information upstream top-2 margin 0.0003
4
- flip invoice_processing_000020/duplicate noul upstream true coreml false upstream top-2 margin 0.0008
5
- flip security_incidents_000023/true_positive noul upstream false coreml true upstream top-2 margin 0.0088
6
- ALL: 2000 decisions, flips 5, max|dp| 0.0082, request p50 61.2 ms p95 63.8 ms, calls 407
7
- bucket use {1024: 400}
8
- choice n=600 acc upstream 0.423 coreml 0.420
9
  noul n=600 acc upstream 0.628 coreml 0.628
10
- score n=800 acc upstream 0.398 coreml 0.396
 
 
 
1
+ flip margins (upstream top-2) [0.0011, 0.0004, 0.0003, 0.0008, 0.0088]
2
+ choice n=600 acc upstream 0.423 coreml 0.422
 
 
 
 
 
 
3
  noul n=600 acc upstream 0.628 coreml 0.628
4
+ score n=800 acc upstream 0.398 coreml 0.398
5
+ typed: token mismatches 0, 2000 decisions, flips 5, max|dp| 0.0082, request p50 60.9 ms
6
+ 64 questions: 7 calls, p50 442 ms end to end