mlx-bun 0.2.0 → 0.3.0

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 (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
package/src/generate.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  // - decode pipelining via mx.async_eval: step n+1's graph is built and
5
5
  // dispatched before step n's token is read back, so the GPU never idles
6
6
  // on the JS round-trip
7
+ // - opt-in early token-zero yield reduces latency before that pipeline starts
7
8
  // - sampling stays on-device; only the chosen token id crosses to JS
8
9
 
10
+ import { appendFileSync } from "node:fs";
9
11
  import { MlxArray, gpuStream } from "./mlx/array";
10
12
  import {
11
13
  activeMemory,
@@ -16,16 +18,25 @@ import {
16
18
  synchronize,
17
19
  } from "./mlx/ffi";
18
20
  import * as ops from "./mlx/ops";
19
- import { CompiledDecode } from "./model/compiled-decode";
20
- import { Gemma4Model, KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
21
- import { PagedKVCache } from "./model/paged-kv";
21
+ import { KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
22
+ import { PagedKVCache } from "./lab/paged-kv/paged-kv";
22
23
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
23
- import { diffusionGenerate } from "./diffusion/diffusion-generate";
24
+ import { denoiseAsync } from "./diffusion/diffusion-generate";
25
+ import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
24
26
  import type { RuntimeModel } from "./model/factory";
27
+ import {
28
+ assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
29
+ type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
30
+ } from "./backends/mlx/autoregressive";
31
+ import { nextPrefillStep } from "./inference/prefill";
32
+ import { evalCacheState, executeMlxPrefillStep } from "./backends/mlx/prefill";
33
+ import { appendFillHidden } from "./backends/mlx/fill-append";
34
+ export { evalCacheState } from "./backends/mlx/prefill";
35
+ import type { TokenLogprobs } from "./contracts/generation";
36
+ export type { TokenLogprobs } from "./contracts/generation";
25
37
  import type { PromptResponseTrace } from "./serve/prompt-response-trace";
26
38
  import type { KvQuantSpec, TurboQuantScheme } from "./config";
27
- import { flagOn } from "./flags";
28
- import { runtimeValue } from "./runtime-config";
39
+ import { runtimeConfig, runtimeValue, withRuntimeConfig, type RuntimeConfig } from "./runtime-config";
29
40
  import {
30
41
  disposeStepExtras,
31
42
  makeStepSampler,
@@ -35,11 +46,32 @@ import {
35
46
  type StepExtras,
36
47
  } from "./sampler";
37
48
  import type { GrammarController } from "./grammar";
49
+ import {
50
+ fillTraceEnabled,
51
+ fillTracePath,
52
+ type FillTraceRecord,
53
+ resolveFillMode,
54
+ type FillSession,
55
+ type FillStats,
56
+ type Proposal,
57
+ } from "./fill/fill-session";
38
58
 
39
59
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
60
+ /** RAM budget for a model-owned paired target/draft prompt snapshot. */
61
+ speculativeCacheBytes?: number;
62
+ /** Resolved host policy. Direct compatibility calls resolve from their
63
+ * captured binding when this is absent. */
64
+ decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
65
+ /** Cooperatively cancel at prefill/decode boundaries; pending native work
66
+ * finishes before owned resources are released. */
67
+ signal?: AbortSignal;
40
68
  maxTokens?: number;
41
69
  eosTokenIds?: number[];
42
70
  prefillChunkSize?: number;
71
+ /** Whether the request supplied its seed explicitly. Durable generation
72
+ * checkpoints use this to distinguish a reproducible user policy from the
73
+ * server-generated seed that must be recovered from checkpoint metadata. */
74
+ seedWasExplicit?: boolean;
43
75
  /** Fired once during prefill, at `snapshotAt` tokens (default: the full
44
76
  * prompt) and BEFORE any further KV is written — the one moment the
45
77
  * caches hold exactly that prefix (post-wrap rings can't be rewound
@@ -51,6 +83,25 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
51
83
  * there would make the entry untrimmable-and-divergent). Prefill is
52
84
  * split at this index when it falls mid-prompt. */
53
85
  snapshotAt?: number;
86
+ /** Resume an interrupted autoregressive generation from a durable KV
87
+ * checkpoint. `cache` must cover every token in `promptTokens` exactly;
88
+ * `initialPendingToken` is the already-sampled next token, so no cache
89
+ * rewind is required (important for recurrent/untrimmable layers). */
90
+ initialPendingToken?: number;
91
+ /** Number of completion tokens already emitted before this resumed run. */
92
+ initialGeneratedTokens?: number;
93
+ /** Original request prompt length used for protocol usage accounting when
94
+ * `promptTokens` also contains replayed completion tokens. */
95
+ originalPromptTokens?: number;
96
+ /** Periodic, safe decode-boundary checkpoint hook. The cache covers
97
+ * `cacheTokens`; `pendingToken` has been sampled but not emitted. */
98
+ checkpointEveryTokens?: number;
99
+ onDecodeCheckpoint?: (state: {
100
+ cacheTokens: number[];
101
+ caches: Cache[];
102
+ generatedTokens: number;
103
+ pendingToken: number;
104
+ }) => void | Promise<void>;
54
105
  /** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
55
106
  * prompt tokens are treated as already prefilled; only the suffix is
56
107
  * forwarded. Caller keeps ownership — generate() will not dispose. */
@@ -61,7 +112,7 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
61
112
  promptEmbeddings?: MlxArray;
62
113
  /** bool [L] marking image tokens (bidirectional attention among them).
63
114
  * MUST be unset when the prompt contains any audio — audio prompts run
64
- * fully causal (audio-input-plan.md §3.3 Q1). */
115
+ * fully causal (docs/design/generic-model-support.md §3.3 Q1). */
65
116
  imageMask?: MlxArray;
66
117
  /** bool [L] marking ALL multimodal soft tokens (image | audio) for
67
118
  * per-layer-input id zeroing (e2b/e4b), decoupled from imageMask so
@@ -82,7 +133,7 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
82
133
  /** Convert once a cache's offset reaches this (uniform-kvBits default
83
134
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
84
135
  quantizedKvStart?: number;
85
- /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
136
+ /** TurboQuant scheme (docs/design/turboquant.md): rotation-based KV
86
137
  * quantization, a CLI-only runtime lever in the same class as uniform
87
138
  * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
88
139
  * checks kvBits/kvConfig first, so set at most one). Full-attention
@@ -90,7 +141,7 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
90
141
  * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
91
142
  * warning names the limitation, never a throw. */
92
143
  turboQuant?: TurboQuantScheme;
93
- /** OPTIONAL paged KV storage (docs/design/paged-kv-cache.md): fresh
144
+ /** OPTIONAL paged KV storage (docs/design/kv-cache.md): fresh
94
145
  * full-attention KVCache layers are replaced with PagedKVCache (block
95
146
  * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
96
147
  * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
@@ -123,45 +174,60 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
123
174
  * advances the matcher, and awaits the async mask precompute (which overlaps
124
175
  * the GPU forward). Non-grammar requests keep the fast pipelined loop. */
125
176
  grammar?: GrammarController;
177
+ /** Token fast-forwarding (src/fill/, docs/design/speculative-decoding.md
178
+ * "Token fast-forwarding"): a per-request table of DETERMINED token spans
179
+ * (compiled from the request's `tools` + the chat template). When the
180
+ * stream enters one, the engine appends the whole span itself with ONE
181
+ * multi-token forward and resumes sampling after it.
182
+ *
183
+ * This is context extension, NOT speculation: nothing is drafted, verified,
184
+ * or rolled back, and no comparison is made against what the model would
185
+ * have produced. Injection bypasses the sampler, which is a documented
186
+ * behavior-policy deviation at temperature > 0 — hence opt-in
187
+ * (MLX_BUN_FILL=strict) and the composition refusals in shouldUseFill.
188
+ * Serial lane only: the batch scheduler and the spec loop never read it. */
189
+ fill?: FillSession;
126
190
  }
127
191
 
128
192
  export function shouldUseGrammarJump(
129
193
  options: Pick<GenerateOptions, "grammar" | "logprobs" | "topLogprobs">,
194
+ runtime: RuntimeConfig = runtimeConfig(),
130
195
  ): boolean {
131
196
  return options.grammar !== undefined &&
132
- flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
197
+ runtime.flag("MLX_BUN_GRAMMAR_JUMP", false) &&
133
198
  !options.logprobs &&
134
199
  !(options.topLogprobs && options.topLogprobs > 0);
135
200
  }
136
201
 
137
- /** Per-token logprob info (only present when GenerateOptions requested it). */
138
- export interface TokenLogprobs {
139
- /** logprob of the emitted token (requires options.logprobs). */
140
- logprob?: number;
141
- /** top-k (id, logprob) pairs, sorted by logprob descending (requires
142
- * options.topLogprobs > 0). mlx-lm leaves argpartition order unspecified;
143
- * sorting is the deterministic reading of the same set. */
144
- top?: { id: number; logprob: number }[];
145
- }
146
-
147
- /** Materialize every cache's state at a prefill chunk boundary. Unlike
148
- * KVCache/QuantizedKVCache/RotatingKVCache, TurboQuantKVCache.state()
149
- * allocates FRESH trimmed slice views on every call (required by its
150
- * snapshotCache/cloneKvCaches callers in kv-store.ts, which already
151
- * dispose them) rather than returning its own live-owned arrays — so
152
- * this chokepoint must dispose that cache kind's state() output itself,
153
- * or the views leak (unreferenced past this call, only reclaimed by the
154
- * FinalizationRegistry backstop on GC of the tiny JS wrapper). */
155
- export function evalCacheState(cache: Cache[]): void {
156
- const turboState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? c.state() : []));
157
- try {
158
- const liveState = cache.flatMap((c) => (c instanceof TurboQuantKVCache ? [] : c.state()));
159
- ops.evalAll([...turboState, ...liveState]);
160
- } finally {
161
- // Disposed even when evalAll throws (Metal OOM / deferred graph error) —
162
- // the whole reason this chokepoint exists is that these views are owned.
163
- for (const a of turboState) a.dispose();
164
- }
202
+ /** Composition gate for token fast-forwarding, enforced inside the engine
203
+ * (the serve layer refuses more shapes it alone can see — a user-fixed seed,
204
+ * a mounted draft model, continuous placement: src/serve/request-prep.ts,
205
+ * src/serve/chat-stage.ts). Throws when MLX_BUN_FILL names the unbuilt echo
206
+ * tier — an operator who asked for K3c must not get K3b silently.
207
+ * - grammar: forced-token production is the grammar's job; two producers in
208
+ * one iteration is forbidden (asserted in the loop).
209
+ * - logprobs/top_logprobs: injected tokens are never sampled, so they have
210
+ * no distribution row (same rule as shouldUseGrammarJump).
211
+ * - promptEmbeddings: vision/audio prompts (and mRoPE) — untested shape.
212
+ * - kvBits/kvConfig/turboQuant: post-conversion multi-token append is
213
+ * L-generic but unvalidated; refuse in v1 rather than pay it silently. */
214
+ export function shouldUseFill(
215
+ options: Pick<
216
+ GenerateOptions,
217
+ "fill" | "grammar" | "logprobs" | "topLogprobs" | "promptEmbeddings"
218
+ | "kvBits" | "kvConfig" | "turboQuant"
219
+ >,
220
+ runtime: RuntimeConfig = runtimeConfig(),
221
+ ): boolean {
222
+ if (!options.fill) return false;
223
+ if (resolveFillMode(runtime.value("MLX_BUN_FILL") ?? "off") === "off") return false;
224
+ return options.grammar === undefined &&
225
+ !options.logprobs &&
226
+ !(options.topLogprobs && options.topLogprobs > 0) &&
227
+ options.promptEmbeddings === undefined &&
228
+ !options.kvBits &&
229
+ !options.kvConfig?.length &&
230
+ options.turboQuant === undefined;
165
231
  }
166
232
 
167
233
  /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
@@ -180,7 +246,7 @@ export function evalCacheState(cache: Cache[]): void {
180
246
  * layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
181
247
  * the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
182
248
  * 24 GB Mac). Numerics untouched: same quantize math, only the eval
183
- * ordering is forced (tests/kv-quant.test.ts, tests/rotating-kvq.test.ts). */
249
+ * ordering is forced (tests/parity/kv-quant.test.ts, tests/parity/rotating-kvq.test.ts). */
184
250
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
185
251
  const { kvBits, kvConfig, turboQuant } = options;
186
252
  if (turboQuant) {
@@ -215,7 +281,7 @@ export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void
215
281
  }
216
282
  }
217
283
 
218
- /** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
284
+ /** Paged-KV conversion (docs/design/kv-cache.md): swap each FRESH
219
285
  * plain full-attention KVCache for a PagedKVCache sized to hold
220
286
  * `capacityTokens` (prompt + maxTokens — known exactly at generate()
221
287
  * setup, so pool exhaustion is unreachable absent an accounting bug).
@@ -241,11 +307,29 @@ export function maybePageKv(
241
307
  }
242
308
 
243
309
  /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
244
- * documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
310
+ * documented v1 non-goal (docs/design/turboquant.md) — they stay bf16
245
311
  * rather than throwing, so mixed full-attention/sliding-window models (e.g.
246
312
  * Gemma) still serve correctly under --kv-quant turbo. */
247
313
  let warnedTurboRotating = false;
248
314
 
315
+ /** Emitted once per process: token fast-forwarding skips models with
316
+ * sliding-window layers in v1 (see the gate in generateInner). */
317
+ let warnedFillRotating = false;
318
+
319
+ /** The rewind surface a verify-policy fill needs. `Cache` declares the
320
+ * spec-round trio as optional members, but the cache list is a UNION with
321
+ * GLM's MLACache (which declares none of them), so the union has no such
322
+ * property to read. One narrowing helper keeps the call sites honest — every
323
+ * use is still optional-chained. */
324
+ type RewindableCache = {
325
+ isTrimmable(): boolean;
326
+ trim(n: number, bypass?: boolean): void;
327
+ specRoundBegin?(): void;
328
+ specRoundCommit?(): void;
329
+ specRoundRollback?(keep: number): void;
330
+ };
331
+ const rewindable = (c: unknown): RewindableCache => c as RewindableCache;
332
+
249
333
  /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
250
334
  * above): only plain full-attention KVCache instances convert, via
251
335
  * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
@@ -258,7 +342,7 @@ function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: n
258
342
  warnedTurboRotating = true;
259
343
  console.warn(
260
344
  "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
261
- "(full-attention only) — docs/design/turboquant-kv.md.",
345
+ "(full-attention only) — docs/design/turboquant.md.",
262
346
  );
263
347
  }
