mlx-bun 0.2.0 → 0.4.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 (433) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +94 -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/archive/investigations/openjarvis-fit.md +198 -0
  8. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  9. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  10. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  11. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  12. package/docs/archive/planning/release-notes-v0.3.0.md +35 -0
  13. package/docs/design/batching.md +1841 -0
  14. package/docs/design/decode-speed-program.md +2346 -169
  15. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  16. package/docs/design/generic-model-support.md +655 -334
  17. package/docs/design/kv-cache.md +824 -0
  18. package/docs/design/orpo-training.md +673 -1239
  19. package/docs/design/speculative-decoding.md +1990 -0
  20. package/docs/design/turboquant.md +2212 -0
  21. package/docs/design/unified-engine-frontier-plan.md +1833 -727
  22. package/docs/design/web-chat-redesign.md +666 -1083
  23. package/docs/planning/PRODUCT_ROADMAP.md +50 -1
  24. package/docs/planning/release-notes-v0.4.0.md +45 -0
  25. package/docs/planning/video-series.md +2 -2
  26. package/docs/reference/benchmarks.md +3613 -0
  27. package/docs/reference/cli.md +434 -160
  28. package/docs/reference/distribution.md +328 -182
  29. package/docs/reference/environment.md +398 -0
  30. package/docs/reference/glossary.md +39 -0
  31. package/docs/reference/library-api.md +566 -168
  32. package/docs/reference/memory.md +227 -64
  33. package/docs/reference/models.md +113 -6
  34. package/docs/reference/server-api.md +944 -429
  35. package/docs/reference/server-config.md +740 -338
  36. package/docs/reference/training.md +393 -254
  37. package/docs/reference/troubleshooting.md +158 -0
  38. package/package.json +9 -4
  39. package/src/audio/decode.ts +2 -2
  40. package/src/audio/features.ts +2 -2
  41. package/src/backends/mlx/assistant-target.ts +46 -0
  42. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  43. package/src/backends/mlx/autoregressive.ts +108 -0
  44. package/src/backends/mlx/batch-group.ts +1790 -0
  45. package/src/backends/mlx/cache-attachment.ts +67 -0
  46. package/src/backends/mlx/cache-layout.ts +31 -0
  47. package/src/backends/mlx/checkpoint-state.ts +53 -0
  48. package/src/backends/mlx/continuation-persistence.ts +106 -0
  49. package/src/backends/mlx/continuation-request.ts +43 -0
  50. package/src/backends/mlx/continuation.ts +77 -0
  51. package/src/backends/mlx/diffusion.ts +39 -0
  52. package/src/backends/mlx/draft-target.ts +32 -0
  53. package/src/backends/mlx/evaluation.ts +105 -0
  54. package/src/backends/mlx/fill-append.ts +45 -0
  55. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  56. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  57. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  58. package/src/backends/mlx/gateway-binding.ts +130 -0
  59. package/src/backends/mlx/graph.ts +38 -0
  60. package/src/backends/mlx/kv-codec.ts +9 -0
  61. package/src/backends/mlx/kv-maintenance.ts +133 -0
  62. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  63. package/src/backends/mlx/memory-client.ts +189 -0
  64. package/src/backends/mlx/methods.ts +90 -0
  65. package/src/backends/mlx/model-prompt.ts +164 -0
  66. package/src/backends/mlx/model-serving.ts +79 -0
  67. package/src/backends/mlx/prefill-cohort.ts +89 -0
  68. package/src/backends/mlx/prefill-rows.ts +200 -0
  69. package/src/backends/mlx/prefill.ts +33 -0
  70. package/src/backends/mlx/projected-context-rows.ts +72 -0
  71. package/src/backends/mlx/request-policy.ts +20 -0
  72. package/src/backends/mlx/request-state-policy.ts +41 -0
  73. package/src/backends/mlx/rollback.ts +89 -0
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +242 -0
  76. package/src/backends/mlx/speculative-group.ts +359 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +114 -0
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +46 -0
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/text-engine.ts +73 -0
  83. package/src/backends/mlx/transitioning-kv-rows.ts +165 -0
  84. package/src/cli.ts +66 -58
  85. package/src/client.ts +66 -0
  86. package/src/config.ts +21 -4
  87. package/src/contracts/completion.ts +16 -0
  88. package/src/contracts/execution.ts +60 -0
  89. package/src/contracts/generation.ts +103 -0
  90. package/src/contracts/host.ts +7 -0
  91. package/src/contracts/jobs.ts +66 -0
  92. package/src/contracts/pi-provider.ts +30 -0
  93. package/src/contracts/pi.ts +259 -0
  94. package/src/contracts/prefix-cache.ts +23 -0
  95. package/src/contracts/resources.ts +25 -0
  96. package/src/contracts/sampling.ts +23 -0
  97. package/src/contracts/scheduling.ts +45 -0
  98. package/src/contracts/tsconfig.json +14 -0
  99. package/src/diffusion/diffusion-generate.ts +218 -153
  100. package/src/embed.ts +1 -1
  101. package/src/engine/admission.ts +59 -0
  102. package/src/engine/cancellation.ts +38 -0
  103. package/src/engine/engine.ts +56 -0
  104. package/src/engine/execution-coordinator.ts +70 -0
  105. package/src/engine/execution-plan.ts +53 -0
  106. package/src/engine/resources.ts +53 -0
  107. package/src/engine/scheduler.ts +72 -0
  108. package/src/engine/session.ts +247 -0
  109. package/src/eval/README.md +44 -1
  110. package/src/eval/kl.ts +236 -3
  111. package/src/eval/reference-logits.ts +207 -0
  112. package/src/eval/runner.ts +8 -96
  113. package/src/fill/echo-index.ts +188 -0
  114. package/src/fill/fill-session.ts +452 -0
  115. package/src/fill/proposal.ts +68 -0
  116. package/src/fill/schema-rows.ts +506 -0
  117. package/src/fill/tool-boundary.ts +103 -0
  118. package/src/fit.ts +30 -16
  119. package/src/generate.ts +709 -417
  120. package/src/grammar.ts +12 -7
  121. package/src/harness-pi.ts +14 -33
  122. package/src/hub-rest.ts +4 -4
  123. package/src/index.ts +7 -0
  124. package/src/inference/denoising.ts +17 -0
  125. package/src/inference/draft-acceptance.ts +41 -0
  126. package/src/inference/draft-output.ts +49 -0
  127. package/src/inference/graph.ts +33 -0
  128. package/src/inference/prefill.ts +39 -0
  129. package/src/inference/rollback.ts +7 -0
  130. package/src/jobs/db.ts +4 -0
  131. package/src/jobs/index.ts +2 -0
  132. package/src/jobs/job-entry.ts +12 -12
  133. package/src/jobs/runner.ts +98 -13
  134. package/src/jobs/task-client.ts +19 -0
  135. package/src/jobs/types.ts +2 -58
  136. package/src/kv-scheme.ts +70 -12
  137. package/src/kv-store.ts +177 -63
  138. package/src/lab/README.md +18 -0
  139. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  140. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  141. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  142. package/src/lab/paged-kv/paged-kv-rows.ts +83 -0
  143. package/src/{model → lab/paged-kv}/paged-kv.ts +32 -15
  144. package/src/library.ts +40 -0
  145. package/src/lora.ts +18 -0
  146. package/src/media-fetch.ts +1 -1
  147. package/src/memory/article.ts +1 -1
  148. package/src/memory/chunk.ts +1 -1
  149. package/src/memory/model.ts +30 -169
  150. package/src/memory/prompts.ts +1 -1
  151. package/src/memory/rest.ts +1 -1
  152. package/src/memory/schedule.ts +1 -1
  153. package/src/memory/stages.ts +4 -1
  154. package/src/memory/vault.ts +1 -3
  155. package/src/mlx/array.ts +11 -11
  156. package/src/mlx/compile.ts +12 -2
  157. package/src/mlx/ffi.ts +58 -11
  158. package/src/mlx/materialize.ts +23 -0
  159. package/src/mlx/metal-kernel.ts +10 -11
  160. package/src/mlx/ops.ts +33 -13
  161. package/src/mlx/quantized-rows.ts +36 -0
  162. package/src/mlx/token-bitmask.ts +52 -0
  163. package/src/mlx/turboquant-kv-decode.ts +82 -0
  164. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  165. package/src/mlx/turboquant-ops.ts +33 -3
  166. package/src/mlx/turboquant-tables.ts +2 -2
  167. package/src/model/artifact-identity.ts +73 -0
  168. package/src/model/batched-kv.ts +235 -0
  169. package/src/model/batched-mask.ts +20 -6
  170. package/src/model/batched-quant.ts +9 -3
  171. package/src/model/batched-quantized-kv.ts +144 -0
  172. package/src/model/batched-rotating-quant.ts +78 -17
  173. package/src/model/batched-rotating-state.ts +76 -0
  174. package/src/model/batched-rotating.ts +65 -23
  175. package/src/model/batched-row-storage.ts +52 -0
  176. package/src/model/batched-ssm.ts +52 -0
  177. package/src/model/batched-turboquant-kv.ts +93 -0
  178. package/src/model/compiled-decode.ts +8 -2
  179. package/src/model/decoded-kv-donor.ts +12 -0
  180. package/src/model/delayed-quantized-kv.ts +72 -0
  181. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  182. package/src/model/delayed-turboquant-kv.ts +55 -0
  183. package/src/model/diffusion-gemma.ts +8 -8
  184. package/src/model/factory.ts +76 -33
  185. package/src/model/fingerprint.ts +1 -1
  186. package/src/model/flash-attention.ts +3 -3
  187. package/src/model/full-prefill-padding.ts +56 -0
  188. package/src/model/full-prefill-row.ts +105 -0
  189. package/src/model/gemma4-base.ts +179 -80
  190. package/src/model/gemma4.ts +40 -32
  191. package/src/model/generated/gemma4-12b.ts +6 -3
  192. package/src/model/generated/gemma4-26b.ts +6 -3
  193. package/src/model/generated/gemma4-e4b.ts +6 -3
  194. package/src/model/glm52-cache.ts +80 -4
  195. package/src/model/glm52-dsa.ts +13 -9
  196. package/src/model/glm52-memory.ts +1 -1
  197. package/src/model/glm52-mla.ts +12 -6
  198. package/src/model/glm52.ts +19 -40
  199. package/src/model/implementation.ts +69 -0
  200. package/src/model/kv-attention-view.ts +67 -0
  201. package/src/model/minicpm5.ts +17 -15
  202. package/src/model/profile.ts +12 -2
  203. package/src/model/qwen-append.ts +12 -0
  204. package/src/model/qwen-conv.ts +52 -0
  205. package/src/model/qwen3-delta-state.ts +74 -0
  206. package/src/model/qwen3-delta.ts +59 -36
  207. package/src/model/qwen3_5-checkpoint.ts +164 -0
  208. package/src/model/qwen3_5.ts +202 -78
  209. package/src/model/rotating-kv-donor.ts +35 -0
  210. package/src/model/rotating-kv-layout.ts +262 -0
  211. package/src/model/rotating-row-transaction.ts +35 -0
  212. package/src/model/speculative-rotating-kv.ts +101 -0
  213. package/src/model/ssm-prefill-padding.ts +39 -0
  214. package/src/model/support.ts +1 -1
  215. package/src/model/trellis-balanced-scatter.ts +51 -0
  216. package/src/model/trellis-linear.ts +672 -0
  217. package/src/model/trellis-shared-m.ts +151 -0
  218. package/src/model/trellis-shared-scatter.ts +144 -0
  219. package/src/model/trellis-splitk-prefill.ts +158 -0
  220. package/src/model/trellis-tiled-prefill.ts +136 -0
  221. package/src/model/trellis-vector-expand.ts +62 -0
  222. package/src/model/trellis-wide-prefill.ts +94 -0
  223. package/src/model/turboquant-codec.ts +83 -0
  224. package/src/model/universal/dense.ts +15 -14
  225. package/src/model/universal/modules.ts +8 -1
  226. package/src/native-pack.ts +6 -5
  227. package/src/pi-provider.ts +6 -64
  228. package/src/pi-web.ts +8 -315
  229. package/src/preflight.ts +31 -15
  230. package/src/prompt-cache.ts +110 -69
  231. package/src/quantize/allocator.ts +1 -1
  232. package/src/quantize/config-writer.ts +10 -3
  233. package/src/quantize/rotate.ts +4 -4
  234. package/src/quantize/trellis.ts +586 -0
  235. package/src/responses.ts +28 -1
  236. package/src/runtime-config.ts +18 -4
  237. package/src/sampler.ts +40 -29
  238. package/src/serve/batch-scheduler.ts +8 -1671
  239. package/src/serve/chat-request.ts +391 -0
  240. package/src/serve/chat-stage.ts +195 -0
  241. package/src/serve/checkpoint-identity.ts +64 -0
  242. package/src/serve/completion-executor.ts +15 -7
  243. package/src/serve/discovery-routes.ts +12 -13
  244. package/src/serve/generation-gateway.ts +183 -213
  245. package/src/serve/http.ts +159 -0
  246. package/src/serve/inference-request.ts +79 -0
  247. package/src/serve/isolate.ts +297 -105
  248. package/src/serve/lab-routes.ts +3 -0
  249. package/src/serve/lane-registry.ts +2 -1
  250. package/src/serve/model-admin-routes.ts +7 -5
  251. package/src/serve/model-binding.ts +43 -0
  252. package/src/serve/model-host.ts +511 -0
  253. package/src/serve/openai-wire.ts +103 -0
  254. package/src/serve/parent-application.ts +56 -0
  255. package/src/serve/pipeline.ts +33 -0
  256. package/src/serve/preparation.ts +33 -0
  257. package/src/serve/request-plan.ts +23 -18
  258. package/src/serve/request-prep.ts +344 -0
  259. package/src/serve/response-lease.ts +24 -0
  260. package/src/serve/responses-client.ts +69 -0
  261. package/src/serve/session-completion-engine.ts +110 -0
  262. package/src/serve/session-search.ts +1 -1
  263. package/src/serve/static-routes.ts +5 -5
  264. package/src/serve/text-completion-stage.ts +104 -0
  265. package/src/serve/token-streams.ts +519 -0
  266. package/src/server.ts +335 -2711
  267. package/src/spec/assistant-rows.ts +92 -0
  268. package/src/spec/assistant-source.ts +31 -55
  269. package/src/spec/cache-identity.ts +22 -0
  270. package/src/spec/deepspec-rows.ts +29 -0
  271. package/src/spec/deepspec-source.ts +16 -14
  272. package/src/spec/dflash-source.ts +36 -12
  273. package/src/spec/drafter.ts +153 -142
  274. package/src/spec/dspark/ab-stats.ts +3 -3
  275. package/src/spec/dspark/calibration.ts +2 -2
  276. package/src/spec/dspark/deepspec-module.ts +97 -122
  277. package/src/spec/dspark/generate-dflash.ts +2 -2
  278. package/src/spec/dspark/generate.ts +2 -2
  279. package/src/spec/dspark/loader.ts +1 -1
  280. package/src/spec/dspark/module-dflash.ts +129 -172
  281. package/src/spec/dspark/module.ts +3 -3
  282. package/src/spec/dspark/quantize-drafter.ts +1 -1
  283. package/src/spec/generate.ts +1 -1
  284. package/src/spec/glm52-mtp-graph.ts +29 -0
  285. package/src/spec/glm52-mtp-rows.ts +132 -0
  286. package/src/spec/glm52-mtp-source.ts +54 -137
  287. package/src/spec/glm52-mtp-state.ts +33 -0
  288. package/src/spec/ngram-source.ts +93 -21
  289. package/src/spec/projected-draft-rows.ts +73 -0
  290. package/src/spec/qwen-mtp-module.ts +171 -0
  291. package/src/spec/qwen-mtp-rows.ts +219 -0
  292. package/src/spec/qwen-mtp-source.ts +147 -195
  293. package/src/spec/qwen-mtp-state.ts +30 -0
  294. package/src/spec/serve-loop.ts +201 -201
  295. package/src/spec/source.ts +110 -13
  296. package/src/spec/two-model-rows.ts +61 -0
  297. package/src/spec/two-model.ts +44 -102
  298. package/src/ssd-cache.ts +99 -19
  299. package/src/ssd-durability.ts +36 -20
  300. package/src/tool-call.ts +51 -14
  301. package/src/train/flash-cce.ts +2 -2
  302. package/src/train/forward.ts +6 -0
  303. package/src/train/loss.ts +1 -1
  304. package/src/train/prefix-shared.ts +5 -1
  305. package/src/train/segmented.ts +5 -3
  306. package/src/train/trainer.ts +6 -6
  307. package/src/vision/prompt.ts +1 -1
  308. package/src/vision/qwen3vl-tower.ts +52 -10
  309. package/src/web/app.html +1 -1
  310. package/src/web/app.js +62 -60
  311. package/src/web/src/adapters-panel.ts +3 -3
  312. package/src/web/src/assistant.ts +2 -36
  313. package/src/web/src/chat.ts +4 -4
  314. package/src/web/src/composer.ts +4 -4
  315. package/src/web/src/hub.ts +1 -1
  316. package/src/web/src/markdown.ts +0 -0
  317. package/src/web/src/memory-panel.ts +1 -1
  318. package/src/web/src/model-picker.ts +1 -1
  319. package/src/web/src/palette.ts +1 -1
  320. package/src/web/src/protocol.ts +3 -39
  321. package/src/web/src/sessions.ts +1 -1
  322. package/src/web/src/ui-catalog.ts +4 -18
  323. package/src/web-assets.ts +47 -0
  324. package/src/weights.ts +55 -9
  325. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  326. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  327. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  328. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  329. package/docs/archive/investigations/hlg-ab.html +0 -34
  330. package/docs/archive/investigations/hlg-explorer.html +0 -238
  331. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  332. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  333. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  334. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  335. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  336. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  337. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  338. package/docs/archive/investigations/hlg-report.html +0 -262
  339. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  340. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  341. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  342. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  343. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  344. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  345. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  346. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  347. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  348. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  349. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  350. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  351. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  352. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  353. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  354. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  355. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  356. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  357. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  358. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  359. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  360. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  361. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  362. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  363. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  364. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  365. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  366. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  367. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  368. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  369. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  370. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  371. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  372. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  373. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  374. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  375. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  376. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  377. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  378. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  379. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  380. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  381. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  382. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  383. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  384. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  385. package/docs/archive/mlx-bun-lab-report.html +0 -275
  386. package/docs/archive/wwdc-mlx-bun.html +0 -848
  387. package/docs/design/adapters-end-to-end.md +0 -205
  388. package/docs/design/batching-perf-path.md +0 -255
  389. package/docs/design/batching-v2-plan.md +0 -529
  390. package/docs/design/bucketing-stage.md +0 -197
  391. package/docs/design/compat-cli-surface-design.md +0 -230
  392. package/docs/design/docs-reorg-plan.md +0 -165
  393. package/docs/design/dspark-serving-program.md +0 -371
  394. package/docs/design/dspark-speculative-decoding.md +0 -318
  395. package/docs/design/grammar-spec-batching-integration.md +0 -301
  396. package/docs/design/memory-inference-path.md +0 -422
  397. package/docs/design/memory-synthesis.md +0 -287
  398. package/docs/design/memory-system.md +0 -237
  399. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  400. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  401. package/docs/design/omlx-adoption-map.md +0 -98
  402. package/docs/design/orpo-dynamic-lambda.md +0 -220
  403. package/docs/design/orpo-future-enhancements.md +0 -340
  404. package/docs/design/paged-kv-cache.md +0 -214
  405. package/docs/design/parallel-slots.md +0 -567
  406. package/docs/design/parity-tier-dag.md +0 -161
  407. package/docs/design/repo-cleanup-plan.md +0 -231
  408. package/docs/design/runtime-isolation.md +0 -263
  409. package/docs/design/segmented-backward-training.md +0 -543
  410. package/docs/design/spec-decode-larger-targets.md +0 -229
  411. package/docs/design/ssd-kv-cold-tier.md +0 -415
  412. package/docs/design/structured-output.md +0 -484
  413. package/docs/design/tauri-desktop-app.md +0 -127
  414. package/docs/design/the-dreaming-master-plan.md +0 -445
  415. package/docs/design/turboquant-kv.md +0 -235
  416. package/docs/design/turboquant-weights.md +0 -467
  417. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  418. package/docs/design/web-chat-beat-matrix.md +0 -315
  419. package/docs/design/write-pipeline-entity.md +0 -468
  420. package/docs/planning/IDEAS.md +0 -69
  421. package/docs/reference/embedding.md +0 -197
  422. package/docs/reference/features-matrix.md +0 -101
  423. package/docs/reference/orpo-quickstart.md +0 -135
  424. package/src/flags.ts +0 -11
  425. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  426. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  427. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  428. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  429. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  430. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  431. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  432. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  433. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
