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
@@ -0,0 +1,118 @@
1
+ // DraftSource — the seam between the serve-time speculative verify loop
2
+ // (src/spec/serve-loop.ts) and whatever produces draft tokens. Designed in
3
+ // docs/design/mlx-lm-tool-parity-plan.md §7 so every drafter shares ONE
4
+ // verify/accept executor:
5
+ // - TwoModelSource (src/spec/two-model.ts) — mlx-lm parity (L1 oracle:
6
+ // mlx_lm.server --draft-model), a full second model. Ignores `target`.
7
+ // - AssistantSource (src/spec/assistant-source.ts) — the optiq KV-borrowing
8
+ // Gemma drafter (src/spec/drafter.ts; L2 oracle: optiq spec_generate).
9
+ // Reads the target's donor K/V + anchor hidden each step.
10
+ // - DflashSource (src/spec/dflash-source.ts) — DSpark (L3, KL/quality-gated).
11
+ // Taps the target's multi-layer hiddens (prefill + verify) into a growing
12
+ // H_ctx (docs/design/dspark-speculative-decoding.md).
13
+ // - NgramSource (src/spec/ngram-source.ts) — model-free prompt lookup.
14
+ // - Glm52NativeMtpSource (src/spec/glm52-mtp-source.ts) — the target
15
+ // artifact's native Colibri MTP row.
16
+ //
17
+ // The sources differ ONLY in what fills the draft; the serve loop, admission
18
+ // accounting, and stats never change. The KV-borrowing sources need target
19
+ // state the two-model source doesn't — carried by the `target` view at open()
20
+ // and the optional prefill/draft/commit arguments below (all no-ops for
21
+ // two-model). See [[dspark-seam-kv-borrowing]].
22
+
23
+ import type { MlxArray } from "../mlx/array";
24
+ import type { RuntimeModel } from "../model/factory";
25
+ import type { Cache } from "../model/gemma4";
26
+
27
+ /** Read-only handle to the target's live decode state, handed to every source
28
+ * at open(). Two-model ignores it; KV-borrowing sources read the target's
29
+ * model (embed / lm-head / the draft module) and its LIVE cache array (donor
30
+ * K/V views + offset — the SAME caches the serve loop drives). */
31
+ export interface TargetView {
32
+ readonly model: RuntimeModel;
33
+ readonly caches: Cache[];
34
+ }
35
+
36
+ /** A per-request draft-token producer. Created per generation (owns its own
37
+ * draft-side state), disposed by the serve loop's finally. */
38
+ export interface DraftSource {
39
+ /** Target prefill shape required by this source's oracle. Most mlx-lm
40
+ * sources leave the final prompt token pending; native Colibri MTP starts
41
+ * from a full-prompt target forward. */
42
+ readonly prefillMode?: "tail-split" | "full";
43
+ /** Request one fixed target kernel family across the speculative verify
44
+ * batch. Native GLM MTP uses the direct-Colibri SPEC_PIN contract. */
45
+ readonly pinTargetKernelFamily?: boolean;
46
+
47
+ /** Multi-layer target tap the source needs captured on the target's prefill
48
+ * AND every verify forward (DSpark's H_ctx; e4b {20,31,41,42}). When set,
49
+ * the serve loop sets model.hiddenTap around those forwards and passes the
50
+ * captured context [1,L,m*H] into prefill()/commit(). Undefined for sources
51
+ * that don't tap (two-model, assistant). */
52
+ readonly tapLayers?: number[];
53
+
54
+ /** Process the prompt (two-model: prefill the draft model's cache;
55
+ * assistant/dflash: read the target's state — mostly a no-op, but DSpark
56
+ * seeds H_ctx from `ctxML`, the tapped prefill context [1,Lp,m*H], present
57
+ * iff tapLayers is set). */
58
+ prefill(promptIds: number[], ctxML?: MlxArray): void | Promise<void>;
59
+
60
+ /** Propose 0..n tokens (RETURN LENGTH IS AUTHORITATIVE — a source may
61
+ * return fewer than n, e.g. DSpark's confidence-scheduled draft-length
62
+ * pruning; ZERO means "skip drafting this round" — DeepSpec ℓ=0 semantics,
63
+ * the serve loop degenerates to one plain target step, still tapped +
64
+ * committed for context-growing sources). The serve loop verifies over
65
+ * exactly the returned length. `feed` is the token(s) the draft has not
66
+ * yet consumed: [pending]
67
+ * after a rejection/first round, or [lastDraft, bonus] after an all-accept
68
+ * round (mlx-lm's re-feed rule, generate.py:645-648). `stepBase` is the
69
+ * emitted-token index, threaded to the sampler for per-step RNG streams.
70
+ * `anchorHidden` is the target's final hidden [1,1,H] at the pending/anchor
71
+ * position — the assistant source borrows it for its first draft step;
72
+ * two-model and dflash ignore it. */
73
+ draft(
74
+ feed: number[],
75
+ n: number,
76
+ stepBase: number,
77
+ anchorHidden?: MlxArray,
78
+ ): number[] | Promise<number[]>;
79
+
80
+ /** Verify outcome for the last round: kAccept of d accepted, where d is the
81
+ * length draft() actually RETURNED (≤ n). Two-model: trim the draft cache
82
+ * by max(d - kAccept - 1, 0) — mlx-lm's rewind rule (generate.py:589-591).
83
+ * DSpark: grow H_ctx by the accepted window from `vCtxML`, the verified
84
+ * window's tapped context [1,d+1,m*H] (present iff tapLayers is set), and
85
+ * drop the rejected tips. Native GLM MTP receives the target's verified
86
+ * hidden window [1,d+1,H] in `verifiedHidden` and the kAccept accepted token
87
+ * ids in `acceptedTokens`; it uses them to rebuild accepted MTP KV rows
88
+ * from target state rather than recursively drafted hidden state. */
89
+ commit(
90
+ d: number,
91
+ kAccept: number,
92
+ vCtxML?: MlxArray,
93
+ verifiedHidden?: MlxArray,
94
+ acceptedTokens?: readonly number[],
95
+ ): void | Promise<void>;
96
+
97
+ /** Resident per-request draft weights, for admission accounting (0 when the
98
+ * provider owns the weights). */
99
+ readonly weightsBytes: number;
100
+ dispose(): void;
101
+ }
102
+
103
+ /** Server-lifetime owner of the draft machinery (the loaded draft model);
104
+ * open() mints a per-request DraftSource. */
105
+ export interface DraftProvider {
106
+ /** Human-readable id (registry id / path tail) for logs + cache namespacing. */
107
+ readonly id: string;
108
+ readonly weightsBytes: number;
109
+ open(opts: {
110
+ /** The request's sampler over logprobs [1,V] → token array [1] (the SAME
111
+ * sampler as the target — mlx-lm parity; greedy drafting under a
112
+ * temperature>0 request is NOT parity). */
113
+ sampler: (logprobs: MlxArray, step: number) => MlxArray;
114
+ /** The target model + its live caches (donor views / offset). */
115
+ target: TargetView;
116
+ }): DraftSource;
117
+ dispose(): void;
118
+ }
@@ -0,0 +1,171 @@
1
+ // Two-model speculative drafting — mlx-lm parity (`mlx_lm.server
2
+ // --draft-model`). A full second model drafts autoregressively; the serve
3
+ // loop (src/spec/serve-loop.ts) verifies with the target. L1 oracle:
4
+ // mlx_lm.server with the same target/draft pair, greedy, token-for-token
5
+ // (spec-vs-spec — both batch the verify lm-head, so neither is bit-exact to
6
+ // stock decode at knife-edges; see src/spec/generate.ts header).
7
+ //
8
+ // Faithfulness notes (read from the oracle venv's generate.py):
9
+ // - drafts are sampled with the REQUEST sampler (generate.py:593-601) —
10
+ // greedy drafting under a temperature>0 request is not parity;
11
+ // - draft-cache rewind is max(n - kAccept - 1, 0) (generate.py:589-591),
12
+ // with the all-accept re-feed of the last draft handled by the serve
13
+ // loop's `feed` (generate.py:645-648);
14
+ // - logits processors do NOT run on draft steps (they're target-side).
15
+
16
+ import { MlxArray } from "../mlx/array";
17
+ import * as ops from "../mlx/ops";
18
+ import { clearCache } from "../mlx/ffi";
19
+ import { flagOn } from "../flags";
20
+ import { loadModelConfig } from "../config";
21
+ import { Weights } from "../weights";
22
+ import { createModel, type RuntimeModel } from "../model/factory";
23
+ import type { Cache } from "../model/gemma4";
24
+ import { toLogprobs } from "../sampler";
25
+ import type { DraftProvider, DraftSource } from "./source";
26
+
27
+ /** mlx-lm's prefill_step_size — the draft drain chunks at the same stride
28
+ * as the target's (serve-loop.ts); chunking is numerically exact for
29
+ * causal attention. */
30
+ const PREFILL_CHUNK = 2048;
31
+
32
+ export class TwoModelProvider implements DraftProvider {
33
+ readonly id: string;
34
+ readonly weightsBytes: number;
35
+
36
+ private constructor(
37
+ readonly model: RuntimeModel,
38
+ private readonly weights: Weights,
39
+ id: string,
40
+ ) {
41
+ this.id = id;
42
+ this.weightsBytes = [...weights.shards.files.values()].reduce(
43
+ (a, f) => a + f.mmap.size,
44
+ 0,
45
+ );
46
+ }
47
+
48
+ /** Load the draft model from a snapshot dir. Vocab-size mismatch with the
49
+ * target is a WARNING (mlx-lm parity, server.py:363-368); the tokenizer-
50
+ * family hard check lives at the server (it owns both tokenizers). */
51
+ static async load(modelDir: string, targetVocabSize?: number): Promise<TwoModelProvider> {
52
+ const config = await loadModelConfig(modelDir);
53
+ const weights = await Weights.open(modelDir);
54
+ const model = createModel(weights, config);
55
+ if (
56
+ targetVocabSize !== undefined &&
57
+ config.text.vocabSize !== targetVocabSize
58
+ )
59
+ console.warn(
60
+ `draft model vocab size ${config.text.vocabSize} != target ${targetVocabSize} — ` +
61
+ `speculation will accept nothing useful (mlx-lm warns identically)`,
62
+ );
63
+ return new TwoModelProvider(model, weights, modelDir.split("/").filter(Boolean).at(-1)!);
64
+ }
65
+
66
+ open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
67
+ return new TwoModelSource(this.model, opts.sampler);
68
+ }
69
+
70
+ dispose(): void {
71
+ this.weights.dispose();
72
+ }
73
+ }
74
+
75
+ class TwoModelSource implements DraftSource {
76
+ readonly weightsBytes = 0; // provider-owned weights; per-request adds caches only
77
+ private caches: Cache[];
78
+
79
+ constructor(
80
+ private readonly model: RuntimeModel,
81
+ private readonly sampler: (lp: MlxArray, step: number) => MlxArray,
82
+ ) {
83
+ this.caches = this.model.makeCache();
84
+ }
85
+
86
+ prefill(promptIds: number[]): void {
87
+ // Oracle convention (mlx-lm speculative_generate_step._prefill, re-anchored
88
+ // 2026-07-07): the DRAFT model also drains only to len-1 (`while y.size >
89
+ // 1`) — the last prompt token stays unprocessed and is consumed by the
90
+ // FIRST draft() round's feed ([pending] = that same token under the serve
91
+ // loop's oracle shape), mirroring _draft_generate's first _step. Draft-side
92
+ // full-prefill was the residual knife-edge flipper in the 2026-07-07 live
93
+ // oracle gate (γ=2 haiku cell). MLX_BUN_PREFILL_TAIL_SPLIT=0 reverts.
94
+ // (mlx-lm chunks its drain at prefill_step_size=2048; chunking is
95
+ // numerically exact for causal attention — same KV, same positions —
96
+ // so we chunk too. Pre-2026-07-07 this was a SINGLE forward of the
97
+ // whole head: nothing enforced the "<2048 serve regime" the old
98
+ // comment assumed, and a 32k prompt through the spec lane ran one
99
+ // 32k-position draft forward — a large activation/mask transient on a
100
+ // 24 GB box, and untested numerics vs the chunked oracle.)
101
+ const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
102
+ const upTo = tailSplit && promptIds.length > 1 ? promptIds.length - 1 : promptIds.length;
103
+ for (let at = 0; at < upTo; at += PREFILL_CHUNK) {
104
+ const end = Math.min(at + PREFILL_CHUNK, upTo);
105
+ const ids = ops.fromInt32(promptIds.slice(at, end), [1, end - at]);
106
+ const h = this.model.forwardHidden(ids, this.caches);
107
+ ids.dispose();
108
+ // The head's last-position logits are not needed: the serve loop's
109
+ // first `feed` is consumed at the top of draft().
110
+ h.dispose();
111
+ clearCache();
112
+ }
113
+ }
114
+
115
+ /** One forward over `feed`, sample the first draft from its last position,
116
+ * then n-1 single-token steps — mirrors _draft_generate's autoregressive
117
+ * chain (with async_eval pipelining upstream; eager here, the draft is
118
+ * small and the serve loop overlaps nothing with it in v1). */
119
+ draft(feed: number[], n: number, stepBase: number): number[] {
120
+ const drafts: number[] = [];
121
+ let input = feed;
122
+ for (let k = 0; k < n; k++) {
123
+ const ids = ops.fromInt32(input, [1, input.length]);
124
+ const h = this.model.forwardHidden(ids, this.caches);
125
+ ids.dispose();
126
+ const L = h.shape[1]!;
127
+ const H = h.shape[2]!;
128
+ const hLast = h.slice([0, L - 1, 0], [1, L, H]);
129
+ h.dispose();
130
+ const logits = this.model.logitsFromHidden(hLast);
131
+ hLast.dispose();
132
+ const V = logits.shape[logits.shape.length - 1]!;
133
+ const flat = ops.reshape(logits, [1, V]);
134
+ logits.dispose();
135
+ const lp = toLogprobs(flat);
136
+ flat.dispose();
137
+ const tokArr = this.sampler(lp, stepBase + k);
138
+ lp.dispose();
139
+ const tok = ops.itemUint32(tokArr);
140
+ tokArr.dispose();
141
+ drafts.push(tok);
142
+ input = [tok];
143
+ }
144
+ return drafts;
145
+ }
146
+
147
+ commit(n: number, kAccept: number): void {
148
+ const rewind = Math.max(n - kAccept - 1, 0);
149
+ if (rewind > 0) {
150
+ for (const c of this.caches) {
151
+ if (!c.isTrimmable()) {
152
+ // The serve loop stops speculating on the TARGET-side trim check
153
+ // before this can matter; a wrapped draft ring here just means the
154
+ // source is done being useful. Recreate is the safe recovery.
155
+ this.resetCaches();
156
+ return;
157
+ }
158
+ c.trim(rewind);
159
+ }
160
+ }
161
+ }
162
+
163
+ private resetCaches(): void {
164
+ for (const c of this.caches) c.dispose();
165
+ this.caches = this.model.makeCache();
166
+ }
167
+
168
+ dispose(): void {
169
+ for (const c of this.caches) c.dispose();
170
+ }
171
+ }
package/src/ssd-cache.ts CHANGED
@@ -21,7 +21,7 @@ import { join } from "node:path";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { commonPrefixLength } from "./prompt-cache";
23
23
  import {
24
- saveKvCache, loadKvCache, readKvHeader,
24
+ saveKvCache, saveKvCacheAsync, loadKvCache, readKvHeader,
25
25
  type KvSaveMeta, type KvLoadExpect, type LoadedKvCache,
26
26
  } from "./kv-store";
