mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
@@ -0,0 +1,247 @@
1
+ # Runtime isolation — the AI may crash, the UI never may
2
+
3
+ Status: **design** (not started)
4
+ Owner: serving layer / runtime
5
+ Default today: inference runs **in-process**, on the server's event-loop
6
+ thread. This doc proposes moving it to a **subprocess**.
7
+
8
+ ## Goal
9
+
10
+ One `mlx-bun` process plays three or four roles at once:
11
+
12
+ 1. **Serves the web UI** (static assets, the chat page).
13
+ 2. **Serves the API** (`/v1/*` for the web UI, the CLI, and external
14
+ agents).
15
+ 3. **Runs the AI** (weight load, prefill, decode — the heavy GPU work).
16
+
17
+ One executable doing all of this is the product's whole charm, and for
18
+ most people, most of the time, it is perfect. It breaks in one specific
19
+ way: when the AI is working hard — say an agentic pipeline classifying a
20
+ photo library — the **web UI lags**. Page refreshes stall, `/status`
21
+ polls hang, chat messages queue behind the GPU.
22
+
23
+ Waiting for a *chat reply* is fine and expected (local inference takes
24
+ time; hosted services take up to a minute too). A **laggy UI is neither
25
+ normal nor acceptable.** The UI must stay responsive no matter what the
26
+ AI is doing — and if the AI process dies (an uncatchable GPU OOM), the UI
27
+ must survive and report it, not go down with it.
28
+
29
+ ## Root cause — blocking work on the event-loop thread
30
+
31
+ The server is a single Bun event loop. The governing principle of an
32
+ event loop is: **the loop thread may only ever do non-blocking work.**
33
+ We violate it by running the synchronous MLX FFI eval loop *on* that
34
+ thread.
35
+
36
+ Concretely:
37
+
38
+ - **The per-token GPU read is a blocking FFI call.** Each decode step
39
+ ends with `ops.itemUint32(cur)` → `a.eval()` → `mlx_eval`
40
+ (`src/mlx/ops.ts:471`), which parks the single JS thread until the GPU
41
+ returns. Compute is dispatched async (`asyncEvalAll` + decode
42
+ pipelining, `src/generate.ts:386`) — good — but reading the value back
43
+ over `bun:ffi` is a hard, synchronous wait on the main thread. There is
44
+ no thread handoff.
45
+ - **The loop yields only *microtasks*, not *macrotasks*.** Bun services
46
+ I/O — accepting connections, answering `/status`, serving the page —
47
+ only at macrotask boundaries. A bare `yield` in the async generator
48
+ plus an `await` of an already-resolved value drains the microtask
49
+ queue without ever reaching the I/O phase.
50
+ - **Streaming partially hides this; non-streaming does not.** The SSE
51
+ path's `onToken` returns `new Promise(r => setImmediate(r))` ~every
52
+ 25 ms (`src/server.ts:1341`) — a real macrotask yield, so the page
53
+ breathes. The **non-streaming** path's `onToken` returns `undefined`
54
+ every token (`src/server.ts:1407`), so the whole completion is one
55
+ blocking-FFI-plus-microtask chain and the loop is starved start to
56
+ finish.
57
+ - **Prefill never yields at all.** The chunked prompt eval calls
58
+ `ops.evalAll(...)` synchronously with no `await` between chunks
59
+ (`src/generate.ts:294`). A long prompt freezes the page for the full
60
+ prefill *even in streaming mode*.
61
+
62
+ ### Why yielding alone is not the fix
63
+
64
+ A `setImmediate` yield can interleave I/O *between* tokens, but it
65
+ **cannot interrupt an in-flight `mlx_eval`** — that C call is atomic from
66
+ JS's view. Each token blocks for tens of ms; a prefill blocks one big
67
+ chunk (hundreds of ms to seconds) with no yield point possible inside the
68
+ kernel. So perfect yielding gives the UI thin inter-token slices, not
69
+ isolation. Adequate for "chat plus occasional"; inadequate for "agentic
70
+ pipeline running flat-out while I refresh the page." For true isolation
71
+ the blocking work must leave the thread.
72
+
73
+ ## Decision — subprocess, not worker thread
74
+
75
+ Move the AI runtime off the event-loop thread. Two ways:
76
+
77
+ - **Worker thread** (Bun `Worker`): shares the process, cheapest IPC
78
+ (a `SharedArrayBuffer` token ring). But a GPU OOM / SIGTRAP — which our
79
+ own notes flag as **uncatchable** (CLAUDE.md "async GPU exec OOM is
80
+ uncatchable") — takes down the whole process, **including the UI
81
+ server**.
82
+ - **Subprocess** (`Bun.spawn`): crash isolation. An MLX OOM kills the
83
+ inference process; the UI/API server survives, reports it, and
84
+ respawns. Heavier IPC, but on a single-user local box a pipe/socket is
85
+ trivially fast next to token cadence.
86
+
87
+ **Decision: subprocess.** The product rule is "the AI may crash, the UI
88
+ never may," and only address-space isolation delivers that against an
89
+ uncatchable fault. It also reuses machinery the repo has already proven.
90
+
91
+ ## We already built the blueprint — for jobs
92
+
93
+ `src/jobs/runner.ts` already does exactly this for the *other* heavy GPU
94
+ consumers (quantize / finetune):
95
+
96
+ - `submitSubprocess()` creates a job row and `Bun.spawn`s the binary as a
97
+ child (`src/jobs/runner.ts:210`, entry `src/jobs/job-entry.ts`).
98
+ - A single global **GPU lease** (`gpuLeaseHolder`) serializes GPU access;
99
+ `isGpuBusy()` / `currentGpuJob()` (`src/jobs/runner.ts:159`) expose it,
100
+ **and the server already gates token generation on it** so
101
+ quantize/finetune don't fight live inference.
102
+ - IPC is a file-backed SQLite job store + stdout/stderr line pumping into
103
+ a log + an SSE tail (`src/jobs/sse.ts`).
104
+
105
+ So the pattern — heavy GPU work → its own process → server thread stays
106
+ free, serialized by one lease — is established and tested. **Inference is
107
+ the one heavy consumer still running inline on the event-loop thread.**
108
+ It is the exception, and it is the one people hit on every message.
109
+
110
+ ## The seam already exists
111
+
112
+ Request handlers do not call `generate()` directly — they call
113
+ `gateway.run(promptIds, options, onToken, vision, shape)`
114
+ (`src/serve/generation-gateway.ts:108`, call sites `src/server.ts:1331`
115
+ and `:1407`). The `GenerationGateway` is the abstraction boundary between
116
+ the HTTP layer and execution. **We replace what is behind it** (inline
117
+ `generate()` → an IPC client that talks to the inference subprocess); the
118
+ HTTP/SSE layer, the `onToken` contract, and the `false`-halts-generation
119
+ semantics are unchanged.
120
+
121
+ ## Target architecture
122
+
123
+ - **Main process = pure reactor.** `Bun.serve` does static UI + the API
124
+ surface + routing + tokenization/templating/SSE encoding (all cheap,
125
+ non-blocking JS). **Zero MLX calls.** Once generation is off-thread,
126
+ nothing else on the main thread blocks — static serves and `/status`
127
+ are already async; they lag today only because the loop is
128
+ monopolized. So this alone makes the UI instant.
129
+ - **Inference runtime = a persistent subprocess** owning the model +
130
+ Metal context, doing prefill/decode, streaming tokens back. Persistent
131
+ (not spawn-per-request): weight load is the cost, can't pay it per
132
+ request. One process, one model, one GPU.
133
+ - **One GPU lease governs all consumers** — live inference, agentic API
134
+ calls, quantize, finetune. The GPU is the single serialized resource
135
+ (matching the single-user reality: chat waiting behind the photo
136
+ pipeline is the *acceptable* kind of waiting), while UI/API I/O is
137
+ fully parallel and never waits.
138
+
139
+ ```
140
+ ┌──────────────────────────────────────────────┐
141
+ │ main process (event loop — never blocks) │
142
+ │ Bun.serve: static UI · /v1/* API · routing │
143
+ │ tokenize · template · SSE encode │
144
+ │ │ gateway.run() = IPC client │
145
+ └────────┼──────────────────────────────────────┘
146
+ │ request ↓ ↑ token stream
147
+ ┌────────┼──────────────────────────────────────┐
148
+ │ inference subprocess (may block freely) │
149
+ │ owns model + Metal · prefill · decode │
150
+ │ holds the GPU lease while generating │
151
+ └────────────────────────────────────────────────┘
152
+ (crash here ⇒ parent respawns; UI untouched)
153
+ ```
154
+
155
+ ## IPC sketch
156
+
157
+ - **Transport:** Bun supports a structured `ipc` channel on `Bun.spawn`
158
+ (parent ⇄ child message passing); a unix domain socket is the
159
+ fallback. Tokens are slow relative to message passing, so per-token
160
+ messages are fine; batch a few if needed. (Jobs use stdout-line + the
161
+ SQLite store, which is right for fire-and-forget long jobs but too
162
+ coarse for low-latency per-token streaming — inference wants the
163
+ message channel.)
164
+ - **Request → child:** `{ promptIds, options, shape }`. Tokenization and
165
+ template rendering stay in the parent (cheap); the child receives
166
+ token ids.
167
+ - **Child → parent:** a stream of `{ token }` messages, then a final
168
+ `{ stats }`. The parent's gateway client invokes the handler's existing
169
+ `onToken` per message — per-row SSE fan-out keeps working unchanged.
170
+ - **Cancellation / backpressure:** `onToken` returning `false` (stop
171
+ sequence) or a client disconnect must abort the in-flight generation —
172
+ send a cancel message; the child checks it at the decode-loop boundary
173
+ (the natural place `generate()` already yields).
174
+ - **Crash handling:** child `exited` non-zero ⇒ fail any in-flight
175
+ request with a clean 5xx, respawn the child, reload the model. The UI
176
+ server never noticed.
177
+
178
+ ## Lease reconciliation (the one genuinely new problem)
179
+
180
+ Jobs are *transient* (spawn → run → exit, lease released on exit). The
181
+ inference worker is *persistent* (resident, holds the model in RAM). They
182
+ must share one GPU coherently:
183
+
184
+ - A quantize/finetune job needs the GPU while the inference worker is
185
+ resident. Options: (a) the inference worker **drains and yields** the
186
+ lease for the job's duration (finish current gen, pause admission,
187
+ release), then resumes; (b) the persistent worker **also runs the
188
+ jobs** (one GPU owner, no cross-process lease at all). (b) is cleaner
189
+ long-term but couples job lifecycle into the inference process; (a)
190
+ keeps jobs as-is. **Open — pick during phase 3.**
191
+ - On 24 GB, the resident model already dominates memory; a concurrent
192
+ finetune that also needs multi-GB is an OOM footgun regardless of
193
+ process layout. The lease must remain *exclusive*, never truly
194
+ concurrent on the GPU.
195
+
196
+ ## What this unlocks / simplifies
197
+
198
+ - **Delete the yield hacks.** Once the loop blocks only in the child, the
199
+ `setImmediate` throttle (`src/server.ts:1341`) and any prefill
200
+ chunk-yielding (`src/generate.ts:294`) are no longer needed for UI
201
+ responsiveness — the hot path gets cleaner and marginally faster.
202
+ - **Folds into model hot-swap** (see PRODUCT_ROADMAP "Model hot-swap from
203
+ the UI"): the model lives in the worker, so `loadContext` and swap
204
+ happen there while the main thread serves a live "swapping…" state.
205
+ These two items converge on "the model is not on the main thread."
206
+ - **Flags the next blocker:** vision preprocessing (`Bun.Image` decode,
207
+ `src/vision/preprocess.ts`) is also main-thread CPU work and should
208
+ ride into the worker, or it becomes the next source of lag.
209
+
210
+ ## Staging (incremental, not big-bang)
211
+
212
+ - **Phase 1 — stop the bleeding (in-process).** Macrotask-yield the
213
+ non-streaming `onToken` (`src/server.ts:1407`) and the prefill chunk
214
+ loop (`src/generate.ts:294`), mirroring what streaming already does.
215
+ Trivial change; kills the acute hang for the common case. Does **not**
216
+ isolate the UI under sustained load — that's Phase 2.
217
+ - **Phase 2 — inference subprocess behind the gateway.** Stand up the
218
+ persistent inference child + IPC; make `GenerationGateway` an IPC
219
+ client. Main process makes zero MLX calls. The HTTP layer is untouched.
220
+ This is the real fix.
221
+ - **Phase 3 — unify the GPU lease.** Bring inference under the same lease
222
+ as quantize/finetune so all GPU consumers schedule coherently; resolve
223
+ the persistent-vs-transient reconciliation above.
224
+
225
+ ## Exit criteria
226
+
227
+ - Under a sustained generation load (e.g. a tight `/v1` loop), a parallel
228
+ web-UI page load and `/status` poll return in **< 50 ms** (today: stalls
229
+ for the full completion in non-streaming, inter-token in streaming).
230
+ - Killing the inference process mid-generation leaves the UI server up; a
231
+ fresh page load and `/status` still succeed, and the in-flight request
232
+ returns a clean error rather than a hang.
233
+ - No throughput regression vs the in-process path on the single-stream
234
+ decode benchmark (token IPC overhead is below noise).
235
+
236
+ ## Open questions
237
+
238
+ - Bun `ipc` channel vs unix socket vs `SharedArrayBuffer` ring for the
239
+ token stream — measure latency/overhead before committing.
240
+ - One inference worker only (single GPU, single model) — confirmed by the
241
+ single-user model. Multiple models = future, and pairs with hot-swap.
242
+ - Lease reconciliation strategy (a) drain-and-yield vs (b) worker runs
243
+ jobs — decide in Phase 3.
244
+ - Where does prompt-cache live — in the worker (it holds KV bytes, must be
245
+ byte-capped per CLAUDE.md). Almost certainly the worker.
246
+ - Startup UX: parent up and serving the UI immediately; worker model load
247
+ happens behind a "loading…" state the parent reports.