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
package/src/generate.ts CHANGED
@@ -15,20 +15,34 @@ import {
15
15
  } from "./mlx/ffi";
16
16
  import * as ops from "./mlx/ops";
17
17
  import { CompiledDecode } from "./model/compiled-decode";
18
- import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
18
+ import { Gemma4Model, KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
19
+ import { PagedKVCache } from "./model/paged-kv";
19
20
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
20
21
  import { diffusionGenerate } from "./diffusion/diffusion-generate";
21
22
  import type { RuntimeModel } from "./model/factory";
22
- import type { KvQuantSpec } from "./config";
23
+ import type { KvQuantSpec, TurboQuantScheme } from "./config";
24
+ import { flagOn } from "./flags";
23
25
  import {
24
26
  makeLogitsProcessors, makeSampler,
25
27
  type LogitsProcessorOptions, type SamplerOptions, toLogprobs,
26
28
  } from "./sampler";
29
+ import type { GrammarController } from "./grammar";
27
30
 
28
31
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
29
32
  maxTokens?: number;
30
33
  eosTokenIds?: number[];
31
34
  prefillChunkSize?: number;
35
+ /** Fired once during prefill, at `snapshotAt` tokens (default: the full
36
+ * prompt) and BEFORE any further KV is written — the one moment the
37
+ * caches hold exactly that prefix (post-wrap rings can't be rewound
38
+ * later). The server's prompt-boundary cache snapshot hangs here. */
39
+ onPrefillDone?: () => void;
40
+ /** Token count at which onPrefillDone fires (the STABLE cache boundary —
41
+ * chat prompts end in a generation primer, e.g. 12B's thought-channel
42
+ * tokens, that the NEXT turn's re-render does not contain; snapshotting
43
+ * there would make the entry untrimmable-and-divergent). Prefill is
44
+ * split at this index when it falls mid-prompt. */
45
+ snapshotAt?: number;
32
46
  /** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
33
47
  * prompt tokens are treated as already prefilled; only the suffix is
34
48
  * forwarded. Caller keeps ownership — generate() will not dispose. */
@@ -37,8 +51,16 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
37
51
  * covering the whole prompt; prefilled in one shot (no chunking).
38
52
  * Caller keeps ownership. */
39
53
  promptEmbeddings?: MlxArray;
40
- /** bool [L] marking image tokens (bidirectional attention among them). */
54
+ /** bool [L] marking image tokens (bidirectional attention among them).
55
+ * MUST be unset when the prompt contains any audio — audio prompts run
56
+ * fully causal (audio-input-plan.md §3.3 Q1). */
41
57
  imageMask?: MlxArray;
58
+ /** bool [L] marking ALL multimodal soft tokens (image | audio) for
59
+ * per-layer-input id zeroing (e2b/e4b), decoupled from imageMask so
60
+ * audio-only prompts (no bidirectional mask) still zero their positions.
61
+ * When unset, zeroing falls back to imageMask (the legacy vision shape).
62
+ * Caller keeps ownership. */
63
+ multimodalMask?: MlxArray;
42
64
  /** Quantize full-attention KV caches to this many bits (4 or 8).
43
65
  * Rotating (sliding-window) caches stay bf16 — they're window-capped
44
66
  * and upstream rotating-cache quantization is NYI. */
@@ -52,6 +74,22 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
52
74
  /** Convert once a cache's offset reaches this (uniform-kvBits default
53
75
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
54
76
  quantizedKvStart?: number;
77
+ /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
78
+ * quantization, a CLI-only runtime lever in the same class as uniform
79
+ * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
80
+ * checks kvBits/kvConfig first, so set at most one). Full-attention
81
+ * KVCache layers convert via TurboQuantKVCache.fromKVCache;
82
+ * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
83
+ * warning names the limitation, never a throw. */
84
+ turboQuant?: TurboQuantScheme;
85
+ /** OPTIONAL paged KV storage (docs/design/paged-kv-cache.md): fresh
86
+ * full-attention KVCache layers are replaced with PagedKVCache (block
87
+ * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
88
+ * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
89
+ * turboQuant/draft/compiled decode (callers refuse the combos; the
90
+ * cache swap itself only ever touches plain empty KVCache entries).
91
+ * Values are gated bit-exact vs the plain path (tests/paged-kv*). */
92
+ pagedKv?: { blockSize?: number };
55
93
  /** Mounted LoRA adapter ids to apply (resolved/validated by
56
94
  * AdapterManager.resolveSpec). Residuals sum in order. Set on the
57
95
  * model's LoraState for exactly the duration of this generation —
@@ -70,6 +108,22 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
70
108
  /** Capture the top-k (token id, logprob) pairs per emitted token from the
71
109
  * same distribution (mlx_lm.server `top_logprobs`). 0/unset = off. */
72
110
  topLogprobs?: number;
111
+ /** Grammar-constrained decoding (src/grammar.ts): a compiled GrammarController
112
+ * that masks invalid tokens to -inf each step. L2-class (oMLX oracle). When
113
+ * set, the decode loop takes a slightly different shape: it eager-reads the
114
+ * token id (acceptToken needs a JS number, which the pipelined loop defers),
115
+ * advances the matcher, and awaits the async mask precompute (which overlaps
116
+ * the GPU forward). Non-grammar requests keep the fast pipelined loop. */
117
+ grammar?: GrammarController;
118
+ }
119
+
120
+ export function shouldUseGrammarJump(
121
+ options: Pick<GenerateOptions, "grammar" | "logprobs" | "topLogprobs">,
122
+ ): boolean {
123
+ return options.grammar !== undefined &&
124
+ flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
125
+ !options.logprobs &&
126
+ !(options.topLogprobs && options.topLogprobs > 0);
73
127
  }