package/src/generate.ts CHANGED
@@ -4,10 +4,13 @@
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 {
13
+ Dtype,
11
14
  activeMemory,
12
15
  clearCache,
13
16
  maxRecommendedWorkingSetSize,
@@ -16,30 +19,61 @@ import {
16
19
  synchronize,
17
20
  } from "./mlx/ffi";
18
21
  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";
22
+ import { KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
23
+ import { maybePageKv } from "./backends/mlx/request-state-policy";
24
+ export { maybePageKv } from "./backends/mlx/request-state-policy";
22
25
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
23
- import { diffusionGenerate } from "./diffusion/diffusion-generate";
26
+ import { denoiseAsync } from "./diffusion/diffusion-generate";
27
+ import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
24
28
  import type { RuntimeModel } from "./model/factory";
29
+ import {
30
+ assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
31
+ type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
32
+ } from "./backends/mlx/autoregressive";
33
+ import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
34
+ import { nextPrefillStep } from "./inference/prefill";
35
+ import { evalCacheState, executeMlxPrefillStep } from "./backends/mlx/prefill";
36
+ import { appendFillHidden } from "./backends/mlx/fill-append";
37
+ export { evalCacheState } from "./backends/mlx/prefill";
38
+ import type { TokenLogprobs } from "./contracts/generation";
39
+ export type { TokenLogprobs } from "./contracts/generation";
25
40
  import type { PromptResponseTrace } from "./serve/prompt-response-trace";
26
41
  import type { KvQuantSpec, TurboQuantScheme } from "./config";
27
- import { flagOn } from "./flags";
28
- import { runtimeValue } from "./runtime-config";
42
+ import { runtimeConfig, runtimeValue, withRuntimeConfig, type RuntimeConfig } from "./runtime-config";
29
43
  import {
30
44
  disposeStepExtras,
31
45
  makeStepSampler,
46
+ readStepExtras,
32
47
  stepExtrasArrays,
33
48
  type LogitsProcessorOptions,
34
49
  type SamplerOptions,
35
50
  type StepExtras,
36
51
  } from "./sampler";
37
52
  import type { GrammarController } from "./grammar";
53
+ import {
54
+ fillTraceEnabled,
55
+ fillTracePath,
56
+ type FillTraceRecord,
57
+ resolveFillMode,
58
+ type FillSession,
59
+ type FillStats,
60
+ type Proposal,
61
+ } from "./fill/fill-session";
38
62
 
39
63
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
64
+ /** Resolved host policy. Direct compatibility calls resolve from their
65
+ * captured binding when this is absent. */
66
+ decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
67
+ /** Cooperatively cancel at prefill/decode boundaries; pending native work
68
+ * finishes before owned resources are released. */
69
+ signal?: AbortSignal;
40
70
  maxTokens?: number;
41
71
  eosTokenIds?: number[];
42
72
  prefillChunkSize?: number;
73
+ /** Whether the request supplied its seed explicitly. Durable generation
74
+ * checkpoints use this to distinguish a reproducible user policy from the
75
+ * server-generated seed that must be recovered from checkpoint metadata. */
76
+ seedWasExplicit?: boolean;
43
77
  /** Fired once during prefill, at `snapshotAt` tokens (default: the full
44
78
  * prompt) and BEFORE any further KV is written — the one moment the
45
79
  * caches hold exactly that prefix (post-wrap rings can't be rewound
@@ -51,6 +85,25 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
51
85
  * there would make the entry untrimmable-and-divergent). Prefill is
52
86
  * split at this index when it falls mid-prompt. */
53
87
  snapshotAt?: number;
88
+ /** Resume an interrupted autoregressive generation from a durable KV
89
+ * checkpoint. `cache` must cover every token in `promptTokens` exactly;
90
+ * `initialPendingToken` is the already-sampled next token, so no cache
91
+ * rewind is required (important for recurrent/untrimmable layers). */
92
+ initialPendingToken?: number;
93
+ /** Number of completion tokens already emitted before this resumed run. */
94
+ initialGeneratedTokens?: number;
95
+ /** Original request prompt length used for protocol usage accounting when
96
+ * `promptTokens` also contains replayed completion tokens. */
97
+ originalPromptTokens?: number;
98
+ /** Periodic, safe decode-boundary checkpoint hook. The cache covers
99
+ * `cacheTokens`; `pendingToken` has been sampled but not emitted. */
100
+ checkpointEveryTokens?: number;
101
+ onDecodeCheckpoint?: (state: {
102
+ cacheTokens: number[];
103
+ caches: Cache[];
104
+ generatedTokens: number;
105
+ pendingToken: number;
106
+ }) => void | Promise<void>;
54
107
  /** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
55
108
  * prompt tokens are treated as already prefilled; only the suffix is
56
109
  * forwarded. Caller keeps ownership — generate() will not dispose. */
@@ -61,7 +114,7 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
61
114
  promptEmbeddings?: MlxArray;
62
115
  /** bool [L] marking image tokens (bidirectional attention among them).
63
116
  * MUST be unset when the prompt contains any audio — audio prompts run
64
- * fully causal (audio-input-plan.md §3.3 Q1). */
117
+ * fully causal (docs/design/generic-model-support.md §3.3 Q1). */
65
118
  imageMask?: MlxArray;
66
119
  /** bool [L] marking ALL multimodal soft tokens (image | audio) for
67
120
  * per-layer-input id zeroing (e2b/e4b), decoupled from imageMask so
@@ -70,29 +123,28 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
70
123
  * Caller keeps ownership. */
71
124
  multimodalMask?: MlxArray;
72
125
  /** Quantize full-attention KV caches to this many bits (4 or 8).
73
- * Rotating (sliding-window) caches stay bf16 — they're window-capped
74
- * and upstream rotating-cache quantization is NYI. */
126
+ * Full-attention and rotating caches both convert when populated. */
75
127
  kvBits?: number;
76
128
  kvGroupSize?: number;
77
129
  /** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
78
130
  * Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
79
- * the cache list (== layer index for the donor prefix); entries for
80
- * rotating/sliding caches are skipped until Phase 9. */
131
+ * the cache list (== layer index for the donor prefix), including
132
+ * rotating/sliding caches. */
81
133
  kvConfig?: KvQuantSpec[];
82
134
  /** Convert once a cache's offset reaches this (uniform-kvBits default
83
135
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
84
136
  quantizedKvStart?: number;
85
- /** TurboQuant scheme (docs/design/turboquant-kv.md): rotation-based KV
137
+ /** TurboQuant scheme (docs/design/turboquant.md): rotation-based KV
86
138
  * quantization, a CLI-only runtime lever in the same class as uniform
87
139
  * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
88
- * checks kvBits/kvConfig first, so set at most one). Full-attention
140
+ * checks turboQuant first, so set at most one). Full-attention
89
141
  * KVCache layers convert via TurboQuantKVCache.fromKVCache;
90
142
  * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
91
143
  * warning names the limitation, never a throw. */
92
144
  turboQuant?: TurboQuantScheme;
93
- /** OPTIONAL paged KV storage (docs/design/paged-kv-cache.md): fresh
145
+ /** OPTIONAL paged KV storage (docs/design/kv-cache.md): fresh
94
146
  * full-attention KVCache layers are replaced with PagedKVCache (block
95
- * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
147
+ * pool + gather-to-contiguous) before prefill. Scope: B1/B>1
96
148
  * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
97
149
  * turboQuant/draft/compiled decode (callers refuse the combos; the
98
150
  * cache swap itself only ever touches plain empty KVCache entries).
@@ -123,164 +175,90 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
123
175
  * advances the matcher, and awaits the async mask precompute (which overlaps
124
176
  * the GPU forward). Non-grammar requests keep the fast pipelined loop. */
125
177
  grammar?: GrammarController;
178
+ /** Token fast-forwarding (src/fill/, docs/design/speculative-decoding.md
179
+ * "Token fast-forwarding"): a per-request table of DETERMINED token spans
180
+ * (compiled from the request's `tools` + the chat template). When the
181
+ * stream enters one, the engine appends the whole span itself with ONE
182
+ * multi-token forward and resumes sampling after it.
183
+ *
184
+ * This is context extension, NOT speculation: nothing is drafted, verified,
185
+ * or rolled back, and no comparison is made against what the model would
186
+ * have produced. Injection bypasses the sampler, which is a documented
187
+ * behavior-policy deviation at temperature > 0 — hence opt-in
188
+ * (MLX_BUN_FILL=strict) and the composition refusals in shouldUseFill.
189
+ * Serial lane only: the batch scheduler and the spec loop never read it. */
190
+ fill?: FillSession;
126
191
  }
127
192
 
128
193
  export function shouldUseGrammarJump(
129
194
  options: Pick<GenerateOptions, "grammar" | "logprobs" | "topLogprobs">,
195
+ runtime: RuntimeConfig = runtimeConfig(),
130
196
  ): boolean {
131
197
  return options.grammar !== undefined &&
132
- flagOn("MLX_BUN_GRAMMAR_JUMP", false) &&
198
+ runtime.flag("MLX_BUN_GRAMMAR_JUMP", false) &&
133
199
  !options.logprobs &&
134
200
  !(options.topLogprobs && options.topLogprobs > 0);
135
201
  }
136
202
 
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
- }
203
+ /** Composition gate for token fast-forwarding, enforced inside the engine
204
+ * (the serve layer refuses more shapes it alone can see — a user-fixed seed,
205
+ * a mounted draft model, continuous placement: src/serve/request-prep.ts,
206
+ * src/serve/chat-stage.ts). Throws when MLX_BUN_FILL names the unbuilt echo
207
+ * tier — an operator who asked for K3c must not get K3b silently.
208
+ * - grammar: forced-token production is the grammar's job; two producers in
209
+ * one iteration is forbidden (asserted in the loop).
210
+ * - logprobs/top_logprobs: injected tokens are never sampled, so they have
211
+ * no distribution row (same rule as shouldUseGrammarJump).
212
+ * - promptEmbeddings: vision/audio prompts (and mRoPE) — untested shape.
213
+ * - kvBits/kvConfig/turboQuant: post-conversion multi-token append is
214
+ * L-generic but unvalidated; refuse in v1 rather than pay it silently. */
215
+ export function shouldUseFill(
216
+ options: Pick<
217
+ GenerateOptions,
218
+ "fill" | "grammar" | "logprobs" | "topLogprobs" | "promptEmbeddings"
219
+ | "kvBits" | "kvConfig" | "turboQuant"
220
+ >,
221
+ runtime: RuntimeConfig = runtimeConfig(),
222
+ ): boolean {
223
+ if (!options.fill) return false;
224
+ if (resolveFillMode(runtime.value("MLX_BUN_FILL") ?? "off") === "off") return false;
225
+ return options.grammar === undefined &&
226
+ !options.logprobs &&
227
+ !(options.topLogprobs && options.topLogprobs > 0) &&
228
+ options.promptEmbeddings === undefined &&
229
+ !options.kvBits &&
230
+ !options.kvConfig?.length &&
231
+ options.turboQuant === undefined;
165
232
  }
