alexwengg commited on
Commit
7bf8323
·
verified ·
1 Parent(s): 029514e

Decision-2.0-Eos-0.8B Core ML: shared prefix + chunked packed questions, fp16 package, runtime, parity reports

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
Decision2EosPacked.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e8a434580763e03408199536441dcfabbb3e58f479e2ef9e823e69d8b497e443
3
+ size 21981050
Decision2EosPacked.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:680030e081b927dc5902d522e0df4f24bdf3a13910c228625d7fd98ac324e939
3
+ size 1515655104
Decision2EosPacked.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "4DDB3568-C852-4A8A-8E49-47C9F5FCB612": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "7323AD02-87D1-4987-95F3-AC1D30199D66": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "4DDB3568-C852-4A8A-8E49-47C9F5FCB612"
18
+ }
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: vllm-sr/Decision-2.0-Eos-0.8B
4
+ tags:
5
+ - coreml
6
+ - decision-making
7
+ - on-device
8
+ - system-one
9
+ ---
10
+
11
+ # Decision-2.0-Eos-0.8B · Core ML
12
+
13
+ [vllm-sr/Decision-2.0-Eos-0.8B](https://huggingface.co/vllm-sr/Decision-2.0-Eos-0.8B) (vLLM Semantic Router, Apache-2.0)
14
+ converted to Core ML for on-device use on Apple silicon. Decision 2.0 answers Choice, Yes/No and Score questions
15
+ about one input, with a probability for every option and no text generation.
16
+
17
+ This port answers **every question of a request in one model call**. The text all the questions share (the context) is
18
+ read once, and each question then continues from it as if it were alone. Eos uses a Qwen3.5 hybrid backbone (Gated
19
+ DeltaNet + attention), so "continues from it" covers the DeltaNet recurrent state and conv history as well as the
20
+ attention keys. See [How it works](#how-it-works).
21
+
22
+ | | |
23
+ |---|---|
24
+ | Source | `vllm-sr/Decision-2.0-Eos-0.8B` @ `3594047d` (Qwen3.5-0.8B-Base backbone + shared decision head) |
25
+ | Package | `Decision2EosPacked.mlpackage`, fp16, 1.4 GB, five functions sharing one copy of the weights |
26
+ | Functions | `S128_C256_N32`, `S256_C512_N64`, `S512_C768_N96`, `S512_C1024_N128`, `S1024_C1024_N128` (shared-prefix tokens / packed question tokens / option slots) |
27
+ | Requires | macOS 15+ / iOS 18+; run on the GPU (`CPU_AND_GPU`) |
28
+
29
+ Kai 0.6B (Qwen3 backbone) is at [FluidInference/decision-2.0-kai-coreml](https://huggingface.co/FluidInference/decision-2.0-kai-coreml),
30
+ with the same Python runtime.
31
+
32
+ ## Quickstart
33
+
34
+ ```bash
35
+ pip install coremltools tokenizers numpy
36
+ hf download FluidInference/decision-2.0-eos-coreml --local-dir decision-2.0-eos-coreml
37
+ cd decision-2.0-eos-coreml && python example.py
38
+ ```
39
+
40
+ `decision2_coreml.py` mirrors the upstream `system_one()` output (`choice`/`noul`/`score`, `probabilities`,
41
+ `confidence`, `legend`). It picks the smallest function that fits a request. A request that fits none is split into
42
+ chunks, each repeating the shared prefix, on whichever function needs the fewest milliseconds.
43
+
44
+ ## Fidelity
45
+
46
+ Against the upstream runtime (fp32, Transformers 5.18, MPS) on the **typed-decisions TEST** split
47
+ (`LocalLLaMA/typed-decisions`, 400 requests, 2,000 decisions):
48
+
49
+ | | Upstream | Core ML fp16 |
50
+ |---|---:|---:|
51
+ | Choice accuracy (600) | 0.430 | 0.433 |
52
+ | Yes/No accuracy (600) | 0.598 | 0.598 |
53
+ | Score accuracy (800) | 0.379 | 0.376 |
54
+ | Answers that differ from upstream | — | 4 / 2,000 (all near-ties: upstream top-2 margin ≤ 0.003) |
55
+ | Max probability difference | — | 0.0077 |
56
+
57
+ Tokenization (via `tokenizers`) matches the upstream encoder on all 2,000 questions. The fp32 PyTorch version of the
58
+ graph (`conversion/eos_graph.py`, `EosChunked`) matches upstream to 2e-6. The accuracies are only for the comparison
59
+ with upstream: they are not the model card's benchmark protocol.
60
+
61
+ ## Speed (MacBook Pro M5 Pro, 24 GB, macOS 27)
62
+
63
+ | Request | Upstream PyTorch, MPS | Core ML (GPU) |
64
+ |---|---:|---:|
65
+ | Card quickstart, 3 questions | 306 ms | **34 ms** |
66
+ | typed-decisions request, 5 questions (median) | 1,595 ms | **110 ms** |
67
+ | One email, 64 questions | 15,647 ms | **874 ms** (13 calls) |
68
+
69
+ Times are full requests (tokenize, pack, predict, answer) and exclude model loading. Per call: S128_C256 34 ms,
70
+ S256_C512 64 ms, S512_C768 109 ms, S512_C1024 131 ms, S1024_C1024 176 ms. On a Mac the upstream runtime has none of its
71
+ GPU kernels (flash-linear-attention, causal-conv1d), so the speedup is specific to this machine. The Neural Engine was
72
+ not used: on Kev-0.8B, which has the same backbone, it was about 24× slower than the GPU.
73
+
74
+ ## How it works
75
+
76
+ One call takes the shared prefix (right-padded to S) and the questions' suffixes packed end to end (C tokens):
77
+
78
+ - **Attention layers:** one mask. Prefix tokens are causal; a suffix token sees the real prefix tokens and earlier
79
+ tokens of its own question.
80
+ - **Gated DeltaNet layers:** the prefix runs the chunked delta rule (chunk 64; padded positions get β = g = 0, exact
81
+ no-ops), which yields its final recurrent state S₀. The packed questions also run in chunks of 64. A token whose
82
+ question began in an earlier chunk continues from the carried state; a token whose question began in this chunk
83
+ starts from S₀ (`cont`). Decays and the WY inverse are masked to the question within each chunk (`seg_chunks`), and
84
+ only the question of a chunk's last token carries its state forward (`last_seg`). A question's first three
85
+ convolution lags read the prefix's last three inputs (`lag_keep`, `lag_tail`).
86
+ - **Readout:** Decision 2.0's head (bilinear + MLP over LayerNorm'd hidden states) at each option's last token and the
87
+ question's final token (`cand_idx`, `query_idx`).
88
+
89
+ So every question sees exactly its own upstream row (`Context … Question … Options … Decision:`), and the delta-rule
90
+ cost stays linear in packed tokens. Full input list: `coreml_config.json`.
91
+
92
+ ## Conversion
93
+
94
+ `conversion/`: `eos_graph.py` (Decision 2.0 graph on top of `kev_stages.py` / `qwen35_export.py`, the Qwen3.5 export
95
+ from [Kev-0.8B Core ML](https://huggingface.co/FluidInference/kev-0.8b-coreml)), `convert_eos.py S cP N` (torch 2.7.0,
96
+ coremltools 9.0), `combine.py`, parity and timing scripts.
97
+
98
+ ## License
99
+
100
+ Apache-2.0, as the source model. `LICENSE`, `tokenizer*.json` and `decision_config.json` are copied unchanged from the
101
+ source repository.
conversion/cases.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LEVELS5 = ["None", "Low", "Medium", "High", "Critical"]
2
+ CASES = [
3
+ ("The order arrived damaged yesterday. The customer has a receipt and asks for a replacement today.",
4
+ {"route": {"type":"choice","instructions":"Which team should handle this request?",
5
+ "criteria":{"returns":"Refunds, replacements and damaged deliveries","billing":"Payments, invoices and charges","technical":"Product setup and faults"}},
6
+ "receipt": {"type":"noul","instructions":"Does the customer have a receipt?"},
7
+ "urgency": {"type":"score","instructions":"How urgent is this request?","criteria":["Routine","Soon","Today"]}}),
8
+ ("Hi team, our production database went down at 3am and customers cannot log in. We need someone on this immediately. Invoice #4521 is also overdue but that can wait.",
9
+ {"severity": {"type":"score","instructions":"How severe is the incident?","criteria":LEVELS5},
10
+ "outage": {"type":"noul","instructions":"Is there an active outage?"},
11
+ "billing": {"type":"noul","instructions":"Is a billing topic mentioned?"},
12
+ "dept": {"type":"choice","instructions":"Which department?","criteria":{"sre":"Site reliability and on-call","finance":"Accounts receivable","sales":"New business","hr":"People operations"}},
13
+ "sentiment": {"type":"choice","instructions":"Customer sentiment?","criteria":{"pos":"Positive","neu":"Neutral","neg":"Negative"}}}),
14
+ ({"user":"What's the capital of Australia?","history":[]},
15
+ {"route": {"type":"choice","instructions":"Which model should answer?",
16
+ "criteria":{"small":"A small fast model for simple factual lookups","large":"A large reasoning model for complex multi-step problems","code":"A coding model"}},
17
+ "jailbreak": {"type":"noul","instructions":"Is this a jailbreak attempt?"},
18
+ "difficulty": {"type":"score","instructions":"How hard is this query?","criteria":LEVELS5}}),
19
+ ]
conversion/check_release.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
18
+ for qid, q in qs.items(): # tokenization vs upstream encoder
19
+ up = pack.rows(tok, st, {qid: q})[0][2]; mine = r_encode(r_row(st, q), m.tokenizer)
20
+ tokdiff += up["ids"] != mine["ids"] or up["candidate_positions"] != mine["candidate_positions"]
21
+ t = time.time(); a = m.system_one(state=st, questions=qs)["answers"]; ts.append((time.time() - t) * 1000)
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 = []
38
+ for _ in range(5):
39
+ t = time.time(); m.system_one(state=q64ctx.state, questions=q64ctx.Q); ts.append((time.time() - t) * 1000)
40
+ print(f"64 questions: {len(m._calls([r_encode(r_row(q64ctx.state, q), m.tokenizer) for q in q64ctx.Q.values()]))} calls, p50 {np.median(ts):.0f} ms end to end")
conversion/combine.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merge single-bucket packages into one multifunction package (weights stored once; macOS 15+)."""
2
+ import sys
3
+ import coremltools as ct
4
+ out, specs = sys.argv[1], sys.argv[2:] # name=path ...
5
+ desc = ct.utils.MultiFunctionDescriptor()
6
+ for s in specs:
7
+ name, path = s.split("=")
8
+ desc.add_function(path, src_function_name="main", target_function_name=name)
9
+ desc.default_function_name = specs[1].split("=")[0] if len(specs) > 1 else specs[0].split("=")[0]
10
+ ct.utils.save_multifunction(desc, out)
11
+ print("saved", out)
conversion/convert_eos.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, time
2
+ import numpy as np, torch, coremltools as ct
3
+ from eos_graph import load_chunked, load_eos, load_rows
4
+ # packed: convert_eos.py S P N | rows: convert_eos.py S BxQ N
5
+ S, N = int(sys.argv[1]), int(sys.argv[3])
6
+ if "x" in sys.argv[2]:
7
+ B, Q = map(int, sys.argv[2].split("x")); P = B * Q
8
+ g = load_rows("eos", S, B, Q); tag = f"S{S}_R{B}x{Q}_N{N}"
9
+ elif sys.argv[2].startswith("c"):
10
+ P = int(sys.argv[2][1:]); g = load_chunked("eos", S, P); tag = f"S{S}_C{P}_N{N}"
11
+ else:
12
+ P = int(sys.argv[2]); g = load_eos("eos", S, P); tag = f"S{S}_P{P}_N{N}"
13
+ chunked = sys.argv[2].startswith("c")
14
+ out = sys.argv[4] if len(sys.argv) > 4 else f"coreml/eos_{tag}.mlpackage"
15
+ T, R = S + P, g.cfg.rotary_dim
16
+ ex = (torch.zeros(1, T, dtype=torch.int32), torch.zeros(T, R), torch.zeros(T, R), torch.ones(S), torch.zeros(3, S),
17
+ torch.eye(P), torch.zeros(3, P), torch.zeros(3, P, 3))
18
+ if chunked:
19
+ M = P // 64
20
+ ex += (torch.eye(64).expand(M, 64, 64).contiguous(), torch.zeros(P), torch.zeros(M, 64))
21
+ ex += (torch.zeros(N, dtype=torch.int32), torch.zeros(N, dtype=torch.int32))
22
+ t = time.time()
23
+ with torch.no_grad():
24
+ tr = torch.jit.trace(g, ex, check_trace=False)
25
+ f16 = np.float16
26
+ m = ct.convert(tr, convert_to="mlprogram", minimum_deployment_target=ct.target.macOS15,
27
+ inputs=[ct.TensorType(name="input_ids", shape=(1, T), dtype=np.int32),
28
+ ct.TensorType(name="cos", shape=(T, R), dtype=f16), ct.TensorType(name="sin", shape=(T, R), dtype=f16),
29
+ ct.TensorType(name="valid", shape=(S,), dtype=f16), ct.TensorType(name="tail_onehot", shape=(3, S), dtype=f16),
30
+ ct.TensorType(name="segment", shape=(P, P), dtype=f16), ct.TensorType(name="lag_keep", shape=(3, P), dtype=f16),
31
+ ct.TensorType(name="lag_tail", shape=(3, P, 3), dtype=f16)]
32
+ + ([ct.TensorType(name="seg_chunks", shape=(P // 64, 64, 64), dtype=f16), ct.TensorType(name="cont", shape=(P,), dtype=f16),
33
+ ct.TensorType(name="last_seg", shape=(P // 64, 64), dtype=f16)] if chunked else [])
34
+ + [ct.TensorType(name="cand_idx", shape=(N,), dtype=np.int32), ct.TensorType(name="query_idx", shape=(N,), dtype=np.int32)],
35
+ outputs=[ct.TensorType(name="logits", dtype=np.float32)], compute_precision=ct.precision.FLOAT16)
36
+ m.short_description = "Decision-2.0-Eos-0.8B (vllm-sr): shared prefix + packed questions, one call"
37
+ m.save(out); print("saved", out, f"{time.time()-t:.0f}s")
conversion/eos_graph.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision-2.0-Eos-0.8B (Qwen3.5 hybrid) as one fused Core ML graph: shared prefix + packed questions.
2
+
3
+ Built on the Kev-0.8B FusedPass (kev_stages.py): the shared prefix is the "state" (S tokens, right-padded; padded
4
+ positions are exact no-ops in the delta rule and masked in attention), the questions' suffixes are packed in P
5
+ tokens. Each suffix restarts from the prefix's recurrent state and conv tail (segment mask), and attends to the
6
+ prefix and itself. The readout is Decision 2.0's shared bilinear + MLP head at each option's last token and the
7
+ question's final token (gather indices into the packed region).
8
+ """
9
+
10
+ import json
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from safetensors.torch import load_file
16
+ from torch import nn
17
+
18
+ import kev_stages
19
+ from kev_stages import MASK_VALUE, FusedPass
20
+ from qwen35_export import TextConfig, rope_cos_sin
21
+
22
+ LAGS = 3
23
+
24
+
25
+ class EosFused(FusedPass):
26
+ def __init__(self, cfg: TextConfig, state_len: int, packed_len: int, lane: int | None = None, chunk: int = 64):
27
+ # lane = None: questions may span the whole packed region (inverse over all P)
28
+ super().__init__(cfg, state_len, packed_len, batch=1, max_options=2, chunk_size=chunk,
29
+ lane=lane or packed_len)
30
+ h = cfg.hidden_size
31
+ del self.q, self.k, self.pointer_scale, self.inverse_temperature
32
+ self.embed_tokens = nn.Embedding(1, h) # resized on load
33
+ self.candidate_norm = nn.LayerNorm(h)
34
+ self.query_norm = nn.LayerNorm(h)
35
+ self.key = nn.Linear(h, 256, bias=False)
36
+ self.query = nn.Linear(h, 256, bias=False)
37
+ self.candidate_mlp = nn.Linear(h, 256)
38
+ self.query_mlp = nn.Linear(h, 256, bias=False)
39
+ self.scalar = nn.Linear(256, 1, bias=False)
40
+
41
+ def forward(self, input_ids, cos, sin, valid, tail_onehot, segment, lag_keep, lag_tail, cand_idx, query_idx):
42
+ """input_ids [1, S+P]; cos/sin [S+P, R]; valid [S]; tail_onehot [3, S]; segment [P, P]; lag_keep [3, P];
43
+ lag_tail [3, P, 3]; cand_idx/query_idx [N] (indices into the packed region) -> logits [N]."""
44
+ S, P = self.S, self.P
45
+ bias = torch.cat([
46
+ torch.cat([self.state_causal, self.state_block], dim=1),
47
+ torch.cat([((1.0 - valid) * MASK_VALUE)[None, :].expand(P, S), (1.0 - segment) * MASK_VALUE], dim=1),
48
+ ], dim=0)
49
+ x = self.embed_tokens(input_ids)
50
+ for layer in self.decoder.layers:
51
+ h = layer.input_layernorm(x)
52
+ if layer.is_linear:
53
+ out = self._delta(layer.linear_attn, h, valid, tail_onehot, segment, lag_keep, lag_tail)
54
+ else:
55
+ out = self._attention(layer.self_attn, h, cos, sin, bias)
56
+ x = x + out
57
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
58
+ xq = self.norm(x[0, S:])
59
+ cand = self.candidate_norm(xq[cand_idx].float())
60
+ qry = self.query_norm(xq[query_idx].float())
61
+ bilinear = (self.key(cand) * self.query(qry)).sum(-1) / 16.0
62
+ nonlinear = self.scalar(F.gelu(self.candidate_mlp(cand) + self.query_mlp(qry))).squeeze(-1)
63
+ return bilinear + nonlinear
64
+
65
+
66
+ def load_eos(root: str, state_len: int, packed_len: int, lane: int | None = None) -> EosFused:
67
+ cfg = TextConfig(json.load(open(f"{root}/backbone/config.json")))
68
+ state = {k: v.float() for k, v in load_file(f"{root}/backbone/model.safetensors").items()}
69
+ model = EosFused(cfg, state_len, packed_len, lane)
70
+ model.decoder.load_merged(state)
71
+ model.norm.load_state_dict({"weight": state["norm.weight"]})
72
+ model.embed_tokens = nn.Embedding.from_pretrained(state["embed_tokens.weight"], freeze=True)
73
+ head = {k: v.float() for k, v in load_file(f"{root}/decision_head.safetensors").items()}
74
+ missing, unexpected = model.load_state_dict(head, strict=False)
75
+ assert not unexpected and not [m for m in missing if m.split(".")[0] in
76
+ ("candidate_norm", "query_norm", "key", "query", "candidate_mlp",
77
+ "query_mlp", "scalar")], (missing, unexpected)
78
+ return model.eval()
79
+
80
+
81
+ def prefix_len(encoded):
82
+ seqs = [e["ids"] for e in encoded]
83
+ limit = min(min(e["candidate_positions"][0] for e in encoded), min(len(s) for s in seqs) - 1)
84
+ for i in range(limit):
85
+ if any(s[i] != seqs[0][i] for s in seqs):
86
+ return i
87
+ return limit
88
+
89
+
90
+ def eos_inputs(cfg: TextConfig, encoded, S: int, P: int, pad_id: int, lane: int | None = None,
91
+ row_len: int | None = None):
92
+ """Fused inputs for a group of encoded rows (vendored `encode` output) sharing their common token prefix.
93
+ With `lane`, a question that would cross a lane boundary starts at the next lane; with `row_len`, question j
94
+ starts at j * row_len (EosRows)."""
95
+ pre = prefix_len(encoded)
96
+ if not 1 <= pre <= S:
97
+ raise ValueError(f"prefix {pre} tokens does not fit S{S}")
98
+ ids = np.full(S + P, pad_id, dtype=np.int64)
99
+ pos = np.zeros(S + P, dtype=np.int64)
100
+ ids[:pre] = encoded[0]["ids"][:pre]
101
+ pos[:pre] = np.arange(pre)
102
+ pos[pre:S] = pre - 1
103
+ valid = np.zeros(S, dtype=np.float32)
104
+ valid[:pre] = 1
105
+ tail = np.zeros((LAGS, S), dtype=np.float32)
106
+ for i in range(LAGS): # tail row i = pre-conv input at position pre - LAGS + i (zero before the start)
107
+ p = pre - LAGS + i
108
+ if p >= 0:
109
+ tail[i, p] = 1
110
+ segment = np.eye(P, dtype=np.float32)
111
+ keep = np.zeros((LAGS, P), dtype=np.float32)
112
+ lag_tail = np.zeros((LAGS, P, LAGS), dtype=np.float32)
113
+ pos[S:] = pre
114
+ cand, qry, owner = [], [], []
115
+ start = 0
116
+ for j, e in enumerate(encoded):
117
+ suffix = e["ids"][pre:]
118
+ n = len(suffix)
119
+ if row_len:
120
+ start = j * row_len
121
+ if n > row_len:
122
+ raise ValueError(f"question of {n} tokens > row {row_len}")
123
+ elif lane and start // lane != (start + n - 1) // lane:
124
+ start = (start // lane + 1) * lane
125
+ if start + n > P:
126
+ raise ValueError(f"questions exceed P{P}")
127
+ ids[S + start : S + start + n] = suffix
128
+ pos[S + start : S + start + n] = np.arange(pre, pre + n)
129
+ segment[start : start + n, start : start + n] = np.tril(np.ones((n, n), dtype=np.float32))
130
+ for p in range(n):
131
+ for s in range(1, LAGS + 1):
132
+ if p >= s:
133
+ keep[s - 1, start + p] = 1
134
+ else:
135
+ lag_tail[s - 1, start + p, LAGS + p - s] = 1
136
+ for c in e["candidate_positions"]:
137
+ cand.append(start + c - pre)
138
+ qry.append(start + e["query_position"] - pre)
139
+ owner.append(j)
140
+ start += n
141
+ cos, sin = rope_cos_sin(cfg, torch.from_numpy(pos)[None].expand(3, -1))
142
+ return {
143
+ "input_ids": ids[None].astype(np.int32), "cos": cos.numpy(), "sin": sin.numpy(), "valid": valid,
144
+ "tail_onehot": tail, "segment": segment, "lag_keep": keep, "lag_tail": lag_tail,
145
+ }, cand, qry, owner, start
146
+
147
+
148
+ def with_slots(x, cand, qry, N):
149
+ if len(cand) > N:
150
+ raise ValueError(f"{len(cand)} options > N{N}")
151
+ pad = N - len(cand)
152
+ return {**x, "cand_idx": np.array(cand + [0] * pad, dtype=np.int32),
153
+ "query_idx": np.array(qry + [0] * pad, dtype=np.int32)}
154
+
155
+
156
+ class EosRows(EosFused):
157
+ """Questions in B rows of Q tokens (one question per row, right-padded): the question delta rule runs chunked
158
+ (chunk 64) from the prefix's final state, batched over rows, instead of one P x P segment inverse."""
159
+
160
+ def __init__(self, cfg: TextConfig, state_len: int, rows: int, row_len: int, chunk: int = 64):
161
+ assert state_len % chunk == 0 and row_len % chunk == 0
162
+ super().__init__(cfg, state_len, rows * row_len, lane=row_len, chunk=chunk)
163
+ self.B, self.Q = rows, row_len
164
+
165
+ def _delta(self, net, x, valid, tail_onehot, segment, lag_keep, lag_tail):
166
+ cfg, S, P, B, Q = self.cfg, self.S, self.P, self.B, self.Q
167
+ T = S + P
168
+ H, Dk, Dv, kh = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim, cfg.lin_k_heads
169
+ rep = H // kh
170
+ pre = net.in_proj_qkv(x)[0]
171
+ pre_s, pre_p = pre[:S], pre[S:]
172
+ tail = torch.matmul(tail_onehot, pre_s)
173
+ weight = net.conv1d.weight[:, 0]
174
+ lags = weight.shape[1] - 1
175
+ conv_s, conv_p = pre_s * weight[:, lags], pre_p * weight[:, lags]
176
+ for s in range(1, lags + 1):
177
+ conv_s = conv_s + F.pad(pre_s[: S - s], (0, 0, s, 0)) * weight[:, lags - s]
178
+ shifted = F.pad(pre_p[: P - s], (0, 0, s, 0)) * lag_keep[s - 1][:, None] + torch.matmul(lag_tail[s - 1], tail)
179
+ conv_p = conv_p + shifted * weight[:, lags - s]
180
+ qkv = F.silu(torch.cat([conv_s, conv_p], dim=0))
181
+ q = qkv[:, : net.key_dim].reshape(T, kh, Dk)
182
+ k = qkv[:, net.key_dim : 2 * net.key_dim].reshape(T, kh, Dk)
183
+ v = qkv[:, 2 * net.key_dim :].reshape(T, H, Dv).transpose(0, 1)
184
+ z = net.in_proj_z(x).reshape(1, T, H, Dv)
185
+ beta = torch.sigmoid(net.in_proj_b(x))[0].transpose(0, 1)
186
+ g = (-torch.exp(net.A_log) * F.softplus(net.in_proj_a(x) + net.dt_bias))[0].transpose(0, 1)
187
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
188
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
189
+ q = (q[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk) * (Dk**-0.5)).transpose(0, 1)
190
+ k = k[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk).transpose(0, 1)
191
+
192
+ C, N = net.chunk, net.num_chunks
193
+ new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay = kev_stages._delta_chunk_terms(
194
+ net, q[:, :S].reshape(H, N, C, Dk), k[:, :S].reshape(H, N, C, Dk), v[:, :S].reshape(H, N, C, Dv),
195
+ (beta[:, :S] * valid).reshape(H, N, C, 1), (g[:, :S] * valid).reshape(H, N, C))
196
+ state = torch.zeros(H, Dk, Dv, dtype=x.dtype)
197
+ outs = []
198
+ for i in range(N):
199
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], state)
200
+ outs.append(torch.matmul(q_dec[:, i], state) + torch.matmul(attn_intra[:, i], v_new))
201
+ state = state * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
202
+
203
+ G, M = H * B, Q // C
204
+ new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay = kev_stages._delta_chunk_terms(
205
+ net, q[:, S:].reshape(G, M, C, Dk), k[:, S:].reshape(G, M, C, Dk), v[:, S:].reshape(G, M, C, Dv),
206
+ beta[:, S:].reshape(G, M, C, 1), g[:, S:].reshape(G, M, C))
207
+ st = state[:, None].expand(H, B, Dk, Dv).reshape(G, Dk, Dv)
208
+ rows = []
209
+ for i in range(M):
210
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], st)
211
+ rows.append(torch.matmul(q_dec[:, i], st) + torch.matmul(attn_intra[:, i], v_new))
212
+ if i + 1 < M:
213
+ st = st * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
214
+ outs.append(torch.stack(rows, dim=1).reshape(H, P, Dv))
215
+
216
+ core = torch.cat(outs, dim=1).transpose(0, 1)[None]
217
+ core = net.norm(core, z)
218
+ return net.out_proj(core.reshape(1, T, H * Dv))
219
+
220
+
221
+ def load_rows(root: str, state_len: int, rows: int, row_len: int) -> EosRows:
222
+ cfg = TextConfig(json.load(open(f"{root}/backbone/config.json")))
223
+ state = {k: v.float() for k, v in load_file(f"{root}/backbone/model.safetensors").items()}
224
+ model = EosRows(cfg, state_len, rows, row_len)
225
+ model.decoder.load_merged(state)
226
+ model.norm.load_state_dict({"weight": state["norm.weight"]})
227
+ model.embed_tokens = nn.Embedding.from_pretrained(state["embed_tokens.weight"], freeze=True)
228
+ head = {k: v.float() for k, v in load_file(f"{root}/decision_head.safetensors").items()}
229
+ model.load_state_dict(head, strict=False)
230
+ return model.eval()
231
+
232
+
233
+ class EosChunked(EosFused):
234
+ """Packed questions with a chunked delta rule (chunk C) instead of one P x P segment inverse.
235
+
236
+ Per chunk, a token starts either from the carried recurrent state (its question began in an earlier chunk,
237
+ `cont` = 1) or from the prefix's final state S0 (its question began in this chunk). `seg_chunks` [M, C, C] is the
238
+ segment mask restricted to each chunk (j <= t, same question); `last_seg` [M, C] marks the tokens in the question
239
+ of the chunk's last token, the only one whose state is carried to the next chunk."""
240
+
241
+ def __init__(self, cfg: TextConfig, state_len: int, packed_len: int, chunk: int = 64):
242
+ assert state_len % chunk == 0 and packed_len % chunk == 0
243
+ super().__init__(cfg, state_len, packed_len, lane=packed_len, chunk=chunk)
244
+ self.C, self.M = chunk, packed_len // chunk
245
+ self.register_buffer("eye_c", torch.eye(chunk), persistent=False)
246
+
247
+ def forward(self, input_ids, cos, sin, valid, tail_onehot, segment, lag_keep, lag_tail, seg_chunks, cont,
248
+ last_seg, cand_idx, query_idx):
249
+ self._extra = (seg_chunks, cont, last_seg)
250
+ return super().forward(input_ids, cos, sin, valid, tail_onehot, segment, lag_keep, lag_tail, cand_idx, query_idx)
251
+
252
+ def _delta(self, net, x, valid, tail_onehot, segment, lag_keep, lag_tail):
253
+ seg_chunks, cont, last_seg = self._extra
254
+ cfg, S, P, C, M = self.cfg, self.S, self.P, self.C, self.M
255
+ T = S + P
256
+ H, Dk, Dv, kh = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim, cfg.lin_k_heads
257
+ rep = H // kh
258
+ pre = net.in_proj_qkv(x)[0]
259
+ pre_s, pre_p = pre[:S], pre[S:]
260
+ tail = torch.matmul(tail_onehot, pre_s)
261
+ weight = net.conv1d.weight[:, 0]
262
+ lags = weight.shape[1] - 1
263
+ conv_s, conv_p = pre_s * weight[:, lags], pre_p * weight[:, lags]
264
+ for s in range(1, lags + 1):
265
+ conv_s = conv_s + F.pad(pre_s[: S - s], (0, 0, s, 0)) * weight[:, lags - s]
266
+ shifted = F.pad(pre_p[: P - s], (0, 0, s, 0)) * lag_keep[s - 1][:, None] + torch.matmul(lag_tail[s - 1], tail)
267
+ conv_p = conv_p + shifted * weight[:, lags - s]
268
+ qkv = F.silu(torch.cat([conv_s, conv_p], dim=0))
269
+ q = qkv[:, : net.key_dim].reshape(T, kh, Dk)
270
+ k = qkv[:, net.key_dim : 2 * net.key_dim].reshape(T, kh, Dk)
271
+ v = qkv[:, 2 * net.key_dim :].reshape(T, H, Dv).transpose(0, 1)
272
+ z = net.in_proj_z(x).reshape(1, T, H, Dv)
273
+ beta = torch.sigmoid(net.in_proj_b(x))[0].transpose(0, 1)
274
+ g = (-torch.exp(net.A_log) * F.softplus(net.in_proj_a(x) + net.dt_bias))[0].transpose(0, 1)
275
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
276
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
277
+ q = (q[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk) * (Dk**-0.5)).transpose(0, 1)
278
+ k = k[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk).transpose(0, 1)
279
+
280
+ N = net.num_chunks
281
+ new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay = kev_stages._delta_chunk_terms(
282
+ net, q[:, :S].reshape(H, N, net.chunk, Dk), k[:, :S].reshape(H, N, net.chunk, Dk),
283
+ v[:, :S].reshape(H, N, net.chunk, Dv), (beta[:, :S] * valid).reshape(H, N, net.chunk, 1),
284
+ (g[:, :S] * valid).reshape(H, N, net.chunk))
285
+ state = torch.zeros(H, Dk, Dv, dtype=x.dtype)
286
+ outs = []
287
+ for i in range(N):
288
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], state)
289
+ outs.append(torch.matmul(q_dec[:, i], state) + torch.matmul(attn_intra[:, i], v_new))
290
+ state = state * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
291
+ s0 = state
292
+
293
+ # questions: packed, chunked, segment-aware
294
+ qp = q[:, S:].reshape(H, M, C, Dk)
295
+ kp = k[:, S:].reshape(H, M, C, Dk)
296
+ vp = v[:, S:].reshape(H, M, C, Dv)
297
+ bp = beta[:, S:].reshape(H, M, C, 1)
298
+ gp = g[:, S:].reshape(H, M, C, 1)
299
+ cum = torch.matmul(seg_chunks, gp).squeeze(-1) # [H, M, C]: decay since max(chunk start, question start)
300
+ pair_decay = torch.exp((cum[..., :, None] - cum[..., None, :]) * seg_chunks + (seg_chunks - 1.0) * 1e4)
301
+ k_beta = kp * bp
302
+ kkt = torch.matmul(k_beta, kp.transpose(-1, -2)) * pair_decay
303
+ attn = torch.matmul(qp, kp.transpose(-1, -2)) * pair_decay
304
+ inv = kev_stages._unit_lower_inverse((self.eye_c + kkt * (seg_chunks - self.eye_c)).reshape(H * M, C, C), C)
305
+ inv = inv.reshape(H, M, C, C)
306
+ decay = torch.exp(cum)[..., None] # [H, M, C, 1]
307
+ new_v = torch.matmul(inv, vp * bp)
308
+ k_cd = torch.matmul(inv, k_beta * decay)
309
+ q_dec = qp * decay
310
+ ls = last_seg[None, :, :]
311
+ to_last = (torch.exp((cum[..., -1:] - cum) * ls) * ls)[..., None] # [H, M, C, 1]
312
+ k_last = kp * to_last
313
+ last_decay = torch.exp(cum[..., -1:])[..., None] # [H, M, 1, 1]
314
+ cont_c = cont.reshape(M, C, 1)
315
+ carried = s0
316
+ for i in range(M):
317
+ c = cont_c[i]
318
+ v_new = new_v[:, i] - c * torch.matmul(k_cd[:, i], carried) - (1.0 - c) * torch.matmul(k_cd[:, i], s0)
319
+ outs.append(c * torch.matmul(q_dec[:, i], carried) + (1.0 - c) * torch.matmul(q_dec[:, i], s0)
320
+ + torch.matmul(attn[:, i], v_new))
321
+ if i + 1 < M:
322
+ cl = cont_c[i, -1:]
323
+ init = cl * carried + (1.0 - cl) * s0
324
+ carried = init * last_decay[:, i] + torch.matmul(k_last[:, i].transpose(-1, -2), v_new)
325
+
326
+ core = torch.cat(outs, dim=1).transpose(0, 1)[None]
327
+ core = net.norm(core, z)
328
+ return net.out_proj(core.reshape(1, T, H * Dv))
329
+
330
+
331
+ def load_chunked(root: str, state_len: int, packed_len: int) -> EosChunked:
332
+ cfg = TextConfig(json.load(open(f"{root}/backbone/config.json")))
333
+ state = {k: v.float() for k, v in load_file(f"{root}/backbone/model.safetensors").items()}
334
+ model = EosChunked(cfg, state_len, packed_len)
335
+ model.decoder.load_merged(state)
336
+ model.norm.load_state_dict({"weight": state["norm.weight"]})
337
+ model.embed_tokens = nn.Embedding.from_pretrained(state["embed_tokens.weight"], freeze=True)
338
+ model.load_state_dict({k: v.float() for k, v in load_file(f"{root}/decision_head.safetensors").items()},
339
+ strict=False)
340
+ return model.eval()
341
+
342
+
343
+ def chunk_inputs(segment: np.ndarray, C: int = 64):
344
+ """seg_chunks [M, C, C], cont [P], last_seg [M, C] from the packed segment mask [P, P]."""
345
+ P = segment.shape[0]
346
+ M = P // C
347
+ seg_chunks = np.stack([segment[i * C : (i + 1) * C, i * C : (i + 1) * C] for i in range(M)])
348
+ starts = np.argmax(segment > 0, axis=1) # first position of each token's question (segment row's first 1)
349
+ cont = (starts < (np.arange(P) // C) * C).astype(np.float32)
350
+ last_seg = np.stack([segment[(i + 1) * C - 1, i * C : (i + 1) * C] for i in range(M)])
351
+ return seg_chunks.astype(np.float32), cont, last_seg.astype(np.float32)
conversion/kev_stages.py ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two-stage Kev export: read the state once, then answer every question from its cache.
2
+
3
+ Kev's server does this with a KV + recurrent-state cache; a row-per-question export re-reads the state for every
4
+ question. Stage A (`StatePass`) runs the state tokens and returns, per layer, what a continuation needs:
5
+
6
+ - full-attention layers: keys (after norm and RoPE) and values of the state positions;
7
+ - Gated DeltaNet layers: the recurrent state after the last real token, and the last `conv_kernel - 1` pre-convolution
8
+ inputs (the causal conv's history).
9
+
10
+ The state is right-padded to a bucket. Padded positions get beta = 0 and g = 0 in the delta rule, which makes them
11
+ exact no-ops on the recurrent state (the WY inverse row of a beta = 0 position is the identity row), so the state
12
+ comes out as of the last real token. Attention keys of padded positions are masked in stage B.
13
+
14
+ Stage B (`QuestionPass`) runs B question branches at once, each right-padded to Q tokens and positioned right after
15
+ the state, on top of that cache, and applies Kev's pointer head at <decide> and each </opt>.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch import nn
22
+
23
+ from qwen35_export import DecoderChunk, RMSNorm, TextConfig, rotate_half
24
+
25
+ MASK_VALUE = -1e4
26
+ MASKED_INVERSE = True
27
+
28
+
29
+ def _rope(x, cos, sin, r):
30
+ return torch.cat([x[..., :r] * cos + rotate_half(x[..., :r]) * sin, x[..., r:]], dim=-1)
31
+
32
+
33
+ def _delta_chunk_terms(net, q, k, v, beta, g):
34
+ """Chunked delta-rule terms for tensors shaped [G, N, C, D] (beta [G, N, C, 1], g [G, N, C])."""
35
+ g_col = g.unsqueeze(-1)
36
+ cum = torch.matmul(net.prefix_sum_t, g_col).squeeze(-1)
37
+ to_end = torch.matmul(net.suffix_sum_t, g_col).squeeze(-1)
38
+ lead = g.shape[:2]
39
+ pair = torch.matmul(net.pair_sum_t, g_col).reshape(*lead, net.chunk, net.chunk)
40
+ pair_decay = torch.exp(pair) * net.lower_incl
41
+ k_beta, v_beta = k * beta, v * beta
42
+ kkt = torch.matmul(k_beta, k.transpose(-1, -2)) * pair_decay
43
+ attn_intra = torch.matmul(q, k.transpose(-1, -2)) * pair_decay
44
+ inv = _unit_lower_inverse(net.eye + kkt * net.strict_lower, net.chunk) if MASKED_INVERSE else \
45
+ net.unit_lower_inverse(net.eye + kkt * net.strict_lower)
46
+ new_v = torch.matmul(inv, v_beta)
47
+ k_cumdecay = torch.matmul(inv, k_beta * torch.exp(cum)[..., None])
48
+ q_dec = q * torch.exp(cum)[..., None]
49
+ k_dec = k * torch.exp(to_end)[..., None]
50
+ chunk_decay = torch.exp(cum[..., -1:])[..., None]
51
+ return new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay
52
+
53
+
54
+ def _delta_inputs(net, x, conv_input_prefix=None):
55
+ """Projections of a DeltaNet layer for x [B, L, D]. Returns q, k, v (heads expanded), z, beta, g, and the
56
+ pre-convolution qkv [B, L, conv]."""
57
+ cfg = net.cfg
58
+ B, L = x.shape[0], x.shape[1]
59
+ H, Dk, Dv, kh = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim, cfg.lin_k_heads
60
+ rep = H // kh
61
+ pre = net.in_proj_qkv(x) # [B, L, conv]
62
+ seq = pre.transpose(1, 2) # [B, conv, L]
63
+ if conv_input_prefix is None:
64
+ seq = F.pad(seq, (cfg.conv_kernel - 1, 0))
65
+ else:
66
+ seq = torch.cat([conv_input_prefix, seq], dim=-1)
67
+ qkv = F.silu(net.conv1d(seq)).transpose(1, 2) # [B, L, conv]
68
+ q = qkv[..., : net.key_dim].reshape(B, L, kh, Dk)
69
+ k = qkv[..., net.key_dim : 2 * net.key_dim].reshape(B, L, kh, Dk)
70
+ v = qkv[..., 2 * net.key_dim :].reshape(B, L, H, Dv)
71
+ z = net.in_proj_z(x).reshape(B, L, H, Dv)
72
+ beta = torch.sigmoid(net.in_proj_b(x)) # [B, L, H]
73
+ g = -torch.exp(net.A_log) * F.softplus(net.in_proj_a(x) + net.dt_bias) # [B, L, H]
74
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
75
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
76
+ q = q[:, :, :, None].expand(B, L, kh, rep, Dk).reshape(B, L, H, Dk) * (Dk**-0.5)
77
+ k = k[:, :, :, None].expand(B, L, kh, rep, Dk).reshape(B, L, H, Dk)
78
+ return q, k, v, z, beta, g, pre
79
+
80
+
81
+ class StatePass(nn.Module):
82
+ def __init__(self, cfg: TextConfig, seq_len: int, chunk_size: int = 128):
83
+ super().__init__()
84
+ self.cfg, self.S = cfg, seq_len
85
+ self.decoder = DecoderChunk(cfg, 0, cfg.num_layers, seq_len, chunk_size=chunk_size, with_head=False)
86
+
87
+ def _attention(self, attn, x, cos, sin):
88
+ cfg, S = self.cfg, self.S
89
+ h, kv, d, r = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim, cfg.rotary_dim
90
+ qg = attn.q_proj(x).view(1, S, h, 2 * d)
91
+ q, gate = qg[..., :d], qg[..., d:].reshape(1, S, h * d)
92
+ q = attn.q_norm(q).transpose(1, 2)
93
+ k = attn.k_norm(attn.k_proj(x).view(1, S, kv, d)).transpose(1, 2)
94
+ v = attn.v_proj(x).view(1, S, kv, d).transpose(1, 2)
95
+ c, s = cos[None, None], sin[None, None]
96
+ q, k = _rope(q, c, s, r), _rope(k, c, s, r)
97
+ rep = h // kv
98
+ kr = k[:, :, None].expand(1, kv, rep, S, d).reshape(1, h, S, d)
99
+ vr = v[:, :, None].expand(1, kv, rep, S, d).reshape(1, h, S, d)
100
+ scores = torch.matmul(q, kr.transpose(-1, -2)) * (d**-0.5) + self.decoder.mask
101
+ out = torch.matmul(torch.softmax(scores, dim=-1), vr).transpose(1, 2).reshape(1, S, h * d)
102
+ return attn.o_proj(out * torch.sigmoid(gate)), k[0], v[0]
103
+
104
+ def _delta(self, net, x, valid, tail_onehot):
105
+ cfg = self.cfg
106
+ L, C, N = self.S, net.chunk, net.num_chunks
107
+ H, Dk, Dv = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim
108
+ q, k, v, z, beta, g, pre = _delta_inputs(net, x)
109
+ tail = torch.matmul(tail_onehot, pre[0]).transpose(0, 1) # [conv, kernel-1]
110
+ mask = valid[None, :, None]
111
+ beta, g = beta * mask, g * mask # padded positions: no write, no decay
112
+ q = q[0].permute(1, 0, 2).reshape(H, N, C, Dk)
113
+ k = k[0].permute(1, 0, 2).reshape(H, N, C, Dk)
114
+ v = v[0].permute(1, 0, 2).reshape(H, N, C, Dv)
115
+ beta = beta[0].T.reshape(H, N, C, 1)
116
+ g = g[0].T.reshape(H, N, C)
117
+ new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay = _delta_chunk_terms(net, q, k, v, beta, g)
118
+ state = torch.zeros(H, Dk, Dv, dtype=x.dtype)
119
+ outs = []
120
+ for i in range(N):
121
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], state)
122
+ outs.append(torch.matmul(q_dec[:, i], state) + torch.matmul(attn_intra[:, i], v_new))
123
+ state = state * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
124
+ core = torch.stack(outs, dim=1).reshape(H, L, Dv).permute(1, 0, 2)
125
+ core = net.norm(core, z[0])
126
+ return net.out_proj(core.reshape(1, L, H * Dv)), state, tail
127
+
128
+ def forward(self, hidden, cos, sin, valid, tail_onehot):
129
+ """hidden [1, S, D], cos/sin [S, R], valid [S] (1 = real token), tail_onehot [kernel-1, S] selecting the last
130
+ real positions in order -> keys, values [A, kv, S, d]; delta states [Ld, H, Dk, Dv]; conv tails [Ld, conv, k-1]."""
131
+ keys, values, states, tails = [], [], [], []
132
+ x = hidden
133
+ for layer in self.decoder.layers:
134
+ h = layer.input_layernorm(x)
135
+ if layer.is_linear:
136
+ out, state, tail = self._delta(layer.linear_attn, h, valid, tail_onehot)
137
+ states.append(state)
138
+ tails.append(tail)
139
+ else:
140
+ out, key, value = self._attention(layer.self_attn, h, cos, sin)
141
+ keys.append(key)
142
+ values.append(value)
143
+ x = x + out
144
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
145
+ return torch.stack(keys), torch.stack(values), torch.stack(states), torch.stack(tails)
146
+
147
+
148
+ class QuestionPass(nn.Module):
149
+ def __init__(self, cfg: TextConfig, state_len: int, question_len: int, batch: int, max_options: int,
150
+ pointer_dim: int = 256):
151
+ super().__init__()
152
+ self.cfg, self.S, self.Q, self.B, self.K = cfg, state_len, question_len, batch, max_options
153
+ # chunk = the whole question: one delta-rule chunk on top of the cached state
154
+ self.decoder = DecoderChunk(cfg, 0, cfg.num_layers, question_len, chunk_size=question_len, with_head=False)
155
+ self.norm = RMSNorm(cfg.hidden_size, cfg.eps)
156
+ self.q = nn.Linear(cfg.hidden_size, pointer_dim)
157
+ self.k = nn.Linear(cfg.hidden_size, pointer_dim)
158
+ self.register_buffer("pointer_scale", torch.tensor([pointer_dim**-0.5]))
159
+ self.register_buffer("inverse_temperature", torch.tensor([1.0]))
160
+ causal = torch.full((question_len, question_len), MASK_VALUE).triu(1)
161
+ self.register_buffer("causal", causal)
162
+
163
+ def _attention(self, attn, x, cos, sin, key_cache, value_cache, cache_bias):
164
+ cfg, B, Q, S = self.cfg, self.B, self.Q, self.S
165
+ h, kv, d, r = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim, cfg.rotary_dim
166
+ qg = attn.q_proj(x).view(B, Q, h, 2 * d)
167
+ q, gate = qg[..., :d], qg[..., d:].reshape(B, Q, h * d)
168
+ q = attn.q_norm(q).transpose(1, 2) # [B, h, Q, d]
169
+ k = attn.k_norm(attn.k_proj(x).view(B, Q, kv, d)).transpose(1, 2)
170
+ v = attn.v_proj(x).view(B, Q, kv, d).transpose(1, 2)
171
+ c, s = cos[None, None], sin[None, None]
172
+ q, k = _rope(q, c, s, r), _rope(k, c, s, r)
173
+ k_all = torch.cat([key_cache[None].expand(B, kv, S, d), k], dim=2) # [B, kv, S+Q, d]
174
+ v_all = torch.cat([value_cache[None].expand(B, kv, S, d), v], dim=2)
175
+ rep, T = h // kv, S + Q
176
+ k_all = k_all[:, :, None].expand(B, kv, rep, T, d).reshape(B, h, T, d)
177
+ v_all = v_all[:, :, None].expand(B, kv, rep, T, d).reshape(B, h, T, d)
178
+ bias = torch.cat([cache_bias[None, :].expand(Q, S), self.causal], dim=1) # [Q, S+Q]
179
+ scores = torch.matmul(q, k_all.transpose(-1, -2)) * (d**-0.5) + bias
180
+ out = torch.matmul(torch.softmax(scores, dim=-1), v_all).transpose(1, 2).reshape(B, Q, h * d)
181
+ return attn.o_proj(out * torch.sigmoid(gate))
182
+
183
+ def _delta(self, net, x, state, tail):
184
+ cfg, B, Q = self.cfg, self.B, self.Q
185
+ H, Dk, Dv = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim
186
+ q, k, v, z, beta, g, _ = _delta_inputs(net, x, conv_input_prefix=tail[None].expand(B, -1, -1))
187
+ G = B * H
188
+ q = q.permute(0, 2, 1, 3).reshape(G, 1, Q, Dk)
189
+ k = k.permute(0, 2, 1, 3).reshape(G, 1, Q, Dk)
190
+ v = v.permute(0, 2, 1, 3).reshape(G, 1, Q, Dv)
191
+ beta = beta.permute(0, 2, 1).reshape(G, 1, Q, 1)
192
+ g = g.permute(0, 2, 1).reshape(G, 1, Q)
193
+ new_v, k_cumdecay, q_dec, _, attn_intra, _ = _delta_chunk_terms(net, q, k, v, beta, g)
194
+ s0 = state[None].expand(B, H, Dk, Dv).reshape(G, Dk, Dv)
195
+ v_new = new_v[:, 0] - torch.matmul(k_cumdecay[:, 0], s0)
196
+ core = torch.matmul(q_dec[:, 0], s0) + torch.matmul(attn_intra[:, 0], v_new) # [G, Q, Dv]
197
+ core = core.reshape(B, H, Q, Dv).permute(0, 2, 1, 3) # [B, Q, H, Dv]
198
+ core = net.norm(core, z)
199
+ return net.out_proj(core.reshape(B, Q, H * Dv))
200
+
201
+ def forward(self, hidden, cos, sin, keys, values, cache_valid, states, tails, decide_onehot, option_onehot,
202
+ option_mask):
203
+ """hidden [B, Q, D]; cos/sin [Q, R] (positions n .. n+Q-1); keys/values [A, kv, S, d]; cache_valid [S];
204
+ states [Ld, H, Dk, Dv]; tails [Ld, conv, k-1]; decide_onehot [B, Q]; option_onehot [B, K, Q];
205
+ option_mask [B, K] -> logits [B, K] (temperature applied), probabilities [B, K]."""
206
+ cache_bias = (1.0 - cache_valid) * MASK_VALUE
207
+ x = hidden
208
+ a = d = 0
209
+ for layer in self.decoder.layers:
210
+ h = layer.input_layernorm(x)
211
+ if layer.is_linear:
212
+ out = self._delta(layer.linear_attn, h, states[d], tails[d])
213
+ d += 1
214
+ else:
215
+ out = self._attention(layer.self_attn, h, cos, sin, keys[a], values[a], cache_bias)
216
+ a += 1
217
+ x = x + out
218
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
219
+ decide = self.norm(torch.matmul(decide_onehot[:, None, :], x)[:, 0]) # [B, D]
220
+ options = self.norm(torch.matmul(option_onehot, x)) # [B, K, D]
221
+ logits = (self.k(options) * self.q(decide)[:, None, :]).sum(-1) * self.pointer_scale * self.inverse_temperature
222
+ logits = torch.where(option_mask > 0.5, logits, torch.full_like(logits, MASK_VALUE))
223
+ return logits, torch.softmax(logits, dim=-1)
224
+
225
+
226
+ def load_stages(merged, state_len: int, question_len: int, batch: int, max_options: int, chunk_size: int = 128):
227
+ import json
228
+ from pathlib import Path
229
+
230
+ from safetensors.torch import load_file
231
+
232
+ merged = Path(merged)
233
+ meta = json.loads((merged / "meta.json").read_text())
234
+ cfg = TextConfig(meta["text_config"])
235
+ state = load_file(str(merged / "text.safetensors"))
236
+ head = load_file(str(merged / "head.safetensors"))
237
+ first = StatePass(cfg, state_len, chunk_size)
238
+ first.decoder.load_merged(state)
239
+ second = QuestionPass(cfg, state_len, question_len, batch, max_options)
240
+ second.decoder.load_merged(state)
241
+ second.norm.load_state_dict({"weight": state["norm.weight"]})
242
+ second.q.load_state_dict({"weight": head["q.weight"], "bias": head["q.bias"]})
243
+ second.k.load_state_dict({"weight": head["k.weight"], "bias": head["k.bias"]})
244
+ second.pointer_scale.fill_(meta["pointer_scale"])
245
+ second.inverse_temperature.fill_(1.0 / meta["temperature"])
246
+ return first.eval(), second.eval(), cfg, meta, state["embed_tokens.weight"]
247
+
248
+
249
+ def stage_inputs(cfg, embed, state_ids, question_rows, state_len, question_len, batch, max_options, pad_id):
250
+ """question_rows: [(branch ids, decide offset, option offsets)] with offsets inside the branch."""
251
+ from qwen35_export import rope_cos_sin
252
+
253
+ n = len(state_ids)
254
+ if n > state_len or len(question_rows) > batch:
255
+ raise ValueError(f"state {n} > {state_len} or {len(question_rows)} questions > {batch}")
256
+ ids = torch.full((state_len,), pad_id, dtype=torch.long)
257
+ ids[:n] = torch.tensor(state_ids)
258
+ positions = torch.arange(state_len)
259
+ cos, sin = rope_cos_sin(cfg, positions.unsqueeze(0).expand(3, -1))
260
+ valid = torch.zeros(state_len)
261
+ valid[:n] = 1
262
+ kernel = cfg.conv_kernel - 1
263
+ tail = torch.zeros(kernel, state_len)
264
+ for j in range(kernel):
265
+ p = n - kernel + j
266
+ if p >= 0:
267
+ tail[j, p] = 1
268
+ state_in = (embed[ids].unsqueeze(0), cos, sin, valid, tail)
269
+
270
+ q_ids = torch.full((batch, question_len), pad_id, dtype=torch.long)
271
+ decide = torch.zeros(batch, question_len)
272
+ options = torch.zeros(batch, max_options, question_len)
273
+ mask = torch.zeros(batch, max_options)
274
+ for b, (branch, d, opts) in enumerate(question_rows):
275
+ if len(branch) > question_len or len(opts) > max_options:
276
+ raise ValueError("question exceeds its bucket")
277
+ q_ids[b, : len(branch)] = torch.tensor(branch)
278
+ decide[b, d] = 1
279
+ for i, o in enumerate(opts):
280
+ options[b, i, o] = 1
281
+ mask[b, i] = 1
282
+ q_pos = torch.arange(n, n + question_len)
283
+ q_cos, q_sin = rope_cos_sin(cfg, q_pos.unsqueeze(0).expand(3, -1))
284
+ return state_in, (embed[q_ids], q_cos, q_sin, valid, decide, options, mask)
285
+
286
+
287
+ class PackedQuestionPass(nn.Module):
288
+ """Stage B with the questions packed end to end in one sequence of P tokens instead of B padded rows.
289
+
290
+ `segment` [P, P] is 1 where j <= i in the same question (padding positions are their own one-token segments):
291
+ it is the attention mask over the question tokens, the segment-local cumulative-decay operator of the delta rule
292
+ (every question restarts from the cached state), and its strict part is the WY triangle. A question's first
293
+ conv_kernel - 1 positions read the state's conv tail instead of the previous question: `lag_keep` [k-1, P] keeps
294
+ in-segment lags and `lag_tail` [k-1, P, k-1] selects tail entries for the rest.
295
+ """
296
+
297
+ def __init__(self, cfg: TextConfig, state_len: int, packed_len: int, batch: int, max_options: int,
298
+ pointer_dim: int = 256):
299
+ super().__init__()
300
+ self.cfg, self.S, self.P, self.B, self.K = cfg, state_len, packed_len, batch, max_options
301
+ self.decoder = DecoderChunk(cfg, 0, cfg.num_layers, packed_len, chunk_size=packed_len, with_head=False)
302
+ self.norm = RMSNorm(cfg.hidden_size, cfg.eps)
303
+ self.q = nn.Linear(cfg.hidden_size, pointer_dim)
304
+ self.k = nn.Linear(cfg.hidden_size, pointer_dim)
305
+ self.register_buffer("pointer_scale", torch.tensor([pointer_dim**-0.5]))
306
+ self.register_buffer("inverse_temperature", torch.tensor([1.0]))
307
+ self.register_buffer("eye", torch.eye(packed_len))
308
+
309
+ def _attention(self, attn, x, cos, sin, key_cache, value_cache, bias):
310
+ cfg, P, S = self.cfg, self.P, self.S
311
+ h, kv, d, r = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim, cfg.rotary_dim
312
+ qg = attn.q_proj(x).view(1, P, h, 2 * d)
313
+ q, gate = qg[..., :d], qg[..., d:].reshape(1, P, h * d)
314
+ q = attn.q_norm(q).transpose(1, 2)
315
+ k = attn.k_norm(attn.k_proj(x).view(1, P, kv, d)).transpose(1, 2)
316
+ v = attn.v_proj(x).view(1, P, kv, d).transpose(1, 2)
317
+ c, s = cos[None, None], sin[None, None]
318
+ q, k = _rope(q, c, s, r), _rope(k, c, s, r)
319
+ k_all = torch.cat([key_cache[None], k], dim=2) # [1, kv, S+P, d]
320
+ v_all = torch.cat([value_cache[None], v], dim=2)
321
+ rep, T = h // kv, S + P
322
+ k_all = k_all[:, :, None].expand(1, kv, rep, T, d).reshape(1, h, T, d)
323
+ v_all = v_all[:, :, None].expand(1, kv, rep, T, d).reshape(1, h, T, d)
324
+ scores = torch.matmul(q, k_all.transpose(-1, -2)) * (d**-0.5) + bias
325
+ out = torch.matmul(torch.softmax(scores, dim=-1), v_all).transpose(1, 2).reshape(1, P, h * d)
326
+ return attn.o_proj(out * torch.sigmoid(gate))
327
+
328
+ def _delta(self, net, x, state, tail, segment, lag_keep, lag_tail):
329
+ cfg, P = self.cfg, self.P
330
+ H, Dk, Dv, kh = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim, cfg.lin_k_heads
331
+ rep = H // kh
332
+ pre = net.in_proj_qkv(x)[0] # [P, conv]
333
+ weight = net.conv1d.weight[:, 0] # [conv, kernel]
334
+ lags = weight.shape[1] - 1
335
+ conv = pre * weight[:, lags]
336
+ for s in range(1, lags + 1):
337
+ shifted = F.pad(pre[: P - s], (0, 0, s, 0)) * lag_keep[s - 1][:, None]
338
+ shifted = shifted + torch.matmul(lag_tail[s - 1], tail.transpose(0, 1))
339
+ conv = conv + shifted * weight[:, lags - s]
340
+ qkv = F.silu(conv)
341
+ q = qkv[:, : net.key_dim].reshape(P, kh, Dk)
342
+ k = qkv[:, net.key_dim : 2 * net.key_dim].reshape(P, kh, Dk)
343
+ v = qkv[:, 2 * net.key_dim :].reshape(P, H, Dv).transpose(0, 1) # [H, P, Dv]
344
+ z = net.in_proj_z(x).reshape(1, P, H, Dv)
345
+ beta = torch.sigmoid(net.in_proj_b(x))[0].transpose(0, 1)[..., None] # [H, P, 1]
346
+ g = (-torch.exp(net.A_log) * F.softplus(net.in_proj_a(x) + net.dt_bias))[0].transpose(0, 1) # [H, P]
347
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
348
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
349
+ q = (q[:, :, None].expand(P, kh, rep, Dk).reshape(P, H, Dk) * (Dk**-0.5)).transpose(0, 1) # [H, P, Dk]
350
+ k = k[:, :, None].expand(P, kh, rep, Dk).reshape(P, H, Dk).transpose(0, 1)
351
+ cum = torch.matmul(g, segment.transpose(0, 1)) # [H, P]: decay since the segment start
352
+ pair = (cum[:, :, None] - cum[:, None, :]) * segment + (segment - 1.0) * 1e4
353
+ pair_decay = torch.exp(pair)
354
+ k_beta, v_beta = k * beta, v * beta
355
+ kkt = torch.matmul(k_beta, k.transpose(-1, -2)) * pair_decay
356
+ attn_intra = torch.matmul(q, k.transpose(-1, -2)) * pair_decay
357
+ inv = _unit_lower_inverse(self.eye + kkt * (segment - self.eye), P)
358
+ decay = torch.exp(cum)[..., None]
359
+ v_new = torch.matmul(inv, v_beta) - torch.matmul(torch.matmul(inv, k_beta * decay), state)
360
+ core = torch.matmul(q * decay, state) + torch.matmul(attn_intra, v_new) # [H, P, Dv]
361
+ core = net.norm(core.transpose(0, 1)[None], z)
362
+ return net.out_proj(core.reshape(1, P, H * Dv))
363
+
364
+ def forward(self, hidden, cos, sin, keys, values, cache_valid, states, tails, segment, lag_keep, lag_tail,
365
+ decide_onehot, option_onehot, option_mask):
366
+ """hidden [1, P, D]; cos/sin [P, R] (each question at positions n, n+1, ...); keys/values [A, kv, S, d];
367
+ cache_valid [S]; states [Ld, H, Dk, Dv]; tails [Ld, conv, k-1]; segment [P, P]; lag_keep [k-1, P];
368
+ lag_tail [k-1, P, k-1]; decide_onehot [B, P]; option_onehot [B, K, P]; option_mask [B, K]
369
+ -> logits [B, K] (temperature applied), probabilities [B, K]."""
370
+ bias = torch.cat([((1.0 - cache_valid) * MASK_VALUE)[None, :].expand(self.P, self.S),
371
+ (1.0 - segment) * MASK_VALUE], dim=1)
372
+ x = hidden
373
+ a = d = 0
374
+ for layer in self.decoder.layers:
375
+ h = layer.input_layernorm(x)
376
+ if layer.is_linear:
377
+ out = self._delta(layer.linear_attn, h, states[d], tails[d], segment, lag_keep, lag_tail)
378
+ d += 1
379
+ else:
380
+ out = self._attention(layer.self_attn, h, cos, sin, keys[a], values[a], bias)
381
+ a += 1
382
+ x = x + out
383
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
384
+ decide = self.norm(torch.matmul(decide_onehot, x[0])) # [B, D]
385
+ options = self.norm(torch.matmul(option_onehot, x[0])) # [B, K, D]
386
+ logits = (self.k(options) * self.q(decide)[:, None, :]).sum(-1) * self.pointer_scale * self.inverse_temperature
387
+ logits = torch.where(option_mask > 0.5, logits, torch.full_like(logits, MASK_VALUE))
388
+ return logits, torch.softmax(logits, dim=-1)
389
+
390
+
391
+ def load_packed(merged, state_len: int, packed_len: int, batch: int, max_options: int):
392
+ import json
393
+ from pathlib import Path
394
+
395
+ from safetensors.torch import load_file
396
+
397
+ merged = Path(merged)
398
+ meta = json.loads((merged / "meta.json").read_text())
399
+ cfg = TextConfig(meta["text_config"])
400
+ state = load_file(str(merged / "text.safetensors"))
401
+ head = load_file(str(merged / "head.safetensors"))
402
+ model = PackedQuestionPass(cfg, state_len, packed_len, batch, max_options)
403
+ model.decoder.load_merged(state)
404
+ model.norm.load_state_dict({"weight": state["norm.weight"]})
405
+ model.q.load_state_dict({"weight": head["q.weight"], "bias": head["q.bias"]})
406
+ model.k.load_state_dict({"weight": head["k.weight"], "bias": head["k.bias"]})
407
+ model.pointer_scale.fill_(meta["pointer_scale"])
408
+ model.inverse_temperature.fill_(1.0 / meta["temperature"])
409
+ return model.eval()
410
+
411
+
412
+ def packed_inputs(cfg, embed, state_count, question_rows, packed_len, batch, max_options, pad_id, lags=3, lane=None):
413
+ """question_rows: [(branch ids, decide offset, option offsets)] -> PackedQuestionPass inputs after the cache.
414
+ With `lane`, a question that would cross a lane boundary starts at the next lane."""
415
+ from qwen35_export import rope_cos_sin
416
+
417
+ P = packed_len
418
+ ids = torch.full((P,), pad_id, dtype=torch.long)
419
+ positions = torch.full((P,), state_count, dtype=torch.long)
420
+ segment = torch.eye(P)
421
+ lag_keep = torch.zeros(lags, P)
422
+ lag_tail = torch.zeros(lags, P, lags)
423
+ decide = torch.zeros(batch, P)
424
+ options = torch.zeros(batch, max_options, P)
425
+ mask = torch.zeros(batch, max_options)
426
+ start = 0
427
+ for b, (branch, d, opts) in enumerate(question_rows):
428
+ n = len(branch)
429
+ if lane and start // lane != (start + n - 1) // lane:
430
+ start = (start // lane + 1) * lane
431
+ if start + n > P or b >= batch or len(opts) > max_options:
432
+ raise ValueError("questions exceed the packed bucket")
433
+ ids[start : start + n] = torch.tensor(branch)
434
+ positions[start : start + n] = torch.arange(state_count, state_count + n)
435
+ segment[start : start + n, start : start + n] = torch.ones(n, n).tril()
436
+ for p in range(n):
437
+ for s in range(1, lags + 1):
438
+ if p >= s:
439
+ lag_keep[s - 1, start + p] = 1
440
+ else:
441
+ lag_tail[s - 1, start + p, lags + p - s] = 1
442
+ decide[b, start + d] = 1
443
+ for i, o in enumerate(opts):
444
+ options[b, i, start + o] = 1
445
+ mask[b, i] = 1
446
+ start += n
447
+ cos, sin = rope_cos_sin(cfg, positions.unsqueeze(0).expand(3, -1))
448
+ return embed[ids].unsqueeze(0), cos, sin, segment, lag_keep, lag_tail, decide, options, mask
449
+
450
+
451
+ def _masked_block_inverse(t, C, block=None):
452
+ """Same blocking as GatedDeltaNet.unit_lower_inverse, kept as one [.., C, C] matrix: with D the inverse of t's
453
+ diagonal s-blocks and L = t's lower-left s-blocks inside each diagonal 2s-block, the 2s-block inverse is
454
+ D - D L D (L D L = 0). Four ops per level instead of block extraction, slicing and concatenation."""
455
+ idx = torch.arange(C)
456
+ inv = None
457
+ s = 1
458
+ while s < min(C, block or C):
459
+ same_pair = (idx[:, None] // (2 * s)) == (idx[None, :] // (2 * s))
460
+ lower_left = same_pair & ((idx[:, None] // s) % 2 == 1) & ((idx[None, :] // s) % 2 == 0)
461
+ mask = lower_left.to(t.dtype)
462
+ if inv is None:
463
+ inv = torch.eye(C, dtype=t.dtype) - t * mask
464
+ else:
465
+ inv = inv - torch.matmul(torch.matmul(inv, t * mask), inv)
466
+ s *= 2
467
+ return inv
468
+
469
+
470
+ def _unit_lower_inverse(t, C, block=None):
471
+ """GatedDeltaNet.unit_lower_inverse for a [G, C, C] unit-lower-triangular batch of any power-of-two C. `block`:
472
+ t is known block-diagonal in blocks of that size (packed questions never cross a lane)."""
473
+ if MASKED_INVERSE:
474
+ return _masked_block_inverse(t, C, block)
475
+ b = t.shape[0]
476
+ inv = torch.ones(b, C, 1, 1, dtype=t.dtype)
477
+ s = 1
478
+ while s < C:
479
+ n2 = C // (2 * s)
480
+ blocks = (t.reshape(b, n2, 2 * s, n2, 2 * s) * torch.eye(n2, dtype=t.dtype)[:, None, :, None]).sum(-2)
481
+ x = blocks[..., s:, :s]
482
+ pair = inv.reshape(b, n2, 2, s, s)
483
+ a_inv, b_inv = pair[..., 0, :, :], pair[..., 1, :, :]
484
+ lower = -torch.matmul(b_inv, torch.matmul(x, a_inv))
485
+ inv = torch.cat([torch.cat([a_inv, torch.zeros_like(a_inv)], dim=-1), torch.cat([lower, b_inv], dim=-1)], dim=-2)
486
+ s *= 2
487
+ return inv.reshape(b, C, C)
488
+
489
+
490
+ class FusedPass(nn.Module):
491
+ """State pass and packed question pass in one call: sequence = S state tokens (right-padded) + P packed question
492
+ tokens. Projections, norms, MLPs and attention run once over all S + P positions (attention under one combined
493
+ mask); only the delta rule splits: chunked over the state, then one segment-masked chunk for the questions on top
494
+ of the state's final recurrent state and conv tail."""
495
+
496
+ def __init__(self, cfg: TextConfig, state_len: int, packed_len: int, batch: int, max_options: int,
497
+ chunk_size: int = 64, pointer_dim: int = 256, lane: int = 128):
498
+ super().__init__()
499
+ self.cfg, self.S, self.P, self.B, self.K = cfg, state_len, packed_len, batch, max_options
500
+ self.lane = min(lane, packed_len)
501
+ self.decoder = DecoderChunk(cfg, 0, cfg.num_layers, state_len, chunk_size=min(chunk_size, state_len),
502
+ with_head=False)
503
+ self.norm = RMSNorm(cfg.hidden_size, cfg.eps)
504
+ self.q = nn.Linear(cfg.hidden_size, pointer_dim)
505
+ self.k = nn.Linear(cfg.hidden_size, pointer_dim)
506
+ self.register_buffer("pointer_scale", torch.tensor([pointer_dim**-0.5]))
507
+ self.register_buffer("inverse_temperature", torch.tensor([1.0]))
508
+ self.register_buffer("eye_p", torch.eye(packed_len))
509
+ self.register_buffer("state_causal", torch.full((state_len, state_len), MASK_VALUE).triu(1))
510
+ self.register_buffer("state_block", torch.full((state_len, packed_len), MASK_VALUE))
511
+
512
+ def _attention(self, attn, x, cos, sin, bias):
513
+ cfg, T = self.cfg, self.S + self.P
514
+ h, kv, d, r = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim, cfg.rotary_dim
515
+ qg = attn.q_proj(x).view(1, T, h, 2 * d)
516
+ q, gate = qg[..., :d], qg[..., d:].reshape(1, T, h * d)
517
+ q = attn.q_norm(q).transpose(1, 2)
518
+ k = attn.k_norm(attn.k_proj(x).view(1, T, kv, d)).transpose(1, 2)
519
+ v = attn.v_proj(x).view(1, T, kv, d).transpose(1, 2)
520
+ c, s = cos[None, None], sin[None, None]
521
+ q, k = _rope(q, c, s, r), _rope(k, c, s, r)
522
+ rep = h // kv
523
+ k = k[:, :, None].expand(1, kv, rep, T, d).reshape(1, h, T, d)
524
+ v = v[:, :, None].expand(1, kv, rep, T, d).reshape(1, h, T, d)
525
+ scores = torch.matmul(q, k.transpose(-1, -2)) * (d**-0.5) + bias
526
+ out = torch.matmul(torch.softmax(scores, dim=-1), v).transpose(1, 2).reshape(1, T, h * d)
527
+ return attn.o_proj(out * torch.sigmoid(gate))
528
+
529
+ def _delta(self, net, x, valid, tail_onehot, segment, lag_keep, lag_tail):
530
+ cfg, S, P = self.cfg, self.S, self.P
531
+ T = S + P
532
+ H, Dk, Dv, kh = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim, cfg.lin_k_heads
533
+ rep = H // kh
534
+ pre = net.in_proj_qkv(x)[0] # [T, conv]
535
+ pre_s, pre_p = pre[:S], pre[S:]
536
+ tail = torch.matmul(tail_onehot, pre_s) # [k-1, conv]
537
+ weight = net.conv1d.weight[:, 0] # [conv, kernel]
538
+ lags = weight.shape[1] - 1
539
+ conv_s, conv_p = pre_s * weight[:, lags], pre_p * weight[:, lags]
540
+ for s in range(1, lags + 1):
541
+ conv_s = conv_s + F.pad(pre_s[: S - s], (0, 0, s, 0)) * weight[:, lags - s]
542
+ shifted = F.pad(pre_p[: P - s], (0, 0, s, 0)) * lag_keep[s - 1][:, None] + torch.matmul(lag_tail[s - 1], tail)
543
+ conv_p = conv_p + shifted * weight[:, lags - s]
544
+ qkv = F.silu(torch.cat([conv_s, conv_p], dim=0)) # [T, conv]
545
+ q = qkv[:, : net.key_dim].reshape(T, kh, Dk)
546
+ k = qkv[:, net.key_dim : 2 * net.key_dim].reshape(T, kh, Dk)
547
+ v = qkv[:, 2 * net.key_dim :].reshape(T, H, Dv).transpose(0, 1) # [H, T, Dv]
548
+ z = net.in_proj_z(x).reshape(1, T, H, Dv)
549
+ beta = torch.sigmoid(net.in_proj_b(x))[0].transpose(0, 1) # [H, T]
550
+ g = (-torch.exp(net.A_log) * F.softplus(net.in_proj_a(x) + net.dt_bias))[0].transpose(0, 1) # [H, T]
551
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
552
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
553
+ q = (q[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk) * (Dk**-0.5)).transpose(0, 1) # [H, T, Dk]
554
+ k = k[:, :, None].expand(T, kh, rep, Dk).reshape(T, H, Dk).transpose(0, 1)
555
+
556
+ # state: chunked, padded positions are no-ops
557
+ C, N = net.chunk, net.num_chunks
558
+ beta_s, g_s = beta[:, :S] * valid, g[:, :S] * valid
559
+ new_v, k_cumdecay, q_dec, k_dec, attn_intra, chunk_decay = _delta_chunk_terms(
560
+ net, q[:, :S].reshape(H, N, C, Dk), k[:, :S].reshape(H, N, C, Dk), v[:, :S].reshape(H, N, C, Dv),
561
+ beta_s.reshape(H, N, C, 1), g_s.reshape(H, N, C))
562
+ state = torch.zeros(H, Dk, Dv, dtype=x.dtype)
563
+ outs = []
564
+ for i in range(N):
565
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], state)
566
+ outs.append(torch.matmul(q_dec[:, i], state) + torch.matmul(attn_intra[:, i], v_new))
567
+ state = state * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
568
+
569
+ # questions: one segment-masked chunk from the final state
570
+ qp, kp, vp = q[:, S:], k[:, S:], v[:, S:]
571
+ beta_p = beta[:, S:][..., None]
572
+ cum = torch.matmul(g[:, S:], segment.transpose(0, 1)) # [H, P]
573
+ pair_decay = torch.exp((cum[:, :, None] - cum[:, None, :]) * segment + (segment - 1.0) * 1e4)
574
+ k_beta = kp * beta_p
575
+ kkt = torch.matmul(k_beta, kp.transpose(-1, -2)) * pair_decay
576
+ attn_p = torch.matmul(qp, kp.transpose(-1, -2)) * pair_decay
577
+ inv = _unit_lower_inverse(self.eye_p + kkt * (segment - self.eye_p), P, self.lane)
578
+ decay = torch.exp(cum)[..., None]
579
+ v_new = torch.matmul(inv, vp * beta_p) - torch.matmul(torch.matmul(inv, k_beta * decay), state)
580
+ outs.append(torch.matmul(qp * decay, state) + torch.matmul(attn_p, v_new))
581
+
582
+ core = torch.cat(outs, dim=1).transpose(0, 1)[None] # [1, T, H, Dv]
583
+ core = net.norm(core, z)
584
+ return net.out_proj(core.reshape(1, T, H * Dv))
585
+
586
+ def forward(self, hidden, cos, sin, valid, tail_onehot, segment, lag_keep, lag_tail, decide_onehot, option_onehot,
587
+ option_mask):
588
+ """hidden [1, S+P, D]; cos/sin [S+P, R]; valid [S]; tail_onehot [k-1, S]; segment [P, P]; lag_keep [k-1, P];
589
+ lag_tail [k-1, P, k-1]; decide_onehot [B, P]; option_onehot [B, K, P]; option_mask [B, K]
590
+ -> logits [B, K] (temperature applied), probabilities [B, K]."""
591
+ S, P = self.S, self.P
592
+ bias = torch.cat([
593
+ torch.cat([self.state_causal, self.state_block], dim=1),
594
+ torch.cat([((1.0 - valid) * MASK_VALUE)[None, :].expand(P, S), (1.0 - segment) * MASK_VALUE], dim=1),
595
+ ], dim=0)
596
+ x = hidden
597
+ for layer in self.decoder.layers:
598
+ h = layer.input_layernorm(x)
599
+ if layer.is_linear:
600
+ out = self._delta(layer.linear_attn, h, valid, tail_onehot, segment, lag_keep, lag_tail)
601
+ else:
602
+ out = self._attention(layer.self_attn, h, cos, sin, bias)
603
+ x = x + out
604
+ x = x + layer.mlp(layer.post_attention_layernorm(x))
605
+ xq = x[0, S:]
606
+ decide = self.norm(torch.matmul(decide_onehot, xq))
607
+ options = self.norm(torch.matmul(option_onehot, xq))
608
+ logits = (self.k(options) * self.q(decide)[:, None, :]).sum(-1) * self.pointer_scale * self.inverse_temperature
609
+ logits = torch.where(option_mask > 0.5, logits, torch.full_like(logits, MASK_VALUE))
610
+ return logits, torch.softmax(logits, dim=-1)
611
+
612
+
613
+ def load_fused(merged, state_len: int, packed_len: int, batch: int, max_options: int, chunk_size: int = 64,
614
+ lane: int = 128):
615
+ import json
616
+ from pathlib import Path
617
+
618
+ from safetensors.torch import load_file
619
+
620
+ merged = Path(merged)
621
+ meta = json.loads((merged / "meta.json").read_text())
622
+ cfg = TextConfig(meta["text_config"])
623
+ state = load_file(str(merged / "text.safetensors"))
624
+ head = load_file(str(merged / "head.safetensors"))
625
+ model = FusedPass(cfg, state_len, packed_len, batch, max_options, chunk_size, lane=lane)
626
+ model.decoder.load_merged(state)
627
+ model.norm.load_state_dict({"weight": state["norm.weight"]})
628
+ model.q.load_state_dict({"weight": head["q.weight"], "bias": head["q.bias"]})
629
+ model.k.load_state_dict({"weight": head["k.weight"], "bias": head["k.bias"]})
630
+ model.pointer_scale.fill_(meta["pointer_scale"])
631
+ model.inverse_temperature.fill_(1.0 / meta["temperature"])
632
+ return model.eval()
633
+
634
+
635
+ def fused_inputs(cfg, embed, state_ids, question_rows, state_len, packed_len, batch, max_options, pad_id, lane=128):
636
+ state_in, _ = stage_inputs(cfg, embed, state_ids, [], state_len, 32, 2, max_options, pad_id)
637
+ s_hidden, s_cos, s_sin, valid, tail = state_in
638
+ p_hidden, p_cos, p_sin, segment, keep, lag_tail, decide, options, mask = packed_inputs(
639
+ cfg, embed, len(state_ids), question_rows, packed_len, batch, max_options, pad_id, lane=min(lane, packed_len))
640
+ return (torch.cat([s_hidden, p_hidden], dim=1), torch.cat([s_cos, p_cos]), torch.cat([s_sin, p_sin]), valid, tail,
641
+ segment, keep, lag_tail, decide, options, mask)
642
+
643
+
644
+ def pack_groups(lengths, packed_len, batch, lane=None):
645
+ """Greedy packing (the order is kept) of question lengths into calls of `packed_len` tokens and `batch` readouts,
646
+ no question crossing a lane. Returns lists of question indices."""
647
+ groups, current, start = [], [], 0
648
+ for i, n in enumerate(lengths):
649
+ if n > (lane or packed_len):
650
+ raise ValueError(f"question of {n} tokens exceeds the lane")
651
+ placed = start
652
+ if lane and placed // lane != (placed + n - 1) // lane:
653
+ placed = (placed // lane + 1) * lane
654
+ if current and (placed + n > packed_len or len(current) == batch):
655
+ groups.append(current)
656
+ current, placed = [], 0
657
+ current.append(i)
658
+ start = placed + n
659
+ if current:
660
+ groups.append(current)
661
+ return groups
conversion/pack.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Host side: System One request -> packed tree inputs, and logits -> answers (vendored code)."""
2
+
3
+ import sys
4
+
5
+ import numpy as np
6
+
7
+ sys.path.insert(0, "kai")
8
+ from decision2._vendor.dev2model.decision_model import encode # noqa: E402
9
+ from decision2._vendor.dev2model.infer import product_answer, question_to_row # noqa: E402
10
+ from decision2._vendor.dev2model.score_bias import apply as apply_score_bias # noqa: E402
11
+
12
+ NEG = -1e4
13
+
14
+
15
+ def rows(tokenizer, state, questions, cap=8192):
16
+ item = {"id": "request", "state": state}
17
+ out = []
18
+ for qid, q in questions.items():
19
+ row = question_to_row(item, qid, q)
20
+ out.append((qid, row, encode(row, tokenizer, cap)))
21
+ return out
22
+
23
+
24
+ def pack(jobs, L, N, pad_id):
25
+ """Shared prefix once, then each suffix; suffix tokens see prefix + own suffix (causal)."""
26
+ seqs = [e["ids"] for _, _, e in jobs]
27
+ P = min(min(e["candidate_positions"][0] for _, _, e in jobs), min(len(s) for s in seqs) - 1)
28
+ for i in range(P):
29
+ if any(s[i] != seqs[0][i] for s in seqs):
30
+ P = i
31
+ break
32
+ ids, pos, seg = list(seqs[0][:P]), list(range(P)), [-1] * P
33
+ starts = []
34
+ for j, s in enumerate(seqs):
35
+ starts.append(len(ids) - P)
36
+ ids += s[P:]
37
+ pos += range(P, len(s))
38
+ seg += [j] * (len(s) - P)
39
+ T = len(ids)
40
+ if T > L:
41
+ raise ValueError(f"packed length {T} > {L}")
42
+ cand, qry, owner = [], [], []
43
+ for j, (_, _, e) in enumerate(jobs):
44
+ for c in e["candidate_positions"]:
45
+ cand.append(c if c < P else c + starts[j])
46
+ qry.append(e["query_position"] + starts[j])
47
+ owner.append(j)
48
+ if len(cand) > N:
49
+ raise ValueError(f"{len(cand)} candidates > {N}")
50
+ seg = np.array(seg + [-2] * (L - T))
51
+ p = np.array(pos + [0] * (L - T))
52
+ i = np.arange(L)
53
+ causal = i[None, :] <= i[:, None]
54
+ same = (seg[None, :] == seg[:, None]) | (seg[None, :] == -1)
55
+ allow = causal & same & (seg[None, :] != -2)
56
+ allow[np.arange(L), np.arange(L)] = True # padding rows attend to themselves
57
+ mask = np.where(allow, 0.0, NEG).astype(np.float32)[None, None]
58
+ pad = N - len(cand)
59
+ return {
60
+ "input_ids": np.array([ids + [pad_id] * (L - T)], dtype=np.int32),
61
+ "position_ids": p[None].astype(np.int32),
62
+ "mask": mask,
63
+ "cand_idx": np.array(cand + [0] * pad, dtype=np.int32),
64
+ "query_idx": np.array(qry + [0] * pad, dtype=np.int32),
65
+ }, owner, T
66
+
67
+
68
+ def answers(jobs, logits, owner, score_bias=None, temps=None):
69
+ per = [[] for _ in jobs]
70
+ for j, v in zip(owner, logits[: len(owner)]):
71
+ per[j].append(float(v))
72
+ out = {}
73
+ for (qid, row, e), values in zip(jobs, per):
74
+ if score_bias is not None and row["task_type"] == "score":
75
+ values = apply_score_bias(score_bias, values, len(row["options"]))
76
+ out[qid] = product_answer(
77
+ row["task_type"], e["keys"], values, (temps or {}).get(row["task_type"], 1.0),
78
+ [o["description"] for o in row["options"]],
79
+ )
80
+ return out
81
+
82
+
83
+ def size(jobs):
84
+ """(packed tokens, candidates) of one packed call, without building it."""
85
+ seqs = [e["ids"] for _, _, e in jobs]
86
+ P = min(min(e["candidate_positions"][0] for _, _, e in jobs), min(len(s) for s in seqs) - 1)
87
+ for i in range(P):
88
+ if any(s[i] != seqs[0][i] for s in seqs):
89
+ P = i
90
+ break
91
+ return P + sum(len(s) - P for s in seqs), sum(len(e["keys"]) for _, _, e in jobs)
92
+
93
+
94
+ def fits(jobs, L, N):
95
+ T, C = size(jobs)
96
+ return T <= L and C <= N
97
+
98
+
99
+ def chunks(jobs, L, N):
100
+ """Greedy split of a request's questions into groups that each pack into one L/N call."""
101
+ groups, cur = [], []
102
+ for j in range(len(jobs)):
103
+ if not cur or fits([jobs[i] for i in cur + [j]], L, N):
104
+ cur.append(j)
105
+ else:
106
+ groups.append(cur)
107
+ cur = [j]
108
+ return groups + [cur]
109
+
110
+
111
+ def run(model, jobs, L, N, pad_id):
112
+ """Logits per job (list of lists), over as many packed calls as needed."""
113
+ per = [None] * len(jobs)
114
+ for group in chunks(jobs, L, N):
115
+ sub = [jobs[i] for i in group]
116
+ x, owner, _ = pack(sub, L, N, pad_id)
117
+ x["mask"] = x["mask"].astype("float16")
118
+ out = model.predict(x)["logits"]
119
+ for k, i in enumerate(group):
120
+ per[i] = [float(v) for v, o in zip(out, owner) if o == k]
121
+ return per
conversion/parity_chunked_torch.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, torch
2
+ from transformers import AutoModel, AutoTokenizer
3
+ from cases import CASES
4
+ import pack, q64ctx
5
+ from eos_graph import load_chunked, eos_inputs, with_slots, chunk_inputs
6
+ S, P = int(sys.argv[1]), int(sys.argv[2])
7
+ ref = AutoModel.from_pretrained("eos", trust_remote_code=True, device="mps")
8
+ tok = AutoTokenizer.from_pretrained("eos"); pad = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
9
+ g = load_chunked("eos", S, P); worst = 0
10
+ cases = CASES + [(q64ctx.state, dict(list(q64ctx.Q.items())[:9]))]
11
+ for state, qs in cases:
12
+ r = ref.system_one(state=state, questions=qs)["answers"]
13
+ jobs = pack.rows(tok, state, qs)
14
+ x, cand, qry, owner, used = eos_inputs(g.cfg, [e for _, _, e in jobs], S, P, pad)
15
+ x = with_slots(x, cand, qry, 64)
16
+ x["seg_chunks"], x["cont"], x["last_seg"] = chunk_inputs(x["segment"])
17
+ names = ("input_ids", "cos", "sin", "valid", "tail_onehot", "segment", "lag_keep", "lag_tail", "seg_chunks", "cont", "last_seg", "cand_idx", "query_idx")
18
+ with torch.no_grad():
19
+ lg = g(*[torch.from_numpy(x[k]) for k in names])
20
+ a = pack.answers(jobs, lg.tolist(), owner, None)
21
+ d = 0
22
+ for q in qs:
23
+ pr = r[q].get("probabilities", {"true": r[q].get("noul")}); pa = a[q].get("probabilities", {"true": a[q].get("noul")})
24
+ d = max(d, max(abs(pr[k] - pa[k]) for k in pr))
25
+ worst = max(worst, d); print(f"{len(qs)} questions, packed {used}, max dp {d:.2e}")
26
+ print("WORST", worst)
conversion/q64ctx.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, sys, time
2
+ import numpy as np, coremltools as ct
3
+ from transformers import AutoModel, AutoTokenizer
4
+ import pack
5
+ state = ("Subject: URGENT - payment failed for order #88213\n\nHi, I tried to pay for my annual Pro plan renewal "
6
+ "with my Visa ending 4421 but the charge was declined twice, and now my team of 12 is locked out of the dashboard. "
7
+ "We have a board demo tomorrow at 9am PST. I already contacted my bank and they say the card is fine. Can you "
8
+ "please extend access for 48 hours and send me an invoice I can pay by wire instead? Also, the export to CSV "
9
+ "feature has been timing out since last week. Thanks, Dana (Head of Ops, Brightline Logistics)")
10
+ yn = ["Is this about a failed payment?", "Is the customer locked out?", "Is there a deadline mentioned?", "Does the customer ask for an invoice?",
11
+ "Is a bug reported?", "Is the customer angry?", "Is this spam?", "Is this a phishing attempt?", "Is the sender an existing customer?",
12
+ "Does the message mention a team size?", "Does the customer request a refund?", "Does the customer want to cancel?",
13
+ "Is wire transfer mentioned?", "Is a credit card mentioned?", "Is personal data shared?", "Does it need a reply today?"]
14
+ Q = {}
15
+ for i in range(64):
16
+ k = i % 4
17
+ if k == 0: Q[f"q{i}"] = {"type": "noul", "instructions": yn[(i // 4) % 16] + ("" if i < 32 else " (strict)")}
18
+ elif k == 1: Q[f"q{i}"] = {"type": "choice", "instructions": f"Which team should own item {i}?", "criteria": {"billing": "Payments and invoices", "support": "Product bugs", "sales": "Plans and upgrades", "security": "Fraud and access"}}
19
+ elif k == 2: Q[f"q{i}"] = {"type": "score", "instructions": f"Urgency rating {i}?", "criteria": ["None", "Low", "Medium", "High", "Critical"]}
20
+ else: Q[f"q{i}"] = {"type": "choice", "instructions": f"Overall sentiment {i}?", "criteria": {"pos": "Positive", "neu": "Neutral", "neg": "Negative"}}
conversion/qwen35_export.py ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trace-friendly, prefill-only Qwen3.5 text decoder (copied from models/computer-use/cua-s1-4b/coreml, mobius PR #104).
2
+
3
+ Cua-S1-4B reads one forward pass: the logits of the answer letters A..Z at the
4
+ last prompt position. Nothing is generated, so this graph has no KV cache, no
5
+ recurrent-state I/O and no full vocabulary head:
6
+
7
+ - inputs are `inputs_embeds` (host-side embedding gather, so a multimodal
8
+ caller can splice vision features in) plus host-computed M-RoPE `cos`/`sin`;
9
+ - prompts are right-padded to a fixed bucket; the model is causal and the gated
10
+ delta rule is a forward scan, so padding after the last real token cannot
11
+ change anything before it;
12
+ - the last chunk gathers the hidden state at `last_index` (one-hot matmul),
13
+ applies the final norm and multiplies by the 26 tied-embedding rows of the
14
+ letter tokens.
15
+
16
+ The gated delta rule uses the chunked (WY/UT) form. The unit-lower-triangular
17
+ solve is a recursive 2x2 block inverse (log2(chunk) batched matmul levels). The
18
+ nilpotent power series (I + N)(I + N^2)... is exact algebraically but its terms
19
+ grow combinatorially before cancelling and overflow on real Qwen3.5 layers.
20
+ Within-chunk decay differences are computed as masked sums of the per-step
21
+ decays instead of differences of cumulative sums, which keeps them accurate in
22
+ fp16 when the cumulative decay is large.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+
29
+ import torch
30
+ import torch.nn.functional as F
31
+ from torch import nn
32
+
33
+
34
+ class TextConfig:
35
+ def __init__(self, cfg: dict):
36
+ self.hidden_size = cfg["hidden_size"]
37
+ self.intermediate_size = cfg["intermediate_size"]
38
+ self.num_layers = cfg["num_hidden_layers"]
39
+ self.layer_types = cfg["layer_types"]
40
+ self.eps = cfg["rms_norm_eps"]
41
+ self.num_heads = cfg["num_attention_heads"]
42
+ self.num_kv_heads = cfg["num_key_value_heads"]
43
+ self.head_dim = cfg["head_dim"]
44
+ self.lin_k_heads = cfg["linear_num_key_heads"]
45
+ self.lin_v_heads = cfg["linear_num_value_heads"]
46
+ self.lin_k_dim = cfg["linear_key_head_dim"]
47
+ self.lin_v_dim = cfg["linear_value_head_dim"]
48
+ self.conv_kernel = cfg["linear_conv_kernel_dim"]
49
+ rope = cfg["rope_parameters"]
50
+ self.rope_theta = rope["rope_theta"]
51
+ self.rotary_dim = int(self.head_dim * rope.get("partial_rotary_factor", 1.0))
52
+ self.mrope_section = rope.get("mrope_section", [11, 11, 10])
53
+
54
+
55
+ def rope_cos_sin(cfg: TextConfig, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
56
+ """Interleaved M-RoPE tables. `position_ids` is [3, L] (t, h, w); returns [L, rotary_dim] fp32 each.
57
+
58
+ Mirrors `Qwen3_5TextRotaryEmbedding` (computed in float64 then cast, host side)."""
59
+ dim = cfg.rotary_dim
60
+ inv_freq = 1.0 / (cfg.rope_theta ** (torch.arange(0, dim, 2, dtype=torch.float64) / dim))
61
+ freqs = position_ids.to(torch.float64)[:, :, None] * inv_freq[None, None, :] # [3, L, dim/2]
62
+ thw = freqs[0].clone()
63
+ for axis, offset in ((1, 1), (2, 2)):
64
+ length = cfg.mrope_section[axis] * 3
65
+ thw[:, offset:length:3] = freqs[axis][:, offset:length:3]
66
+ emb = torch.cat([thw, thw], dim=-1)
67
+ return emb.cos().float(), emb.sin().float()
68
+
69
+
70
+ class RMSNorm(nn.Module):
71
+ """Qwen3.5 zero-centred RMSNorm: x * rsqrt(mean(x^2)+eps) * (1 + w)."""
72
+
73
+ def __init__(self, dim: int, eps: float):
74
+ super().__init__()
75
+ self.eps = eps
76
+ self.weight = nn.Parameter(torch.zeros(dim))
77
+
78
+ def forward(self, x):
79
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
80
+ return x * (1.0 + self.weight)
81
+
82
+
83
+ class MLP(nn.Module):
84
+ def __init__(self, cfg: TextConfig):
85
+ super().__init__()
86
+ self.gate_proj = nn.Linear(cfg.hidden_size, cfg.intermediate_size, bias=False)
87
+ self.up_proj = nn.Linear(cfg.hidden_size, cfg.intermediate_size, bias=False)
88
+ self.down_proj = nn.Linear(cfg.intermediate_size, cfg.hidden_size, bias=False)
89
+
90
+ def forward(self, x):
91
+ return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
92
+
93
+
94
+ def rotate_half(x):
95
+ half = x.shape[-1] // 2
96
+ return torch.cat([-x[..., half:], x[..., :half]], dim=-1)
97
+
98
+
99
+ class FullAttention(nn.Module):
100
+ def __init__(self, cfg: TextConfig):
101
+ super().__init__()
102
+ self.cfg = cfg
103
+ h, kv, d = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim
104
+ self.q_proj = nn.Linear(cfg.hidden_size, h * d * 2, bias=False)
105
+ self.k_proj = nn.Linear(cfg.hidden_size, kv * d, bias=False)
106
+ self.v_proj = nn.Linear(cfg.hidden_size, kv * d, bias=False)
107
+ self.o_proj = nn.Linear(h * d, cfg.hidden_size, bias=False)
108
+ self.q_norm = RMSNorm(d, cfg.eps)
109
+ self.k_norm = RMSNorm(d, cfg.eps)
110
+
111
+ def forward(self, x, cos, sin, mask):
112
+ cfg = self.cfg
113
+ L = x.shape[1]
114
+ h, kv, d, r = cfg.num_heads, cfg.num_kv_heads, cfg.head_dim, cfg.rotary_dim
115
+ qg = self.q_proj(x).view(1, L, h, 2 * d)
116
+ q, gate = qg[..., :d], qg[..., d:]
117
+ gate = gate.reshape(1, L, h * d)
118
+ q = self.q_norm(q).transpose(1, 2) # [1, h, L, d]
119
+ k = self.k_norm(self.k_proj(x).view(1, L, kv, d)).transpose(1, 2)
120
+ v = self.v_proj(x).view(1, L, kv, d).transpose(1, 2)
121
+
122
+ c, s = cos[None, None], sin[None, None] # [1, 1, L, r]
123
+ q = torch.cat([q[..., :r] * c + rotate_half(q[..., :r]) * s, q[..., r:]], dim=-1)
124
+ k = torch.cat([k[..., :r] * c + rotate_half(k[..., :r]) * s, k[..., r:]], dim=-1)
125
+
126
+ rep = h // kv
127
+ k = k[:, :, None].expand(1, kv, rep, L, d).reshape(1, h, L, d)
128
+ v = v[:, :, None].expand(1, kv, rep, L, d).reshape(1, h, L, d)
129
+ scores = torch.matmul(q, k.transpose(-1, -2)) * (d**-0.5) + mask
130
+ out = torch.matmul(torch.softmax(scores, dim=-1), v) # [1, h, L, d]
131
+ out = out.transpose(1, 2).reshape(1, L, h * d)
132
+ return self.o_proj(out * torch.sigmoid(gate))
133
+
134
+
135
+ class GatedRMSNorm(nn.Module):
136
+ def __init__(self, dim: int, eps: float):
137
+ super().__init__()
138
+ self.eps = eps
139
+ self.weight = nn.Parameter(torch.ones(dim))
140
+
141
+ def forward(self, x, gate):
142
+ x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
143
+ return self.weight * x * F.silu(gate)
144
+
145
+
146
+ class GatedDeltaNet(nn.Module):
147
+ def __init__(self, cfg: TextConfig, seq_len: int, chunk_size: int):
148
+ super().__init__()
149
+ self.cfg = cfg
150
+ self.chunk = chunk_size
151
+ assert seq_len % chunk_size == 0, "bucket length must be a multiple of the delta-rule chunk size"
152
+ self.num_chunks = seq_len // chunk_size
153
+ kd = cfg.lin_k_heads * cfg.lin_k_dim
154
+ vd = cfg.lin_v_heads * cfg.lin_v_dim
155
+ self.key_dim, self.value_dim = kd, vd
156
+ self.conv_dim = 2 * kd + vd
157
+ self.in_proj_qkv = nn.Linear(cfg.hidden_size, self.conv_dim, bias=False)
158
+ self.in_proj_z = nn.Linear(cfg.hidden_size, vd, bias=False)
159
+ self.in_proj_b = nn.Linear(cfg.hidden_size, cfg.lin_v_heads, bias=False)
160
+ self.in_proj_a = nn.Linear(cfg.hidden_size, cfg.lin_v_heads, bias=False)
161
+ self.conv1d = nn.Conv1d(self.conv_dim, self.conv_dim, cfg.conv_kernel, groups=self.conv_dim, bias=False)
162
+ self.dt_bias = nn.Parameter(torch.ones(cfg.lin_v_heads))
163
+ self.A_log = nn.Parameter(torch.zeros(cfg.lin_v_heads))
164
+ self.norm = GatedRMSNorm(cfg.lin_v_dim, cfg.eps)
165
+ self.out_proj = nn.Linear(vd, cfg.hidden_size, bias=False)
166
+
167
+ C = chunk_size
168
+ idx = torch.arange(C)
169
+ # incl[i, j, k] = 1 if j < k <= i : sum_k g_k = cum_i - cum_j (lower triangle incl. diagonal is 0 on i == j)
170
+ incl = ((idx[None, None, :] > idx[None, :, None]) & (idx[None, None, :] <= idx[:, None, None])).float()
171
+ self.register_buffer("pair_sum_t", incl.reshape(C * C, C), persistent=False) # [C*C, C]
172
+ self.register_buffer("prefix_sum_t", (idx[None, :] <= idx[:, None]).float(), persistent=False) # [C, C]
173
+ self.register_buffer("suffix_sum_t", (idx[None, :] > idx[:, None]).float(), persistent=False)
174
+ self.register_buffer("lower_incl", (idx[None, :] <= idx[:, None]).float(), persistent=False)
175
+ self.register_buffer("strict_lower", (idx[None, :] < idx[:, None]).float(), persistent=False)
176
+ self.register_buffer("eye", torch.eye(C), persistent=False)
177
+ assert C & (C - 1) == 0, "delta-rule chunk size must be a power of two"
178
+ for n in (C // (2 * s) for s in (2**i for i in range(int(math.log2(C))))):
179
+ self.register_buffer(f"block_eye_{n}", torch.eye(n), persistent=False)
180
+
181
+ def unit_lower_inverse(self, t: torch.Tensor) -> torch.Tensor:
182
+ """Inverse of unit-lower-triangular [..., C, C] by recursive 2x2 blocking:
183
+ [[A, 0], [X, B]]^-1 = [[A^-1, 0], [-B^-1 X A^-1, B^-1]].
184
+
185
+ Stable like forward substitution (it only forms the true inverse's sub-blocks),
186
+ but log2(C) batched levels instead of C sequential row updates."""
187
+ C = self.chunk
188
+ lead = t.shape[:-2]
189
+ t = t.reshape(-1, C, C) # Core ML tensors are rank <= 5
190
+ b = t.shape[0]
191
+ inv = torch.ones(b, C, 1, 1, dtype=t.dtype, device=t.device) # 1x1 diagonal blocks of a unit triangle
192
+ s = 1
193
+ while s < C:
194
+ n2 = C // (2 * s)
195
+ # diagonal 2s x 2s blocks of t, then their lower-left s x s part
196
+ blocks = (t.reshape(b, n2, 2 * s, n2, 2 * s) * getattr(self, f"block_eye_{n2}")[:, None, :, None]).sum(-2)
197
+ x = blocks[..., s:, :s] # [b, n2, s, s]
198
+ pair = inv.reshape(b, n2, 2, s, s)
199
+ a_inv, b_inv = pair[..., 0, :, :], pair[..., 1, :, :]
200
+ lower = -torch.matmul(b_inv, torch.matmul(x, a_inv))
201
+ top = torch.cat([a_inv, torch.zeros_like(a_inv)], dim=-1)
202
+ bottom = torch.cat([lower, b_inv], dim=-1)
203
+ inv = torch.cat([top, bottom], dim=-2) # [b, n2, 2s, 2s]
204
+ s *= 2
205
+ return inv.reshape(*lead, C, C)
206
+
207
+ def forward(self, x):
208
+ cfg = self.cfg
209
+ L = x.shape[1]
210
+ C, N = self.chunk, self.num_chunks
211
+ H, Dk, Dv = cfg.lin_v_heads, cfg.lin_k_dim, cfg.lin_v_dim
212
+ rep = cfg.lin_v_heads // cfg.lin_k_heads
213
+
214
+ qkv = self.in_proj_qkv(x).transpose(1, 2) # [1, conv_dim, L]
215
+ qkv = F.silu(self.conv1d(F.pad(qkv, (cfg.conv_kernel - 1, 0)))).transpose(1, 2)
216
+ q = qkv[..., : self.key_dim].reshape(1, L, cfg.lin_k_heads, Dk)
217
+ k = qkv[..., self.key_dim : 2 * self.key_dim].reshape(1, L, cfg.lin_k_heads, Dk)
218
+ v = qkv[..., 2 * self.key_dim :].reshape(1, L, H, Dv)
219
+ z = self.in_proj_z(x).reshape(1, L, H, Dv)
220
+ beta = torch.sigmoid(self.in_proj_b(x)) # [1, L, H]
221
+ g = -torch.exp(self.A_log) * F.softplus(self.in_proj_a(x) + self.dt_bias) # [1, L, H], <= 0
222
+
223
+ # l2norm (FLA convention) then repeat k heads up to v heads
224
+ q = q * torch.rsqrt((q * q).sum(-1, keepdim=True) + 1e-6)
225
+ k = k * torch.rsqrt((k * k).sum(-1, keepdim=True) + 1e-6)
226
+ q = q[:, :, :, None].expand(1, L, cfg.lin_k_heads, rep, Dk).reshape(1, L, H, Dk)
227
+ k = k[:, :, :, None].expand(1, L, cfg.lin_k_heads, rep, Dk).reshape(1, L, H, Dk)
228
+ q = q * (Dk**-0.5)
229
+
230
+ # -> [H, N, C, D]
231
+ q = q[0].permute(1, 0, 2).reshape(H, N, C, Dk)
232
+ k = k[0].permute(1, 0, 2).reshape(H, N, C, Dk)
233
+ v = v[0].permute(1, 0, 2).reshape(H, N, C, Dv)
234
+ beta = beta[0].T.reshape(H, N, C, 1)
235
+ g = g[0].T.reshape(H, N, C)
236
+
237
+ # constant-first matmuls: `x @ const` would lower to Core ML `linear` and be picked up by weight compression
238
+ g_col = g.unsqueeze(-1) # [H, N, C, 1]
239
+ cum = torch.matmul(self.prefix_sum_t, g_col).squeeze(-1) # cum_i = sum_{k<=i} g_k
240
+ to_end = torch.matmul(self.suffix_sum_t, g_col).squeeze(-1) # sum_{k>i} g_k = cum_last - cum_i
241
+ pair = torch.matmul(self.pair_sum_t, g_col).reshape(H, N, C, C) # cum_i - cum_j (0 above diagonal)
242
+ pair_decay = torch.exp(pair) * self.lower_incl
243
+
244
+ k_beta = k * beta
245
+ v_beta = v * beta
246
+ kkt = torch.matmul(k_beta, k.transpose(-1, -2)) * pair_decay
247
+ attn_intra = torch.matmul(q, k.transpose(-1, -2)) * pair_decay
248
+
249
+ inv = self.unit_lower_inverse(self.eye + kkt * self.strict_lower)
250
+ new_v = torch.matmul(inv, v_beta) # [H, N, C, Dv]
251
+ k_cumdecay = torch.matmul(inv, k_beta * torch.exp(cum)[..., None]) # [H, N, C, Dk]
252
+
253
+ q_dec = q * torch.exp(cum)[..., None]
254
+ k_dec = k * torch.exp(to_end)[..., None]
255
+ chunk_decay = torch.exp(cum[..., -1:])[..., None] # [H, N, 1, 1]
256
+
257
+ state = torch.zeros(H, Dk, Dv, dtype=x.dtype, device=x.device)
258
+ outs = []
259
+ for i in range(N):
260
+ v_new = new_v[:, i] - torch.matmul(k_cumdecay[:, i], state)
261
+ outs.append(torch.matmul(q_dec[:, i], state) + torch.matmul(attn_intra[:, i], v_new))
262
+ if i + 1 < N:
263
+ state = state * chunk_decay[:, i] + torch.matmul(k_dec[:, i].transpose(-1, -2), v_new)
264
+ core = torch.stack(outs, dim=1).reshape(H, L, Dv).permute(1, 0, 2) # [L, H, Dv]
265
+
266
+ core = self.norm(core, z[0])
267
+ return self.out_proj(core.reshape(1, L, H * Dv))
268
+
269
+
270
+ class DecoderLayer(nn.Module):
271
+ def __init__(self, cfg: TextConfig, layer_idx: int, seq_len: int, chunk_size: int):
272
+ super().__init__()
273
+ self.is_linear = cfg.layer_types[layer_idx] == "linear_attention"
274
+ if self.is_linear:
275
+ self.linear_attn = GatedDeltaNet(cfg, seq_len, chunk_size)
276
+ else:
277
+ self.self_attn = FullAttention(cfg)
278
+ self.mlp = MLP(cfg)
279
+ self.input_layernorm = RMSNorm(cfg.hidden_size, cfg.eps)
280
+ self.post_attention_layernorm = RMSNorm(cfg.hidden_size, cfg.eps)
281
+
282
+ def forward(self, x, cos, sin, mask):
283
+ h = self.input_layernorm(x)
284
+ h = self.linear_attn(h) if self.is_linear else self.self_attn(h, cos, sin, mask)
285
+ x = x + h
286
+ return x + self.mlp(self.post_attention_layernorm(x))
287
+
288
+
289
+ class DecoderChunk(nn.Module):
290
+ """Layers [start, end). The last chunk (with_head) returns letter logits [1, n_letters]."""
291
+
292
+ def __init__(
293
+ self,
294
+ cfg: TextConfig,
295
+ start: int,
296
+ end: int,
297
+ seq_len: int,
298
+ chunk_size: int = 64,
299
+ with_head: bool = False,
300
+ n_letters: int = 26,
301
+ ):
302
+ super().__init__()
303
+ self.start, self.end, self.with_head = start, end, with_head
304
+ self.layers = nn.ModuleList([DecoderLayer(cfg, i, seq_len, chunk_size) for i in range(start, end)])
305
+ mask = torch.full((seq_len, seq_len), -1e4).triu(1)
306
+ self.register_buffer("mask", mask[None, None], persistent=False)
307
+ if with_head:
308
+ self.norm = RMSNorm(cfg.hidden_size, cfg.eps)
309
+ self.letter_head = nn.Linear(cfg.hidden_size, n_letters, bias=False)
310
+
311
+ def forward(self, hidden, cos, sin, last_onehot=None):
312
+ for layer in self.layers:
313
+ hidden = layer(hidden, cos, sin, self.mask)
314
+ if not self.with_head:
315
+ return hidden
316
+ last = torch.matmul(last_onehot, hidden[0]) # [1, hidden]
317
+ return self.letter_head(self.norm(last))
318
+
319
+ def load_merged(self, state: dict[str, torch.Tensor], letter_rows: torch.Tensor | None = None):
320
+ """`state` uses Qwen3_5TextModel names relative to the language model (`layers.N...`, `norm.weight`)."""
321
+ own = {}
322
+ for local, global_idx in enumerate(range(self.start, self.end)):
323
+ prefix = f"layers.{global_idx}."
324
+ for key, value in state.items():
325
+ if key.startswith(prefix):
326
+ own[f"layers.{local}.{key[len(prefix):]}"] = value
327
+ if self.with_head:
328
+ own["norm.weight"] = state["norm.weight"]
329
+ own["letter_head.weight"] = letter_rows
330
+ missing, unexpected = self.load_state_dict({k: v.float() for k, v in own.items()}, strict=False)
331
+ missing = [m for m in missing if not m.endswith(("mask",))]
332
+ if missing or unexpected:
333
+ raise RuntimeError(f"weight mismatch: missing={missing[:8]} unexpected={unexpected[:8]}")
conversion/time_eos.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, time
2
+ import numpy as np, coremltools as ct
3
+ for path in sys.argv[1:]:
4
+ m = ct.models.MLModel(path, compute_units=ct.ComputeUnit.CPU_AND_GPU)
5
+ spec = {i.name: tuple(i.type.multiArrayType.shape) for i in m.get_spec().description.input}
6
+ x = {}
7
+ for k, shp in spec.items():
8
+ dt = np.int32 if k in ("input_ids", "cand_idx", "query_idx") else np.float16
9
+ x[k] = np.zeros(shp, dtype=dt)
10
+ x["valid"][:] = 1; x["segment"][:] = np.eye(spec["segment"][0])
11
+ if "seg_chunks" in x: x["seg_chunks"][:] = np.eye(64)
12
+ m.predict(x); ts = []
13
+ for _ in range(10):
14
+ t = time.time(); m.predict(x); ts.append((time.time() - t) * 1000)
15
+ print(f"{path.split('/')[-1]:40s} p50 {np.median(ts):6.1f} ms")
conversion/typed_ref.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upstream answers for typed-decisions TEST (400 requests, 2,000 decisions), cached as JSONL."""
2
+ import json, sys, time
3
+ import pandas as pd
4
+ from transformers import AutoModel
5
+ root, out = sys.argv[1], sys.argv[2]
6
+ d = pd.read_parquet("data/typed/all/test-00000-of-00001.parquet")
7
+ m = AutoModel.from_pretrained(root, trust_remote_code=True, device="mps")
8
+ t = time.time()
9
+ with open(out, "w") as f:
10
+ for r in d.itertuples():
11
+ a = m.system_one(state=json.loads(r.state), questions=json.loads(r.questions))
12
+ f.write(json.dumps({"id": r.id, "answers": a["answers"], "tokens": a["usage"]["input_tokens"]}) + "\n")
13
+ print(f"{len(d)} requests in {time.time()-t:.0f}s")
coreml_config.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "Decision-2.0-Eos-0.8B",
3
+ "source": "vllm-sr/Decision-2.0-Eos-0.8B",
4
+ "source_revision": "3594047d69f476f1d01cf84c593e213fc3a4dfe0",
5
+ "backbone": "qwen3_5",
6
+ "package": "Decision2EosPacked.mlpackage",
7
+ "functions": {"S128_C256_N32": 34.3, "S256_C512_N64": 64.1, "S512_C768_N96": 108.6, "S512_C1024_N128": 131.0, "S1024_C1024_N128": 176.0},
8
+ "functions_note": "function name -> measured predict p50 ms on M5 Pro GPU, used to choose chunking. S = shared-prefix tokens, C = packed question tokens (multiple of 64), N = option slots",
9
+ "pad_token_id": 248044,
10
+ "rope": {"rotary_dim": 64, "rope_theta": 10000000},
11
+ "inputs": {
12
+ "input_ids": "[1, S+C] int32: shared prefix right-padded to S, then every question's suffix, then padding",
13
+ "cos, sin": "[S+C, 64] float16: RoPE tables (prefix positions 0.., each suffix continues from the prefix length)",
14
+ "valid": "[S] float16: 1 for real prefix tokens",
15
+ "tail_onehot": "[3, S] float16: selects the prefix's last 3 pre-convolution inputs (conv history)",
16
+ "segment": "[C, C] float16: 1 where j <= i within the same question (padding = own segment)",
17
+ "lag_keep, lag_tail": "[3, C], [3, C, 3] float16: a question's first 3 conv lags read the prefix tail",
18
+ "seg_chunks": "[C/64, 64, 64] float16: segment restricted to each 64-token chunk",
19
+ "cont": "[C] float16: 1 if the token's question started in an earlier chunk",
20
+ "last_seg": "[C/64, 64] float16: tokens in the question of each chunk's last token",
21
+ "cand_idx, query_idx": "[N] int32: packed-region index of each option's last token and of its question's final token"
22
+ },
23
+ "outputs": {"logits": "[N] float32: one raw logit per option (before softmax)"}
24
+ }
decision2_coreml.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).
8
+
9
+ from decision2_coreml import Decision2CoreML
10
+ model = Decision2CoreML(".") # this repo's folder
11
+ model.system_one(state="...", questions={...})
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import math
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import coremltools as ct
22
+ import numpy as np
23
+ from tokenizers import Tokenizer
24
+
25
+ MAX_OPTIONS = 255
26
+ NEG = -1e4
27
+
28
+
29
+ def canonical(value: Any) -> str:
30
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
31
+
32
+
33
+ def _payload(value: Any) -> str:
34
+ return value if isinstance(value, str) else canonical(value)
35
+
36
+
37
+ def question_to_row(state: Any, question: dict[str, Any]) -> dict[str, Any]:
38
+ kind = question.get("type")
39
+ if kind not in ("choice", "noul", "score"):
40
+ raise ValueError("unsupported question type")
41
+ if not question.get("instructions"):
42
+ raise ValueError("missing question instructions")
43
+ criteria = question.get("criteria")
44
+ if kind == "score":
45
+ if not isinstance(criteria, list) or not 2 <= len(criteria) <= 10:
46
+ raise ValueError("score criteria must be an ordered list of 2..10 levels")
47
+ options = [{"key": str(i), "description": d} for i, d in enumerate(criteria)]
48
+ else:
49
+ if kind == "noul":
50
+ criteria = criteria or {}
51
+ if not isinstance(criteria, dict) or set(criteria) - {"false", "true"}:
52
+ raise ValueError("noul requires only false and true criteria")
53
+ if len(criteria) < 2:
54
+ criteria = {"false": criteria.get("false", "No"), "true": criteria.get("true", "Yes")}
55
+ if not isinstance(criteria, dict) or not 2 <= len(criteria) <= MAX_OPTIONS:
56
+ raise ValueError("choice criteria must be an object with 2..255 options")
57
+ options = [{"key": k, "description": d} for k, d in criteria.items()]
58
+ return {"state": state, "instructions": question["instructions"], "options": options, "task_type": kind}
59
+
60
+
61
+ def encode(row: dict[str, Any], tokenizer: Tokenizer) -> dict[str, Any]:
62
+ prefix = (
63
+ f"Context:\n{_payload(row['state'])}\n\n"
64
+ f"Task type: {row['task_type']}\nQuestion:\n{_payload(row['instructions'])}\nOptions:"
65
+ )
66
+ ids = tokenizer.encode(prefix, add_special_tokens=False).ids
67
+ endpoints = []
68
+ for option in row["options"]:
69
+ text = "\n<option>\n" + canonical({"key": option["key"], "description": option["description"]}) + "\n</option>"
70
+ ids.extend(tokenizer.encode(text, add_special_tokens=False).ids)
71
+ endpoints.append(len(ids) - 1)
72
+ suffix = "\n\nSelect the single option best supported by the context and instructions.\nDecision:"
73
+ ids.extend(tokenizer.encode(suffix, add_special_tokens=False).ids)
74
+ return {"ids": ids, "candidate_positions": endpoints, "query_position": len(ids) - 1,
75
+ "keys": [o["key"] for o in row["options"]]}
76
+
77
+
78
+ def _prefix(encoded: list[dict[str, Any]]) -> int:
79
+ seqs = [e["ids"] for e in encoded]
80
+ limit = min(min(e["candidate_positions"][0] for e in encoded), min(len(s) for s in seqs) - 1)
81
+ for i in range(limit):
82
+ if any(s[i] != seqs[0][i] for s in seqs):
83
+ return i
84
+ return limit
85
+
86
+
87
+ def packed_size(encoded: list[dict[str, Any]]) -> tuple[int, int]:
88
+ P = _prefix(encoded)
89
+ return P + sum(len(e["ids"]) - P for e in encoded), sum(len(e["keys"]) for e in encoded)
90
+
91
+
92
+ def pack(encoded: list[dict[str, Any]], L: int, N: int, pad_id: int) -> tuple[dict[str, np.ndarray], list[int]]:
93
+ """Shared prefix once, then each question's suffix; a suffix sees the prefix and itself (causal)."""
94
+ P = _prefix(encoded)
95
+ ids, pos, seg = list(encoded[0]["ids"][:P]), list(range(P)), [-1] * P
96
+ cand, qry, owner = [], [], []
97
+ for j, e in enumerate(encoded):
98
+ shift = len(ids) - P
99
+ ids += e["ids"][P:]
100
+ pos += range(P, len(e["ids"]))
101
+ seg += [j] * (len(e["ids"]) - P)
102
+ for c in e["candidate_positions"]:
103
+ cand.append(c if c < P else c + shift)
104
+ qry.append(e["query_position"] + shift)
105
+ owner.append(j)
106
+ T = len(ids)
107
+ if T > L or len(cand) > N:
108
+ raise ValueError(f"{T} tokens / {len(cand)} options exceed L{L}_N{N}")
109
+ s = np.array(seg + [-2] * (L - T))
110
+ i = np.arange(L)
111
+ allow = (i[None, :] <= i[:, None]) & ((s[None, :] == s[:, None]) | (s[None, :] == -1)) & (s[None, :] != -2)
112
+ allow[i, i] = True
113
+ return {
114
+ "input_ids": np.array([ids + [pad_id] * (L - T)], dtype=np.int32),
115
+ "position_ids": np.array([pos + [0] * (L - T)], dtype=np.int32),
116
+ "mask": np.where(allow, 0.0, NEG).astype(np.float16)[None, None],
117
+ "cand_idx": np.array(cand + [0] * (N - len(cand)), dtype=np.int32),
118
+ "query_idx": np.array(qry + [0] * (N - len(cand)), dtype=np.int32),
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]
184
+ total = sum(exps)
185
+ probs = [v / total for v in exps]
186
+ pmap = dict(zip(keys, probs))
187
+ if kind == "noul":
188
+ return {"type": "noul", "noul": pmap["true"]}
189
+ entropy = -sum(p * math.log(p) for p in probs if p > 0)
190
+ confidence = max(0.0, min(1.0, 1.0 - entropy / math.log(len(keys))))
191
+ if kind == "score":
192
+ return {"type": "score", "score": sum(int(k) * pmap[k] for k in keys), "probabilities": pmap,
193
+ "confidence": confidence,
194
+ "legend": {k: d if isinstance(d, str) else canonical(d) for k, d in zip(keys, descriptions)}}
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):
205
+ root = Path(root)
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():
262
+ try:
263
+ rows[qid] = question_to_row(state, q)
264
+ encoded[qid] = encode(rows[qid], self.tokenizer)
265
+ except ValueError:
266
+ answers[qid] = {"type": q.get("type") if isinstance(q, dict) else None, "error": "invalid_question"}
267
+ qids = list(encoded)
268
+ if qids:
269
+ for qid, values in zip(qids, self.logits([encoded[q] for q in qids])):
270
+ row = rows[qid]
271
+ offsets = self.score_bias.get(len(values)) if row["task_type"] == "score" else None
272
+ if offsets:
273
+ values = [v + b for v, b in zip(values, offsets)]
274
+ answers[qid] = answer(row["task_type"], encoded[qid]["keys"], values,
275
+ [o["description"] for o in row["options"]])
276
+ return {"model": self.config["model_name"], "answers": {q: answers[q] for q in questions},
277
+ "usage": {"input_tokens": sum(len(e["ids"]) for e in encoded.values()), "output_tokens": 0}}
decision_config.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architecture": "qwen3.5-text-endpoints-global-query-shared-bilinear-mlp",
3
+ "prompt_version": "decision2-segmented-options-global-query-v1",
4
+ "base_revision": "2fc06364715b967f1860aea9cf38778875588b17",
5
+ "source_prompt_version": "structured-segmented-candidate-endpoints-global-query-v2",
6
+ "head_dim": 256,
7
+ "max_options": 255,
8
+ "text_parameter_count": 752393024,
9
+ "parameter_dtype": "float32",
10
+ "autocast_dtype": "bfloat16",
11
+ "head_compute_dtype": "float32",
12
+ "attention": "sdpa",
13
+ "checkpoint_format": "full",
14
+ "full_training_source": {
15
+ "kind": "decision1",
16
+ "revision": null,
17
+ "source_fingerprint": {
18
+ "source_name": "363c4a5e56afc115b1c78c837633956d0bbb63ab",
19
+ "files_sha256": {
20
+ "backbone/config.json": "5cece452e606f8ffca1790b79018fed5b0fde7d1a0791d375ca09492e0d1809b",
21
+ "backbone/model.safetensors": "613f491da2794c5d22f68e81bd2d41000c5675bc4538180148e6e453e8198abd",
22
+ "config.json": "37c16ffad2439cc06c7a40de037e59341cb57ae4e948cbaf9b1950c5d38d0d37",
23
+ "decision_config.json": "71fde3c2ea6f4bf4e781028d7f8f39da73599f9d868979efbfbd8b7f43dd68c3",
24
+ "decision_head.safetensors": "75f4ee8b512aea8f74fdcb42849b4cd37cfb37ff17c5d0b5fda1bb7b4f6d72f6",
25
+ "tokenizer.json": "06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523",
26
+ "tokenizer_config.json": "bee8eba30f0eb4af73c0fe2cd06d0f89b657d7819941c438157ec42f7c80ea87"
27
+ }
28
+ }
29
+ },
30
+ "training_mode": "full",
31
+ "loss_version": "decision2-valid-k-ce-plus-optional-brier-and-replay-kl-v1",
32
+ "initialization": "linear-interpolation-of-full-checkpoints",
33
+ "interpolation": {
34
+ "alpha": 0.75,
35
+ "formula": "W = (1 - alpha) * release + alpha * arm, per tensor in FP32 (backbone and decision head)",
36
+ "release": {
37
+ "path": "/runs/m14/inputs/refs/DEV2.0-0.8B/bede7938a8c209c09f27400b79eed57948d6b75e",
38
+ "model_sha256": "3f02f0e5fc68a1fd985ce48bf3a3bb2d4ed16d1a1705c2258317b78a4d6514f4"
39
+ },
40
+ "arm": {
41
+ "path": "/runs/m16/inputs/arms/08b-RA",
42
+ "model_sha256": "f25beedd6b9b80fd8ce92857bfd6a14d77d73624e4fd410cc535357c0a1b41fb",
43
+ "soup": {
44
+ "method": "uniform weight average (FP32)",
45
+ "members": [
46
+ {
47
+ "path": "/runs/m12/arms/full/m12-08b-RA-s1/checkpoint-0004115",
48
+ "model_sha256": "576022450d7e284c33f46f680d1ddaeb419b093353b21f0753efe6f73b120f47"
49
+ },
50
+ {
51
+ "path": "/runs/m12/arms/full/m12-08b-RA-s2/checkpoint-0004109",
52
+ "model_sha256": "6f254ab5458ef0038ee0528f84d7aaea57335004912d9d23f8a432673311898f"
53
+ }
54
+ ]
55
+ }
56
+ }
57
+ }
58
+ }
example.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from decision2_coreml import Decision2CoreML
3
+
4
+ model = Decision2CoreML(".")
5
+ result = model.system_one(
6
+ state="The order arrived damaged yesterday. The customer has a receipt and asks for a replacement today.",
7
+ questions={
8
+ "route": {"type": "choice", "instructions": "Which team should handle this request?",
9
+ "criteria": {"returns": "Refunds, replacements and damaged deliveries",
10
+ "billing": "Payments, invoices and charges",
11
+ "technical": "Product setup and faults"}},
12
+ "receipt": {"type": "noul", "instructions": "Does the customer have a receipt?"},
13
+ "urgency": {"type": "score", "instructions": "How urgent is this request?",
14
+ "criteria": ["Routine", "Soon", "Today"]},
15
+ },
16
+ )
17
+ print(json.dumps(result["answers"], indent=2))
reports/typed-decisions-test.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flip margins (upstream top-2) [0.0001, 0.0005, 0.0007, 0.0026]
2
+ choice n=600 acc upstream 0.430 coreml 0.433
3
+ noul n=600 acc upstream 0.598 coreml 0.598
4
+ score n=800 acc upstream 0.379 coreml 0.376
5
+ typed: token mismatches 0, 2000 decisions, flips 4, max|dp| 0.0077, request p50 110.1 ms
6
+ 64 questions: 13 calls, p50 874 ms end to end
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06b9509352d2af50381ab2247e083b80d32d5c0aba91c272ca9ff729b6a0e523
3
+ size 19989325
tokenizer_config.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "audio_bos_token": "<|audio_start|>",
4
+ "audio_eos_token": "<|audio_end|>",
5
+ "audio_token": "<|audio_pad|>",
6
+ "backend": "tokenizers",
7
+ "bos_token": null,
8
+ "clean_up_tokenization_spaces": false,
9
+ "eos_token": "<|im_end|>",
10
+ "errors": "replace",
11
+ "image_token": "<|image_pad|>",
12
+ "is_local": true,
13
+ "local_files_only": true,
14
+ "model_max_length": 262144,
15
+ "model_specific_special_tokens": {
16
+ "audio_bos_token": "<|audio_start|>",
17
+ "audio_eos_token": "<|audio_end|>",
18
+ "audio_token": "<|audio_pad|>",
19
+ "image_token": "<|image_pad|>",
20
+ "video_token": "<|video_pad|>",
21
+ "vision_bos_token": "<|vision_start|>",
22
+ "vision_eos_token": "<|vision_end|>"
23
+ },
24
+ "pad_token": "<|endoftext|>",
25
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
26
+ "split_special_tokens": false,
27
+ "tokenizer_class": "Qwen2Tokenizer",
28
+ "unk_token": null,
29
+ "video_token": "<|video_pad|>",
30
+ "vision_bos_token": "<|vision_start|>",
31
+ "vision_eos_token": "<|vision_end|>"
32
+ }