mlx-bun 0.0.10 → 0.0.12

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 (216) hide show
  1. package/README.md +107 -32
  2. package/THIRD_PARTY_LICENSES.md +10 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +58 -35
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/colibri-glm52-port.md +1479 -0
  10. package/docs/design/compat-cli-surface-design.md +4 -3
  11. package/docs/design/decode-speed-program.md +172 -0
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +371 -0
  14. package/docs/design/dspark-speculative-decoding.md +167 -5
  15. package/docs/design/faithful-l1-consolidation.md +310 -0
  16. package/docs/design/generic-model-support.md +8 -5
  17. package/docs/design/grammar-spec-batching-integration.md +301 -0
  18. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  19. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  20. package/docs/design/omlx-adoption-map.md +14 -6
  21. package/docs/design/orpo-training.md +34 -30
  22. package/docs/design/paged-kv-cache.md +214 -0
  23. package/docs/design/parallel-slots.md +82 -49
  24. package/docs/design/parity-tier-dag.md +43 -33
  25. package/docs/design/pre-colibri-stabilization.md +329 -0
  26. package/docs/design/repo-cleanup-plan.md +189 -0
  27. package/docs/design/runtime-isolation.md +19 -3
  28. package/docs/design/segmented-backward-training.md +3 -2
  29. package/docs/design/ssd-kv-cold-tier.md +130 -0
  30. package/docs/design/structured-output.md +484 -0
  31. package/docs/design/the-dreaming-master-plan.md +2 -3
  32. package/docs/design/turboquant-kv.md +235 -0
  33. package/docs/design/unified-engine-frontier-plan.md +774 -0
  34. package/docs/design/web-chat-beat-matrix.md +315 -0
  35. package/docs/design/web-chat-redesign.md +1112 -0
  36. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  37. package/docs/investigations/dspark-handoff.md +182 -16
  38. package/docs/investigations/indexshare-performance-spike.md +357 -0
  39. package/docs/investigations/lab-build-journal.md +2 -2
  40. package/docs/investigations/openwiki-evaluation.md +249 -0
  41. package/docs/planning/IDEAS.md +14 -0
  42. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  43. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  44. package/docs/planning/chat-ui-vision.md +50 -0
  45. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  46. package/docs/planning/release-notes-v0.0.11.md +239 -0
  47. package/docs/planning/release-notes-v0.0.12.md +80 -0
  48. package/docs/reference/cli.md +63 -18
  49. package/docs/reference/distribution.md +37 -6
  50. package/docs/reference/embedding.md +40 -16
  51. package/docs/reference/features-matrix.md +99 -0
  52. package/docs/reference/library-api.md +69 -20
  53. package/docs/reference/memory.md +26 -6
  54. package/docs/reference/models.md +60 -3
  55. package/docs/reference/orpo-quickstart.md +3 -4
  56. package/docs/reference/server-api.md +717 -24
  57. package/docs/reference/server-config.md +265 -129
  58. package/docs/reference/training.md +24 -16
  59. package/package.json +5 -3
  60. package/src/anthropic.ts +67 -44
  61. package/src/audio/conformer.ts +647 -0
  62. package/src/audio/decode.ts +133 -0
  63. package/src/audio/features.ts +202 -0
  64. package/src/audio/transcode.ts +67 -0
  65. package/src/chat-template.ts +155 -10
  66. package/src/cli.ts +483 -113
  67. package/src/config.ts +116 -24
  68. package/src/diffusion/diffusion-generate.ts +14 -8
  69. package/src/download.ts +143 -12
  70. package/src/eval/README.md +7 -6
  71. package/src/eval/ifeval.ts +63 -120
  72. package/src/eval/kl.ts +59 -3
  73. package/src/eval/quality-db.ts +2 -2
  74. package/src/eval/runner.ts +17 -8
  75. package/src/eval/tasks/ifeval.ts +167 -31
  76. package/src/expert-io.ts +391 -0
  77. package/src/expert-offload.ts +15 -2
  78. package/src/expert-residency.ts +1134 -0
  79. package/src/expert-usage.ts +415 -0
  80. package/src/flags.ts +13 -0
  81. package/src/generate.ts +438 -35
  82. package/src/grammar.ts +585 -0
  83. package/src/hf-push.ts +1 -1
  84. package/src/hub-rest.ts +242 -0
  85. package/src/index.ts +14 -2
  86. package/src/jobs/runner.ts +37 -18
  87. package/src/kv-store.ts +586 -93
  88. package/src/lora.ts +85 -14
  89. package/src/md-modules.d.ts +42 -0
  90. package/src/media-fetch.ts +426 -0
  91. package/src/memory/rest.ts +245 -0
  92. package/src/memory/vault.ts +74 -0
  93. package/src/mlx/array.ts +149 -19
  94. package/src/mlx/autograd.ts +53 -16
  95. package/src/mlx/checkpoint.ts +46 -13
  96. package/src/mlx/custom-vjp.ts +83 -35
  97. package/src/mlx/ffi.ts +27 -3
  98. package/src/mlx/metal-kernel.ts +7 -4
  99. package/src/mlx/ops.ts +153 -23
  100. package/src/mlx/turboquant-ops.ts +831 -0
  101. package/src/mlx/turboquant-tables.ts +237 -0
  102. package/src/model/batched-mask.ts +62 -1
  103. package/src/model/batched-quant.ts +206 -0
  104. package/src/model/batched-rotating-quant.ts +346 -0
  105. package/src/model/batched-rotating.ts +46 -1
  106. package/src/model/compiled-decode.ts +4 -15
  107. package/src/model/factory.ts +103 -6
  108. package/src/model/gemma4-base.ts +395 -39
  109. package/src/model/gemma4.ts +122 -33
  110. package/src/model/generated/gemma4-12b.ts +9 -13
  111. package/src/model/generated/gemma4-26b.ts +10 -5
  112. package/src/model/generated/gemma4-e4b.ts +14 -9
  113. package/src/model/glm52-atlas.ts +511 -0
  114. package/src/model/glm52-cache.ts +706 -0
  115. package/src/model/glm52-config.ts +262 -0
  116. package/src/model/glm52-container.ts +305 -0
  117. package/src/model/glm52-coupling.ts +388 -0
  118. package/src/model/glm52-dsa.ts +602 -0
  119. package/src/model/glm52-expert-layout.ts +253 -0
  120. package/src/model/glm52-layout.ts +152 -0
  121. package/src/model/glm52-memory.ts +473 -0
  122. package/src/model/glm52-mla.ts +786 -0
  123. package/src/model/glm52-moe.ts +223 -0
  124. package/src/model/glm52-pilot.ts +537 -0
  125. package/src/model/glm52-reference.ts +445 -0
  126. package/src/model/glm52-residency.ts +557 -0
  127. package/src/model/glm52-resident-weights.ts +135 -0
  128. package/src/model/glm52-streamed-experts.ts +453 -0
  129. package/src/model/glm52-streamed-metal.ts +610 -0
  130. package/src/model/glm52-weights.ts +503 -0
  131. package/src/model/glm52.ts +1153 -0
  132. package/src/model/minicpm5.ts +58 -102
  133. package/src/model/paged-kv.ts +253 -0
  134. package/src/model/qwen3-delta.ts +76 -15
  135. package/src/model/qwen3-moe.ts +404 -0
  136. package/src/model/qwen3.ts +9 -8
  137. package/src/model/qwen3_5.ts +88 -23
  138. package/src/model/support.ts +19 -1
  139. package/src/model/universal/dense.ts +40 -5
  140. package/src/model/universal/rope.ts +36 -21
  141. package/src/native/expert_io.c +523 -0
  142. package/src/native-pack.ts +6 -4
  143. package/src/pi-web.ts +1299 -82
  144. package/src/preflight.ts +27 -8
  145. package/src/prompt-cache.ts +293 -33
  146. package/src/quantize/quantizer.ts +16 -1
  147. package/src/registry.ts +57 -2
  148. package/src/responses.ts +62 -49
  149. package/src/serve/batch-scheduler.ts +1050 -78
  150. package/src/serve/generation-gateway.ts +252 -24
  151. package/src/serve/isolate.ts +417 -0
  152. package/src/serve/lane-registry.ts +44 -0
  153. package/src/serve/session-search.ts +326 -0
  154. package/src/server.ts +1701 -175
  155. package/src/spec/assistant-source.ts +150 -0
  156. package/src/spec/deepspec-source.ts +143 -0
  157. package/src/spec/dflash-source.ts +124 -0
  158. package/src/spec/dspark/ab-stats.ts +136 -0
  159. package/src/spec/dspark/calibration.ts +112 -0
  160. package/src/spec/dspark/deepspec-module.ts +801 -0
  161. package/src/spec/dspark/generate-dflash.ts +72 -20
  162. package/src/spec/dspark/loader.ts +23 -0
  163. package/src/spec/dspark/module-dflash.ts +278 -36
  164. package/src/spec/dspark/quantize-drafter.ts +91 -0
  165. package/src/spec/generate.ts +59 -25
  166. package/src/spec/glm52-mtp-source.ts +392 -0
  167. package/src/spec/ngram-source.ts +132 -0
  168. package/src/spec/serve-loop.ts +617 -0
  169. package/src/spec/source.ts +118 -0
  170. package/src/spec/two-model.ts +171 -0
  171. package/src/ssd-cache.ts +111 -35
  172. package/src/tokenizer.ts +288 -2
  173. package/src/tool-approvals.ts +131 -0
  174. package/src/tool-call.ts +438 -22
  175. package/src/train/flash-cce.ts +2 -2
  176. package/src/train/job.ts +0 -12
  177. package/src/train/lora-params.ts +17 -6
  178. package/src/train/trainer.ts +60 -30
  179. package/src/vision/prompt.ts +217 -48
  180. package/src/web/app.html +1197 -1791
  181. package/src/web/app.js +5694 -0
  182. package/src/web/icon.svg +13 -0
  183. package/src/web/manifest.webmanifest +24 -0
  184. package/src/web/src/adapters-panel.ts +288 -0
  185. package/src/web/src/api.ts +57 -0
  186. package/src/web/src/assistant.ts +485 -0
  187. package/src/web/src/chat.ts +1050 -0
  188. package/src/web/src/composer.ts +959 -0
  189. package/src/web/src/dataset.ts +132 -0
  190. package/src/web/src/finetune.ts +267 -0
  191. package/src/web/src/hub.ts +428 -0
  192. package/src/web/src/main.ts +98 -0
  193. package/src/web/src/markdown.ts +0 -0
  194. package/src/web/src/memory-panel.ts +572 -0
  195. package/src/web/src/model-picker.ts +159 -0
  196. package/src/web/src/palette.ts +346 -0
  197. package/src/web/src/protocol.ts +62 -0
  198. package/src/web/src/quantize.ts +167 -0
  199. package/src/web/src/rag.ts +274 -0
  200. package/src/web/src/sessions.ts +469 -0
  201. package/src/web/src/shell.ts +804 -0
  202. package/src/web/src/status.ts +300 -0
  203. package/src/web/src/ui-catalog.ts +92 -0
  204. package/src/web/sw.js +71 -0
  205. package/src/web/vendor/README.md +61 -0
  206. package/src/web/vendor/hljs-LICENSE +29 -0
  207. package/src/web/vendor/hljs-theme.css +59 -0
  208. package/src/web/vendor/hljs.js +26 -0
  209. package/src/web-tools.ts +54 -2
  210. package/src/weights.ts +82 -19
  211. package/src/model/fused-decode-kernel.ts +0 -312
  212. package/src/model/fused-geglu-kernel.ts +0 -198
  213. package/src/model/fused-mlp-kernel.ts +0 -169
  214. package/src/model/fused-swiglu-kernel.ts +0 -240
  215. package/src/model/steel-linear-kernel.ts +0 -96
  216. package/src/status-page.html +0 -497