166
233
 
167
- /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
168
- * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
169
- * caches convert too — Phase 9):
170
- * - per-layer bits/group_size selection (kvConfig overrides kvBits,
171
- * matching optiq's --kv-config precedence; shipped kv_config.json
172
- * files cover EVERY cache-owning layer, sliding ones included —
173
- * verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
174
- * rotating quantization engages straight from the config; uniform
175
- * kvBits — like optiq --kv-bits — reaches them too), and
176
- * - STREAMING conversion (optiq streaming_kv_quant / serve.py
177
- * patched_maybe_quantize): eval each layer's quantized triples and
178
- * clear the buffer pool before building the next layer's conversion.
179
- * Lazily batching every layer's toQuantized into one eval pins ALL
180
- * layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
181
- * the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
182
- * 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). */
234
+ /** Compatibility entry point for callers that perform a single conversion.
235
+ * Execution sessions compose createKvMaintenance once and reuse it. */
184
236
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
185
- const { kvBits, kvConfig, turboQuant } = options;
186
- if (turboQuant) {
187
- maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
188
- return;
189
- }
190
- if (!kvBits && !kvConfig?.length) return;
191
- const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
192
- const byLayer = kvConfig?.length
193
- ? new Map(kvConfig.map((e) => [e.layerIdx, e]))
194
- : null;
195
- for (let i = 0; i < cache.length; i++) {
196
- const c = cache[i]!;
197
- if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
198
- // OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
199
- // runs bf16, then the populated cache is quantized before decode.
200
- // Converting empty caches at start=0 makes prefill itself quantized
201
- // and diverges from the oracle path.
202
- if (c.offset === 0) continue;
203
- if (byLayer) {
204
- const e = byLayer.get(i);
205
- if (!e) continue;
206
- cache[i] = c.toQuantized(e.groupSize, e.bits);
207
- } else {
208
- cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
209
- }
210
- // Streaming half: materialize THIS layer's conversion now so its bf16
211
- // source (already released by toQuantized) frees before the next layer
212
- // converts — the transient stays ~one layer, not the whole cache.
213
- ops.evalAll(cache[i]!.state());
214
- clearCache();
215
- }
216
- }
217
-
218
- /** Paged-KV conversion (docs/design/paged-kv-cache.md): swap each FRESH
219
- * plain full-attention KVCache for a PagedKVCache sized to hold
220
- * `capacityTokens` (prompt + maxTokens — known exactly at generate()
221
- * setup, so pool exhaustion is unreachable absent an accounting bug).
222
- * Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
223
- * prefill: paging changes storage layout, not arithmetic, so there is no
224
- * "convert when populated" trigger. Sliding-window (RotatingKVCache)
225
- * layers keep today's scheme — mixed paged-full + rotating-sliding is
226
- * the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
227
- * entirely: the serve lane bypasses prompt-cache reuse for paged
228
- * requests, so this only guards library callers. */
229
- export function maybePageKv(
230
- cache: Cache[], options: GenerateOptions, capacityTokens: number,
231
- ): void {
232
- if (!options.pagedKv) return;
233
- if (cache.some((c) => c.offset > 0)) return;
234
- const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
235
- for (let i = 0; i < cache.length; i++) {
236
- if (cache[i] instanceof KVCache) {
237
- cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
238
- cache[i] = new PagedKVCache(capacityTokens, blockSize);
239
- }
240
- }
237
+ createKvMaintenance(options)(cache);
241
238
  }
