mlx-bun 0.0.8 → 0.0.10

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 (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -0,0 +1,193 @@
1
+ // Faithful DFlash decode loop — the draft attends to a GROWING multi-layer
2
+ // context H_ctx (the target's tapped hiddens over the accepted stream), per
3
+ // paper §3.1 / Eq 3 + §5.1 runtime. Parallel to generate.ts (v1 single-vector).
4
+ //
5
+ // Each verify pass is TAPPED (captures the m layers at the verified positions);
6
+ // after accepting kAccept drafts, the anchor + accepted tokens' m-layer hiddens
7
+ // are appended to H_ctx (rejected tips are never added — mirrors the KV trim).
8
+ // Losslessness is unchanged: greedy = longest-prefix match; temp>0 = min(1,p/q)
9
+ // + residual resample. H_ctx only affects WHICH tokens get drafted (τ), never
10
+ // what is emitted.
11
+
12
+ import { MlxArray } from "../../mlx/array";
13
+ import { Dtype } from "../../mlx/ffi";
14
+ import * as ops from "../../mlx/ops";
15
+ import { Gemma4Model } from "../../model/gemma4";
16
+ import type { DflashDrafter } from "./module-dflash";
17
+ import { KeyStream, processLogits, probsOf, probAtToken, sampleToken, sampleResidual, type DSparkSampleConfig } from "./sample";
18
+
19
+ export interface DflashGenOptions {
20
+ gamma?: number;
21
+ maxTokens?: number;
22
+ eosTokenIds?: number[];
23
+ sample?: DSparkSampleConfig;
24
+ }
25
+ export interface DflashStats {
26
+ emitted: number; drafted: number; accepted: number; targetCalls: number;
27
+ decodeMs: number; prefillMs: number; acceptanceRate: number; meanAcceptLen: number; meanConf: number; decodeTps: number;
28
+ }
29
+ export interface DflashResult { tokens: number[]; stats: DflashStats }
30
+
31
+ /** Tapped forward: run the target over `ids`, returning the final post-norm
32
+ * hidden [1,L,H] AND the m-layer context [1,L,m*H] (tapLayers concatenated on
33
+ * the feature axis). */
34
+ 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;
37
+ let ctxML: MlxArray;
38
+ try {
39
+ finalH = model.forwardHidden(ids, caches);
40
+ const cap = model.hiddenTap.captured;
41
+ const perLayer = tapLayers.map((li) => { const a = cap.get(li); if (!a) throw new Error(`layer ${li} not captured`); return a; });
42
+ ctxML = ops.concatAxis(perLayer, 2); // [1,L,m*H]
43
+ for (const [, a] of cap) a.dispose();
44
+ } finally {
45
+ model.hiddenTap = null;
46
+ }
47
+ return { finalH, ctxML };
48
+ }
49
+
50
+ function sampleFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number, cfg: DSparkSampleConfig | null, keys: KeyStream): number {
51
+ const H = hidden.shape[2]!;
52
+ const hSl = hidden.slice([0, pos, 0], [1, pos + 1, H]);
53
+ const logits = model.logitsFromHidden(hSl); hSl.dispose();
54
+ const V = logits.shape[2]!;
55
+ const flat = ops.reshape(logits, [1, V]); logits.dispose();
56
+ let tok: number;
57
+ if (cfg) { const sc = processLogits(flat, cfg); tok = sampleToken(sc, keys.next()); sc.dispose(); }
58
+ else { const am = ops.argmaxAxis(flat, -1); tok = ops.itemUint32(am); am.dispose(); }
59
+ flat.dispose();
60
+ return tok;
61
+ }
62
+
63
+ function picksBatched(model: Gemma4Model, hidden: MlxArray, count: number): number[] {
64
+ const logits = model.logitsFromHidden(hidden);
65
+ const V = logits.shape[2]!;
66
+ const out: number[] = [];
67
+ for (let k = 0; k < count; k++) {
68
+ const sl = logits.slice([0, k, 0], [1, k + 1, V]);
69
+ const flat = ops.reshape(sl, [1, V]); sl.dispose();
70
+ const am = ops.argmaxAxis(flat, -1); flat.dispose();
71
+ out.push(ops.itemUint32(am)); am.dispose();
72
+ }
73
+ logits.dispose();
74
+ return out;
75
+ }
76
+
77
+ interface Verdict { kAccept: number; emit: number }
78
+
79
+ function verifyGreedy(model: Gemma4Model, vHidden: MlxArray, drafts: number[], gamma: number): Verdict {
80
+ const gt = picksBatched(model, vHidden, gamma + 1);
81
+ let k = 0; while (k < gamma && drafts[k] === gt[k]) k++;
82
+ return { kAccept: k, emit: gt[k]! };
83
+ }
84
+ function verifySampling(model: Gemma4Model, vHidden: MlxArray, draftLogits: MlxArray, drafts: number[], gamma: number, cfg: DSparkSampleConfig, keys: KeyStream): Verdict {
85
+ const V = draftLogits.shape[2]!;
86
+ const tl = model.logitsFromHidden(vHidden);
87
+ const us = ops.randomUniform([gamma], Dtype.float32, 0, 1, keys.next());
88
+ const u = us.toFloat32(); us.dispose();
89
+ const slice = (a: MlxArray, i: number) => { const s = a.slice([0, i, 0], [1, i + 1, V]); const f = ops.reshape(s, [1, V]); s.dispose(); return f; };
90
+ let verdict: Verdict | null = null;
91
+ for (let k = 0; k < gamma; k++) {
92
+ 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();
95
+ const pX = probAtToken(P, x), qX = probAtToken(Q, x);
96
+ if (u[k]! < Math.min(1, qX > 0 ? pX / qX : 1)) { P.dispose(); Q.dispose(); continue; }
97
+ const corr = sampleResidual(P, Q, keys.next()); P.dispose(); Q.dispose();
98
+ verdict = { kAccept: k, emit: corr }; break;
99
+ }
100
+ if (!verdict) { const bs = processLogits(slice(tl, gamma), cfg); verdict = { kAccept: gamma, emit: sampleToken(bs, keys.next()) }; bs.dispose(); }
101
+ tl.dispose();
102
+ return verdict;
103
+ }
104
+
105
+ export function dflashGenerate(model: Gemma4Model, drafter: DflashDrafter, promptTokens: number[], options: DflashGenOptions = {}): DflashResult {
106
+ const gamma = options.gamma ?? drafter.cfg.gamma;
107
+ const { maxTokens = 256, eosTokenIds = model.config.eosTokenIds } = options;
108
+ const sampleCfg = options.sample && options.sample.temperature > 0 ? options.sample : null;
109
+ const keys = new KeyStream(options.sample?.seed ?? 0);
110
+ const tapLayers = drafter.cfg.tapLayers;
111
+
112
+ const caches = model.makeCache();
113
+ const stats: DflashStats = { emitted: 0, drafted: 0, accepted: 0, targetCalls: 0, decodeMs: 0, prefillMs: 0, acceptanceRate: 0, meanAcceptLen: 0, meanConf: 0, decodeTps: 0 };
114
+ const out: number[] = [];
115
+ let confSum = 0, rounds = 0, acceptLenSum = 0;
116
+ let H_ctx: MlxArray | null = null;
117
+
118
+ try {
119
+ // 1. prefill (tapped): seed H_ctx with the prompt's m-layer hiddens.
120
+ const t0 = performance.now();
121
+ const ids = ops.fromInt32(promptTokens, [1, promptTokens.length]);
122
+ const { finalH, ctxML } = forwardTapped(model, ids, caches, tapLayers);
123
+ ids.dispose();
124
+ stats.targetCalls++;
125
+ H_ctx = ctxML; // [1, Lp, m*H]
126
+ const Lp = finalH.shape[1]!;
127
+ let next = sampleFromHidden(model, finalH, Lp - 1, sampleCfg, keys);
128
+ finalH.dispose();
129
+ stats.prefillMs = performance.now() - t0;
130
+
131
+ if (eosTokenIds.includes(next)) return finalize(stats, out, rounds, acceptLenSum, confSum);
132
+ out.push(next); stats.emitted++;
133
+
134
+ const tDecode = performance.now();
135
+ 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 });
138
+ const drafts = block.tokens;
139
+ stats.drafted += gamma;
140
+ for (const c of block.conf) confSum += c;
141
+
142
+ // 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);
145
+ vIds.dispose();
146
+ stats.targetCalls++;
147
+
148
+ const { kAccept, emit } = sampleCfg
149
+ ? verifySampling(model, vHidden, block.draftLogits, drafts, gamma, sampleCfg, keys)
150
+ : verifyGreedy(model, vHidden, drafts, gamma);
151
+ block.draftLogits.dispose();
152
+ stats.accepted += kAccept; rounds++; acceptLenSum += kAccept + 1;
153
+
154
+ // 2c. emit accepted drafts
155
+ for (let k = 0; k < kAccept; k++) {
156
+ const isEos = eosTokenIds.includes(drafts[k]!);
157
+ if (!isEos) out.push(drafts[k]!);
158
+ stats.emitted++;
159
+ if (isEos || stats.emitted >= maxTokens) { vHidden.dispose(); vCtxML.dispose(); break outer; }
160
+ }
161
+ const emitIsEos = eosTokenIds.includes(emit);
162
+ if (!emitIsEos) out.push(emit);
163
+ stats.emitted++;
164
+
165
+ // 2d. append accepted stream's m-layer hiddens (anchor + kAccept drafts =
166
+ // vCtxML[:, 0..kAccept]) to H_ctx; never the rejected tips.
167
+ const add = vCtxML.slice([0, 0, 0], [1, kAccept + 1, vCtxML.shape[2]!]);
168
+ const grown = ops.concatAxis([H_ctx!, add], 1);
169
+ H_ctx!.dispose(); add.dispose(); vCtxML.dispose();
170
+ H_ctx = grown;
171
+
172
+ // 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); }
174
+
175
+ vHidden.dispose();
176
+ if (emitIsEos || stats.emitted >= maxTokens) break;
177
+ next = emit;
178
+ }
179
+ stats.decodeMs = performance.now() - tDecode;
180
+ } finally {
181
+ H_ctx?.dispose();
182
+ for (const c of caches) c.dispose();
183
+ }
184
+ return finalize(stats, out, rounds, acceptLenSum, confSum);
185
+ }
186
+
187
+ function finalize(stats: DflashStats, out: number[], rounds: number, acceptLenSum: number, confSum: number): DflashResult {
188
+ stats.acceptanceRate = stats.drafted ? stats.accepted / stats.drafted : 0;
189
+ stats.meanAcceptLen = rounds ? acceptLenSum / rounds : 0;
190
+ stats.meanConf = stats.drafted ? confSum / stats.drafted : 0;
191
+ stats.decodeTps = stats.decodeMs ? ((stats.emitted - 1) / stats.decodeMs) * 1000 : 0;
192
+ return { tokens: out, stats };
193
+ }
@@ -0,0 +1,290 @@
1
+ // DSpark speculative decode loop — a fork of src/spec/generate.ts
2
+ // (specGenerate). Same γ-draft / verify-in-one-pass / accept-longest-prefix /
3
+ // rollback spine, but the drafter is the trainable DSpark module: one PARALLEL
4
+ // backbone pass + a cheap SEQUENTIAL Markov head produce the γ-block, instead
5
+ // of γ sequential KV-borrowing drafter calls. DSpark needs only the target's
6
+ // final hidden as context (the tap), so the donor-K/V plumbing is gone.
7
+ //
8
+ // Two lossless verify modes — both leave the OUTPUT DISTRIBUTION exactly equal
9
+ // to vanilla e4b decode; the drafter only moves τ (speed), never WHAT is
10
+ // emitted:
11
+ // - GREEDY (temperature 0): accept a draft iff it equals the target's argmax
12
+ // (longest-prefix). Bit-identical to vanilla greedy e4b.
13
+ // - SAMPLING (temperature > 0): the speculative-sampling rule — accept x_k
14
+ // w.p. min(1, p_k(x_k)/q_k(x_k)); on rejection resample from the residual
15
+ // norm(relu(p−q)) and stop; if the whole block is accepted, sample a bonus
16
+ // from p_{γ+1}. p and q use the SAME top-p/top-k/temperature processing
17
+ // (sample.ts), so the emitted stream is distributed exactly as if e4b
18
+ // sampled token-by-token at that temperature — independent of draft quality.
19
+ //
20
+ // Verify the corresponding gate before chasing τ (scripts/dspark-ab.ts): greedy
21
+ // output must match model.generate; the sampling rule's correctness is the
22
+ // statistical "emit distribution == target" check in scripts/dspark-smoke.ts.
23
+ // If a gate fails, the accept/reject or KV rollback is wrong — fix that first.
24
+
25
+ import { MlxArray } from "../../mlx/array";
26
+ import { Dtype } from "../../mlx/ffi";
27
+ import * as ops from "../../mlx/ops";
28
+ import { Gemma4Model } from "../../model/gemma4";
29
+ import type { DSparkDrafter } from "./module";
30
+ import { KeyStream, processLogits, probsOf, probAtToken, sampleToken, sampleResidual, type DSparkSampleConfig } from "./sample";
31
+
32
+ export interface DSparkOptions {
33
+ gamma?: number;
34
+ maxTokens?: number;
35
+ eosTokenIds?: number[];
36
+ /** Sampling config; omit (or temperature 0) ⇒ greedy. */
37
+ sample?: DSparkSampleConfig;
38
+ }
39
+
40
+ export interface DSparkStats {
41
+ emitted: number;
42
+ drafted: number;
43
+ accepted: number;
44
+ targetCalls: number;
45
+ decodeMs: number;
46
+ prefillMs: number;
47
+ acceptanceRate: number;
48
+ /** Mean accepted length per verify round (τ), incl. the bonus/correction token. */
49
+ meanAcceptLen: number;
50
+ /** Mean predicted confidence over drafted tokens (diagnostic). */
51
+ meanConf: number;
52
+ decodeTps: number;
53
+ }
54
+
55
+ export interface DSparkResult {
56
+ tokens: number[];
57
+ stats: DSparkStats;
58
+ }
59
+
60
+ /** Greedy pick from one hidden position [1,1,H]. */
61
+ function pickFromHidden(model: Gemma4Model, hidden: MlxArray, pos: number): number {
62
+ const H = hidden.shape[2]!;
63
+ const hSl = hidden.slice([0, pos, 0], [1, pos + 1, H]);
64
+ const logits = model.logitsFromHidden(hSl);
65
+ hSl.dispose();
66
+ const V = logits.shape[2]!;
67
+ const flat = ops.reshape(logits, [1, V]);
68
+ logits.dispose();
69
+ const am = ops.argmaxAxis(flat, -1);
70
+ flat.dispose();
71
+ const t = ops.itemUint32(am);
72
+ am.dispose();
73
+ return t;
74
+ }
75
+
76
+ /** Batched verify: one lm-head over the γ+1 window, argmax per position. */
77
+ function picksBatched(model: Gemma4Model, hidden: MlxArray, count: number): number[] {
78
+ const logits = model.logitsFromHidden(hidden); // [1, L, V]
79
+ const V = logits.shape[2]!;
80
+ const out: number[] = [];
81
+ for (let k = 0; k < count; k++) {
82
+ const sl = logits.slice([0, k, 0], [1, k + 1, V]);
83
+ const flat = ops.reshape(sl, [1, V]);
84
+ sl.dispose();
85
+ const am = ops.argmaxAxis(flat, -1);
86
+ flat.dispose();
87
+ out.push(ops.itemUint32(am));
88
+ am.dispose();
89
+ }
90
+ logits.dispose();
91
+ return out;
92
+ }
93
+
94
+ /** Result of one verify round: how many leading drafts were accepted, and the
95
+ * correction/bonus token to emit after them. */
96
+ interface RoundVerdict { kAccept: number; emit: number }
97
+
98
+ /** GREEDY verify: longest prefix where draft == target argmax. */
99
+ function verifyGreedy(model: Gemma4Model, vHidden: MlxArray, drafts: number[], gamma: number): RoundVerdict {
100
+ const gt = picksBatched(model, vHidden, gamma + 1);
101
+ let kAccept = 0;
102
+ while (kAccept < gamma && drafts[kAccept] === gt[kAccept]) kAccept++;
103
+ return { kAccept, emit: gt[kAccept]! };
104
+ }
105
+
106
+ /** SAMPLING verify (speculative sampling): accept x_k w.p. min(1, p/q); on the
107
+ * first rejection resample from norm(relu(p−q)); if all accepted sample a bonus
108
+ * from p_{γ+1}. p,q processed identically (top-p/top-k/temperature). */
109
+ function verifySampling(
110
+ model: Gemma4Model, vHidden: MlxArray, draftLogits: MlxArray, drafts: number[],
111
+ gamma: number, cfg: DSparkSampleConfig, keys: KeyStream,
112
+ ): RoundVerdict {
113
+ const V = draftLogits.shape[2]!;
114
+ const targetLogits = model.logitsFromHidden(vHidden); // [1,γ+1,V]
115
+ // γ uniforms for the accept tests, drawn together.
116
+ const uArr = ops.randomUniform([gamma], Dtype.float32, 0, 1, keys.next());
117
+ const us = uArr.toFloat32();
118
+ uArr.dispose();
119
+
120
+ let verdict: RoundVerdict | null = null;
121
+ for (let k = 0; k < gamma; k++) {
122
+ const x = drafts[k]!;
123
+ const tScaled = processLogits(sliceTmp(targetLogits, k, V), cfg);
124
+ const P = probsOf(tScaled);
125
+ const dScaled = processLogits(sliceTmp(draftLogits, k, V), cfg);
126
+ const Q = probsOf(dScaled);
127
+ tScaled.dispose(); dScaled.dispose();
128
+ const pX = probAtToken(P, x);
129
+ const qX = probAtToken(Q, x);
130
+ const accept = us[k]! < Math.min(1, qX > 0 ? pX / qX : 1);
131
+ if (accept) {
132
+ P.dispose(); Q.dispose();
133
+ continue;
134
+ }
135
+ // reject at k → resample correction from the residual, stop.
136
+ const corrected = sampleResidual(P, Q, keys.next());
137
+ P.dispose(); Q.dispose();
138
+ verdict = { kAccept: k, emit: corrected };
139
+ break;
140
+ }
141
+ if (!verdict) {
142
+ // all γ accepted → bonus from p_{γ+1}
143
+ const bScaled = processLogits(sliceTmp(targetLogits, gamma, V), cfg);
144
+ const bonus = sampleToken(bScaled, keys.next());
145
+ bScaled.dispose();
146
+ verdict = { kAccept: gamma, emit: bonus };
147
+ }
148
+ targetLogits.dispose();
149
+ return verdict;
150
+ }
151
+
152
+ /** [1,L,V] → [1,V] at position k (a fresh owned array). */
153
+ function sliceTmp(logits: MlxArray, k: number, V: number): MlxArray {
154
+ const sl = logits.slice([0, k, 0], [1, k + 1, V]);
155
+ const flat = ops.reshape(sl, [1, V]);
156
+ sl.dispose();
157
+ return flat;
158
+ }
159
+
160
+ export function dsparkGenerate(
161
+ model: Gemma4Model,
162
+ drafter: DSparkDrafter,
163
+ promptTokens: number[],
164
+ options: DSparkOptions = {},
165
+ ): DSparkResult {
166
+ const gamma = options.gamma ?? drafter.cfg.gamma;
167
+ const { maxTokens = 256, eosTokenIds = model.config.eosTokenIds } = options;
168
+ if (gamma < 1) throw new Error("gamma must be >= 1");
169
+ if (gamma > drafter.cfg.gamma)
170
+ throw new Error(`gamma ${gamma} exceeds drafter max ${drafter.cfg.gamma}`);
171
+ const sampleCfg = options.sample && options.sample.temperature > 0 ? options.sample : null;
172
+ const keys = new KeyStream(options.sample?.seed ?? 0);
173
+
174
+ const caches = model.makeCache();
175
+ const stats: DSparkStats = {
176
+ emitted: 0, drafted: 0, accepted: 0, targetCalls: 0,
177
+ decodeMs: 0, prefillMs: 0, acceptanceRate: 0,
178
+ meanAcceptLen: 0, meanConf: 0, decodeTps: 0,
179
+ };
180
+ const out: number[] = [];
181
+ let confSum = 0;
182
+ let rounds = 0;
183
+ let acceptLenSum = 0;
184
+
185
+ try {
186
+ // 1. prefill → first token. For sampling, the first token must also be
187
+ // sampled from the target dist (not argmax) to stay lossless.
188
+ const t0 = performance.now();
189
+ const ids = ops.fromInt32(promptTokens, [1, promptTokens.length]);
190
+ const hidden = model.forwardHidden(ids, caches);
191
+ ids.dispose();
192
+ stats.targetCalls++;
193
+ const H = hidden.shape[2]!;
194
+ const Lp = hidden.shape[1]!;
195
+ let next: number;
196
+ if (sampleCfg) {
197
+ const hSl = hidden.slice([0, Lp - 1, 0], [1, Lp, H]);
198
+ const logits = model.logitsFromHidden(hSl);
199
+ hSl.dispose();
200
+ const flat = ops.reshape(logits, [1, logits.shape[2]!]);
201
+ logits.dispose();
202
+ const scaled = processLogits(flat, sampleCfg);
203
+ flat.dispose();
204
+ next = sampleToken(scaled, keys.next());
205
+ scaled.dispose();
206
+ } else {
207
+ next = pickFromHidden(model, hidden, Lp - 1);
208
+ }
209
+ let lastHidden = hidden.slice([0, Lp - 1, 0], [1, Lp, H]); // [1,1,H]
210
+ hidden.dispose();
211
+ stats.prefillMs = performance.now() - t0;
212
+
213
+ const nextIsEos = eosTokenIds.includes(next);
214
+ if (!nextIsEos) out.push(next);
215
+ stats.emitted++;
216
+ if (nextIsEos) { lastHidden.dispose(); return finalize(stats, out, rounds, acceptLenSum, confSum); }
217
+
218
+ // 2. outer loop
219
+ const tDecode = performance.now();
220
+ outer: while (stats.emitted < maxTokens) {
221
+ // 2a. draft the γ-block: one backbone pass + sequential Markov head
222
+ const hCtx = ops.reshape(lastHidden, [1, H]); // [1,H]
223
+ const block = drafter.forwardInfer(model, hCtx, next, gamma, { sample: sampleCfg ?? undefined, keys });
224
+ hCtx.dispose();
225
+ const drafts = block.tokens;
226
+ stats.drafted += gamma;
227
+ for (const c of block.conf) confSum += c;
228
+
229
+ // 2b. verify all γ drafts (+ the pending token) in one forward
230
+ const vIds = ops.fromInt32([next, ...drafts], [1, gamma + 1]);
231
+ const vHidden = model.forwardHidden(vIds, caches);
232
+ vIds.dispose();
233
+ stats.targetCalls++;
234
+
235
+ // 2c. accept (greedy longest-prefix OR speculative sampling)
236
+ const { kAccept, emit } = sampleCfg
237
+ ? verifySampling(model, vHidden, block.draftLogits, drafts, gamma, sampleCfg, keys)
238
+ : verifyGreedy(model, vHidden, drafts, gamma);
239
+ block.draftLogits.dispose();
240
+ stats.accepted += kAccept;
241
+ rounds++;
242
+ acceptLenSum += kAccept + 1; // accepted drafts + bonus/correction token
243
+
244
+ // 2d. emit accepted drafts
245
+ for (let k = 0; k < kAccept; k++) {
246
+ const isEos = eosTokenIds.includes(drafts[k]!);
247
+ if (!isEos) out.push(drafts[k]!);
248
+ stats.emitted++;
249
+ if (isEos || stats.emitted >= maxTokens) { vHidden.dispose(); break outer; }
250
+ }
251
+
252
+ // 2e. emit the correction (or bonus when all accepted)
253
+ const emitIsEos = eosTokenIds.includes(emit);
254
+ if (!emitIsEos) out.push(emit);
255
+ stats.emitted++;
256
+
257
+ // 2f. roll back rejected entries. bypass=true: we always trim the last
258
+ // n = gamma - kAccept (≤ γ) tips immediately after the verify concat
259
+ // write, which is the one case where trimming past the sliding window is
260
+ // sound (the newest tokens are intact at the buffer tail). See
261
+ // RotatingKVCache.trim.
262
+ if (kAccept < gamma) {
263
+ const n = gamma - kAccept;
264
+ for (const c of caches) c.trim(n, true);
265
+ }
266
+
267
+ if (emitIsEos || stats.emitted >= maxTokens) { vHidden.dispose(); break; }
268
+
269
+ // 2g. chain state
270
+ next = emit;
271
+ lastHidden.dispose();
272
+ lastHidden = vHidden.slice([0, kAccept, 0], [1, kAccept + 1, H]);
273
+ vHidden.dispose();
274
+ }
275
+ stats.decodeMs = performance.now() - tDecode;
276
+ lastHidden.dispose();
277
+ } finally {
278
+ for (const c of caches) c.dispose();
279
+ }
280
+
281
+ return finalize(stats, out, rounds, acceptLenSum, confSum);
282
+ }
283
+
284
+ function finalize(stats: DSparkStats, out: number[], rounds: number, acceptLenSum: number, confSum: number): DSparkResult {
285
+ stats.acceptanceRate = stats.drafted ? stats.accepted / stats.drafted : 0;
286
+ stats.meanAcceptLen = rounds ? acceptLenSum / rounds : 0;
287
+ stats.meanConf = stats.drafted ? confSum / stats.drafted : 0;
288
+ stats.decodeTps = stats.decodeMs ? ((stats.emitted - 1) / stats.decodeMs) * 1000 : 0;
289
+ return { tokens: out, stats };
290
+ }
@@ -0,0 +1,213 @@
1
+ // DSpark training objective (paper §3.3, Eq 9–12).
2
+ //
3
+ // L = α_ce·L_ce + α_tv·L_tv + α_conf·L_conf (defaults 0.1 / 0.9 / 1.0)
4
+ //
5
+ // all three position-weighted by w_k = exp(−(k−1)/γ) (emphasizes earlier
6
+ // block positions, which contribute most to prefix-based accepted length):
7
+ //
8
+ // L_ce = −Σ_k w_k · log p^d_k(x*_k) (Eq 9)
9
+ // L_tv = Σ_k w_k · ‖p^d_k − p^t_k‖₁ (Eq 10)
10
+ // L_conf = −Σ_k w_k · [ c*_k·log c_k + (1−c*_k)·log(1−c_k) ] (Eq 11)
11
+ //
12
+ // where the analytic acceptance label c*_k = 1 − ½‖p^d_k − p^t_k‖₁ (Eq 8) is
13
+ // STOP-GRADIENT'd — it supervises the confidence head without leaking BCE
14
+ // gradient back into the draft distribution (L_tv already drives p^d→p^t).
15
+ //
16
+ // p^t is the FROZEN target's true distribution at each block position; the
17
+ // caller computes targetLogits = model.logitsFromHidden(targetHidden) (same
18
+ // head DSpark drafts with) and passes it as a constant. M = A·γ is small
19
+ // (γ≈5–7), so plain MLX materialization is fine; a fused TV kernel (the
20
+ // flash-cce vocab-blocked pattern) is only worth porting if measurement
21
+ // shows this term bottlenecks — it does not at these sizes.
22
+
23
+ import { MlxArray } from "../../mlx/array";
24
+ import * as ops from "../../mlx/ops";
25
+ import type { DSparkTrainOut } from "./module";
26
+
27
+ export interface LossWeights {
28
+ ce: number;
29
+ tv: number;
30
+ conf: number;
31
+ }
32
+
33
+ export const DEFAULT_LOSS_WEIGHTS: LossWeights = { ce: 0.1, tv: 0.9, conf: 1.0 };
34
+
35
+ /** Position weights w_k = exp(−(k−1)/γ) as a [1, γ] float32 row. */
36
+ export function positionWeights(gamma: number): MlxArray {
37
+ const w = new Float32Array(gamma);
38
+ for (let j = 0; j < gamma; j++) w[j] = Math.exp(-j / gamma);
39
+ return MlxArray.fromFloat32(w, [1, gamma]).eval();
40
+ }
41
+
42
+ /** mean_A( Σ_k w_k · perPos[A,k] ) → scalar. */
43
+ function weightedReduce(perPos: MlxArray, w: MlxArray): MlxArray {
44
+ const A = perPos.shape[0]!;
45
+ const weighted = ops.mul(perPos, w); // [A,γ] · [1,γ] broadcast
46
+ const overK = ops.sumAxis(weighted, 1, false); // [A]
47
+ weighted.dispose();
48
+ const summed = ops.sumAxis(overK, 0, false); // scalar
49
+ overK.dispose();
50
+ const out = ops.mulScalar(summed, 1 / A);
51
+ summed.dispose();
52
+ return out;
53
+ }
54
+
55
+ /** Per-position L1 distance ‖p^d − p^t‖₁ → [A, γ]. Caller owns the result. */
56
+ function l1PerPos(pd: MlxArray, pt: MlxArray): MlxArray {
57
+ const diff = ops.sub(pd, pt);
58
+ const ad = ops.abs(diff);
59
+ diff.dispose();
60
+ const l1 = ops.sumAxis(ad, 2, false); // [A,γ]
61
+ ad.dispose();
62
+ return l1;
63
+ }
64
+
65
+ /**
66
+ * Full DSpark loss. Returns the scalar to differentiate plus the three term
67
+ * scalars (for logging — they share the graph; read them with .toFloat32()
68
+ * AFTER the backward, or eval them alongside the value).
69
+ *
70
+ * - out.draftLogits [A,γ,V] (U+B, pre-softmax, float32)
71
+ * - out.conf [A,γ] (sigmoid output ∈ (0,1))
72
+ * - targetLogits [A,γ,V] (frozen target logits; constant)
73
+ * - xStar [A,γ] int32 ground-truth next tokens
74
+ */
75
+ export function dsparkLoss(
76
+ out: DSparkTrainOut,
77
+ targetLogits: MlxArray,
78
+ xStar: MlxArray,
79
+ gamma: number,
80
+ w: MlxArray,
81
+ weights: LossWeights = DEFAULT_LOSS_WEIGHTS,
82
+ ): { loss: MlxArray; ce: MlxArray; tv: MlxArray; conf: MlxArray } {
83
+ const dl = out.draftLogits; // [A,γ,V]
84
+ const A = dl.shape[0]!;
85
+
86
+ const pd = ops.softmaxAxis(dl, 2, true); // [A,γ,V]
87
+ const ptRaw = ops.softmaxAxis(targetLogits, 2, true);
88
+ const pt = ops.stopGradient(ptRaw); // target is constant
89
+ ptRaw.dispose();
90
+
91
+ // --- L_ce: −log p^d(x*) ---
92
+ const lse = ops.logsumexpAxis(dl, 2, true); // [A,γ,1]
93
+ const logp = ops.sub(dl, lse); // [A,γ,V]
94
+ lse.dispose();
95
+ const xsIdx = ops.expandDims(xStar, 2); // [A,γ,1] int32
96
+ const logpStar = ops.takeAlongAxis(logp, xsIdx, 2); // [A,γ,1]
97
+ logp.dispose(); xsIdx.dispose();
98
+ const cePos0 = ops.reshape(logpStar, [A, gamma]); // [A,γ]
99
+ logpStar.dispose();
100
+ const cePos = ops.neg(cePos0); // −log p^d(x*)
101
+ cePos0.dispose();
102
+ const ceTerm = weightedReduce(cePos, w);
103
+ cePos.dispose();
104
+
105
+ // --- L1 / TV / c* (share the L1) ---
106
+ const l1 = l1PerPos(pd, pt); // [A,γ]
107
+ pd.dispose(); pt.dispose();
108
+ const tvTerm = weightedReduce(l1, w);
109
+
110
+ // c*_k = 1 − ½·l1, stop-gradient (supervision label)
111
+ const half = ops.mulScalar(l1, 0.5);
112
+ const cStarRaw = rsubScalar(half, 1.0); // 1 − ½l1
113
+ half.dispose();
114
+ const cStar = ops.stopGradient(cStarRaw);
115
+ cStarRaw.dispose();
116
+ l1.dispose();
117
+
118
+ // --- L_conf: BCE(c, c*) ---
119
+ const eps = 1e-6;
120
+ const c = clampUnit(out.conf, eps);
121
+ const omc = rsubScalar(c, 1.0); // 1 − c
122
+ const logc = ops.log(c);
123
+ const log1mc = ops.log(omc);
124
+ c.dispose(); omc.dispose();
125
+ const t1 = ops.mul(cStar, logc); // c*·log c
126
+ const omcStar = rsubScalar(cStar, 1.0); // 1 − c*
127
+ cStar.dispose(); logc.dispose();
128
+ const t2 = ops.mul(omcStar, log1mc); // (1−c*)·log(1−c)
129
+ omcStar.dispose(); log1mc.dispose();
130
+ const bcePos0 = ops.add(t1, t2);
131
+ t1.dispose(); t2.dispose();
132
+ const bcePos = ops.neg(bcePos0);
133
+ bcePos0.dispose();
134
+ const confTerm = weightedReduce(bcePos, w);
135
+ bcePos.dispose();
136
+
137
+ // --- weighted sum ---
138
+ const a = ops.mulScalar(ceTerm, weights.ce);
139
+ const b = ops.mulScalar(tvTerm, weights.tv);
140
+ const cc = ops.mulScalar(confTerm, weights.conf);
141
+ const ab = ops.add(a, b); a.dispose(); b.dispose();
142
+ const loss = ops.add(ab, cc); ab.dispose(); cc.dispose();
143
+ return { loss, ce: ceTerm, tv: tvTerm, conf: confTerm };
144
+ }
145
+
146
+ /**
147
+ * Held-out monitoring (NO gradient): analytic per-position acceptance
148
+ * a_k = 1 − ½‖p^d_k − p^t_k‖₁ and the expected accepted length
149
+ * τ = 1 + Σ_{j=1}^{γ} Π_{i≤j} a_i (the +1 is the target bonus token). This is
150
+ * the cheap proxy to early-stop on — τ is the number that matters, not loss.
151
+ * Returns means over the batch.
152
+ */
153
+ export function analyticAcceptance(
154
+ out: DSparkTrainOut,
155
+ targetLogits: MlxArray,
156
+ ): { perPos: number[]; tau: number } {
157
+ const dl = out.draftLogits;
158
+ const A = dl.shape[0]!;
159
+ const gamma = dl.shape[1]!;
160
+ const pd = ops.softmaxAxis(dl, 2, true);
161
+ const pt = ops.softmaxAxis(targetLogits, 2, true);
162
+ const l1 = l1PerPos(pd, pt); // [A,γ]
163
+ pd.dispose(); pt.dispose();
164
+ const half = ops.mulScalar(l1, 0.5);
165
+ l1.dispose();
166
+ const aMat = rsubScalar(half, 1.0); // [A,γ] = a_k per anchor
167
+ half.dispose();
168
+ const flat = aMat.toFloat32(); // [A*γ]
169
+ aMat.dispose();
170
+
171
+ const perPos: number[] = new Array(gamma).fill(0);
172
+ let tauSum = 0;
173
+ for (let row = 0; row < A; row++) {
174
+ let cum = 1;
175
+ let tau = 1; // bonus token
176
+ for (let j = 0; j < gamma; j++) {
177
+ const a = flat[row * gamma + j]!;
178
+ perPos[j]! += a;
179
+ cum *= a;
180
+ tau += cum;
181
+ }
182
+ tauSum += tau;
183
+ }
184
+ for (let j = 0; j < gamma; j++) perPos[j]! /= A;
185
+ return { perPos, tau: tauSum / A };
186
+ }
187
+
188
+ // --- small scalar helpers (no direct rsub/clamp binding) ---------------
189
+
190
+ /** value − a, elementwise (value + (−a)). */
191
+ function rsubScalar(a: MlxArray, value: number): MlxArray {
192
+ const neg = ops.neg(a);
193
+ const res = addScalarF32(neg, value);
194
+ neg.dispose();
195
+ return res;
196
+ }
197
+
198
+ /** a + value (float32). */
199
+ function addScalarF32(a: MlxArray, value: number): MlxArray {
200
+ const s = MlxArray.fromFloat32(new Float32Array([value]), [1]);
201
+ const out = ops.add(a, s);
202
+ s.dispose();
203
+ return out;
204
+ }
205
+
206
+ /** clamp x into [eps, 1−eps]. */
207
+ function clampUnit(x: MlxArray, eps: number): MlxArray {
208
+ const lo = MlxArray.fromFloat32(new Float32Array([eps]), [1]);
209
+ const hi = MlxArray.fromFloat32(new Float32Array([1 - eps]), [1]);
210
+ const out = ops.clip(x, lo, hi);
211
+ lo.dispose(); hi.dispose();
212
+ return out;
213
+ }