27
27
  import type { Cache } from "./model/gemma4-base";
@@ -32,8 +32,27 @@ export interface SsdIndexEntry {
32
32
  tokens: number[];
33
33
  bytes: number;
34
34
  mtimeMs: number;
35
+ /** Whether every serialized cache can trim (derived from kinds+state at
36
+ * store/scan time). An untrimmable entry (wrapped ring, SSM) can only
37
+ * seed prompts it matches WITHOUT a trim — find() must not prefer it
38
+ * over a shorter usable one (the 2026-07-06 restart-0 defect: the
39
+ * [prompt+gen] file always won, always got rejected after restore). */
40
+ trimmable: boolean;
35
41
  }
36
42
 
43
+ /** Trimmability from a file header alone (mirror of the live caches'
44
+ * isTrimmable(): kv/qkv/turboquant always trim; rotating kinds only pre-wrap
45
+ * (offset < maxSize); GLM compressed kinds always trim; ssm never). Keep in
46
+ * sync with gemma4-base/qwen3-delta/glm52-cache. */
47
+ const headerTrimmable = (caches: { kind: string; offset: number; maxSize?: number }[]): boolean =>
48
+ caches.every((c) =>
49
+ c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant" ||
50
+ c.kind === "mla" || c.kind === "mla-dsa" || c.kind === "mtp-mla"
51
+ ? true
52
+ : c.kind === "rotating" || c.kind === "rotating-qkv"
53
+ ? c.offset < (c.maxSize ?? 0)
54
+ : false);
55
+
37
56
  export interface SsdStoreOptions {
38
57
  dir: string;
39
58
  maxBytes: number;
@@ -94,6 +113,7 @@ export class SsdCacheStore {
94
113
  try {
95
114
  const h = readKvHeader(path); // verifies the header hash
96
115
  if (
116
+ h.modelId !== this.#opts.modelId ||
97
117
  h.configFingerprint !== this.#opts.configFingerprint ||
98
118
  h.tokenizerHash !== this.#opts.tokenizerHash
99
119
  )
@@ -101,6 +121,7 @@ export class SsdCacheStore {
101
121
  const st = statSync(path);
102
122
  this.#index.push({
103
123
  path, ns: h.ns ?? "", tokens: h.tokens, bytes: st.size, mtimeMs: st.mtimeMs,
124
+ trimmable: headerTrimmable(h.caches),
104
125
  });
105
126
  } catch {
106
127
  try { rmSync(path, { force: true }); } catch {}
@@ -110,13 +131,15 @@ export class SsdCacheStore {
110
131
  return this.#index.length;
111
132
  }
112
133
 
113
- /** Best stored entry for `prompt` in `ns`: the longest common prefix
114
- * (capped at prompt.length−1 — at least one token must forward). An
115
- * entry LONGER than the matched prefix is still returned (the divergent-
116
- * tail pattern: same long context, different question) — the caller
117
- * trims after restore when every cache kind is trimmable, and falls
118
- * back to a fresh prefill when not (ring post-wrap, SSM). Pure index
119
- * lookup; no I/O. */
134
+ /** Best USABLE stored entry for `prompt` in `ns`: the longest common
135
+ * prefix (capped at prompt.length−1 — at least one token must forward).
136
+ * An entry LONGER than the matched prefix is returned only when it can
137
+ * actually seed the prompt: the divergent tail must be trimmable after
138
+ * restore. Untrimmable entries (ring post-wrap, SSM) that would need a
139
+ * trim are SKIPPED here — before this gate, the big [prompt+gen] file
140
+ * always outranked the usable boundary snapshot, got restored, and was
141
+ * thrown away by take()'s backstop (the 2026-07-06 restart-0 + wasted
142
+ * 84 s restore-then-re-prefill path). Pure index lookup; no I/O. */
120
143
  find(prompt: number[], ns = ""): { entry: SsdIndexEntry; prefixLen: number } | null {
121
144
  let best: SsdIndexEntry | null = null;
122
145
  let bestLen = 0;
@@ -124,18 +147,22 @@ export class SsdCacheStore {
124
147
  if (e.ns !== ns) continue;
125
148
  const p = Math.min(commonPrefixLength(e.tokens, prompt), prompt.length - 1);
126
149
  if (p === 0 || p <= bestLen) continue;
150
+ if (e.tokens.length - p > 0 && !e.trimmable) continue;
127
151
  bestLen = p;
128
152
  best = e;
129
153
  }
130
154
  return best ? { entry: best, prefixLen: bestLen } : null;
131
155
  }
132
156
 
133
- /** Restore an entry zero-copy (COW mmap; pages fault in lazily). Bumps
134
- * LRU mtime. On ANY failure the file is dropped and null returned. */
157
+ /** Restore an entry via kv-store's STREAMED COPY (bounded host
158
+ * transient — live entry + one tensor; no mapping survives the call,
159
+ * the caches own their bytes). Bumps LRU mtime. On ANY failure the
160
+ * file is dropped and null returned. */
135
161
  restore(entry: SsdIndexEntry, model: { makeCache(): Cache[] }): LoadedKvCache | null {
136
162
  const t0 = performance.now();
137
163
  try {
138
164
  const expect: KvLoadExpect = {
165
+ modelId: this.#opts.modelId,
139
166
  configFingerprint: this.#opts.configFingerprint,
140
167
  tokenizerHash: this.#opts.tokenizerHash,
141
168
  ns: entry.ns,
@@ -147,7 +174,10 @@ export class SsdCacheStore {
147
174
  this.stats.restores++;
148
175
  this.stats.restoreMsLast = performance.now() - t0;
149
176
  return loaded;
150
- } catch {
177
+ } catch (err) {
178
+ // Loud: a failed restore silently degraded to a full re-prefill for
179
+ // a month before the 2026-07-06 bench made it observable.
180
+ console.warn(`[ssd-cache] restore failed, dropping ${entry.path}: ${(err as Error).message}`);
151
181
  this.remove(entry.path);
152
182
  return null;
153
183
  }
@@ -165,35 +195,81 @@ export class SsdCacheStore {
165
195
  const path = join(dir, `${randomUUID()}.mlxkv`);
166
196
  try {
167
197
  mkdirSync(dir, { recursive: true });
168
- const meta: KvSaveMeta = {
169
- modelId: this.#opts.modelId,
170
- configFingerprint: this.#opts.configFingerprint,
171
- tokenizerHash: this.#opts.tokenizerHash,
172
- ns,
173
- };
174
- saveKvCache(path, tokens, caches, meta);
175
- const st = statSync(path);
176
- if (st.size > this.#opts.maxBytes) {
177
- rmSync(path, { force: true });
178
- return false;
179
- }
180
- // Supersede shadowed ancestors of this prefix (same ns).
198
+ saveKvCache(path, tokens, caches, this.#meta(ns));
199
+ return this.#indexStored(path, tokens, caches, ns);
200
+ } catch (err) {
201
+ return this.#storeFailed(path, err);
202
+ }
203
+ }
204
+
205
+ /** Non-blocking store (the write-behind persistence path): same file,
206
+ * index, and supersede semantics as store(), but the tensor flush
207
+ * yields the event loop between tensors so serving interleaves.
208
+ * Caller passes zero-copy CLONES it owns (a consistent snapshot no
209
+ * matter what the live entry does meanwhile) and disposes them after.
210
+ * `waitTurn` gates every per-tensor step (see saveKvCacheAsync) — the
211
+ * server passes the gateway's onIdle so the flush only progresses while
212
+ * the engine is idle, never mid-decode. */
213
+ async storeAsync(tokens: number[], caches: Cache[], ns = "", waitTurn?: () => Promise<void>): Promise<boolean> {
214
+ const dir = join(this.#root, nsHash(ns));
215
+ const path = join(dir, `${randomUUID()}.mlxkv`);
216
+ try {
217
+ mkdirSync(dir, { recursive: true });
218
+ await saveKvCacheAsync(path, tokens, caches, this.#meta(ns), waitTurn);
219
+ return this.#indexStored(path, tokens, caches, ns);
220
+ } catch (err) {
221
+ return this.#storeFailed(path, err);
222
+ }
223
+ }
224
+
225
+ #meta(ns: string): KvSaveMeta {
226
+ return {
227
+ modelId: this.#opts.modelId,
228
+ configFingerprint: this.#opts.configFingerprint,
229
+ tokenizerHash: this.#opts.tokenizerHash,
230
+ ns,
231
+ };
232
+ }
233
+
234
+ #indexStored(path: string, tokens: number[], caches: Cache[], ns: string): boolean {
235
+ const st = statSync(path);
236
+ if (st.size > this.#opts.maxBytes) {
237
+ rmSync(path, { force: true });
238
+ console.warn(`[ssd-cache] entry not stored: ${st.size} bytes exceeds the ${this.#opts.maxBytes}-byte cap`);
239
+ return false;
240
+ }
241
+ const trimmable = caches.every((c) => c.isTrimmable());
242
+ // Exact duplicates (same tokens) are replaced regardless of
243
+ // trimmability — the new file serves exactly the old one's matches.
244
+ for (const e of [...this.#index]) {
245
+ if (e.ns !== ns || e.tokens.length !== tokens.length) continue;
246
+ if (commonPrefixLength(e.tokens, tokens) === tokens.length) this.remove(e.path);
247
+ }
248
+ // Supersede shadowed ancestors of this prefix (same ns) — ONLY when
249
+ // the new entry can serve their prefixes (all caches trimmable; the
250
+ // same guard as PromptCache.put()). An untrimmable [prompt+gen]
251
+ // entry deleting the boundary snapshot was the gemma restart-0
252
+ // defect (2026-07-06): the only restorable file died the moment the
253
+ // unrestorable one landed.
254
+ if (trimmable) {
181
255
  for (const e of [...this.#index]) {
182
256
  if (e.ns !== ns || e.tokens.length >= tokens.length) continue;
183
257
  if (commonPrefixLength(e.tokens, tokens) === e.tokens.length) this.remove(e.path);
184
258
  }
185
- this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now() });
186
- this.stats.spills++;
187
- this.evictToCap();
188
- return true;
189
- } catch (err) {
190
- try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
191
- if (!this.#warnedWriteFailure) {
192
- this.#warnedWriteFailure = true;
193
- console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
194
- }
195
- return false;
196
259
  }
260
+ this.#index.push({ path, ns, tokens, bytes: st.size, mtimeMs: Date.now(), trimmable });
261
+ this.stats.spills++;
262
+ this.evictToCap();
263
+ return true;
264
+ }
265
+
266
+ #storeFailed(path: string, err: unknown): boolean {
267
+ try { rmSync(path, { force: true }); rmSync(`${path}.tmp`, { force: true }); } catch {}
268
+ if (!this.#warnedWriteFailure) {
269
+ this.#warnedWriteFailure = true;
270
+ console.warn(`[ssd-cache] store failed (disk full or unwritable?) — cold tier disabled for this entry: ${(err as Error).message}`);
271
+ }
272
+ return false;
197
273
  }
198
274
 
199
275
  /** Enforce the byte cap: unlink oldest-mtime entries until under it. */