mlx-bun 0.0.12 → 0.2.0

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 (226) hide show
  1. package/README.md +40 -24
  2. package/bin/mlx-bun.mjs +1 -1
  3. package/docs/{design → archive}/hlg-sampling.md +3 -3
  4. package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
  5. package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
  6. package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
  7. package/docs/archive/planning/release-notes-v0.0.13.md +77 -0
  8. package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  9. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  10. package/docs/design/colibri-glm52-port.md +8 -1
  11. package/docs/design/compat-cli-surface-design.md +1 -1
  12. package/docs/design/decode-speed-program.md +1 -1
  13. package/docs/design/docs-reorg-plan.md +1 -1
  14. package/docs/design/dspark-serving-program.md +1 -1
  15. package/docs/design/dspark-speculative-decoding.md +53 -3
  16. package/docs/design/grammar-spec-batching-integration.md +1 -1
  17. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  18. package/docs/design/orpo-dynamic-lambda.md +1 -1
  19. package/docs/design/orpo-future-enhancements.md +1 -1
  20. package/docs/design/orpo-training.md +2 -2
  21. package/docs/design/parity-tier-dag.md +1 -1
  22. package/docs/design/repo-cleanup-plan.md +46 -4
  23. package/docs/design/segmented-backward-training.md +2 -2
  24. package/docs/design/ssd-kv-cold-tier.md +127 -0
  25. package/docs/design/turboquant-weights.md +467 -0
  26. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  27. package/docs/design/web-chat-redesign.md +5 -5
  28. package/docs/planning/chunk-task-roadmap.md +1 -1
  29. package/docs/planning/curve-sampler-research-plan.md +4 -4
  30. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  31. package/docs/planning/release-notes-v0.2.0.md +49 -0
  32. package/docs/planning/video-series.md +421 -0
  33. package/docs/reference/cli.md +15 -4
  34. package/docs/reference/distribution.md +1 -0
  35. package/docs/reference/features-matrix.md +3 -1
  36. package/docs/reference/library-api.md +41 -1
  37. package/docs/reference/models.md +33 -0
  38. package/docs/reference/server-api.md +86 -19
  39. package/docs/reference/server-config.md +57 -33
  40. package/package.json +2 -2
  41. package/src/anthropic.ts +92 -3
  42. package/src/chat-template.ts +34 -1
  43. package/src/cli.ts +131 -36
  44. package/src/config.ts +10 -0
  45. package/src/eval/kl.ts +27 -20
  46. package/src/eval/runner.ts +23 -4
  47. package/src/eval/tasks/bfcl.ts +2 -1
  48. package/src/eval/tasks/gsm8k.ts +2 -1
  49. package/src/eval/tasks/hashhop.ts +2 -1
  50. package/src/eval/tasks/humaneval.ts +5 -4
  51. package/src/eval/tasks/ifeval.ts +2 -1
  52. package/src/eval/tasks/mmlu.ts +2 -1
  53. package/src/expert-io.ts +3 -1
  54. package/src/expert-offload.ts +3 -1
  55. package/src/expert-trace.ts +3 -1
  56. package/src/fit.ts +24 -43
  57. package/src/flags.ts +7 -9
  58. package/src/generate.ts +107 -92
  59. package/src/grammar.ts +2 -1
  60. package/src/harness-pi.ts +1 -1
  61. package/src/index.ts +20 -0
  62. package/src/jobs/job-entry.ts +3 -2
  63. package/src/kv-scheme.ts +199 -0
  64. package/src/kv-store.ts +370 -205
  65. package/src/media-fetch.ts +19 -3
  66. package/src/memory/model.ts +2 -1
  67. package/src/memory/vault.ts +2 -1
  68. package/src/memory-plan.ts +18 -0
  69. package/src/mlx/ffi.ts +8 -11
  70. package/src/mlx/ops.ts +31 -6
  71. package/src/model/batched-mask.ts +1 -1
  72. package/src/model/batched-quant.ts +1 -1
  73. package/src/model/batched-rotating-quant.ts +43 -97
  74. package/src/model/batched-rotating-state.ts +73 -0
  75. package/src/model/batched-rotating.ts +56 -105
  76. package/src/model/batched-row-storage.ts +127 -0
  77. package/src/model/compiled-decode.ts +3 -2
  78. package/src/model/factory.ts +44 -49
  79. package/src/model/gemma4-base.ts +163 -10
  80. package/src/model/generated/gemma4-12b.ts +50 -146
  81. package/src/model/generated/gemma4-26b.ts +32 -92
  82. package/src/model/generated/gemma4-e4b.ts +26 -74
  83. package/src/model/glm52-cache.ts +19 -9
  84. package/src/model/glm52-memory.ts +28 -1
  85. package/src/model/minicpm5.ts +2 -1
  86. package/src/model/profile.ts +433 -0
  87. package/src/model/qwen3-delta.ts +98 -2
  88. package/src/model/qwen3-mrope.ts +252 -0
  89. package/src/model/qwen3_5.ts +217 -25
  90. package/src/native/frame_extract.swift +140 -0
  91. package/src/native-pack.ts +13 -6
  92. package/src/pi-web.ts +6 -5
  93. package/src/prompt-cache.ts +2 -2
  94. package/src/quantize/atomic-output.ts +38 -0
  95. package/src/quantize/calibration.ts +2 -1
  96. package/src/quantize/config-writer.ts +6 -0
  97. package/src/quantize/index.ts +28 -1
  98. package/src/quantize/job.ts +11 -0
  99. package/src/quantize/quantizer.ts +265 -100
  100. package/src/quantize/rotate.ts +603 -0
  101. package/src/quantize/safetensors-writer.ts +79 -1
  102. package/src/quantize/sensitivity.ts +41 -59
  103. package/src/quantize/weight-transform.ts +292 -0
  104. package/src/responses.ts +102 -2
  105. package/src/runtime-config.ts +67 -0
  106. package/src/sampler.ts +272 -0
  107. package/src/serve/admin-routes.ts +184 -0
  108. package/src/serve/aux-routes.ts +132 -0
  109. package/src/serve/batch-scheduler.ts +200 -87
  110. package/src/serve/completion-executor.ts +346 -0
  111. package/src/serve/completion-sink.ts +157 -0
  112. package/src/serve/discovery-routes.ts +205 -0
  113. package/src/serve/generation-gateway.ts +116 -109
  114. package/src/serve/kv-budget.ts +14 -0
  115. package/src/serve/lab-routes.ts +338 -0
  116. package/src/serve/model-admin-routes.ts +153 -0
  117. package/src/serve/prompt-response-trace.ts +147 -0
  118. package/src/serve/request-plan.ts +158 -0
  119. package/src/serve/static-routes.ts +101 -0
  120. package/src/server.ts +768 -1367
  121. package/src/spec/dflash-source.ts +2 -1
  122. package/src/spec/dspark/module-dflash.ts +1 -1
  123. package/src/spec/glm52-mtp-source.ts +8 -0
  124. package/src/spec/qwen-mtp-source.ts +431 -0
  125. package/src/spec/serve-loop.ts +36 -68
  126. package/src/ssd-cache.ts +17 -14
  127. package/src/ssd-durability.ts +195 -0
  128. package/src/train/flash-cce.ts +12 -11
  129. package/src/train/forward.ts +32 -1
  130. package/src/train/loss.ts +2 -1
  131. package/src/train/segmented.ts +4 -3
  132. package/src/train/trainer.ts +3 -2
  133. package/src/vision/preprocess.ts +1 -1
  134. package/src/vision/prompt.ts +47 -1
  135. package/src/vision/qwen3vl-preprocess.ts +409 -0
  136. package/src/vision/qwen3vl-prompt.ts +196 -0
  137. package/src/vision/qwen3vl-tower.ts +513 -0
  138. package/src/vision/video-frames.ts +148 -0
  139. package/src/web/app.js +10 -3
  140. package/src/web/src/chat.ts +12 -5
  141. package/src/weights.ts +81 -26
  142. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  143. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  147. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  148. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  149. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  150. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  151. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  152. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  207. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  208. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  209. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  218. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  219. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  220. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  221. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  222. /package/docs/{planning → archive/planning}/journal.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  224. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  225. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  226. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