242
239
 
243
- /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
244
- * documented v1 non-goal (docs/design/turboquant-kv.md) — they stay bf16
245
- * rather than throwing, so mixed full-attention/sliding-window models (e.g.
246
- * Gemma) still serve correctly under --kv-quant turbo. */
247
- let warnedTurboRotating = false;
240
+ /** Emitted once per process: token fast-forwarding skips models with
241
+ * sliding-window layers in v1 (see the gate in generateInner). */
242
+ let warnedFillRotating = false;
248
243
 
249
- /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
250
- * above): only plain full-attention KVCache instances convert, via
251
- * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
252
- * never throw). Same offset===0 skip-empty-cache rule as the affine path. */
253
- function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
254
- for (let i = 0; i < cache.length; i++) {
255
- const c = cache[i]!;
256
- if (c instanceof RotatingKVCache) {
257
- if (!warnedTurboRotating) {
258
- warnedTurboRotating = true;
259
- console.warn(
260
- "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
261
- "(full-attention only) — docs/design/turboquant-kv.md.",
262
- );
263
- }
264
- continue;
265
- }
266
- if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
267
- const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
268
- cache[i] = tq;
269
- // state() allocates fresh trimmed slice views for this cache kind
270
- // (see evalCacheState) — dispose after materializing (throw included),
271
- // or they leak.
272
- const state = tq.state();
273
- try {
274
- ops.evalAll(state);
275
- } finally {
276
- for (const a of state) a.dispose();
277
- }
278
- clearCache();
279
- }
280
- }
244
+ /** The rewind surface a verify-policy fill needs. `Cache` declares the
245
+ * spec-round trio as optional members, but the cache list is a UNION with
246
+ * GLM's MLACache (which declares none of them), so the union has no such
247
+ * property to read. One narrowing helper keeps the call sites honest — every
248
+ * use is still optional-chained. */
249
+ type RewindableCache = {
250
+ isTrimmable(): boolean;
251
+ trim(n: number, bypass?: boolean): void;
252
+ specRoundBegin?(): void;
253
+ specRoundCommit?(): void;
254
+ specRoundRollback?(keep: number): void;
255
+ };
256
+ const rewindable = (c: unknown): RewindableCache => c as RewindableCache;
281
257
 
282
258
  export interface GenerateStats {
283
259
  promptTokens: number;
260
+ /** An observed terminal cause takes precedence over the token-count limit. */
261
+ finishReason?: "stop" | "length";
284
262
  /** Prompt tokens skipped via a pre-warmed cache. */
285
263
  cachedTokens: number;
286
264
  generatedTokens: number;
@@ -302,6 +280,9 @@ export interface GenerateStats {
302
280
  rejected?: number; rounds?: number; acceptanceLengths?: number[];
303
281
  tokensPerForward?: number; forwardsSaved?: number;
304
282
  };
283
+ /** Token fast-forwarding telemetry (serial lane, MLX_BUN_FILL=strict).
284
+ * Present only when a fill table was actually armed for this generation. */
285
+ fill?: FillStats;
305
286
  }
306
287
 
