mlx-bun 0.0.10 → 0.0.11

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 (173) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -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/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +14 -6
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -15,12 +15,22 @@ import * as ops from "../../mlx/ops";
15
15
  import { Gemma4Model } from "../../model/gemma4";
16
16
  import type { DflashDrafter } from "./module-dflash";
17
17
  import { KeyStream, processLogits, probsOf, probAtToken, sampleToken, sampleResidual, type DSparkSampleConfig } from "./sample";
18
+ import type { ConfSample } from "./calibration";
18
19
 
19
20
  export interface DflashGenOptions {
20
21
  gamma?: number;
21
22
  maxTokens?: number;
22
23
  eosTokenIds?: number[];
23
24
  sample?: DSparkSampleConfig;
25
+ /** Confidence-scheduled draft-length pruning (Alg 1). Defaults to the
26
+ * checkpoint's STS thresholds (drafter.cfg.sts) when calibrated; pass
27
+ * minConf for a uniform manual override; pass thresholds:[] to disable. */
28
+ thresholds?: number[];
29
+ minConf?: number;
30
+ /** Calibration hook (scripts/dspark-calibrate.ts): called once per draft
31
+ * round with that round's per-position (confidence, accepted) samples,
32
+ * AFTER kAccept is known. Not used by ordinary decode. */
33
+ onRound?: (samples: ConfSample[]) => void;
24
34
  }