74
128
 
75
129
  /** Per-token logprob info (only present when GenerateOptions requested it). */
@@ -82,14 +136,35 @@ export interface TokenLogprobs {
82
136
  top?: { id: number; logprob: number }[];
83
137
  }
84
138
 
139
+ /** Materialize every cache's state at a prefill chunk boundary. Unlike
140
+ * KVCache/QuantizedKVCache/RotatingKVCache, TurboQuantKVCache.state()
141
+ * allocates FRESH trimmed slice views on every call (required by its
142
+ * snapshotCache/cloneKvCaches callers in kv-store.ts, which already
143
+ * dispose them) rather than returning its own live-owned arrays — so
144
+ * this chokepoint must dispose that cache kind's state() output itself,
145
+ * or the views leak (unreferenced past this call, only reclaimed by the
146
+ * FinalizationRegistry backstop on GC of the tiny JS wrapper). */
147
+ export function evalCacheState(cache: Cache[]): void {
148
+ const turboState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? c.state() : []));
149
+ try {
150
+ const liveState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? [] : c.state()));
151
+ ops.evalAll([...turboState, ...liveState]);
152
+ } finally {
153
+ // Disposed even when evalAll throws (Metal OOM / deferred graph error) —
154
+ // the whole reason this chokepoint exists is that these views are owned.
155
+ for (const a of turboState) a.dispose();
156
+ }
157
+ }
158
+
85
159
  /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
86
160
  * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
87
161
  * caches convert too — Phase 9):
88
162
  * - per-layer bits/group_size selection (kvConfig overrides kvBits,
89
163
  * matching optiq's --kv-config precedence; shipped kv_config.json
90
- * files list full-attention layers only, so rotating quantization
91
- * engages through uniform kvBits — like optiq --kv-bits — or a config
92
- * that names sliding layers), and
164
+ * files cover EVERY cache-owning layer, sliding ones included —
165
+ * verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
166
+ * rotating quantization engages straight from the config; uniform
167
+ * kvBits — like optiq --kv-bits — reaches them too), and
93
168
  * - STREAMING conversion (optiq streaming_kv_quant / serve.py
94
169
  * patched_maybe_quantize): eval each layer's quantized triples and
95
170
  * clear the buffer pool before building the next layer's conversion.
@@ -99,7 +174,11 @@ export interface TokenLogprobs {
99
174
  * 24 GB Mac). Numerics untouched: same quantize math, only the eval
100
175
  * ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
101
176
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
102
- const { kvBits, kvConfig } = options;
177
+ const { kvBits, kvConfig, turboQuant } = options;
178
+ if (turboQuant) {
179
+ maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
180
+ return;
181
+ }
103
182
  if (!kvBits && !kvConfig?.length) return;
104
183
  const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
105
184
  const byLayer = kvConfig?.length
@@ -128,6 +207,70 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
128
207
  }
129
208
  }
130
209
 
