mlx-bun 0.3.0 → 0.5.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 (231) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +58 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/investigations/openjarvis-fit.md +198 -0
  5. package/docs/archive/investigations/qwen38-27b-campaign-log.md +1092 -0
  6. package/docs/archive/planning/release-notes-v0.4.0.md +45 -0
  7. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  8. package/docs/design/batching.md +1535 -50
  9. package/docs/design/decode-speed-program.md +394 -1171
  10. package/docs/design/generic-model-support.md +195 -3
  11. package/docs/design/kv-cache.md +538 -225
  12. package/docs/design/speculative-decoding.md +552 -50
  13. package/docs/design/turboquant.md +140 -17
  14. package/docs/design/unified-engine-frontier-plan.md +259 -150
  15. package/docs/planning/PRODUCT_ROADMAP.md +21 -6
  16. package/docs/planning/public-readiness-handoff.md +244 -0
  17. package/docs/planning/public-readiness.md +149 -0
  18. package/docs/reference/benchmarks.md +3537 -1112
  19. package/docs/reference/cli.md +102 -2
  20. package/docs/reference/distribution.md +45 -13
  21. package/docs/reference/environment.md +159 -13
  22. package/docs/reference/library-api.md +18 -4
  23. package/docs/reference/memory.md +1 -1
  24. package/docs/reference/models.md +28 -7
  25. package/docs/reference/server-api.md +257 -33
  26. package/docs/reference/server-config.md +372 -151
  27. package/docs/reference/troubleshooting.md +13 -21
  28. package/package.json +2 -2
  29. package/src/audio/audiotoolbox.ts +112 -0
  30. package/src/audio/conformer.ts +8 -0
  31. package/src/audio/mic-capture.ts +98 -0
  32. package/src/audio/silero-vad.ts +379 -0
  33. package/src/audio/whisper-decode.ts +774 -0
  34. package/src/audio/whisper-fast.ts +732 -0
  35. package/src/audio/whisper-format.ts +73 -0
  36. package/src/audio/whisper-mel.ts +185 -0
  37. package/src/audio/whisper-timing.ts +274 -0
  38. package/src/audio/whisper-tokenizer.ts +239 -0
  39. package/src/audio/whisper-transcribe.ts +410 -0
  40. package/src/backends/mlx/assistant-target.ts +46 -0
  41. package/src/backends/mlx/autoregressive-draft-rows.ts +120 -0
  42. package/src/backends/mlx/autoregressive.ts +22 -1
  43. package/src/backends/mlx/batch-group.ts +464 -236
  44. package/src/backends/mlx/cache-attachment.ts +67 -0
  45. package/src/backends/mlx/cache-layout.ts +31 -0
  46. package/src/backends/mlx/checkpoint-state.ts +53 -0
  47. package/src/backends/mlx/continuation-persistence.ts +107 -0
  48. package/src/backends/mlx/continuation-request.ts +43 -0
  49. package/src/backends/mlx/continuation.ts +77 -0
  50. package/src/backends/mlx/draft-target.ts +7 -22
  51. package/src/backends/mlx/encoder-cache.ts +25 -0
  52. package/src/backends/mlx/fill-append.ts +21 -8
  53. package/src/backends/mlx/fill-group.ts +497 -0
  54. package/src/backends/mlx/full-kv-row-append.ts +32 -0
  55. package/src/backends/mlx/full-kv-row-donor.ts +54 -0
  56. package/src/backends/mlx/full-transitioning-kv-rows.ts +65 -0
  57. package/src/backends/mlx/gateway-binding.ts +122 -19
  58. package/src/backends/mlx/kv-codec.ts +9 -0
  59. package/src/backends/mlx/kv-maintenance.ts +161 -0
  60. package/src/backends/mlx/kv-tensor-rows.ts +212 -0
  61. package/src/backends/mlx/mixed-iteration.ts +62 -0
  62. package/src/backends/mlx/model-prompt.ts +46 -32
  63. package/src/backends/mlx/model-serving.ts +5 -4
  64. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  65. package/src/backends/mlx/prefill-cohort.ts +100 -0
  66. package/src/backends/mlx/prefill-policy.ts +26 -0
  67. package/src/backends/mlx/prefill-rows.ts +244 -0
  68. package/src/backends/mlx/prefill.ts +1 -1
  69. package/src/backends/mlx/projected-context-rows.ts +72 -0
  70. package/src/backends/mlx/prompt-input.ts +29 -0
  71. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  72. package/src/backends/mlx/request-state-policy.ts +54 -0
  73. package/src/backends/mlx/rollback.ts +45 -4
  74. package/src/backends/mlx/row-sampling.ts +49 -0
  75. package/src/backends/mlx/serial-executor.ts +51 -73
  76. package/src/backends/mlx/speculative-group.ts +438 -0
  77. package/src/backends/mlx/speculative-round.ts +161 -0
  78. package/src/backends/mlx/speculative.ts +24 -7
  79. package/src/backends/mlx/state-rows.ts +104 -0
  80. package/src/backends/mlx/state-views.ts +21 -1
  81. package/src/backends/mlx/target-layout-capability.ts +35 -0
  82. package/src/backends/mlx/transitioning-kv-rows.ts +173 -0
  83. package/src/cli.ts +333 -36
  84. package/src/contracts/execution.ts +21 -0
  85. package/src/contracts/object-cache.ts +8 -0
  86. package/src/contracts/pi-provider.ts +1 -1
  87. package/src/contracts/pi.ts +3 -1
  88. package/src/contracts/prefix-cache.ts +33 -0
  89. package/src/contracts/resources.ts +7 -0
  90. package/src/contracts/sampling.ts +23 -0
  91. package/src/contracts/scheduling.ts +27 -0
  92. package/src/engine/execution-plan.ts +38 -25
  93. package/src/engine/execution-tasks.ts +44 -0
  94. package/src/engine/namespaced-cache.ts +13 -0
  95. package/src/engine/resources.ts +12 -1
  96. package/src/engine/scheduler.ts +32 -7
  97. package/src/fill/fill-session.ts +14 -3
  98. package/src/fit.ts +29 -15
  99. package/src/generate.ts +105 -195
  100. package/src/grammar.ts +13 -0
  101. package/src/inference/draft-acceptance.ts +41 -0
  102. package/src/inference/draft-output.ts +49 -0
  103. package/src/inference/prefill.ts +6 -0
  104. package/src/inference/rollback.ts +2 -2
  105. package/src/kv-scheme.ts +92 -12
  106. package/src/kv-store.ts +183 -93
  107. package/src/lab/README.md +5 -1
  108. package/src/lab/paged-kv/paged-attention.ts +117 -0
  109. package/src/lab/paged-kv/paged-kv-rows.ts +111 -0
  110. package/src/lab/paged-kv/paged-kv.ts +126 -46
  111. package/src/md-modules.d.ts +5 -0
  112. package/src/mlx/array.ts +39 -1
  113. package/src/mlx/ffi.ts +19 -0
  114. package/src/mlx/normalized-argmax.ts +116 -0
  115. package/src/mlx/ops.ts +97 -10
  116. package/src/model/artifact-identity.ts +73 -0
  117. package/src/model/batched-kv.ts +235 -0
  118. package/src/model/batched-mask.ts +12 -4
  119. package/src/model/batched-quant.ts +8 -2
  120. package/src/model/batched-quantized-kv.ts +144 -0
  121. package/src/model/batched-rotating-quant.ts +76 -15
  122. package/src/model/batched-rotating-state.ts +76 -0
  123. package/src/model/batched-rotating.ts +64 -22
  124. package/src/model/batched-row-storage.ts +52 -0
  125. package/src/model/batched-ssm.ts +52 -0
  126. package/src/model/batched-turboquant-kv.ts +94 -0
  127. package/src/model/compiled-decode.ts +7 -1
  128. package/src/model/decoded-kv-donor.ts +12 -0
  129. package/src/model/delayed-quantized-kv.ts +72 -0
  130. package/src/model/delayed-rotating-quantized-kv.ts +93 -0
  131. package/src/model/delayed-turboquant-kv.ts +57 -0
  132. package/src/model/factory.ts +26 -0
  133. package/src/model/fingerprint.ts +12 -0
  134. package/src/model/full-prefill-padding.ts +56 -0
  135. package/src/model/full-prefill-row.ts +105 -0
  136. package/src/model/gemma4-base.ts +183 -82
  137. package/src/model/gemma4.ts +116 -28
  138. package/src/model/generated/gemma4-12b.ts +4 -1
  139. package/src/model/generated/gemma4-26b.ts +4 -1
  140. package/src/model/generated/gemma4-e4b.ts +4 -1
  141. package/src/model/glm52-cache.ts +80 -4
  142. package/src/model/glm52-mla.ts +12 -6
  143. package/src/model/glm52.ts +19 -40
  144. package/src/model/kv-attention-view.ts +70 -0
  145. package/src/model/minicpm5.ts +11 -7
  146. package/src/model/profile.ts +29 -4
  147. package/src/model/quantized-append-attention.ts +31 -0
  148. package/src/model/qwen3-delta-state.ts +74 -0
  149. package/src/model/qwen3-delta.ts +59 -36
  150. package/src/model/qwen3-mrope.ts +7 -9
  151. package/src/model/qwen3_5.ts +177 -75
  152. package/src/model/rotating-kv-donor.ts +35 -0
  153. package/src/model/rotating-kv-layout.ts +262 -0
  154. package/src/model/rotating-row-transaction.ts +35 -0
  155. package/src/model/speculative-rotating-kv.ts +101 -0
  156. package/src/model/ssm-prefill-padding.ts +39 -0
  157. package/src/model/support.ts +8 -0
  158. package/src/model/token-groups.ts +66 -0
  159. package/src/model/trellis-linear.ts +4 -4
  160. package/src/model/turboquant-codec.ts +89 -0
  161. package/src/model/universal/dense.ts +14 -13
  162. package/src/model/whisper.ts +435 -0
  163. package/src/native/mic_capture.swift +92 -0
  164. package/src/native-pack.ts +6 -5
  165. package/src/pi-web.ts +9 -1
  166. package/src/preflight.ts +31 -15
  167. package/src/prompt-cache.ts +306 -86
  168. package/src/runtime-config.ts +1 -5
  169. package/src/safetensors.ts +9 -2
  170. package/src/sampler.ts +37 -25
  171. package/src/serve/audio-routes.ts +323 -0
  172. package/src/serve/chat-request.ts +2 -0
  173. package/src/serve/chat-stage.ts +19 -14
  174. package/src/serve/completion-executor.ts +9 -1
  175. package/src/serve/discovery-routes.ts +17 -1
  176. package/src/serve/generated-token-history.ts +57 -0
  177. package/src/serve/generation-gateway.ts +88 -38
  178. package/src/serve/model-binding.ts +7 -1
  179. package/src/serve/model-host.ts +3 -4
  180. package/src/serve/preparation.ts +3 -3
  181. package/src/serve/prompt-response-trace.ts +14 -0
  182. package/src/serve/request-plan.ts +6 -3
  183. package/src/serve/request-prep.ts +40 -25
  184. package/src/serve/text-completion-stage.ts +2 -2
  185. package/src/serve/transcription-server.ts +64 -0
  186. package/src/serve/transcription-service.ts +502 -0
  187. package/src/server.ts +185 -261
  188. package/src/spec/assistant-rows.ts +93 -0
  189. package/src/spec/assistant-source.ts +28 -18
  190. package/src/spec/cache-identity.ts +22 -0
  191. package/src/spec/deepspec-rows.ts +29 -0
  192. package/src/spec/deepspec-source.ts +12 -10
  193. package/src/spec/dflash-source.ts +28 -4
  194. package/src/spec/drafter.ts +153 -142
  195. package/src/spec/dspark/deepspec-module.ts +95 -120
  196. package/src/spec/dspark/module-dflash.ts +126 -170
  197. package/src/spec/glm52-mtp-graph.ts +29 -0
  198. package/src/spec/glm52-mtp-rows.ts +132 -0
  199. package/src/spec/glm52-mtp-source.ts +54 -136
  200. package/src/spec/glm52-mtp-state.ts +33 -0
  201. package/src/spec/ngram-source.ts +115 -21
  202. package/src/spec/projected-draft-rows.ts +73 -0
  203. package/src/spec/qwen-mtp-module.ts +171 -0
  204. package/src/spec/qwen-mtp-rows.ts +230 -0
  205. package/src/spec/qwen-mtp-source.ts +84 -226
  206. package/src/spec/qwen-mtp-state.ts +30 -0
  207. package/src/spec/serve-loop.ts +73 -69
  208. package/src/spec/source.ts +93 -15
  209. package/src/spec/two-model-rows.ts +62 -0
  210. package/src/spec/two-model.ts +45 -103
  211. package/src/ssd-cache.ts +107 -36
  212. package/src/ssd-durability.ts +29 -27
  213. package/src/storage/host-buffer.ts +24 -0
  214. package/src/storage/kv-writer.ts +83 -0
  215. package/src/storage/kv-writer.worker.js +264 -0
  216. package/src/storage/retention-policy.ts +37 -0
  217. package/src/tiered-prompt-cache.ts +45 -0
  218. package/src/vision/embedder.ts +8 -0
  219. package/src/vision/prompt.ts +101 -65
  220. package/src/vision/qwen3vl-prompt.ts +101 -64
  221. package/src/vision/qwen3vl-tower.ts +11 -0
  222. package/src/vision/siglip.ts +8 -0
  223. package/src/web/app.html +12 -0
  224. package/src/web/app.js +217 -57
  225. package/src/web/src/chat.ts +4 -1
  226. package/src/web/src/composer.ts +3 -0
  227. package/src/web/src/voice.ts +141 -0
  228. package/src/web/sw.js +1 -1
  229. package/src/backends/mlx/memory-guard.ts +0 -40
  230. package/src/spec/prefix-state.ts +0 -45
  231. /package/docs/{planning → archive/planning}/release-notes-v0.3.0.md +0 -0