25
35
  export interface DflashStats {
26
36
  emitted: number; drafted: number; accepted: number; targetCalls: number;
@@ -32,19 +42,26 @@ export interface DflashResult { tokens: number[]; stats: DflashStats }
32
42
  * hidden [1,L,H] AND the m-layer context [1,L,m*H] (tapLayers concatenated on
33
43
  * the feature axis). */
34
44
  function forwardTapped(model: Gemma4Model, ids: MlxArray, caches: import("../../model/gemma4").Cache[], tapLayers: number[]): { finalH: MlxArray; ctxML: MlxArray } {
35
- model.hiddenTap = { layers: new Set(tapLayers), captured: new Map() };
36
- let finalH: MlxArray;
45
+ const cap = new Map<number, MlxArray>();
46
+ model.hiddenTap = { layers: new Set(tapLayers), captured: cap };
47
+ let finalH: MlxArray | null = null;
37
48
  let ctxML: MlxArray;
38
49
  try {
39
50
  finalH = model.forwardHidden(ids, caches);
40
- const cap = model.hiddenTap.captured;
41
51
  const perLayer = tapLayers.map((li) => { const a = cap.get(li); if (!a) throw new Error(`layer ${li} not captured`); return a; });
42
52
  ctxML = ops.concatAxis(perLayer, 2); // [1,L,m*H]
43
53
  for (const [, a] of cap) a.dispose();
54
+ cap.clear(); // consumed — the finally must not double-dispose
55
+ const out = { finalH, ctxML };
56
+ finalH = null; // ownership returned to the caller
57
+ return out;
44
58
  } finally {
59
+ // Free partial captures + an orphaned finalH on the throw path (on success
60
+ // both are already gone).
61
+ finalH?.dispose();
62
+ for (const [, a] of cap) a.dispose();
45
63
  model.hiddenTap = null;
46
64
  }
47
- return { finalH, ctxML };
48
65
  }
49
66
 
50
67
  function sampleFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number, cfg: DSparkSampleConfig | null, keys: KeyStream): number {
@@ -90,14 +107,22 @@ function verifySampling(model: Gemma4Model, vHidden: MlxArray, draftLogits: MlxA
90
107
  let verdict: Verdict | null = null;
91
108
  for (let k = 0; k < gamma; k++) {
92
109
  const x = drafts[k]!;
93
- const ps = processLogits(slice(tl, k), cfg); const P = probsOf(ps); ps.dispose();
94
- const qs = processLogits(slice(draftLogits, k), cfg); const Q = probsOf(qs); qs.dispose();
110
+ // processLogits does not consume its input — bind each sliced row and
111
+ // dispose it after (an inline slice(...) here leaked one [1,V] per position).
112
+ const tRow = slice(tl, k);
113
+ const ps = processLogits(tRow, cfg); tRow.dispose(); const P = probsOf(ps); ps.dispose();
114
+ const qRow = slice(draftLogits, k);
115
+ const qs = processLogits(qRow, cfg); qRow.dispose(); const Q = probsOf(qs); qs.dispose();
95
116
  const pX = probAtToken(P, x), qX = probAtToken(Q, x);
96
117
  if (u[k]! < Math.min(1, qX > 0 ? pX / qX : 1)) { P.dispose(); Q.dispose(); continue; }
97
118
  const corr = sampleResidual(P, Q, keys.next()); P.dispose(); Q.dispose();
98
119
  verdict = { kAccept: k, emit: corr }; break;
99
120
  }
100
- if (!verdict) { const bs = processLogits(slice(tl, gamma), cfg); verdict = { kAccept: gamma, emit: sampleToken(bs, keys.next()) }; bs.dispose(); }
121
+ if (!verdict) {
122
+ const bRow = slice(tl, gamma);
123
+ const bs = processLogits(bRow, cfg); bRow.dispose();
124
+ verdict = { kAccept: gamma, emit: sampleToken(bs, keys.next()) }; bs.dispose();
125
+ }
101
126
  tl.dispose();
102
127
  return verdict;
103
128
  }
@@ -114,6 +139,12 @@ export function dflashGenerate(model: Gemma4Model, drafter: DflashDrafter, promp
114
139
  const out: number[] = [];
115
140
  let confSum = 0, rounds = 0, acceptLenSum = 0;
116
141
  let H_ctx: MlxArray | null = null;
142
+ // Round-local tensors, hoisted so the finally frees them on a mid-round
143
+ // throw (verify, sampling, concat); each is nulled at its normal disposal
144
+ // — the serve-loop discipline (src/spec/serve-loop.ts).
145
+ let vHidden: MlxArray | null = null;
146
+ let vCtxML: MlxArray | null = null;
147
+ let blockLogits: MlxArray | null = null;
117
148
 
118
149
  try {
119
150
  // 1. prefill (tapped): seed H_ctx with the prompt's m-layer hiddens.
@@ -132,31 +163,49 @@ export function dflashGenerate(model: Gemma4Model, drafter: DflashDrafter, promp
132
163
  out.push(next); stats.emitted++;
133
164
 
134
165
  const tDecode = performance.now();
166
+ // Alg 1 thresholds: explicit option > checkpoint STS calibration > none.
167
+ const thresholds = options.thresholds ?? drafter.cfg.sts?.thresholds;
135
168
  outer: while (stats.emitted < maxTokens) {
136
- // 2a. draft against the current (growing) H_ctx.
137
- const block = drafter.forwardInfer(model, H_ctx!, next, gamma, { sample: sampleCfg ?? undefined, keys });
169
+ // 2a. draft against the current (growing) H_ctx. The scheduler may prune
170
+ // the block short (d ≤ γ) — verify then covers exactly d positions.
171
+ const block = drafter.forwardInfer(model, H_ctx!, next, gamma, {
172
+ sample: sampleCfg ?? undefined, keys, thresholds, minConf: options.minConf,
173
+ });
138
174
  const drafts = block.tokens;
139
- stats.drafted += gamma;
175
+ blockLogits = block.draftLogits ?? null;
176
+ const d = drafts.length;
177
+ stats.drafted += d;
140
178
  for (const c of block.conf) confSum += c;
141
179
 
142
180
  // 2b. verify (tapped) — get accepted tokens' m-layer hiddens to append.
143
- const vIds = ops.fromInt32([next, ...drafts], [1, gamma + 1]);
144
- const { finalH: vHidden, ctxML: vCtxML } = forwardTapped(model, vIds, caches, tapLayers);
181
+ const vIds = ops.fromInt32([next, ...drafts], [1, d + 1]);
182
+ const ft = forwardTapped(model, vIds, caches, tapLayers);
183
+ vHidden = ft.finalH;
184
+ vCtxML = ft.ctxML;
145
185
  vIds.dispose();
146
186
  stats.targetCalls++;
147
187
 
148
188
  const { kAccept, emit } = sampleCfg
149
- ? verifySampling(model, vHidden, block.draftLogits, drafts, gamma, sampleCfg, keys)
150
- : verifyGreedy(model, vHidden, drafts, gamma);
151
- block.draftLogits.dispose();
189
+ ? verifySampling(model, vHidden, blockLogits!, drafts, d, sampleCfg, keys)
190
+ : verifyGreedy(model, vHidden, drafts, d);
191
+ blockLogits?.dispose();
192
+ blockLogits = null;
152
193
  stats.accepted += kAccept; rounds++; acceptLenSum += kAccept + 1;
153
194
 
195
+ // Calibration hook (scripts/dspark-calibrate.ts): report this round's
196
+ // per-position (confidence, accepted) outcomes now that kAccept is known.
197
+ if (options.onRound) {
198
+ const roundSamples: ConfSample[] = [];
199
+ for (let k = 0; k < drafts.length; k++) roundSamples.push({ pos: k, conf: block.conf[k]!, accepted: k < kAccept });
200
+ options.onRound(roundSamples);
201
+ }
202
+
154
203
  // 2c. emit accepted drafts
155
204
  for (let k = 0; k < kAccept; k++) {
156
205
  const isEos = eosTokenIds.includes(drafts[k]!);
157
206
  if (!isEos) out.push(drafts[k]!);
158
207
  stats.emitted++;
159
- if (isEos || stats.emitted >= maxTokens) { vHidden.dispose(); vCtxML.dispose(); break outer; }
208
+ if (isEos || stats.emitted >= maxTokens) break outer; // finally frees vHidden/vCtxML
160
209
  }
161
210
  const emitIsEos = eosTokenIds.includes(emit);
162
211
  if (!emitIsEos) out.push(emit);
@@ -164,21 +213,24 @@ export function dflashGenerate(model: Gemma4Model, drafter: DflashDrafter, promp
164
213
 
165
214
  // 2d. append accepted stream's m-layer hiddens (anchor + kAccept drafts =
166
215
  // vCtxML[:, 0..kAccept]) to H_ctx; never the rejected tips.
167
- const add = vCtxML.slice([0, 0, 0], [1, kAccept + 1, vCtxML.shape[2]!]);
216
+ const add = vCtxML!.slice([0, 0, 0], [1, kAccept + 1, vCtxML!.shape[2]!]);
168
217
  const grown = ops.concatAxis([H_ctx!, add], 1);
169
- H_ctx!.dispose(); add.dispose(); vCtxML.dispose();
218
+ H_ctx!.dispose(); add.dispose(); vCtxML!.dispose(); vCtxML = null;
170
219
  H_ctx = grown;
171
220
 
172
221
  // 2e. roll back rejected KV tips (bypass trim, past-window safe)
173
- if (kAccept < gamma) { const n = gamma - kAccept; for (const c of caches) c.trim(n, true); }
222
+ if (kAccept < d) { const nRej = d - kAccept; for (const c of caches) c.trim(nRej, true); }
174
223
 
175
- vHidden.dispose();
224
+ vHidden!.dispose(); vHidden = null;
176
225
  if (emitIsEos || stats.emitted >= maxTokens) break;
177
226
  next = emit;
178
227
  }
179
228
  stats.decodeMs = performance.now() - tDecode;
180
229
  } finally {
181
230
  H_ctx?.dispose();
231
+ vHidden?.dispose();
232
+ vCtxML?.dispose();
233
+ blockLogits?.dispose();
182
234
  for (const c of caches) c.dispose();
183
235
  }
184
236
  return finalize(stats, out, rounds, acceptLenSum, confSum);
@@ -0,0 +1,23 @@
1
+ // Central DSpark checkpoint loader — the variant dispatch the modules never
2
+ // had (scripts imported their class directly; the serve path needs one entry
3
+ // point). Reads dspark.json and returns the matching drafter:
4
+ // variant "dspark" — the faithful KV-injection module (canonical)
5
+ // variant "dflash" (legacy) — same module, pre-rename stamp
6
+ // variant absent — a v1 single-vector checkpoint (module.ts,
7
+ // superseded research baseline) — refused for
8
+ // serving with a pointer at the v2 trainer.
9
+
10
+ import { readFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { DflashDrafter } from "./module-dflash";
13
+
14
+ export function loadDsparkDrafter(dir: string): DflashDrafter {
15
+ const metaPath = join(dir, "dspark.json");
16
+ if (!existsSync(metaPath)) throw new Error(`no dspark.json in ${dir} — not a DSpark checkpoint`);
17
+ const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { variant?: string };
18
+ if (meta.variant === "dspark" || meta.variant === "dflash") return DflashDrafter.load(dir);
19
+ throw new Error(
20
+ `${dir} is a v1 single-vector DSpark checkpoint (variant=${meta.variant ?? "none"}) — ` +
21
+ `superseded, not serveable; retrain with scripts/dspark-train-dflash.ts`,
22
+ );
23
+ }
@@ -23,6 +23,21 @@ import { processLogits, sampleToken, KeyStream, type DSparkSampleConfig } from "
23
23
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
 
26
+ /** STS calibration (§3.2.1) — per-position confidence thresholds fit on real
27
+ * (confidence, accepted) verify outcomes (src/spec/dspark/calibration.ts,
28
+ * scripts/dspark-calibrate.ts). Consumed by the confidence-scheduled
29
+ * draft-length pruning (Alg 1, single-user form) in forwardInfer. Stored
30
+ * inside DflashConfig so it round-trips through dspark.json for free.
31
+ * ABSENT ⟹ no pruning — an uncalibrated checkpoint drafts exactly as before. */
32
+ export interface StsCalibration {
33
+ /** thresholds[k] = min confidence to KEEP draft position k. length γ. */
34
+ thresholds: number[];
35
+ /** Target acceptance precision the thresholds were fit for (e.g. 0.5). */
36
+ target: number;
37
+ /** Verify outcomes the fit saw (provenance). */
38
+ samples?: number;
39
+ }
40
+
26
41
  export interface DflashConfig {
27
42
  gamma: number;
28
43
  dDraft: number;
@@ -34,6 +49,10 @@ export interface DflashConfig {
34
49
  /** Target layer indices tapped for H_ctx (Eq 2). nLayers-of-target..sentinel.
35
50
  * For e4b (42 layers): index 0..41 = post-layer residual, 42 = post-finalNorm. */
36
51
  tapLayers: number[];
52
+ /** Sequential head: "markov" (Eq 5, default) or "rnn" (Eq 6). */
53
+ seqHead?: "markov" | "rnn";
54
+ /** STS calibration (§3.2.1); absent = no confidence pruning. */
55
+ sts?: StsCalibration;
37
56
  }
38
57
 
39
58
  export const DEFAULT_DFLASH_CONFIG: DflashConfig = {
@@ -49,8 +68,30 @@ export const DEFAULT_DFLASH_CONFIG: DflashConfig = {
49
68
  export interface TargetDims { hiddenSize: number; vocabSize: number; eps: number }
50
69
 
51
70
  export interface DflashTrainOut { draftLogits: MlxArray; conf: MlxArray }
52
- export interface DflashDraftBlock { tokens: number[]; conf: number[]; draftLogits: MlxArray }
53
- export interface DflashDraftOpts { sample?: DSparkSampleConfig; keys?: KeyStream }
71
+ /** tokens/conf may be SHORTER than the requested γ when confidence pruning
72
+ * fires (Alg 1); always ≥1. draftLogits covers exactly tokens.length
73
+ * positions — ABSENT when the caller passed collectLogits:false (the loop
74
+ * never materialized/concatenated the per-position logits). */
75
+ export interface DflashDraftBlock { tokens: number[]; conf: number[]; draftLogits?: MlxArray }
76
+ export interface DflashDraftOpts {
77
+ sample?: DSparkSampleConfig;
78
+ keys?: KeyStream;
79
+ /** Confidence-scheduled draft-length pruning (Alg 1, single-user form):
80
+ * position k is DROPPED (and the block truncated there) when its predicted
81
+ * acceptance c_k < thresholds[k] (per-position, STS-calibrated) or < minConf
82
+ * (uniform manual override). Position 0 is always kept (a source must
83
+ * return ≥1 token). Both absent ⟹ fixed-γ drafting (pre-scheduler
84
+ * behavior). Losslessness is invariant — pruning only changes how many
85
+ * positions the target VERIFIES, never what is emitted. */
86
+ thresholds?: number[];
87
+ minConf?: number;
88
+ /** Materialize+concat the per-position draft logits into the returned
89
+ * DflashDraftBlock.draftLogits. Default true (the standalone
90
+ * dflashGenerate sampling-verify path reads it). The serve-loop draft()
91
+ * path runs its own verify lm-head and never reads it — pass false there
92
+ * to skip the dead concat + a host-unrelated GPU alloc every round. */
93
+ collectLogits?: boolean;
94
+ }
54
95
 
55
96
  const CDT = Dtype.float32;
56
97
  const NEG = -1e9;
@@ -126,6 +167,15 @@ export class DflashDrafter {
126
167
  set("markov.w2", zerosArray([r, V])); // W2=0 → starts as pure parallel DFlash
127
168
  set("conf.w", zerosArray([dDraft + r, 1]));
128
169
  set("conf.b", zerosArray([1]));
170
+ // rnn.* MUST be initialized last (after every markov/shared param) so the
171
+ // seeded rng stream hands out IDENTICAL draws to a markov-only init up to
172
+ // this point — this is what makes the init-equivalence gate hold (an rnn
173
+ // drafter and a markov drafter, same seed, are bit-identical at t=0).
174
+ if (cfg.seqHead === "rnn") {
175
+ set("rnn.wH", normalArray(rng, [r, r], 0.02));
176
+ set("rnn.bH", zerosArray([r]));
177
+ set("rnn.wO", zerosArray([r, V])); // zero-init → starts as pure DFlash, mirrors markov.w2
178
+ }
129
179
  return d;
130
180
  }
131
181
 
@@ -261,6 +311,61 @@ export class DflashDrafter {
261
311
  const B = ops.matmul(emb1, this.get("markov.w2")); // [A,γ,V]
262
312
  return { B, emb1 };
263
313
  }
314
+
315
+ /** RNN sequential head — the ELMAN variant. VERIFIED-DIVERGENT from paper
316
+ * Eq 6 (2026-07-06 audit vs arXiv:2607.05147): the paper's cell is GATED
317
+ * over z_k=[s_{k−1}; W₁[x_{k−1}]; h_k] (backbone hidden included!) with a
318
+ * fused (2r+d)→3r projection, s_k=σ(W_g z)⊙s_{k−1}+(1−σ)⊙tanh(W_c z),
319
+ * B_k=W₂ᵀtanh(W_o z) — DeepSpec's RNNHead is that form. This ungated
320
+ * tanh recurrence over the shared token embedding (no h_k) is kept as a
321
+ * documented VARIANT ([[dont-delete-optionality-on-one-measurement]]):
322
+ * the paper reports the RNN head's gains over Markov as "marginal" and
323
+ * production uses Markov (our default). Add the gated cell as
324
+ * seqHead:"gru" if it ever earns a run; details in the design doc's
325
+ * "DeepSpec ground truth" section. */
326
+ #rnn(prevToks: MlxArray): { B: MlxArray; emb1: MlxArray } {
327
+ const { gamma: G, markovRank: r } = this.cfg;
328
+ const A = prevToks.shape[0]!;
329
+ const emb1 = ops.takeAxis(this.get("markov.w1"), prevToks, 0); // [A,γ,r] (shared embedding, no 2nd table)
330
+ const wH = this.get("rnn.wH"), bH = this.get("rnn.bH"), wO = this.get("rnn.wO");
331
+ let s: MlxArray | null = zerosArray([A, r]); // s_{-1} = 0
332
+ const bSlices: MlxArray[] = [];
333
+ const pending: MlxArray[] = []; // iteration temps, for the catch (forwardInfer pattern)
334
+ try {
335
+ for (let k = 0; k < G; k++) {
336
+ const e1S = emb1.slice([0, k, 0], [A, k + 1, r]); pending.push(e1S);
337
+ const e1k = ops.reshape(e1S, [A, r]); pending.push(e1k);
338
+ e1S.dispose();
339
+ const sh = ops.matmul(s!, wH); pending.push(sh);
340
+ s!.dispose(); s = null;
341
+ let sNext = ops.add(sh, e1k); pending.push(sNext);
342
+ sh.dispose(); e1k.dispose();
343
+ sNext = disposing(sNext, ops.add(sNext, bH)); pending.push(sNext);
344
+ sNext = disposing(sNext, ops.tanh(sNext)); pending.push(sNext);
345
+ s = sNext; // s_k
346
+ const bk = ops.matmul(s, wO); pending.push(bk); // [A,V]
347
+ bSlices.push(ops.reshape(bk, [A, 1, this.dims.vocabSize])); bk.dispose();
348
+ pending.length = 0;
349
+ }
350
+ s!.dispose(); s = null;
351
+ const B = ops.concatAxis(bSlices, 1); // [A,γ,V]
352
+ for (const b of bSlices) b.dispose();
353
+ return { B, emb1 };
354
+ } catch (err) {
355
+ // Mid-recurrence throw: free the carried state, the collected slices,
356
+ // the current iteration's temps, and the emb1 the caller never received
357
+ // (dispose() idempotence makes over-disposal inert).
358
+ for (const a of pending) a.dispose();
359
+ for (const b of bSlices) b.dispose();
360
+ s?.dispose();
361
+ emb1.dispose();
362
+ throw err;
363
+ }
364
+ }
365
+
366
+ #seqHead(prevToks: MlxArray): { B: MlxArray; emb1: MlxArray } {
367
+ return this.cfg.seqHead === "rnn" ? this.#rnn(prevToks) : this.#markov(prevToks);
368
+ }
264
369
  #confidence(block: MlxArray, emb1: MlxArray): MlxArray {
265
370
  const inp = ops.concatAxis([block, emb1], 2);
266
371
  let z = ops.matmul(inp, this.get("conf.w")); inp.dispose();
@@ -275,23 +380,49 @@ export class DflashDrafter {
275
380
  * hCtx [A,Lctx,m*H], ctxMask [A,Lctx]|null, anchorEmb [A,H], prevToks [A,γ]. */
276
381
  forwardTrain(model: Gemma4Model, hCtx: MlxArray, ctxMask: MlxArray | null, anchorEmb: MlxArray, prevToks: MlxArray): DflashTrainOut {
277
382
  const A = hCtx.shape[0]!;
278
- const block = this.#backbone(hCtx, anchorEmb, ctxMask, A);
279
- const U = this.#baseLogits(model, block);
280
- const Uf = U.astype(CDT); U.dispose();
281
- const { B, emb1 } = this.#markov(prevToks);
282
- const draftLogits = disposing(Uf, ops.add(Uf, B)); B.dispose();
283
- const conf = this.#confidence(block, emb1);
284
- block.dispose(); emb1.dispose();
285
- return { draftLogits, conf };
383
+ let block: MlxArray | null = null, Uf: MlxArray | null = null;
384
+ let B: MlxArray | null = null, emb1: MlxArray | null = null;
385
+ try {
386
+ block = this.#backbone(hCtx, anchorEmb, ctxMask, A);
387
+ const U = this.#baseLogits(model, block);
388
+ Uf = U.astype(CDT); U.dispose();
389
+ ({ B, emb1 } = this.#seqHead(prevToks));
390
+ const draftLogits = ops.add(Uf, B);
391
+ Uf.dispose(); Uf = null; B.dispose(); B = null;
392
+ const conf = this.#confidence(block, emb1);
393
+ block.dispose(); block = null; emb1.dispose(); emb1 = null;
394
+ return { draftLogits, conf };
395
+ } catch (err) {
396
+ // Free whatever a mid-forward throw left live (each slot is nulled the
397
+ // moment its normal disposal ran — no double-free).
398
+ block?.dispose(); Uf?.dispose(); B?.dispose(); emb1?.dispose();
399
+ throw err;
400
+ }
286
401
  }
287
402
 
288
403
  /** Inference: parallel backbone once, then sequential Markov sampling.
289
- * hCtx [1,Lctx,m*H] (full current context), anchorTok the bonus token. */
404
+ * hCtx [1,Lctx,m*H] (full current context), anchorTok the bonus token.
405
+ *
406
+ * Host-sync discipline (docs/investigations/dspark-handoff.md item 3): the
407
+ * greedy path's token recurrence stays ON-DEVICE across positions — the
408
+ * argmax result [1] uint32 feeds directly into the next position's
409
+ * takeAxis, no itemUint32 in the loop. Per-position token arrays are
410
+ * collected and resolved to host numbers with exactly ONE concat +
411
+ * toIntTokens() after the loop. Confidence is the one read the pruning
412
+ * ALGORITHM inherently needs per position (Alg 1 must decide whether to
413
+ * stop NOW) — so it stays per-position whenever thresholds/minConf are
414
+ * actually active; when pruning is inactive there is no such need, so
415
+ * confidence is deferred the same way (collect [1] arrays, one concat +
416
+ * one toFloat32 after the loop). The sampling path already forces a host
417
+ * read every position (sampleToken draws from a device RNG key) — that
418
+ * path is unchanged. */
290
419
  forwardInfer(model: Gemma4Model, hCtx: MlxArray, anchorTok: number, gamma: number, opts: DflashDraftOpts = {}): DflashDraftBlock {
291
420
  const { dDraft, markovRank: r } = this.cfg;
292
421
  const V = this.dims.vocabSize;
293
422
  const sample = opts.sample && opts.sample.temperature > 0 ? opts.sample : null;
294
423
  const keys = opts.keys ?? new KeyStream(opts.sample?.seed ?? 0);
424
+ const collectLogits = opts.collectLogits ?? true;
425
+ const pruningActive = opts.thresholds !== undefined || opts.minConf !== undefined;
295
426
 
296
427
  const ids = ops.fromInt32([anchorTok], [1, 1]);
297
428
  const ae3 = model.embed.encode(ids); ids.dispose();
@@ -302,30 +433,134 @@ export class DflashDrafter {
302
433
  const Uf = U.astype(CDT); U.dispose();
303
434
 
304
435
  const w1 = this.get("markov.w1"), w2 = this.get("markov.w2");
305
- const tokens: number[] = [], conf: number[] = [];
306
- const perPos: MlxArray[] = [];
307
- let prevTok = anchorTok;
308
- for (let k = 0; k < gamma; k++) {
309
- const Uk = ops.reshape(Uf.slice([0, k, 0], [1, k + 1, V]), [1, V]);
310
- const prevIdx = ops.fromInt32([prevTok], [1]);
311
- const e1 = ops.takeAxis(w1, prevIdx, 0); prevIdx.dispose();
312
- const Bk = ops.matmul(e1, w2);
313
- const logitsK = ops.add(Uk, Bk); Uk.dispose(); Bk.dispose();
314
- let tok: number;
315
- if (sample) { const sc = processLogits(logitsK, sample); tok = sampleToken(sc, keys.next()); sc.dispose(); }
316
- else { const am = ops.argmaxAxis(logitsK, -1); tok = ops.itemUint32(am); am.dispose(); }
317
- perPos.push(ops.reshape(logitsK, [1, 1, V])); logitsK.dispose();
318
- const hk = ops.reshape(block.slice([0, k, 0], [1, k + 1, dDraft]), [1, dDraft]);
319
- const ci = ops.concatAxis([hk, e1], 1); hk.dispose(); e1.dispose();
320
- let cz = ops.matmul(ci, this.get("conf.w")); ci.dispose();
321
- cz = disposing(cz, ops.add(cz, this.get("conf.b")));
322
- cz = disposing(cz, ops.sigmoid(cz));
323
- conf.push(cz.toFloat32()[0]!); cz.dispose();
324
- tokens.push(tok); prevTok = tok;
436
+ const isRnn = this.cfg.seqHead === "rnn";
437
+ const wH = isRnn ? this.get("rnn.wH") : null;
438
+ const bH = isRnn ? this.get("rnn.bH") : null;
439
+ const wO = isRnn ? this.get("rnn.wO") : null;
440
+ let s: MlxArray | null = isRnn ? zerosArray([1, r]) : null; // s_{-1} = 0, carried across k
441
+ const conf: number[] = [];
442
+ const perPos: MlxArray[] = []; // per-position [1,1,V] draft logits (collectLogits only)
443
+ const tokArrs: MlxArray[] = []; // per-position [1] device token ids, greedy path only
444
+ const confArrs: MlxArray[] = []; // per-position [1] device confidences, deferred (no pruning) only
445
+ let tokens: number[] = [];
446
+ let prevIdx: MlxArray = ops.fromInt32([anchorTok], [1]); // device token id fed to takeAxis
447
+ let ownPrevIdx = true; // false once prevIdx aliases a tokArrs/argmax entry we must not double-dispose
448
+ // Iteration-local temps register here at creation so the catch can reach
449
+ // them on a mid-flight throw; drained (length=0) at the bottom of each
450
+ // iteration once everything in it was disposed on the normal path.
451
+ // dispose() idempotence makes catch-side re-disposal of the already-freed
452
+ // ones inert.
453
+ const pending: MlxArray[] = [];
454
+ try {
455
+ for (let k = 0; k < gamma; k++) {
456
+ const UkS = Uf.slice([0, k, 0], [1, k + 1, V]); pending.push(UkS);
457
+ const Uk = ops.reshape(UkS, [1, V]); pending.push(Uk);
458
+ UkS.dispose();
459
+ const e1 = ops.takeAxis(w1, prevIdx, 0); pending.push(e1); // [1,r], shared embedding either way
460
+ if (ownPrevIdx) { prevIdx.dispose(); ownPrevIdx = false; } // consumed; prevIdx reassigned below
461
+ let Bk: MlxArray;
462
+ if (isRnn) {
463
+ const sh = ops.matmul(s!, wH!); pending.push(sh);
464
+ s!.dispose(); s = null;
465
+ let sNext = ops.add(sh, e1); pending.push(sNext);
466
+ sh.dispose();
467
+ sNext = disposing(sNext, ops.add(sNext, bH!)); pending.push(sNext);
468
+ sNext = disposing(sNext, ops.tanh(sNext)); pending.push(sNext);
469
+ s = sNext; // s_k, survives to the next iteration (covered by the s slot from here)
470
+ Bk = ops.matmul(s, wO!); pending.push(Bk);
471
+ } else {
472
+ Bk = ops.matmul(e1, w2); pending.push(Bk);
473
+ }
474
+ const logitsK = ops.add(Uk, Bk); pending.push(logitsK);
475
+ Uk.dispose(); Bk.dispose();
476
+ // prevIdx becomes the SINGLE tracked owner of the next token id the
477
+ // moment it exists — no separate local for the catch to miss.
478
+ if (sample) {
479
+ const sc = processLogits(logitsK, sample); pending.push(sc);
480
+ const tok = sampleToken(sc, keys.next()); sc.dispose();
481
+ tokens.push(tok);
482
+ prevIdx = ops.fromInt32([tok], [1]); ownPrevIdx = true;
483
+ } else {
484
+ const am = ops.argmaxAxis(logitsK, -1); // [1] uint32, ON-DEVICE — no itemUint32 here
485
+ tokArrs.push(am);
486
+ prevIdx = am; ownPrevIdx = false; // aliases tokArrs[k] — that collection owns it
487
+ }
488
+ if (collectLogits) perPos.push(ops.reshape(logitsK, [1, 1, V]));
489
+ logitsK.dispose();
490
+ const hkS = block.slice([0, k, 0], [1, k + 1, dDraft]); pending.push(hkS);
491
+ const hk = ops.reshape(hkS, [1, dDraft]); pending.push(hk);
492
+ hkS.dispose();
493
+ const ci = ops.concatAxis([hk, e1], 1); pending.push(ci);
494
+ hk.dispose(); e1.dispose();
495
+ let cz = ops.matmul(ci, this.get("conf.w")); pending.push(cz);
496
+ ci.dispose();
497
+ cz = disposing(cz, ops.add(cz, this.get("conf.b"))); pending.push(cz);
498
+ cz = disposing(cz, ops.sigmoid(cz)); pending.push(cz);
499
+
500
+ // Alg 1 (single-user): drop position k when its predicted acceptance
501
+ // is below the calibrated/manual threshold — the target then
502
+ // verifies a shorter window. Position 0 always survives (sources
503
+ // return ≥1 token). Pruning inherently needs the confidence value
504
+ // THIS position, on the host, to decide whether to stop — no way
505
+ // around that read when active. When inactive, defer it (device
506
+ // array, resolved once after the loop) since nothing this iteration
507
+ // depends on its value.
508
+ const thr = opts.thresholds?.[k] ?? opts.minConf;
509
+ if (pruningActive) {
510
+ const cVal = cz.toFloat32()[0]!; cz.dispose();
511
+ if (thr !== undefined && k > 0 && cVal < thr) {
512
+ // Drop position k SYMMETRICALLY on both paths so tokens/conf/
513
+ // draftLogits stay aligned (the DflashDraftBlock contract —
514
+ // verifySampling slices draftLogits per token). Greedy: pop the
515
+ // device array (prevIdx aliases it — one dispose via the owning
516
+ // collection). Sample: pop the host token + free the standalone
517
+ // fromInt32.
518
+ if (!sample) tokArrs.pop()!.dispose(); // also frees prevIdx (same array)
519
+ else { prevIdx.dispose(); tokens.pop(); }
520
+ ownPrevIdx = false; // dead either way — post-loop/catch must not re-dispose
521
+ if (collectLogits) perPos.pop()!.dispose(); // logits row for the dropped position
522
+ break;
523
+ }
524
+ conf.push(cVal);
525
+ } else {
526
+ confArrs.push(cz);
527
+ }
528
+ pending.length = 0; // everything above was disposed (or handed to a collection)
529
+ }
530
+ pending.length = 0; // early-break path: the broken iteration's temps are all freed too
531
+ if (ownPrevIdx) prevIdx.dispose(); // last iteration's fresh sample-path array, unused past the loop
532
+ s?.dispose(); // final on-device state (loop end or early break — both leave s live)
533
+ Uf.dispose(); block.dispose();
534
+
535
+ if (!sample) {
536
+ const allToks = ops.concatAxis(tokArrs, 0); // [nKept] uint32, ONE host read below
537
+ for (const a of tokArrs) a.dispose();
538
+ tokens = allToks.toIntTokens(); allToks.dispose();
539
+ }
540
+ if (!pruningActive && confArrs.length) {
541
+ const allConf = ops.concatAxis(confArrs, 0); // [nKept,1]
542
+ for (const a of confArrs) a.dispose();
543
+ const flat = allConf.toFloat32(); allConf.dispose();
544
+ for (const v of flat) conf.push(v);
545
+ }
546
+ const draftLogits = collectLogits ? ops.concatAxis(perPos, 1) : undefined;
547
+ if (collectLogits) for (const a of perPos) a.dispose();
548
+ return { tokens, conf, draftLogits };
549
+ } catch (err) {
550
+ // Cleanup on a mid-flight throw — the collections, the carried state,
551
+ // AND the current iteration's registered temps (`pending`). dispose()
552
+ // is idempotent (array.ts #disposed guard) so re-disposing an array the
553
+ // try block already freed (or that aliases one of these collections,
554
+ // e.g. prevIdx === tokArrs[k]) is inert, never a double-free bug.
555
+ for (const a of pending) a.dispose();
556
+ if (ownPrevIdx) prevIdx.dispose();
557
+ for (const a of tokArrs) a.dispose();
558
+ for (const a of confArrs) a.dispose();
559
+ for (const a of perPos) a.dispose();
560
+ s?.dispose();
561
+ Uf.dispose(); block.dispose();
562
+ throw err;
325
563
  }
326
- Uf.dispose(); block.dispose();
327
- const draftLogits = ops.concatAxis(perPos, 1); for (const a of perPos) a.dispose();
328
- return { tokens, conf, draftLogits };
329
564
  }
330
565
 
331
566
  save(dir: string): void {
@@ -333,7 +568,9 @@ export class DflashDrafter {
333
568
  const tensors: NamedTensor[] = this.names.map((name) => ({ name, array: this.get(name) }));
334
569
  writeShardedSafetensors(dir, tensors);
335
570
  writeFileSync(join(dir, "dspark.json"), JSON.stringify({
336
- kind: "dspark-drafter", variant: "dflash", version: 1,
571
+ // "dspark" is the canonical variant (this faithful module IS DSpark);
572
+ // load() accepts the legacy "dflash" stamp from older checkpoints.
573
+ kind: "dspark-drafter", variant: "dspark", version: 1,
337
574
  target_id: this.targetId, config: this.cfg, dims: this.dims, param_names: this.names,
338
575
  }, null, 2));
339
576
  }
@@ -341,7 +578,8 @@ export class DflashDrafter {
341
578
  const meta = JSON.parse(readFileSync(join(dir, "dspark.json"), "utf8")) as {
342
579
  variant?: string; config: DflashConfig; dims: TargetDims; target_id: string;
343
580
  };
344
- if (meta.variant !== "dflash") throw new Error(`${dir} is not a dflash drafter (variant=${meta.variant})`);
581
+ if (meta.variant !== "dspark" && meta.variant !== "dflash")
582
+ throw new Error(`${dir} is not a dspark drafter (variant=${meta.variant ?? "none — a v1 single-vector checkpoint?"})`);
345
583
  const d = new DflashDrafter(meta.config, meta.dims, meta.target_id);
346
584
  const tensors = loadAdapterTensors(join(dir, "model.safetensors"));
347
585
  try {
@@ -357,5 +595,9 @@ function buildNames(cfg: DflashConfig): string[] {
357
595
  for (let i = 0; i < cfg.nLayers; i++)
358
596
  names.push(`bb.${i}.attn_norm`, `bb.${i}.q`, `bb.${i}.k`, `bb.${i}.v`, `bb.${i}.o`, `bb.${i}.ff_norm`, `bb.${i}.gate`, `bb.${i}.up`, `bb.${i}.down`);
359
597
  names.push("W_c", "ctx_norm", "tok_proj", "mask_emb", "block_pos", "out_norm", "out_proj", "markov.w1", "markov.w2", "conf.w", "conf.b");
598
+ // rnn.* MUST be appended LAST — see the matching comment in initFromDims:
599
+ // this ordering keeps the shared/markov params drawing identical seeded rng
600
+ // values whether or not the rnn head is present (init-equivalence gate).
601
+ if (cfg.seqHead === "rnn") names.push("rnn.wH", "rnn.bH", "rnn.wO");
360
602
  return names;
361
603
  }