package/src/grammar.ts ADDED
@@ -0,0 +1,585 @@
1
+ // Grammar-constrained decoding via @mlc-ai/web-xgrammar (WASM).
2
+ //
3
+ // Serves the structured-output API surface: response_format (json_object /
4
+ // json_schema), guided_grammar (EBNF/LARK), guided_regex, guided_choice,
5
+ // and the structured_outputs alias — full parity with oMLX/vLLM. From our
6
+ // side it's all one shape: compile something → GrammarMatcher → per-step
7
+ // bitmask → mask invalid logits to -inf before the sampler. The only
8
+ // difference between the modes is which xgrammar compile method we call.
9
+ //
10
+ // Fidelity tier: L2-class (oMLX is the oracle — same model+prompt+schema+seed
11
+ // must produce the same token stream). mlx-lm has no grammar support, so this
12
+ // is purely an oMLX-parity feature, not an L1 contract. The bitmask does not
13
+ // change the numerics of VALID tokens (it only sets invalid ones to -inf), so
14
+ // it touches neither L1 nor L2 decode numerics.
15
+ //
16
+ // Integration: a GrammarController is a stateful logits masker. Because
17
+ // xgrammar's getNextTokenBitmask() is async (WASM) and acceptToken() needs
18
+ // the token id as a JS number, the controller does NOT ride the sync
19
+ // LogitsProcessor array (which operates on device-side token arrays with no
20
+ // readback). Instead generate() drives it explicitly: after each token is
21
+ // sampled, call accept(tokenId) (sync matcher advance + fires async mask
22
+ // precompute that overlaps the next GPU forward), then await ready() before
23
+ // the next sampleStep; applyMask() applies the ready bitmask synchronously.
24
+ // See the grammar branch in generate()'s decode loop.
25
+
26
+ import xgrammar, {
27
+ type TokenizerInfo as XTokenizerInfo,
28
+ type GrammarCompiler as XGrammarCompiler,
29
+ type CompiledGrammar as XCompiledGrammar,
30
+ type GrammarMatcher as XGrammarMatcher,
31
+ type Grammar as XGrammar,
32
+ } from "@mlc-ai/web-xgrammar";
33
+ import { readFileSync } from "node:fs";
34
+ import { MlxArray } from "./mlx/array";
35
+ import * as ops from "./mlx/ops";
36
+ import type { LoadedTokenizer } from "./tokenizer";
37
+
38
+ /** The request shape — the union of every structured-output field name across
39
+ * the OpenAI/oMLX/vLLM API surfaces. BOTH spellings are accepted: the wire
40
+ * protocol is snake_case (the server passes the raw request body — this was
41
+ * a live bug until 2026-07-03: only camelCase was read, so response_format
42
+ * over HTTP silently no-op'd; caught by the feature-matrix benchmark's
43
+ * conformance gate); library callers use camelCase. */
44
+ export interface GrammarRequest {
45
+ /** OpenAI response_format. {type:"json_object"} | {type:"json_schema",
46
+ * json_schema:{name,schema,strict?}} | {type:"text"} (text = no-op). */
47
+ responseFormat?: unknown;
48
+ response_format?: unknown;
49
+ /** vLLM/oMLX: raw EBNF/LARK grammar string. */
50
+ guidedGrammar?: string;
51
+ guided_grammar?: string;
52
+ /** vLLM/oMLX: regex. Compiled via `root ::= <regex>` EBNF embedding, so
53
+ * only the regex∩EBNF subset works (the F5 gap — real regex→EBNF or
54
+ * structural-tag RegexFormat is the tracked fix, structured-output.md). */
55
+ guidedRegex?: string;
56
+ guided_regex?: string;
57
+ /** vLLM/oMLX: restrict output to one of these strings (enum). */
58
+ guidedChoice?: string[];
59
+ guided_choice?: string[];
60
+ /** vLLM/oMLX: a JSON schema object directly (alias for response_format's
61
+ * json_schema.schema). */
62
+ structuredOutputs?: unknown;
63
+ structured_outputs?: unknown;
64
+ }
65
+
66
+ /** A compiled grammar ready to mask logits. Null when the request asks for no
67
+ * constraint (text, or none of the fields set). `warning` is set when the
68
+ * requested grammar could not be compiled and the caller should degrade to
69
+ * prompt injection (mirrors oMLX's _compile_grammar_for_request returning None
70
+ * + a Warning header). */
71
+ export interface CompiledGrammarResult {
72
+ controller: GrammarController;
73
+ /** Schema/grammar for the degrade-path system-prompt injection (oMLX parity). */
74
+ degradeHint: string | null;
75
+ }
76
+
77
+ /** xgrammar TokenizerInfo is expensive to build (it decodes every vocab id).
78
+ * Cache one per tokenizer.json path — it's vocab-structural, not per-request. */
79
+ /** Module-level serializer for ALL xgrammar WASM calls. The WASM instance is
80
+ * single-threaded; CONCURRENT async calls corrupt emscripten's binding
81
+ * layer (BindingError: Expected null or instance of VectorInt, got an
82
+ * instance of VectorInt — reproduced in tests/grammar.test.ts B1 test, which
83
+ * fires 4 overlapping compiles + fills). Under `--batch N` the scheduler
84
+ * fires N accept()s (each kicks an async fill) then awaits ready() on all;
85
+ * and the server compiles grammar for concurrent requests. Without
86
+ * serialization the overlapping WASM calls crash. This queue keeps the
87
+ * async-overlap API (fills still overlap GPU compute — the queue only
88
+ * serializes the CPU-side WASM calls, each 0.004–0.19 ms) while ensuring
89
+ * only ONE xgrammar call is in flight at a time. Both the compile path
90
+ * (compileGrammarRequest) and the fill path (prime/accept) ride it. */
91
+ let wasmChain: Promise<unknown> = Promise.resolve();
92
+ function wasmQueue<T>(fn: () => Promise<T>): Promise<T> {
93
+ const run = wasmChain.then(fn, fn); // run regardless of prior success/failure
94
+ wasmChain = run.then(
95
+ () => undefined,
96
+ () => undefined,
97
+ );
98
+ return run;
99
+ }
100
+
101
+ /** F4: ONE TokenizerInfo + ONE compiler per tokenizer.json path, cached for
102
+ * the process lifetime. xgrammar's GrammarCompiler carries an internal
103
+ * compilation cache (cacheEnabled defaults true), so agentic clients
104
+ * replaying the same JSON schema hit a cached grammar instead of paying a
105
+ * full schema→grammar compile per request. Both are vocab-structural.
106
+ * Controllers get ownsCompiler=false and never dispose it.
107
+ * The entry holds a PROMISE, installed synchronously before any await —
108
+ * concurrent compiles after a path change share ONE build (single-flight)
109
+ * instead of racing to rebuild and double-dispose the previous pair. */
110
+ let cachedTokenizerInfo: {
111
+ path: string;
112
+ pair: Promise<{ info: XTokenizerInfo; compiler: XGrammarCompiler }>;
113
+ } | null = null;
114
+
115
+ /** Extract the ordered vocab array + detect xgrammar vocab type from
116
+ * tokenizer.json (the same data src/tokenizer.ts already loads).
117
+ * - decoder.type == "ByteLevel" → byte_level (Llama3, Qwen)
118
+ * - decoder is Sequence w/ ByteFallback → byte_fallback (Gemma/SP)
119
+ * - otherwise → raw
120
+ * prependSpace is true for byte_level (ByteLevel pretokenizers add a leading
121
+ * space). Validated in scripts/experiments/xgrammar-spike.ts across Gemma
122
+ * (byte_fallback, 256k), Llama-3 / Qwen2.5 (byte_level, 128k/151k). */
123
+ function loadVocab(tokenizerJsonPath: string): {
124
+ vocab: string[];
125
+ vocabType: string;
126
+ prependSpace: boolean;
127
+ } {
128
+ const t = JSON.parse(readFileSync(tokenizerJsonPath, "utf8"));
129
+ const v = t.model?.vocab;
130
+ if (!v || typeof v !== "object")
131
+ throw new Error("tokenizer.json has no model.vocab");
132
+ const entries = Object.entries(v as Record<string, number>);
133
+ const size = entries.length;
134
+ const vocab = new Array<string>(size);
135
+ for (const [tok, id] of entries) vocab[id] = tok;
136
+
137
+ let vocabType = "raw";
138
+ const dec = t.decoder;
139
+ if (dec?.type === "ByteLevel") vocabType = "byte_level";
140
+ else if (dec?.type === "Sequence" && Array.isArray(dec.decoders)) {
141
+ if (dec.decoders.some((d: { type?: string }) => d?.type === "ByteFallback"))
142
+ vocabType = "byte_fallback";
143
+ } else if (dec?.type === "ByteFallback") {
144
+ vocabType = "byte_fallback";
145
+ }
146
+ return { vocab, vocabType, prependSpace: vocabType === "byte_level" };
147
+ }
148
+
149
+ /** Get (cached) xgrammar TokenizerInfo + its process-lifetime compiler for a
150
+ * tokenizer.json path. On a path change (model switch) the previous pair is
151
+ * disposed exactly once, by the single flight that replaced it. */
152
+ function getTokenizerInfo(
153
+ tokenizerJsonPath: string,
154
+ ): Promise<{ info: XTokenizerInfo; compiler: XGrammarCompiler }> {
155
+ if (cachedTokenizerInfo?.path === tokenizerJsonPath) return cachedTokenizerInfo.pair;
156
+ const prev = cachedTokenizerInfo;
157
+ const pair = (async () => {
158
+ const { vocab, vocabType, prependSpace } = loadVocab(tokenizerJsonPath);
159
+ const info = await wasmQueue(() =>
160
+ xgrammar.TokenizerInfo.createTokenizerInfo(vocab, vocabType, prependSpace, vocab.length),
161
+ );
162
+ const compiler = await wasmQueue(() => xgrammar.GrammarCompiler.createGrammarCompiler(info));
163
+ if (prev)
164
+ prev.pair.then(
165
+ (p) => {
166
+ p.compiler.dispose();
167
+ p.info.dispose();
168
+ },
169
+ () => undefined,
170
+ );
171
+ return { info, compiler };
172
+ })();
173
+ // Install synchronously — concurrent callers join this flight. A failed
174
+ // flight uncaches itself (callers still see the rejection) so a transient
175
+ // error doesn't poison the path forever.
176
+ cachedTokenizerInfo = { path: tokenizerJsonPath, pair };
177
+ pair.catch(() => {
178
+ if (cachedTokenizerInfo?.pair === pair) cachedTokenizerInfo = null;
179
+ });
180
+ return pair;
181
+ }
182
+
183
+ /** Vocab size the matcher masks against (config.vocab_size, may exceed the
184
+ * tokenizer vocab due to padding). Falls back to the tokenizer length. */
185
+ function effectiveVocabSize(tokenizer: LoadedTokenizer, configVocabSize?: number): number {
186
+ // The matcher is built against TokenizerInfo's vocab; mask logits at the
187
+ // model's logit width (config vocab_size, >= tokenizer vocab when padded).
188
+ return configVocabSize ?? tokenizer.vocabSize ?? 0;
189
+ }
190
+
191
+ /** Stateful grammar masker. Driven explicitly by generate()'s decode loop
192
+ * (see module doc): accept(tokenId) advances the matcher + fires async mask
193
+ * precompute; ready() awaits it; applyMask(logits) sets invalid ids to -inf. */
194
+ export class GrammarController {
195
+ private readonly matcher: XGrammarMatcher;
196
+ private readonly compiled: XCompiledGrammar;
197
+ private readonly compiler: XGrammarCompiler;
198
+ private readonly ownsCompiler: boolean;
199
+ private readonly vocabSize: number;
200
+ /** Raw-encode hook for jump-forward (encode WITHOUT special tokens); null
201
+ * disables jumpForward(). */
202
+ private readonly encodeRaw: ((text: string) => number[]) | null;
203
+ /** Bitmask width in int32s = ceil(V / 32). */
204
+ private readonly maskWidth: number;
205
+ private readyMask: Int32Array;
206
+ private pending: Promise<void> | null;
207
+ private terminated = false;
208
+ private disposed = false;
209
+ /** Tokens emitted via jumpForward() this generation — telemetry/test hook. */
210
+ jumpedTokens = 0;
211
+
212
+ constructor(
213
+ matcher: XGrammarMatcher,
214
+ compiled: XCompiledGrammar,
215
+ compiler: XGrammarCompiler,
216
+ ownsCompiler: boolean,
217
+ vocabSize: number,
218
+ encodeRaw?: (text: string) => number[],
219
+ ) {
220
+ this.matcher = matcher;
221
+ this.compiled = compiled;
222
+ this.compiler = compiler;
223
+ this.ownsCompiler = ownsCompiler;
224
+ this.vocabSize = vocabSize;
225
+ this.encodeRaw = encodeRaw ?? null;
226
+ this.maskWidth = Math.ceil(vocabSize / 32);
227
+ this.readyMask = new Int32Array(this.maskWidth).fill(-1);
228
+ this.pending = null;
229
+ }
230
+
231
+ /** Precompute the step-0 mask. Must be awaited before the first applyMask(). */
232
+ async prime(): Promise<void> {
233
+ this.readyMask = await wasmQueue(() => this.matcher.getNextTokenBitmask());
234
+ }
235
+
236
+ /** Apply the ready bitmask to logits [1, V]. Invalid token ids → -inf.
237
+ * Sync — the mask is already materialized. Returns a NEW array (caller
238
+ * disposes the input). Mirrors oMLX's apply_token_bitmask_mlx. */
239
+ applyMask(logits: MlxArray): MlxArray {
240
+ // Build a -inf/0 additive float mask on device from the int32 bitmask.
241
+ // Each int32 bit b (little-endian within the word) gates token
242
+ // word*32 + b. Set = valid (xgrammar convention). Adding -inf → -inf,
243
+ // adding 0 → noop.
244
+ const V = logits.shape[logits.shape.length - 1]!;
245
+ const maskArr = new Float32Array(V);
246
+ for (let id = 0; id < V; id++) {
247
+ const word = id >>> 5;
248
+ const bit = id & 31;
249
+ // F3: V is the model's logit width (config.vocab_size, may exceed the
250
+ // tokenizer vocab via padding); the matcher's mask has the TOKENIZER's
251
+ // width. Padded ids beyond the mask are never valid — mask them
252
+ // explicitly rather than relying on undefined>>>bit coercing to 0.
253
+ const w = word < this.readyMask.length ? this.readyMask[word]! : 0;
254
+ const valid = (w >>> bit) & 1;
255
+ maskArr[id] = valid ? 0 : -Infinity;
256
+ }
257
+ const mask = MlxArray.fromFloat32(maskArr, [1, V]).astype(logits.dtype);
258
+ const out = ops.add(logits, mask);
259
+ mask.dispose();
260
+ return out;
261
+ }
262
+
263
+ /** Advance the matcher by the just-sampled token and fire async mask
264
+ * precompute for the NEXT step (overlaps the GPU forward). Call AFTER the
265
+ * token is read back to JS. No-op once terminated. */
266
+ accept(tokenId: number): void {
267
+ if (this.terminated) return;
268
+ const ok = this.matcher.acceptToken(tokenId);
269
+ if (!ok) {
270
+ // The sampler only ever picks valid tokens (we masked the rest), so an
271
+ // accept failure means the vocab/mask drifted from the model's actual
272
+ // tokenization — not a logic bug in the walk. Log + keep going; the
273
+ // output may be malformed but generation continues rather than dying.
274
+ console.warn(`grammar: matcher rejected token ${tokenId}`);
275
+ }
276
+ if (this.matcher.isTerminated()) {
277
+ this.terminated = true;
278
+ return;
279
+ }
280
+ this.pending = this.fireFill();
281
+ }
282
+
283
+ /** Queue the async bitmask fill. The closure re-checks `disposed` at RUN
284
+ * time: a controller disposed while its fill is still queued (an exception
285
+ * between accept/jumpForward and ready()) must not call into the deleted
286
+ * WASM matcher — that BindingError poisons the module-wide wasmChain for
287
+ * every later grammar request (found by tests/grammar-jump.test.ts). */
288
+ private fireFill(): Promise<void> {
289
+ return wasmQueue(() =>
290
+ this.disposed
291
+ ? Promise.resolve(this.readyMask)
292
+ : this.matcher.getNextTokenBitmask(),
293
+ ).then((m) => {
294
+ this.readyMask = m;
295
+ });
296
+ }
297
+
298
+ /** Await the mask precompute fired by accept(). Should be called before the
299
+ * next applyMask(); usually resolves immediately (overlapped the forward). */
300
+ async ready(): Promise<void> {
301
+ if (this.pending) {
302
+ await this.pending;
303
+ this.pending = null;
304
+ }
305
+ }
306
+
307
+ get isTerminated(): boolean {
308
+ return this.terminated;
309
+ }
310
+
311
+ /** Jump-forward decoding (SGLang's technique via xgrammar's
312
+ * findJumpForwardString; opt-in — MLX_BUN_GRAMMAR_JUMP=1): when the grammar
313
+ * admits exactly one continuation string (JSON keys/punctuation, enum
314
+ * bodies — longest with `any_whitespace:false` compact schemas), return its
315
+ * token ids so the decode loop can carry them into the KV with ONE
316
+ * multi-token forward instead of one masked forward per token.
317
+ *
318
+ * Contract: MUTATES the matcher for every id it returns (and only those) —
319
+ * the caller MUST emit exactly the returned ids, then await ready() before
320
+ * the next applyMask. Call only after ready() has resolved (no WASM fill in
321
+ * flight — the matcher calls here are sync). Returns null (matcher
322
+ * untouched) when: no hook, terminated, the jump retokenizes to <2 ids
323
+ * (a 1-token jump saves no forward), maxIds < 2, or the first id is
324
+ * rejected. If a LATER id is rejected the accepted prefix is returned —
325
+ * those ids already advanced the matcher, so they must be emitted; the
326
+ * loop resumes masked decoding after them (no rollback needed by
327
+ * construction).
328
+ *
329
+ * Fidelity note (why opt-in, no oracle): encode(jumpStr) standalone can
330
+ * tokenize the forced span differently than the model would have sampled
331
+ * it. The emitted STRING is identical (token strings concatenate to the
332
+ * jump string) and grammar validity is preserved (every id is
333
+ * matcher-accepted), but the token STREAM — and, conditioning on it,
334
+ * content generated AFTER the forced span — may differ from an unjumped
335
+ * run. SentencePiece-family tokenizers whose raw encode prepends a space
336
+ * simply never jump (the first id gets rejected) — the guard degrades to
337
+ * normal decoding, never to invalid output. */
338
+ jumpForward(maxIds: number): number[] | null {
339
+ if (this.terminated || !this.encodeRaw || maxIds < 2) return null;
340
+ const str = this.matcher.findJumpForwardString();
341
+ if (!str || str.length < 2) return null;
342
+ const ids = this.encodeRaw(str);
343
+ if (ids.length < 2) return null;
344
+ const take = Math.min(ids.length, maxIds);
345
+ if (take < 2) return null;
346
+ const accepted: number[] = [];
347
+ for (let i = 0; i < take; i++) {
348
+ const id = ids[i]!;
349
+ if (!this.matcher.acceptToken(id)) break; // keep the accepted prefix
350
+ accepted.push(id);
351
+ if (this.matcher.isTerminated()) {
352
+ this.terminated = true;
353
+ break;
354
+ }
355
+ }
356
+ if (accepted.length === 0) return null; // matcher untouched
357
+ this.jumpedTokens += accepted.length;
358
+ if (!this.terminated) this.pending = this.fireFill();
359
+ return accepted;
360
+ }
361
+
362
+ dispose(): void {
363
+ // Idempotent: generate()/the gateway dispose in finally, and the server's
364
+ // early-reject paths dispose pre-run — a throw inside run can reach both.
365
+ if (this.disposed) return;
366
+ this.disposed = true; // queued fills become no-ops (see fireFill)
367
+ this.matcher.dispose();
368
+ this.compiled.dispose();
369
+ if (this.ownsCompiler) this.compiler.dispose();
370
+ }
371
+ }
372
+
373
+ /** Normalize the request fields + compile. Returns null when no constraint is
374
+ * requested (text / none set). On compile failure returns
375
+ * `{ controller: null, degradeHint }` — callers inject a "respond in valid
376
+ * JSON" system prompt + emit a Warning header (oMLX parity, never 500). */
377
+ export async function compileGrammarRequest(
378
+ req: GrammarRequest,
379
+ tokenizer: LoadedTokenizer,
380
+ configVocabSize?: number,
381
+ ): Promise<{ controller: GrammarController | null; degradeHint: string | null } | null> {
382
+ const tokenizerJsonPath = tokenizer.tokenizerJsonPath;
383
+ if (!tokenizerJsonPath)
384
+ throw new Error("grammar: tokenizer.tokenizerJsonPath is required (set by loadTokenizer)");
385
+
386
+ const resolved = resolveGrammarRequest(req);
387
+ if (!resolved) return null;
388
+
389
+ // Each xgrammar WASM call rides wasmQueue individually (NOT a body-level
390
+ // wrap — prime() calls wasmQueue internally, so a body-level wrap would
391
+ // deadlock reentrantly). Individual calls serialize without nesting.
392
+ const { info, compiler } = await getTokenizerInfo(tokenizerJsonPath);
393
+ let compiled: XCompiledGrammar | null = null;
394
+ let degradeHint: string | null = null;
395
+
396
+ try {
397
+ switch (resolved.kind) {
398
+ case "json_object":
399
+ compiled = await wasmQueue(() => compiler.compileBuiltinJSONGrammar());
400
+ break;
401
+ case "json_schema": {
402
+ const s = resolved.schema;
403
+ compiled = await wasmQueue(() =>
404
+ compiler.compileJSONSchema(
405
+ typeof s === "string" ? s : JSON.stringify(s),
406
+ resolved.anyWhitespace ?? true,
407
+ resolved.indent,
408
+ resolved.separators,
409
+ resolved.strict ?? true,
410
+ ),
411
+ );
412
+ break;
413
+ }
414
+ case "grammar": {
415
+ const g = await wasmQueue(() => xgrammar.Grammar.fromEBNF(resolved.ebnf, resolved.rootRule));
416
+ compiled = await wasmQueue(() => compiler.compileGrammar(g));
417
+ g.dispose();
418
+ break;
419
+ }
420
+ case "regex": {
421
+ const ebnf = `root ::= ${resolved.regex}`;
422
+ const g = await wasmQueue(() => xgrammar.Grammar.fromEBNF(ebnf));
423
+ compiled = await wasmQueue(() => compiler.compileGrammar(g));
424
+ g.dispose();
425
+ break;
426
+ }
427
+ case "choice": {
428
+ // F6: escape everything an EBNF string literal can't hold bare.
429
+ // \n\r\t have named escapes; any other control char has no EBNF
430
+ // spelling — throw into the degrade path (prompt injection +
431
+ // Warning header), never a 500.
432
+ const esc = (c: string) => {
433
+ const body = c
434
+ .replace(/\\/g, "\\\\")
435
+ .replace(/"/g, '\\"')
436
+ .replace(/\n/g, "\\n")
437
+ .replace(/\r/g, "\\r")
438
+ .replace(/\t/g, "\\t");
439
+ // eslint-disable-next-line no-control-regex
440
+ if (/[\x00-\x1f]/.test(body))
441
+ throw new Error("guided_choice: unsupported control character in choice");
442
+ return `"${body}"`;
443
+ };
444
+ const opts = resolved.choices.map(esc).join(" | ");
445
+ const g = await wasmQueue(() => xgrammar.Grammar.fromEBNF(`root ::= ${opts}`));
446
+ compiled = await wasmQueue(() => compiler.compileGrammar(g));
447
+ g.dispose();
448
+ break;
449
+ }
450
+ }
451
+ } catch (e) {
452
+ degradeHint = resolved.degradeDescription ?? String(e);
453
+ compiled = null;
454
+ }
455
+
456
+ if (!compiled) {
457
+ if (!degradeHint) degradeHint = resolved.degradeDescription ?? "grammar compile failed";
458
+ // The compiler is the cached per-TokenizerInfo instance (F4) — a failed
459
+ // compile must NOT dispose it; the WASM abort is catchable and the
460
+ // compiler state survives (verified in tests/grammar.test.ts).
461
+ // Return the hint (NOT bare null): it drives the degrade path's
462
+ // system-prompt injection + Warning header. Dropping it here served
463
+ // unconstrained output with no Warning at all (found 2026-07-07 sweep).
464
+ return { controller: null, degradeHint };
465
+ }
466
+
467
+ // terminateWithoutStopToken=true: the matcher terminates as soon as the
468
+ // grammar is SATISFIED (e.g. the closing `}` of a complete JSON), not only
469
+ // when the model emits a stop token. This is what oMLX does — generation
470
+ // halts once the structured output is complete, so the sampler never faces
471
+ // an all-rejected (all--inf) mask after the grammar closes (which would
472
+ // make greedy argmax return a garbage token id like 0 that the matcher
473
+ // then rejects, looping until max_tokens).
474
+ const matcher = await wasmQueue(() =>
475
+ xgrammar.GrammarMatcher.createGrammarMatcher(compiled!, undefined, true),
476
+ );
477
+ const vocabSize = effectiveVocabSize(tokenizer, configVocabSize) || info.getVocabSize();
478
+ const controller = new GrammarController(
479
+ matcher, compiled, compiler, false, vocabSize,
480
+ // Raw encode (no BOS/specials) for jump-forward retokenization.
481
+ (s) => tokenizer.encode(s, false),
482
+ );
483
+ await controller.prime();
484
+ return { controller, degradeHint };
485
+ }
486
+
487
+ /** Internal: normalize the union of request fields into one compile target. */
488
+ function resolveGrammarRequest(req: GrammarRequest):
489
+ | (
490
+ | { kind: "json_object"; degradeDescription: string }
491
+ | {
492
+ kind: "json_schema";
493
+ schema: unknown;
494
+ strict?: boolean;
495
+ anyWhitespace?: boolean;
496
+ indent?: number;
497
+ separators?: [string, string];
498
+ degradeDescription: string;
499
+ }
500
+ | { kind: "grammar"; ebnf: string; rootRule?: string; degradeDescription: string }
501
+ | { kind: "regex"; regex: string; degradeDescription: string }
502
+ | { kind: "choice"; choices: string[]; degradeDescription: string }
503
+ )
504
+ | null {
505
+ // Normalize the two spellings first (wire = snake_case, library = camel).
506
+ const guidedGrammar = req.guidedGrammar ?? req.guided_grammar;
507
+ const guidedRegex = req.guidedRegex ?? req.guided_regex;
508
+ const guidedChoice = req.guidedChoice ?? req.guided_choice;
509
+ const structuredOutputs = req.structuredOutputs ?? req.structured_outputs;
510
+ const responseFormat = req.responseFormat ?? req.response_format;
511
+
512
+ // Precedence mirrors oMLX's _effective_guided_grammar / _compile_grammar:
513
+ // explicit grammar > response_format json_schema > json_object > regex >
514
+ // choice > structured_outputs. text / unset → null (no constraint).
515
+ if (guidedGrammar) {
516
+ return {
517
+ kind: "grammar",
518
+ ebnf: guidedGrammar,
519
+ degradeDescription: "guided_grammar",
520
+ };
521
+ }
522
+
523
+ const rf = responseFormat;
524
+ if (rf && typeof rf === "object") {
525
+ const type = (rf as { type?: string }).type;
526
+ if (type === "json_schema") {
527
+ const js = (rf as {
528
+ json_schema?: {
529
+ schema?: unknown;
530
+ strict?: boolean;
531
+ any_whitespace?: boolean;
532
+ anyWhitespace?: boolean;
533
+ };
534
+ }).json_schema;
535
+ const schema = js?.schema ?? structuredOutputs;
536
+ if (schema) {
537
+ return {
538
+ kind: "json_schema",
539
+ schema,
540
+ strict: js?.strict,
541
+ // Whitespace-stall escape hatch (structured-output.md known gaps):
542
+ // any_whitespace=false compiles the schema with xgrammar's compact
543
+ // separators — no whitespace choice points, so a whitespace-
544
+ // degenerate base model can't greedily tab-loop to max_tokens.
545
+ // Default stays true (oMLX parity: model-chosen formatting).
546
+ anyWhitespace: js?.any_whitespace ?? js?.anyWhitespace,
547
+ degradeDescription: "response_format json_schema",
548
+ };
549
+ }
550
+ } else if (type === "json_object") {
551
+ return { kind: "json_object", degradeDescription: "response_format json_object" };
552
+ }
553
+ // type === "text" → no constraint (OpenAI: text = free-form)
554
+ }
555
+
556
+ if (structuredOutputs && typeof structuredOutputs === "object") {
557
+ // structured_outputs as a bare schema object (oMLX alias)
558
+ return {
559
+ kind: "json_schema",
560
+ schema: structuredOutputs,
561
+ degradeDescription: "structured_outputs",
562
+ };
563
+ }
564
+
565
+ if (guidedRegex) {
566
+ return { kind: "regex", regex: guidedRegex, degradeDescription: "guided_regex" };
567
+ }
568
+
569
+ if (guidedChoice && guidedChoice.length > 0) {
570
+ return {
571
+ kind: "choice",
572
+ choices: guidedChoice,
573
+ degradeDescription: "guided_choice",
574
+ };
575
+ }
576
+
577
+ return null;
578
+ }
579
+
580
+ /** Kill switch: MLX_BUN_GRAMMAR=0 disables grammar compilation entirely
581
+ * (requests degrade to prompt injection). Matches the project's envelope-gated
582
+ * kill-switch discipline (MLX_BUN_COMPILED_DECODE, MLX_BUN_GRAMMAR_BATCH, …). */
583
+ export function grammarEnabled(): boolean {
584
+ return process.env.MLX_BUN_GRAMMAR !== "0";
585
+ }
package/src/hf-push.ts CHANGED
@@ -420,7 +420,7 @@ async function uploadLfsFiles(
420
420
  const putRes = await fetch(upload.href, {
421
421
  method: "PUT",
422
422
  headers: { ...(upload.header ?? {}) },
423
- body: item.bytes,
423
+ body: new Blob([Uint8Array.from(item.bytes)]),
424
424
  });
425
425
  if (!putRes.ok) throw await httpError(`LFS upload of ${item.file.repoPath}`, putRes);
426
426
  onProgress?.(item.file.repoPath, item.file.size, item.file.size);