package/src/generate.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { appendFileSync } from "node:fs";
11
11
  import { MlxArray, gpuStream } from "./mlx/array";
12
12
  import {
13
+ Dtype,
13
14
  activeMemory,
14
15
  clearCache,
15
16
  maxRecommendedWorkingSetSize,
@@ -18,16 +19,18 @@ import {
18
19
  synchronize,
19
20
  } from "./mlx/ffi";
20
21
  import * as ops from "./mlx/ops";
21
- import { KVCache, RotatingKVCache, TurboQuantKVCache, type Cache } from "./model/gemma4";
22
- import { PagedKVCache } from "./lab/paged-kv/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";
23
25
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
24
26
  import { denoiseAsync } from "./diffusion/diffusion-generate";
25
27
  import { bindLegacyDenoisingModel, type MlxDenoisingBinding } from "./backends/mlx/diffusion";
26
28
  import type { RuntimeModel } from "./model/factory";
27
29
  import {
28
- assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel,
30
+ assertMlxAutoregressiveBinding, bindLegacyAutoregressiveModel, supportsCommittedAppendCache,
29
31
  type MlxAutoregressiveBinding, type MlxModelMemory, type MlxDecodeStep, type MlxTokenAppend,
30
32
  } from "./backends/mlx/autoregressive";
33
+ import { createKvMaintenance } from "./backends/mlx/kv-maintenance";
31
34
  import { nextPrefillStep } from "./inference/prefill";
32
35
  import { evalCacheState, executeMlxPrefillStep } from "./backends/mlx/prefill";
33
36
  import { appendFillHidden } from "./backends/mlx/fill-append";
@@ -40,6 +43,7 @@ import { runtimeConfig, runtimeValue, withRuntimeConfig, type RuntimeConfig } fr
40
43
  import {
41
44
  disposeStepExtras,
42
45
  makeStepSampler,
46
+ readStepExtras,
43
47
  stepExtrasArrays,
44
48
  type LogitsProcessorOptions,
45
49
  type SamplerOptions,
@@ -57,8 +61,8 @@ import {
57
61
  } from "./fill/fill-session";
58
62
 
59
63
  export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
60
- /** RAM budget for a model-owned paired target/draft prompt snapshot. */
61
- speculativeCacheBytes?: number;
64
+ /** Application session affinity; independent of numerical cache identity. */
65
+ cacheSessionId?: string;
62
66
  /** Resolved host policy. Direct compatibility calls resolve from their
63
67
  * captured binding when this is absent. */
64
68
  decodePolicy?: Readonly<Pick<import("./contracts/execution").ResolvedExecution, "compiledDecode" | "grammarJump">>;
@@ -121,14 +125,13 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
121
125
  * Caller keeps ownership. */
122
126
  multimodalMask?: MlxArray;
123
127
  /** Quantize full-attention KV caches to this many bits (4 or 8).
124
- * Rotating (sliding-window) caches stay bf16 — they're window-capped
125
- * and upstream rotating-cache quantization is NYI. */
128
+ * Full-attention and rotating caches both convert when populated. */
126
129
  kvBits?: number;
127
130
  kvGroupSize?: number;
128
131
  /** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
129
132
  * Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
130
- * the cache list (== layer index for the donor prefix); entries for
131
- * rotating/sliding caches are skipped until Phase 9. */
133
+ * the cache list (== layer index for the donor prefix), including
134
+ * rotating/sliding caches. */
132
135
  kvConfig?: KvQuantSpec[];
133
136
  /** Convert once a cache's offset reaches this (uniform-kvBits default
134
137
  * 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
@@ -136,14 +139,14 @@ export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions
136
139
  /** TurboQuant scheme (docs/design/turboquant.md): rotation-based KV
137
140
  * quantization, a CLI-only runtime lever in the same class as uniform
138
141
  * kvBits (mutually exclusive with kvBits/kvConfig — maybeQuantizeKv
139
- * checks kvBits/kvConfig first, so set at most one). Full-attention
142
+ * checks turboQuant first, so set at most one). Full-attention
140
143
  * KVCache layers convert via TurboQuantKVCache.fromKVCache;
141
144
  * RotatingKVCache (sliding-window) layers stay bf16 in v1 — a one-time
142
145
  * warning names the limitation, never a throw. */
143
146
  turboQuant?: TurboQuantScheme;
144
147
  /** OPTIONAL paged KV storage (docs/design/kv-cache.md): fresh
145
148
  * full-attention KVCache layers are replaced with PagedKVCache (block
146
- * pool + gather-to-contiguous) before prefill. v1 scope: serial batch=1
149
+ * pool + gather-to-contiguous) before prefill. Scope: B1/B>1
147
150
  * Gemma4-family, bf16 — mutually exclusive with kvBits/kvConfig/
148
151
  * turboQuant/draft/compiled decode (callers refuse the combos; the
149
152
  * cache swap itself only ever touches plain empty KVCache entries).
@@ -200,17 +203,16 @@ export function shouldUseGrammarJump(
200
203
  }
201
204
 
202
205
  /** Composition gate for token fast-forwarding, enforced inside the engine
203
- * (the serve layer refuses more shapes it alone can see — a user-fixed seed,
204
- * a mounted draft model, continuous placement: src/serve/request-prep.ts,
205
- * src/serve/chat-stage.ts). Throws when MLX_BUN_FILL names the unbuilt echo
206
- * tier — an operator who asked for K3c must not get K3b silently.
206
+ * The serving planner owns request-level eligibility, including explicit
207
+ * seeds in serial execution and mounted draft models. Shared strict fill
208
+ * has its own grouped method and uses the same append-format declaration.
207
209
  * - grammar: forced-token production is the grammar's job; two producers in
208
210
  * one iteration is forbidden (asserted in the loop).
209
211
  * - logprobs/top_logprobs: injected tokens are never sampled, so they have
210
212
  * no distribution row (same rule as shouldUseGrammarJump).
211
213
  * - promptEmbeddings: vision/audio prompts (and mRoPE) — untested shape.
212
- * - kvBits/kvConfig/turboQuant: post-conversion multi-token append is
213
- * L-generic but unvalidated; refuse in v1 rather than pay it silently. */
214
+ * - affine KV: the model's append binding declares its supported formats.
215
+ * Verify-policy quantized appends remain a separate gate. */
214
216
  export function shouldUseFill(
215
217
  options: Pick<
216
218
  GenerateOptions,
@@ -218,6 +220,7 @@ export function shouldUseFill(
218
220
  | "kvBits" | "kvConfig" | "turboQuant"
219
221
  >,
220
222
  runtime: RuntimeConfig = runtimeConfig(),
223
+ append?: Pick<MlxTokenAppend, "affineKvBits" | "turboQuantFormats"> | null,
221
224
  ): boolean {
222
225
  if (!options.fill) return false;
223
226
  if (resolveFillMode(runtime.value("MLX_BUN_FILL") ?? "off") === "off") return false;
@@ -225,93 +228,15 @@ export function shouldUseFill(
225
228
  !options.logprobs &&
226
229
  !(options.topLogprobs && options.topLogprobs > 0) &&
227
230
  options.promptEmbeddings === undefined &&
228
- !options.kvBits &&
229
- !options.kvConfig?.length &&
230
- options.turboQuant === undefined;
231
+ supportsCommittedAppendCache(append, options);
231
232
  }
232
233
 
233
- /** Port of mlx-lm maybe_quantize_kv_cache + BOTH halves of optiq serve's
234
- * per-layer patched variant (incl. patch_rotating_to_quantized: rotating
235
- * caches convert too — Phase 9):
236
- * - per-layer bits/group_size selection (kvConfig overrides kvBits,
237
- * matching optiq's --kv-config precedence; shipped kv_config.json
238
- * files cover EVERY cache-owning layer, sliding ones included —
239
- * verified 12B 48/48, 26B 30/30, e4b 24/24 distinct caches — so
240
- * rotating quantization engages straight from the config; uniform
241
- * kvBits — like optiq --kv-bits — reaches them too), and
242
- * - STREAMING conversion (optiq streaming_kv_quant / serve.py
243
- * patched_maybe_quantize): eval each layer's quantized triples and
244
- * clear the buffer pool before building the next layer's conversion.
245
- * Lazily batching every layer's toQuantized into one eval pins ALL
246
- * layers' bf16 K/V as graph inputs alongside ALL quantized outputs —
247
- * the exact transient optiq's fix kills (16.35 → 7.60 GB at 32k on a
248
- * 24 GB Mac). Numerics untouched: same quantize math, only the eval
249
- * ordering is forced (tests/parity/kv-quant.test.ts, tests/parity/rotating-kvq.test.ts). */
234
+ /** Compatibility entry point for callers that perform a single conversion.
235
+ * Execution sessions compose createKvMaintenance once and reuse it. */
250
236
  export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
251
- const { kvBits, kvConfig, turboQuant } = options;
252
- if (turboQuant) {
253
- maybeTurboQuantizeKv(cache, turboQuant, options.quantizedKvStart ?? 0);
254
- return;
255
- }
256
- if (!kvBits && !kvConfig?.length) return;
257
- const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
258
- const byLayer = kvConfig?.length
259
- ? new Map(kvConfig.map((e) => [e.layerIdx, e]))
260
- : null;
261
- for (let i = 0; i < cache.length; i++) {
262
- const c = cache[i]!;
263
- if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
264
- // OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
265
- // runs bf16, then the populated cache is quantized before decode.
266
- // Converting empty caches at start=0 makes prefill itself quantized
267
- // and diverges from the oracle path.
268
- if (c.offset === 0) continue;
269
- if (byLayer) {
270
- const e = byLayer.get(i);
271
- if (!e) continue;
272
- cache[i] = c.toQuantized(e.groupSize, e.bits);
273
- } else {
274
- cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
275
- }
276
- // Streaming half: materialize THIS layer's conversion now so its bf16
277
- // source (already released by toQuantized) frees before the next layer
278
- // converts — the transient stays ~one layer, not the whole cache.
279
- ops.evalAll(cache[i]!.state());
280
- clearCache();
281
- }
237
+ createKvMaintenance(options)(cache);
282
238
  }
283
239
 
284
- /** Paged-KV conversion (docs/design/kv-cache.md): swap each FRESH
285
- * plain full-attention KVCache for a PagedKVCache sized to hold
286
- * `capacityTokens` (prompt + maxTokens — known exactly at generate()
287
- * setup, so pool exhaustion is unreachable absent an accounting bug).
288
- * Same in-place-mutation shape as maybeQuantizeKv, but runs ONCE before
289
- * prefill: paging changes storage layout, not arithmetic, so there is no
290
- * "convert when populated" trigger. Sliding-window (RotatingKVCache)
291
- * layers keep today's scheme — mixed paged-full + rotating-sliding is
292
- * the supported v1 shape. Pre-warmed caches (offset > 0) skip conversion
293
- * entirely: the serve lane bypasses prompt-cache reuse for paged
294
- * requests, so this only guards library callers. */
295
- export function maybePageKv(
296
- cache: Cache[], options: GenerateOptions, capacityTokens: number,
297
- ): void {
298
- if (!options.pagedKv) return;
299
- if (cache.some((c) => c.offset > 0)) return;
300
- const blockSize = options.pagedKv.blockSize ?? PagedKVCache.DEFAULT_BLOCK_SIZE;
301
- for (let i = 0; i < cache.length; i++) {
302
- if (cache[i] instanceof KVCache) {
303
- cache[i]!.dispose(); // fresh (offset 0) — nothing stored yet
304
- cache[i] = new PagedKVCache(capacityTokens, blockSize);
305
- }
306
- }
307
- }
308
-
309
- /** Emitted once per process: RotatingKVCache (sliding-window) layers are a
310
- * documented v1 non-goal (docs/design/turboquant.md) — they stay bf16
311
- * rather than throwing, so mixed full-attention/sliding-window models (e.g.
312
- * Gemma) still serve correctly under --kv-quant turbo. */
313
- let warnedTurboRotating = false;
314
-
315
240
  /** Emitted once per process: token fast-forwarding skips models with
316
241
  * sliding-window layers in v1 (see the gate in generateInner). */
317
242
  let warnedFillRotating = false;
@@ -330,39 +255,6 @@ type RewindableCache = {
330
255
  };
331
256
  const rewindable = (c: unknown): RewindableCache => c as RewindableCache;
332
257
 
333
- /** TurboQuant conversion chokepoint (mirrors the uniform/config branch
334
- * above): only plain full-attention KVCache instances convert, via
335
- * TurboQuantKVCache.fromKVCache — RotatingKVCache stays bf16 (warn once,
336
- * never throw). Same offset===0 skip-empty-cache rule as the affine path. */
337
- function maybeTurboQuantizeKv(cache: Cache[], scheme: TurboQuantScheme, start: number): void {
338
- for (let i = 0; i < cache.length; i++) {
339
- const c = cache[i]!;
340
- if (c instanceof RotatingKVCache) {
341
- if (!warnedTurboRotating) {
342
- warnedTurboRotating = true;
343
- console.warn(
344
- "[turbo-quant] sliding-window (RotatingKVCache) layers stay bf16 in v1 " +
345
- "(full-attention only) — docs/design/turboquant.md.",
346
- );
347
- }
348
- continue;
349
- }
350
- if (!(c instanceof KVCache) || c.offset < start || c.offset === 0) continue;
351
- const tq = TurboQuantKVCache.fromKVCache(c, scheme.kBits, scheme.vBits);
352
- cache[i] = tq;
353
- // state() allocates fresh trimmed slice views for this cache kind
354
- // (see evalCacheState) — dispose after materializing (throw included),
355
- // or they leak.
356
- const state = tq.state();
357
- try {
358
- ops.evalAll(state);
359
- } finally {
360
- for (const a of state) a.dispose();
361
- }
362
- clearCache();
363
- }
364
- }
365
-
366
258
  export interface GenerateStats {
367
259
  promptTokens: number;
368
260
  /** An observed terminal cause takes precedence over the token-count limit. */
@@ -455,9 +347,11 @@ export class Generation implements AsyncIterable<GeneratedToken> {
455
347
  // Scope semantics match the reference: set → generate → synchronize →
456
348
  // restore; nothing stays pinned between generations. Re-entrant: only
457
349
  // the outermost wiring scope touches the limit.
458
- // macOS 26.6 reports a 24.96 GiB recommended set on this 24 GB machine.
459
- // The old 0.75 fraction therefore stopped wiring the 13-16 GiB Qwen/GLM
460
- // 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.
461
355
  // Keep smaller 8-9 GiB models unwired while covering the large-model class.
462
356
  const WIRE_THRESHOLD = 0.5;
463
357
  let wiredScopeDepth = 0;
@@ -705,7 +599,7 @@ async function* generateInner(
705
599
  const {
706
600
  maxTokens = 512,
707
601
  eosTokenIds = binding.eosTokenIds,
708
- prefillChunkSize = 2048,
602
+ prefillChunkSize = binding.prefillPolicy?.chunkSize(promptTokens.length) ?? 2048,
709
603
  } = options;
710
604
 
711
605
  try {
@@ -719,6 +613,7 @@ async function* generateInner(
719
613
  throw error;
720
614
  }
721
615
  const graph = binding.graph;
616
+ const maintainKv = createKvMaintenance(options);
722
617
 
723
618
  // logprobs capture (mlx_lm.server semantics — see GenerateOptions.logprobs).
724
619
  // Everything below is gated: when neither flag is set, no extra ops, evals,
@@ -746,6 +641,7 @@ async function* generateInner(
746
641
  const ownsCache = !options.cache;
747
642
  const resuming = options.initialPendingToken !== undefined;
748
643
  let cache: Cache[] = options.cache ?? [];
644
+ let appender: MlxTokenAppend | null = null;
749
645
  try {
750
646
  if (ownsCache) cache = binding.makeCache();
751
647
  if (!cache.length) throw new Error("AR binding returned an empty cache");
@@ -760,6 +656,9 @@ async function* generateInner(
760
656
  // Replace fresh full-attention caches before any forward. The deepest
761
657
  // write is prompt + maxTokens - 1, so capacity covers every decode step.
762
658
  maybePageKv(cache, options, promptTokens.length + maxTokens);
659
+ appender = options.fill ? binding.createAppend?.({
660
+ hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
661
+ }) ?? null : null;
763
662
  } catch (error) {
764
663
  if (ownsCache) for (const state of cache) state.dispose();
765
664
  stepSampler.dispose();
@@ -773,7 +672,7 @@ async function* generateInner(
773
672
  // window) layers append multi-token writes through #updateConcat — O(window)
774
673
  // per append rather than the O(L) a plain ring pays — so v1 warns and skips
775
674
  // the whole feature for those models rather than paying it silently.
776
- let fillOn = shouldUseFill(options, runtime);
675
+ let fillOn = shouldUseFill(options, runtime, appender);
777
676
  if (fillOn && cache.some((c) => c instanceof RotatingKVCache)) {
778
677
  fillOn = false;
779
678
  if (!warnedFillRotating) {
@@ -793,29 +692,10 @@ async function* generateInner(
793
692
  // restore a pre-round snapshot and bit-exactly replay the accepted prefix.
794
693
  // A model whose caches can do neither still gets assert-policy fills; verify
795
694
  // proposals are dropped and counted (stats.verifyUnsupported).
796
- const verifyCapable = fillOn && cache.every(
695
+ const verifyCapable = fillOn && !options.kvBits && !options.kvConfig?.length && !options.turboQuant && cache.every(
797
696
  (c) => c.isTrimmable() || typeof rewindable(c).specRoundRollback === "function",
798
697
  );
799
698
  closeBatchSetup?.();
800
- /** Device-side logprob capture for one step, computed from the SAME lp the
801
- * sampler saw (post-processors, pre-truncation) — read back lazily with the
802
- * token so decode pipelining is preserved. */
803
- /** Read extras back to JS (forces eval — they were async-dispatched with the
804
- * token) without appending casts behind the next decode step. */
805
- const readExtras = (e: StepExtras | null): TokenLogprobs | undefined => {
806
- if (!e) return undefined;
807
- const out: TokenLogprobs = {};
808
- if (e.sel) out.logprob = e.sel.toFloat32Host()[0]!;
809
- if (e.topIdx && e.topVals) {
810
- const ids = e.topIdx.toIntTokens();
811
- const vals = e.topVals.toFloat32Host();
812
- out.top = Array.from(ids, (id, i) => ({ id, logprob: vals[i]! })).sort(
813
- (a, b) => b.logprob - a.logprob,
814
- );
815
- }
816
- disposeStepExtras(e);
817
- return out;
818
- };
819
699
 
820
700
  // logits [1,1,V] → sampled token array [1] (+ optional logprob capture,
821
701
  // all on-device)
@@ -844,10 +724,10 @@ async function* generateInner(
844
724
  let pendingExtras: StepExtras | null = null;
845
725
  let nextExtras: StepExtras | null = null;
846
726
  let decoder: MlxDecodeStep | null = null;
847
- let appender: MlxTokenAppend | null = null;
848
727
  let finished = false;
849
728
  let threw = false;
850
729
  let executionError: unknown;
730
+ let earlyUnforwardedToken: number | null = null;
851
731
  let closeTokenZero: (() => void) | undefined;
852
732
  const makeStats = (): GenerateStats => ({
853
733
  promptTokens: options.originalPromptTokens ?? promptTokens.length,
@@ -946,9 +826,8 @@ async function* generateInner(
946
826
  nextPending = null;
947
827
  disposeStepExtras(nextExtras);
948
828
  nextExtras = null;
949
- // No-op under the fill exclusions (kv quant refuses fill); kept so the
950
- // boundary stays correct if those exclusions ever loosen.
951
- maybeQuantizeKv(cache, options);
829
+ // Conversion runs at the ordinary committed-token boundary as well.
830
+ maintainKv(cache);
952
831
 
953
832
  let checkpointMs = 0;
954
833
  let roundOpen = false;
@@ -963,11 +842,18 @@ async function* generateInner(
963
842
  let logitsAll: MlxArray | null = null;
964
843
  try {
965
844
  const chunkSize = (state: readonly Cache[]) => {
966
- const maxChunk = appender?.maxChunkSize(state) ?? 1;
845
+ const maxChunk = Math.min(appender?.maxChunkSize(state) ?? 1,
846
+ maintainKv.maxAppendTokens?.(state) ?? Number.POSITIVE_INFINITY);
967
847
  return fill.appendChunkSize > 0 ? Math.min(fill.appendChunkSize, maxChunk) : maxChunk;
968
848
  };
849
+ const committedGraph = appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph);
850
+ const forwardCommitted = maintainKv.maxAppendTokens ? async (ids: MlxArray, state: Cache[]) => {
851
+ const hidden = await committedGraph(ids, state);
852
+ try { maintainKv(state); return hidden; }
853
+ catch (error) { hidden.dispose(); throw error; }
854
+ } : committedGraph;
969
855
  hf = await appendFillHidden(
970
- !verify && appender ? appender.forwardHidden.bind(appender) : graph.forwardHidden.bind(graph),
856
+ verify ? graph.forwardHidden.bind(graph) : forwardCommitted,
971
857
  cache, ids, verify ? () => ids.length : chunkSize,
972
858
  );
973
859
  const [, Lf, Hf] = hf.shape as [number, number, number];
@@ -1061,7 +947,10 @@ async function* generateInner(
1061
947
  // pending token was sampled from that exact state before the snapshot,
1062
948
  // so resume starts directly at the decode loop without another forward.
1063
949
  if (needsTokenHistory) stepSampler.seedHistory(promptTokens);
1064
- pending = ops.fromInt32([options.initialPendingToken!], [1]);
950
+ // Device samplers return uint32. Preserve that signature on restore so
951
+ // compiled decode does not trace a different signed-token graph first.
952
+ using restoredToken = ops.fromInt32([options.initialPendingToken!], [1]);
953
+ pending = restoredToken.astype(Dtype.uint32);
1065
954
  ops.asyncEvalAll([pending]);
1066
955
  prefillMs = 0;
1067
956
  closePrefill?.();
@@ -1072,7 +961,7 @@ async function* generateInner(
1072
961
  mechanism: diagnostics.mechanism ?? "serial",
1073
962
  boundary: "initial",
1074
963
  });
1075
- maybeQuantizeKv(cache, options);
964
+ maintainKv(cache);
1076
965
  closeInitialKv?.();
1077
966
  tPrefill = performance.now();
1078
967
  let h0: MlxArray;
@@ -1096,7 +985,7 @@ async function* generateInner(
1096
985
  : undefined;
1097
986
  const tailSplit = runtime.flag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
1098
987
  const forward = graph.forwardHidden.bind(graph);
1099
- const maintain = () => maybeQuantizeKv(cache, options);
988
+ const maintain = () => maintainKv(cache);
1100
989
  while (true) {
1101
990
  options.signal?.throwIfAborted();
1102
991
  const step = nextPrefillStep({ length: promptTokens.length, position: pos,
@@ -1152,9 +1041,6 @@ async function* generateInner(
1152
1041
  decoder = options.decodePolicy?.compiledDecode === false ? null : binding.createDecode?.({
1153
1042
  hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1154
1043
  }) ?? null;
1155
- appender = fillOn ? binding.createAppend?.({
1156
- hasAdapters: !!options.adapters?.length, pagedKv: !!options.pagedKv,
1157
- }) ?? null : null;
1158
1044
  let stop = false;
1159
1045
  /** Token id read eagerly at the top of the loop for grammar (reused for
1160
1046
  * the yield, avoiding a second readback). -1 when grammar is off — the
@@ -1170,7 +1056,7 @@ async function* generateInner(
1170
1056
  const grammarJump = options.decodePolicy?.grammarJump ?? shouldUseGrammarJump(options, runtime);
1171
1057
  // Yield token zero before its own decode forward. The captured runtime
1172
1058
  // keeps this scheduling choice stable across consumer awaits.
1173
- const earlyFirstToken = runtime.value("MLX_BUN_EARLY_FIRST_TOKEN") === "1" &&
1059
+ const earlyFirstToken = runtime.flag("MLX_BUN_EARLY_FIRST_TOKEN", true) &&
1174
1060
  maxTokens > 1 && !resuming && !fillOn && !options.grammar;
1175
1061
  while (!stop) {
1176
1062
  options.signal?.throwIfAborted();
@@ -1186,14 +1072,16 @@ async function* generateInner(
1186
1072
  closeTokenZero?.();
1187
1073
  closeTokenZero = undefined;
1188
1074
  tDecode = performance.now();
1189
- const logprobs = readExtras(curExtras);
1075
+ const logprobs = readStepExtras(curExtras);
1190
1076
  curExtras = null;
1191
1077
  pendingExtras = null;
1192
1078
  // Count before yielding so an early return reports this token. Its
1193
1079
  // KV is still absent; forwarded describes the prompt-only cache.
1194
1080
  generated++;
1195
1081
  yieldedEarly = true;
1082
+ earlyUnforwardedToken = firstToken;
1196
1083
  yield { token: firstToken, index: stepIndex, ...(logprobs ? { logprobs } : {}) };
1084
+ earlyUnforwardedToken = null;
1197
1085
  options.signal?.throwIfAborted();
1198
1086
  }
1199
1087
  }
@@ -1242,7 +1130,7 @@ async function* generateInner(
1242
1130
  // contract); the next sampled token, if the budget and grammar allow
1243
1131
  // one, comes from its last position. Compiled decode resumes on the
1244
1132
  // following iteration (supports() re-checks the grown caches).
1245
- maybeQuantizeKv(cache, options);
1133
+ maintainKv(cache);
1246
1134
  pushHistory(cur);
1247
1135
  stepSampler.commitNumbers(jumpEmit);
1248
1136
  const chunk = [grammarTok, ...jumpEmit];
@@ -1267,7 +1155,7 @@ async function* generateInner(
1267
1155
  h.dispose(); // burst ends the generation (max_tokens or grammar done)
1268
1156
  }
1269
1157
  } else if (stepIndex + 1 < maxTokens && !options.grammar?.isTerminated) {
1270
- maybeQuantizeKv(cache, options);
1158
+ maintainKv(cache);
1271
1159
  pushHistory(cur);
1272
1160
  let logits: MlxArray | null = null;
1273
1161
  let evalWith: MlxArray[] = [];
@@ -1347,7 +1235,7 @@ async function* generateInner(
1347
1235
  }
1348
1236
  // readExtras before the yield: if the consumer breaks at this yield,
1349
1237
  // the extras are already read and disposed.
1350
- const logprobs = readExtras(curExtras);
1238
+ const logprobs = readStepExtras(curExtras);
1351
1239
  pendingExtras = null;
1352
1240
  if (!yieldedEarly) yield { token, index: generated - 1, ...(logprobs ? { logprobs } : {}) };
1353
1241
  options.signal?.throwIfAborted();
@@ -1412,31 +1300,53 @@ async function* generateInner(
1412
1300
  executionError = e;
1413
1301
  throw e;
1414
1302
  } finally {
1415
- if (!finished) {
1416
- pending?.dispose();
1417
- nextPending?.dispose();
1418
- disposeStepExtras(pendingExtras);
1419
- disposeStepExtras(nextExtras);
1420
- }
1421
1303
  try {
1422
- const closing = decoder?.close();
1423
- if (closing) await closing;
1304
+ // An early consumer return leaves token zero outside the retained KV.
1305
+ // Finish that one M=1 forward before handing caller-owned caches back,
1306
+ // matching the ordinary pipeline's boundary. Otherwise a later prefix
1307
+ // hit folds the token into an M>1 prefill and changes native reductions.
1308
+ // Aborted requests and caches owned by this run need no retained state.
1309
+ if (!ownsCache && !threw && !options.signal?.aborted && earlyUnforwardedToken !== null) {
1310
+ maintainKv(cache);
1311
+ const ids = ops.reshape(pending!, [1, 1]);
1312
+ try {
1313
+ const hidden = await graph.forwardHidden(ids, cache);
1314
+ try { evalCacheState(cache); }
1315
+ finally { hidden.dispose(); }
1316
+ } finally { ids.dispose(); }
1317
+ forwarded.push(earlyUnforwardedToken);
1318
+ }
1424
1319
  } catch (error) {
1425
- if (threw) throw new AggregateError([executionError, error],
1426
- "AR execution and decoder cleanup failed", { cause: executionError });
1320
+ threw = true;
1321
+ executionError = error;
1427
1322
  throw error;
1428
1323
  } finally {
1429
- if (ownsCache) for (const c of cache) c.dispose();
1430
- // TokenizerInfo is process-cached; this request owns the matcher.
1431
- options.grammar?.dispose();
1432
- stepSampler.dispose();
1433
- }
1434
- if (!finished && !threw) {
1435
- // forced early return (consumer break at a yield): still report
1436
- // stats — `forwarded` only lists tokens whose KV actually entered
1437
- // the cache, so cacheTokens stays exact for PromptCache.put().
1438
- decodeMs = performance.now() - tDecode;
1439
- return makeStats();
1324
+ if (!finished) {
1325
+ pending?.dispose();
1326
+ nextPending?.dispose();
1327
+ disposeStepExtras(pendingExtras);
1328
+ disposeStepExtras(nextExtras);
1329
+ }
1330
+ try {
1331
+ const closing = decoder?.close();
1332
+ if (closing) await closing;
1333
+ } catch (error) {
1334
+ if (threw) throw new AggregateError([executionError, error],
1335
+ "AR execution and decoder cleanup failed", { cause: executionError });
1336
+ throw error;
1337
+ } finally {
1338
+ if (ownsCache) for (const c of cache) c.dispose();
1339
+ // TokenizerInfo is process-cached; this request owns the matcher.
1340
+ options.grammar?.dispose();
1341
+ stepSampler.dispose();
1342
+ }
1343
+ if (!finished && !threw) {
1344
+ // forced early return (consumer break at a yield): still report
1345
+ // stats — `forwarded` only lists tokens whose KV actually entered
1346
+ // the cache, so cacheTokens stays exact for PromptCache.put().
1347
+ decodeMs = performance.now() - tDecode;
1348
+ return makeStats();
1349
+ }
1440
1350
  }
1441
1351
  }
1442
1352
  }
package/src/grammar.ts CHANGED
@@ -314,6 +314,19 @@ export class GrammarController {
314
314
  return this.terminated;
315
315
  }
316
316
 
317
+ /** Propose the forced string without advancing the matcher. Tokenization
318
+ * can differ at its first boundary; verification and the normal grammar
319
+ * sampler accept or reject the candidates before anything is committed. */
320
+ async proposeTokens(maxTokens: number): Promise<number[]> {
321
+ await this.ready();
322
+ if (this.terminated || !this.encodeRaw) return [];
323
+ return wasmQueue(async () => {
324
+ if (this.disposed) return [];
325
+ const text = this.matcher.findJumpForwardString();
326
+ return text ? this.encodeRaw!(text).slice(0, maxTokens) : [];
327
+ });
328
+ }
329
+
317
330
  /** Jump-forward decoding (SGLang's technique via xgrammar's
318
331
  * findJumpForwardString; opt-in — MLX_BUN_GRAMMAR_JUMP=1): when the grammar
319
332
  * admits exactly one continuation string (JSON keys/punctuation, enum
@@ -0,0 +1,41 @@
1
+ /** One request's exact-token speculative acceptance walk. The backend owns
2
+ * logits and sampling; this state owns only the accepted prefix and stop facts.
3
+ * A group advances each live walk independently, regardless of batch size. */
4
+ export class DraftAcceptance {
5
+ position = 0;
6
+ accepted = 0;
7
+ correction: number | null = null;
8
+ readonly emitted: number[] = [];
9
+ sawEos = false;
10
+ grammarDone = false;
11
+ done = false;
12
+
13
+ constructor(
14
+ readonly drafts: readonly number[],
15
+ readonly remaining: number,
16
+ readonly eosTokenIds: readonly number[],
17
+ ) {}
18
+
19
+ /** Supply one target sample after its sampler has updated request history
20
+ * and grammar. Once done, this request needs no further target samples. */
21
+ accept(token: number, grammarDone = false): void {
22
+ const matches = this.position < this.drafts.length && token === this.drafts[this.position];
23
+ this.position++;
24
+ if (matches) {
25
+ this.accepted++;
26
+ if (this.eosTokenIds.includes(token)) {
27
+ this.sawEos = this.done = true;
28
+ return;
29
+ }
30
+ this.emitted.push(token);
31
+ this.grammarDone = grammarDone;
32
+ this.done = grammarDone || this.emitted.length >= this.remaining;
33
+ return;
34
+ }
35
+ this.correction = token;
36
+ this.sawEos = this.eosTokenIds.includes(token);
37
+ if (!this.sawEos) this.emitted.push(token);
38
+ this.grammarDone = grammarDone;
39
+ this.done = true;
40
+ }
41
+ }
@@ -0,0 +1,49 @@
1
+ import type { Cancellation, CancelReason, GenerationOutput, TokenLogprobs } from "../contracts/generation";
2
+ import type { DraftAcceptance } from "./draft-acceptance";
3
+
4
+ export interface DraftOutputRow {
5
+ readonly acceptance: DraftAcceptance;
6
+ readonly logprobs?: readonly (TokenLogprobs | undefined)[];
7
+ readonly output: Pick<GenerationOutput, "commit">;
8
+ readonly cancellation?: Cancellation;
9
+ }
10
+
11
+ /** Counts apply to this round. Accepted counts describe processed proposed
12
+ * inputs, excluding an unprocessed correction/bonus and any unpublished EOS.
13
+ * Failed/cancelled requests must be discarded, never checkpointed. */
14
+ export type DraftOutputResult = { readonly generated: number; readonly accepted: number } & (
15
+ | { readonly kind: "continue"; readonly pending: number }
16
+ | { readonly kind: "stop" | "length" }
17
+ | { readonly kind: "cancelled"; readonly reason: CancelReason }
18
+ | { readonly kind: "failed"; readonly error: unknown }
19
+ );
20
+
21
+ /** Deliver verified content through the output interface before resolving
22
+ * retained state. A stopped or failed consumer affects only its own request.
23
+ * Token-sized publications preserve callbacks that stop inside a draft burst. */
24
+ export async function deliverDraftOutputs(rows: readonly DraftOutputRow[]): Promise<DraftOutputResult[]> {
25
+ const results: DraftOutputResult[] = [];
26
+ for (const row of rows) results.push(await deliver(row));
27
+ return results;
28
+ }
29
+
30
+ async function deliver(row: DraftOutputRow): Promise<DraftOutputResult> {
31
+ const { acceptance } = row;
32
+ let generated = 0;
33
+ const retained = () => ({ generated, accepted: Math.min(generated, acceptance.accepted) });
34
+ try {
35
+ for (const token of acceptance.emitted) {
36
+ const reason = row.cancellation?.reason;
37
+ if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
38
+ const metadata = row.logprobs?.[generated];
39
+ const more = await row.output.commit([token], metadata ? [metadata] : undefined);
40
+ generated++;
41
+ if (more === false) return { kind: "stop", ...retained() };
42
+ }
43
+ const reason = row.cancellation?.reason;
44
+ if (reason) return { kind: "cancelled", reason, generated, accepted: 0 };
45
+ if (acceptance.sawEos || acceptance.grammarDone) return { kind: "stop", ...retained() };
46
+ if (generated >= acceptance.remaining) return { kind: "length", ...retained() };
47
+ return { kind: "continue", pending: acceptance.correction!, ...retained() };
48
+ } catch (error) { return { kind: "failed", error, generated, accepted: 0 }; }
49
+ }