mlx-bun 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
@@ -0,0 +1,617 @@
1
+ // Serve-time speculative decoding — the ONE verify/accept executor behind
2
+ // `serve --draft-model` (docs/design/mlx-lm-tool-parity-plan.md §7; sequenced
3
+ // by docs/design/grammar-spec-batching-integration.md Phase B).
4
+ //
5
+ // Faithful to mlx-lm's speculative_generate_step (generate.py:473-654, read
6
+ // from the oracle venv): per round, draft n tokens from the DraftSource, run
7
+ // ONE target forward over [pending, ...drafts] (n+1 positions), sample the
8
+ // target per position over the BATCHED lm-head logits (one matmul — matches
9
+ // both oracles; see src/spec/generate.ts header on why this legitimately
10
+ // diverges from stock decode at bf16 knife-edges), accept the longest prefix
11
+ // where target token == draft token (exact token-match acceptance, NOT
12
+ // distribution-level rejection sampling), emit the correction (or bonus)
13
+ // token, and trim the target caches by the rejected count.
14
+ //
15
+ // Deviations from upstream, deliberate and documented:
16
+ // - Rotating-cache ring wrap: upstream RAISES when a cache stops being
17
+ // trimmable mid-generation; a serve endpoint must not 500 mid-stream, so
18
+ // we STOP SPECULATING and finish the generation with plain single-token
19
+ // decode (bit-equivalent continuation — the target's own samples).
20
+ // - Prompt-cache reuse is BYPASSED in v1 (fresh caches per spec request,
21
+ // cachedTokens=0). mlx-lm composes spec with its LRU prompt cache
22
+ // (target+draft caches per entry); wiring that through our PromptCache +
23
+ // SSD tier is a tracked follow-up in the integration plan, not silently
24
+ // absent.
25
+ //
26
+ // Grammar × spec (Phase C, the constrained verify walk — novel: NO runtime
27
+ // serves both; mlx-lm has no grammar, oMLX no spec): the drafter runs FREE
28
+ // (drafts are proposals; a grammar-invalid draft is simply rejected at
29
+ // verify), and the grammar mask rides the per-position accept walk in
30
+ // samplePos — mask before sample, matcher advances on emitted tokens only,
31
+ // so rejected drafts never touch grammar state and no matcher rollback is
32
+ // needed. Grammar termination mid-burst truncates the round (the all--inf
33
+ // guarantee). Gates (no oracle): greedy grammar+spec ≡ greedy grammar-only
34
+ // long-prefix + 100% schema validity (tests/spec-serve.test.ts).
35
+ //
36
+ // Emission discipline: tokens flow through the caller's onToken ONE AT A
37
+ // TIME, in order (bursts of ≤ n+1 per round) — the server's stop-sequence
38
+ // matcher and detokenizer see exactly the stream they'd see from generate().
39
+ // EOS is never emitted as content (generate() parity). onToken returning
40
+ // false halts the generation mid-burst.
41
+
42
+ import { MlxArray } from "../mlx/array";
43
+ import * as ops from "../mlx/ops";
44
+ import { clearCache } from "../mlx/ffi";
45
+ import { flagOn } from "../flags";
46
+ import type { RuntimeModel } from "../model/factory";
47
+ import type { Cache } from "../model/gemma4";
48
+ import type { GenerateOptions, GenerateStats } from "../generate";
49
+ import { makeSampler, makeLogitsProcessors, toLogprobs } from "../sampler";
50
+ import type { OnToken } from "../serve/generation-gateway";
51
+ import type { DraftProvider } from "./source";
52
+
53
+ const PREFILL_CHUNK = 2048;
54
+
55
+ /** Run one target forward, and when `tapLayers` is set (⟹ a Gemma4 target — a
56
+ * DSpark-style source), also return the captured multi-layer context
57
+ * [1,L,m*H] (tapLayers concatenated on the feature axis; index nLayers is the
58
+ * post-finalNorm sentinel). Non-tapping sources get ctxML=null and never
59
+ * touch model.hiddenTap. Mirrors generate-dflash.ts forwardTapped. */
60
+ async function forwardMaybeTap(
61
+ model: RuntimeModel,
62
+ ids: MlxArray,
63
+ caches: Cache[],
64
+ tapLayers: number[] | undefined,
65
+ ): Promise<{ hidden: MlxArray; ctxML: MlxArray | null }> {
66
+ if (!tapLayers) {
67
+ const asyncModel = model as RuntimeModel & {
68
+ forwardHiddenAsync?: (
69
+ ids: MlxArray,
70
+ caches: Cache[],
71
+ ) => Promise<MlxArray>;
72
+ };
73
+ const hidden = typeof asyncModel.forwardHiddenAsync === "function"
74
+ ? await asyncModel.forwardHiddenAsync(ids, caches)
75
+ : model.forwardHidden(ids, caches);
76
+ return { hidden, ctxML: null };
77
+ }
78
+ const m = model as unknown as {
79
+ hiddenTap: { layers: Set<number>; captured: Map<number, MlxArray> } | null;
80
+ };
81
+ const cap = new Map<number, MlxArray>();
82
+ m.hiddenTap = { layers: new Set(tapLayers), captured: cap };
83
+ let hidden: MlxArray | null = null;
84
+ try {
85
+ hidden = model.forwardHidden(ids, caches);
86
+ const perLayer = tapLayers.map((li) => {
87
+ const a = cap.get(li);
88
+ if (!a) throw new Error(`spec tap: layer ${li} not captured`);
89
+ return a;
90
+ });
91
+ const ctxML = ops.concatAxis(perLayer, 2); // [1,L,m*H]
92
+ for (const [, a] of cap) a.dispose();
93
+ cap.clear(); // consumed — the finally must not double-dispose
94
+ const out = { hidden, ctxML };
95
+ hidden = null; // ownership returned to the caller
96
+ return out;
97
+ } finally {
98
+ // On any throw (forward mid-capture, missing layer, concat), free the
99
+ // partially-captured tap tensors and the orphaned hidden. On success both
100
+ // are already gone (cap cleared, hidden nulled).
101
+ hidden?.dispose();
102
+ for (const [, a] of cap) a.dispose();
103
+ m.hiddenTap = null;
104
+ }
105
+ }
106
+
107
+ export interface SpecServeExtras {
108
+ drafted: number;
109
+ accepted: number;
110
+ rejected: number;
111
+ targetCalls: number;
112
+ rounds: number;
113
+ acceptanceLengths: number[];
114
+ tokensPerForward: number;
115
+ forwardsSaved: number;
116
+ /** Per-draft-position counters (index = position within a round's block,
117
+ * 0..γ-1): how many rounds drafted/accepted at that position. Drives the
118
+ * Phase-1c per-position acceptance report (scripts/dspark-drafter-ab.ts)
119
+ * — near-zero cost, always populated. */
120
+ draftedByPos: number[];
121
+ acceptedByPos: number[];
122
+ }
123
+
124
+ export async function specServeRun(
125
+ model: RuntimeModel,
126
+ provider: DraftProvider,
127
+ numDraftTokens: number,
128
+ promptIds: number[],
129
+ options: GenerateOptions & { stopSequences?: string[] },
130
+ onToken: OnToken,
131
+ ): Promise<GenerateStats> {
132
+ const maxTokens = options.maxTokens ?? 512;
133
+ const eos = options.eosTokenIds ?? model.config.eosTokenIds;
134
+ const sampler = makeSampler(options);
135
+ const processors = makeLogitsProcessors(options);
136
+ const gamma = Math.max(1, numDraftTokens);
137
+
138
+ // Allocated INSIDE the try below (2026-07-07 review): provider.open()
139
+ // throws on a mismatched (target, drafter) pairing — as pre-try consts a
140
+ // throw leaked options.grammar (a live WASM matcher) and the fresh caches,
141
+ // and turned a config error into a per-request 500. loadContext now
142
+ // probe-opens the pairing at startup, so a throw here is belt+suspenders.
143
+ let caches: Cache[] = [];
144
+ let source: import("./source").DraftSource | null = null;
145
+ let tapLayers: number[] | undefined;
146
+ // Target final hidden [1,1,H] at the anchor position — the assistant source
147
+ // borrows it for its first draft step of each round; two-model/dflash ignore
148
+ // it. Retained across rounds, disposed in finally.
149
+ let anchorHidden: MlxArray | null = null;
150
+ // Round/prefill scratch tensors, hoisted so the finally disposes them on ANY
151
+ // throw mid-round/prefill (grammar WASM reject, awaited onToken reject, a GPU
152
+ // error). Each is nulled the instant its in-body disposal or ownership
153
+ // transfer runs, so the finally never double-frees. (A serve endpoint hits
154
+ // these throw seams per request; a try-body local would leak GPU memory.)
155
+ let lastLogits: MlxArray | null = null;
156
+ let prefillCtx: MlxArray | null = null;
157
+ const ctxParts: MlxArray[] = []; // per-chunk tapped context (DSpark only)
158
+ let vHidden: MlxArray | null = null;
159
+ let vCtxML: MlxArray | null = null;
160
+ let vLogits: MlxArray | null = null;
161
+ let roundRow: MlxArray | null = null; // the in-flight verify/continuation row
162
+ // Device-side token history for the logits processors (repetition/presence/
163
+ // frequency penalties, logit_bias) — generate()'s pushHistory discipline.
164
+ let history: MlxArray | null =
165
+ processors.length > 0 ? ops.fromInt32(promptIds, [promptIds.length]) : null;
166
+
167
+ const extras: SpecServeExtras = {
168
+ drafted: 0,
169
+ accepted: 0,
170
+ rejected: 0,
171
+ targetCalls: 0,
172
+ rounds: 0,
173
+ acceptanceLengths: [],
174
+ tokensPerForward: 0,
175
+ forwardsSaved: 0,
176
+ draftedByPos: [],
177
+ acceptedByPos: [],
178
+ };
179
+ const stats: GenerateStats = {
180
+ promptTokens: promptIds.length,
181
+ cachedTokens: 0,
182
+ generatedTokens: 0,
183
+ prefillMs: 0, decodeMs: 0, prefillTps: 0, decodeTps: 0,
184
+ cacheTokens: [],
185
+ spec: extras,
186
+ };
187
+
188
+ /** Sample ONE verify position from a [1,V] logits row: processors →
189
+ * grammar mask (Phase C: the constrained verify walk — the mask has the
190
+ * final say, same ordering as serial sampleStep and the batch closure) →
191
+ * log-softmax → sampler. The GRAMMAR walk is inherently sequential: the
192
+ * mask at position i reflects every token emitted before it, so this
193
+ * awaits the matcher's async fill (fired by the previous accept) before
194
+ * masking, samples, then advances the matcher — the matcher only ever
195
+ * moves on tokens that are (about to be) EMITTED, which is what makes
196
+ * rollback-free v1 correct: rejected DRAFTS never touch grammar state.
197
+ * Appends the sampled token to the processor history. */
198
+ const grammar = options.grammar;
199
+ const samplePos = async (logits1V: MlxArray, step: number): Promise<number> => {
200
+ // Owned intermediates drained in the finally on any throw — grammar
201
+ // fill reject, applyMask/toLogprobs/sampler error (2026-07-07 review:
202
+ // these were the one spot the header's hoist discipline missed; a
203
+ // grammar+penalties request whose matcher fill rejects orphaned one
204
+ // [1,V] row per occurrence). `cur` is owned only once it diverges from
205
+ // the caller's row.
206
+ let cur: MlxArray = logits1V;
207
+ let lp: MlxArray | null = null;
208
+ let tokArr: MlxArray | null = null;
209
+ try {
210
+ for (const p of processors) {
211
+ const next = p(history, cur);
212
+ if (cur !== logits1V) cur.dispose();
213
+ cur = next;
214
+ }
215
+ if (grammar && !grammar.isTerminated) {
216
+ await grammar.ready();
217
+ const masked = grammar.applyMask(cur);
218
+ if (cur !== logits1V) cur.dispose();
219
+ cur = masked;
220
+ }
221
+ lp = toLogprobs(cur);
222
+ if (cur !== logits1V) cur.dispose();
223
+ cur = logits1V; // consumed — nothing owned under this name now
224
+ tokArr = sampler(lp, step);
225
+ lp.dispose();
226
+ lp = null;
227
+ const tok = ops.itemUint32(tokArr);
228
+ tokArr.dispose();
229
+ tokArr = null;
230
+ // Advance the matcher on the emitted token (fires the next async fill).
231
+ // EOS is never content and never grammar-valid — don't feed it.
232
+ if (grammar && !eos.includes(tok)) grammar.accept(tok);
233
+ if (history) {
234
+ const t1 = ops.fromInt32([tok], [1]);
235
+ const prev = history;
236
+ history = ops.concatAxis([prev, t1], 0);
237
+ prev.dispose();
238
+ t1.dispose();
239
+ }
240
+ return tok;
241
+ } finally {
242
+ if (cur !== logits1V) cur.dispose();
243
+ lp?.dispose();
244
+ tokArr?.dispose();
245
+ }
246
+ };
247
+
248
+ /** The [1,V] logits row at position `pos` of a hidden window — batched
249
+ * lm-head is applied by the caller ONCE; this slices its output. */
250
+ const logitsRow = (logitsWindow: MlxArray, pos: number): MlxArray => {
251
+ const V = logitsWindow.shape[logitsWindow.shape.length - 1]!;
252
+ const sl = logitsWindow.slice([0, pos, 0], [1, pos + 1, V]);
253
+ const flat = ops.reshape(sl, [1, V]);
254
+ sl.dispose();
255
+ return flat;
256
+ };
257
+
258
+ try {
259
+ caches = model.makeCache();
260
+ const src = provider.open({ sampler, target: { model, caches } });
261
+ source = src;
262
+ tapLayers = src.tapLayers;
263
+ // ---- prefill (target, chunked; optionally tapped for DSpark), then seed
264
+ // the source. Order: the source's prefill needs the tapped context that the
265
+ // target prefill produces, so target-first (two-model's own draft prefill
266
+ // and the assistant no-op are order-independent). ----
267
+ //
268
+ // Oracle prefill convention (re-anchored 2026-07-07 after PR #18; then
269
+ // corrected to the SPEC oracle's true shape after a live gate vs
270
+ // speculative_generate_step still flipped a knife-edge): mlx-lm's
271
+ // speculative path drains BOTH models to len-1 (`_prefill: while y.size >
272
+ // 1`) and has NO separate step-0 at all — the un-drained last prompt
273
+ // token HEADS THE FIRST VERIFY WINDOW ([lastPromptToken, ...drafts]),
274
+ // and the first emitted token is that window's position-0 sample
275
+ // (generate.py:578-618). A separate L=1 step-0 (the serial-lane
276
+ // convention) is ulp-different from the (1+γ)-window GEMM head and still
277
+ // flips near-ties. So under the flag: drain to len-1, pending = the last
278
+ // prompt token, straight into the rounds. Gated live: 4/4 token-for-token
279
+ // vs the oracle venv (γ∈{2,3} × 2 prompts, incl. the knife-edge cell).
280
+ // MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts to the legacy full-chunk + sampled
281
+ // token0 shape (kill switch; also the 1-token-prompt degenerate path).
282
+ //
283
+ // Seam interaction: the tapped seed covers rows 0..len-2 only — the last
284
+ // prompt token's row arrives via the FIRST verify round's vCtxML (it is
285
+ // that round's anchor row, which commit() appends) — so context sources
286
+ // stay in exact lockstep, and DSpark's round-1 anchor IS the last prompt
287
+ // token at position len-1, matching DeepSpec's reference loop. The
288
+ // assistant source's first-round anchor hidden = "the hidden that
289
+ // produced pending" = position len-2, the last drain chunk's final row.
290
+ const t0 = performance.now();
291
+ const tailSplit =
292
+ src.prefillMode !== "full" &&
293
+ flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
294
+ const oracleShape = tailSplit && promptIds.length > 1;
295
+ if (oracleShape) {
296
+ let pos = 0;
297
+ const end = promptIds.length - 1; // last prompt token NEVER prefilled
298
+ while (pos < end) {
299
+ const n = Math.min(PREFILL_CHUNK, end - pos);
300
+ const chunk = promptIds.slice(pos, pos + n);
301
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
302
+ const { hidden: h, ctxML } = await forwardMaybeTap(
303
+ model,
304
+ ids,
305
+ caches,
306
+ tapLayers,
307
+ );
308
+ ids.dispose();
309
+ if (ctxML) ctxParts.push(ctxML);
310
+ if (pos + n >= end) {
311
+ const L = h.shape[1]!;
312
+ const H = h.shape[2]!;
313
+ anchorHidden = h.slice([0, L - 1, 0], [1, L, H]); // position len-2
314
+ }
315
+ h.dispose(); // logits never computed during the drain
316
+ clearCache();
317
+ pos += n;
318
+ }
319
+ } else {
320
+ // Legacy shape: full chunked prefill (final chunk included), token0
321
+ // sampled from the last position below.
322
+ for (let off = 0; off < promptIds.length; off += PREFILL_CHUNK) {
323
+ const chunk = promptIds.slice(off, off + PREFILL_CHUNK);
324
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
325
+ const { hidden: h, ctxML } = await forwardMaybeTap(
326
+ model,
327
+ ids,
328
+ caches,
329
+ tapLayers,
330
+ );
331
+ ids.dispose();
332
+ if (ctxML) ctxParts.push(ctxML);
333
+ if (off + PREFILL_CHUNK >= promptIds.length) {
334
+ const L = h.shape[1]!;
335
+ const H = h.shape[2]!;
336
+ anchorHidden = h.slice([0, L - 1, 0], [1, L, H]); // [1,1,H], retained
337
+ const lg = model.logitsFromHidden(anchorHidden);
338
+ const V = lg.shape[lg.shape.length - 1]!;
339
+ lastLogits = ops.reshape(lg, [1, V]);
340
+ lg.dispose();
341
+ }
342
+ h.dispose();
343
+ clearCache();
344
+ }
345
+ }
346
+ extras.targetCalls++;
347
+ // Seed the source: two-model prefills its own draft cache; DSpark seeds
348
+ // H_ctx from the tapped prompt context (ownership transfers to prefill);
349
+ // the assistant is a no-op.
350
+ if (tapLayers) {
351
+ if (ctxParts.length === 1) prefillCtx = ctxParts[0]!;
352
+ else if (ctxParts.length > 1) {
353
+ prefillCtx = ops.concatAxis(ctxParts, 1); // [1,Lp,m*H]
354
+ for (const p of ctxParts) p.dispose();
355
+ }
356
+ ctxParts.length = 0; // parts consumed (transferred as prefillCtx or disposed)
357
+ await src.prefill(promptIds, prefillCtx ?? undefined); // takes ownership of prefillCtx
358
+ prefillCtx = null; // ownership transferred
359
+ } else {
360
+ await src.prefill(promptIds);
361
+ }
362
+ stats.prefillMs = performance.now() - t0;
363
+ stats.prefillTps = (promptIds.length / Math.max(stats.prefillMs, 1e-6)) * 1000;
364
+
365
+ // ---- first pending token ----
366
+ // Oracle shape: pending = the UNPROCESSED last prompt token — never
367
+ // emitted (it's prompt), it heads the first verify window; the first
368
+ // generated token is that window's position-0 sample, inside the walk.
369
+ // Legacy shape: sample + emit token0 from the prefill logits (old
370
+ // convention, kill-switch / 1-token prompts).
371
+ const tDecode = performance.now();
372
+ let pending: number;
373
+ if (oracleShape) {
374
+ pending = promptIds[promptIds.length - 1]!;
375
+ } else {
376
+ pending = await samplePos(lastLogits!, 0);
377
+ lastLogits!.dispose();
378
+ lastLogits = null;
379
+ if (eos.includes(pending)) {
380
+ stats.decodeMs = performance.now() - tDecode;
381
+ return stats;
382
+ }
383
+ stats.generatedTokens++;
384
+ if (
385
+ (await onToken(pending)) === false ||
386
+ stats.generatedTokens >= maxTokens ||
387
+ // grammar satisfied at token 0 (a 1-token grammar) — finish "stop"
388
+ grammar?.isTerminated
389
+ ) {
390
+ stats.decodeMs = performance.now() - tDecode;
391
+ return stats;
392
+ }
393
+ }
394
+
395
+ let feed: number[] = [pending];
396
+ let speculating = true;
397
+
398
+ // ---- rounds ----
399
+ decode: while (stats.generatedTokens < maxTokens) {
400
+ if (!speculating) {
401
+ // plain single-token continuation (post-ring-wrap fallback)
402
+ const ids = ops.fromInt32([pending], [1, 1]);
403
+ const { hidden: h } = await forwardMaybeTap(
404
+ model,
405
+ ids,
406
+ caches,
407
+ undefined,
408
+ );
409
+ ids.dispose();
410
+ const lg = model.logitsFromHidden(h);
411
+ h.dispose();
412
+ const V = lg.shape[lg.shape.length - 1]!;
413
+ roundRow = ops.reshape(lg, [1, V]);
414
+ lg.dispose();
415
+ extras.targetCalls++;
416
+ const tok = await samplePos(roundRow, stats.generatedTokens);
417
+ roundRow.dispose();
418
+ roundRow = null;
419
+ clearCache();
420
+ if (eos.includes(tok)) break;
421
+ stats.generatedTokens++;
422
+ pending = tok;
423
+ if ((await onToken(tok)) === false) break;
424
+ if (grammar?.isTerminated) break; // grammar closed — finish "stop"
425
+ continue;
426
+ }
427
+
428
+ const n = Math.min(gamma, Math.max(1, maxTokens - stats.generatedTokens));
429
+
430
+ // Ring-wrap gate, BEFORE the round writes anything: a rejected-draft
431
+ // rollback needs trim(), and a RotatingKVCache stops being trimmable
432
+ // once its ring wraps (offset ≥ maxSize) — at which point the rejected
433
+ // KV would be woven into the live window (unrecoverable, upstream
434
+ // RAISES here, generate.py:529-533). Degrade to plain decode while the
435
+ // caches are still clean instead of 500ing a stream.
436
+ const roundFits = caches.every((c) =>
437
+ "maxSize" in c && typeof (c as { maxSize?: number }).maxSize === "number"
438
+ ? c.offset + n + 1 < (c as { maxSize: number }).maxSize
439
+ : c.isTrimmable(),
440
+ );
441
+ if (!roundFits) {
442
+ console.warn(
443
+ "spec: sliding window nearly wrapped — finishing without speculation",
444
+ );
445
+ speculating = false;
446
+ continue;
447
+ }
448
+
449
+ // (a) draft UP TO n tokens (request sampler, mlx-lm parity). The return
450
+ // length d is authoritative — DSpark's confidence scheduler may prune the
451
+ // block short or skip it entirely. The assistant source
452
+ // borrows the target's anchor hidden; two-model/dflash ignore it.
453
+ const drafts = await src.draft(
454
+ feed,
455
+ n,
456
+ stats.generatedTokens,
457
+ anchorHidden ?? undefined,
458
+ );
459
+ const d = drafts.length;
460
+ if (d < 0 || d > n) throw new Error(`DraftSource returned ${d} drafts (contract: 0..${n})`);
461
+ if (Bun.env.MLX_BUN_SPEC_TRACE === "1") {
462
+ console.error(
463
+ `[SPEC_TRACE] round=${extras.rounds + 1} ` +
464
+ `feed=${feed.join(",")} drafts=${drafts.join(",")}`,
465
+ );
466
+ }
467
+ // d === 0 (a confidence scheduler skipping the round, DeepSpec ℓ=0
468
+ // semantics) needs NO special case: the verify window degenerates to
469
+ // [pending] alone, the accept walk to the single bonus position, commit
470
+ // to a 0-accept round (tapped sources still grow context by the anchor
471
+ // row — lockstep with the target cache).
472
+ extras.drafted += d;
473
+ for (let i = 0; i < d; i++)
474
+ extras.draftedByPos[i] = (extras.draftedByPos[i] ?? 0) + 1;
475
+
476
+ // (b) ONE target forward over [pending, ...drafts] (optionally tapped for
477
+ // DSpark's H_ctx). vHidden is retained past the accept walk: its slice at
478
+ // the emitted position is the next round's anchor hidden.
479
+ const vIds = ops.fromInt32([pending, ...drafts], [1, d + 1]);
480
+ const pinTarget = src.pinTargetKernelFamily === true &&
481
+ "setSpecKernelPinned" in model;
482
+ if (pinTarget) model.setSpecKernelPinned(true);
483
+ try {
484
+ const ft = await forwardMaybeTap(model, vIds, caches, tapLayers);
485
+ // Assign these before the lm-head call so the outer cleanup owns them
486
+ // even if logits construction throws.
487
+ vHidden = ft.hidden;
488
+ vCtxML = ft.ctxML;
489
+ vLogits = model.logitsFromHidden(vHidden);
490
+ } finally {
491
+ vIds.dispose();
492
+ if (pinTarget) model.setSpecKernelPinned(false);
493
+ }
494
+ extras.targetCalls++;
495
+ // The lm-head remains one logical batched operation. Native GLM MTP's
496
+ // SPEC_PIN implementation evaluates its rows through the same M=1
497
+ // quantized family before concatenating them.
498
+
499
+ // (c) per-position accept walk: sample the target at each position,
500
+ // accept while it reproduces the draft. Sampling is sequential because
501
+ // the processor history (and, in Phase C, the grammar mask) at position
502
+ // i depends on the tokens accepted at positions < i.
503
+ let kAccept = 0;
504
+ let correction: number | null = null; // target's token at first mismatch (or bonus)
505
+ let sawEos = false;
506
+ let halted = false;
507
+ const emitted: number[] = [];
508
+ let grammarDone = false;
509
+ for (let i = 0; i <= d; i++) {
510
+ roundRow = logitsRow(vLogits!, i);
511
+ const tok = await samplePos(roundRow, stats.generatedTokens + emitted.length);
512
+ roundRow.dispose();
513
+ roundRow = null;
514
+ if (i < d && tok === drafts[i]) {
515
+ kAccept++;
516
+ // EOS is never content — even when it arrives as an ACCEPTED DRAFT
517
+ // (the correction/bonus branch below always excluded it; this
518
+ // branch pushed-then-broke, leaking the EOS through onToken —
519
+ // caught by the 2026-07-07 live oracle gate: streams were
520
+ // bit-identical, ours emitted one extra "content" token: <|eot_id|>).
521
+ if (eos.includes(tok)) { sawEos = true; break; }
522
+ emitted.push(tok);
523
+ // grammar termination mid-burst truncates the round — nothing may
524
+ // be sampled past a satisfied grammar (the all--inf guarantee).
525
+ if (grammar?.isTerminated) { grammarDone = true; break; }
526
+ if (stats.generatedTokens + emitted.length >= maxTokens) break;
527
+ continue;
528
+ }
529
+ // mismatch (target replaces the draft) or bonus (i === n)
530
+ correction = tok;
531
+ if (!eos.includes(tok)) emitted.push(tok);
532
+ else sawEos = true;
533
+ if (grammar?.isTerminated) grammarDone = true;
534
+ break;
535
+ }
536
+ vLogits!.dispose();
537
+ vLogits = null;
538
+ extras.accepted += kAccept;
539
+ extras.rejected = extras.drafted - extras.accepted;
540
+ extras.rounds++;
541
+ extras.acceptanceLengths.push(kAccept);
542
+ for (let i = 0; i < kAccept; i++)
543
+ extras.acceptedByPos[i] = (extras.acceptedByPos[i] ?? 0) + 1;
544
+ clearCache();
545
+
546
+ // (d) emit the round's tokens through onToken, one at a time
547
+ for (const tok of emitted) {
548
+ stats.generatedTokens++;
549
+ if ((await onToken(tok)) === false) { halted = true; break; }
550
+ if (stats.generatedTokens >= maxTokens) break;
551
+ }
552
+ extras.tokensPerForward = extras.rounds > 0
553
+ ? stats.generatedTokens / extras.rounds
554
+ : 0;
555
+ extras.forwardsSaved = Math.max(
556
+ 0,
557
+ Math.max(0, stats.generatedTokens - 1) - extras.rounds,
558
+ );
559
+ const stop = sawEos || halted || grammarDone || stats.generatedTokens >= maxTokens;
560
+
561
+ // (e) roll back the rejected suffix + commit to the source (skipped on
562
+ // stop — the target caches are about to be disposed). commit takes
563
+ // ownership of vCtxML (DSpark grows H_ctx by the accepted window; the
564
+ // others ignore it). The next anchor = the target hidden at the emitted
565
+ // position (the assistant borrows it; generate.ts:230 pattern — the slice
566
+ // outlives its parent's dispose).
567
+ if (!stop) {
568
+ if (kAccept < d) for (const c of caches) c.trim(d - kAccept);
569
+ await src.commit(
570
+ d,
571
+ kAccept,
572
+ vCtxML ?? undefined,
573
+ vHidden,
574
+ drafts.slice(0, kAccept),
575
+ ); // takes ownership of vCtxML; verifiedHidden stays caller-owned
576
+ vCtxML = null;
577
+ const H = vHidden!.shape[2]!;
578
+ anchorHidden?.dispose();
579
+ anchorHidden = vHidden!.slice([0, kAccept, 0], [1, kAccept + 1, H]); // [1,1,H]
580
+ } else {
581
+ vCtxML?.dispose();
582
+ vCtxML = null;
583
+ }
584
+ vHidden!.dispose();
585
+ vHidden = null;
586
+ if (stop) break decode;
587
+
588
+ // (f) chain: mlx-lm's re-feed rule (generate.py:645-648)
589
+ const emit = correction!; // non-null: the walk always sets it unless it broke on EOS/max inside accepts
590
+ if (d > 0 && kAccept === d) feed = [drafts[d - 1]!, emit]; // d=0 has no last draft to re-feed
591
+ else feed = [emit];
592
+ pending = emit;
593
+ }
594
+
595
+ stats.decodeMs = performance.now() - tDecode;
596
+ stats.decodeTps = stats.decodeMs
597
+ ? (stats.generatedTokens / stats.decodeMs) * 1000
598
+ : 0;
599
+ return stats;
600
+ } finally {
601
+ // Dispose whatever a mid-round/prefill throw left live (each is nulled the
602
+ // instant its normal disposal or ownership transfer ran, so no double-free).
603
+ anchorHidden?.dispose();
604
+ lastLogits?.dispose();
605
+ prefillCtx?.dispose();
606
+ for (const p of ctxParts) p.dispose();
607
+ vLogits?.dispose();
608
+ roundRow?.dispose();
609
+ vHidden?.dispose();
610
+ vCtxML?.dispose();
611
+ for (const c of caches) c.dispose();
612
+ source?.dispose();
613
+ history?.dispose();
614
+ options.grammar?.dispose(); // Phase C will consume it; never leak either way
615
+ clearCache();
616
+ }
617
+ }