mlx-bun 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +71 -588
  2. package/docs/README.md +91 -0
  3. package/docs/{design → archive/investigations}/audio-input-plan.md +4 -4
  4. package/docs/{design → archive/investigations}/diffusion-gemma-port.md +3 -3
  5. package/docs/{design → archive/investigations}/faithful-l1-consolidation.md +4 -4
  6. package/docs/{design → archive/investigations}/minicpm5-decode-megakernel.md +11 -11
  7. package/docs/{design → archive/investigations}/optimization_plan.md +1 -1
  8. package/docs/{design → archive/investigations}/pre-colibri-stabilization.md +5 -5
  9. package/docs/{design → archive/investigations}/the-dreaming-handoff.md +2 -2
  10. package/docs/design/batching.md +569 -0
  11. package/docs/design/decode-speed-program.md +3178 -169
  12. package/docs/design/dreaming-nightly-pipeline.md +675 -282
  13. package/docs/design/generic-model-support.md +579 -334
  14. package/docs/design/kv-cache.md +680 -0
  15. package/docs/design/orpo-training.md +673 -1239
  16. package/docs/design/speculative-decoding.md +1627 -0
  17. package/docs/design/turboquant.md +2102 -0
  18. package/docs/design/unified-engine-frontier-plan.md +1831 -727
  19. package/docs/design/web-chat-redesign.md +666 -1083
  20. package/docs/planning/PRODUCT_ROADMAP.md +34 -1
  21. package/docs/planning/release-notes-v0.3.0.md +35 -0
  22. package/docs/planning/video-series.md +2 -2
  23. package/docs/reference/benchmarks.md +1162 -0
  24. package/docs/reference/cli.md +394 -159
  25. package/docs/reference/distribution.md +321 -182
  26. package/docs/reference/environment.md +348 -0
  27. package/docs/reference/glossary.md +39 -0
  28. package/docs/reference/library-api.md +566 -168
  29. package/docs/reference/memory.md +227 -64
  30. package/docs/reference/models.md +104 -6
  31. package/docs/reference/server-api.md +886 -425
  32. package/docs/reference/server-config.md +638 -330
  33. package/docs/reference/training.md +393 -254
  34. package/docs/reference/troubleshooting.md +166 -0
  35. package/package.json +9 -4
  36. package/src/audio/decode.ts +2 -2
  37. package/src/audio/features.ts +2 -2
  38. package/src/backends/mlx/autoregressive.ts +108 -0
  39. package/src/backends/mlx/batch-group.ts +1646 -0
  40. package/src/backends/mlx/diffusion.ts +39 -0
  41. package/src/backends/mlx/draft-target.ts +47 -0
  42. package/src/backends/mlx/evaluation.ts +105 -0
  43. package/src/backends/mlx/fill-append.ts +45 -0
  44. package/src/backends/mlx/gateway-binding.ts +71 -0
  45. package/src/backends/mlx/graph.ts +38 -0
  46. package/src/backends/mlx/memory-client.ts +189 -0
  47. package/src/backends/mlx/memory-guard.ts +40 -0
  48. package/src/backends/mlx/methods.ts +90 -0
  49. package/src/backends/mlx/model-prompt.ts +164 -0
  50. package/src/backends/mlx/model-serving.ts +79 -0
  51. package/src/backends/mlx/prefill.ts +33 -0
  52. package/src/backends/mlx/request-policy.ts +20 -0
  53. package/src/backends/mlx/rollback.ts +51 -0
  54. package/src/backends/mlx/serial-executor.ts +273 -0
  55. package/src/backends/mlx/speculative.ts +114 -0
  56. package/src/backends/mlx/state-views.ts +26 -0
  57. package/src/backends/mlx/text-engine.ts +73 -0
  58. package/src/cli.ts +58 -25
  59. package/src/client.ts +66 -0
  60. package/src/config.ts +21 -4
  61. package/src/contracts/completion.ts +16 -0
  62. package/src/contracts/execution.ts +49 -0
  63. package/src/contracts/generation.ts +103 -0
  64. package/src/contracts/host.ts +7 -0
  65. package/src/contracts/jobs.ts +66 -0
  66. package/src/contracts/pi-provider.ts +30 -0
  67. package/src/contracts/pi.ts +259 -0
  68. package/src/contracts/resources.ts +18 -0
  69. package/src/contracts/scheduling.ts +27 -0
  70. package/src/contracts/tsconfig.json +14 -0
  71. package/src/diffusion/diffusion-generate.ts +218 -153
  72. package/src/embed.ts +1 -1
  73. package/src/engine/admission.ts +59 -0
  74. package/src/engine/cancellation.ts +38 -0
  75. package/src/engine/engine.ts +56 -0
  76. package/src/engine/execution-coordinator.ts +70 -0
  77. package/src/engine/execution-plan.ts +51 -0
  78. package/src/engine/resources.ts +42 -0
  79. package/src/engine/scheduler.ts +67 -0
  80. package/src/engine/session.ts +247 -0
  81. package/src/eval/README.md +44 -1
  82. package/src/eval/kl.ts +236 -3
  83. package/src/eval/reference-logits.ts +207 -0
  84. package/src/eval/runner.ts +8 -96
  85. package/src/fill/echo-index.ts +188 -0
  86. package/src/fill/fill-session.ts +452 -0
  87. package/src/fill/proposal.ts +68 -0
  88. package/src/fill/schema-rows.ts +506 -0
  89. package/src/fill/tool-boundary.ts +103 -0
  90. package/src/fit.ts +1 -1
  91. package/src/generate.ts +656 -268
  92. package/src/grammar.ts +12 -7
  93. package/src/harness-pi.ts +14 -33
  94. package/src/hub-rest.ts +4 -4
  95. package/src/index.ts +7 -0
  96. package/src/inference/denoising.ts +17 -0
  97. package/src/inference/graph.ts +33 -0
  98. package/src/inference/prefill.ts +39 -0
  99. package/src/inference/rollback.ts +7 -0
  100. package/src/jobs/db.ts +4 -0
  101. package/src/jobs/index.ts +2 -0
  102. package/src/jobs/job-entry.ts +12 -12
  103. package/src/jobs/runner.ts +98 -13
  104. package/src/jobs/task-client.ts +19 -0
  105. package/src/jobs/types.ts +2 -58
  106. package/src/kv-store.ts +109 -49
  107. package/src/lab/README.md +18 -0
  108. package/src/{assets → lab/curve}/curve-designer.html +2 -2
  109. package/src/{curve-sampler.ts → lab/curve/curve-sampler.ts} +2 -2
  110. package/src/{expert-trace.ts → lab/expert-trace/expert-trace.ts} +5 -5
  111. package/src/{model → lab/paged-kv}/paged-kv.ts +9 -6
  112. package/src/library.ts +40 -0
  113. package/src/lora.ts +18 -0
  114. package/src/media-fetch.ts +1 -1
  115. package/src/memory/article.ts +1 -1
  116. package/src/memory/chunk.ts +1 -1
  117. package/src/memory/model.ts +30 -169
  118. package/src/memory/prompts.ts +1 -1
  119. package/src/memory/rest.ts +1 -1
  120. package/src/memory/schedule.ts +1 -1
  121. package/src/memory/stages.ts +4 -1
  122. package/src/memory/vault.ts +1 -3
  123. package/src/mlx/array.ts +9 -10
  124. package/src/mlx/compile.ts +12 -2
  125. package/src/mlx/ffi.ts +58 -11
  126. package/src/mlx/materialize.ts +23 -0
  127. package/src/mlx/metal-kernel.ts +10 -11
  128. package/src/mlx/ops.ts +33 -13
  129. package/src/mlx/quantized-rows.ts +36 -0
  130. package/src/mlx/token-bitmask.ts +52 -0
  131. package/src/mlx/turboquant-kv-decode.ts +82 -0
  132. package/src/mlx/turboquant-kv-inverse.ts +122 -0
  133. package/src/mlx/turboquant-ops.ts +33 -3
  134. package/src/mlx/turboquant-tables.ts +2 -2
  135. package/src/model/batched-mask.ts +8 -2
  136. package/src/model/batched-quant.ts +1 -1
  137. package/src/model/batched-rotating-quant.ts +2 -2
  138. package/src/model/batched-rotating.ts +1 -1
  139. package/src/model/compiled-decode.ts +1 -1
  140. package/src/model/diffusion-gemma.ts +8 -8
  141. package/src/model/factory.ts +76 -33
  142. package/src/model/fingerprint.ts +1 -1
  143. package/src/model/flash-attention.ts +3 -3
  144. package/src/model/gemma4-base.ts +26 -9
  145. package/src/model/gemma4.ts +6 -6
  146. package/src/model/generated/gemma4-12b.ts +2 -2
  147. package/src/model/generated/gemma4-26b.ts +2 -2
  148. package/src/model/generated/gemma4-e4b.ts +2 -2
  149. package/src/model/glm52-dsa.ts +13 -9
  150. package/src/model/glm52-memory.ts +1 -1
  151. package/src/model/implementation.ts +69 -0
  152. package/src/model/minicpm5.ts +6 -8
  153. package/src/model/profile.ts +12 -2
  154. package/src/model/qwen-append.ts +12 -0
  155. package/src/model/qwen-conv.ts +52 -0
  156. package/src/model/qwen3_5-checkpoint.ts +164 -0
  157. package/src/model/qwen3_5.ts +140 -56
  158. package/src/model/support.ts +1 -1
  159. package/src/model/trellis-balanced-scatter.ts +51 -0
  160. package/src/model/trellis-linear.ts +672 -0
  161. package/src/model/trellis-shared-m.ts +151 -0
  162. package/src/model/trellis-shared-scatter.ts +144 -0
  163. package/src/model/trellis-splitk-prefill.ts +158 -0
  164. package/src/model/trellis-tiled-prefill.ts +136 -0
  165. package/src/model/trellis-vector-expand.ts +62 -0
  166. package/src/model/trellis-wide-prefill.ts +94 -0
  167. package/src/model/universal/dense.ts +1 -1
  168. package/src/model/universal/modules.ts +8 -1
  169. package/src/native-pack.ts +6 -5
  170. package/src/pi-provider.ts +6 -64
  171. package/src/pi-web.ts +8 -315
  172. package/src/prompt-cache.ts +96 -53
  173. package/src/quantize/allocator.ts +1 -1
  174. package/src/quantize/config-writer.ts +10 -3
  175. package/src/quantize/rotate.ts +4 -4
  176. package/src/quantize/trellis.ts +586 -0
  177. package/src/responses.ts +28 -1
  178. package/src/runtime-config.ts +18 -4
  179. package/src/sampler.ts +4 -4
  180. package/src/serve/batch-scheduler.ts +8 -1671
  181. package/src/serve/chat-request.ts +391 -0
  182. package/src/serve/chat-stage.ts +195 -0
  183. package/src/serve/checkpoint-identity.ts +64 -0
  184. package/src/serve/completion-executor.ts +15 -7
  185. package/src/serve/discovery-routes.ts +12 -13
  186. package/src/serve/generation-gateway.ts +154 -193
  187. package/src/serve/http.ts +159 -0
  188. package/src/serve/inference-request.ts +79 -0
  189. package/src/serve/isolate.ts +297 -105
  190. package/src/serve/lab-routes.ts +3 -0
  191. package/src/serve/lane-registry.ts +2 -1
  192. package/src/serve/model-admin-routes.ts +7 -5
  193. package/src/serve/model-binding.ts +43 -0
  194. package/src/serve/model-host.ts +512 -0
  195. package/src/serve/openai-wire.ts +103 -0
  196. package/src/serve/parent-application.ts +56 -0
  197. package/src/serve/pipeline.ts +33 -0
  198. package/src/serve/preparation.ts +33 -0
  199. package/src/serve/request-plan.ts +19 -15
  200. package/src/serve/request-prep.ts +346 -0
  201. package/src/serve/response-lease.ts +24 -0
  202. package/src/serve/responses-client.ts +69 -0
  203. package/src/serve/session-completion-engine.ts +110 -0
  204. package/src/serve/session-search.ts +1 -1
  205. package/src/serve/static-routes.ts +5 -5
  206. package/src/serve/text-completion-stage.ts +104 -0
  207. package/src/serve/token-streams.ts +519 -0
  208. package/src/server.ts +317 -2649
  209. package/src/spec/assistant-source.ts +10 -44
  210. package/src/spec/deepspec-source.ts +6 -6
  211. package/src/spec/dflash-source.ts +8 -8
  212. package/src/spec/dspark/ab-stats.ts +3 -3
  213. package/src/spec/dspark/calibration.ts +2 -2
  214. package/src/spec/dspark/deepspec-module.ts +2 -2
  215. package/src/spec/dspark/generate-dflash.ts +2 -2
  216. package/src/spec/dspark/generate.ts +2 -2
  217. package/src/spec/dspark/loader.ts +1 -1
  218. package/src/spec/dspark/module-dflash.ts +4 -3
  219. package/src/spec/dspark/module.ts +3 -3
  220. package/src/spec/dspark/quantize-drafter.ts +1 -1
  221. package/src/spec/generate.ts +1 -1
  222. package/src/spec/glm52-mtp-source.ts +1 -1
  223. package/src/spec/ngram-source.ts +1 -1
  224. package/src/spec/prefix-state.ts +45 -0
  225. package/src/spec/qwen-mtp-source.ts +166 -69
  226. package/src/spec/serve-loop.ts +154 -156
  227. package/src/spec/source.ts +42 -10
  228. package/src/spec/two-model.ts +1 -1
  229. package/src/ssd-cache.ts +80 -12
  230. package/src/ssd-durability.ts +25 -13
  231. package/src/tool-call.ts +51 -14
  232. package/src/train/flash-cce.ts +2 -2
  233. package/src/train/forward.ts +6 -0
  234. package/src/train/loss.ts +1 -1
  235. package/src/train/prefix-shared.ts +5 -1
  236. package/src/train/segmented.ts +5 -3
  237. package/src/train/trainer.ts +6 -6
  238. package/src/vision/prompt.ts +1 -1
  239. package/src/vision/qwen3vl-tower.ts +52 -10
  240. package/src/web/app.html +1 -1
  241. package/src/web/app.js +5 -3
  242. package/src/web/src/adapters-panel.ts +3 -3
  243. package/src/web/src/assistant.ts +2 -36
  244. package/src/web/src/chat.ts +4 -4
  245. package/src/web/src/composer.ts +4 -4
  246. package/src/web/src/hub.ts +1 -1
  247. package/src/web/src/markdown.ts +0 -0
  248. package/src/web/src/memory-panel.ts +1 -1
  249. package/src/web/src/model-picker.ts +1 -1
  250. package/src/web/src/palette.ts +1 -1
  251. package/src/web/src/protocol.ts +3 -39
  252. package/src/web/src/sessions.ts +1 -1
  253. package/src/web/src/ui-catalog.ts +4 -18
  254. package/src/web-assets.ts +47 -0
  255. package/src/weights.ts +55 -9
  256. package/docs/archive/investigations/curve-runs/bisector-route.json +0 -98
  257. package/docs/archive/investigations/curve-runs/bisector-trace.json +0 -1393
  258. package/docs/archive/investigations/curve-runs/curve-terrain.json +0 -1179
  259. package/docs/archive/investigations/curve-runs/curve-terrain.svg +0 -42
  260. package/docs/archive/investigations/hlg-ab.html +0 -34
  261. package/docs/archive/investigations/hlg-explorer.html +0 -238
  262. package/docs/archive/investigations/hlg-figs/cliff.svg +0 -1
  263. package/docs/archive/investigations/hlg-figs/cross.svg +0 -1
  264. package/docs/archive/investigations/hlg-figs/edges.svg +0 -1
  265. package/docs/archive/investigations/hlg-figs/k20-grids.svg +0 -1
  266. package/docs/archive/investigations/hlg-figs/k20-peaks.svg +0 -1
  267. package/docs/archive/investigations/hlg-figs/lw-curve.svg +0 -1
  268. package/docs/archive/investigations/hlg-figs/terrain.svg +0 -1
  269. package/docs/archive/investigations/hlg-report.html +0 -262
  270. package/docs/archive/investigations/hlg-runs/hlg-ab.json +0 -149
  271. package/docs/archive/investigations/hlg-runs/hlg-blind-prompts.md +0 -87
  272. package/docs/archive/investigations/hlg-runs/hlg-blind.json +0 -107
  273. package/docs/archive/investigations/hlg-runs/hlg-cliff.json +0 -197
  274. package/docs/archive/investigations/hlg-runs/hlg-cross.json +0 -505
  275. package/docs/archive/investigations/hlg-runs/hlg-edge.json +0 -388
  276. package/docs/archive/investigations/hlg-runs/hlg-eval1.json +0 -25
  277. package/docs/archive/investigations/hlg-runs/hlg-frontier.json +0 -86
  278. package/docs/archive/investigations/hlg-runs/hlg-grid.json +0 -242
  279. package/docs/archive/investigations/hlg-runs/hlg-grid2.json +0 -343
  280. package/docs/archive/investigations/hlg-runs/hlg-lw-coarse.json +0 -110
  281. package/docs/archive/investigations/hlg-runs/hlg-map.json +0 -383
  282. package/docs/archive/investigations/hlg-runs/run0-original.log +0 -159
  283. package/docs/archive/investigations/hlg-runs/runA-recommended.log +0 -131
  284. package/docs/archive/investigations/hlg-runs/runAA-cliff-cpm5.log +0 -26
  285. package/docs/archive/investigations/hlg-runs/runAB-grid2-e4b.log +0 -18
  286. package/docs/archive/investigations/hlg-runs/runAC-peak-e4b-partial.log +0 -25
  287. package/docs/archive/investigations/hlg-runs/runAD-ab-samples.log +0 -8
  288. package/docs/archive/investigations/hlg-runs/runAE-edge-e4b.log +0 -51
  289. package/docs/archive/investigations/hlg-runs/runAF-logit-calibration.log +0 -10
  290. package/docs/archive/investigations/hlg-runs/runAG-eval-config2.log +0 -16
  291. package/docs/archive/investigations/hlg-runs/runB-gain-variants.log +0 -192
  292. package/docs/archive/investigations/hlg-runs/runC-pivots.log +0 -162
  293. package/docs/archive/investigations/hlg-runs/runD-oetf.log +0 -132
  294. package/docs/archive/investigations/hlg-runs/runE-eotf.log +0 -162
  295. package/docs/archive/investigations/hlg-runs/runF-shaper-working.log +0 -86
  296. package/docs/archive/investigations/hlg-runs/runG-shaper-sweep.log +0 -101
  297. package/docs/archive/investigations/hlg-runs/runH-wsweep.log +0 -176
  298. package/docs/archive/investigations/hlg-runs/runI-asweep.log +0 -176
  299. package/docs/archive/investigations/hlg-runs/runJ-knobmap.log +0 -49
  300. package/docs/archive/investigations/hlg-runs/runK-frontier.log +0 -19
  301. package/docs/archive/investigations/hlg-runs/runL-widemap.log +0 -65
  302. package/docs/archive/investigations/hlg-runs/runM-frontier-emb.log +0 -15
  303. package/docs/archive/investigations/hlg-runs/runN-verify.log +0 -116
  304. package/docs/archive/investigations/hlg-runs/runO-frontier-seed5000.log +0 -15
  305. package/docs/archive/investigations/hlg-runs/runP-grid-AxSm.log +0 -15
  306. package/docs/archive/investigations/hlg-runs/runQ-grid-tgW.log +0 -15
  307. package/docs/archive/investigations/hlg-runs/runR-blind-transcript.log +0 -79
  308. package/docs/archive/investigations/hlg-runs/runS-cross-coherence.log +0 -33
  309. package/docs/archive/investigations/hlg-runs/runT-cross-cpm5.log +0 -33
  310. package/docs/archive/investigations/hlg-runs/runU-cross-12b.log +0 -33
  311. package/docs/archive/investigations/hlg-runs/runV-lw-e4b.log +0 -22
  312. package/docs/archive/investigations/hlg-runs/runW-lw-12b.log +0 -22
  313. package/docs/archive/investigations/hlg-runs/runX-lw-cpm5.log +0 -22
  314. package/docs/archive/investigations/hlg-runs/runY-cliff-e4b.log +0 -26
  315. package/docs/archive/investigations/hlg-runs/runZ-cliff-12b.log +0 -26
  316. package/docs/archive/mlx-bun-lab-report.html +0 -275
  317. package/docs/archive/wwdc-mlx-bun.html +0 -848
  318. package/docs/design/adapters-end-to-end.md +0 -205
  319. package/docs/design/batching-perf-path.md +0 -255
  320. package/docs/design/batching-v2-plan.md +0 -529
  321. package/docs/design/bucketing-stage.md +0 -197
  322. package/docs/design/compat-cli-surface-design.md +0 -230
  323. package/docs/design/docs-reorg-plan.md +0 -165
  324. package/docs/design/dspark-serving-program.md +0 -371
  325. package/docs/design/dspark-speculative-decoding.md +0 -318
  326. package/docs/design/grammar-spec-batching-integration.md +0 -301
  327. package/docs/design/memory-inference-path.md +0 -422
  328. package/docs/design/memory-synthesis.md +0 -287
  329. package/docs/design/memory-system.md +0 -237
  330. package/docs/design/mlx-lm-serving-execution-seam.md +0 -81
  331. package/docs/design/mlx-lm-tool-parity-plan.md +0 -545
  332. package/docs/design/omlx-adoption-map.md +0 -98
  333. package/docs/design/orpo-dynamic-lambda.md +0 -220
  334. package/docs/design/orpo-future-enhancements.md +0 -340
  335. package/docs/design/paged-kv-cache.md +0 -214
  336. package/docs/design/parallel-slots.md +0 -567
  337. package/docs/design/parity-tier-dag.md +0 -161
  338. package/docs/design/repo-cleanup-plan.md +0 -231
  339. package/docs/design/runtime-isolation.md +0 -263
  340. package/docs/design/segmented-backward-training.md +0 -543
  341. package/docs/design/spec-decode-larger-targets.md +0 -229
  342. package/docs/design/ssd-kv-cold-tier.md +0 -415
  343. package/docs/design/structured-output.md +0 -484
  344. package/docs/design/tauri-desktop-app.md +0 -127
  345. package/docs/design/the-dreaming-master-plan.md +0 -445
  346. package/docs/design/turboquant-kv.md +0 -235
  347. package/docs/design/turboquant-weights.md +0 -467
  348. package/docs/design/w4a16-compute-precision-spike.md +0 -232
  349. package/docs/design/web-chat-beat-matrix.md +0 -315
  350. package/docs/design/write-pipeline-entity.md +0 -468
  351. package/docs/planning/IDEAS.md +0 -69
  352. package/docs/reference/embedding.md +0 -197
  353. package/docs/reference/features-matrix.md +0 -101
  354. package/docs/reference/orpo-quickstart.md +0 -135
  355. package/src/flags.ts +0 -11
  356. /package/docs/{design → archive/investigations}/colibri-glm52-port.md +0 -0
  357. /package/docs/{planning → archive/planning}/chunk-task-roadmap.md +0 -0
  358. /package/docs/{planning → archive/planning}/curve-sampler-research-plan.md +0 -0
  359. /package/docs/{planning → archive/planning}/memory-docs-and-dag-plan.md +0 -0
  360. /package/docs/{planning → archive/planning}/pi-integration-review.md +0 -0
  361. /package/docs/{planning → archive/planning}/release-notes-v0.2.0.md +0 -0
  362. /package/src/{memory-plan.ts → admission-plan.ts} +0 -0
  363. /package/{docs/archive/investigations → src/serve/assets}/curve-terrain.html +0 -0
  364. /package/{docs/dag → src/serve/assets}/training-inference-map.html +0 -0