264
348
  continue;
@@ -281,6 +365,8 @@ function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: n
281
365
 
282
366
  export interface GenerateStats {
283
367
  promptTokens: number;
368
+ /** An observed terminal cause takes precedence over the token-count limit. */
369
+ finishReason?: "stop" | "length";
284
370
  /** Prompt tokens skipped via a pre-warmed cache. */
285
371
  cachedTokens: number;
286
372
  generatedTokens: number;
@@ -302,6 +388,9 @@ export interface GenerateStats {
302
388
  rejected?: number; rounds?: number; acceptanceLengths?: number[];
303
389
  tokensPerForward?: number; forwardsSaved?: number;
304
390
  };
391
+ /** Token fast-forwarding telemetry (serial lane, MLX_BUN_FILL=strict).
392
+ * Present only when a fill table was actually armed for this generation. */
393
+ fill?: FillStats;
305
394
  }
306
395
 
307
396
  export interface GenerateDiagnostics {
@@ -318,16 +407,23 @@ export interface GeneratedToken {
318
407
 
319
408
  export class Generation implements AsyncIterable<GeneratedToken> {
320
409
  stats: GenerateStats | null = null;
321
- readonly #iter: AsyncGenerator<GeneratedToken, GenerateStats>;
410
+ readonly #next: () => Promise<IteratorResult<GeneratedToken, GenerateStats>>;
411
+ readonly #return: () => Promise<IteratorResult<GeneratedToken, GenerateStats>>;
322
412
 
323
- constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>) {
324
- this.#iter = iter;
413
+ constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>, runtime = runtimeConfig()) {
414
+ // Async generators resume in the caller's context on every next/return.
415
+ // Bind both operations so consumer awaits and early close cannot change
416
+ // the runtime snapshot seen by kernels or their cleanup.
417
+ const next = iter.next.bind(iter);
418
+ const close = () => iter.return(undefined as unknown as GenerateStats);
419
+ this.#next = () => withRuntimeConfig(runtime, next);
420
+ this.#return = () => withRuntimeConfig(runtime, close);
325
421
  }
326
422
 
327
423
  async *[Symbol.asyncIterator](): AsyncGenerator<GeneratedToken> {
328
424
  try {
329
425
  while (true) {
330
- const r = await this.#iter.next();
426
+ const r = await this.#next();
331
427
  if (r.done) {
332
428
  this.stats = r.value;
333
429
  return;
@@ -340,7 +436,7 @@ export class Generation implements AsyncIterable<GeneratedToken> {
340
436
  // disposal, wired/adapter scopes) and capture the stats its
341
437
  // early-return path still reports.
342
438
  if (this.stats === null) {
343
- const r = await this.#iter.return(undefined as unknown as GenerateStats);
439
+ const r = await this.#return();
344
440
  if (r.done && r.value) this.stats = r.value;
345
441
  }
346
442
  }
@@ -367,14 +463,7 @@ const WIRE_THRESHOLD = 0.5;
367
463
  let wiredScopeDepth = 0;
368
464
  let wiredOldLimit = 0;
369
465
 
370
- type WiredModelMemory = {
371
- readonly weightsBytes: number;
372
- readonly expertRuntime?: {
373
- readonly plan: { readonly plannedBytes: number };
374
- flushUsage?: () => void;
375
- finishUsage?: () => Promise<void>;
376
- } | null;
377
- };
466
+ type WiredModelMemory = MlxModelMemory;
378
467
 
379
468
  /** Bytes whose MLX graph must stay resident while a model executes. */
380
469
  export function wiredWorkingSetBytes(model: WiredModelMemory): number {
@@ -422,19 +511,59 @@ export function generate(
422
511
  options: GenerateOptions = {},
423
512
  diagnostics: GenerateDiagnostics = {},
424
513
  ): Generation {
514
+ return bindGeneration(model)(promptTokens, options, diagnostics);
515
+ }
516
+
517
+ /** Resolve the compatibility model once at host construction. Every request
518
+ * borrows the same binding and immutable runtime snapshot. */
519
+ export function bindGeneration(model: RuntimeModel): (
520
+ promptTokens: number[], options?: GenerateOptions, diagnostics?: GenerateDiagnostics,
521
+ ) => Generation {
425
522
  // DiffusionGemma is non-autoregressive: route to the denoising engine instead
426
523
  // of the AR decode loop. Same Generation/GenerateStats contract so the CLI and
427
524
  // server stream it through the existing token machinery.
428
- let inner =
429
- model instanceof DiffusionGemmaModel
430
- ? generateDiffusionInner(model, promptTokens, options)
431
- : generateInner(model, promptTokens, options, diagnostics);
432
- if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
433
- const memoryModel: WiredModelMemory = model;
434
- if (memoryModel.expertRuntime?.flushUsage)
435
- inner = usageScoped(memoryModel, inner);
436
- const wire = modelNeedsWiredLimit(model);
437
- return new Generation(wire ? wiredScoped(inner) : inner);
525
+ if (!(model instanceof DiffusionGemmaModel)) {
526
+ const binding = bindLegacyAutoregressiveModel(model);
527
+ return (prompt, options, diagnostics) => generateAutoregressive(binding, prompt, options, diagnostics);
528
+ }
529
+ const binding = bindLegacyDenoisingModel(model);
530
+ return (prompt, options) => generateDenoising(binding, prompt, options);
531
+ }
532
+
533
+ /** Execute a supplied backend binding through the same AR loop and resource
534
+ * scopes as generate(). Callers serialize shared model/adapter access. Replacing
535
+ * this binding replaces graph, cache construction, media, and optional decode
536
+ * together; it never borrows another model's compiled execution implicitly. */
537
+ export function generateAutoregressive(
538
+ binding: MlxAutoregressiveBinding,
539
+ promptTokens: number[],
540
+ options: GenerateOptions = {},
541
+ diagnostics: GenerateDiagnostics = {},
542
+ ): Generation {
543
+ const runtime = binding.runtime ?? runtimeConfig();
544
+ let inner = generateInner(binding, promptTokens, options, diagnostics, runtime);
545
+ if (options.adapters?.length && binding.adapters) {
546
+ inner = adapterScoped({ loraState: binding.adapters }, options.adapters, inner);
547
+ }
548
+ if (binding.memory.expertRuntime?.finishUsage || binding.memory.expertRuntime?.flushUsage)
549
+ inner = usageScoped(binding.memory, inner);
550
+ return new Generation(modelNeedsWiredLimit(binding.memory, undefined,
551
+ runtime.value("MLX_BUN_FORCE_WIRE") === "1") ? wiredScoped(inner) : inner, runtime);
552
+ }
553
+
554
+ /** Denoising bindings use their own state and feedback graph. Callers hold
555
+ * the native runtime's exclusive generation lease, including its global RNG. */
556
+ export function generateDenoising<State>(
557
+ binding: MlxDenoisingBinding<State>, promptTokens: number[], options: GenerateOptions = {},
558
+ ): Generation {
559
+ let inner = generateDiffusionInner(binding, promptTokens, options);
560
+ if (options.adapters?.length && binding.adapters)
561
+ inner = adapterScoped({ loraState: binding.adapters }, options.adapters, inner);
562
+ if (binding.memory.expertRuntime?.finishUsage || binding.memory.expertRuntime?.flushUsage)
563
+ inner = usageScoped(binding.memory, inner);
564
+ const runtime = binding.runtime ?? runtimeConfig();
565
+ return new Generation(modelNeedsWiredLimit(binding.memory, undefined,
566
+ runtime.value("MLX_BUN_FORCE_WIRE") === "1") ? wiredScoped(inner) : inner, runtime);
438
567
  }
439
568
 
440
569
  /** Non-autoregressive diffusion generation, adapted to the AR Generation
@@ -442,11 +571,12 @@ export function generate(
442
571
  * streams the emitted tokens. v1: greedy (temperature 0, confidence-threshold
443
572
  * sampler — the OptiQ default); per-block intra-stream + temperature>0
444
573
  * (categorical) are follow-ups. */
445
- async function* generateDiffusionInner(
446
- model: DiffusionGemmaModel,
574
+ async function* generateDiffusionInner<State>(
575
+ binding: MlxDenoisingBinding<State>,
447
576
  promptTokens: number[],
448
577
  options: GenerateOptions,
449
578
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
579
+ options.signal?.throwIfAborted();
450
580
  const t0 = performance.now();
451
581
  const maxTokens = options.maxTokens ?? 256;
452
582
  // A fresh random canvas seed per request unless the caller pins one.
@@ -456,40 +586,52 @@ async function* generateDiffusionInner(
456
586
  : BigInt(Math.floor(Math.random() * 0x7fffffff));
457
587
  // The shipped checkpoint's tokenizer stops on {1, 106}; union any caller eos.
458
588
  const eos = [...new Set([1, 106, ...(options.eosTokenIds ?? [])])];
459
- const result = diffusionGenerate(model, promptTokens, {
589
+ const result = await denoiseAsync(binding.graph, promptTokens, {
460
590
  maxTokens,
461
591
  sampler: "confidence-threshold",
462
592
  temperature: 0,
463
593
  eosTokenIds: eos,
464
594
  seed,
465
595
  visionPixels: options.visionPixels,
466
- });
596
+ }, options.signal);
467
597
  const decodeMs = performance.now() - t0;
468
598
  let index = 0;
469
- for (const token of result.tokens) yield { token, index: index++ };
470
- return {
471
- promptTokens: promptTokens.length,
472
- cachedTokens: 0,
473
- generatedTokens: result.tokens.length,
474
- prefillTps: 0,
475
- decodeTps: result.tokens.length / Math.max(decodeMs / 1000, 1e-9),
476
- prefillMs: 0,
477
- decodeMs,
478
- cacheTokens: [],
479
- };
599
+ let failure: { error: unknown } | undefined;
600
+ try {
601
+ for (const token of result.tokens) {
602
+ options.signal?.throwIfAborted();
603
+ yield { token, index: index++ };
604
+ }
605
+ } catch (error) { failure = { error }; }
606
+ finally {
607
+ // A stop parser may close delivery before the finished canvas is exhausted.
608
+ // Settle the emitted count on return(), without suppressing cancellation/errors.
609
+ if (failure) throw failure.error;
610
+ return {
611
+ promptTokens: promptTokens.length,
612
+ cachedTokens: 0,
613
+ generatedTokens: index,
614
+ prefillTps: 0,
615
+ decodeTps: index / Math.max(decodeMs / 1000, 1e-9),
616
+ prefillMs: 0,
617
+ decodeMs,
618
+ cacheTokens: [],
619
+ };
620
+ }
480
621
  }
481
622
 
482
623
  /** Hold the model's active-adapter list for exactly this generation. */
483
624
  async function* adapterScoped(
484
- model: RuntimeModel,
625
+ model: { loraState: { active: string[] } },
485
626
  adapters: string[],
486
627
  inner: AsyncGenerator<GeneratedToken, GenerateStats>,
487
628
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
629
+ const previous = model.loraState.active;
488
630
  model.loraState.active = adapters;
489
631
  try {
490
632
  return yield* inner;
491
633
  } finally {
492
- model.loraState.active = [];
634
+ model.loraState.active = previous;
493
635
  }
494
636
  }
495
637
 
@@ -549,36 +691,35 @@ export async function withModelUsageFlush<T>(
549
691
  }
550
692
  }
551
693
 
552
- /** Forward through either the ordinary synchronous model surface or a
553
- * streamed-expert model whose layer execution must cross async I/O. Keeping
554
- * this choice inside the shared generator preserves the complete generation
555
- * contract (sampling, grammar, logprobs, prompt cache, and cancellation) for
556
- * both execution styles. */
557
- async function forwardHiddenForGeneration(
558
- model: RuntimeModel,
559
- ids: MlxArray,
560
- cache: Cache[],
561
- ): Promise<MlxArray> {
562
- const asyncModel = model as RuntimeModel & {
563
- forwardHiddenAsync?: (ids: MlxArray, cache: Cache[]) => Promise<MlxArray>;
564
- };
565
- return typeof asyncModel.forwardHiddenAsync === "function"
566
- ? await asyncModel.forwardHiddenAsync(ids, cache)
567
- : model.forwardHidden(ids, cache);
568
- }
569
-
570
694
  async function* generateInner(
571
- model: RuntimeModel,
695
+ binding: MlxAutoregressiveBinding,
572
696
  promptTokens: number[],
573
697
  options: GenerateOptions,
574
698
  diagnostics: GenerateDiagnostics,
699
+ runtime: RuntimeConfig,
575
700
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
701
+ if (options.signal?.aborted) {
702
+ options.grammar?.dispose();
703
+ options.signal.throwIfAborted();
704
+ }
576
705
  const {
577
706
  maxTokens = 512,
578
- eosTokenIds = model.config.eosTokenIds,
707
+ eosTokenIds = binding.eosTokenIds,
579
708
  prefillChunkSize = 2048,
580
709
  } = options;
581
710
 
711
+ try {
712
+ assertMlxAutoregressiveBinding(binding);
713
+ if (options.promptEmbeddings && !binding.forwardEmbeddings)
714
+ throw new Error("AR binding does not support prompt embeddings");
715
+ if (options.adapters?.length && !binding.adapters)
716
+ throw new Error("AR binding does not support adapters");
717
+ } catch (error) {
718
+ options.grammar?.dispose();
719
+ throw error;
720
+ }
721
+ const graph = binding.graph;
722
+
582
723
  // logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
583
724
  // Everything below is gated: when neither flag is set, no extra ops, evals,
584
725
  // or readbacks happen on the hot path.
@@ -603,17 +744,58 @@ async function* generateInner(
603
744
  mechanism: diagnostics.mechanism ?? "serial",
604
745
  });
605
746
  const ownsCache = !options.cache;
606
- const cache = options.cache ?? model.makeCache();
747
+ const resuming = options.initialPendingToken !== undefined;
748
+ let cache: Cache[] = options.cache ?? [];
749
+ try {
750
+ if (ownsCache) cache = binding.makeCache();
751
+ if (!cache.length) throw new Error("AR binding returned an empty cache");
752
+ const cached = cache[0]!.offset;
753
+ if ((!resuming && cached >= promptTokens.length) ||
754
+ (resuming && cached !== promptTokens.length))
755
+ throw new Error(
756
+ resuming
757
+ ? `resumed cache (${cached} tokens) must exactly cover the resume prefix (${promptTokens.length})`
758
+ : `pre-warmed cache (${cached} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
759
+ );
760
+ // Replace fresh full-attention caches before any forward. The deepest
761
+ // write is prompt + maxTokens - 1, so capacity covers every decode step.
762
+ maybePageKv(cache, options, promptTokens.length + maxTokens);
763
+ } catch (error) {
764
+ if (ownsCache) for (const state of cache) state.dispose();
765
+ stepSampler.dispose();
766
+ options.grammar?.dispose();
767
+ closeBatchSetup?.();
768
+ closePrefill?.();
769
+ throw error;
770
+ }
607
771
  const cachedTokens = cache[0]!.offset;
608
- if (cachedTokens >= promptTokens.length)
609
- throw new Error(
610
- `pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
611
- );
612
- // Paged KV (default off): swap fresh full-attention caches for paged
613
- // storage BEFORE prefill. Capacity is exact — the deepest write lands at
614
- // prompt + maxTokens − 1 (step maxTokens−1's forward), so this bound
615
- // makes pool exhaustion an accounting-bug tripwire, not a request limit.
616
- maybePageKv(cache, options, promptTokens.length + maxTokens);
772
+ // Token fast-forwarding gate (see shouldUseFill). RotatingKVCache (sliding
773
+ // window) layers append multi-token writes through #updateConcat — O(window)
774
+ // per append rather than the O(L) a plain ring pays — so v1 warns and skips
775
+ // the whole feature for those models rather than paying it silently.
776
+ let fillOn = shouldUseFill(options, runtime);
777
+ if (fillOn && cache.some((c) => c instanceof RotatingKVCache)) {
778
+ fillOn = false;
779
+ if (!warnedFillRotating) {
780
+ warnedFillRotating = true;
781
+ console.warn(
782
+ "[fill] sliding-window (RotatingKVCache) layers skip token " +
783
+ "fast-forwarding in v1 (multi-token append is O(window) there) — " +
784
+ "docs/design/speculative-decoding.md.",
785
+ );
786
+ }
787
+ }
788
+ const fillTrace = fillOn && fillTraceEnabled();
789
+ const fillTraceFile = fillOn ? fillTracePath() : null;
790
+ // Verify-policy proposals (echo tier) need the rejected tail rewound. That
791
+ // is the SAME contract the spec lane's rounds use: trimmable caches drop the
792
+ // tail; recurrent caches (SSMCache — gated-DeltaNet state, untrimmable)
793
+ // restore a pre-round snapshot and bit-exactly replay the accepted prefix.
794
+ // A model whose caches can do neither still gets assert-policy fills; verify
795
+ // proposals are dropped and counted (stats.verifyUnsupported).
796
+ const verifyCapable = fillOn && cache.every(
797
+ (c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
798
+ );
617
799
  closeBatchSetup?.();
618
800
  /** Device-side logprob capture for one step, computed from the SAME lp the
619
801
  * sampler saw (post-processors, pre-truncation) — read back lazily with the
@@ -654,27 +836,237 @@ async function* generateInner(
654
836
  let prefillMs = 0;
655
837
  let tDecode = 0;
656
838
  let decodeMs = 0;
657
- let generated = 0;
839
+ let generated = options.initialGeneratedTokens ?? 0;
840
+ let finishReason: GenerateStats["finishReason"];
658
841
  const forwarded: number[] = [];
659
842
  let pending: MlxArray | null = null;
660
843
  let nextPending: MlxArray | null = null;
661
844
  let pendingExtras: StepExtras | null = null;
662
845
  let nextExtras: StepExtras | null = null;
846
+ let decoder: MlxDecodeStep | null = null;
847
+ let appender: MlxTokenAppend | null = null;
663
848
  let finished = false;
664
849
  let threw = false;
850
+ let executionError: unknown;
665
851
  let closeTokenZero: (() => void) | undefined;
666
852
  const makeStats = (): GenerateStats => ({
667
- promptTokens: promptTokens.length,
668
- cachedTokens,
853
+ promptTokens: options.originalPromptTokens ?? promptTokens.length,
854
+ cachedTokens: Math.min(cachedTokens, options.originalPromptTokens ?? promptTokens.length),
669
855
  generatedTokens: generated,
856
+ ...(finishReason ? { finishReason } : {}),
670
857
  prefillMs,
671
858
  decodeMs,
672
- prefillTps: ((promptTokens.length - cachedTokens) / prefillMs) * 1000,
859
+ prefillTps: prefillMs > 0
860
+ ? ((promptTokens.length - cachedTokens) / prefillMs) * 1000
861
+ : 0,
673
862
  decodeTps: (generated / decodeMs) * 1000,
674
863
  cacheTokens: [...promptTokens, ...forwarded],
864
+ ...(fillOn ? { fill: options.fill!.stats } : {}),
675
865
  });
676
866
 
867
+ /** The one invariant that makes fill safe: the caches hold exactly
868
+ * prompt + everything `forwarded` records, so a fill append must forward
869
+ * ONLY the injected ids — the normal step already consumed `cur` and wrote
870
+ * its KV. Forwarding [token, ...ids] would duplicate a position and
871
+ * silently corrupt both the KV and PromptCache.put's key. Checked under
872
+ * MLX_BUN_FILL_TRACE=1 on both sides of the append. */
873
+ const assertFillAlignment = (where: string): void => {
874
+ const offset = cache[0]!.offset;
875
+ const want = promptTokens.length + forwarded.length;
876
+ if (offset !== want)
877
+ throw new Error(
878
+ `[fill] cache misalignment ${where}: cache offset ${offset} != ` +
879
+ `prompt ${promptTokens.length} + forwarded ${forwarded.length}`,
880
+ );
881
+ };
882
+
883
+ /** THE apply primitive for token fast-forwarding — ONE chunked forward
884
+ * carries a proposal into the KV (and the recurrent state), and BOTH
885
+ * policies ride it (src/fill/proposal.ts):
886
+ *
887
+ * - assert: the span is determined (a template scaffold). Append and move
888
+ * on. No readback, no checkpoint, no rewind. The in-flight sample for
889
+ * the position after `token` is dropped UNEXAMINED — a discarded
890
+ * pipeline dispatch, not a rejected draft.
891
+ * - verify: the span is likely (a copy from earlier in the session).
892
+ * Position 0 is checked BEFORE the forward against the in-flight sample
893
+ * (free, and a mismatch costs nothing — nothing has been written).
894
+ * Positions 1..m-1 are checked against the argmax already sitting in
895
+ * THIS forward's logits, so verification adds no pass over the weights.
896
+ * The rejected tail is rewound through the same cache contract the spec
897
+ * lane's rounds use, and decode resumes at the first disagreement.
898
+ *
899
+ * Returns the ids the engine must now emit (empty = no fill happened).
900
+ * Mutates nextPending/nextExtras/forwarded, exactly like the normal step. */
901
+ /** One trace record: the proposal next to the model's own token at every
902
+ * span position (`actual[0]` = in-flight sample, `actual[j]` = argmax after
903
+ * ids[j-1]). Appended as JSONL to MLX_BUN_FILL_TRACE=<file>. */
904
+ const traceProposal = (
905
+ proposal: Proposal, generatedNow: number, accepted: number, actual: number[],
906
+ ): void => {
907
+ const ids = proposal.ids;
908
+ let firstMismatch = -1;
909
+ for (let j = 0; j < ids.length && j < actual.length; j++)
910
+ if (actual[j] !== ids[j]) { firstMismatch = j; break; }
911
+ const dec = options.fill!.decode;
912
+ const rec: FillTraceRecord = {
913
+ ts: new Date().toISOString(), origin: proposal.origin, policy: proposal.policy,
914
+ generated: generatedNow, proposedLen: ids.length, accepted, firstMismatch,
915
+ proposed: ids, actual,
916
+ ...(dec ? { proposedText: dec(ids), actualText: dec(actual) } : {}),
917
+ };
918
+ try { appendFileSync(fillTraceFile!, JSON.stringify(rec) + "\n"); } catch { /* trace is best-effort */ }
919
+ };
920
+
921
+ const applyProposal = async (
922
+ proposal: Proposal, generatedNow: number,
923
+ ): Promise<number[]> => {
924
+ const fill = options.fill!;
925
+ const ids = proposal.ids;
926
+ const verify = proposal.policy === "verify";
927
+ if (verify && !verifyCapable) {
928
+ fill.noteVerifyUnsupported();
929
+ fill.commit(proposal, 0);
930
+ return [];
931
+ }
932
+ // The in-flight sample IS the model's own choice for position 0 (read for
933
+ // the trace under both policies; the served assert path never checks it).
934
+ const inFlight = verify || fillTraceFile ? ops.itemUint32(nextPending!) : -1;
935
+ if (verify) {
936
+ if (inFlight !== ids[0]) {
937
+ if (fillTraceFile) traceProposal(proposal, generatedNow, 0, [inFlight]);
938
+ fill.commit(proposal, 0);
939
+ return []; // nextPending survives; this becomes an ordinary step
940
+ }
941
+ } else {
942
+ fill.noteWastedSample();
943
+ }
944
+ if (fillTrace) assertFillAlignment("at fill entry");
945
+ nextPending!.dispose();
946
+ nextPending = null;
947
+ disposeStepExtras(nextExtras);
948
+ nextExtras = null;
949
+ // No-op under the fill exclusions (kv quant refuses fill); kept so the
950
+ // boundary stays correct if those exclusions ever loosen.
951
+ maybeQuantizeKv(cache, options);
952
+
953
+ let checkpointMs = 0;
954
+ let roundOpen = false;
955
+ if (verify) {
956
+ const t0 = performance.now();
957
+ for (const c of cache) rewindable(c).specRoundBegin?.();
958
+ roundOpen = true;
959
+ checkpointMs += performance.now() - t0;
960
+ }
961
+ let accepted = ids.length;
962
+ let hf: MlxArray | null = null;
963
+ let logitsAll: MlxArray | null = null;
964
+ try {
965
+ const chunkSize = (state: readonly Cache[]) => {
966
+ const maxChunk = appender?.maxChunkSize(state) ?? 1;
967
+ return fill.appendChunkSize > 0 ? Math.min(fill.appendChunkSize, maxChunk) : maxChunk;
968
+ };
969
+ hf = await appendFillHidden(
970
+ !verify && appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph),
971
+ cache, ids, verify ? () => ids.length : chunkSize,
972
+ );
973
+ const [, Lf, Hf] = hf.shape as [number, number, number];
974
+ let pred: number[] | null = null;
975
+ if (verify || fillTraceFile) {
976
+ // Free logits: this forward already computed every span position's
977
+ // hidden state. argmax at j is the model's continuation after ids[j],
978
+ // so it verifies ids[j+1]. (Under assert this readback is trace-only.)
979
+ logitsAll = graph.projectLogits(hf, { type: "all" });
980
+ const argmax = ops.argmaxAxis(logitsAll, -1);
981
+ pred = argmax.toIntTokens();
982
+ argmax.dispose();
983
+ }
984
+ if (verify && pred) {
985
+ for (let j = 0; j + 1 < ids.length; j++) {
986
+ if (pred[j] !== ids[j + 1]) { accepted = j + 1; break; }
987
+ }
988
+ }
989
+ if (fillTraceFile && pred)
990
+ traceProposal(proposal, generatedNow, accepted, [inFlight, ...pred.slice(0, ids.length - 1)]);
991
+ const t1 = performance.now();
992
+ if (accepted < ids.length) {
993
+ // Trimmable caches drop the rejected tail; recurrent caches restore
994
+ // their pre-round snapshot and bit-exactly replay the accepted prefix
995
+ // (Cache.specRoundRollback — the same primitive src/spec/serve-loop.ts
996
+ // uses after its accept walk).
997
+ for (const c of cache) {
998
+ const rc = rewindable(c);
999
+ if (rc.specRoundRollback) rc.specRoundRollback(accepted);
1000
+ else rc.trim(ids.length - accepted);
1001
+ }
1002
+ roundOpen = false;
1003
+ } else if (roundOpen) {
1004
+ for (const c of cache) rewindable(c).specRoundCommit?.();
1005
+ roundOpen = false;
1006
+ }
1007
+ if (verify) checkpointMs += performance.now() - t1;
1008
+ forwarded.push(...ids.slice(0, accepted));
1009
+ if (fillTrace) assertFillAlignment("after fill append");
1010
+ // The sampler's token history gets the ACCEPTED ids only, before the
1011
+ // resume sample — so logits processors (repetition/presence/frequency)
1012
+ // see exactly the sequence that was emitted.
1013
+ stepSampler.commitNumbers(ids.slice(0, accepted));
1014
+ // Resume from the LAST ACCEPTED position's logits: the bonus token on a
1015
+ // full accept, the correction on a partial one. Same sampler and step
1016
+ // index as an ordinary step — the correction's KV is written by the next
1017
+ // iteration's forward, like any sampled token.
1018
+ const willGen = generatedNow + accepted;
1019
+ if (willGen < maxTokens) {
1020
+ let logits: MlxArray;
1021
+ if (logitsAll) {
1022
+ const V = logitsAll.shape[2]!;
1023
+ logits = logitsAll.slice([0, accepted - 1, 0], [1, accepted, V]);
1024
+ } else {
1025
+ const hLast = hf.slice([0, Lf - 1, 0], [1, Lf, Hf]);
1026
+ logits = graph.projectLogits(hLast, { type: "all" });
1027
+ hLast.dispose();
1028
+ }
1029
+ const sn = sampleStep(logits, willGen);
1030
+ nextPending = sn.tok;
1031
+ nextExtras = sn.extras;
1032
+ logits.dispose();
1033
+ ops.asyncEvalAll([nextPending, ...stepExtrasArrays(nextExtras)]);
1034
+ }
1035
+ } finally {
1036
+ // A throw mid-round would otherwise leave the recurrent caches armed
1037
+ // (their next forward raises "spec round already recorded").
1038
+ if (roundOpen) for (const c of cache) rewindable(c).specRoundCommit?.();
1039
+ logitsAll?.dispose();
1040
+ hf?.dispose();
1041
+ }
1042
+ if (verify) fill.noteVerifyEvent(checkpointMs);
1043
+ fill.commit(proposal, accepted);
1044
+ if (fillTrace)
1045
+ console.error(
1046
+ `[fill] ${proposal.origin}/${proposal.policy} ${accepted}/${ids.length} ` +
1047
+ `after ${generatedNow} generated (events ${fill.stats.events}, ` +
1048
+ `injected ${fill.stats.injected}, checkpoint ${checkpointMs.toFixed(2)}ms)`,
1049
+ );
1050
+ return ids.slice(0, accepted);
1051
+ };
1052
+
1053
+ let nextCheckpoint = options.checkpointEveryTokens && options.checkpointEveryTokens > 0
1054
+ ? (Math.floor(generated / options.checkpointEveryTokens) + 1) * options.checkpointEveryTokens
1055
+ : Number.POSITIVE_INFINITY;
1056
+ let tPrefill = performance.now();
1057
+
677
1058
  try {
1059
+ if (resuming) {
1060
+ // The checkpoint cache already covers the complete replay prefix. Its
1061
+ // pending token was sampled from that exact state before the snapshot,
1062
+ // so resume starts directly at the decode loop without another forward.
1063
+ if (needsTokenHistory) stepSampler.seedHistory(promptTokens);
1064
+ pending = ops.fromInt32([options.initialPendingToken!], [1]);
1065
+ ops.asyncEvalAll([pending]);
1066
+ prefillMs = 0;
1067
+ closePrefill?.();
1068
+ tDecode = performance.now();
1069
+ } else {
678
1070
  // ---- prefill ----
679
1071
  const closeInitialKv = diagnostics.trace?.begin("prefill.kv_maintenance", {
680
1072
  mechanism: diagnostics.mechanism ?? "serial",
@@ -682,7 +1074,7 @@ async function* generateInner(
682
1074
  });
683
1075
  maybeQuantizeKv(cache, options);
684
1076
  closeInitialKv?.();
685
- const tPrefill = performance.now();
1077
+ tPrefill = performance.now();
686
1078
  let h0: MlxArray;
687
1079
  if (options.promptEmbeddings) {
688
1080
  if (cachedTokens !== 0)
@@ -692,115 +1084,44 @@ async function* generateInner(
692
1084
  // e2b/e4b need the spliced token ids to build per-layer inputs
693
1085
  // (multimodal soft-token positions zeroed inside forwardEmbeddings).
694
1086
  const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
695
- h0 = model.forwardEmbeddings(
1087
+ h0 = binding.forwardEmbeddings!(
696
1088
  options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
697
1089
  options.multimodalMask ?? null,
698
1090
  );
699
1091
  embedIds.dispose();
700
1092
  } else {
701
1093
  let pos = cachedTokens;
702
- // Stable-boundary snapshot: prefill up to snapshotAt first (plain
703
- // chunks, no logits), fire the hook while the caches hold exactly
704
- // that prefix, then continue with the remainder below. Splitting here
705
- // changes chunk shapes only for requests that opted in — the same
706
- // numerics class as a prompt-cache-hit continuation prefill.
707
- const snapAt = options.onPrefillDone && options.snapshotAt !== undefined
1094
+ const snapshotAt = options.onPrefillDone && options.snapshotAt !== undefined
708
1095
  ? Math.min(Math.max(options.snapshotAt, cachedTokens), promptTokens.length)
709
- : promptTokens.length;
710
- if (snapAt > pos && snapAt < promptTokens.length) {
711
- while (pos < snapAt) {
712
- const chunk = promptTokens.slice(pos, Math.min(pos + prefillChunkSize, snapAt));
713
- const closeChunk = diagnostics.trace?.begin("prefill.chunk", {
714
- mechanism: diagnostics.mechanism ?? "serial",
715
- startToken: pos,
716
- tokens: chunk.length,
717
- });
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
- closeChunk?.();
726
- pos += chunk.length;
727
- // Macrotask yield between chunks (runtime-isolation.md Phase 1):
728
- // each chunk is a synchronous multi-hundred-ms FFI eval; without
729
- // this the event loop serves no I/O for the WHOLE prefill.
730
- await new Promise<void>((r) => setImmediate(r));
731
- }
732
- options.onPrefillDone?.();
733
- }
734
- // Oracle prefill convention (mlx-lm 0.31.3 generate_step,
735
- // generate.py:430-453): drain the prompt only to len-1 — chunks of
736
- // min(prefillChunkSize, remaining-1) — then compute step-0 logits from
737
- // a SEPARATE L=1 forward of the LAST prompt token (its server's
738
- // batched engine shares the convention: insert_segments' forced final
739
- // 1-token segment + GenerationBatch._step, generate.py:1645/1327).
740
- // Forwarding the last token as the tail of an L=n GEMM instead is
741
- // ulp-different in bf16 (qmm-vs-qmv + L-dependent SDPA dispatch) in
742
- // BOTH the step-0 logits and that token's stored KV — near-tie greedy
743
- // streams flip (2026-07-07 12B completion-probe ✗: first token flip at
744
- // step 24). MLX_BUN_PREFILL_TAIL_SPLIT=0 restores the old
745
- // full-final-chunk convention (A/B lever + kill switch).
746
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
747
- const drainGate = tailSplit ? 1 : prefillChunkSize;
748
- while (promptTokens.length - pos > drainGate) {
749
- const n = tailSplit
750
- ? Math.min(prefillChunkSize, promptTokens.length - pos - 1)
751
- : prefillChunkSize;
752
- const chunk = promptTokens.slice(pos, pos + n);
1096
+ : undefined;
1097
+ const tailSplit = runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
1098
+ const forward = graph.forwardHidden.bind(graph);
1099
+ const maintain = () => maybeQuantizeKv(cache, options);
1100
+ while (true) {
1101
+ options.signal?.throwIfAborted();
1102
+ const step = nextPrefillStep({ length: promptTokens.length, position: pos,
1103
+ chunkSize: prefillChunkSize, tailSplit, snapshotAt });
1104
+ if (step.kind === "final" && needsTokenHistory) stepSampler.seedHistory(promptTokens);
753
1105
  const closeChunk = diagnostics.trace?.begin("prefill.chunk", {
754
- mechanism: diagnostics.mechanism ?? "serial",
755
- startToken: pos,
756
- tokens: chunk.length,
1106
+ mechanism: diagnostics.mechanism ?? "serial", startToken: step.start,
1107
+ tokens: step.end - step.start, ...(step.kind === "final" ? { final: true } : {}),
757
1108
  });
758
- const ids = ops.fromInt32(chunk, [1, chunk.length]);
759
- const h = await forwardHiddenForGeneration(model, ids, cache);
760
- ids.dispose();
761
- h.dispose(); // logits never computed for non-final chunks
762
- evalCacheState(cache);
763
- maybeQuantizeKv(cache, options);
764
- // mlx-lm _prefill clears the allocator cache after every chunk;
765
- // without this, prefill transients pile up in the buffer cache
766
- // and the first decode step pays a one-shot reclaim stall that
767
- // scales with prompt length (~800 ms after an 8k prefill —
768
- // measured, scripts/decode-split.ts; the context-scaling decode
769
- // gap's main term).
770
- clearCache();
771
- closeChunk?.();
772
- pos += n;
773
- if (runtimeValue("MLX_BUN_PREFILL_MEM_LOG") === "1")
1109
+ let hidden: MlxArray | null;
1110
+ try { hidden = await executeMlxPrefillStep(forward, cache, promptTokens, step, maintain); }
1111
+ finally { closeChunk?.(); }
1112
+ pos = step.end;
1113
+ if (hidden) { h0 = hidden; break; }
1114
+ if (runtime.value("MLX_BUN_PREFILL_MEM_LOG") === "1")
774
1115
  console.error(`[prefill-mem] ${pos} active ${(activeMemory() / 2 ** 30).toFixed(2)} peak ${(peakMemory() / 2 ** 30).toFixed(2)}`);
775
- // Macrotask yield between chunks (runtime-isolation.md Phase 1).
776
- await new Promise<void>((r) => setImmediate(r));
777
- }
778
- if (needsTokenHistory) {
779
- stepSampler.seedHistory(promptTokens);
1116
+ await new Promise<void>((resolve) => setImmediate(resolve));
1117
+ if (step.snapshot) options.onPrefillDone?.();
780
1118
  }
781
- // Under tailSplit this is exactly [promptTokens[len-1]] — the L=1
782
- // step-0 forward (uncompiled L=1 is bit-exact with compiled decode,
783
- // tests/compiled-decode.test.ts). The last prompt token's KV enters
784
- // the cache HERE, so downstream bookkeeping (forwarded[], cacheTokens,
785
- // PromptCache.put alignment) is unchanged: after step 0 the caches
786
- // cover exactly the prompt, as before.
787
- const lastChunk = promptTokens.slice(pos);
788
- const closeChunk = diagnostics.trace?.begin("prefill.chunk", {
789
- mechanism: diagnostics.mechanism ?? "serial",
790
- startToken: pos,
791
- tokens: lastChunk.length,
792
- final: true,
793
- });
794
- const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
795
- h0 = await forwardHiddenForGeneration(model, ids0, cache);
796
- ids0.dispose();
797
- closeChunk?.();
798
1119
  }
799
1120
  if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
800
1121
  options.onPrefillDone?.();
801
1122
  // The trace's prefill span is the uncached model-forward portion only.
802
1123
  // Token-0 lm-head, sampling, and readback are a separate additive stage.
803
- if (diagnostics.trace && runtimeValue("MLX_BUN_P2R_SYNC") === "1")
1124
+ if (diagnostics.trace && runtime.value("MLX_BUN_P2R_SYNC") === "1")
804
1125
  synchronize(gpuStream);
805
1126
  closePrefill?.();
806
1127
  closeTokenZero = diagnostics.trace?.begin("token_zero.total", {
@@ -809,7 +1130,7 @@ async function* generateInner(
809
1130
  const [, L0, H] = h0.shape as [number, number, number];
810
1131
  const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
811
1132
  h0.dispose();
812
- const logits0 = model.logitsFromHidden(hLast);
1133
+ const logits0 = graph.projectLogits(hLast, { type: "all" });
813
1134
  hLast.dispose();
814
1135
  const s0 = sampleStep(logits0, 0); // token array [1] (+ optional extras)
815
1136
  pending = s0.tok;
@@ -824,30 +1145,16 @@ async function* generateInner(
824
1145
  // tok/s measure the same quantity. The boundary cost is real and
825
1146
  // scales with prompt length; it belongs to "having prefilled".
826
1147
  ops.asyncEvalAll([pending, ...stepExtrasArrays(pendingExtras)]);
1148
+ }
827
1149
 
828
1150
  // ---- decode (pipelined) ----
829
- // Compiled decode (docs/design/optimization_plan.md Phase A): replay the per-step
830
- // graph in C++ instead of rebuilding it through bun:ffi every token.
831
- // Bit-exact with the uncompiled path (tests/compiled-decode.test.ts);
832
- // MLX_BUN_COMPILED_DECODE=0 is the kill switch / A-B lever. LoRA
833
- // generations stay uncompiled (adapter weights would bake into the
834
- // trace as constants). Any unsupported cache state falls back for
835
- // the rest of the generation.
836
- // MoE models stay uncompiled: GatherQMM lacks output_shapes in mlx
837
- // 0.6.0, and shapeless replay re-infers the whole tape whenever the
838
- // growing attention windows change shape (= every step). Remove this
839
- // when upstream implements GatherQMM::output_shapes.
840
- let compiled =
841
- flagOn("MLX_BUN_COMPILED_DECODE", true) &&
842
- !options.adapters?.length &&
843
- // Paged caches can't compile (data-dependent block-list length —
844
- // the shapeless-replay hazard; CompiledDecode.supports() also
845
- // excludes them per step, this just skips the setup).
846
- !options.pagedKv &&
847
- model.config.modelType.startsWith("gemma4") &&
848
- !model.config.text.enableMoeBlock
849
- ? CompiledDecode.for(model as Gemma4Model)
850
- : null;
1151
+ // Selection and transactional fallback belong to the graph's binding.
1152
+ decoder = options.decodePolicy?.compiledDecode === false ? null : binding.createDecode?.({
1153
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1154
+ }) ?? null;
1155
+ appender = fillOn ? binding.createAppend?.({
1156
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1157
+ }) ?? null : null;
851
1158
  let stop = false;
852
1159
  /** Token id read eagerly at the top of the loop for grammar (reused for
853
1160
  * the yield, avoiding a second readback). -1 when grammar is off — the
@@ -860,11 +1167,38 @@ async function* generateInner(
860
1167
  // (see GrammarController.jumpForward for the contract + the fidelity
861
1168
  // note on why this is opt-in). Excluded when logprobs are requested
862
1169
  // (jumped tokens are never sampled, so they'd have no logprobs rows).
863
- const grammarJump = shouldUseGrammarJump(options);
1170
+ const grammarJump = options.decodePolicy?.grammarJump ?? shouldUseGrammarJump(options, runtime);
1171
+ // Yield token zero before its own decode forward. The captured runtime
1172
+ // keeps this scheduling choice stable across consumer awaits.
1173
+ const earlyFirstToken = runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1" &&
1174
+ maxTokens > 1 && !resuming && !fillOn && !options.grammar;
864
1175
  while (!stop) {
1176
+ options.signal?.throwIfAborted();
865
1177
  const cur = pending!;
866
- const curExtras = pendingExtras;
867
- pendingExtras = null;
1178
+ const stepIndex = generated;
1179
+ let curExtras = pendingExtras;
1180
+ let yieldedEarly = false;
1181
+ if (earlyFirstToken && stepIndex === 0) {
1182
+ const firstToken = ops.itemUint32(cur);
1183
+ options.signal?.throwIfAborted();
1184
+ if (!eosTokenIds.includes(firstToken)) {
1185
+ prefillMs = performance.now() - tPrefill;
1186
+ closeTokenZero?.();
1187
+ closeTokenZero = undefined;
1188
+ tDecode = performance.now();
1189
+ const logprobs = readExtras(curExtras);
1190
+ curExtras = null;
1191
+ pendingExtras = null;
1192
+ // Count before yielding so an early return reports this token. Its
1193
+ // KV is still absent; forwarded describes the prompt-only cache.
1194
+ generated++;
1195
+ yieldedEarly = true;
1196
+ yield { token: firstToken, index: stepIndex, ...(logprobs ? { logprobs } : {}) };
1197
+ options.signal?.throwIfAborted();
1198
+ }
1199
+ }
1200
+ // Keep current extras owned by the run until readback succeeds. A
1201
+ // forward/grammar failure must leave them reachable by finally.
868
1202
  // Grammar advance (src/grammar.ts): acceptToken needs the token id as a
869
1203
  // JS number, which the pipelined loop defers (it operates on device
870
1204
  // arrays). So grammar requests eager-read cur here, advance the matcher,
@@ -885,11 +1219,11 @@ async function* generateInner(
885
1219
  let jumpEmit: number[] | null = null;
886
1220
  if (options.grammar) {
887
1221
  grammarTok = ops.itemUint32(cur);
888
- if (generated + 1 < maxTokens) {
1222
+ if (stepIndex + 1 < maxTokens) {
889
1223
  options.grammar.accept(grammarTok);
890
1224
  await options.grammar.ready();
891
1225
  if (grammarJump && !options.grammar.isTerminated) {
892
- jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
1226
+ jumpEmit = options.grammar.jumpForward(maxTokens - (stepIndex + 1));
893
1227
  // jumpForward advanced the matcher and fired the post-jump mask
894
1228
  // fill; it must be ready before this iteration's sampleStep.
895
1229
  if (jumpEmit) await options.grammar.ready();
@@ -913,16 +1247,16 @@ async function* generateInner(
913
1247
  stepSampler.commitNumbers(jumpEmit);
914
1248
  const chunk = [grammarTok, ...jumpEmit];
915
1249
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
916
- const h = await forwardHiddenForGeneration(model, ids, cache);
1250
+ const h = await graph.forwardHidden(ids, cache);
917
1251
  ids.dispose();
918
1252
  // Every chunk token's KV is in the cache regardless of what follows.
919
1253
  forwarded.push(...chunk);
920
- const willGen = generated + 1 + jumpEmit.length;
1254
+ const willGen = stepIndex + 1 + jumpEmit.length;
921
1255
  if (willGen < maxTokens && !options.grammar!.isTerminated) {
922
1256
  const [, Lj, Hj] = h.shape as [number, number, number];
923
1257
  const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
924
1258
  h.dispose();
925
- const logits = model.logitsFromHidden(hLast);
1259
+ const logits = graph.projectLogits(hLast, { type: "all" });
926
1260
  hLast.dispose();
927
1261
  const sn = sampleStep(logits, willGen);
928
1262
  nextPending = sn.tok;
@@ -932,34 +1266,24 @@ async function* generateInner(
932
1266
  } else {
933
1267
  h.dispose(); // burst ends the generation (max_tokens or grammar done)
934
1268
  }
935
- } else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
1269
+ } else if (stepIndex + 1 < maxTokens && !options.grammar?.isTerminated) {
936
1270
  maybeQuantizeKv(cache, options);
937
1271
  pushHistory(cur);
938
1272
  let logits: MlxArray | null = null;
939
1273
  let evalWith: MlxArray[] = [];
940
- if (compiled && CompiledDecode.supports(cache)) {
941
- try {
942
- const r = compiled.step(cur, cache);
943
- logits = r.logits;
944
- evalWith = r.evalWith;
945
- } catch (e) {
946
- // Safe to re-forward the SAME token below: a failed step is
947
- // transactional — segmented mode stages ring adopts and rolls
948
- // back committed js-layer writes on throw (see #stepSegmented),
949
- // and buffer growth done by a partial prepare is benign for the
950
- // uncompiled path (updateAndFetch re-checks capacity).
951
- compiled = null;
952
- console.warn(`compiled decode disabled for this generation: ${e}`);
953
- }
1274
+ const decoded = decoder?.tryStep(cur, cache);
1275
+ if (decoded) {
1276
+ logits = decoded.logits;
1277
+ evalWith = decoded.evalWith;
954
1278
  }
955
1279
  if (!logits) {
956
1280
  const ids = ops.reshape(cur, [1, 1]);
957
- const h = await forwardHiddenForGeneration(model, ids, cache);
1281
+ const h = await graph.forwardHidden(ids, cache);
958
1282
  ids.dispose();
959
- logits = model.logitsFromHidden(h);
1283
+ logits = graph.projectLogits(h, { type: "all" });
960
1284
  h.dispose();
961
1285
  }
962
- const sn = sampleStep(logits, generated + 1);
1286
+ const sn = sampleStep(logits, stepIndex + 1);
963
1287
  nextPending = sn.tok;
964
1288
  nextExtras = sn.extras;
965
1289
  logits.dispose();
@@ -970,6 +1294,7 @@ async function* generateInner(
970
1294
  // sync-read step n's token while n+1 computes (grammar already read it
971
1295
  // eagerly above — reuse to avoid a second GPU sync)
972
1296
  const token = options.grammar ? grammarTok : ops.itemUint32(cur);
1297
+ options.signal?.throwIfAborted();
973
1298
  if (generated === 0) {
974
1299
  // first token arrived: prompt clock stops, decode clock starts
975
1300
  // (mlx-lm stream_generate's n==0 clock swap; the first token is
@@ -981,23 +1306,51 @@ async function* generateInner(
981
1306
  }
982
1307
  cur.dispose();
983
1308
  pending = null;
984
- generated++;
1309
+ if (!yieldedEarly) generated++;
985
1310
  // if a next-step graph was built, this token's KV entered the cache
986
1311
  // (jump iterations pushed the whole chunk already)
987
1312
  if (nextPending !== null && !jumpEmit) forwarded.push(token);
988
1313
 
989
1314
  if (eosTokenIds.includes(token)) {
1315
+ finishReason = "stop";
990
1316
  disposeStepExtras(curExtras);
1317
+ pendingExtras = null;
991
1318
  nextPending?.dispose();
992
1319
  nextPending = null;
993
1320
  disposeStepExtras(nextExtras);
994
1321
  nextExtras = null;
995
1322
  stop = true;
996
1323
  } else {
1324
+ // ---- token fast-forwarding (K3): APPEND, then yield ----------------
1325
+ // The engine already knows the next m tokens, so it writes them into
1326
+ // the KV itself with an append and resumes sampling after them. No
1327
+ // draft, no verify, no rollback: an injected token is indistinguishable
1328
+ // to the model from one it sampled. The append happens BEFORE any of
1329
+ // the burst's yields, which is what makes a consumer break mid-burst
1330
+ // safe — `forwarded` already describes the cache exactly.
1331
+ let fillEmit: number[] | null = null;
1332
+ if (fillOn) {
1333
+ if (jumpEmit)
1334
+ throw new Error("fill and grammar jump-forward cannot share an iteration");
1335
+ // A fill needs an in-flight step to ride; with nextPending null this
1336
+ // token's KV was never written and the generation ends here. push()
1337
+ // still runs so the session's history (and echo index) stay exact.
1338
+ const proposal = options.fill!.push(
1339
+ token, nextPending !== null ? maxTokens - generated : 0,
1340
+ );
1341
+ if (proposal && nextPending !== null) {
1342
+ const emitted = await applyProposal(proposal, generated);
1343
+ if (emitted.length) fillEmit = emitted;
1344
+ } else if (proposal) {
1345
+ options.fill!.commit(proposal, 0); // unreachable at budget 0; belt
1346
+ }
1347
+ }
997
1348
  // readExtras before the yield: if the consumer breaks at this yield,
998
1349
  // the extras are already read and disposed.
999
1350
  const logprobs = readExtras(curExtras);
1000
- yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1351
+ pendingExtras = null;
1352
+ if (!yieldedEarly) yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1353
+ options.signal?.throwIfAborted();
1001
1354
  // mlx-lm generate_step: clear_cache after token 0 (drops the
1002
1355
  // remaining prefill transients) and every 256 tokens after
1003
1356
  if ((generated - 1) % 256 === 0) clearCache();
@@ -1008,11 +1361,24 @@ async function* generateInner(
1008
1361
  // safe — `forwarded` already reflects the cache exactly.
1009
1362
  if (jumpEmit) {
1010
1363
  for (const jt of jumpEmit) {
1364
+ options.signal?.throwIfAborted();
1011
1365
  generated++;
1012
1366
  yield { token: jt, index: generated - 1 };
1013
1367
  if ((generated - 1) % 256 === 0) clearCache();
1014
1368
  }
1015
1369
  }
1370
+ // Fill burst: same one-yield-per-token shape as the jump burst, so
1371
+ // CompletionSink / StopMatcher / StreamDecoder see an ordinary stream
1372
+ // (a stop sequence inside an injected span fires exactly where it
1373
+ // would have in an unfilled run).
1374
+ if (fillEmit) {
1375
+ for (const ft of fillEmit) {
1376
+ options.signal?.throwIfAborted();
1377
+ generated++;
1378
+ yield { token: ft, index: generated - 1 };
1379
+ if ((generated - 1) % 256 === 0) clearCache();
1380
+ }
1381
+ }
1016
1382
  if (nextPending === null) {
1017
1383
  stop = true;
1018
1384
  } else {
@@ -1021,6 +1387,21 @@ async function* generateInner(
1021
1387
  pendingExtras = nextExtras;
1022
1388
  nextExtras = null;
1023
1389
  }
1390
+ if (
1391
+ pending !== null &&
1392
+ options.onDecodeCheckpoint &&
1393
+ generated >= nextCheckpoint
1394
+ ) {
1395
+ const pendingToken = ops.itemUint32(pending);
1396
+ await options.onDecodeCheckpoint({
1397
+ cacheTokens: [...promptTokens, ...forwarded],
1398
+ caches: cache,
1399
+ generatedTokens: generated,
1400
+ pendingToken,
1401
+ });
1402
+ const every = options.checkpointEveryTokens!;
1403
+ while (nextCheckpoint <= generated) nextCheckpoint += every;
1404
+ }
1024
1405
  }
1025
1406
  }
1026
1407
  decodeMs = performance.now() - tDecode;
@@ -1028,6 +1409,7 @@ async function* generateInner(
1028
1409
  return makeStats();
1029
1410
  } catch (e) {
1030
1411
  threw = true;
1412
+ executionError = e;
1031
1413
  throw e;
1032
1414
  } finally {
1033
1415
  if (!finished) {
@@ -1036,13 +1418,19 @@ async function* generateInner(
1036
1418
  disposeStepExtras(pendingExtras);
1037
1419
  disposeStepExtras(nextExtras);
1038
1420
  }
1039
- if (ownsCache) for (const c of cache) c.dispose();
1040
- // The grammar controller owns native WASM state (GrammarMatcher +
1041
- // TokenizerInfo cache) — dispose on every exit path (normal, early break,
1042
- // throw). TokenizerInfo itself is process-cached (vocab-structural), so
1043
- // only the per-request matcher/compiled are freed here.
1044
- options.grammar?.dispose();
1045
- stepSampler.dispose();
1421
+ try {
1422
+ const closing = decoder?.close();
1423
+ if (closing) await closing;
1424
+ } catch (error) {
1425
+ if (threw) throw new AggregateError([executionError, error],
1426
+ "AR execution and decoder cleanup failed", { cause: executionError });
1427
+ throw error;
1428
+ } finally {
1429
+ if (ownsCache) for (const c of cache) c.dispose();
1430
+ // TokenizerInfo is process-cached; this request owns the matcher.
1431
+ options.grammar?.dispose();
1432
+ stepSampler.dispose();
1433
+ }
1046
1434
  if (!finished && !threw) {
1047
1435
  // forced early return (consumer break at a yield): still report
1048
1436
  // stats — `forwarded` only lists tokens whose KV actually entered