210
+ /** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
211
+ * plain full-attention KVCache for a PagedKVCache sized to hold
212
+ * `capacityTokens` (prompt + maxTokens — known exactly at generate()
213
+ * setup, so pool exhaustion is unreachable absent an accounting bug).
214
+ * Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
215
+ * prefill: paging changes storage layout, not arithmetic, so there is no
216
+ * "convert when populated" trigger. Sliding-window (RotatingKVCache)
217
+ * layers keep today's scheme — mixed paged-full + rotating-sliding is
218
+ * the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
219
+ * entirely: the serve lane bypasses prompt-cache reuse for paged
220
+ * requests, so this only guards library callers. */
221
+ export function maybePageKv(
222
+ cache: Cache[], options: GenerateOptions, capacityTokens: number,
223
+ ): void {
224
+ if (!options.pagedKv) return;
225
+ if (cache.some((c) => c.offset > 0)) return;
226
+ const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
227
+ for (let i = 0; i < cache.length; i++) {
228
+ if (cache[i] instanceof KVCache) {
229
+ cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
230
+ cache[i] = new PagedKVCache(capacityTokens, blockSize);
231
+ }
232
+ }
233
+ }
234
+
235
+ /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
236
+ * documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
237
+ * rather than throwing, so mixed full-attention/sliding-window models (e.g.
238
+ * Gemma) still serve correctly under --kv-quant turbo. */
239
+ let warnedTurboRotating = false;
240
+
241
+ /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
242
+ * above): only plain full-attention KVCache instances convert, via
243
+ * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
244
+ * never throw). Same offset===0 skip-empty-cache rule as the affine path. */
245
+ function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
246
+ for (let i = 0; i < cache.length; i++) {
247
+ const c = cache[i]!;
248
+ if (c instanceof RotatingKVCache) {
249
+ if (!warnedTurboRotating) {
250
+ warnedTurboRotating = true;
251
+ console.warn(
252
+ "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
253
+ "(full-attention only) — docs/design/turboquant-kv.md.",
254
+ );
255
+ }
256
+ continue;
257
+ }
258
+ if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
259
+ const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
260
+ cache[i] = tq;
261
+ // state() allocates fresh trimmed slice views for this cache kind
262
+ // (see evalCacheState) — dispose after materializing (throw included),
263
+ // or they leak.
264
+ const state = tq.state();
265
+ try {
266
+ ops.evalAll(state);
267
+ } finally {
268
+ for (const a of state) a.dispose();
269
+ }
270
+ clearCache();
271
+ }
272
+ }
273
+
131
274
  export interface GenerateStats {
132
275
  promptTokens: number;
133
276
  /** Prompt tokens skipped via a pre-warmed cache. */
@@ -141,6 +284,16 @@ export interface GenerateStats {
141
284
  * decoded token that was forwarded, including a trailing EOS the
142
285
  * pipeline forwarded before reading it). For PromptCache.put(). */
143
286
  cacheTokens: number[];
287
+ /** Speculative-decoding telemetry (serve --draft-model path only).
288
+ * draftedByPos/acceptedByPos: per-draft-position round counts (index =
289
+ * position within a round's block) — the Phase-1c per-position
290
+ * acceptance signal. */
291
+ spec?: {
292
+ drafted: number; accepted: number; targetCalls: number;
293
+ draftedByPos?: number[]; acceptedByPos?: number[];
294
+ rejected?: number; rounds?: number; acceptanceLengths?: number[];
295
+ tokensPerForward?: number; forwardsSaved?: number;
296
+ };
144
297
  }
145
298
 