@@ -1,3 +1,4 @@
1
+ import { CancellationSource } from "../engine/cancellation";
1
2
  // Job runner: turns a registered `JobRunner` into a tracked, logged job.
2
3
  // Two execution modes — both create a row, both stream NDJSON events to the
3
4
  // log, both own terminal status:
@@ -14,6 +15,8 @@
14
15
  // Port of optiq lab jobs.py `submit` + `_job_main`, split across this file
15
16
  // and job-entry.ts.
16
17
 
18
+ import type { DisposableResource } from "../contracts/resources";
19
+ import { createJobTaskClient } from "./task-client";
17
20
  import { appendFileSync } from "node:fs";
18
21
  import { fileURLToPath } from "node:url";
19
22
  import type { Emit, JobEvent, JobRunner } from "./types";
@@ -104,18 +107,28 @@ export interface SubmitResult {
104
107
  * on success sets 'done'/progress 1/ended_at + emits 'done', on throw sets
105
108
  * 'failed'/error/ended_at + emits 'failed'. Returns once the task is
106
109
  * scheduled (does not await completion — poll the row / tail the log). */
110
+ interface InProcessWork { cancellation: CancellationSource; done: Promise<void>; }
111
+ const inProcess = new WeakMap<JobStore, Set<InProcessWork>>();
112
+
107
113
  export function submitInProcess(
108
114
  store: JobStore,
109
115
  kind: string,
110
116
  config: Record<string, unknown>,
111
117
  outputPath?: string,
112
118
  ): SubmitResult {
119
+ if (closedStores.has(store)) throw new Error("job host is closed");
113
120
  const row = store.create(kind, config, outputPath);
114
121
  const emit = makeEmit(store, row.id, row.log_path);
115
122
  store.setStatus(row.id, "running");
116
123
  emit({ type: "started", ts: Date.now() });
117
124
 
118
125
  const runner = getRunner(kind);
126
+ const cancellation = new CancellationSource();
127
+ const running = inProcess.get(store) ?? new Set<InProcessWork>();
128
+ inProcess.set(store, running);
129
+ let finish!: () => void;
130
+ const work = { cancellation, done: new Promise<void>((resolve) => { finish = resolve; }) };
131
+ running.add(work);
119
132
  void (async () => {
120
133
  if (!runner) {
121
134
  const error = `no runner registered for kind "${kind}"`;
@@ -124,7 +137,7 @@ export function submitInProcess(
124
137
  return;
125
138
  }
126
139
  try {
127
- const result = await runner(emit, JSON.parse(row.config_json));
140
+ const result = await createJobTaskClient(runner).run(JSON.parse(row.config_json), emit, cancellation);
128
141
  const out = result?.outputPath;
129
142
  if (out) store.setOutputPath(row.id, out);
130
143
  store.setProgress(row.id, 1);
@@ -135,7 +148,7 @@ export function submitInProcess(
135
148
  store.setStatus(row.id, "failed", { error, endedAt: nowIso() });
136
149
  emit({ type: "failed", error, ts: Date.now() });
137
150
  }
138
- })();
151
+ })().finally(() => { running.delete(work); finish(); });
139
152
 
140
153
  return { jobId: row.id, outputPath };
141
154
  }
@@ -145,16 +158,25 @@ export function submitInProcess(
145
158
  // ---------------------------------------------------------------------------
146
159
 
147
160
  interface QueuedSpawn {
161
+ acquire?: (signal: AbortSignal) => Promise<DisposableResource>;
162
+ lease?: DisposableResource;
163
+ abort: AbortController;
164
+ proc?: Bun.Subprocess;
165
+ finished: Promise<void>;
166
+ finish(): void;
148
167
  store: JobStore;
149
168
  jobId: string;
150
169
  entry: string;
151
170
  bin: string;
171
+ compiled: boolean;
152
172
  spawn: typeof Bun.spawn;
153
173
  onComplete?: (jobId: string, code: number) => void;
154
174
  }
155
175
 
156
176
  let gpuLeaseHolder: string | null = null;
157
177
  const spawnQueue: QueuedSpawn[] = [];
178
+ let activeSpawn: QueuedSpawn | undefined;
179
+ const closedStores = new WeakSet<JobStore>();
158
180
 
159
181
  /** True while a GPU (subprocess) job is running — the server gates token
160
182
  * generation on this so quantize/finetune don't fight live inference. */
@@ -168,6 +190,8 @@ export function currentGpuJob(): string | null {
168
190
  }
169
191
 
170
192
  export interface SubprocessOpts {
193
+ /** Host execution lease, held until child exit (including crashes). */
194
+ acquire?: (signal: AbortSignal) => Promise<DisposableResource>;
171
195
  /** Override the entry script (tests). Defaults to JOB_ENTRY_PATH. */
172
196
  entry?: string;
173
197
  /** Override the runtime binary (tests). Defaults to "bun". */
@@ -189,14 +213,20 @@ export function submitSubprocess(
189
213
  outputPath?: string,
190
214
  opts: SubprocessOpts = {},
191
215
  ): SubmitResult {
216
+ if (closedStores.has(store)) throw new Error("job host is closed");
192
217
  const row = store.create(kind, config, outputPath);
218
+ let finish!: () => void;
219
+ const finished = new Promise<void>((resolve) => { finish = resolve; });
193
220
  const item: QueuedSpawn = {
221
+ abort: new AbortController(), finished, finish,
194
222
  store,
195
223
  jobId: row.id,
196
224
  entry: opts.entry ?? JOB_ENTRY_PATH,
225
+ compiled: opts.entry === undefined && opts.bin === undefined && JOB_ENTRY_PATH.includes("$bunfs"),
197
226
  bin: opts.bin ?? "bun",
198
227
  spawn: opts.spawn ?? Bun.spawn,
199
228
  onComplete: opts.onComplete,
229
+ acquire: opts.acquire,
200
230
  };
201
231
  spawnQueue.push(item);
202
232
  drainQueue();
@@ -215,7 +245,7 @@ function spawnNow(item: QueuedSpawn): void {
215
245
  // a file-backed DB.
216
246
  let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
217
247
  try {
218
- proc = spawn([bin, entry, jobId], {
248
+ proc = spawn(item.compiled ? [process.execPath, "__job", jobId] : [bin, entry, jobId], {
219
249
  stdout: "pipe",
220
250
  stderr: "pipe",
221
251
  env: {
@@ -230,14 +260,16 @@ function spawnNow(item: QueuedSpawn): void {
230
260
  error: errString(e),
231
261
  endedAt: nowIso(),
232
262
  });
263
+ } catch (error) {
264
+ console.error(`[jobs] failed to record spawn failure: ${errString(error)}`);
233
265
  } finally {
234
- releaseLease(jobId);
266
+ releaseLease(item);
235
267
  }
236
268
  return;
237
269
  }
238
270
 
239
- const row = store.get(jobId);
240
- const logPath = row?.log_path;
271
+ item.proc = proc;
272
+ const logPath = store.get(jobId)?.log_path;
241
273
  const logLine = (line: string) => {
242
274
  if (!line || !logPath) return;
243
275
  const ev: JobEvent = { type: "log", line };
@@ -252,7 +284,7 @@ function spawnNow(item: QueuedSpawn): void {
252
284
  // code 0 ⇒ trust the child's terminal status (it set done/failed itself).
253
285
  // non-zero ⇒ if the row never reached terminal (crash before the wrapper
254
286
  // could write), force it failed.
255
- if (code !== 0) {
287
+ try { if (code !== 0) {
256
288
  const cur = store.get(jobId);
257
289
  if (cur && (cur.status === "queued" || cur.status === "running")) {
258
290
  store.setStatus(jobId, "failed", {
@@ -260,15 +292,25 @@ function spawnNow(item: QueuedSpawn): void {
260
292
  endedAt: nowIso(),
261
293
  });
262
294
  }
295
+ } } catch (error) {
296
+ console.error(`[jobs] failed to record child exit: ${errString(error)}`);
297
+ } finally {
298
+ releaseLease(item);
299
+ try { item.onComplete?.(jobId, code); } catch {}
300
+ drainQueue();
263
301
  }
264
- releaseLease(jobId);
265
- try { item.onComplete?.(jobId, code); } catch {}
266
- drainQueue();
267
302
  })();
268
303
  }
269
304
 
270
- function releaseLease(jobId: string): void {
271
- if (gpuLeaseHolder === jobId) gpuLeaseHolder = null;
305
+ function releaseLease(item: QueuedSpawn): void {
306
+ try { item.lease?.dispose(); }
307
+ catch (error) { console.error(`[jobs] execution lease cleanup failed: ${errString(error)}`); }
308
+ finally {
309
+ item.lease = undefined;
310
+ if (gpuLeaseHolder === item.jobId) gpuLeaseHolder = null;
311
+ if (activeSpawn === item) activeSpawn = undefined;
312
+ item.finish();
313
+ }
272
314
  }
273
315
 
274
316
  /** Run the next queued subprocess job if the lease is free. */
@@ -279,8 +321,51 @@ export function drainQueue(): void {
279
321
  // A synchronous spawn failure releases the lease. Keep draining
280
322
  // iteratively so a run of bad queue entries cannot recurse or wedge the
281
323
  // first startable job behind them.
282
- spawnNow(next);
324
+ activeSpawn = next;
325
+ if (next.acquire) {
326
+ gpuLeaseHolder = next.jobId; // reserve FIFO order while inference drains
327
+ void Promise.resolve().then(() => next.acquire!(next.abort.signal)).then((lease) => {
328
+ next.lease = lease;
329
+ if (next.abort.signal.aborted) throw next.abort.signal.reason;
330
+ spawnNow(next);
331
+ }).catch((error) => {
332
+ try {
333
+ next.store.setStatus(next.jobId, "failed", { error: errString(error), endedAt: nowIso() });
334
+ } catch (recordError) {
335
+ console.error(`[jobs] failed to record admission failure: ${errString(recordError)}`);
336
+ } finally { releaseLease(next); }
337
+ }).finally(drainQueue);
338
+ } else spawnNow(next);
339
+ }
340
+ }
341
+
342
+ /** Dataset work stays in-process, but belongs to the application host too. */
343
+ export async function closeInProcessJobs(store: JobStore): Promise<void> {
344
+ closedStores.add(store);
345
+ const active = [...inProcess.get(store) ?? []];
346
+ for (const work of active) work.cancellation.cancel("engine_closed");
347
+ await Promise.all(active.map((work) => work.done));
348
+ }
349
+
350
+ /** Stop only this host's managed GPU jobs. Await process death before releasing
351
+ * the execution lease; queued and admission-waiting jobs never spawn. */
352
+ export async function closeSubprocessJobs(store: JobStore): Promise<void> {
353
+ closedStores.add(store);
354
+ for (let i = spawnQueue.length - 1; i >= 0; i--) {
355
+ const item = spawnQueue[i]!;
356
+ if (item.store !== store) continue;
357
+ spawnQueue.splice(i, 1);
358
+ store.setStatus(item.jobId, "failed", { error: "job host closed", endedAt: nowIso() });
359
+ item.finish();
283
360
  }
361
+ const active = activeSpawn;
362
+ if (!active || active.store !== store) return;
363
+ active.abort.abort(new Error("job host closed"));
364
+ const proc = active.proc;
365
+ if (proc) proc.kill("SIGTERM");
366
+ const force = proc ? setTimeout(() => { if (proc.exitCode === null) proc.kill("SIGKILL"); }, 3000) : undefined;
367
+ try { await active.finished; }
368
+ finally { if (force) clearTimeout(force); }
284
369
  }
285
370
 
286
371
  /** Read a child stream line-by-line, buffering partial trailing lines, and
@@ -0,0 +1,19 @@
1
+ import type { TaskClient } from "../contracts/completion";
2
+ import { throwIfCancelled } from "../engine/cancellation";
3
+ import type { JobEvent, JobRunner } from "./types";
4
+
5
+ /** Numerical runners retain their artifact policy; the client owns delivery
6
+ * and cooperative cancellation at progress boundaries. No event buffering. */
7
+ export function createJobTaskClient(runner: JobRunner): TaskClient<
8
+ Record<string, unknown>, JobEvent, { outputPath?: string } | void
9
+ > {
10
+ return {
11
+ async run(request, report, cancellation) {
12
+ const check = () => { if (cancellation) throwIfCancelled(cancellation); };
13
+ check();
14
+ const result = await runner((event) => { check(); report(event); check(); }, request);
15
+ check();
16
+ return result;
17
+ },
18
+ };
19
+ }
package/src/jobs/types.ts CHANGED
@@ -1,58 +1,2 @@
1
- // Shared job-system contract. Both the job runner (src/jobs/*) and every
2
- // job producer (src/dataset/*, src/quantize/*, src/train/*) speak this
3
- // vocabulary. Mirrors the optiq lab SSE event grammar so the web wizards
4
- // port over mechanically (started → log/stage/metric → done|failed).
5
-
6
- /** A single streamed job event, serialized one-per-line into the job's
7
- * NDJSON log and re-emitted to the browser as SSE `data:` frames. */
8
- export type JobEvent =
9
- | { type: "started"; ts: number }
10
- | { type: "log"; line: string }
11
- | {
12
- type: "stage";
13
- stage: string;
14
- progress?: number; // 0..1
15
- message?: string;
16
- [k: string]: unknown;
17
- }
18
- | {
19
- type: "metric";
20
- kind: "train" | "val";
21
- step: number;
22
- loss: number;
23
- [k: string]: unknown; // grad_norm, learning_rate, tokens_per_sec, accuracy, margin, progress, message
24
- }
25
- | { type: "done"; ts: number; output_dir?: string; summary?: unknown; [k: string]: unknown }
26
- | { type: "failed"; error: string; ts: number };
27
-
28
- /** Sink a job calls to report progress. Implementations append to the log
29
- * file and (for stage/metric events carrying `progress`/`message`) update
30
- * the SQLite row. Never throws — a logging failure must not kill a job. */
31
- export type Emit = (e: JobEvent) => void;
32
-
33
- /** The unit of work. Returns an optional output path recorded on the row.
34
- * Receives the parsed `config` from the submit request. */
35
- export type JobRunner = (
36
- emit: Emit,
37
- config: Record<string, unknown>,
38
- ) => Promise<{ outputPath?: string } | void>;
39
-
40
- /** Job kinds in the system. */
41
- export type JobKind = "quantize" | "finetune" | "dataset" | "noop" | "crash";
42
-
43
- export type JobStatus = "queued" | "running" | "done" | "failed" | "zombie";
44
-
45
- /** A persisted job row (shape returned by the HTTP `GET /api/jobs/:id`). */
46
- export interface JobRow {
47
- id: string;
48
- kind: string;
49
- status: JobStatus;
50
- config_json: string;
51
- progress: number;
52
- message: string | null;
53
- log_path: string;
54
- output_path: string | null;
55
- error: string | null;
56
- started_at: string;
57
- ended_at: string | null;
58
- }
1
+ /** Compatibility exports; producer and browser share the portable wire contract. */
2
+ export type * from "../contracts/jobs";
package/src/kv-store.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { cleanupFailure, disposeResources } from "./engine/resources";
1
2
  // KV-cache persistence: save prompt caches to disk, reload by streamed copy.
2
- // The serialization core of the SSD cold tier (docs/design/ssd-kv-cold-tier.md).
3
+ // The serialization core of the SSD cold tier (docs/design/kv-cache.md).
3
4
  //
4
5
  // File layout (every tensor PAGE-ALIGNED — the Phase 1 corollary: files
5
6
  // we write can be mmap'd and handed to the GPU without copies):
@@ -121,9 +122,26 @@ export interface KvSaveMeta {
121
122
  ns?: string;
122
123
  /** sha256 of tokenizer.json: identical ids must mean identical text. */
123
124
  tokenizerHash?: string;
125
+ /** In-flight generation state. The serialized caches cover `tokens`;
126
+ * pendingToken was sampled from that state but has not been emitted. */
127
+ generationCheckpoint?: {
128
+ key: string;
129
+ cacheNs: string;
130
+ originalPromptTokens: number;
131
+ generatedTokens: number;
132
+ pendingToken: number;
133
+ /** Actual sampler seed, including a server-generated default. Required to
134
+ * continue the random stream exactly after a restart. */
135
+ seed: number;
136
+ /** True when `seed` was part of the client request rather than generated
137
+ * by the server. */
138
+ seedWasExplicit: boolean;
139
+ };
124
140
  }
125
141
 
126
142
  export interface KvFileHeader extends KvSaveMeta {
143
+ /** Absent in earlier v3 files, which used mlx-cache-v3. */
144
+ codecProvider?: string;
127
145
  formatVersion: 3;
128
146
  createdAt: number;
129
147
  tokens: number[];
@@ -143,7 +161,7 @@ const alignUp = (n: number) => Math.ceil(n / ALIGN) * ALIGN;
143
161
  * materialization until rawBytes at write time). */
144
162
  interface TensorSource { arr: MlxArray; disposeAfter: boolean }
145
163
 
146
- interface SnapshotContext {
164
+ export interface SnapshotContext {
147
165
  slots: TensorSlot[];
148
166
  push(a: MlxArray, disposeAfter: boolean): void;
149
167
  liveSlice(a: MlxArray, upTo: number): MlxArray;
@@ -151,21 +169,21 @@ interface SnapshotContext {
151
169
  pushTriple(t: ops.QuantizedTensor, upTo: number | null): void;
152
170
  }
153
171
 
154
- interface CloneContext {
172
+ export interface CloneContext {
155
173
  view(a: MlxArray): MlxArray;
156
174
  liveView(a: MlxArray, upTo: number): MlxArray;
157
175
  mlaView(a: MlxArray, upTo: number): MlxArray;
158
176
  tripleView(t: ops.QuantizedTensor, upTo: number | null): ops.QuantizedTensor;
159
177
  }
160
178
 
161
- interface LoadContext {
179
+ export interface LoadContext {
162
180
  path: string;
163
181
  arr(slot: TensorSlot): MlxArray;
164
182
  grownArr(slot: TensorSlot, offset: number): MlxArray;
165
183
  triple(slots: TensorSlot[], at: number): ops.QuantizedTensor;
166
184
  }
167
185
 
168
- interface CacheCodec {
186
+ export interface CacheCodec {
169
187
  matches(cache: Cache): boolean;
170
188
  snapshot(cache: Cache, context: SnapshotContext): CacheHeaderEntry;
171
189
  clone(cache: Cache, context: CloneContext): Cache;
@@ -458,16 +476,50 @@ const CACHE_CODECS = {
458
476
  },
459
477
  } satisfies Record<CacheKind, CacheCodec>;
460
478
 
461
- const cacheCodec = (cache: Cache): CacheCodec => {
462
- const codec = Object.values(CACHE_CODECS).find((candidate) => candidate.matches(cache));
463
- if (!codec) throw new Error(`unsupported cache signature ${cacheSignature(cache)}`);
464
- return codec;
465
- };
479
+ /** Codecs belong to the loaded backend binding. The persisted ID is checked
480
+ * before allocating state; codecs are engine code, never loaded from a file. */
481
+ export interface CacheCodecProvider {
482
+ readonly id: string;
483
+ forCache(cache: Cache): CacheCodec;
484
+ forHeader(entry: CacheHeaderEntry): CacheCodec;
485
+ }
486
+
487
+ export function createCacheCodecProvider(
488
+ id: string, entries: Readonly<Partial<Record<CacheKind, CacheCodec>>>,
489
+ ): CacheCodecProvider {
490
+ if (!id.trim()) throw new Error("cache codec provider needs an identity");
491
+ const table = new Map(Object.entries(entries).map(([kind, codec]) => [kind, Object.freeze({
492
+ matches: codec.matches.bind(codec), snapshot: codec.snapshot.bind(codec), clone: codec.clone.bind(codec),
493
+ load: codec.load.bind(codec), headerTrimmable: codec.headerTrimmable.bind(codec),
494
+ })]));
495
+ const candidates = [...table.values()];
496
+ const selected = new WeakMap<Cache, CacheCodec>();
497
+ return Object.freeze({
498
+ id,
499
+ forCache(cache: Cache) {
500
+ const bound = selected.get(cache);
501
+ if (bound) return bound;
502
+ const matches = candidates.filter((codec) => codec.matches(cache));
503
+ if (matches.length !== 1)
504
+ throw new Error(`cache codec provider ${id}: ${matches.length} matches for ${cacheSignature(cache)}`);
505
+ selected.set(cache, matches[0]!);
506
+ return matches[0]!;
507
+ },
508
+ forHeader(entry: CacheHeaderEntry) {
509
+ const codec = table.get(entry.kind);
510
+ if (!codec) throw new Error(`cache codec provider ${id}: unsupported cache kind ${entry.kind}`);
511
+ return codec;
512
+ },
513
+ });
514
+ }
515
+
516
+ export const legacyCacheCodecs = createCacheCodecProvider("mlx-cache-v3", CACHE_CODECS);
466
517
 
467
- export const cacheHeadersTrimmable = (caches: readonly CacheHeaderEntry[]): boolean =>
468
- caches.every((entry) => CACHE_CODECS[entry.kind].headerTrimmable(entry));
518
+ export const cacheHeadersTrimmable = (
519
+ caches: readonly CacheHeaderEntry[], codecs: CacheCodecProvider = legacyCacheCodecs,
520
+ ): boolean => caches.every((entry) => codecs.forHeader(entry).headerTrimmable(entry));
469
521
 
470
- function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry; sources: TensorSource[]; next: number } {
522
+ function snapshotCache(c: Cache, dataOffset: number, codecs: CacheCodecProvider): { entry: CacheHeaderEntry; sources: TensorSource[]; next: number } {
471
523
  const slots: TensorSlot[] = [];
472
524
  const sources: TensorSource[] = [];
473
525
  let cursor = dataOffset;
@@ -493,10 +545,13 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
493
545
  }
494
546
  };
495
547
 
496
- const entry = cacheCodec(c).snapshot(c, {
497
- slots, push, liveSlice, liveMlaSlice, pushTriple,
498
- });
499
- return { entry, sources, next: cursor };
548
+ try {
549
+ const entry = codecs.forCache(c).snapshot(c, { slots, push, liveSlice, liveMlaSlice, pushTriple });
550
+ return { entry, sources, next: cursor };
551
+ } catch (error) {
552
+ for (const source of sources) if (source.disposeAfter) source.arr.dispose();
553
+ throw error;
554
+ }
500
555
  }
501
556
 
502
557
  /** Persist `caches` (+ the exact token prefix they encode) to `path`.
@@ -513,15 +568,17 @@ function snapshotCache(c: Cache, dataOffset: number): { entry: CacheHeaderEntry;
513
568
  * called while the caches hold EXACTLY the state to snapshot (post-wrap
514
569
  * rings cannot be rewound later). Same per-kind dispatch as
515
570
  * snapshotCache/loadKvCache. */
516
- export function cloneKvCaches(caches: Cache[]): Cache[] {
517
- const view = (a: MlxArray): MlxArray => a.slice(a.shape.map(() => 0), [...a.shape]);
571
+ export function cloneKvCaches(caches: Cache[], codecs: CacheCodecProvider = legacyCacheCodecs): Cache[] {
572
+ const pending: MlxArray[] = [];
573
+ const hold = (array: MlxArray): MlxArray => { pending.push(array); return array; };
574
+ const view = (a: MlxArray): MlxArray => hold(a.slice(a.shape.map(() => 0), [...a.shape]));
518
575
  const liveView = (a: MlxArray, upTo: number): MlxArray => {
519
576
  const [B, H, , D] = a.shape as [number, number, number, number];
520
- return a.slice([0, 0, 0, 0], [B, H, upTo, D]);
577
+ return hold(a.slice([0, 0, 0, 0], [B, H, upTo, D]));
521
578
  };
522
579
  const mlaView = (a: MlxArray, upTo: number): MlxArray => {
523
580
  const [B, , D] = a.shape as [number, number, number];
524
- return a.slice([0, 0, 0], [B, upTo, D]);
581
+ return hold(a.slice([0, 0, 0], [B, upTo, D]));
525
582
  };
526
583
  const tripleView = (t: ops.QuantizedTensor, upTo: number | null): ops.QuantizedTensor => ({
527
584
  packed: upTo === null ? view(t.packed) : liveView(t.packed, upTo),
@@ -531,11 +588,11 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
531
588
  const out: Cache[] = [];
532
589
  try {
533
590
  for (const c of caches) {
534
- out.push(cacheCodec(c).clone(c, { view, liveView, mlaView, tripleView }));
591
+ out.push(codecs.forCache(c).clone(c, { view, liveView, mlaView, tripleView }));
592
+ pending.length = 0; // transferred to the returned cache
535
593
  }
536
594
  } catch (err) {
537
- for (const c of out) c.dispose();
538
- throw err;
595
+ cleanupFailure(err, () => disposeResources([...pending, ...out]));
539
596
  }
540
597
  return out;
541
598
  }
@@ -545,14 +602,14 @@ export function cloneKvCaches(caches: Cache[]): Cache[] {
545
602
  * between yields so the EVENT LOOP KEEPS SERVING while a multi-hundred-MB
546
603
  * entry flushes (the write-behind persistence contract: durability never
547
604
  * blocks a request). try/finally still runs on early generator close. */
548
- function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta): Generator<void, void, void> {
605
+ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta, codecs: CacheCodecProvider): Generator<void, void, void> {
549
606
  // Plan pass: header entries + lazy tensor sources, NO bytes materialized.
550
607
  const entries: CacheHeaderEntry[] = [];
551
608
  const sources: TensorSource[] = [];
552
609
  let dataOffset = 0; // relative; rebased after header is sized
553
610
  try {
554
611
  for (const c of caches) {
555
- const s = snapshotCache(c, dataOffset);
612
+ const s = snapshotCache(c, dataOffset, codecs);
556
613
  entries.push(s.entry);
557
614
  sources.push(...s.sources);
558
615
  dataOffset = s.next;
@@ -561,7 +618,7 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
561
618
  // Header size is final NOW: hashes are fixed-width placeholders that get
562
619
  // patched in place (same byte length) after the data pass computes them.
563
620
  const header: KvFileHeader = {
564
- formatVersion: 3, createdAt: Date.now(), ...meta, tokens, caches: entries,
621
+ formatVersion: 3, createdAt: Date.now(), ...meta, codecProvider: codecs.id, tokens, caches: entries,
565
622
  };
566
623
  const headerLen = new TextEncoder().encode(JSON.stringify(header)).length;
567
624
  const dataStart = alignUp(PREFIX_LEN + headerLen);
@@ -626,32 +683,34 @@ function* saveKvCacheSteps(path: string, tokens: number[], caches: Cache[], meta
626
683
  }
627
684
  }
628
685
 
629
- export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}): void {
630
- for (const _ of saveKvCacheSteps(path, tokens, caches, meta)) { /* drain */ }
686
+ export function saveKvCache(path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {}, codecs: CacheCodecProvider = legacyCacheCodecs): void {
687
+ for (const _ of saveKvCacheSteps(path, tokens, caches, meta, codecs)) { /* drain */ }
631
688
  }
632
689
 
633
690
  /** Non-blocking variant: yields the event loop after every tensor write so
634
691
  * serving interleaves with the flush. Caller owns `caches` lifetime for
635
692
  * the duration (pass zero-copy clones, dispose after).
636
693
  *
637
- * `waitTurn` (optional) is awaited BEFORE every step — including the first —
638
- * so the caller can gate the flush's schedule (the server passes the
639
- * gateway's onIdle: each tensor step is a blocking GPU sync on the decode
640
- * stream + a synchronous multi-MB writeSync, and the old unconditional
641
- * setImmediate pacing interleaved those slices exactly between decode
642
- * tokens — the 2026-07-07 decode@ctx contamination. A request arriving
643
- * MID-flush pauses the remaining tensors until idle again). Gate failures
644
- * are swallowed: scheduling advice must never corrupt the write path
645
- * (an early generator close inside the fd-open section would leak the fd). */
694
+ * `runStep` (optional) runs each blocking tensor step inside the caller's
695
+ * exclusion domain. Waiting for an idle observation is not sufficient: a
696
+ * request can acquire the engine between that check and `steps.next()`,
697
+ * putting rawBytesView's GPU sync on the decode stream during prefill. */
646
698
  export async function saveKvCacheAsync(
647
699
  path: string, tokens: number[], caches: Cache[], meta: KvSaveMeta = {},
648
- waitTurn?: () => Promise<void>,
700
+ runStep?: <T>(step: () => T) => Promise<T>, codecs: CacheCodecProvider = legacyCacheCodecs,
649
701
  ): Promise<void> {
650
- const steps = saveKvCacheSteps(path, tokens, caches, meta);
651
- while (true) {
652
- if (waitTurn) await waitTurn().catch(() => {});
653
- if (steps.next().done) break;
654
- await new Promise<void>((r) => setImmediate(r));
702
+ const steps = saveKvCacheSteps(path, tokens, caches, meta, codecs);
703
+ try {
704
+ while (true) {
705
+ const next = runStep ? await runStep(() => steps.next()) : steps.next();
706
+ if (next.done) break;
707
+ await new Promise<void>((r) => setImmediate(r));
708
+ }
709
+ } catch (error) {
710
+ // A rejected execution lease can leave the writer suspended with an
711
+ // open fd. Throw through it so its existing failure cleanup runs.
712
+ steps.throw(error);
713
+ throw error;
655
714
  }
656
715
  }
657
716
 
@@ -666,7 +725,7 @@ export interface SpillItem {
666
725
  /** Bounded write-behind queue (2026-07-07 post-merge review fix).
667
726
  *
668
727
  * Pending spill/snapshot clones pin their entries' GPU buffers until the
669
- * idle-gated flush gets a turn; the old bare promise chain queued them
728
+ * generation-locked flush gets a turn; the old bare promise chain queued them
670
729
  * WITHOUT BOUND, so under sustained traffic (gate starved, evictions
671
730
  * ongoing) resident memory = prompt-cache cap + every queued clone —
672
731
  * allocator pressure exactly under the load that caused the evictions.
@@ -904,9 +963,12 @@ function withStepCapacity(a: MlxArray, offset: number): MlxArray {
904
963
  export function loadKvCache(
905
964
  path: string,
906
965
  model: { makeCache(): Cache[] },
907
- expect: KvLoadExpect = {},
966
+ expect: KvLoadExpect = {}, codecs: CacheCodecProvider = legacyCacheCodecs,
908
967
  ): LoadedKvCache {
909
968
  const header = readKvHeader(path);
969
+ if ((header.codecProvider ?? legacyCacheCodecs.id) !== codecs.id)
970
+ throw new Error(`${path}: cache codec provider mismatch`);
971
+ for (const entry of header.caches) codecs.forHeader(entry);
910
972
  for (const key of [
911
973
  "modelId",
912
974
  "configFingerprint",
@@ -966,9 +1028,7 @@ export function loadKvCache(
966
1028
  const caches: Cache[] = [];
967
1029
  try {
968
1030
  for (const e of header.caches) {
969
- const codec = CACHE_CODECS[e.kind];
970
- if (!codec)
971
- throw new Error(`${path}: unknown cache kind ${(e as { kind: string }).kind}`);
1031
+ const codec = codecs.forHeader(e);
972
1032
  caches.push(codec.load(e, { path, arr, grownArr, triple }));
973
1033
  // This entry's tensors are now owned by its cache — stop tracking them
974
1034
  // (the catch must not double-free through both pending AND caches).
@@ -0,0 +1,18 @@
1
+ # src/lab — quarantined, no-oracle code
2
+
3
+ Everything here is a Lab-tier module (docs/design/unified-engine-frontier-plan.md):
4
+ no external oracle, off by default, kept only while a measurement decides its
5
+ fate (PLAN.md "Maintainability program", D6 darlings). Rules, gate-enforced by
6
+ `scripts/check-hygiene.ts` (check 11):
7
+
8
+ - nothing outside `src/lab/` may import from `src/lab/` except the edges listed
9
+ in `LAB_IMPORT_ALLOWLIST` — those are recorded debt, not permission;
10
+ - a new production dependency on a Lab module is a decision, not a default:
11
+ either promote the module out of the lab with an oracle/measurement, or
12
+ route it through an option seam.
13
+
14
+ | module | flag / entry | oracle |
15
+ |---|---|---|
16
+ | `curve/` (curve sampler + designer page) | `--curve-*`, `/curves` | none (Lab) |
17
+ | `paged-kv/` | `--paged-kv` (serial-only) | parity vs plain KVCache (bit-exact), no measured win |
18
+ | `expert-trace/` | `MLX_BUN_EXPERT_TRACE` hook in gemma4 | closed study (expert-offload) |
@@ -88,7 +88,7 @@ button.ghost{background:var(--card);border:1px solid var(--bd);border-radius:8px
88
88
 
89
89
  <script>
90
90
  const ln=Math.log;
91
- const UMIN=ln(1e-6); // bottom-left / plot floor (matches src/curve-sampler.ts CURVE_UMIN)
91
+ const UMIN=ln(1e-6); // bottom-left / plot floor (matches src/lab/curve/curve-sampler.ts CURVE_UMIN)
92
92
  const ENGINE_BASE = location.protocol.startsWith("http") ? location.origin : "http://localhost:8080";
93
93
  // ---- curve state: 5 movable control points, sorted ascending in p_in ----
94
94
  function identityPoints(){ return [{x_pct:1e-4,y_pct:1e-4},{x_pct:0.1,y_pct:0.1},{x_pct:1,y_pct:1},{x_pct:9,y_pct:9},{x_pct:100,y_pct:100}]; }
@@ -102,7 +102,7 @@ const PROMPTS=[
102
102
  "Describe the feeling of a city at 3am in one vivid sentence.",
103
103
  ];
104
104
 
105
- // ---- math: monotone cubic (PCHIP), IDENTICAL to src/curve-sampler.ts ----
105
+ // ---- math: monotone cubic (PCHIP), IDENTICAL to src/lab/curve/curve-sampler.ts ----
106
106
  const Lp=pt=>[ln(Math.max(pt.x_pct,1e-9)/100),ln(Math.max(pt.y_pct,1e-9)/100)];
107
107
  function knotArrays(c){ const k=c.points.map(Lp).sort((a,b)=>a[0]-b[0]); return {X:k.map(q=>q[0]),Y:k.map(q=>q[1])}; }
108
108
  function secants(c){ const {X,Y}=knotArrays(c),s=[]; for(let i=X.length-1;i>0;i--)s.push((Y[i]-Y[i-1])/(X[i]-X[i-1])); return s; }
@@ -16,8 +16,8 @@
16
16
  // BELOW lowers them (renorm makes the literal number an intent); the SLOPE across
17
17
  // a region sets how evenly the tokens inside it spread (flat<1 even, steep>1 separated).
18
18
 
19
- import { MlxArray } from "./mlx/array";
20
- import * as ops from "./mlx/ops";
19
+ import { MlxArray } from "../../mlx/array";
20
+ import * as ops from "../../mlx/ops";
21
21
 
22
22
  export interface CurvePoint { x_pct: number; y_pct: number; }
23
23
  export interface CurveParams {