mlx-bun 0.0.11 → 0.0.13

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 (82) hide show
  1. package/README.md +63 -27
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1486 -0
  4. package/docs/design/pre-colibri-stabilization.md +329 -0
  5. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  6. package/docs/investigations/indexshare-performance-spike.md +357 -0
  7. package/docs/planning/release-notes-v0.0.12.md +80 -0
  8. package/docs/planning/release-notes-v0.0.13.md +77 -0
  9. package/docs/reference/cli.md +28 -2
  10. package/docs/reference/distribution.md +25 -1
  11. package/docs/reference/embedding.md +5 -2
  12. package/docs/reference/features-matrix.md +5 -2
  13. package/docs/reference/library-api.md +41 -13
  14. package/docs/reference/models.md +49 -2
  15. package/docs/reference/server-api.md +113 -20
  16. package/docs/reference/server-config.md +21 -14
  17. package/docs/reference/training.md +10 -1
  18. package/package.json +1 -1
  19. package/src/anthropic.ts +54 -44
  20. package/src/chat-template.ts +189 -11
  21. package/src/cli.ts +127 -8
  22. package/src/config.ts +81 -26
  23. package/src/diffusion/diffusion-generate.ts +14 -8
  24. package/src/download.ts +103 -12
  25. package/src/eval/ifeval.ts +63 -120
  26. package/src/eval/runner.ts +14 -5
  27. package/src/eval/tasks/ifeval.ts +167 -31
  28. package/src/expert-io.ts +391 -0
  29. package/src/expert-residency.ts +1134 -0
  30. package/src/expert-usage.ts +415 -0
  31. package/src/generate.ts +114 -17
  32. package/src/hf-push.ts +1 -1
  33. package/src/index.ts +14 -2
  34. package/src/jobs/runner.ts +37 -18
  35. package/src/kv-store.ts +147 -8
  36. package/src/lora.ts +77 -14
  37. package/src/media-fetch.ts +198 -56
  38. package/src/mlx/array.ts +43 -0
  39. package/src/mlx/autograd.ts +51 -14
  40. package/src/mlx/checkpoint.ts +46 -13
  41. package/src/mlx/custom-vjp.ts +83 -35
  42. package/src/model/factory.ts +96 -5
  43. package/src/model/gemma4-base.ts +32 -0
  44. package/src/model/glm52-atlas.ts +511 -0
  45. package/src/model/glm52-cache.ts +706 -0
  46. package/src/model/glm52-config.ts +262 -0
  47. package/src/model/glm52-container.ts +305 -0
  48. package/src/model/glm52-coupling.ts +388 -0
  49. package/src/model/glm52-dsa.ts +602 -0
  50. package/src/model/glm52-expert-layout.ts +253 -0
  51. package/src/model/glm52-layout.ts +152 -0
  52. package/src/model/glm52-memory.ts +473 -0
  53. package/src/model/glm52-mla.ts +786 -0
  54. package/src/model/glm52-moe.ts +223 -0
  55. package/src/model/glm52-pilot.ts +537 -0
  56. package/src/model/glm52-reference.ts +445 -0
  57. package/src/model/glm52-residency.ts +557 -0
  58. package/src/model/glm52-resident-weights.ts +135 -0
  59. package/src/model/glm52-streamed-experts.ts +453 -0
  60. package/src/model/glm52-streamed-metal.ts +610 -0
  61. package/src/model/glm52-weights.ts +503 -0
  62. package/src/model/glm52.ts +1153 -0
  63. package/src/model/support.ts +8 -1
  64. package/src/native/expert_io.c +523 -0
  65. package/src/native-pack.ts +6 -4
  66. package/src/responses.ts +62 -49
  67. package/src/serve/batch-scheduler.ts +95 -16
  68. package/src/serve/generation-gateway.ts +41 -16
  69. package/src/server.ts +471 -93
  70. package/src/spec/glm52-mtp-source.ts +392 -0
  71. package/src/spec/serve-loop.ts +99 -18
  72. package/src/spec/source.ts +31 -6
  73. package/src/ssd-cache.ts +6 -2
  74. package/src/tool-call.ts +30 -2
  75. package/src/train/job.ts +0 -10
  76. package/src/train/lora-params.ts +17 -6
  77. package/src/train/trainer.ts +59 -26
  78. package/src/web/app.html +13 -2
  79. package/src/web/app.js +30 -3
  80. package/src/web/src/status.ts +51 -3
  81. package/src/web-tools.ts +54 -2
  82. package/src/weights.ts +82 -19