307
288
  export interface GenerateDiagnostics {
@@ -318,16 +299,23 @@ export interface GeneratedToken {
318
299
 
319
300
  export class Generation implements AsyncIterable<GeneratedToken> {
320
301
  stats: GenerateStats | null = null;
321
- readonly #iter: AsyncGenerator<GeneratedToken, GenerateStats>;
302
+ readonly #next: () => Promise<IteratorResult<GeneratedToken, GenerateStats>>;
303
+ readonly #return: () => Promise<IteratorResult<GeneratedToken, GenerateStats>>;
322
304
 
323
- constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>) {
324
- this.#iter = iter;
305
+ constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>, runtime = runtimeConfig()) {
306
+ // Async generators resume in the caller's context on every next/return.
307
+ // Bind both operations so consumer awaits and early close cannot change
308
+ // the runtime snapshot seen by kernels or their cleanup.
309
+ const next = iter.next.bind(iter);
310
+ const close = () => iter.return(undefined as unknown as GenerateStats);
311
+ this.#next = () => withRuntimeConfig(runtime, next);
312
+ this.#return = () => withRuntimeConfig(runtime, close);
325
313
  }
326
314
 
327
315
  async *[Symbol.asyncIterator](): AsyncGenerator<GeneratedToken> {
328
316
  try {
329
317
  while (true) {
330
- const r = await this.#iter.next();
318
+ const r = await this.#next();
331
319
  if (r.done) {
332
320
  this.stats = r.value;
333
321
  return;
@@ -340,7 +328,7 @@ export class Generation implements AsyncIterable<GeneratedToken> {
340
328
  // disposal, wired/adapter scopes) and capture the stats its
341
329
  // early-return path still reports.
342
330
  if (this.stats === null) {
343
- const r = await this.#iter.return(undefined as unknown as GenerateStats);
331
+ const r = await this.#return();
344
332
  if (r.done && r.value) this.stats = r.value;
345
333
  }
346
334
  }
@@ -359,22 +347,17 @@ export class Generation implements AsyncIterable<GeneratedToken> {
359
347
  // Scope semantics match the reference: set → generate → synchronize →
360
348
  // restore; nothing stays pinned between generations. Re-entrant: only
361
349
  // the outermost wiring scope touches the limit.
362
- // macOS 26.6 reports a 24.96 GiB recommended set on this 24 GB machine.
363
- // The old 0.75 fraction therefore stopped wiring the 13-16 GiB Qwen/GLM
364
- // models even though they page heavily without an explicit wired limit.
350
+ // macOS 26.6 reports a 26.8e9 B (24.96 GiB, 0.78 × RAM) recommended set on
351
+ // a 32 GB M1 Max (2026-09-08; the earlier "24 GB machine" attribution was
352
+ // wrong — 24.96 GiB exceeds a 24 GB box's RAM). At 0.75 × that the old
353
+ // fraction stopped wiring the 13-16 GiB Qwen/GLM models even though they
354
+ // page heavily without an explicit wired limit.
365
355
  // Keep smaller 8-9 GiB models unwired while covering the large-model class.
366
356
  const WIRE_THRESHOLD = 0.5;
367
357
  let wiredScopeDepth = 0;
368
358
  let wiredOldLimit = 0;
369
359
 
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
- };
360
+ type WiredModelMemory = MlxModelMemory;
378
361
 
379
362
  /** Bytes whose MLX graph must stay resident while a model executes. */
380
363
  export function wiredWorkingSetBytes(model: WiredModelMemory): number {
@@ -422,19 +405,59 @@ export function generate(
422
405
  options: GenerateOptions = {},
423
406
  diagnostics: GenerateDiagnostics = {},
424
407
  ): Generation {
408
+ return bindGeneration(model)(promptTokens, options, diagnostics);
409
+ }
410
+
411
+ /** Resolve the compatibility model once at host construction. Every request
412
+ * borrows the same binding and immutable runtime snapshot. */
413
+ export function bindGeneration(model: RuntimeModel): (
414
+ promptTokens: number[], options?: GenerateOptions, diagnostics?: GenerateDiagnostics,
415
+ ) => Generation {
425
416
  // DiffusionGemma is non-autoregressive: route to the denoising engine instead
426
417
  // of the AR decode loop. Same Generation/GenerateStats contract so the CLI and
427
418
  // 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);
419
+ if (!(model instanceof DiffusionGemmaModel)) {
420
+ const binding = bindLegacyAutoregressiveModel(model);
421
+ return (prompt, options, diagnostics) => generateAutoregressive(binding, prompt, options, diagnostics);
422
+ }
423
+ const binding = bindLegacyDenoisingModel(model);
424
+ return (prompt, options) => generateDenoising(binding, prompt, options);
425
+ }
426
+
427
+ /** Execute a supplied backend binding through the same AR loop and resource
428
+ * scopes as generate(). Callers serialize shared model/adapter access. Replacing
429
+ * this binding replaces graph, cache construction, media, and optional decode
430
+ * together; it never borrows another model's compiled execution implicitly. */
431
+ export function generateAutoregressive(
432
+ binding: MlxAutoregressiveBinding,
433
+ promptTokens: number[],
434
+ options: GenerateOptions = {},
435
+ diagnostics: GenerateDiagnostics = {},
436
+ ): Generation {
437
+ const runtime = binding.runtime ?? runtimeConfig();
438
+ let inner = generateInner(binding, promptTokens, options, diagnostics, runtime);
439
+ if (options.adapters?.length && binding.adapters) {
440
+ inner = adapterScoped({ loraState: binding.adapters }, options.adapters, inner);
441
+ }
442
+ if (binding.memory.expertRuntime?.finishUsage || binding.memory.expertRuntime?.flushUsage)
443
+ inner = usageScoped(binding.memory, inner);
444
+ return new Generation(modelNeedsWiredLimit(binding.memory, undefined,
445
+ runtime.value("MLX_BUN_FORCE_WIRE") === "1") ? wiredScoped(inner) : inner, runtime);
446
+ }
447
+
448
+ /** Denoising bindings use their own state and feedback graph. Callers hold
449
+ * the native runtime's exclusive generation lease, including its global RNG. */
450
+ export function generateDenoising<State>(
451
+ binding: MlxDenoisingBinding<State>, promptTokens: number[], options: GenerateOptions = {},
452
+ ): Generation {
453
+ let inner = generateDiffusionInner(binding, promptTokens, options);
454
+ if (options.adapters?.length && binding.adapters)
455
+ inner = adapterScoped({ loraState: binding.adapters }, options.adapters, inner);
456
+ if (binding.memory.expertRuntime?.finishUsage || binding.memory.expertRuntime?.flushUsage)
457
+ inner = usageScoped(binding.memory, inner);
458
+ const runtime = binding.runtime ?? runtimeConfig();
459
+ return new Generation(modelNeedsWiredLimit(binding.memory, undefined,
460
+ runtime.value("MLX_BUN_FORCE_WIRE") === "1") ? wiredScoped(inner) : inner, runtime);
438
461
  }
439
462
 
440
463
  /** Non-autoregressive diffusion generation, adapted to the AR Generation
@@ -442,11 +465,12 @@ export function generate(
442
465
  * streams the emitted tokens. v1: greedy (temperature 0, confidence-threshold
443
466
  * sampler — the OptiQ default); per-block intra-stream + temperature>0
444
467
  * (categorical) are follow-ups. */
445
- async function* generateDiffusionInner(
446
- model: DiffusionGemmaModel,
468
+ async function* generateDiffusionInner<State>(
469
+ binding: MlxDenoisingBinding<State>,
447
470
  promptTokens: number[],
448
471
  options: GenerateOptions,
449
472
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
473
+ options.signal?.throwIfAborted();
450
474
  const t0 = performance.now();
451
475
  const maxTokens = options.maxTokens ?? 256;
452
476
  // A fresh random canvas seed per request unless the caller pins one.
@@ -456,40 +480,52 @@ async function* generateDiffusionInner(
456
480
  : BigInt(Math.floor(Math.random() * 0x7fffffff));
457
481
  // The shipped checkpoint's tokenizer stops on {1, 106}; union any caller eos.
458
482
  const eos = [...new Set([1, 106, ...(options.eosTokenIds ?? [])])];
459
- const result = diffusionGenerate(model, promptTokens, {
483
+ const result = await denoiseAsync(binding.graph, promptTokens, {
460
484
  maxTokens,
461
485
  sampler: "confidence-threshold",
462
486
  temperature: 0,
463
487
  eosTokenIds: eos,
464
488
  seed,
465
489
  visionPixels: options.visionPixels,
466
- });
490
+ }, options.signal);
467
491
  const decodeMs = performance.now() - t0;
468
492
  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
- };
493
+ let failure: { error: unknown } | undefined;
494
+ try {
495
+ for (const token of result.tokens) {
496
+ options.signal?.throwIfAborted();
497
+ yield { token, index: index++ };
498
+ }
499
+ } catch (error) { failure = { error }; }
500
+ finally {
501
+ // A stop parser may close delivery before the finished canvas is exhausted.
502
+ // Settle the emitted count on return(), without suppressing cancellation/errors.
503
+ if (failure) throw failure.error;
504
+ return {
505
+ promptTokens: promptTokens.length,
506
+ cachedTokens: 0,
507
+ generatedTokens: index,
508
+ prefillTps: 0,
509
+ decodeTps: index / Math.max(decodeMs / 1000, 1e-9),
510
+ prefillMs: 0,
511
+ decodeMs,
512
+ cacheTokens: [],
513
+ };
514
+ }
480
515
  }
481
516
 
482
517
  /** Hold the model's active-adapter list for exactly this generation. */
483
518
  async function* adapterScoped(
484
- model: RuntimeModel,
519
+ model: { loraState: { active: string[] } },
485
520
  adapters: string[],
486
521
  inner: AsyncGenerator<GeneratedToken, GenerateStats>,
487
522
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
523
+ const previous = model.loraState.active;
488
524
  model.loraState.active = adapters;
489
525
  try {
490
526
  return yield* inner;
491
527
  } finally {
492
- model.loraState.active = [];
528
+ model.loraState.active = previous;
493
529
  }
494
530
  }
495
531
 
@@ -549,36 +585,36 @@ export async function withModelUsageFlush<T>(
549
585
  }
550
586
  }
551
587
 
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
588
  async function* generateInner(
571
- model: RuntimeModel,
589
+ binding: MlxAutoregressiveBinding,
572
590
  promptTokens: number[],
573
591
  options: GenerateOptions,
574
592
  diagnostics: GenerateDiagnostics,
593
+ runtime: RuntimeConfig,
575
594
  ): AsyncGenerator<GeneratedToken, GenerateStats> {
595
+ if (options.signal?.aborted) {
596
+ options.grammar?.dispose();
597
+ options.signal.throwIfAborted();
598
+ }
576
599
  const {
577
600
  maxTokens = 512,
578
- eosTokenIds = model.config.eosTokenIds,
601
+ eosTokenIds = binding.eosTokenIds,
579
602
  prefillChunkSize = 2048,
580
603
  } = options;
581
604
 
605
+ try {
606
+ assertMlxAutoregressiveBinding(binding);
607
+ if (options.promptEmbeddings && !binding.forwardEmbeddings)
608
+ throw new Error("AR binding does not support prompt embeddings");
609
+ if (options.adapters?.length && !binding.adapters)
610
+ throw new Error("AR binding does not support adapters");
611
+ } catch (error) {
612
+ options.grammar?.dispose();
613
+ throw error;
614
+ }
615
+ const graph = binding.graph;
616
+ const maintainKv = createKvMaintenance(options);
617
+
582
618
  // logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
583
619
  // Everything below is gated: when neither flag is set, no extra ops, evals,
584
620
  // or readbacks happen on the hot path.
@@ -603,37 +639,59 @@ async function* generateInner(
603
639
  mechanism: diagnostics.mechanism ?? "serial",
604
640
  });
605
641
  const ownsCache = !options.cache;
606
- const cache = options.cache ?? model.makeCache();
642
+ const resuming = options.initialPendingToken !== undefined;
643
+ let cache: Cache[] = options.cache ?? [];
644
+ try {
645
+ if (ownsCache) cache = binding.makeCache();
646
+ if (!cache.length) throw new Error("AR binding returned an empty cache");
647
+ const cached = cache[0]!.offset;
648
+ if ((!resuming && cached >= promptTokens.length) ||
649
+ (resuming && cached !== promptTokens.length))
650
+ throw new Error(
651
+ resuming
652
+ ? `resumed cache (${cached} tokens) must exactly cover the resume prefix (${promptTokens.length})`
653
+ : `pre-warmed cache (${cached} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
654
+ );
655
+ // Replace fresh full-attention caches before any forward. The deepest
656
+ // write is prompt + maxTokens - 1, so capacity covers every decode step.
657
+ maybePageKv(cache, options, promptTokens.length + maxTokens);
658
+ } catch (error) {
659
+ if (ownsCache) for (const state of cache) state.dispose();
660
+ stepSampler.dispose();
661
+ options.grammar?.dispose();
662
+ closeBatchSetup?.();
663
+ closePrefill?.();
664
+ throw error;
665
+ }
607
666
  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);
617
- closeBatchSetup?.();
618
- /** Device-side logprob capture for one step, computed from the SAME lp the
619
- * sampler saw (post-processors, pre-truncation) — read back lazily with the
620
- * token so decode pipelining is preserved. */
621
- /** Read extras back to JS (forces eval — they were async-dispatched with the
622
- * token) without appending casts behind the next decode step. */
623
- const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
624
- if (!e) return undefined;
625
- const out: TokenLogprobs = {};
626
- if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
627
- if (e.topIdx && e.topVals) {
628
- const ids = e.topIdx.toIntTokens();
629
- const vals = e.topVals.toFloat32Host();
630
- out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
631
- (a, b) => b.logprob - a.logprob,
667
+ // Token fast-forwarding gate (see shouldUseFill). RotatingKVCache (sliding
668
+ // window) layers append multi-token writes through #updateConcat — O(window)
669
+ // per append rather than the O(L) a plain ring pays — so v1 warns and skips
670
+ // the whole feature for those models rather than paying it silently.
671
+ let fillOn = shouldUseFill(options, runtime);
672
+ if (fillOn && cache.some((c) => c instanceof RotatingKVCache)) {
673
+ fillOn = false;
674
+ if (!warnedFillRotating) {
675
+ warnedFillRotating = true;
676
+ console.warn(
677
+ "[fill] sliding-window (RotatingKVCache) layers skip token " +
678
+ "fast-forwarding in v1 (multi-token append is O(window) there) — " +
679
+ "docs/design/speculative-decoding.md.",
632
680
  );
633
681
  }
634
- disposeStepExtras(e);
635
- return out;
636
- };
682
+ }
683
+ const fillTrace = fillOn && fillTraceEnabled();
684
+ const fillTraceFile = fillOn ? fillTracePath() : null;
685
+ // Verify-policy proposals (echo tier) need the rejected tail rewound. That
686
+ // is the SAME contract the spec lane's rounds use: trimmable caches drop the
687
+ // tail; recurrent caches (SSMCache — gated-DeltaNet state, untrimmable)
688
+ // restore a pre-round snapshot and bit-exactly replay the accepted prefix.
689
+ // A model whose caches can do neither still gets assert-policy fills; verify
690
+ // proposals are dropped and counted (stats.verifyUnsupported).
691
+ const verifyCapable = fillOn && cache.every(
692
+ (c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
693
+ );
694
+ closeBatchSetup?.();
637
695
 
638
696
  // logits [1,1,V] → sampled token array [1] (+ optional logprob capture,
639
697
  // all on-device)
@@ -654,35 +712,249 @@ async function* generateInner(
654
712
  let prefillMs = 0;
655
713
  let tDecode = 0;
656
714
  let decodeMs = 0;
657
- let generated = 0;
715
+ let generated = options.initialGeneratedTokens ?? 0;
716
+ let finishReason: GenerateStats["finishReason"];
658
717
  const forwarded: number[] = [];
659
718
  let pending: MlxArray | null = null;
660
719
  let nextPending: MlxArray | null = null;
661
720
  let pendingExtras: StepExtras | null = null;
662
721
  let nextExtras: StepExtras | null = null;
722
+ let decoder: MlxDecodeStep | null = null;
723
+ let appender: MlxTokenAppend | null = null;
663
724
  let finished = false;
664
725
  let threw = false;
726
+ let executionError: unknown;
727
+ let earlyUnforwardedToken: number | null = null;
665
728
  let closeTokenZero: (() => void) | undefined;
666
729
  const makeStats = (): GenerateStats => ({
667
- promptTokens: promptTokens.length,
668
- cachedTokens,
730
+ promptTokens: options.originalPromptTokens ?? promptTokens.length,
731
+ cachedTokens: Math.min(cachedTokens, options.originalPromptTokens ?? promptTokens.length),
669
732
  generatedTokens: generated,
733
+ ...(finishReason ? { finishReason } : {}),
670
734
  prefillMs,
671
735
  decodeMs,
672
- prefillTps: ((promptTokens.length - cachedTokens) / prefillMs) * 1000,
736
+ prefillTps: prefillMs > 0
737
+ ? ((promptTokens.length - cachedTokens) / prefillMs) * 1000
738
+ : 0,
673
739
  decodeTps: (generated / decodeMs) * 1000,
674
740
  cacheTokens: [...promptTokens, ...forwarded],
741
+ ...(fillOn ? { fill: options.fill!.stats } : {}),
675
742
  });
676
743
 
744
+ /** The one invariant that makes fill safe: the caches hold exactly
745
+ * prompt + everything `forwarded` records, so a fill append must forward
746
+ * ONLY the injected ids — the normal step already consumed `cur` and wrote
747
+ * its KV. Forwarding [token, ...ids] would duplicate a position and
748
+ * silently corrupt both the KV and PromptCache.put's key. Checked under
749
+ * MLX_BUN_FILL_TRACE=1 on both sides of the append. */
750
+ const assertFillAlignment = (where: string): void => {
751
+ const offset = cache[0]!.offset;
752
+ const want = promptTokens.length + forwarded.length;
753
+ if (offset !== want)
754
+ throw new Error(
755
+ `[fill] cache misalignment ${where}: cache offset ${offset} != ` +
756
+ `prompt ${promptTokens.length} + forwarded ${forwarded.length}`,
757
+ );
758
+ };
759
+
760
+ /** THE apply primitive for token fast-forwarding — ONE chunked forward
761
+ * carries a proposal into the KV (and the recurrent state), and BOTH
762
+ * policies ride it (src/fill/proposal.ts):
763
+ *
764
+ * - assert: the span is determined (a template scaffold). Append and move
765
+ * on. No readback, no checkpoint, no rewind. The in-flight sample for
766
+ * the position after `token` is dropped UNEXAMINED — a discarded
767
+ * pipeline dispatch, not a rejected draft.
768
+ * - verify: the span is likely (a copy from earlier in the session).
769
+ * Position 0 is checked BEFORE the forward against the in-flight sample
770
+ * (free, and a mismatch costs nothing — nothing has been written).
771
+ * Positions 1..m-1 are checked against the argmax already sitting in
772
+ * THIS forward's logits, so verification adds no pass over the weights.
773
+ * The rejected tail is rewound through the same cache contract the spec
774
+ * lane's rounds use, and decode resumes at the first disagreement.
775
+ *
776
+ * Returns the ids the engine must now emit (empty = no fill happened).
777
+ * Mutates nextPending/nextExtras/forwarded, exactly like the normal step. */
778
+ /** One trace record: the proposal next to the model's own token at every
779
+ * span position (`actual[0]` = in-flight sample, `actual[j]` = argmax after
780
+ * ids[j-1]). Appended as JSONL to MLX_BUN_FILL_TRACE=<file>. */
781
+ const traceProposal = (
782
+ proposal: Proposal, generatedNow: number, accepted: number, actual: number[],
783
+ ): void => {
784
+ const ids = proposal.ids;
785
+ let firstMismatch = -1;
786
+ for (let j = 0; j < ids.length && j < actual.length; j++)
787
+ if (actual[j] !== ids[j]) { firstMismatch = j; break; }
788
+ const dec = options.fill!.decode;
789
+ const rec: FillTraceRecord = {
790
+ ts: new Date().toISOString(), origin: proposal.origin, policy: proposal.policy,
791
+ generated: generatedNow, proposedLen: ids.length, accepted, firstMismatch,
792
+ proposed: ids, actual,
793
+ ...(dec ? { proposedText: dec(ids), actualText: dec(actual) } : {}),
794
+ };
795
+ try { appendFileSync(fillTraceFile!, JSON.stringify(rec) + "\n"); } catch { /* trace is best-effort */ }
796
+ };
797
+
798
+ const applyProposal = async (
799
+ proposal: Proposal, generatedNow: number,
800
+ ): Promise<number[]> => {
801
+ const fill = options.fill!;
802
+ const ids = proposal.ids;
803
+ const verify = proposal.policy === "verify";
804
+ if (verify && !verifyCapable) {
805
+ fill.noteVerifyUnsupported();
806
+ fill.commit(proposal, 0);
807
+ return [];
808
+ }
809
+ // The in-flight sample IS the model's own choice for position 0 (read for
810
+ // the trace under both policies; the served assert path never checks it).
811
+ const inFlight = verify || fillTraceFile ? ops.itemUint32(nextPending!) : -1;
812
+ if (verify) {
813
+ if (inFlight !== ids[0]) {
814
+ if (fillTraceFile) traceProposal(proposal, generatedNow, 0, [inFlight]);
815
+ fill.commit(proposal, 0);
816
+ return []; // nextPending survives; this becomes an ordinary step
817
+ }
818
+ } else {
819
+ fill.noteWastedSample();
820
+ }
821
+ if (fillTrace) assertFillAlignment("at fill entry");
822
+ nextPending!.dispose();
823
+ nextPending = null;
824
+ disposeStepExtras(nextExtras);
825
+ nextExtras = null;
826
+ // No-op under the fill exclusions (kv quant refuses fill); kept so the
827
+ // boundary stays correct if those exclusions ever loosen.
828
+ maintainKv(cache);
829
+
830
+ let checkpointMs = 0;
831
+ let roundOpen = false;
832
+ if (verify) {
833
+ const t0 = performance.now();
834
+ for (const c of cache) rewindable(c).specRoundBegin?.();
835
+ roundOpen = true;
836
+ checkpointMs += performance.now() - t0;
837
+ }
838
+ let accepted = ids.length;
839
+ let hf: MlxArray | null = null;
840
+ let logitsAll: MlxArray | null = null;
841
+ try {
842
+ const chunkSize = (state: readonly Cache[]) => {
843
+ const maxChunk = appender?.maxChunkSize(state) ?? 1;
844
+ return fill.appendChunkSize > 0 ? Math.min(fill.appendChunkSize, maxChunk) : maxChunk;
845
+ };
846
+ hf = await appendFillHidden(
847
+ !verify && appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph),
848
+ cache, ids, verify ? () => ids.length : chunkSize,
849
+ );
850
+ const [, Lf, Hf] = hf.shape as [number, number, number];
851
+ let pred: number[] | null = null;
852
+ if (verify || fillTraceFile) {
853
+ // Free logits: this forward already computed every span position's
854
+ // hidden state. argmax at j is the model's continuation after ids[j],
855
+ // so it verifies ids[j+1]. (Under assert this readback is trace-only.)
856
+ logitsAll = graph.projectLogits(hf, { type: "all" });
857
+ const argmax = ops.argmaxAxis(logitsAll, -1);
858
+ pred = argmax.toIntTokens();
859
+ argmax.dispose();
860
+ }
861
+ if (verify && pred) {
862
+ for (let j = 0; j + 1 < ids.length; j++) {
863
+ if (pred[j] !== ids[j + 1]) { accepted = j + 1; break; }
864
+ }
865
+ }
866
+ if (fillTraceFile && pred)
867
+ traceProposal(proposal, generatedNow, accepted, [inFlight, ...pred.slice(0, ids.length - 1)]);
868
+ const t1 = performance.now();
869
+ if (accepted < ids.length) {
870
+ // Trimmable caches drop the rejected tail; recurrent caches restore
871
+ // their pre-round snapshot and bit-exactly replay the accepted prefix
872
+ // (Cache.specRoundRollback — the same primitive src/spec/serve-loop.ts
873
+ // uses after its accept walk).
874
+ for (const c of cache) {
875
+ const rc = rewindable(c);
876
+ if (rc.specRoundRollback) rc.specRoundRollback(accepted);
877
+ else rc.trim(ids.length - accepted);
878
+ }
879
+ roundOpen = false;
880
+ } else if (roundOpen) {
881
+ for (const c of cache) rewindable(c).specRoundCommit?.();
882
+ roundOpen = false;
883
+ }
884
+ if (verify) checkpointMs += performance.now() - t1;
885
+ forwarded.push(...ids.slice(0, accepted));
886
+ if (fillTrace) assertFillAlignment("after fill append");
887
+ // The sampler's token history gets the ACCEPTED ids only, before the
888
+ // resume sample — so logits processors (repetition/presence/frequency)
889
+ // see exactly the sequence that was emitted.
890
+ stepSampler.commitNumbers(ids.slice(0, accepted));
891
+ // Resume from the LAST ACCEPTED position's logits: the bonus token on a
892
+ // full accept, the correction on a partial one. Same sampler and step
893
+ // index as an ordinary step — the correction's KV is written by the next
894
+ // iteration's forward, like any sampled token.
895
+ const willGen = generatedNow + accepted;
896
+ if (willGen < maxTokens) {
897
+ let logits: MlxArray;
898
+ if (logitsAll) {
899
+ const V = logitsAll.shape[2]!;
900
+ logits = logitsAll.slice([0, accepted - 1, 0], [1, accepted, V]);
901
+ } else {
902
+ const hLast = hf.slice([0, Lf - 1, 0], [1, Lf, Hf]);
903
+ logits = graph.projectLogits(hLast, { type: "all" });
904
+ hLast.dispose();
905
+ }
906
+ const sn = sampleStep(logits, willGen);
907
+ nextPending = sn.tok;
908
+ nextExtras = sn.extras;
909
+ logits.dispose();
910
+ ops.asyncEvalAll([nextPending, ...stepExtrasArrays(nextExtras)]);
911
+ }
912
+ } finally {
913
+ // A throw mid-round would otherwise leave the recurrent caches armed
914
+ // (their next forward raises "spec round already recorded").
915
+ if (roundOpen) for (const c of cache) rewindable(c).specRoundCommit?.();
916
+ logitsAll?.dispose();
917
+ hf?.dispose();
918
+ }
919
+ if (verify) fill.noteVerifyEvent(checkpointMs);
920
+ fill.commit(proposal, accepted);
921
+ if (fillTrace)
922
+ console.error(
923
+ `[fill] ${proposal.origin}/${proposal.policy} ${accepted}/${ids.length} ` +
924
+ `after ${generatedNow} generated (events ${fill.stats.events}, ` +
925
+ `injected ${fill.stats.injected}, checkpoint ${checkpointMs.toFixed(2)}ms)`,
926
+ );
927
+ return ids.slice(0, accepted);
928
+ };
929
+
930
+ let nextCheckpoint = options.checkpointEveryTokens && options.checkpointEveryTokens > 0
931
+ ? (Math.floor(generated / options.checkpointEveryTokens) + 1) * options.checkpointEveryTokens
932
+ : Number.POSITIVE_INFINITY;
933
+ let tPrefill = performance.now();
934
+
677
935
  try {
936
+ if (resuming) {
937
+ // The checkpoint cache already covers the complete replay prefix. Its
938
+ // pending token was sampled from that exact state before the snapshot,
939
+ // so resume starts directly at the decode loop without another forward.
940
+ if (needsTokenHistory) stepSampler.seedHistory(promptTokens);
941
+ // Device samplers return uint32. Preserve that signature on restore so
942
+ // compiled decode does not trace a different signed-token graph first.
943
+ using restoredToken = ops.fromInt32([options.initialPendingToken!], [1]);
944
+ pending = restoredToken.astype(Dtype.uint32);
945
+ ops.asyncEvalAll([pending]);
946
+ prefillMs = 0;
947
+ closePrefill?.();
948
+ tDecode = performance.now();
949
+ } else {
678
950
  // ---- prefill ----
679
951
  const closeInitialKv = diagnostics.trace?.begin("prefill.kv_maintenance", {
680
952
  mechanism: diagnostics.mechanism ?? "serial",
681
953
  boundary: "initial",
682
954
  });
683
- maybeQuantizeKv(cache, options);
955
+ maintainKv(cache);
684
956
  closeInitialKv?.();
685
- const tPrefill = performance.now();
957
+ tPrefill = performance.now();
686
958
  let h0: MlxArray;
687
959
  if (options.promptEmbeddings) {
688
960
  if (cachedTokens !== 0)
@@ -692,115 +964,44 @@ async function* generateInner(
692
964
  // e2b/e4b need the spliced token ids to build per-layer inputs
693
965
  // (multimodal soft-token positions zeroed inside forwardEmbeddings).
694
966
  const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
695
- h0 = model.forwardEmbeddings(
967
+ h0 = binding.forwardEmbeddings!(
696
968
  options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
697
969
  options.multimodalMask ?? null,
698
970
  );
699
971
  embedIds.dispose();
700
972
  } else {
701
973
  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
974
+ const snapshotAt = options.onPrefillDone && options.snapshotAt !== undefined
708
975
  ? 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);
976
+ : undefined;
977
+ const tailSplit = runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
978
+ const forward = graph.forwardHidden.bind(graph);
979
+ const maintain = () => maintainKv(cache);
980
+ while (true) {
981
+ options.signal?.throwIfAborted();
982
+ const step = nextPrefillStep({ length: promptTokens.length, position: pos,
983
+ chunkSize: prefillChunkSize, tailSplit, snapshotAt });
984
+ if (step.kind === "final" && needsTokenHistory) stepSampler.seedHistory(promptTokens);
753
985
  const closeChunk = diagnostics.trace?.begin("prefill.chunk", {
754
- mechanism: diagnostics.mechanism ?? "serial",
755
- startToken: pos,
756
- tokens: chunk.length,
986
+ mechanism: diagnostics.mechanism ?? "serial", startToken: step.start,
987
+ tokens: step.end - step.start, ...(step.kind === "final" ? { final: true } : {}),
757
988
  });
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")
989
+ let hidden: MlxArray | null;
990
+ try { hidden = await executeMlxPrefillStep(forward, cache, promptTokens, step, maintain); }
991
+ finally { closeChunk?.(); }
992
+ pos = step.end;
993
+ if (hidden) { h0 = hidden; break; }
994
+ if (runtime.value("MLX_BUN_PREFILL_MEM_LOG") === "1")
774
995
  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));
996
+ await new Promise<void>((resolve) => setImmediate(resolve));
997
+ if (step.snapshot) options.onPrefillDone?.();
777
998
  }
778
- if (needsTokenHistory) {
779
- stepSampler.seedHistory(promptTokens);
780
- }
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
999
  }
799
1000
  if (options.snapshotAt === undefined || options.snapshotAt >= promptTokens.length)
800
1001
  options.onPrefillDone?.();
801
1002
  // The trace's prefill span is the uncached model-forward portion only.
802
1003
  // Token-0 lm-head, sampling, and readback are a separate additive stage.
803
- if (diagnostics.trace && runtimeValue("MLX_BUN_P2R_SYNC") === "1")
1004
+ if (diagnostics.trace && runtime.value("MLX_BUN_P2R_SYNC") === "1")
804
1005
  synchronize(gpuStream);
805
1006
  closePrefill?.();
806
1007
  closeTokenZero = diagnostics.trace?.begin("token_zero.total", {
@@ -809,7 +1010,7 @@ async function* generateInner(
809
1010
  const [, L0, H] = h0.shape as [number, number, number];
810
1011
  const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
811
1012
  h0.dispose();
812
- const logits0 = model.logitsFromHidden(hLast);
1013
+ const logits0 = graph.projectLogits(hLast, { type: "all" });
813
1014
  hLast.dispose();
814
1015
  const s0 = sampleStep(logits0, 0); // token array [1] (+ optional extras)
815
1016
  pending = s0.tok;
@@ -824,30 +1025,16 @@ async function* generateInner(
824
1025
  // tok/s measure the same quantity. The boundary cost is real and
825
1026
  // scales with prompt length; it belongs to "having prefilled".
826
1027
  ops.asyncEvalAll([pending, ...stepExtrasArrays(pendingExtras)]);
1028
+ }
827
1029
 
828
1030
  // ---- 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;
1031
+ // Selection and transactional fallback belong to the graph's binding.
1032
+ decoder = options.decodePolicy?.compiledDecode === false ? null : binding.createDecode?.({
1033
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1034
+ }) ?? null;
1035
+ appender = fillOn ? binding.createAppend?.({
1036
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1037
+ }) ?? null : null;
851
1038
  let stop = false;
852
1039
  /** Token id read eagerly at the top of the loop for grammar (reused for
853
1040
  * the yield, avoiding a second readback). -1 when grammar is off — the
@@ -860,11 +1047,40 @@ async function* generateInner(
860
1047
  // (see GrammarController.jumpForward for the contract + the fidelity
861
1048
  // note on why this is opt-in). Excluded when logprobs are requested
862
1049
  // (jumped tokens are never sampled, so they'd have no logprobs rows).
863
- const grammarJump = shouldUseGrammarJump(options);
1050
+ const grammarJump = options.decodePolicy?.grammarJump ?? shouldUseGrammarJump(options, runtime);
1051
+ // Yield token zero before its own decode forward. The captured runtime
1052
+ // keeps this scheduling choice stable across consumer awaits.
1053
+ const earlyFirstToken = runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1" &&
1054
+ maxTokens > 1 && !resuming && !fillOn && !options.grammar;
864
1055
  while (!stop) {
1056
+ options.signal?.throwIfAborted();
865
1057
  const cur = pending!;
866
- const curExtras = pendingExtras;
867
- pendingExtras = null;
1058
+ const stepIndex = generated;
1059
+ let curExtras = pendingExtras;
1060
+ let yieldedEarly = false;
1061
+ if (earlyFirstToken && stepIndex === 0) {
1062
+ const firstToken = ops.itemUint32(cur);
1063
+ options.signal?.throwIfAborted();
1064
+ if (!eosTokenIds.includes(firstToken)) {
1065
+ prefillMs = performance.now() - tPrefill;
1066
+ closeTokenZero?.();
1067
+ closeTokenZero = undefined;
1068
+ tDecode = performance.now();
1069
+ const logprobs = readStepExtras(curExtras);
1070
+ curExtras = null;
1071
+ pendingExtras = null;
1072
+ // Count before yielding so an early return reports this token. Its
1073
+ // KV is still absent; forwarded describes the prompt-only cache.
1074
+ generated++;
1075
+ yieldedEarly = true;
1076
+ earlyUnforwardedToken = firstToken;
1077
+ yield { token: firstToken, index: stepIndex, ...(logprobs ? { logprobs } : {}) };
1078
+ earlyUnforwardedToken = null;
1079
+ options.signal?.throwIfAborted();
1080
+ }
1081
+ }
1082
+ // Keep current extras owned by the run until readback succeeds. A
1083
+ // forward/grammar failure must leave them reachable by finally.
868
1084
  // Grammar advance (src/grammar.ts): acceptToken needs the token id as a
869
1085
  // JS number, which the pipelined loop defers (it operates on device
870
1086
  // arrays). So grammar requests eager-read cur here, advance the matcher,
@@ -885,11 +1101,11 @@ async function* generateInner(
885
1101
  let jumpEmit: number[] | null = null;
886
1102
  if (options.grammar) {
887
1103
  grammarTok = ops.itemUint32(cur);
888
- if (generated + 1 < maxTokens) {
1104
+ if (stepIndex + 1 < maxTokens) {
889
1105
  options.grammar.accept(grammarTok);
890
1106
  await options.grammar.ready();
891
1107
  if (grammarJump && !options.grammar.isTerminated) {
892
- jumpEmit = options.grammar.jumpForward(maxTokens - (generated + 1));
1108
+ jumpEmit = options.grammar.jumpForward(maxTokens - (stepIndex + 1));
893
1109
  // jumpForward advanced the matcher and fired the post-jump mask
894
1110
  // fill; it must be ready before this iteration's sampleStep.
895
1111
  if (jumpEmit) await options.grammar.ready();
@@ -908,21 +1124,21 @@ async function* generateInner(
908
1124
  // contract); the next sampled token, if the budget and grammar allow
909
1125
  // one, comes from its last position. Compiled decode resumes on the
910
1126
  // following iteration (supports() re-checks the grown caches).
911
- maybeQuantizeKv(cache, options);
1127
+ maintainKv(cache);
912
1128
  pushHistory(cur);
913
1129
  stepSampler.commitNumbers(jumpEmit);
914
1130
  const chunk = [grammarTok, ...jumpEmit];
915
1131
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
916
- const h = await forwardHiddenForGeneration(model, ids, cache);
1132
+ const h = await graph.forwardHidden(ids, cache);
917
1133
  ids.dispose();
918
1134
  // Every chunk token's KV is in the cache regardless of what follows.
919
1135
  forwarded.push(...chunk);
920
- const willGen = generated + 1 + jumpEmit.length;
1136
+ const willGen = stepIndex + 1 + jumpEmit.length;
921
1137
  if (willGen < maxTokens && !options.grammar!.isTerminated) {
922
1138
  const [, Lj, Hj] = h.shape as [number, number, number];
923
1139
  const hLast = h.slice([0, Lj - 1, 0], [1, Lj, Hj]);
924
1140
  h.dispose();
925
- const logits = model.logitsFromHidden(hLast);
1141
+ const logits = graph.projectLogits(hLast, { type: "all" });
926
1142
  hLast.dispose();
927
1143
  const sn = sampleStep(logits, willGen);
928
1144
  nextPending = sn.tok;
@@ -932,34 +1148,24 @@ async function* generateInner(
932
1148
  } else {
933
1149
  h.dispose(); // burst ends the generation (max_tokens or grammar done)
934
1150
  }
935
- } else if (generated + 1 < maxTokens && !options.grammar?.isTerminated) {
936
- maybeQuantizeKv(cache, options);
1151
+ } else if (stepIndex + 1 < maxTokens && !options.grammar?.isTerminated) {
1152
+ maintainKv(cache);
937
1153
  pushHistory(cur);
938
1154
  let logits: MlxArray | null = null;
939
1155
  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
- }
1156
+ const decoded = decoder?.tryStep(cur, cache);
1157
+ if (decoded) {
1158
+ logits = decoded.logits;
1159
+ evalWith = decoded.evalWith;
954
1160
  }
955
1161
  if (!logits) {
956
1162
  const ids = ops.reshape(cur, [1, 1]);
957
- const h = await forwardHiddenForGeneration(model, ids, cache);
1163
+ const h = await graph.forwardHidden(ids, cache);
958
1164
  ids.dispose();
959
- logits = model.logitsFromHidden(h);
1165
+ logits = graph.projectLogits(h, { type: "all" });
960
1166
  h.dispose();
961
1167
  }
962
- const sn = sampleStep(logits, generated + 1);
1168
+ const sn = sampleStep(logits, stepIndex + 1);
963
1169
  nextPending = sn.tok;
964
1170
  nextExtras = sn.extras;
965
1171
  logits.dispose();
@@ -970,6 +1176,7 @@ async function* generateInner(
970
1176
  // sync-read step n's token while n+1 computes (grammar already read it
971
1177
  // eagerly above — reuse to avoid a second GPU sync)
972
1178
  const token = options.grammar ? grammarTok : ops.itemUint32(cur);
1179
+ options.signal?.throwIfAborted();
973
1180
  if (generated === 0) {
974
1181
  // first token arrived: prompt clock stops, decode clock starts
975
1182
  // (mlx-lm stream_generate's n==0 clock swap; the first token is
@@ -981,23 +1188,51 @@ async function* generateInner(
981
1188
  }
982
1189
  cur.dispose();
983
1190
  pending = null;
984
- generated++;
1191
+ if (!yieldedEarly) generated++;
985
1192
  // if a next-step graph was built, this token's KV entered the cache
986
1193
  // (jump iterations pushed the whole chunk already)
987
1194
  if (nextPending !== null && !jumpEmit) forwarded.push(token);
988
1195
 
989
1196
  if (eosTokenIds.includes(token)) {
1197
+ finishReason = "stop";
990
1198
  disposeStepExtras(curExtras);
1199
+ pendingExtras = null;
991
1200
  nextPending?.dispose();
992
1201
  nextPending = null;
993
1202
  disposeStepExtras(nextExtras);
994
1203
  nextExtras = null;
995
1204
  stop = true;
996
1205
  } else {
1206
+ // ---- token fast-forwarding (K3): APPEND, then yield ----------------
1207
+ // The engine already knows the next m tokens, so it writes them into
1208
+ // the KV itself with an append and resumes sampling after them. No
1209
+ // draft, no verify, no rollback: an injected token is indistinguishable
1210
+ // to the model from one it sampled. The append happens BEFORE any of
1211
+ // the burst's yields, which is what makes a consumer break mid-burst
1212
+ // safe — `forwarded` already describes the cache exactly.
1213
+ let fillEmit: number[] | null = null;
1214
+ if (fillOn) {
1215
+ if (jumpEmit)
1216
+ throw new Error("fill and grammar jump-forward cannot share an iteration");
1217
+ // A fill needs an in-flight step to ride; with nextPending null this
1218
+ // token's KV was never written and the generation ends here. push()
1219
+ // still runs so the session's history (and echo index) stay exact.
1220
+ const proposal = options.fill!.push(
1221
+ token, nextPending !== null ? maxTokens - generated : 0,
1222
+ );
1223
+ if (proposal && nextPending !== null) {
1224
+ const emitted = await applyProposal(proposal, generated);
1225
+ if (emitted.length) fillEmit = emitted;
1226
+ } else if (proposal) {
1227
+ options.fill!.commit(proposal, 0); // unreachable at budget 0; belt
1228
+ }
1229
+ }
997
1230
  // readExtras before the yield: if the consumer breaks at this yield,
998
1231
  // the extras are already read and disposed.
999
- const logprobs = readExtras(curExtras);
1000
- yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1232
+ const logprobs = readStepExtras(curExtras);
1233
+ pendingExtras = null;
1234
+ if (!yieldedEarly) yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1235
+ options.signal?.throwIfAborted();
1001
1236
  // mlx-lm generate_step: clear_cache after token 0 (drops the
1002
1237
  // remaining prefill transients) and every 256 tokens after
1003
1238
  if ((generated - 1) % 256 === 0) clearCache();
@@ -1008,11 +1243,24 @@ async function* generateInner(
1008
1243
  // safe — `forwarded` already reflects the cache exactly.
1009
1244
  if (jumpEmit) {
1010
1245
  for (const jt of jumpEmit) {
1246
+ options.signal?.throwIfAborted();
1011
1247
  generated++;
1012
1248
  yield { token: jt, index: generated - 1 };
1013
1249
  if ((generated - 1) % 256 === 0) clearCache();
1014
1250
  }
1015
1251
  }
1252
+ // Fill burst: same one-yield-per-token shape as the jump burst, so
1253
+ // CompletionSink / StopMatcher / StreamDecoder see an ordinary stream
1254
+ // (a stop sequence inside an injected span fires exactly where it
1255
+ // would have in an unfilled run).
1256
+ if (fillEmit) {
1257
+ for (const ft of fillEmit) {
1258
+ options.signal?.throwIfAborted();
1259
+ generated++;
1260
+ yield { token: ft, index: generated - 1 };
1261
+ if ((generated - 1) % 256 === 0) clearCache();
1262
+ }
1263
+ }
1016
1264
  if (nextPending === null) {
1017
1265
  stop = true;
1018
1266
  } else {
@@ -1021,6 +1269,21 @@ async function* generateInner(
1021
1269
  pendingExtras = nextExtras;
1022
1270
  nextExtras = null;
1023
1271
  }
1272
+ if (
1273
+ pending !== null &&
1274
+ options.onDecodeCheckpoint &&
1275
+ generated >= nextCheckpoint
1276
+ ) {
1277
+ const pendingToken = ops.itemUint32(pending);
1278
+ await options.onDecodeCheckpoint({
1279
+ cacheTokens: [...promptTokens, ...forwarded],
1280
+ caches: cache,
1281
+ generatedTokens: generated,
1282
+ pendingToken,
1283
+ });
1284
+ const every = options.checkpointEveryTokens!;
1285
+ while (nextCheckpoint <= generated) nextCheckpoint += every;
1286
+ }
1024
1287
  }
1025
1288
  }
1026
1289
  decodeMs = performance.now() - tDecode;
@@ -1028,27 +1291,56 @@ async function* generateInner(
1028
1291
  return makeStats();
1029
1292
  } catch (e) {
1030
1293
  threw = true;
1294
+ executionError = e;
1031
1295
  throw e;
1032
1296
  } finally {
1033
- if (!finished) {
1034
- pending?.dispose();
1035
- nextPending?.dispose();
1036
- disposeStepExtras(pendingExtras);
1037
- disposeStepExtras(nextExtras);
1038
- }
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();
1046
- if (!finished && !threw) {
1047
- // forced early return (consumer break at a yield): still report
1048
- // stats — `forwarded` only lists tokens whose KV actually entered
1049
- // the cache, so cacheTokens stays exact for PromptCache.put().
1050
- decodeMs = performance.now() - tDecode;
1051
- return makeStats();
1297
+ try {
1298
+ // An early consumer return leaves token zero outside the retained KV.
1299
+ // Finish that one M=1 forward before handing caller-owned caches back,
1300
+ // matching the ordinary pipeline's boundary. Otherwise a later prefix
1301
+ // hit folds the token into an M>1 prefill and changes native reductions.
1302
+ // Aborted requests and caches owned by this run need no retained state.
1303
+ if (!ownsCache && !threw && !options.signal?.aborted && earlyUnforwardedToken !== null) {
1304
+ maintainKv(cache);
1305
+ const ids = ops.reshape(pending!, [1, 1]);
1306
+ try {
1307
+ const hidden = await graph.forwardHidden(ids, cache);
1308
+ try { evalCacheState(cache); }
1309
+ finally { hidden.dispose(); }
1310
+ } finally { ids.dispose(); }
1311
+ forwarded.push(earlyUnforwardedToken);
1312
+ }
1313
+ } catch (error) {
1314
+ threw = true;
1315
+ executionError = error;
1316
+ throw error;
1317
+ } finally {
1318
+ if (!finished) {
1319
+ pending?.dispose();
1320
+ nextPending?.dispose();
1321
+ disposeStepExtras(pendingExtras);
1322
+ disposeStepExtras(nextExtras);
1323
+ }
1324
+ try {
1325
+ const closing = decoder?.close();
1326
+ if (closing) await closing;
1327
+ } catch (error) {
1328
+ if (threw) throw new AggregateError([executionError, error],
1329
+ "AR execution and decoder cleanup failed", { cause: executionError });
1330
+ throw error;
1331
+ } finally {
1332
+ if (ownsCache) for (const c of cache) c.dispose();
1333
+ // TokenizerInfo is process-cached; this request owns the matcher.
1334
+ options.grammar?.dispose();
1335
+ stepSampler.dispose();
1336
+ }
1337
+ if (!finished && !threw) {
1338
+ // forced early return (consumer break at a yield): still report
1339
+ // stats — `forwarded` only lists tokens whose KV actually entered
1340
+ // the cache, so cacheTokens stays exact for PromptCache.put().
1341
+ decodeMs = performance.now() - tDecode;
1342
+ return makeStats();
1343
+ }
1052
1344
  }
1053
1345
  }
1054
1346
  }