146
299
  export interface GeneratedToken {
@@ -196,6 +349,33 @@ export class Generation implements AsyncIterable<GeneratedToken> {
196
349
  const WIRE_THRESHOLD = 0.75;
197
350
  let wiredScopeDepth = 0;
198
351
  let wiredOldLimit = 0;
352
+
353
+ type WiredModelMemory = {
354
+ readonly weightsBytes: number;
355
+ readonly expertRuntime?: {
356
+ readonly plan: { readonly plannedBytes: number };
357
+ flushUsage?: () => void;
358
+ finishUsage?: () => Promise<void>;
359
+ } | null;
360
+ };
361
+
362
+ /** Bytes whose MLX graph must stay resident while a model executes. */
363
+ export function wiredWorkingSetBytes(model: WiredModelMemory): number {
364
+ const planned = model.expertRuntime?.plan.plannedBytes;
365
+ return typeof planned === "number" && Number.isFinite(planned) && planned > 0
366
+ ? Math.max(model.weightsBytes, planned)
367
+ : model.weightsBytes;
368
+ }
369
+
370
+ export function modelNeedsWiredLimit(
371
+ model: WiredModelMemory,
372
+ recommendedBytes = maxRecommendedWorkingSetSize(),
373
+ force = process.env.MLX_BUN_FORCE_WIRE === "1",
374
+ ): boolean {
375
+ return force ||
376
+ wiredWorkingSetBytes(model) > WIRE_THRESHOLD * recommendedBytes;
377
+ }
378
+
199
379
  function enterWiredScope(): void {
200
380
  if (wiredScopeDepth++ === 0)
201
381
  wiredOldLimit = setWiredLimit(maxRecommendedWorkingSetSize());
@@ -220,9 +400,10 @@ export function generate(
220
400
  ? generateDiffusionInner(model, promptTokens, options)
221
401
  : generateInner(model, promptTokens, options);
222
402
  if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
223
- const wire =
224
- process.env.MLX_BUN_FORCE_WIRE === "1" ||
225
- model.weightsBytes > WIRE_THRESHOLD * maxRecommendedWorkingSetSize();
403
+ const memoryModel: WiredModelMemory = model;
404
+ if (memoryModel.expertRuntime?.flushUsage)
405
+ inner = usageScoped(memoryModel, inner);
406
+ const wire = modelNeedsWiredLimit(model);
226
407
  return new Generation(wire ? wiredScoped(inner) : inner);
227
408
  }
228
409
 
@@ -295,6 +476,68 @@ async function* wiredScoped(
295
476
  }
296
477
  }
297
478
 
479
+ /** Publish the streamed model's route ledger on every generator exit path. */
480
+ async function* usageScoped(
481
+ model: WiredModelMemory,
482
+ inner: AsyncGenerator<GeneratedToken, GenerateStats>,
483
+ ): AsyncGenerator<GeneratedToken, GenerateStats> {
484
+ try {
485
+ return yield* inner;
486
+ } finally {
487
+ if (model.expertRuntime?.finishUsage)
488
+ await model.expertRuntime.finishUsage();
489
+ else
490
+ model.expertRuntime?.flushUsage?.();
491
+ }
492
+ }
493
+
494
+ /** Apply generate()'s scoped wiring policy to non-generator execution paths. */
495
+ export async function withModelWiredLimit<T>(
496
+ model: WiredModelMemory,
497
+ run: () => Promise<T>,
498
+ ): Promise<T> {
499
+ if (!modelNeedsWiredLimit(model)) return run();
500
+ enterWiredScope();
501
+ try {
502
+ return await run();
503
+ } finally {
504
+ exitWiredScope();
505
+ }
506
+ }
507
+
508
+ /** Apply generate()'s usage safe-point to direct/non-generator paths. */
509
+ export async function withModelUsageFlush<T>(
510
+ model: WiredModelMemory,
511
+ run: () => Promise<T>,
512
+ ): Promise<T> {
513
+ try {
514
+ return await run();
515
+ } finally {
516
+ if (model.expertRuntime?.finishUsage)
517
+ await model.expertRuntime.finishUsage();
518
+ else
519
+ model.expertRuntime?.flushUsage?.();
520
+ }
521
+ }
522
+
523
+ /** Forward through either the ordinary synchronous model surface or a
524
+ * streamed-expert model whose layer execution must cross async I/O. Keeping
525
+ * this choice inside the shared generator preserves the complete generation
526
+ * contract (sampling, grammar, logprobs, prompt cache, and cancellation) for
527
+ * both execution styles. */
528
+ async function forwardHiddenForGeneration(
529
+ model: RuntimeModel,
530
+ ids: MlxArray,
531
+ cache: Cache[],
532
+ ): Promise<MlxArray> {
533
+ const asyncModel = model as RuntimeModel & {
534
+ forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
535
+ };
536
+ return typeof asyncModel.forwardHiddenAsync === "function"
537
+ ? await asyncModel.forwardHiddenAsync(ids, cache)
538
+ : model.forwardHidden(ids, cache);
539
+ }
540
+
298
541
  async function* generateInner(
299
542
  model: RuntimeModel,
300
543
  promptTokens: number[],
@@ -324,6 +567,11 @@ async function* generateInner(
324
567
  throw new Error(
325
568
  `pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
326
569
  );
570
+ // Paged KV (default off): swap fresh full-attention caches for paged
571
+ // storage BEFORE prefill. Capacity is exact — the deepest write lands at
572
+ // prompt + maxTokens − 1 (step maxTokens−1's forward), so this bound
573
+ // makes pool exhaustion an accounting-bug tripwire, not a request limit.
574
+ maybePageKv(cache, options, promptTokens.length + maxTokens);
327
575
  /** device-side token history (only maintained when processors need it) */
328
576
  let history: MlxArray | null = null;
329
577
 
@@ -341,15 +589,14 @@ async function* generateInner(
341
589
  if (e) for (const a of [e.sel, e.topIdx, e.topVals]) a?.dispose();
342
590
  };
343
591
  /** Read extras back to JS (forces eval — they were async-dispatched with the
344
- * token, so this normally just copies) and dispose the device arrays. */
592
+ * token) without appending casts behind the next decode step. */
345
593
  const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
346
594
  if (!e) return undefined;
347
595
  const out: TokenLogprobs = {};
348
- if (e.sel) out.logprob = e.sel.toFloat32()[0]!;
596
+ if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
349
597
  if (e.topIdx && e.topVals) {
350
- // uint32 ids read via the f32 cast: exact for ids < 2^24 (vocab ≪ 16M)
351
- const ids = e.topIdx.toFloat32();
352
- const vals = e.topVals.toFloat32();
598
+ const ids = e.topIdx.toIntTokens();
599
+ const vals = e.topVals.toFloat32Host();
353
600
  out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
354
601
  (a, b) => b.logprob - a.logprob,
355
602
  );
@@ -367,6 +614,10 @@ async function* generateInner(
367
614
  const V = logits3d.shape[2]!;
368
615
  let logits = ops.reshape(logits3d, [1, V]);
369
616
  for (const p of processors) logits = disposing(logits, p(history, logits));
617
+ // Grammar mask (src/grammar.ts): apply AFTER the standard processors so the
618
+ // grammar has the final say on validity (a -inf'd token stays -inf through
619
+ // log-softmax). Off (no grammar) = zero cost — the branch is unset.
620
+ if (options.grammar) logits = disposing(logits, options.grammar.applyMask(logits));
370
621
  const lp = toLogprobs(logits);
371
622
  logits.dispose();
372
623
  const tok = sampler(lp, step);
@@ -444,21 +695,65 @@ async function* generateInner(
444
695
  if (needsTokenHistory)
445
696
  history = ops.fromInt32(promptTokens, [promptTokens.length]);
446
697
  // e2b/e4b need the spliced token ids to build per-layer inputs
447
- // (image positions zeroed inside forwardEmbeddings).
698
+ // (multimodal soft-token positions zeroed inside forwardEmbeddings).
448
699
  const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
449
700
  h0 = model.forwardEmbeddings(
450
701
  options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
702
+ options.multimodalMask ?? null,
451
703
  );
452
704
  embedIds.dispose();
453
705
  } else {
454
706
  let pos = cachedTokens;
455
- while (promptTokens.length - pos > prefillChunkSize) {
456
- const chunk = promptTokens.slice(pos, pos + prefillChunkSize);
707
+ // Stable-boundary snapshot: prefill up to snapshotAt first (plain
708
+ // chunks, no logits), fire the hook while the caches hold exactly
709
+ // that prefix, then continue with the remainder below. Splitting here
710
+ // changes chunk shapes only for requests that opted in — the same
711
+ // numerics class as a prompt-cache-hit continuation prefill.
712
+ const snapAt = options.onPrefillDone && options.snapshotAt !== undefined
713
+ ? Math.min(Math.max(options.snapshotAt, cachedTokens), promptTokens.length)
714
+ : promptTokens.length;
715
+ if (snapAt > pos && snapAt < promptTokens.length) {
716
+ while (pos < snapAt) {
717
+ const chunk = promptTokens.slice(pos, Math.min(pos + prefillChunkSize, snapAt));
718
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
719
+ const h = await forwardHiddenForGeneration(model, ids, cache);
720
+ ids.dispose();
721
+ h.dispose();
722
+ evalCacheState(cache);
723
+ maybeQuantizeKv(cache, options);
724
+ clearCache();
725
+ pos += chunk.length;
726
+ // Macrotask yield between chunks (runtime-isolation.md Phase 1):
727
+ // each chunk is a synchronous multi-hundred-ms FFI eval; without
728
+ // this the event loop serves no I/O for the WHOLE prefill.
729
+ await new Promise<void>((r) => setImmediate(r));
730
+ }
731
+ options.onPrefillDone?.();
732
+ }
733
+ // Oracle prefill convention (mlx-lm 0.31.3 generate_step,
734
+ // generate.py:430-453): drain the prompt only to len-1 — chunks of
735
+ // min(prefillChunkSize, remaining-1) — then compute step-0 logits from
736
+ // a SEPARATE L=1 forward of the LAST prompt token (its server's
737
+ // batched engine shares the convention: insert_segments' forced final
738
+ // 1-token segment + GenerationBatch._step, generate.py:1645/1327).
739
+ // Forwarding the last token as the tail of an L=n GEMM instead is
740
+ // ulp-different in bf16 (qmm-vs-qmv + L-dependent SDPA dispatch) in
741
+ // BOTH the step-0 logits and that token's stored KV — near-tie greedy
742
+ // streams flip (2026-07-07 12B completion-probe ✗: first token flip at
743
+ // step 24). MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old
744
+ // full-final-chunk convention (A/B lever + kill switch).
745
+ const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
746
+ const drainGate = tailSplit ? 1 : prefillChunkSize;
747
+ while (promptTokens.length - pos > drainGate) {
748
+ const n = tailSplit
749
+ ? Math.min(prefillChunkSize, promptTokens.length - pos - 1)
750
+ : prefillChunkSize;
751
+ const chunk = promptTokens.slice(pos, pos + n);
457
752
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
458
- const h = model.forwardHidden(ids, cache);
753
+ const h = await forwardHiddenForGeneration(model, ids, cache);
459
754
  ids.dispose();
460
755
  h.dispose(); // logits never computed for non-final chunks
461
- ops.evalAll(cache.flatMap((c) => c.state()));
756
+ evalCacheState(cache);
462
757
  maybeQuantizeKv(cache, options);
463
758
  // mlx-lm _prefill clears the allocator cache after every chunk;
464
759
  // without this, prefill transients pile up in the buffer cache
@@ -467,16 +762,26 @@ async function* generateInner(
467
762
  // measured, scripts/decode-split.ts; the context-scaling decode
468
763
  // gap's main term).
469
764
  clearCache();
470
- pos += prefillChunkSize;
765
+ pos += n;
766
+ // Macrotask yield between chunks (runtime-isolation.md Phase 1).
767
+ await new Promise<void>((r) => setImmediate(r));
471
768
  }
472
769
  if (needsTokenHistory) {
473
770
  history = ops.fromInt32(promptTokens, [promptTokens.length]);
474
771
  }
772
+ // Under tailSplit this is exactly [promptTokens[len-1]] — the L=1
773
+ // step-0 forward (uncompiled L=1 is bit-exact with compiled decode,
774
+ // tests/compiled-decode.test.ts). The last prompt token's KV enters
775
+ // the cache HERE, so downstream bookkeeping (forwarded[], cacheTokens,
776
+ // PromptCache.put alignment) is unchanged: after step 0 the caches
777
+ // cover exactly the prompt, as before.
475
778
  const lastChunk = promptTokens.slice(pos);
476
779
  const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
477
- h0 = model.forwardHidden(ids0, cache);
780
+ h0 = await forwardHiddenForGeneration(model, ids0, cache);
478
781
  ids0.dispose();
479
782
  }
783
+ if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
784
+ options.onPrefillDone?.();
480
785
  const [, L0, H] = h0.shape as [number, number, number];
481
786
  const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
482
787
  h0.dispose();
@@ -508,30 +813,108 @@ async function* generateInner(
508
813
  // 0.6.0, and shapeless replay re-infers the whole tape whenever the
509
814
  // growing attention windows change shape (= every step). Remove this
510
815
  // when upstream implements GatherQMM::output_shapes.
511
- // MLX_BUN_FUSED_DECODE=1 (the N-tiled fused decode experiment) cannot
512
- // compose with compiled decode: the tile loop's bounds bake at trace-time
513
- // N, so growing quantized caches silently stop attending their newest
514
- // rows under shapeless replay (kernel-perf-review 2026-07; reproduced on
515
- // e4b). The explicit opt-in wins over the default optimization — same
516
- // policy as LoRA and MoE below. quantizedSdpa also throws on the combo
517
- // as a backstop.
518
816
  let compiled =
519
- process.env.MLX_BUN_COMPILED_DECODE !== "0" &&
520
- process.env.MLX_BUN_FUSED_DECODE !== "1" &&
817
+ flagOn("MLX_BUN_COMPILED_DECODE", true) &&
521
818
  !options.adapters?.length &&
819
+ // Paged caches can't compile (data-dependent block-list length —
820
+ // the shapeless-replay hazard; CompiledDecode.supports() also
821
+ // excludes them per step, this just skips the setup).
822
+ !options.pagedKv &&
522
823
  model.config.modelType.startsWith("gemma4") &&
523
824
  !model.config.text.enableMoeBlock
524
825
  ? CompiledDecode.for(model as Gemma4Model)
525
826
  : null;
526
827
  let stop = false;
828
+ /** Token id read eagerly at the top of the loop for grammar (reused for
829
+ * the yield, avoiding a second readback). -1 when grammar is off — the
830
+ * pipelined path keeps its deferred itemUint32 below. */
831
+ let grammarTok = -1;
832
+ // Jump-forward decoding (opt-in, MLX_BUN_GRAMMAR_JUMP=1; serial lane
833
+ // only — the batch lane's #stepGrammar doesn't jump yet): when the
834
+ // grammar forces a unique continuation, emit its retokenized ids without
835
+ // per-token forwards — ONE multi-token forward carries them into the KV
836
+ // (see GrammarController.jumpForward for the contract + the fidelity
837
+ // note on why this is opt-in). Excluded when logprobs are requested
838
+ // (jumped tokens are never sampled, so they'd have no logprobs rows).
839
+ const grammarJump = shouldUseGrammarJump(options);
527
840
  while (!stop) {
528
841
  const cur = pending!;
529
842
  const curExtras = pendingExtras;
530
843
  pendingExtras = null;
844
+ // Grammar advance (src/grammar.ts): acceptToken needs the token id as a
845
+ // JS number, which the pipelined loop defers (it operates on device
846
+ // arrays). So grammar requests eager-read cur here, advance the matcher,
847
+ // and await the async mask precompute — which overlaps the GPU forward
848
+ // dispatched just below. This trades the readback/forward overlap for
849
+ // correctness (the mask for step n+1 must reflect token n). Non-grammar
850
+ // requests keep the fast pipelined loop untouched.
851
+ //
852
+ // F1 fix (batched-lane plan): always eager-read `grammarTok` when
853
+ // grammar is on, even at the max_tokens boundary — the emitted token
854
+ // reuses it unconditionally. The OLD code gated the READ on
855
+ // generated+1 < maxTokens but emitted grammarTok regardless, so the LAST
856
+ // iteration skipped the refresh and emitted a stale/garbage token (the
857
+ // previous step's token, or -1 when max_tokens=1) → truncated JSON ended
858
+ // on a corrupted token + cacheTokens recorded the wrong id. Now only
859
+ // accept()/ready() (which prepare the NEXT step's mask) are gated.
860
+ /** Forced ids to emit after cur this iteration (jump-forward), else null. */
861
+ let jumpEmit: number[] | null = null;
862
+ if (options.grammar) {
863
+ grammarTok = ops.itemUint32(cur);
864
+ if (generated + 1 < maxTokens) {
865
+ options.grammar.accept(grammarTok);
866
+ await options.grammar.ready();
867
+ if (grammarJump && !options.grammar.isTerminated) {
868
+ jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
869
+ // jumpForward advanced the matcher and fired the post-jump mask
870
+ // fill; it must be ready before this iteration's sampleStep.
871
+ if (jumpEmit) await options.grammar.ready();
872
+ }
873
+ }
874
+ }
531
875
  // build step n+1's graph from the *unread* pending token
532
876
  nextPending = null;
533
877
  nextExtras = null;
534
- if (generated + 1 < maxTokens) {
878
+ // When the grammar has terminated (a complete valid JSON/schema
879
+ // accepted), there are no valid tokens left — skip building the next
880
+ // step so the sampler never sees an all--inf distribution.
881
+ if (jumpEmit) {
882
+ // JUMP iteration: one [1, 1+m] forward carries cur AND the forced ids
883
+ // into the KV (they are all committed content — jumpForward's
884
+ // contract); the next sampled token, if the budget and grammar allow
885
+ // one, comes from its last position. Compiled decode resumes on the
886
+ // following iteration (supports() re-checks the grown caches).
887
+ maybeQuantizeKv(cache, options);
888
+ pushHistory(cur);
889
+ if (needsTokenHistory) {
890
+ const jt = ops.fromInt32(jumpEmit, [jumpEmit.length]);
891
+ const prev = history!;
892
+ history = ops.concatAxis([prev, jt], 0);
893
+ prev.dispose();
894
+ jt.dispose();
895
+ }
896
+ const chunk = [grammarTok, ...jumpEmit];
897
+ const ids = ops.fromInt32(chunk, [1, chunk.length]);
898
+ const h = await forwardHiddenForGeneration(model, ids, cache);
899
+ ids.dispose();
900
+ // Every chunk token's KV is in the cache regardless of what follows.
901
+ forwarded.push(...chunk);
902
+ const willGen = generated + 1 + jumpEmit.length;
903
+ if (willGen < maxTokens && !options.grammar!.isTerminated) {
904
+ const [, Lj, Hj] = h.shape as [number, number, number];
905
+ const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
906
+ h.dispose();
907
+ const logits = model.logitsFromHidden(hLast);
908
+ hLast.dispose();
909
+ const sn = sampleStep(logits, willGen);
910
+ nextPending = sn.tok;
911
+ nextExtras = sn.extras;
912
+ logits.dispose();
913
+ ops.asyncEvalAll([nextPending, ...extrasArrays(nextExtras)]);
914
+ } else {
915
+ h.dispose(); // burst ends the generation (max_tokens or grammar done)
916
+ }
917
+ } else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
535
918
  maybeQuantizeKv(cache, options);
536
919
  pushHistory(cur);
537
920
  let logits: MlxArray | null = null;
@@ -553,7 +936,7 @@ async function* generateInner(
553
936
  }
554
937
  if (!logits) {
555
938
  const ids = ops.reshape(cur, [1, 1]);
556
- const h = model.forwardHidden(ids, cache);
939
+ const h = await forwardHiddenForGeneration(model, ids, cache);
557
940
  ids.dispose();
558
941
  logits = model.logitsFromHidden(h);
559
942
  h.dispose();
@@ -566,7 +949,9 @@ async function* generateInner(
566
949
  }
567
950
 
568
951
  // sync-read step n's token while n+1 computes
569
- const token = ops.itemUint32(cur);
952
+ // sync-read step n's token while n+1 computes (grammar already read it
953
+ // eagerly above — reuse to avoid a second GPU sync)
954
+ const token = options.grammar ? grammarTok : ops.itemUint32(cur);
570
955
  if (generated === 0) {
571
956
  // first token arrived: prompt clock stops, decode clock starts
572
957
  // (mlx-lm stream_generate's n==0 clock swap; the first token is
@@ -578,7 +963,8 @@ async function* generateInner(
578
963
  pending = null;
579
964
  generated++;
580
965
  // if a next-step graph was built, this token's KV entered the cache
581
- if (nextPending !== null) forwarded.push(token);
966
+ // (jump iterations pushed the whole chunk already)
967
+ if (nextPending !== null && !jumpEmit) forwarded.push(token);
582
968
 
583
969
  if (eosTokenIds.includes(token)) {
584
970
  disposeExtras(curExtras);
@@ -595,6 +981,18 @@ async function* generateInner(
595
981
  // mlx-lm generate_step: clear_cache after token 0 (drops the
596
982
  // remaining prefill transients) and every 256 tokens after
597
983
  if ((generated - 1) % 256 === 0) clearCache();
984
+ // Jump-forward burst: the forced ids follow cur, one yield each (the
985
+ // consumer's stop-sequence matcher and detokenizer see the same
986
+ // one-at-a-time stream shape as always). Their KV is already in the
987
+ // cache (the chunk forward above); a consumer break mid-burst is
988
+ // safe — `forwarded` already reflects the cache exactly.
989
+ if (jumpEmit) {
990
+ for (const jt of jumpEmit) {
991
+ generated++;
992
+ yield { token: jt, index: generated - 1 };
993
+ if ((generated - 1) % 256 === 0) clearCache();
994
+ }
995
+ }
598
996
  if (nextPending === null) {
599
997
  stop = true;
600
998
  } else {
@@ -619,6 +1017,11 @@ async function* generateInner(
619
1017
  disposeExtras(nextExtras);
620
1018
  }
621
1019
  if (ownsCache) for (const c of cache) c.dispose();
1020
+ // The grammar controller owns native WASM state (GrammarMatcher +
1021
+ // TokenizerInfo cache) — dispose on every exit path (normal, early break,
1022
+ // throw). TokenizerInfo itself is process-cached (vocab-structural), so
1023
+ // only the per-request matcher/compiled are freed here.
1024
+ options.grammar?.dispose();
622
1025
  history?.dispose();
623
1026
  if (!finished && !threw) {
624
1027
  // forced early return (consumer break at a yield): still report