@@ -0,0 +1,392 @@
1
+ // Native GLM-5.2 MTP draft source.
2
+ //
3
+ // The target and MTP row share embeddings, the output head, and the resident
4
+ // dense target weights. Only the MTP layer's own dense tensors and int8 routed
5
+ // experts add model weight residency. The generic speculative serve loop owns
6
+ // verification, grammar composition, target-cache rollback, sampling, and
7
+ // telemetry. Prompt lookup remains the model-free alternative DraftProvider;
8
+ // selecting one provider per request prevents two independent draft histories
9
+ // from advancing the target or grammar state twice.
10
+
11
+ import { MlxArray } from "../mlx/array";
12
+ import * as ops from "../mlx/ops";
13
+ import { toLogprobs } from "../sampler";
14
+ import { Glm52DecoderLayer, Glm52Model } from "../model/glm52";
15
+ import { MLACache } from "../model/glm52-cache";
16
+ import { rmsNormF32Mlx } from "../model/glm52-mla";
17
+ import type { DraftProvider, DraftSource } from "./source";
18
+
19
+ type Sampler = (logprobs: MlxArray, step: number) => MlxArray;
20
+
21
+ function lastToken(feed: readonly number[]): number {
22
+ const token = feed.at(-1);
23
+ if (!Number.isSafeInteger(token) || token! < 0)
24
+ throw new Error("native MTP requires a non-empty non-negative token feed");
25
+ return token!;
26
+ }
27
+
28
+ export class Glm52NativeMtpProvider implements DraftProvider {
29
+ readonly id = "glm52-native-mtp";
30
+ readonly weightsBytes = 0;
31
+ readonly #layer: Glm52DecoderLayer;
32
+
33
+ constructor(readonly model: Glm52Model) {
34
+ if (!model.capabilities.mtpMetadata)
35
+ throw new Error("GLM-5.2 artifact does not contain a complete MTP row");
36
+ if (model.capabilities.mtpEnabled === false)
37
+ throw new Error("native GLM-5.2 MTP is disabled for this model instance");
38
+ if (model.glmConfig.numNextnPredictLayers !== 1) {
39
+ throw new Error(
40
+ `native GLM-5.2 MTP requires exactly one next-token layer; got ` +
41
+ `${model.glmConfig.numNextnPredictLayers}`,
42
+ );
43
+ }
44
+ const mtpBackend = model.expertRuntime?.mtpExecutor ?? null;
45
+ if (model.expertBackend && !mtpBackend) {
46
+ throw new Error(
47
+ "streamed GLM-5.2 MTP requires the bounded int8 MTP expert tier",
48
+ );
49
+ }
50
+ this.#layer = new Glm52DecoderLayer(
51
+ model.glmConfig,
52
+ model.weights,
53
+ model.glmConfig.numHiddenLayers,
54
+ false,
55
+ mtpBackend,
56
+ );
57
+ }
58
+
59
+ open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
60
+ if (opts.target.model !== this.model)
61
+ throw new Error("native MTP provider was opened for a different target");
62
+ return new Glm52NativeMtpSource(this.model, this.#layer, opts.sampler);
63
+ }
64
+
65
+ dispose(): void {
66
+ // The target model owns every shared/MTP weight and the expert tier.
67
+ }
68
+ }
69
+
70
+ export class Glm52NativeMtpSource implements DraftSource {
71
+ readonly prefillMode = "full" as const;
72
+ readonly pinTargetKernelFamily = true;
73
+ readonly weightsBytes = 0;
74
+ #cache: MLACache;
75
+ #roundStart = 0;
76
+ #lastDraftCount = 0;
77
+ #closed = false;
78
+
79
+ constructor(
80
+ readonly model: Glm52Model,
81
+ readonly layer: Glm52DecoderLayer,
82
+ readonly sampler: Sampler,
83
+ ) {
84
+ this.#cache = new MLACache({
85
+ kvLoraRank: model.glmConfig.kvLoraRank,
86
+ ropeHeadDim: model.glmConfig.qkRopeHeadDim,
87
+ maxTokens: model.glmConfig.maxPositionEmbeddings,
88
+ role: "mtp",
89
+ });
90
+ }
91
+
92
+ get cacheOffset(): number {
93
+ return this.#cache.offset;
94
+ }
95
+
96
+ get cacheBytes(): number {
97
+ return this.#cache.byteLength;
98
+ }
99
+
100
+ /** Caller-owned zero-copy snapshot for the v3 KV persistence writer. */
101
+ clonePersistentCache(): MLACache {
102
+ this.#checkOpen();
103
+ if (this.#lastDraftCount !== 0)
104
+ throw new Error("native MTP cache cannot snapshot an uncommitted round");
105
+ if (this.#cache.offset === 0)
106
+ throw new Error("native MTP cache cannot snapshot empty state");
107
+ const state = this.#cache.fetch();
108
+ const clone = new MLACache({
109
+ kvLoraRank: this.#cache.kvLoraRank,
110
+ ropeHeadDim: this.#cache.ropeHeadDim,
111
+ maxTokens: this.#cache.maxTokens,
112
+ role: "mtp",
113
+ });
114
+ try {
115
+ clone.restoreCompressedState(state.latent, state.rope, null, this.#cache.offset);
116
+ return clone;
117
+ } catch (error) {
118
+ state.latent.dispose();
119
+ state.rope.dispose();
120
+ clone.dispose();
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Adopt one restored `mtp-mla` cache. Ownership transfers only after every
127
+ * role/geometry/offset check passes; the source disposes it thereafter.
128
+ */
129
+ restorePersistentCache(cache: MLACache): void {
130
+ this.#checkOpen();
131
+ if (this.#lastDraftCount !== 0)
132
+ throw new Error("native MTP cache cannot restore during a draft round");
133
+ if (this.#cache.offset !== 0)
134
+ throw new Error("native MTP cache restore requires an empty source");
135
+ if (cache.role !== "mtp" || cache.dsa)
136
+ throw new Error("native MTP restore requires one mtp-mla cache");
137
+ if (
138
+ cache.kvLoraRank !== this.#cache.kvLoraRank ||
139
+ cache.ropeHeadDim !== this.#cache.ropeHeadDim ||
140
+ cache.maxTokens !== this.#cache.maxTokens
141
+ ) {
142
+ throw new Error("native MTP restored cache geometry does not match model");
143
+ }
144
+ if (cache.offset <= 0 || cache.batchSize !== 1)
145
+ throw new Error("native MTP restored cache must contain one non-empty row");
146
+ this.#cache.dispose();
147
+ this.#cache = cache;
148
+ this.#roundStart = cache.offset;
149
+ }
150
+
151
+ prefill(_promptIds: number[]): void {
152
+ this.#checkOpen();
153
+ if (this.#cache.offset !== 0)
154
+ throw new Error("native MTP source cannot be prefilled twice");
155
+ // Direct Colibri intentionally leaves MTP KV empty here ("niente
156
+ // prefill"): its first draft opens a decode-only window from the target
157
+ // anchor hidden and pending token. Seeding this cache with the prompt
158
+ // would change both the oracle state and the first speculative round.
159
+ }
160
+
161
+ async draft(
162
+ feed: number[],
163
+ n: number,
164
+ stepBase: number,
165
+ anchorHidden?: MlxArray,
166
+ ): Promise<number[]> {
167
+ this.#checkOpen();
168
+ if (!anchorHidden)
169
+ throw new Error("native MTP drafting requires the target anchor hidden");
170
+ if (!Number.isSafeInteger(n) || n < 0)
171
+ throw new Error(`native MTP draft length must be non-negative; got ${n}`);
172
+ if (this.#lastDraftCount !== 0)
173
+ throw new Error("native MTP draft called before the prior round committed");
174
+ const [batch, tokens, hidden] = anchorHidden.shape;
175
+ if (batch !== 1 || tokens !== 1 || hidden !== this.model.glmConfig.hiddenSize) {
176
+ throw new Error(
177
+ `native MTP anchor must be [1,1,${this.model.glmConfig.hiddenSize}], ` +
178
+ `got [${anchorHidden.shape.join(",")}]`,
179
+ );
180
+ }
181
+
182
+ this.#roundStart = this.#cache.offset;
183
+ let token = lastToken(feed);
184
+ let state: MlxArray | null = ops.contiguous(anchorHidden);
185
+ const drafts: number[] = [];
186
+ try {
187
+ for (let position = 0; position < n; position++) {
188
+ const nextState = await this.#step(token, state, stepBase + position);
189
+ state.dispose();
190
+ state = nextState.hidden;
191
+ token = nextState.token;
192
+ drafts.push(token);
193
+ }
194
+ this.#lastDraftCount = drafts.length;
195
+ return drafts;
196
+ } catch (error) {
197
+ const appended = this.#cache.offset - this.#roundStart;
198
+ if (appended > 0) this.#cache.trim(appended);
199
+ throw error;
200
+ } finally {
201
+ state?.dispose();
202
+ }
203
+ }
204
+
205
+ async commit(
206
+ d: number,
207
+ kAccept: number,
208
+ vCtxML?: MlxArray,
209
+ verifiedHidden?: MlxArray,
210
+ acceptedTokens: readonly number[] = [],
211
+ ): Promise<void> {
212
+ this.#checkOpen();
213
+ vCtxML?.dispose();
214
+ if (d !== this.#lastDraftCount)
215
+ throw new Error(`native MTP commit d=${d}, expected ${this.#lastDraftCount}`);
216
+ if (!Number.isSafeInteger(kAccept) || kAccept < 0 || kAccept > d)
217
+ throw new Error(`native MTP accepted ${kAccept} outside [0,${d}]`);
218
+ if (acceptedTokens.length !== kAccept)
219
+ throw new Error(
220
+ `native MTP received ${acceptedTokens.length} accepted tokens for k=${kAccept}`,
221
+ );
222
+ if (kAccept > 0 && !verifiedHidden)
223
+ throw new Error("native MTP absorption requires verified target hidden rows");
224
+
225
+ try {
226
+ // Only the first speculative MTP row is conditioned on a true target
227
+ // hidden. Later rows use earlier MTP hiddens, so accepted positions are
228
+ // rebuilt below from the target's verified hidden window.
229
+ const speculativeTail = Math.max(d - 1, 0);
230
+ if (speculativeTail > 0) this.#cache.trim(speculativeTail);
231
+ if (kAccept > 0)
232
+ await this.#absorb(acceptedTokens, verifiedHidden!);
233
+ const expected = this.#roundStart + (d > 0 ? 1 : 0) + kAccept;
234
+ if (this.#cache.offset !== expected) {
235
+ throw new Error(
236
+ `native MTP cache committed to ${this.#cache.offset}, expected ${expected}`,
237
+ );
238
+ }
239
+ } catch (error) {
240
+ const appended = this.#cache.offset - this.#roundStart;
241
+ if (appended > 0) this.#cache.trim(appended);
242
+ throw error;
243
+ } finally {
244
+ this.#lastDraftCount = 0;
245
+ }
246
+ }
247
+
248
+ dispose(): void {
249
+ if (this.#closed) return;
250
+ this.#closed = true;
251
+ this.#cache.dispose();
252
+ }
253
+
254
+ async #step(
255
+ token: number,
256
+ hidden: MlxArray,
257
+ sampleStep: number,
258
+ ): Promise<{ hidden: MlxArray; token: number }> {
259
+ const config = this.model.glmConfig;
260
+ const ids = ops.fromInt32([token], [1, 1]);
261
+ let embedded: MlxArray | null = null;
262
+ let embeddedNorm: MlxArray | null = null;
263
+ let hiddenNorm: MlxArray | null = null;
264
+ let joined: MlxArray | null = null;
265
+ let projected: MlxArray | null = null;
266
+ let output: MlxArray | null = null;
267
+ let headInput: MlxArray | null = null;
268
+ let logits: MlxArray | null = null;
269
+ let logprobs: MlxArray | null = null;
270
+ let sampled: MlxArray | null = null;
271
+ try {
272
+ embedded = this.model.weights.embedding(
273
+ ids,
274
+ "model.embed_tokens.weight",
275
+ config.vocabSize,
276
+ config.hiddenSize,
277
+ );
278
+ embeddedNorm = rmsNormF32Mlx(
279
+ embedded,
280
+ this.model.weights.tensor(
281
+ `model.layers.${config.numHiddenLayers}.enorm.weight`,
282
+ ),
283
+ config.rmsNormEps,
284
+ );
285
+ hiddenNorm = rmsNormF32Mlx(
286
+ hidden,
287
+ this.model.weights.tensor(
288
+ `model.layers.${config.numHiddenLayers}.hnorm.weight`,
289
+ ),
290
+ config.rmsNormEps,
291
+ );
292
+ joined = ops.concatAxis([embeddedNorm, hiddenNorm], 2);
293
+ projected = this.model.weights.linear(
294
+ joined,
295
+ `model.layers.${config.numHiddenLayers}.eh_proj.weight`,
296
+ config.hiddenSize,
297
+ 2 * config.hiddenSize,
298
+ );
299
+ output = await this.layer.forwardAsync(projected, this.#cache, null);
300
+ headInput = rmsNormF32Mlx(
301
+ output,
302
+ this.model.weights.tensor(
303
+ `model.layers.${config.numHiddenLayers}.shared_head.norm.weight`,
304
+ ),
305
+ config.rmsNormEps,
306
+ );
307
+ logits = this.model.logitsFromHidden(headInput);
308
+ logprobs = toLogprobs(logits);
309
+ sampled = this.sampler(logprobs, sampleStep);
310
+ return {
311
+ hidden: ops.contiguous(output),
312
+ token: ops.itemUint32(sampled),
313
+ };
314
+ } finally {
315
+ ids.dispose();
316
+ embedded?.dispose();
317
+ embeddedNorm?.dispose();
318
+ hiddenNorm?.dispose();
319
+ joined?.dispose();
320
+ projected?.dispose();
321
+ output?.dispose();
322
+ headInput?.dispose();
323
+ logits?.dispose();
324
+ logprobs?.dispose();
325
+ sampled?.dispose();
326
+ }
327
+ }
328
+
329
+ async #absorb(
330
+ acceptedTokens: readonly number[],
331
+ verifiedHidden: MlxArray,
332
+ ): Promise<void> {
333
+ const config = this.model.glmConfig;
334
+ const k = acceptedTokens.length;
335
+ const ids = ops.fromInt32([...acceptedTokens], [1, k]);
336
+ let embedded: MlxArray | null = null;
337
+ let embeddedNorm: MlxArray | null = null;
338
+ let trueRows: MlxArray | null = null;
339
+ let hiddenNorm: MlxArray | null = null;
340
+ let joined: MlxArray | null = null;
341
+ let projected: MlxArray | null = null;
342
+ let output: MlxArray | null = null;
343
+ try {
344
+ embedded = this.model.weights.embedding(
345
+ ids,
346
+ "model.embed_tokens.weight",
347
+ config.vocabSize,
348
+ config.hiddenSize,
349
+ );
350
+ embeddedNorm = rmsNormF32Mlx(
351
+ embedded,
352
+ this.model.weights.tensor(
353
+ `model.layers.${config.numHiddenLayers}.enorm.weight`,
354
+ ),
355
+ config.rmsNormEps,
356
+ );
357
+ trueRows = verifiedHidden.slice(
358
+ [0, 0, 0],
359
+ [1, k, config.hiddenSize],
360
+ );
361
+ hiddenNorm = rmsNormF32Mlx(
362
+ trueRows,
363
+ this.model.weights.tensor(
364
+ `model.layers.${config.numHiddenLayers}.hnorm.weight`,
365
+ ),
366
+ config.rmsNormEps,
367
+ );
368
+ joined = ops.concatAxis([embeddedNorm, hiddenNorm], 2);
369
+ projected = this.model.weights.linear(
370
+ joined,
371
+ `model.layers.${config.numHiddenLayers}.eh_proj.weight`,
372
+ config.hiddenSize,
373
+ 2 * config.hiddenSize,
374
+ );
375
+ output = await this.layer.forwardAsync(projected, this.#cache, null);
376
+ output.eval();
377
+ } finally {
378
+ ids.dispose();
379
+ embedded?.dispose();
380
+ embeddedNorm?.dispose();
381
+ trueRows?.dispose();
382
+ hiddenNorm?.dispose();
383
+ joined?.dispose();
384
+ projected?.dispose();
385
+ output?.dispose();
386
+ }
387
+ }
388
+
389
+ #checkOpen(): void {
390
+ if (this.#closed) throw new Error("native MTP source is closed");
391
+ }
392
+ }
@@ -57,13 +57,24 @@ const PREFILL_CHUNK = 2048;
57
57
  * [1,L,m*H] (tapLayers concatenated on the feature axis; index nLayers is the
58
58
  * post-finalNorm sentinel). Non-tapping sources get ctxML=null and never
59
59
  * touch model.hiddenTap. Mirrors generate-dflash.ts forwardTapped. */
60
- function forwardMaybeTap(
60
+ async function forwardMaybeTap(
61
61
  model: RuntimeModel,
62
62
  ids: MlxArray,
63
63
  caches: Cache[],
64
64
  tapLayers: number[] | undefined,
65
- ): { hidden: MlxArray; ctxML: MlxArray | null } {
66
- if (!tapLayers) return { hidden: model.forwardHidden(ids, caches), ctxML: null };
65
+ ): Promise<{ hidden: MlxArray; ctxML: MlxArray | null }> {
66
+ if (!tapLayers) {
67
+ const asyncModel = model as RuntimeModel & {
68
+ forwardHiddenAsync?: (
69
+ ids: MlxArray,
70
+ caches: Cache[],
71
+ ) => Promise<MlxArray>;
72
+ };
73
+ const hidden = typeof asyncModel.forwardHiddenAsync === "function"
74
+ ? await asyncModel.forwardHiddenAsync(ids, caches)
75
+ : model.forwardHidden(ids, caches);
76
+ return { hidden, ctxML: null };
77
+ }
67
78
  const m = model as unknown as {
68
79
  hiddenTap: { layers: Set<number>; captured: Map<number, MlxArray> } | null;
69
80
  };
@@ -96,7 +107,12 @@ function forwardMaybeTap(
96
107
  export interface SpecServeExtras {
97
108
  drafted: number;
98
109
  accepted: number;
110
+ rejected: number;
99
111
  targetCalls: number;
112
+ rounds: number;
113
+ acceptanceLengths: number[];
114
+ tokensPerForward: number;
115
+ forwardsSaved: number;
100
116
  /** Per-draft-position counters (index = position within a round's block,
101
117
  * 0..γ-1): how many rounds drafted/accepted at that position. Drives the
102
118
  * Phase-1c per-position acceptance report (scripts/dspark-drafter-ab.ts)
@@ -149,7 +165,16 @@ export async function specServeRun(
149
165
  processors.length > 0 ? ops.fromInt32(promptIds, [promptIds.length]) : null;
150
166
 
151
167
  const extras: SpecServeExtras = {
152
- drafted: 0, accepted: 0, targetCalls: 0, draftedByPos: [], acceptedByPos: [],
168
+ drafted: 0,
169
+ accepted: 0,
170
+ rejected: 0,
171
+ targetCalls: 0,
172
+ rounds: 0,
173
+ acceptanceLengths: [],
174
+ tokensPerForward: 0,
175
+ forwardsSaved: 0,
176
+ draftedByPos: [],
177
+ acceptedByPos: [],
153
178
  };
154
179
  const stats: GenerateStats = {
155
180
  promptTokens: promptIds.length,
@@ -263,7 +288,9 @@ export async function specServeRun(
263
288
  // assistant source's first-round anchor hidden = "the hidden that
264
289
  // produced pending" = position len-2, the last drain chunk's final row.
265
290
  const t0 = performance.now();
266
- const tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
291
+ const tailSplit =
292
+ src.prefillMode !== "full" &&
293
+ flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
267
294
  const oracleShape = tailSplit && promptIds.length > 1;
268
295
  if (oracleShape) {
269
296
  let pos = 0;
@@ -272,7 +299,12 @@ export async function specServeRun(
272
299
  const n = Math.min(PREFILL_CHUNK, end - pos);
273
300
  const chunk = promptIds.slice(pos, pos + n);
274
301
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
275
- const { hidden: h, ctxML } = forwardMaybeTap(model, ids, caches, tapLayers);
302
+ const { hidden: h, ctxML } = await forwardMaybeTap(
303
+ model,
304
+ ids,
305
+ caches,
306
+ tapLayers,
307
+ );
276
308
  ids.dispose();
277
309
  if (ctxML) ctxParts.push(ctxML);
278
310
  if (pos + n >= end) {
@@ -290,7 +322,12 @@ export async function specServeRun(
290
322
  for (let off = 0; off < promptIds.length; off += PREFILL_CHUNK) {
291
323
  const chunk = promptIds.slice(off, off + PREFILL_CHUNK);
292
324
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
293
- const { hidden: h, ctxML } = forwardMaybeTap(model, ids, caches, tapLayers);
325
+ const { hidden: h, ctxML } = await forwardMaybeTap(
326
+ model,
327
+ ids,
328
+ caches,
329
+ tapLayers,
330
+ );
294
331
  ids.dispose();
295
332
  if (ctxML) ctxParts.push(ctxML);
296
333
  if (off + PREFILL_CHUNK >= promptIds.length) {
@@ -317,10 +354,10 @@ export async function specServeRun(
317
354
  for (const p of ctxParts) p.dispose();
318
355
  }
319
356
  ctxParts.length = 0; // parts consumed (transferred as prefillCtx or disposed)
320
- src.prefill(promptIds, prefillCtx ?? undefined); // takes ownership of prefillCtx
357
+ await src.prefill(promptIds, prefillCtx ?? undefined); // takes ownership of prefillCtx
321
358
  prefillCtx = null; // ownership transferred
322
359
  } else {
323
- src.prefill(promptIds);
360
+ await src.prefill(promptIds);
324
361
  }
325
362
  stats.prefillMs = performance.now() - t0;
326
363
  stats.prefillTps = (promptIds.length / Math.max(stats.prefillMs, 1e-6)) * 1000;
@@ -363,7 +400,12 @@ export async function specServeRun(
363
400
  if (!speculating) {
364
401
  // plain single-token continuation (post-ring-wrap fallback)
365
402
  const ids = ops.fromInt32([pending], [1, 1]);
366
- const h = model.forwardHidden(ids, caches);
403
+ const { hidden: h } = await forwardMaybeTap(
404
+ model,
405
+ ids,
406
+ caches,
407
+ undefined,
408
+ );
367
409
  ids.dispose();
368
410
  const lg = model.logitsFromHidden(h);
369
411
  h.dispose();
@@ -406,11 +448,22 @@ export async function specServeRun(
406
448
 
407
449
  // (a) draft UP TO n tokens (request sampler, mlx-lm parity). The return
408
450
  // length d is authoritative — DSpark's confidence scheduler may prune the
409
- // block short (source.ts contract; never zero). The assistant source
451
+ // block short or skip it entirely. The assistant source
410
452
  // borrows the target's anchor hidden; two-model/dflash ignore it.
411
- const drafts = src.draft(feed, n, stats.generatedTokens, anchorHidden ?? undefined);
453
+ const drafts = await src.draft(
454
+ feed,
455
+ n,
456
+ stats.generatedTokens,
457
+ anchorHidden ?? undefined,
458
+ );
412
459
  const d = drafts.length;
413
460
  if (d < 0 || d > n) throw new Error(`DraftSource returned ${d} drafts (contract: 0..${n})`);
461
+ if (Bun.env.MLX_BUN_SPEC_TRACE === "1") {
462
+ console.error(
463
+ `[SPEC_TRACE] round=${extras.rounds + 1} ` +
464
+ `feed=${feed.join(",")} drafts=${drafts.join(",")}`,
465
+ );
466
+ }
414
467
  // d === 0 (a confidence scheduler skipping the round, DeepSpec ℓ=0
415
468
  // semantics) needs NO special case: the verify window degenerates to
416
469
  // [pending] alone, the accept walk to the single bonus position, commit
@@ -424,12 +477,24 @@ export async function specServeRun(
424
477
  // DSpark's H_ctx). vHidden is retained past the accept walk: its slice at
425
478
  // the emitted position is the next round's anchor hidden.
426
479
  const vIds = ops.fromInt32([pending, ...drafts], [1, d + 1]);
427
- const ft = forwardMaybeTap(model, vIds, caches, tapLayers);
428
- vHidden = ft.hidden;
429
- vCtxML = ft.ctxML;
430
- vIds.dispose();
480
+ const pinTarget = src.pinTargetKernelFamily === true &&
481
+ "setSpecKernelPinned" in model;
482
+ if (pinTarget) model.setSpecKernelPinned(true);
483
+ try {
484
+ const ft = await forwardMaybeTap(model, vIds, caches, tapLayers);
485
+ // Assign these before the lm-head call so the outer cleanup owns them
486
+ // even if logits construction throws.
487
+ vHidden = ft.hidden;
488
+ vCtxML = ft.ctxML;
489
+ vLogits = model.logitsFromHidden(vHidden);
490
+ } finally {
491
+ vIds.dispose();
492
+ if (pinTarget) model.setSpecKernelPinned(false);
493
+ }
431
494
  extras.targetCalls++;
432
- vLogits = model.logitsFromHidden(vHidden); // batched lm-head, ONE matmul
495
+ // The lm-head remains one logical batched operation. Native GLM MTP's
496
+ // SPEC_PIN implementation evaluates its rows through the same M=1
497
+ // quantized family before concatenating them.
433
498
 
434
499
  // (c) per-position accept walk: sample the target at each position,
435
500
  // accept while it reproduces the draft. Sampling is sequential because
@@ -471,6 +536,9 @@ export async function specServeRun(
471
536
  vLogits!.dispose();
472
537
  vLogits = null;
473
538
  extras.accepted += kAccept;
539
+ extras.rejected = extras.drafted - extras.accepted;
540
+ extras.rounds++;
541
+ extras.acceptanceLengths.push(kAccept);
474
542
  for (let i = 0; i < kAccept; i++)
475
543
  extras.acceptedByPos[i] = (extras.acceptedByPos[i] ?? 0) + 1;
476
544
  clearCache();
@@ -481,6 +549,13 @@ export async function specServeRun(
481
549
  if ((await onToken(tok)) === false) { halted = true; break; }
482
550
  if (stats.generatedTokens >= maxTokens) break;
483
551
  }
552
+ extras.tokensPerForward = extras.rounds > 0
553
+ ? stats.generatedTokens / extras.rounds
554
+ : 0;
555
+ extras.forwardsSaved = Math.max(
556
+ 0,
557
+ Math.max(0, stats.generatedTokens - 1) - extras.rounds,
558
+ );
484
559
  const stop = sawEos || halted || grammarDone || stats.generatedTokens >= maxTokens;
485
560
 
486
561
  // (e) roll back the rejected suffix + commit to the source (skipped on
@@ -491,7 +566,13 @@ export async function specServeRun(
491
566
  // outlives its parent's dispose).
492
567
  if (!stop) {
493
568
  if (kAccept < d) for (const c of caches) c.trim(d - kAccept);
494
- src.commit(d, kAccept, vCtxML ?? undefined); // takes ownership of vCtxML
569
+ await src.commit(
570
+ d,
571
+ kAccept,
572
+ vCtxML ?? undefined,
573
+ vHidden,
574
+ drafts.slice(0, kAccept),
575
+ ); // takes ownership of vCtxML; verifiedHidden stays caller-owned
495
576
  vCtxML = null;
496
577
  const H = vHidden!.shape[2]!;
497
578
  anchorHidden?.dispose();
@@ -1,6 +1,6 @@
1
1
  // DraftSource — the seam between the serve-time speculative verify loop
2
2
  // (src/spec/serve-loop.ts) and whatever produces draft tokens. Designed in
3
- // docs/design/mlx-lm-tool-parity-plan.md §7 so three drafters share ONE
3
+ // docs/design/mlx-lm-tool-parity-plan.md §7 so every drafter shares ONE
4
4
  // verify/accept executor:
5
5
  // - TwoModelSource (src/spec/two-model.ts) — mlx-lm parity (L1 oracle:
6
6
  // mlx_lm.server --draft-model), a full second model. Ignores `target`.
@@ -10,8 +10,11 @@
10
10
  // - DflashSource (src/spec/dflash-source.ts) — DSpark (L3, KL/quality-gated).
11
11
  // Taps the target's multi-layer hiddens (prefill + verify) into a growing
12
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.
13
16
  //
14
- // The three differ ONLY in what fills the draft; the serve loop, admission
17
+ // The sources differ ONLY in what fills the draft; the serve loop, admission
15
18
  // accounting, and stats never change. The KV-borrowing sources need target
16
19
  // state the two-model source doesn't — carried by the `target` view at open()
17
20
  // and the optional prefill/draft/commit arguments below (all no-ops for
@@ -33,6 +36,14 @@ export interface TargetView {
33
36
  /** A per-request draft-token producer. Created per generation (owns its own
34
37
  * draft-side state), disposed by the serve loop's finally. */
35
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
+
36
47
  /** Multi-layer target tap the source needs captured on the target's prefill
37
48
  * AND every verify forward (DSpark's H_ctx; e4b {20,31,41,42}). When set,
38
49
  * the serve loop sets model.hiddenTap around those forwards and passes the
@@ -44,7 +55,7 @@ export interface DraftSource {
44
55
  * assistant/dflash: read the target's state — mostly a no-op, but DSpark
45
56
  * seeds H_ctx from `ctxML`, the tapped prefill context [1,Lp,m*H], present
46
57
  * iff tapLayers is set). */
47
- prefill(promptIds: number[], ctxML?: MlxArray): void;
58
+ prefill(promptIds: number[], ctxML?: MlxArray): void | Promise<void>;
48
59
 
49
60
  /** Propose 0..n tokens (RETURN LENGTH IS AUTHORITATIVE — a source may
50
61
  * return fewer than n, e.g. DSpark's confidence-scheduled draft-length
@@ -59,15 +70,29 @@ export interface DraftSource {
59
70
  * `anchorHidden` is the target's final hidden [1,1,H] at the pending/anchor
60
71
  * position — the assistant source borrows it for its first draft step;
61
72
  * two-model and dflash ignore it. */
62
- draft(feed: number[], n: number, stepBase: number, anchorHidden?: MlxArray): number[];
73
+ draft(
74
+ feed: number[],
75
+ n: number,
76
+ stepBase: number,
77
+ anchorHidden?: MlxArray,
78
+ ): number[] | Promise<number[]>;
63
79
 
64
80
  /** Verify outcome for the last round: kAccept of d accepted, where d is the
65
81
  * length draft() actually RETURNED (≤ n). Two-model: trim the draft cache
66
82
  * by max(d - kAccept - 1, 0) — mlx-lm's rewind rule (generate.py:589-591).
67
83
  * DSpark: grow H_ctx by the accepted window from `vCtxML`, the verified
68
84
  * window's tapped context [1,d+1,m*H] (present iff tapLayers is set), and
69
- * drop the rejected tips. */
70
- commit(d: number, kAccept: number, vCtxML?: MlxArray): void;
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>;
71
96
 
72
97
  /** Resident per-request draft weights, for admission accounting (0 when the
73
98
  * provider owns the weights). */