mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -0,0 +1,536 @@
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
+ function forwardMaybeTap(
61
+ model: RuntimeModel,
62
+ ids: MlxArray,
63
+ caches: Cache[],
64
+ tapLayers: number[] | undefined,
65
+ ): { hidden: MlxArray; ctxML: MlxArray | null } {
66
+ if (!tapLayers) return { hidden: model.forwardHidden(ids, caches), ctxML: null };
67
+ const m = model as unknown as {
68
+ hiddenTap: { layers: Set<number>; captured: Map<number, MlxArray> } | null;
69
+ };
70
+ const cap = new Map<number, MlxArray>();
71
+ m.hiddenTap = { layers: new Set(tapLayers), captured: cap };
72
+ let hidden: MlxArray | null = null;
73
+ try {
74
+ hidden = model.forwardHidden(ids, caches);
75
+ const perLayer = tapLayers.map((li) => {
76
+ const a = cap.get(li);
77
+ if (!a) throw new Error(`spec tap: layer ${li} not captured`);
78
+ return a;
79
+ });
80
+ const ctxML = ops.concatAxis(perLayer, 2); // [1,L,m*H]
81
+ for (const [, a] of cap) a.dispose();
82
+ cap.clear(); // consumed — the finally must not double-dispose
83
+ const out = { hidden, ctxML };
84
+ hidden = null; // ownership returned to the caller
85
+ return out;
86
+ } finally {
87
+ // On any throw (forward mid-capture, missing layer, concat), free the
88
+ // partially-captured tap tensors and the orphaned hidden. On success both
89
+ // are already gone (cap cleared, hidden nulled).
90
+ hidden?.dispose();
91
+ for (const [, a] of cap) a.dispose();
92
+ m.hiddenTap = null;
93
+ }
94
+ }
95
+
96
+ export interface SpecServeExtras {
97
+ drafted: number;
98
+ accepted: number;
99
+ targetCalls: number;
100
+ /** Per-draft-position counters (index = position within a round's block,
101
+ * 0..γ-1): how many rounds drafted/accepted at that position. Drives the
102
+ * Phase-1c per-position acceptance report (scripts/dspark-drafter-ab.ts)
103
+ * — near-zero cost, always populated. */
104
+ draftedByPos: number[];
105
+ acceptedByPos: number[];
106
+ }
107
+
108
+ export async function specServeRun(
109
+ model: RuntimeModel,
110
+ provider: DraftProvider,
111
+ numDraftTokens: number,
112
+ promptIds: number[],
113
+ options: GenerateOptions & { stopSequences?: string[] },
114
+ onToken: OnToken,
115
+ ): Promise<GenerateStats> {
116
+ const maxTokens = options.maxTokens ?? 512;
117
+ const eos = options.eosTokenIds ?? model.config.eosTokenIds;
118
+ const sampler = makeSampler(options);
119
+ const processors = makeLogitsProcessors(options);
120
+ const gamma = Math.max(1, numDraftTokens);
121
+
122
+ // Allocated INSIDE the try below (2026-07-07 review): provider.open()
123
+ // throws on a mismatched (target, drafter) pairing — as pre-try consts a
124
+ // throw leaked options.grammar (a live WASM matcher) and the fresh caches,
125
+ // and turned a config error into a per-request 500. loadContext now
126
+ // probe-opens the pairing at startup, so a throw here is belt+suspenders.
127
+ let caches: Cache[] = [];
128
+ let source: import("./source").DraftSource | null = null;
129
+ let tapLayers: number[] | undefined;
130
+ // Target final hidden [1,1,H] at the anchor position — the assistant source
131
+ // borrows it for its first draft step of each round; two-model/dflash ignore
132
+ // it. Retained across rounds, disposed in finally.
133
+ let anchorHidden: MlxArray | null = null;
134
+ // Round/prefill scratch tensors, hoisted so the finally disposes them on ANY
135
+ // throw mid-round/prefill (grammar WASM reject, awaited onToken reject, a GPU
136
+ // error). Each is nulled the instant its in-body disposal or ownership
137
+ // transfer runs, so the finally never double-frees. (A serve endpoint hits
138
+ // these throw seams per request; a try-body local would leak GPU memory.)
139
+ let lastLogits: MlxArray | null = null;
140
+ let prefillCtx: MlxArray | null = null;
141
+ const ctxParts: MlxArray[] = []; // per-chunk tapped context (DSpark only)
142
+ let vHidden: MlxArray | null = null;
143
+ let vCtxML: MlxArray | null = null;
144
+ let vLogits: MlxArray | null = null;
145
+ let roundRow: MlxArray | null = null; // the in-flight verify/continuation row
146
+ // Device-side token history for the logits processors (repetition/presence/
147
+ // frequency penalties, logit_bias) — generate()'s pushHistory discipline.
148
+ let history: MlxArray | null =
149
+ processors.length > 0 ? ops.fromInt32(promptIds, [promptIds.length]) : null;
150
+
151
+ const extras: SpecServeExtras = {
152
+ drafted: 0, accepted: 0, targetCalls: 0, draftedByPos: [], acceptedByPos: [],
153
+ };
154
+ const stats: GenerateStats = {
155
+ promptTokens: promptIds.length,
156
+ cachedTokens: 0,
157
+ generatedTokens: 0,
158
+ prefillMs: 0, decodeMs: 0, prefillTps: 0, decodeTps: 0,
159
+ cacheTokens: [],
160
+ spec: extras,
161
+ };
162
+
163
+ /** Sample ONE verify position from a [1,V] logits row: processors →
164
+ * grammar mask (Phase C: the constrained verify walk — the mask has the
165
+ * final say, same ordering as serial sampleStep and the batch closure) →
166
+ * log-softmax → sampler. The GRAMMAR walk is inherently sequential: the
167
+ * mask at position i reflects every token emitted before it, so this
168
+ * awaits the matcher's async fill (fired by the previous accept) before
169
+ * masking, samples, then advances the matcher — the matcher only ever
170
+ * moves on tokens that are (about to be) EMITTED, which is what makes
171
+ * rollback-free v1 correct: rejected DRAFTS never touch grammar state.
172
+ * Appends the sampled token to the processor history. */
173
+ const grammar = options.grammar;
174
+ const samplePos = async (logits1V: MlxArray, step: number): Promise<number> => {
175
+ // Owned intermediates drained in the finally on any throw — grammar
176
+ // fill reject, applyMask/toLogprobs/sampler error (2026-07-07 review:
177
+ // these were the one spot the header's hoist discipline missed; a
178
+ // grammar+penalties request whose matcher fill rejects orphaned one
179
+ // [1,V] row per occurrence). `cur` is owned only once it diverges from
180
+ // the caller's row.
181
+ let cur: MlxArray = logits1V;
182
+ let lp: MlxArray | null = null;
183
+ let tokArr: MlxArray | null = null;
184
+ try {
185
+ for (const p of processors) {
186
+ const next = p(history, cur);
187
+ if (cur !== logits1V) cur.dispose();
188
+ cur = next;
189
+ }
190
+ if (grammar && !grammar.isTerminated) {
191
+ await grammar.ready();
192
+ const masked = grammar.applyMask(cur);
193
+ if (cur !== logits1V) cur.dispose();
194
+ cur = masked;
195
+ }
196
+ lp = toLogprobs(cur);
197
+ if (cur !== logits1V) cur.dispose();
198
+ cur = logits1V; // consumed — nothing owned under this name now
199
+ tokArr = sampler(lp, step);
200
+ lp.dispose();
201
+ lp = null;
202
+ const tok = ops.itemUint32(tokArr);
203
+ tokArr.dispose();
204
+ tokArr = null;
205
+ // Advance the matcher on the emitted token (fires the next async fill).
206
+ // EOS is never content and never grammar-valid — don't feed it.
207
+ if (grammar && !eos.includes(tok)) grammar.accept(tok);
208
+ if (history) {
209
+ const t1 = ops.fromInt32([tok], [1]);
210
+ const prev = history;
211
+ history = ops.concatAxis([prev, t1], 0);
212
+ prev.dispose();
213
+ t1.dispose();
214
+ }
215
+ return tok;
216
+ } finally {
217
+ if (cur !== logits1V) cur.dispose();
218
+ lp?.dispose();
219
+ tokArr?.dispose();
220
+ }
221
+ };
222
+
223
+ /** The [1,V] logits row at position `pos` of a hidden window — batched
224
+ * lm-head is applied by the caller ONCE; this slices its output. */
225
+ const logitsRow = (logitsWindow: MlxArray, pos: number): MlxArray => {
226
+ const V = logitsWindow.shape[logitsWindow.shape.length - 1]!;
227
+ const sl = logitsWindow.slice([0, pos, 0], [1, pos + 1, V]);
228
+ const flat = ops.reshape(sl, [1, V]);
229
+ sl.dispose();
230
+ return flat;
231
+ };
232
+
233
+ try {
234
+ caches = model.makeCache();
235
+ const src = provider.open({ sampler, target: { model, caches } });
236
+ source = src;
237
+ tapLayers = src.tapLayers;
238
+ // ---- prefill (target, chunked; optionally tapped for DSpark), then seed
239
+ // the source. Order: the source's prefill needs the tapped context that the
240
+ // target prefill produces, so target-first (two-model's own draft prefill
241
+ // and the assistant no-op are order-independent). ----
242
+ //
243
+ // Oracle prefill convention (re-anchored 2026-07-07 after PR #18; then
244
+ // corrected to the SPEC oracle's true shape after a live gate vs
245
+ // speculative_generate_step still flipped a knife-edge): mlx-lm's
246
+ // speculative path drains BOTH models to len-1 (`_prefill: while y.size >
247
+ // 1`) and has NO separate step-0 at all — the un-drained last prompt
248
+ // token HEADS THE FIRST VERIFY WINDOW ([lastPromptToken, ...drafts]),
249
+ // and the first emitted token is that window's position-0 sample
250
+ // (generate.py:578-618). A separate L=1 step-0 (the serial-lane
251
+ // convention) is ulp-different from the (1+γ)-window GEMM head and still
252
+ // flips near-ties. So under the flag: drain to len-1, pending = the last
253
+ // prompt token, straight into the rounds. Gated live: 4/4 token-for-token
254
+ // vs the oracle venv (γ∈{2,3} × 2 prompts, incl. the knife-edge cell).
255
+ // MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts to the legacy full-chunk + sampled
256
+ // token0 shape (kill switch; also the 1-token-prompt degenerate path).
257
+ //
258
+ // Seam interaction: the tapped seed covers rows 0..len-2 only — the last
259
+ // prompt token's row arrives via the FIRST verify round's vCtxML (it is
260
+ // that round's anchor row, which commit() appends) — so context sources
261
+ // stay in exact lockstep, and DSpark's round-1 anchor IS the last prompt
262
+ // token at position len-1, matching DeepSpec's reference loop. The
263
+ // assistant source's first-round anchor hidden = "the hidden that
264
+ // produced pending" = position len-2, the last drain chunk's final row.
265
+ const t0 = performance.now();
266
+ const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
267
+ const oracleShape = tailSplit && promptIds.length > 1;
268
+ if (oracleShape) {
269
+ let pos = 0;
270
+ const end = promptIds.length - 1; // last prompt token NEVER prefilled
271
+ while (pos < end) {
272
+ const n = Math.min(PREFILL_CHUNK, end - pos);
273
+ const chunk = promptIds.slice(pos, pos + n);
274
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
275
+ const { hidden: h, ctxML } = forwardMaybeTap(model, ids, caches, tapLayers);
276
+ ids.dispose();
277
+ if (ctxML) ctxParts.push(ctxML);
278
+ if (pos + n >= end) {
279
+ const L = h.shape[1]!;
280
+ const H = h.shape[2]!;
281
+ anchorHidden = h.slice([0, L - 1, 0], [1, L, H]); // position len-2
282
+ }
283
+ h.dispose(); // logits never computed during the drain
284
+ clearCache();
285
+ pos += n;
286
+ }
287
+ } else {
288
+ // Legacy shape: full chunked prefill (final chunk included), token0
289
+ // sampled from the last position below.
290
+ for (let off = 0; off < promptIds.length; off += PREFILL_CHUNK) {
291
+ const chunk = promptIds.slice(off, off + PREFILL_CHUNK);
292
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
293
+ const { hidden: h, ctxML } = forwardMaybeTap(model, ids, caches, tapLayers);
294
+ ids.dispose();
295
+ if (ctxML) ctxParts.push(ctxML);
296
+ if (off + PREFILL_CHUNK >= promptIds.length) {
297
+ const L = h.shape[1]!;
298
+ const H = h.shape[2]!;
299
+ anchorHidden = h.slice([0, L - 1, 0], [1, L, H]); // [1,1,H], retained
300
+ const lg = model.logitsFromHidden(anchorHidden);
301
+ const V = lg.shape[lg.shape.length - 1]!;
302
+ lastLogits = ops.reshape(lg, [1, V]);
303
+ lg.dispose();
304
+ }
305
+ h.dispose();
306
+ clearCache();
307
+ }
308
+ }
309
+ extras.targetCalls++;
310
+ // Seed the source: two-model prefills its own draft cache; DSpark seeds
311
+ // H_ctx from the tapped prompt context (ownership transfers to prefill);
312
+ // the assistant is a no-op.
313
+ if (tapLayers) {
314
+ if (ctxParts.length === 1) prefillCtx = ctxParts[0]!;
315
+ else if (ctxParts.length > 1) {
316
+ prefillCtx = ops.concatAxis(ctxParts, 1); // [1,Lp,m*H]
317
+ for (const p of ctxParts) p.dispose();
318
+ }
319
+ ctxParts.length = 0; // parts consumed (transferred as prefillCtx or disposed)
320
+ src.prefill(promptIds, prefillCtx ?? undefined); // takes ownership of prefillCtx
321
+ prefillCtx = null; // ownership transferred
322
+ } else {
323
+ src.prefill(promptIds);
324
+ }
325
+ stats.prefillMs = performance.now() - t0;
326
+ stats.prefillTps = (promptIds.length / Math.max(stats.prefillMs, 1e-6)) * 1000;
327
+
328
+ // ---- first pending token ----
329
+ // Oracle shape: pending = the UNPROCESSED last prompt token — never
330
+ // emitted (it's prompt), it heads the first verify window; the first
331
+ // generated token is that window's position-0 sample, inside the walk.
332
+ // Legacy shape: sample + emit token0 from the prefill logits (old
333
+ // convention, kill-switch / 1-token prompts).
334
+ const tDecode = performance.now();
335
+ let pending: number;
336
+ if (oracleShape) {
337
+ pending = promptIds[promptIds.length - 1]!;
338
+ } else {
339
+ pending = await samplePos(lastLogits!, 0);
340
+ lastLogits!.dispose();
341
+ lastLogits = null;
342
+ if (eos.includes(pending)) {
343
+ stats.decodeMs = performance.now() - tDecode;
344
+ return stats;
345
+ }
346
+ stats.generatedTokens++;
347
+ if (
348
+ (await onToken(pending)) === false ||
349
+ stats.generatedTokens >= maxTokens ||
350
+ // grammar satisfied at token 0 (a 1-token grammar) — finish "stop"
351
+ grammar?.isTerminated
352
+ ) {
353
+ stats.decodeMs = performance.now() - tDecode;
354
+ return stats;
355
+ }
356
+ }
357
+
358
+ let feed: number[] = [pending];
359
+ let speculating = true;
360
+
361
+ // ---- rounds ----
362
+ decode: while (stats.generatedTokens < maxTokens) {
363
+ if (!speculating) {
364
+ // plain single-token continuation (post-ring-wrap fallback)
365
+ const ids = ops.fromInt32([pending], [1, 1]);
366
+ const h = model.forwardHidden(ids, caches);
367
+ ids.dispose();
368
+ const lg = model.logitsFromHidden(h);
369
+ h.dispose();
370
+ const V = lg.shape[lg.shape.length - 1]!;
371
+ roundRow = ops.reshape(lg, [1, V]);
372
+ lg.dispose();
373
+ extras.targetCalls++;
374
+ const tok = await samplePos(roundRow, stats.generatedTokens);
375
+ roundRow.dispose();
376
+ roundRow = null;
377
+ clearCache();
378
+ if (eos.includes(tok)) break;
379
+ stats.generatedTokens++;
380
+ pending = tok;
381
+ if ((await onToken(tok)) === false) break;
382
+ if (grammar?.isTerminated) break; // grammar closed — finish "stop"
383
+ continue;
384
+ }
385
+
386
+ const n = Math.min(gamma, Math.max(1, maxTokens - stats.generatedTokens));
387
+
388
+ // Ring-wrap gate, BEFORE the round writes anything: a rejected-draft
389
+ // rollback needs trim(), and a RotatingKVCache stops being trimmable
390
+ // once its ring wraps (offset ≥ maxSize) — at which point the rejected
391
+ // KV would be woven into the live window (unrecoverable, upstream
392
+ // RAISES here, generate.py:529-533). Degrade to plain decode while the
393
+ // caches are still clean instead of 500ing a stream.
394
+ const roundFits = caches.every((c) =>
395
+ "maxSize" in c && typeof (c as { maxSize?: number }).maxSize === "number"
396
+ ? c.offset + n + 1 < (c as { maxSize: number }).maxSize
397
+ : c.isTrimmable(),
398
+ );
399
+ if (!roundFits) {
400
+ console.warn(
401
+ "spec: sliding window nearly wrapped — finishing without speculation",
402
+ );
403
+ speculating = false;
404
+ continue;
405
+ }
406
+
407
+ // (a) draft UP TO n tokens (request sampler, mlx-lm parity). The return
408
+ // length d is authoritative — DSpark's confidence scheduler may prune the
409
+ // block short (source.ts contract; never zero). The assistant source
410
+ // borrows the target's anchor hidden; two-model/dflash ignore it.
411
+ const drafts = src.draft(feed, n, stats.generatedTokens, anchorHidden ?? undefined);
412
+ const d = drafts.length;
413
+ if (d < 0 || d > n) throw new Error(`DraftSource returned ${d} drafts (contract: 0..${n})`);
414
+ // d === 0 (a confidence scheduler skipping the round, DeepSpec ℓ=0
415
+ // semantics) needs NO special case: the verify window degenerates to
416
+ // [pending] alone, the accept walk to the single bonus position, commit
417
+ // to a 0-accept round (tapped sources still grow context by the anchor
418
+ // row — lockstep with the target cache).
419
+ extras.drafted += d;
420
+ for (let i = 0; i < d; i++)
421
+ extras.draftedByPos[i] = (extras.draftedByPos[i] ?? 0) + 1;
422
+
423
+ // (b) ONE target forward over [pending, ...drafts] (optionally tapped for
424
+ // DSpark's H_ctx). vHidden is retained past the accept walk: its slice at
425
+ // the emitted position is the next round's anchor hidden.
426
+ const vIds = ops.fromInt32([pending, ...drafts], [1, d + 1]);
427
+ const ft = forwardMaybeTap(model, vIds, caches, tapLayers);
428
+ vHidden = ft.hidden;
429
+ vCtxML = ft.ctxML;
430
+ vIds.dispose();
431
+ extras.targetCalls++;
432
+ vLogits = model.logitsFromHidden(vHidden); // batched lm-head, ONE matmul
433
+
434
+ // (c) per-position accept walk: sample the target at each position,
435
+ // accept while it reproduces the draft. Sampling is sequential because
436
+ // the processor history (and, in Phase C, the grammar mask) at position
437
+ // i depends on the tokens accepted at positions < i.
438
+ let kAccept = 0;
439
+ let correction: number | null = null; // target's token at first mismatch (or bonus)
440
+ let sawEos = false;
441
+ let halted = false;
442
+ const emitted: number[] = [];
443
+ let grammarDone = false;
444
+ for (let i = 0; i <= d; i++) {
445
+ roundRow = logitsRow(vLogits!, i);
446
+ const tok = await samplePos(roundRow, stats.generatedTokens + emitted.length);
447
+ roundRow.dispose();
448
+ roundRow = null;
449
+ if (i < d && tok === drafts[i]) {
450
+ kAccept++;
451
+ // EOS is never content — even when it arrives as an ACCEPTED DRAFT
452
+ // (the correction/bonus branch below always excluded it; this
453
+ // branch pushed-then-broke, leaking the EOS through onToken —
454
+ // caught by the 2026-07-07 live oracle gate: streams were
455
+ // bit-identical, ours emitted one extra "content" token: <|eot_id|>).
456
+ if (eos.includes(tok)) { sawEos = true; break; }
457
+ emitted.push(tok);
458
+ // grammar termination mid-burst truncates the round — nothing may
459
+ // be sampled past a satisfied grammar (the all--inf guarantee).
460
+ if (grammar?.isTerminated) { grammarDone = true; break; }
461
+ if (stats.generatedTokens + emitted.length >= maxTokens) break;
462
+ continue;
463
+ }
464
+ // mismatch (target replaces the draft) or bonus (i === n)
465
+ correction = tok;
466
+ if (!eos.includes(tok)) emitted.push(tok);
467
+ else sawEos = true;
468
+ if (grammar?.isTerminated) grammarDone = true;
469
+ break;
470
+ }
471
+ vLogits!.dispose();
472
+ vLogits = null;
473
+ extras.accepted += kAccept;
474
+ for (let i = 0; i < kAccept; i++)
475
+ extras.acceptedByPos[i] = (extras.acceptedByPos[i] ?? 0) + 1;
476
+ clearCache();
477
+
478
+ // (d) emit the round's tokens through onToken, one at a time
479
+ for (const tok of emitted) {
480
+ stats.generatedTokens++;
481
+ if ((await onToken(tok)) === false) { halted = true; break; }
482
+ if (stats.generatedTokens >= maxTokens) break;
483
+ }
484
+ const stop = sawEos || halted || grammarDone || stats.generatedTokens >= maxTokens;
485
+
486
+ // (e) roll back the rejected suffix + commit to the source (skipped on
487
+ // stop — the target caches are about to be disposed). commit takes
488
+ // ownership of vCtxML (DSpark grows H_ctx by the accepted window; the
489
+ // others ignore it). The next anchor = the target hidden at the emitted
490
+ // position (the assistant borrows it; generate.ts:230 pattern — the slice
491
+ // outlives its parent's dispose).
492
+ if (!stop) {
493
+ if (kAccept < d) for (const c of caches) c.trim(d - kAccept);
494
+ src.commit(d, kAccept, vCtxML ?? undefined); // takes ownership of vCtxML
495
+ vCtxML = null;
496
+ const H = vHidden!.shape[2]!;
497
+ anchorHidden?.dispose();
498
+ anchorHidden = vHidden!.slice([0, kAccept, 0], [1, kAccept + 1, H]); // [1,1,H]
499
+ } else {
500
+ vCtxML?.dispose();
501
+ vCtxML = null;
502
+ }
503
+ vHidden!.dispose();
504
+ vHidden = null;
505
+ if (stop) break decode;
506
+
507
+ // (f) chain: mlx-lm's re-feed rule (generate.py:645-648)
508
+ const emit = correction!; // non-null: the walk always sets it unless it broke on EOS/max inside accepts
509
+ if (d > 0 && kAccept === d) feed = [drafts[d - 1]!, emit]; // d=0 has no last draft to re-feed
510
+ else feed = [emit];
511
+ pending = emit;
512
+ }
513
+
514
+ stats.decodeMs = performance.now() - tDecode;
515
+ stats.decodeTps = stats.decodeMs
516
+ ? (stats.generatedTokens / stats.decodeMs) * 1000
517
+ : 0;
518
+ return stats;
519
+ } finally {
520
+ // Dispose whatever a mid-round/prefill throw left live (each is nulled the
521
+ // instant its normal disposal or ownership transfer ran, so no double-free).
522
+ anchorHidden?.dispose();
523
+ lastLogits?.dispose();
524
+ prefillCtx?.dispose();
525
+ for (const p of ctxParts) p.dispose();
526
+ vLogits?.dispose();
527
+ roundRow?.dispose();
528
+ vHidden?.dispose();
529
+ vCtxML?.dispose();
530
+ for (const c of caches) c.dispose();
531
+ source?.dispose();
532
+ history?.dispose();
533
+ options.grammar?.dispose(); // Phase C will consume it; never leak either way
534
+ clearCache();
535
+ }
536
+ }
@@ -0,0 +1,93 @@
1
+ // DraftSource — the seam between the serve-time speculative verify loop
2
+ // (src/spec/serve-loop.ts) and whatever produces draft tokens. Designed in
3
+ // docs/design/mlx-lm-tool-parity-plan.md §7 so three drafters share ONE
4
+ // verify/accept executor:
5
+ // - TwoModelSource (src/spec/two-model.ts) — mlx-lm parity (L1 oracle:
6
+ // mlx_lm.server --draft-model), a full second model. Ignores `target`.
7
+ // - AssistantSource (src/spec/assistant-source.ts) — the optiq KV-borrowing
8
+ // Gemma drafter (src/spec/drafter.ts; L2 oracle: optiq spec_generate).
9
+ // Reads the target's donor K/V + anchor hidden each step.
10
+ // - DflashSource (src/spec/dflash-source.ts) — DSpark (L3, KL/quality-gated).
11
+ // Taps the target's multi-layer hiddens (prefill + verify) into a growing
12
+ // H_ctx (docs/design/dspark-speculative-decoding.md).
13
+ //
14
+ // The three differ ONLY in what fills the draft; the serve loop, admission
15
+ // accounting, and stats never change. The KV-borrowing sources need target
16
+ // state the two-model source doesn't — carried by the `target` view at open()
17
+ // and the optional prefill/draft/commit arguments below (all no-ops for
18
+ // two-model). See [[dspark-seam-kv-borrowing]].
19
+
20
+ import type { MlxArray } from "../mlx/array";
21
+ import type { RuntimeModel } from "../model/factory";
22
+ import type { Cache } from "../model/gemma4";
23
+
24
+ /** Read-only handle to the target's live decode state, handed to every source
25
+ * at open(). Two-model ignores it; KV-borrowing sources read the target's
26
+ * model (embed / lm-head / the draft module) and its LIVE cache array (donor
27
+ * K/V views + offset — the SAME caches the serve loop drives). */
28
+ export interface TargetView {
29
+ readonly model: RuntimeModel;
30
+ readonly caches: Cache[];
31
+ }
32
+
33
+ /** A per-request draft-token producer. Created per generation (owns its own
34
+ * draft-side state), disposed by the serve loop's finally. */
35
+ export interface DraftSource {
36
+ /** Multi-layer target tap the source needs captured on the target's prefill
37
+ * AND every verify forward (DSpark's H_ctx; e4b {20,31,41,42}). When set,
38
+ * the serve loop sets model.hiddenTap around those forwards and passes the
39
+ * captured context [1,L,m*H] into prefill()/commit(). Undefined for sources
40
+ * that don't tap (two-model, assistant). */
41
+ readonly tapLayers?: number[];
42
+
43
+ /** Process the prompt (two-model: prefill the draft model's cache;
44
+ * assistant/dflash: read the target's state — mostly a no-op, but DSpark
45
+ * seeds H_ctx from `ctxML`, the tapped prefill context [1,Lp,m*H], present
46
+ * iff tapLayers is set). */
47
+ prefill(promptIds: number[], ctxML?: MlxArray): void;
48
+
49
+ /** Propose 0..n tokens (RETURN LENGTH IS AUTHORITATIVE — a source may
50
+ * return fewer than n, e.g. DSpark's confidence-scheduled draft-length
51
+ * pruning; ZERO means "skip drafting this round" — DeepSpec ℓ=0 semantics,
52
+ * the serve loop degenerates to one plain target step, still tapped +
53
+ * committed for context-growing sources). The serve loop verifies over
54
+ * exactly the returned length. `feed` is the token(s) the draft has not
55
+ * yet consumed: [pending]
56
+ * after a rejection/first round, or [lastDraft, bonus] after an all-accept
57
+ * round (mlx-lm's re-feed rule, generate.py:645-648). `stepBase` is the
58
+ * emitted-token index, threaded to the sampler for per-step RNG streams.
59
+ * `anchorHidden` is the target's final hidden [1,1,H] at the pending/anchor
60
+ * position — the assistant source borrows it for its first draft step;
61
+ * two-model and dflash ignore it. */
62
+ draft(feed: number[], n: number, stepBase: number, anchorHidden?: MlxArray): number[];
63
+
64
+ /** Verify outcome for the last round: kAccept of d accepted, where d is the
65
+ * length draft() actually RETURNED (≤ n). Two-model: trim the draft cache
66
+ * by max(d - kAccept - 1, 0) — mlx-lm's rewind rule (generate.py:589-591).
67
+ * DSpark: grow H_ctx by the accepted window from `vCtxML`, the verified
68
+ * window's tapped context [1,d+1,m*H] (present iff tapLayers is set), and
69
+ * drop the rejected tips. */
70
+ commit(d: number, kAccept: number, vCtxML?: MlxArray): void;
71
+
72
+ /** Resident per-request draft weights, for admission accounting (0 when the
73
+ * provider owns the weights). */
74
+ readonly weightsBytes: number;
75
+ dispose(): void;
76
+ }
77
+
78
+ /** Server-lifetime owner of the draft machinery (the loaded draft model);
79
+ * open() mints a per-request DraftSource. */
80
+ export interface DraftProvider {
81
+ /** Human-readable id (registry id / path tail) for logs + cache namespacing. */
82
+ readonly id: string;
83
+ readonly weightsBytes: number;
84
+ open(opts: {
85
+ /** The request's sampler over logprobs [1,V] → token array [1] (the SAME
86
+ * sampler as the target — mlx-lm parity; greedy drafting under a
87
+ * temperature>0 request is NOT parity). */
88
+ sampler: (logprobs: MlxArray, step: number) => MlxArray;
89
+ /** The target model + its live caches (donor views / offset). */
90
+ target: TargetView;
91
+ }): DraftSource;
92
+ dispose(): void;
93
+ }