@@ -26,6 +26,7 @@ import { Gemma4Model } from "../model/gemma4";
26
26
  import type { DflashDrafter } from "./dspark/module-dflash";
27
27
  import { loadDsparkDrafter } from "./dspark/loader";
28
28
  import type { DraftProvider, DraftSource, TargetView } from "./source";
29
+ import { runtimeValue } from "../runtime-config";
29
30
 
30
31
  function safetensorsBytes(dir: string): number {
31
32
  let total = 0;
@@ -93,7 +94,7 @@ class DflashSource implements DraftSource {
93
94
  draft(feed: number[], n: number, _stepBase: number, _anchorHidden?: MlxArray): number[] {
94
95
  if (!this.hCtx) throw new Error("DSpark drafter: draft before prefill");
95
96
  const anchorTok = feed[feed.length - 1]!;
96
- const envMin = process.env.MLX_BUN_DSPARK_MINCONF;
97
+ const envMin = runtimeValue("MLX_BUN_DSPARK_MINCONF");
97
98
  const block = this.drafter.forwardInfer(this.model, this.hCtx, anchorTok, n, {
98
99
  thresholds: this.drafter.cfg.sts?.thresholds,
99
100
  collectLogits: false, // serve loop runs its own verify lm-head, never reads this
@@ -403,7 +403,7 @@ export class DflashDrafter {
403
403
  /** Inference: parallel backbone once, then sequential Markov sampling.
404
404
  * hCtx [1,Lctx,m*H] (full current context), anchorTok the bonus token.
405
405
  *
406
- * Host-sync discipline (docs/investigations/dspark-handoff.md item 3): the
406
+ * Host-sync discipline (docs/archive/investigations/dspark-handoff.md item 3): the
407
407
  * greedy path's token recurrence stays ON-DEVICE across positions — the
408
408
  * argmax result [1] uint32 feeds directly into the next position's
409
409
  * takeAxis, no itemUint32 in the loop. Per-position token arrays are
@@ -305,6 +305,14 @@ export class Glm52NativeMtpSource implements DraftSource {
305
305
  config.rmsNormEps,
306
306
  );
307
307
  logits = this.model.logitsFromHidden(headInput);
308
+ // Sampler contract is [1, V] — same 3-D top-k hazard as
309
+ // qwen-mtp-source #sample (2026-08-20); see the comment there.
310
+ {
311
+ const V = logits.shape[logits.shape.length - 1]!;
312
+ const flat = ops.reshape(logits, [1, V]);
313
+ logits.dispose();
314
+ logits = flat;
315
+ }
308
316
  logprobs = toLogprobs(logits);
309
317
  sampled = this.sampler(logprobs, sampleStep);
310
318
  return {
@@ -0,0 +1,431 @@
1
+ // Native Qwen3.8 MTP draft source — the Qwen-TRAINED multi-step prediction
2
+ // head, split from the raw release's last shard and published as
3
+ // mlx-community/Qwen3.8-27B-MTP-* (model_type "qwen3_5_mtp", block_size 3).
4
+ //
5
+ // Reference implementation (locate + port + gate, don't design):
6
+ // mlx_vlm/speculative/drafters/qwen3_5_mtp/qwen3_5_mtp.py. Mechanism, per
7
+ // step: h = fc(concat(rms_emb(embed(token)), rms_hidden(hidden))) → ONE
8
+ // full-attention decoder layer (own KVCache, positions continue the target
9
+ // sequence) → norm → the TARGET's lm_head → next draft token; recursive for
10
+ // the block ("trained with multiple steps"). The drafter is NOT standalone:
11
+ // it binds the target's embed_tokens + lm_head (mtp_use_dedicated_embeddings
12
+ // false) and consumes the target's PRE-final-norm last-layer hidden (mlx-vlm
13
+ // captures it with skip_final_norm=True) — carried here by the tapLayers
14
+ // machinery, NOT the seam's post-norm anchorHidden.
15
+ //
16
+ // Row convention (predict-2-ahead): drafter KV row at position p is built
17
+ // from (embed(token at position p+1), hidden at position p). Invariant
18
+ // across rounds: after commit, the drafter offset equals the next pending
19
+ // token's position minus zero — i.e. draft() always opens by building the
20
+ // pending token's row from the TARGET's TRUE hidden at the emitted position
21
+ // (held over from the verify tap). Computationally identical to mlx-vlm's
22
+ // commit-time "seed" append, just performed at the top of the next round.
23
+ //
24
+ // Weights are plain bf16 (DenseLinear); the published drafter snapshots are
25
+ // already sanitized (norms in runtime layout — no +1.0 shift here).
26
+
27
+ import { MlxArray } from "../mlx/array";
28
+ import * as ops from "../mlx/ops";
29
+ import { toLogprobs } from "../sampler";
30
+ import { loadModelConfig, type ModelConfig } from "../config";
31
+ import { Weights } from "../weights";
32
+ import { disposing, KVCache, RMSNorm, type Mask } from "../model/gemma4-base";
33
+ import { DenseLinear } from "../model/universal/modules";
34
+ import { Qwen35Model } from "../model/qwen3_5";
35
+ import type { DraftProvider, DraftSource, TargetView } from "./source";
36
+
37
+ type Sampler = (logprobs: MlxArray, step: number) => MlxArray;
38
+
39
+ const DRAFT_PREFILL_CHUNK = 2048;
40
+
41
+ /** Dense-weight clone of Qwen3Attention.forward (src/model/qwen3_5.ts —
42
+ * ops copied verbatim; only the projection flavor differs: the drafter is
43
+ * bf16, the target's class hardwires QuantizedLinear). */
44
+ class MtpAttention {
45
+ readonly qProj: DenseLinear;
46
+ readonly kProj: DenseLinear;
47
+ readonly vProj: DenseLinear;
48
+ readonly oProj: DenseLinear;
49
+ readonly qNorm: RMSNorm;
50
+ readonly kNorm: RMSNorm;
51
+ readonly nHeads: number;
52
+ readonly nKvHeads: number;
53
+ readonly headDim: number;
54
+ readonly scale: number;
55
+ readonly ropeDims: number;
56
+ readonly ropeBase: number;
57
+
58
+ constructor(weights: Weights, config: ModelConfig, prefix: string) {
59
+ const t = config.text;
60
+ this.nHeads = t.numAttentionHeads;
61
+ this.nKvHeads = t.numKeyValueHeads;
62
+ this.headDim = t.headDim;
63
+ this.scale = Math.pow(this.headDim, -0.5);
64
+ this.ropeDims = Math.trunc(this.headDim * t.partialRotaryFactor);
65
+ this.ropeBase = t.ropeParameters.full_attention?.ropeTheta ?? 10000;
66
+ const dense = (path: string): DenseLinear =>
67
+ new DenseLinear(weights.tensor(`${path}.weight`), null);
68
+ this.qProj = dense(`${prefix}.q_proj`);
69
+ this.kProj = dense(`${prefix}.k_proj`);
70
+ this.vProj = dense(`${prefix}.v_proj`);
71
+ this.oProj = dense(`${prefix}.o_proj`);
72
+ this.qNorm = new RMSNorm(weights.tensor(`${prefix}.q_norm.weight`), t.rmsNormEps);
73
+ this.kNorm = new RMSNorm(weights.tensor(`${prefix}.k_norm.weight`), t.rmsNormEps);
74
+ }
75
+
76
+ forward(x: MlxArray, mask: Mask, cache: KVCache): MlxArray {
77
+ const [B, L] = x.shape as [number, number, number];
78
+ const qp = this.qProj.forward(x);
79
+ const qpr = disposing(qp, ops.reshape(qp, [B, L, this.nHeads, this.headDim * 2]));
80
+ const [qHeads, gateHeads] = ops.split(qpr, [this.headDim], -1) as [MlxArray, MlxArray];
81
+ qpr.dispose();
82
+ const gate = disposing(gateHeads, ops.reshape(gateHeads, [B, L, this.nHeads * this.headDim]));
83
+
84
+ let k = this.kProj.forward(x);
85
+ let v = this.vProj.forward(x);
86
+
87
+ let q = this.qNorm.forward(qHeads);
88
+ qHeads.dispose();
89
+ q = disposing(q, ops.transposeAxes(q, [0, 2, 1, 3]));
90
+ k = disposing(k, ops.reshape(k, [B, L, this.nKvHeads, this.headDim]));
91
+ k = disposing(k, this.kNorm.forward(k));
92
+ k = disposing(k, ops.transposeAxes(k, [0, 2, 1, 3]));
93
+ v = disposing(v, ops.reshape(v, [B, L, this.nKvHeads, this.headDim]));
94
+ v = disposing(v, ops.transposeAxes(v, [0, 2, 1, 3]));
95
+
96
+ q = disposing(q, ops.rope(q, this.ropeDims, this.ropeBase, cache.offset, null));
97
+ k = disposing(k, ops.rope(k, this.ropeDims, this.ropeBase, cache.offset, null));
98
+
99
+ const [keys, values] = cache.updateAndFetch(k, v);
100
+ k.dispose();
101
+ v.dispose();
102
+ const attn = ops.sdpa(q, keys, values, this.scale, mask.mode, mask.arr);
103
+ keys.dispose();
104
+ values.dispose();
105
+ q.dispose();
106
+
107
+ const attnT = ops.transposeAxes(attn, [0, 2, 1, 3]);
108
+ attn.dispose();
109
+ const merged = ops.reshape(attnT, [B, L, -1]);
110
+ attnT.dispose();
111
+ const sig = ops.sigmoid(gate);
112
+ gate.dispose();
113
+ const gated = ops.mul(merged, sig);
114
+ merged.dispose();
115
+ sig.dispose();
116
+ const out = this.oProj.forward(gated);
117
+ gated.dispose();
118
+ return out;
119
+ }
120
+ }
121
+
122
+ /** The one MTP decoder block: fc-merge → attention → swiglu MLP → norm. */
123
+ class MtpModule {
124
+ readonly fc: DenseLinear;
125
+ readonly preFcNormEmbedding: RMSNorm;
126
+ readonly preFcNormHidden: RMSNorm;
127
+ readonly attn: MtpAttention;
128
+ readonly mlpGate: DenseLinear;
129
+ readonly mlpUp: DenseLinear;
130
+ readonly mlpDown: DenseLinear;
131
+ readonly inputNorm: RMSNorm;
132
+ readonly postAttnNorm: RMSNorm;
133
+ readonly finalNorm: RMSNorm;
134
+
135
+ constructor(weights: Weights, config: ModelConfig) {
136
+ const eps = config.text.rmsNormEps;
137
+ const dense = (path: string): DenseLinear =>
138
+ new DenseLinear(weights.tensor(`${path}.weight`), null);
139
+ this.fc = dense("fc");
140
+ this.preFcNormEmbedding = new RMSNorm(weights.tensor("pre_fc_norm_embedding.weight"), eps);
141
+ this.preFcNormHidden = new RMSNorm(weights.tensor("pre_fc_norm_hidden.weight"), eps);
142
+ this.attn = new MtpAttention(weights, config, "layers.0.self_attn");
143
+ this.mlpGate = dense("layers.0.mlp.gate_proj");
144
+ this.mlpUp = dense("layers.0.mlp.up_proj");
145
+ this.mlpDown = dense("layers.0.mlp.down_proj");
146
+ this.inputNorm = new RMSNorm(weights.tensor("layers.0.input_layernorm.weight"), eps);
147
+ this.postAttnNorm = new RMSNorm(weights.tensor("layers.0.post_attention_layernorm.weight"), eps);
148
+ this.finalNorm = new RMSNorm(weights.tensor("norm.weight"), eps);
149
+ }
150
+
151
+ /** One block forward over [1,S,·]: token embeddings ([1,S,H], target
152
+ * embed_tokens output) paired with hiddens ([1,S,H], target pre-final-norm
153
+ * or the module's own chained output). Appends S rows to `cache`; returns
154
+ * the module output [1,S,H] (post final norm — what the target lm_head
155
+ * consumes AND what chains into the next step's `hidden`). */
156
+ forward(tokenEmbeds: MlxArray, hiddens: MlxArray, cache: KVCache): MlxArray {
157
+ const embNorm = this.preFcNormEmbedding.forward(tokenEmbeds);
158
+ const hidNorm = this.preFcNormHidden.forward(hiddens);
159
+ const joined = ops.concatAxis([embNorm, hidNorm], 2);
160
+ embNorm.dispose();
161
+ hidNorm.dispose();
162
+ const x = this.fc.forward(joined);
163
+ joined.dispose();
164
+
165
+ // Decoder layer (Qwen3Layer.forward shape, dense weights).
166
+ const L = x.shape[1]!;
167
+ const mask = cache.makeMask(L, null);
168
+ const xn = this.inputNorm.forward(x);
169
+ const r = this.attn.forward(xn, mask, cache);
170
+ xn.dispose();
171
+ mask.arr?.dispose();
172
+ const h = ops.add(x, r);
173
+ x.dispose();
174
+ r.dispose();
175
+ const hn = this.postAttnNorm.forward(h);
176
+ const g = this.mlpGate.forward(hn);
177
+ const u = this.mlpUp.forward(hn);
178
+ hn.dispose();
179
+ const silu = ops.silu(g);
180
+ g.dispose();
181
+ const act = ops.mul(silu, u);
182
+ silu.dispose();
183
+ u.dispose();
184
+ const m = this.mlpDown.forward(act);
185
+ act.dispose();
186
+ const out = ops.add(h, m);
187
+ h.dispose();
188
+ m.dispose();
189
+ return disposing(out, this.finalNorm.forward(out));
190
+ }
191
+ }
192
+
193
+ export class QwenMtpProvider implements DraftProvider {
194
+ readonly id: string;
195
+ readonly weightsBytes: number;
196
+ readonly #module: MtpModule;
197
+ readonly #config: ModelConfig;
198
+
199
+ private constructor(id: string, config: ModelConfig, weights: Weights, module: MtpModule) {
200
+ this.id = id;
201
+ this.#config = config;
202
+ this.#module = module;
203
+ this.weightsBytes = [...weights.shards.files.values()]
204
+ .reduce((a, f) => a + f.mmap.size, 0);
205
+ }
206
+
207
+ static async load(dir: string): Promise<QwenMtpProvider> {
208
+ const config = await loadModelConfig(dir);
209
+ if (config.modelType !== "qwen3_5_mtp")
210
+ throw new Error(`${dir}: not a qwen3_5_mtp drafter (model_type ${config.modelType})`);
211
+ const weights = await Weights.open(dir);
212
+ return new QwenMtpProvider(
213
+ dir.split("/").filter(Boolean).at(-1) ?? "qwen-mtp",
214
+ config, weights, new MtpModule(weights, config),
215
+ );
216
+ }
217
+
218
+ open(opts: Parameters<DraftProvider["open"]>[0]): DraftSource {
219
+ const target = opts.target.model;
220
+ if (!(target instanceof Qwen35Model))
221
+ throw new Error("qwen MTP drafting requires a qwen3_5-family target");
222
+ if (target.config.text.hiddenSize !== this.#config.text.hiddenSize) {
223
+ throw new Error(
224
+ `qwen MTP drafter hidden ${this.#config.text.hiddenSize} != target ` +
225
+ `${target.config.text.hiddenSize} — split from a different checkpoint?`,
226
+ );
227
+ }
228
+ return new QwenMtpSource(target, this.#module, opts.sampler);
229
+ }
230
+
231
+ dispose(): void {
232
+ // Weights are mmap-backed and pinned for the process (house rule: no JS
233
+ // dtors into mlx); the provider owns no other native state.
234
+ }
235
+ }
236
+
237
+ export class QwenMtpSource implements DraftSource {
238
+ // Full-prompt target prefill (the bonus token exists before round 1, and
239
+ // the tap covers every prompt position — mlx-vlm's flow).
240
+ readonly prefillMode = "full" as const;
241
+ readonly weightsBytes = 0; // provider owns the drafter weights
242
+ /** Pre-final-norm tap: the LAST layer's output stream (index nLayers-1).
243
+ * The seam's anchorHidden is post-final-norm and is deliberately unused. */
244
+ readonly tapLayers: number[];
245
+
246
+ readonly #target: Qwen35Model;
247
+ readonly #module: MtpModule;
248
+ readonly #sampler: Sampler;
249
+ readonly #cache = new KVCache();
250
+ /** Target pre-norm hidden at the position preceding the next pending
251
+ * token: prefill's last tapped row, then each commit's vCtx row at the
252
+ * emitted position. draft() consumes it to build the pending row. [1,1,H] */
253
+ #pendingTrueHidden: MlxArray | null = null;
254
+ #roundAppended = 0;
255
+ #closed = false;
256
+
257
+ constructor(target: Qwen35Model, module: MtpModule, sampler: Sampler) {
258
+ this.#target = target;
259
+ this.#module = module;
260
+ this.#sampler = sampler;
261
+ this.tapLayers = [target.config.text.numHiddenLayers - 1];
262
+ }
263
+
264
+ /** Drafter prefill: rows for positions 0..L-2, keyed (token_{p+1}, h_p) —
265
+ * the (pending, h_{L-1}) row is NOT built here (the pending token is
266
+ * sampled after target prefill); its true hidden is kept for round 1. */
267
+ async prefill(promptIds: number[], ctxML?: MlxArray): Promise<void> {
268
+ this.#checkOpen();
269
+ if (!ctxML)
270
+ throw new Error("qwen MTP prefill requires the tapped pre-final-norm context");
271
+ try {
272
+ if (this.#cache.offset !== 0)
273
+ throw new Error("qwen MTP source cannot be prefilled twice");
274
+ const L = promptIds.length;
275
+ const H = ctxML.shape[2]!;
276
+ if (ctxML.shape[1]! !== L)
277
+ throw new Error(`qwen MTP tap covered ${ctxML.shape[1]} of ${L} prompt positions`);
278
+ this.#pendingTrueHidden = ctxML.slice([0, L - 1, 0], [1, L, H]);
279
+ for (let pos = 0; pos + 1 < L; pos += DRAFT_PREFILL_CHUNK) {
280
+ const n = Math.min(DRAFT_PREFILL_CHUNK, L - 1 - pos);
281
+ const shifted = promptIds.slice(pos + 1, pos + 1 + n);
282
+ const ids = ops.fromInt32(shifted, [1, n]);
283
+ const embeds = this.#target.embed.encode(ids);
284
+ ids.dispose();
285
+ const hiddens = ctxML.slice([0, pos, 0], [1, pos + n, H]);
286
+ const out = this.#module.forward(embeds, hiddens, this.#cache);
287
+ embeds.dispose();
288
+ hiddens.dispose();
289
+ out.dispose(); // prefill outputs are not seeds; only KV matters here
290
+ }
291
+ if (this.#cache.offset !== L - 1)
292
+ throw new Error(`qwen MTP prefill offset ${this.#cache.offset}, expected ${L - 1}`);
293
+ } finally {
294
+ ctxML?.dispose(); // ownership per the seam contract
295
+ }
296
+ }
297
+
298
+ async draft(feed: number[], n: number, stepBase: number): Promise<number[]> {
299
+ this.#checkOpen();
300
+ if (this.#roundAppended !== 0)
301
+ throw new Error("qwen MTP draft called before the prior round committed");
302
+ if (n <= 0) return [];
303
+ const pending = feed.at(-1);
304
+ if (!Number.isSafeInteger(pending) || pending! < 0)
305
+ throw new Error("qwen MTP requires a non-empty token feed");
306
+
307
+ const drafts: number[] = [];
308
+ let chained: MlxArray | null = null;
309
+ const startOffset = this.#cache.offset;
310
+ try {
311
+ // Build the pending token's row from the TRUE target hidden at the
312
+ // preceding position (prefill tail on round 1, verify tap afterwards).
313
+ if (!this.#pendingTrueHidden)
314
+ throw new Error("qwen MTP draft before prefill/commit");
315
+ const out = this.#stepOne(pending!, this.#pendingTrueHidden);
316
+ this.#pendingTrueHidden.dispose();
317
+ this.#pendingTrueHidden = null;
318
+ this.#roundAppended++;
319
+ drafts.push(this.#sample(out, stepBase));
320
+ chained = out;
321
+ while (drafts.length < n) {
322
+ const out = this.#stepOne(drafts.at(-1)!, chained!);
323
+ chained!.dispose();
324
+ chained = out;
325
+ this.#roundAppended++;
326
+ drafts.push(this.#sample(out, stepBase + drafts.length));
327
+ }
328
+ return drafts;
329
+ } catch (error) {
330
+ const appended = this.#cache.offset - startOffset;
331
+ if (appended > 0) this.#cache.trim(appended);
332
+ this.#roundAppended = 0;
333
+ throw error;
334
+ } finally {
335
+ chained?.dispose();
336
+ }
337
+ }
338
+
339
+ /** mlx-vlm accept semantics: keep drafted rows for accepted positions,
340
+ * trim the rejected tail, then append the missing accepted row (all-accept
341
+ * case) and the correction/bonus row using the TARGET's verified pre-norm
342
+ * hiddens — harvesting the last output as the next round's seed. */
343
+ async commit(
344
+ d: number,
345
+ kAccept: number,
346
+ vCtxML?: MlxArray,
347
+ _verifiedHidden?: MlxArray,
348
+ acceptedTokens: readonly number[] = [],
349
+ ): Promise<void> {
350
+ this.#checkOpen();
351
+ try {
352
+ if (!vCtxML)
353
+ throw new Error("qwen MTP commit requires the tapped verify context");
354
+ if (kAccept > 0 && acceptedTokens.length !== kAccept)
355
+ throw new Error(
356
+ `qwen MTP received ${acceptedTokens.length} accepted tokens for k=${kAccept}`,
357
+ );
358
+ // Rows appended this round cover window tokens 0..d-1 (pending +
359
+ // drafts[0..d-2]); rows keyed by ACCEPTED window tokens (0..kAccept)
360
+ // survive — trim the rejected tail.
361
+ const trim = Math.max(this.#roundAppended - 1 - kAccept, 0);
362
+ if (trim > 0) this.#cache.trim(trim);
363
+
364
+ // All-accept case: window token d (= drafts[d-1]) has no drafted row —
365
+ // append it from the TRUE verify hidden (vCtx row d-1, the hidden at
366
+ // its preceding position). The correction/bonus row is NOT built here:
367
+ // the serve loop re-feeds that token in the next draft()'s `feed`, and
368
+ // draft() opens by building its row from #pendingTrueHidden below.
369
+ const H = vCtxML.shape[2]!;
370
+ if (kAccept === d && d > 0) {
371
+ const ids = ops.fromInt32([acceptedTokens[kAccept - 1]!], [1, 1]);
372
+ const embeds = this.#target.embed.encode(ids);
373
+ ids.dispose();
374
+ const hidden = vCtxML.slice([0, d - 1, 0], [1, d, H]);
375
+ const out = this.#module.forward(embeds, hidden, this.#cache);
376
+ embeds.dispose();
377
+ hidden.dispose();
378
+ out.dispose();
379
+ }
380
+ // Next round's pending row input: the target's verified pre-norm
381
+ // hidden at the emitted position.
382
+ this.#pendingTrueHidden?.dispose();
383
+ this.#pendingTrueHidden = vCtxML.slice([0, kAccept, 0], [1, kAccept + 1, H]);
384
+ } finally {
385
+ vCtxML?.dispose(); // seam: commit takes ownership
386
+ this.#roundAppended = 0;
387
+ }
388
+ }
389
+
390
+ dispose(): void {
391
+ if (this.#closed) return;
392
+ this.#closed = true;
393
+ this.#cache.dispose();
394
+ this.#pendingTrueHidden?.dispose();
395
+ }
396
+
397
+ /** One module forward for (token, hidden) — appends one KV row. */
398
+ #stepOne(token: number, hidden: MlxArray): MlxArray {
399
+ const ids = ops.fromInt32([token], [1, 1]);
400
+ const embed = this.#target.embed.encode(ids);
401
+ ids.dispose();
402
+ const out = this.#module.forward(embed, hidden, this.#cache);
403
+ embed.dispose();
404
+ return out;
405
+ }
406
+
407
+ /** Sample a draft token from the module output via the TARGET's lm head
408
+ * and the request sampler (per-step RNG stream discipline). */
409
+ #sample(moduleOut: MlxArray, step: number): number {
410
+ const logits = this.#target.logitsFromHidden(moduleOut);
411
+ // Sampler contract is [1, V] (the main decode loop's shape). moduleOut
412
+ // is [1, 1, H] → logits [1, 1, V]; without this reshape any sampler
413
+ // that slices 2-D (top-k) throws "[slice] Invalid number of indices…
414
+ // dimension 3" — the serve-lane MTP 500 (chat defaults carry the
415
+ // model's top_k=20; the greedy bench harness never hit it).
416
+ const V = logits.shape[logits.shape.length - 1]!;
417
+ const flat = ops.reshape(logits, [1, V]);
418
+ logits.dispose();
419
+ const logprobs = toLogprobs(flat);
420
+ flat.dispose();
421
+ const tok = this.#sampler(logprobs, step);
422
+ logprobs.dispose();
423
+ const id = ops.itemUint32(tok);
424
+ tok.dispose();
425
+ return id;
426
+ }
427
+
428
+ #checkOpen(): void {
429
+ if (this.#closed) throw new Error("qwen MTP source is closed");
430
+ }
431
+ }
@@ -46,7 +46,7 @@ import { flagOn } from "../flags";
46
46
  import type { RuntimeModel } from "../model/factory";
47
47
  import type { Cache } from "../model/gemma4";
48
48
  import type { GenerateOptions, GenerateStats } from "../generate";
49
- import { makeSampler, makeLogitsProcessors, toLogprobs } from "../sampler";
49
+ import { makeSampler, makeStepSampler } from "../sampler";
50
50
  import type { OnToken } from "../serve/generation-gateway";
51
51
  import type { DraftProvider } from "./source";
52
52
 
@@ -132,7 +132,15 @@ export async function specServeRun(
132
132
  const maxTokens = options.maxTokens ?? 512;
133
133
  const eos = options.eosTokenIds ?? model.config.eosTokenIds;
134
134
  const sampler = makeSampler(options);
135
- const processors = makeLogitsProcessors(options);
135
+ const stepSampler = makeStepSampler(options, {
136
+ tokenRepresentation: "number",
137
+ grammarWait: "before-sample",
138
+ historyUpdate: "after-sample",
139
+ initialHistory: promptIds,
140
+ acceptGrammar: true,
141
+ eosTokenIds: eos,
142
+ sampler,
143
+ });
136
144
  const gamma = Math.max(1, numDraftTokens);
137
145
 
138
146
  // Allocated INSIDE the try below (2026-07-07 review): provider.open()
@@ -159,11 +167,6 @@ export async function specServeRun(
159
167
  let vCtxML: MlxArray | null = null;
160
168
  let vLogits: MlxArray | null = null;
161
169
  let roundRow: MlxArray | null = null; // the in-flight verify/continuation row
162
- // Device-side token history for the logits processors (repetition/presence/
163
- // frequency penalties, logit_bias) — generate()'s pushHistory discipline.
164
- let history: MlxArray | null =
165
- processors.length > 0 ? ops.fromInt32(promptIds, [promptIds.length]) : null;
166
-
167
170
  const extras: SpecServeExtras = {
168
171
  drafted: 0,
169
172
  accepted: 0,
@@ -185,65 +188,12 @@ export async function specServeRun(
185
188
  spec: extras,
186
189
  };
187
190
 
188
- /** Sample ONE verify position from a [1,V] logits row: processors →
189
- * grammar mask (Phase C: the constrained verify walk — the mask has the
190
- * final say, same ordering as serial sampleStep and the batch closure) →
191
- * log-softmax → sampler. The GRAMMAR walk is inherently sequential: the
192
- * mask at position i reflects every token emitted before it, so this
193
- * awaits the matcher's async fill (fired by the previous accept) before
194
- * masking, samples, then advances the matcher — the matcher only ever
195
- * moves on tokens that are (about to be) EMITTED, which is what makes
196
- * rollback-free v1 correct: rejected DRAFTS never touch grammar state.
197
- * Appends the sampled token to the processor history. */
191
+ /** The speculative walk needs eager token ids and waits for each grammar
192
+ * mask. StepSampler keeps its processor, mask, sample, and history ordering
193
+ * identical to the serial and batched lanes. */
198
194
  const grammar = options.grammar;
199
- const samplePos = async (logits1V: MlxArray, step: number): Promise<number> => {
200
- // Owned intermediates drained in the finally on any throw — grammar
201
- // fill reject, applyMask/toLogprobs/sampler error (2026-07-07 review:
202
- // these were the one spot the header's hoist discipline missed; a
203
- // grammar+penalties request whose matcher fill rejects orphaned one
204
- // [1,V] row per occurrence). `cur` is owned only once it diverges from
205
- // the caller's row.
206
- let cur: MlxArray = logits1V;
207
- let lp: MlxArray | null = null;
208
- let tokArr: MlxArray | null = null;
209
- try {
210
- for (const p of processors) {
211
- const next = p(history, cur);
212
- if (cur !== logits1V) cur.dispose();
213
- cur = next;
214
- }
215
- if (grammar && !grammar.isTerminated) {
216
- await grammar.ready();
217
- const masked = grammar.applyMask(cur);
218
- if (cur !== logits1V) cur.dispose();
219
- cur = masked;
220
- }
221
- lp = toLogprobs(cur);
222
- if (cur !== logits1V) cur.dispose();
223
- cur = logits1V; // consumed — nothing owned under this name now
224
- tokArr = sampler(lp, step);
225
- lp.dispose();
226
- lp = null;
227
- const tok = ops.itemUint32(tokArr);
228
- tokArr.dispose();
229
- tokArr = null;
230
- // Advance the matcher on the emitted token (fires the next async fill).
231
- // EOS is never content and never grammar-valid — don't feed it.
232
- if (grammar && !eos.includes(tok)) grammar.accept(tok);
233
- if (history) {
234
- const t1 = ops.fromInt32([tok], [1]);
235
- const prev = history;
236
- history = ops.concatAxis([prev, t1], 0);
237
- prev.dispose();
238
- t1.dispose();
239
- }
240
- return tok;
241
- } finally {
242
- if (cur !== logits1V) cur.dispose();
243
- lp?.dispose();
244
- tokArr?.dispose();
245
- }
246
- };
195
+ const samplePos = async (logits1V: MlxArray, step: number): Promise<number> =>
196
+ (await stepSampler.sample(logits1V, step)).token;
247
197
 
248
198
  /** The [1,V] logits row at position `pos` of a hidden window — batched
249
199
  * lm-head is applied by the caller ONCE; this slices its output. */
@@ -433,10 +383,14 @@ export async function specServeRun(
433
383
  // KV would be woven into the live window (unrecoverable, upstream
434
384
  // RAISES here, generate.py:529-533). Degrade to plain decode while the
435
385
  // caches are still clean instead of 500ing a stream.
386
+ // Non-trimmable recurrent caches (SSMCache — gated-DeltaNet state) are
387
+ // spec-eligible through the round snapshot/replay contract instead:
388
+ // specRoundBegin before the verify forward, then commit or a bit-exact
389
+ // rollback(keep) after the accept walk.
436
390
  const roundFits = caches.every((c) =>
437
391
  "maxSize" in c && typeof (c as { maxSize?: number }).maxSize === "number"
438
392
  ? c.offset + n + 1 < (c as { maxSize: number }).maxSize
439
- : c.isTrimmable(),
393
+ : c.isTrimmable() || typeof c.specRoundBegin === "function",
440
394
  );
441
395
  if (!roundFits) {
442
396
  console.warn(
@@ -476,6 +430,9 @@ export async function specServeRun(
476
430
  // (b) ONE target forward over [pending, ...drafts] (optionally tapped for
477
431
  // DSpark's H_ctx). vHidden is retained past the accept walk: its slice at
478
432
  // the emitted position is the next round's anchor hidden.
433
+ // Arm recurrent caches' spec round around the verify forward (no-op for
434
+ // trimmable caches, which roll back via trim() below).
435
+ for (const c of caches) c.specRoundBegin?.();
479
436
  const vIds = ops.fromInt32([pending, ...drafts], [1, d + 1]);
480
437
  const pinTarget = src.pinTargetKernelFamily === true &&
481
438
  "setSpecKernelPinned" in model;
@@ -565,7 +522,18 @@ export async function specServeRun(
565
522
  // position (the assistant borrows it; generate.ts:230 pattern — the slice
566
523
  // outlives its parent's dispose).
567
524
  if (!stop) {
568
- if (kAccept < d) for (const c of caches) c.trim(d - kAccept);
525
+ // The verify window kept positions 0..kAccept (pending + accepted
526
+ // drafts). Trimmable caches drop the rejected tail; recurrent caches
527
+ // restore their pre-round snapshot and bit-exactly replay those
528
+ // kAccept+1 kept tokens. On full accept the round just commits.
529
+ if (kAccept < d) {
530
+ for (const c of caches) {
531
+ if (c.specRoundRollback) c.specRoundRollback(kAccept + 1);
532
+ else c.trim(d - kAccept);
533
+ }
534
+ } else {
535
+ for (const c of caches) c.specRoundCommit?.();
536
+ }
569
537
  await src.commit(
570
538
  d,
571
539
  kAccept,
@@ -610,7 +578,7 @@ export async function specServeRun(
610
578
  vCtxML?.dispose();
611
579
  for (const c of caches) c.dispose();
612
580
  source?.dispose();
613
- history?.dispose();
581
+ stepSampler.dispose();
614
582
  options.grammar?.dispose(); // Phase C will consume it; never leak either way
615
583
  clearCache();
616
584
  }