mlx-bun 0.0.13 → 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 (224) hide show
  1. package/README.md +31 -21
  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/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
  8. package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
  9. package/docs/design/colibri-glm52-port.md +1 -1
  10. package/docs/design/compat-cli-surface-design.md +1 -1
  11. package/docs/design/decode-speed-program.md +1 -1
  12. package/docs/design/docs-reorg-plan.md +1 -1
  13. package/docs/design/dspark-serving-program.md +1 -1
  14. package/docs/design/dspark-speculative-decoding.md +53 -3
  15. package/docs/design/grammar-spec-batching-integration.md +1 -1
  16. package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
  17. package/docs/design/orpo-dynamic-lambda.md +1 -1
  18. package/docs/design/orpo-future-enhancements.md +1 -1
  19. package/docs/design/orpo-training.md +2 -2
  20. package/docs/design/parity-tier-dag.md +1 -1
  21. package/docs/design/repo-cleanup-plan.md +46 -4
  22. package/docs/design/segmented-backward-training.md +2 -2
  23. package/docs/design/ssd-kv-cold-tier.md +127 -0
  24. package/docs/design/turboquant-weights.md +467 -0
  25. package/docs/design/w4a16-compute-precision-spike.md +1 -1
  26. package/docs/design/web-chat-redesign.md +5 -5
  27. package/docs/planning/chunk-task-roadmap.md +1 -1
  28. package/docs/planning/curve-sampler-research-plan.md +4 -4
  29. package/docs/planning/memory-docs-and-dag-plan.md +1 -1
  30. package/docs/planning/release-notes-v0.2.0.md +49 -0
  31. package/docs/planning/video-series.md +421 -0
  32. package/docs/reference/cli.md +15 -4
  33. package/docs/reference/distribution.md +1 -0
  34. package/docs/reference/features-matrix.md +3 -2
  35. package/docs/reference/library-api.md +41 -1
  36. package/docs/reference/models.md +33 -0
  37. package/docs/reference/server-api.md +69 -14
  38. package/docs/reference/server-config.md +57 -33
  39. package/package.json +2 -2
  40. package/src/anthropic.ts +92 -3
  41. package/src/cli.ts +131 -36
  42. package/src/eval/kl.ts +27 -20
  43. package/src/eval/runner.ts +23 -4
  44. package/src/eval/tasks/bfcl.ts +2 -1
  45. package/src/eval/tasks/gsm8k.ts +2 -1
  46. package/src/eval/tasks/hashhop.ts +2 -1
  47. package/src/eval/tasks/humaneval.ts +5 -4
  48. package/src/eval/tasks/ifeval.ts +2 -1
  49. package/src/eval/tasks/mmlu.ts +2 -1
  50. package/src/expert-io.ts +3 -1
  51. package/src/expert-offload.ts +3 -1
  52. package/src/expert-trace.ts +3 -1
  53. package/src/fit.ts +24 -43
  54. package/src/flags.ts +7 -9
  55. package/src/generate.ts +107 -92
  56. package/src/grammar.ts +2 -1
  57. package/src/harness-pi.ts +1 -1
  58. package/src/index.ts +20 -0
  59. package/src/jobs/job-entry.ts +3 -2
  60. package/src/kv-scheme.ts +199 -0
  61. package/src/kv-store.ts +370 -205
  62. package/src/media-fetch.ts +19 -3
  63. package/src/memory/model.ts +2 -1
  64. package/src/memory/vault.ts +2 -1
  65. package/src/memory-plan.ts +18 -0
  66. package/src/mlx/ffi.ts +8 -11
  67. package/src/mlx/ops.ts +31 -6
  68. package/src/model/batched-mask.ts +1 -1
  69. package/src/model/batched-quant.ts +1 -1
  70. package/src/model/batched-rotating-quant.ts +43 -97
  71. package/src/model/batched-rotating-state.ts +73 -0
  72. package/src/model/batched-rotating.ts +56 -105
  73. package/src/model/batched-row-storage.ts +127 -0
  74. package/src/model/compiled-decode.ts +3 -2
  75. package/src/model/factory.ts +44 -49
  76. package/src/model/gemma4-base.ts +163 -10
  77. package/src/model/generated/gemma4-12b.ts +50 -146
  78. package/src/model/generated/gemma4-26b.ts +32 -92
  79. package/src/model/generated/gemma4-e4b.ts +26 -74
  80. package/src/model/glm52-cache.ts +19 -9
  81. package/src/model/glm52-memory.ts +28 -1
  82. package/src/model/minicpm5.ts +2 -1
  83. package/src/model/profile.ts +433 -0
  84. package/src/model/qwen3-delta.ts +98 -2
  85. package/src/model/qwen3-mrope.ts +252 -0
  86. package/src/model/qwen3_5.ts +217 -25
  87. package/src/native/frame_extract.swift +140 -0
  88. package/src/native-pack.ts +13 -6
  89. package/src/pi-web.ts +6 -5
  90. package/src/prompt-cache.ts +2 -2
  91. package/src/quantize/atomic-output.ts +38 -0
  92. package/src/quantize/calibration.ts +2 -1
  93. package/src/quantize/config-writer.ts +6 -0
  94. package/src/quantize/index.ts +28 -1
  95. package/src/quantize/job.ts +11 -0
  96. package/src/quantize/quantizer.ts +265 -100
  97. package/src/quantize/rotate.ts +603 -0
  98. package/src/quantize/safetensors-writer.ts +79 -1
  99. package/src/quantize/sensitivity.ts +41 -59
  100. package/src/quantize/weight-transform.ts +292 -0
  101. package/src/responses.ts +102 -2
  102. package/src/runtime-config.ts +67 -0
  103. package/src/sampler.ts +272 -0
  104. package/src/serve/admin-routes.ts +184 -0
  105. package/src/serve/aux-routes.ts +132 -0
  106. package/src/serve/batch-scheduler.ts +200 -87
  107. package/src/serve/completion-executor.ts +346 -0
  108. package/src/serve/completion-sink.ts +157 -0
  109. package/src/serve/discovery-routes.ts +205 -0
  110. package/src/serve/generation-gateway.ts +116 -109
  111. package/src/serve/kv-budget.ts +14 -0
  112. package/src/serve/lab-routes.ts +338 -0
  113. package/src/serve/model-admin-routes.ts +153 -0
  114. package/src/serve/prompt-response-trace.ts +147 -0
  115. package/src/serve/request-plan.ts +158 -0
  116. package/src/serve/static-routes.ts +101 -0
  117. package/src/server.ts +704 -1400
  118. package/src/spec/dflash-source.ts +2 -1
  119. package/src/spec/dspark/module-dflash.ts +1 -1
  120. package/src/spec/glm52-mtp-source.ts +8 -0
  121. package/src/spec/qwen-mtp-source.ts +431 -0
  122. package/src/spec/serve-loop.ts +36 -68
  123. package/src/ssd-cache.ts +17 -14
  124. package/src/ssd-durability.ts +195 -0
  125. package/src/train/flash-cce.ts +12 -11
  126. package/src/train/forward.ts +32 -1
  127. package/src/train/loss.ts +2 -1
  128. package/src/train/segmented.ts +4 -3
  129. package/src/train/trainer.ts +3 -2
  130. package/src/vision/preprocess.ts +1 -1
  131. package/src/vision/prompt.ts +47 -1
  132. package/src/vision/qwen3vl-preprocess.ts +409 -0
  133. package/src/vision/qwen3vl-prompt.ts +196 -0
  134. package/src/vision/qwen3vl-tower.ts +513 -0
  135. package/src/vision/video-frames.ts +148 -0
  136. package/src/web/app.js +10 -3
  137. package/src/web/src/chat.ts +12 -5
  138. package/src/weights.ts +81 -26
  139. /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
  140. /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
  141. /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
  142. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
  143. /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
  144. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
  145. /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
  146. /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
  147. /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
  148. /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
  149. /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
  150. /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
  151. /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
  152. /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
  153. /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
  154. /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
  155. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
  156. /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
  157. /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
  158. /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
  159. /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
  160. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
  161. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
  162. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
  163. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
  164. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
  165. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
  166. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
  167. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
  168. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
  169. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
  170. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
  171. /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
  172. /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
  173. /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
  174. /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
  175. /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
  176. /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
  177. /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
  178. /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
  179. /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
  180. /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
  181. /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
  182. /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
  183. /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
  184. /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
  185. /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
  186. /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
  187. /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
  188. /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
  189. /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
  190. /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
  191. /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
  192. /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
  193. /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
  194. /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
  195. /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
  196. /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
  197. /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
  198. /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
  199. /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
  200. /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
  201. /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
  202. /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
  203. /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
  204. /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
  205. /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
  206. /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
  207. /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
  208. /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
  209. /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
  210. /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
  211. /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
  212. /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
  213. /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
  214. /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
  215. /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
  216. /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
  217. /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
  218. /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
  219. /package/docs/{planning → archive/planning}/journal.md +0 -0
  220. /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
  221. /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
  222. /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
  223. /package/docs/{planning → archive/planning}/release-notes-v0.0.13.md +0 -0
  224. /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
@@ -26,7 +26,7 @@
26
26
  // temporal axis and no padding (rows solo-prefill unpadded, decode feeds one
27
27
  // real token per row), so merge/filter are B-axis concat/take and the cache
28
28
  // passes through the step unwrapped (no mask, no per-row RoPE — full layers
29
- // carry positions). Gate: GenerationGateway.willBatch on cache capability.
29
+ // carry positions). Gate: GenerationGateway.place on cache capability.
30
30
  //
31
31
  // Engine mechanics (the serial decode loop's hygiene, transplanted —
32
32
  // batching-v2-plan step 3):
@@ -56,13 +56,22 @@
56
56
 
57
57
  import { MlxArray } from "../mlx/array";
58
58
  import * as ops from "../mlx/ops";
59
- import { clearCache, Dtype } from "../mlx/ffi";
59
+ import { activeMemory, cacheMemory, clearCache, Dtype, peakMemory } from "../mlx/ffi";
60
60
  import { flagOn } from "../flags";
61
+ import { runtimeValue } from "../runtime-config";
61
62
  import { CompiledDecode } from "../model/compiled-decode";
62
63
  import type { Gemma4Model } from "../model/gemma4";
63
64
  import {
64
65
  KVCache, QuantizedKVCache, RotatingKVCache, RotatingQuantizedKVCache,
65
- isBatchableCache, type BatchableCache, type Cache,
66
+ cacheSignature,
67
+ isBatchableCache,
68
+ isPlainKvCache,
69
+ isQuantizedKvCache,
70
+ isRotatingPlainCache,
71
+ isRotatingQuantizedCache,
72
+ isRowBatchCache,
73
+ type BatchableCache,
74
+ type Cache,
66
75
  } from "../model/gemma4-base";
67
76
  import { BatchedDecodeMaskCache, mergeKVRows, extendKVRows, extractKVRow, filterKVRows } from "../model/batched-mask";
68
77
  import {
@@ -71,24 +80,27 @@ import {
71
80
  } from "../model/batched-quant";
72
81
  import { BatchedRotatingQuantCache } from "../model/batched-rotating-quant";
73
82
  import type { KvQuantSpec } from "../config";
83
+ import type { KvScheme } from "../kv-scheme";
74
84
  import { BatchedRotatingCache } from "../model/batched-rotating";
75
85
  import { cloneKvCaches } from "../kv-store";
76
86
  import { SSMCache } from "../model/qwen3-delta";
77
87
  import type { RuntimeModel } from "../model/factory";
78
88
  import type { GrammarController } from "../grammar";
79
- import { kvBytesAt } from "../fit";
80
89
  import { toLogprobs } from "../sampler";
90
+ import { acquireModelWiredLimit } from "../generate";
91
+ import { batchRowKvBytes } from "./kv-budget";
92
+ import type { PromptResponseTrace } from "./prompt-response-trace";
81
93
 
82
94
  /** Decode-pipeline kill switch (read once at load, like the serial loop's
83
95
  * MLX_BUN_COMPILED_DECODE): 1 ⇒ read each step's tokens synchronously. */
84
- const NO_PIPELINE = process.env.MLX_BUN_BATCH_NO_PIPELINE === "1";
96
+ const NO_PIPELINE = runtimeValue("MLX_BUN_BATCH_NO_PIPELINE") === "1";
85
97
 
86
98
  /** Per-step phase timing (MLX_BUN_BATCH_STEP_TRACE=1, debug-only): where a
87
99
  * decode step's wall time goes — graph BUILD+dispatch (host), the pipelined
88
100
  * READ of the previous step's tokens (GPU wait), row EMIT (onToken/SSE), and
89
101
  * the GAP between consecutive steps (drive-loop + everything else). Sums
90
102
  * print via `stepTraceReport()` (the b1 profile experiment calls it). */
91
- const STEP_TRACE = process.env.MLX_BUN_BATCH_STEP_TRACE === "1";
103
+ const STEP_TRACE = runtimeValue("MLX_BUN_BATCH_STEP_TRACE") === "1";
92
104
  const STEP_T = { t0: 0, lastEnd: 0, build: 0, read: 0, emit: 0, gap: 0, n: 0 };
93
105
  export function stepTraceReport(): string {
94
106
  const per = (x: number) => (STEP_T.n ? (x / STEP_T.n).toFixed(3) : "0");
@@ -125,6 +137,10 @@ export interface BatchRequest {
125
137
  * THIS row only (its submit promise rejects; siblings continue). May be
126
138
  * async; keep it cheap — it runs inline in the step loop. */
127
139
  onToken: (token: number) => void | boolean | Promise<void | boolean>;
140
+ /** Diagnostic-only request-local trace. */
141
+ trace?: PromptResponseTrace;
142
+ /** Closes the gateway's queue/admission span when this row leaves pending. */
143
+ onAdmitted?: () => void;
128
144
  /** Client/request lifetime. An aborted pending row is removed before
129
145
  * admission; a row in prefill or decode is evicted at the next safe
130
146
  * scheduler boundary. */
@@ -223,6 +239,7 @@ interface PrefillState {
223
239
  * exactly here, clones + put()s the trim-free prefix entry, then nulls
224
240
  * it. null = no snapshot for this row (short prompt / cached past it). */
225
241
  snapAt: number | null;
242
+ closePrefill?: () => void;
226
243
  }
227
244
 
228
245
  /** Held while the batch is active; the serial fallback acquires the same lock. */
@@ -248,16 +265,10 @@ export interface BatchSchedulerOptions {
248
265
  * evict. A request that can NEVER fit (over budget alone, empty batch)
249
266
  * is rejected instead of deadlocking. Unset = unlimited (v1 behavior). */
250
267
  kvBudgetBytes?: number;
251
- /** Per-layer mixed-precision KV scheme (kv_config.json entries) — Phase
252
- * 3.1 batched quantized KV. Server-wide constant (one scheme per server
253
- * lifetime), so it lives here at construction, not on BatchRequest.
254
- * P1 scope: only layers whose cache is a plain full-attention KVCache
255
- * convert; the GATEWAY admits batching only when every configured
256
- * layerIdx is such a layer (rotating/uniform stay serial). Each joiner's
257
- * solo prefill converts at the SAME chunk boundaries as the serial
258
- * maybeQuantizeKv, so a row's quantized bytes are bit-exact vs serial
259
- * `--kv-quant config` by construction (the L2-oracle composition rule). */
260
- kvConfig?: KvQuantSpec[];
268
+ /** Authoritative server-wide scheme. Each joiner's solo prefill converts at
269
+ * the same chunk boundaries as the serial path, so a row's quantized bytes
270
+ * preserve the L2 composition. Unsupported schemes fail at construction. */
271
+ kvScheme?: KvScheme;
261
272
  /** Prompt-cache hook (Phase 3.2): admission take()s the longest usable
262
273
  * prefix into the joiner's solo caches (suffix-only prefill — the
263
274
  * multi-turn chat TTFT path); rows that finish never-merged put() their
@@ -310,6 +321,7 @@ export class BatchScheduler {
310
321
  readonly #rotMaxSize: number[]; // per-layer sliding window (rot layers only)
311
322
  readonly #compressedProjectors: Array<(tokens: number) => number> | null;
312
323
  readonly #batchCacheMaxTokens: number | null;
324
+ readonly #kvScheme: KvScheme | undefined;
313
325
  /** layerIdx → mixed-precision spec (Phase 3.1); null = bf16 batch (v1). */
314
326
  readonly #kvByLayer: Map<number, KvQuantSpec> | null;
315
327
  /** Compiled decode runner for the B=1 serial-class case (Phase 3.2) —
@@ -326,13 +338,22 @@ export class BatchScheduler {
326
338
  this.#prefillChunkSize = Math.max(1, Math.floor(opts.prefillChunkSize ?? 2048));
327
339
  this.#kvBudgetBytes = opts.kvBudgetBytes;
328
340
  this.#promptCache = opts.promptCache;
341
+ this.#kvScheme = opts.kvScheme;
329
342
  const proto = model.makeCache(); // fresh caches hold no buffers
343
+ if (this.#kvScheme && !this.#kvScheme.batchable(
344
+ model.config,
345
+ (layerIdx) =>
346
+ isPlainKvCache(proto[layerIdx]) || isRotatingPlainCache(proto[layerIdx]),
347
+ )) {
348
+ for (const cache of proto) cache.dispose();
349
+ throw new Error(`unsupported KV scheme for batch scheduler: ${this.#kvScheme.kind}`);
350
+ }
330
351
  this.#kinds = proto.map((c) =>
331
352
  isBatchableCache(c)
332
353
  ? "owned-batch"
333
- : c instanceof RotatingKVCache
354
+ : isRotatingPlainCache(c)
334
355
  ? "rot"
335
- : c instanceof SSMCache
356
+ : cacheSignature(c) === "ssm"
336
357
  ? "ssm"
337
358
  : "full",
338
359
  );
@@ -342,10 +363,11 @@ export class BatchScheduler {
342
363
  this.#batchCacheMaxTokens = proto.every(isBatchableCache)
343
364
  ? Math.min(...proto.map((cache) => cache.maxTokens ?? Number.MAX_SAFE_INTEGER))
344
365
  : null;
345
- this.#kvByLayer = opts.kvConfig?.length
346
- ? new Map(opts.kvConfig.map((e) => [e.layerIdx, e]))
366
+ const kvConfig = this.#kvScheme?.options.kvConfig;
367
+ this.#kvByLayer = kvConfig?.length
368
+ ? new Map(kvConfig.map((entry) => [entry.layerIdx, entry]))
347
369
  : null;
348
- this.#rotMaxSize = proto.map((c) => (c instanceof RotatingKVCache ? c.maxSize : 0));
370
+ this.#rotMaxSize = proto.map((c) => (isRotatingPlainCache(c) ? c.maxSize : 0));
349
371
  for (const c of proto) c.dispose();
350
372
  this.#compiled =
351
373
  flagOn("MLX_BUN_COMPILED_DECODE", true) &&
@@ -366,10 +388,17 @@ export class BatchScheduler {
366
388
  /** Projected KV bytes of one row at its worst case (full prompt + full
367
389
  * completion; the sliding-window term is window-capped by kvBytesAt). */
368
390
  #rowKvBytes(row: Row): number {
369
- const tokens = row.promptTokens + row.req.maxTokens;
370
391
  return this.#compressedProjectors
371
- ? this.#compressedProjectors.reduce((sum, project) => sum + project(tokens), 0)
372
- : kvBytesAt(this.model.config, tokens);
392
+ ? this.#compressedProjectors.reduce(
393
+ (sum, project) => sum + project(row.promptTokens + row.req.maxTokens),
394
+ 0,
395
+ )
396
+ : batchRowKvBytes(
397
+ this.model.config,
398
+ row.promptTokens,
399
+ row.req.maxTokens,
400
+ this.#kvScheme,
401
+ );
373
402
  }
374
403
 
375
404
  async #forwardHidden(ids: MlxArray, cache: Cache[]): Promise<MlxArray> {
@@ -463,6 +492,7 @@ export class BatchScheduler {
463
492
 
464
493
  async #drive(): Promise<void> {
465
494
  let release: (() => void) | null = null;
495
+ let releaseWired: (() => void) | null = null;
466
496
  try {
467
497
  while (true) {
468
498
  for (let i = this.#pending.length - 1; i >= 0; i--) {
@@ -480,10 +510,12 @@ export class BatchScheduler {
480
510
  (!held && this.#pending.length > 0);
481
511
  if (!hasWork) {
482
512
  if (release) { release(); release = null; } // let the serial lane in
513
+ if (releaseWired) { releaseWired(); releaseWired = null; }
483
514
  await new Promise<void>((r) => { this.#wake = r; });
484
515
  this.#wake = null;
485
516
  continue;
486
517
  }
518
+ releaseWired ??= acquireModelWiredLimit(this.model);
487
519
  if (!release && this.#lock) release = await this.#lock.acquire();
488
520
 
489
521
  // Start at most one joiner prefill (drain-aware); it advances one
@@ -492,10 +524,15 @@ export class BatchScheduler {
492
524
  this.#pending.length > 0 && this.#running.length < this.#maxBatch &&
493
525
  this.#kvAdmits(this.#pending[0]!)) {
494
526
  const row = this.#pending.shift()!;
527
+ row.req.onAdmitted?.();
495
528
  // Prompt-cache reuse (Phase 3.2): start the solo prefill from the
496
529
  // longest usable cached prefix — the serial lane's take(), same
497
530
  // semantics (entry ownership transfers; put back or dispose).
531
+ const closeCache = row.req.trace?.begin("cache.lookup_restore", {
532
+ mechanism: "continuous",
533
+ });
498
534
  const hit = this.#promptCache?.take(row.req.promptIds) ?? null;
535
+ closeCache?.();
499
536
  if (hit) row.cachedTokens = hit.tokens.length;
500
537
  // Boundary snapshot (the serial lane's A1, ported): a trim-free
501
538
  // strict-prefix entry at min(stableLen, len-1) for every
@@ -510,19 +547,35 @@ export class BatchScheduler {
510
547
  this.#promptCache && boundary >= 256 && boundary > (hit?.tokens.length ?? 0)
511
548
  ? boundary
512
549
  : null;
550
+ const closePrefill = row.req.trace?.begin("prefill.total", {
551
+ mechanism: "continuous",
552
+ promptTokens: row.promptTokens,
553
+ cachedTokens: row.cachedTokens,
554
+ });
555
+ const closeBatchSetup = row.req.trace?.begin("prefill.batch_setup", {
556
+ mechanism: "continuous",
557
+ });
513
558
  this.#prefill = hit
514
- ? { row, solo: hit.caches, pos: hit.tokens.length, retain: hit.retain, snapAt }
515
- : { row, solo: this.model.makeCache(), pos: 0, snapAt };
559
+ ? {
560
+ row, solo: hit.caches, pos: hit.tokens.length,
561
+ retain: hit.retain, snapAt, closePrefill,
562
+ }
563
+ : { row, solo: this.model.makeCache(), pos: 0, snapAt, closePrefill };
564
+ closeBatchSetup?.();
516
565
  }
517
566
 
518
567
  if (this.#prefill) {
519
568
  const p = this.#prefill;
520
569
  try {
521
- if (await this.#prefillChunk(p)) this.#prefill = null;
570
+ if (await this.#prefillChunk(p)) {
571
+ p.closePrefill?.();
572
+ this.#prefill = null;
573
+ }
522
574
  } catch (e) {
523
575
  // Prefill/admission failure is per-row: reject the joiner, keep
524
576
  // the running batch.
525
577
  this.#prefill = null;
578
+ p.closePrefill?.();
526
579
  for (const c of p.solo) c.dispose();
527
580
  p.retain?.();
528
581
  p.row.reject(e);
@@ -569,6 +622,7 @@ export class BatchScheduler {
569
622
  }
570
623
  } finally {
571
624
  if (release) release();
625
+ releaseWired?.();
572
626
  this.#looping = false;
573
627
  }
574
628
  }
@@ -580,23 +634,30 @@ export class BatchScheduler {
580
634
  * source frees before the next layer converts). Called at every prefill
581
635
  * chunk boundary AND once before merge, exactly where the serial loop
582
636
  * calls maybeQuantizeKv — that placement is what makes a row's quantized
583
- * bytes bit-exact vs serial `--kv-quant config`. P1: plain KVCache
584
- * layers only (the gateway guarantees the config maps only to those). */
585
- #quantizeSolo(solo: Cache[]): void {
637
+ * bytes bit-exact vs serial `--kv-quant config`. Gateway placement and the
638
+ * constructor both guarantee every named cache can convert. */
639
+ #quantizeSolo(solo: Cache[], trace?: PromptResponseTrace): void {
586
640
  if (!this.#kvByLayer) return;
587
- for (let i = 0; i < solo.length; i++) {
588
- const e = this.#kvByLayer.get(i);
589
- const c = solo[i]!;
590
- if (!e || c.offset === 0) continue;
591
- // Milestone 2: rotating layers convert too — RotatingKVCache.
592
- // toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
593
- // dispatch (generate.ts). Converted caches don't match either class
594
- // (the four cache classes are siblings), so re-conversion never fires.
595
- if (!(c instanceof KVCache || c instanceof RotatingKVCache)) continue;
596
- const q = c.toQuantized(e.groupSize, e.bits);
597
- solo[i] = q;
598
- ops.evalAll(q.state());
599
- clearCache();
641
+ const close = trace?.begin("prefill.kv_maintenance", {
642
+ mechanism: "continuous",
643
+ });
644
+ try {
645
+ for (let i = 0; i < solo.length; i++) {
646
+ const e = this.#kvByLayer.get(i);
647
+ const c = solo[i]!;
648
+ if (!e || c.offset === 0) continue;
649
+ // Milestone 2: rotating layers convert too — RotatingKVCache.
650
+ // toQuantized → RotatingQuantizedKVCache, exactly maybeQuantizeKv's
651
+ // dispatch (generate.ts). Converted caches don't match either class
652
+ // (the four cache classes are siblings), so re-conversion never fires.
653
+ if (!(isPlainKvCache(c) || isRotatingPlainCache(c))) continue;
654
+ const q = c.toQuantized(e.groupSize, e.bits);
655
+ solo[i] = q;
656
+ ops.evalAll(q.state());
657
+ clearCache();
658
+ }
659
+ } finally {
660
+ close?.();
600
661
  }
601
662
  }
602
663
 
@@ -607,6 +668,12 @@ export class BatchScheduler {
607
668
  async #prefillChunk(p: PrefillState): Promise<boolean> {
608
669
  p.row.req.signal?.throwIfAborted();
609
670
  const prompt = p.row.req.promptIds;
671
+ const chunkStart = p.pos;
672
+ const closeChunk = p.row.req.trace?.begin("prefill.chunk", {
673
+ mechanism: "continuous",
674
+ startToken: chunkStart,
675
+ });
676
+ try {
610
677
  // Boundary-snapshot chunking (mirrors generate.ts's snapshotAt split):
611
678
  // chunk edges land EXACTLY on snapAt so the clone is taken while the
612
679
  // caches hold precisely that prefix — slicing tokens against caches at
@@ -621,7 +688,7 @@ export class BatchScheduler {
621
688
  ids.dispose();
622
689
  h.dispose();
623
690
  ops.evalAll(p.solo.flatMap((c) => c.state()));
624
- this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
691
+ this.#quantizeSolo(p.solo, p.row.req.trace); // serial converts at every chunk boundary
625
692
  clearCache();
626
693
  p.pos = end;
627
694
  if (p.pos === p.snapAt) {
@@ -651,7 +718,7 @@ export class BatchScheduler {
651
718
  ids.dispose();
652
719
  h.dispose(); // logits never computed for non-final chunks
653
720
  ops.evalAll(p.solo.flatMap((c) => c.state()));
654
- this.#quantizeSolo(p.solo); // serial converts at every chunk boundary
721
+ this.#quantizeSolo(p.solo, p.row.req.trace); // serial converts at every chunk boundary
655
722
  clearCache(); // serial prefill's per-chunk clear (generate.ts)
656
723
  p.pos += this.#prefillChunkSize;
657
724
  return false;
@@ -669,7 +736,7 @@ export class BatchScheduler {
669
736
  ids.dispose();
670
737
  h.dispose();
671
738
  ops.evalAll(p.solo.flatMap((c) => c.state()));
672
- this.#quantizeSolo(p.solo);
739
+ this.#quantizeSolo(p.solo, p.row.req.trace);
673
740
  clearCache();
674
741
  p.pos = prompt.length - 1;
675
742
  }
@@ -683,15 +750,51 @@ export class BatchScheduler {
683
750
  const ids = ops.fromInt32(chunk, [1, chunk.length]);
684
751
  const h = await this.#forwardHidden(ids, p.solo);
685
752
  ids.dispose();
753
+ closeChunk?.();
754
+ p.closePrefill?.();
755
+ p.closePrefill = undefined;
756
+ const closeTokenZero = p.row.req.trace?.begin("token_zero.total", {
757
+ mechanism: "continuous",
758
+ });
759
+ // MLX is lazy: synchronize(stream) only waits for already-submitted work;
760
+ // it does not submit `h`'s graph. Attribution mode must evaluate the final
761
+ // L=1 forward explicitly or its work is silently charged to the head/read.
762
+ const forceAttribution = !!p.row.req.trace &&
763
+ runtimeValue("MLX_BUN_P2R_SYNC") === "1";
764
+ if (forceAttribution) {
765
+ const closeForward = p.row.req.trace!.begin("token_zero.forward", {
766
+ mechanism: "continuous",
767
+ activeBytes: activeMemory(),
768
+ cacheBytes: cacheMemory(),
769
+ peakBytes: peakMemory(),
770
+ });
771
+ ops.evalAll([h, ...p.solo.flatMap((c) => c.state())]);
772
+ closeForward();
773
+ }
686
774
  const [, Lc, H] = h.shape as [number, number, number];
687
775
  const hLast = h.slice([0, Lc - 1, 0], [1, Lc, H]);
688
776
  h.dispose();
689
777
  const lg = this.model.logitsFromHidden(hLast); // [1,1,V]
690
778
  hLast.dispose();
779
+ if (forceAttribution) {
780
+ const closeHead = p.row.req.trace!.begin("token_zero.head", {
781
+ mechanism: "continuous",
782
+ activeBytes: activeMemory(),
783
+ cacheBytes: cacheMemory(),
784
+ peakBytes: peakMemory(),
785
+ });
786
+ ops.evalAll([lg]);
787
+ closeHead();
788
+ }
691
789
  const V = lg.shape[2]!;
692
790
  const last2 = ops.reshape(lg, [1, V]);
693
791
  lg.dispose();
792
+ const closeSample = forceAttribution
793
+ ? p.row.req.trace!.begin("token_zero.sample", { mechanism: "continuous" })
794
+ : undefined;
694
795
  const tok = this.#readToken(p.row.req.sample(last2, 0));
796
+ closeSample?.();
797
+ closeTokenZero?.();
695
798
  last2.dispose();
696
799
  p.row.sampled = 1;
697
800
  p.row.generated = 1;
@@ -728,9 +831,12 @@ export class BatchScheduler {
728
831
  // MLX_BUN_PREFILL_TAIL_SPLIT=0 — the old serial order: token 0 sampled
729
832
  // from the unconverted final-chunk logits, THEN the caches convert
730
833
  // (before decode step 1 == before the merge).
731
- this.#quantizeSolo(p.solo);
834
+ this.#quantizeSolo(p.solo, p.row.req.trace);
732
835
  await this.#mergeJoiner(p);
733
836
  return true;
837
+ } finally {
838
+ closeChunk?.();
839
+ }
734
840
  }
735
841
 
736
842
  /** Merge a fully-prefilled joiner with the running batch, layer by layer
@@ -784,7 +890,7 @@ export class BatchScheduler {
784
890
  ));
785
891
  continue;
786
892
  }
787
- if (p.solo[layer] instanceof QuantizedKVCache) {
893
+ if (isQuantizedKvCache(p.solo[layer])) {
788
894
  // Phase 3.1 — quantized full layer: same merge/extend shapes as the
789
895
  // bf16 branch below, over (packed, scales, biases) triples. The solo
790
896
  // row was converted by #quantizeSolo with the serial ops, so its
@@ -797,7 +903,7 @@ export class BatchScheduler {
797
903
  t.packed.dispose(); t.scales.dispose(); t.biases.dispose();
798
904
  };
799
905
  const prevQ = prev?.[layer] as QuantizedKVCache | undefined;
800
- if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
906
+ if (prevQ && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") {
801
907
  const [k0, v0] = prevQ.temporalView();
802
908
  const ext = extendQuantRows(k0, v0, prevPad, qRow);
803
909
  dispose3(k0); dispose3(v0);
@@ -830,7 +936,7 @@ export class BatchScheduler {
830
936
  for (const r of rows) { dispose3(r.keys); dispose3(r.values); }
831
937
  }
832
938
  // qRow views are disposed via the rows loop above or here for extend
833
- if (prevQ && process.env.MLX_BUN_BATCH_EXTEND !== "0") { dispose3(qRow.keys); dispose3(qRow.values); }
939
+ if (prevQ && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") { dispose3(qRow.keys); dispose3(qRow.values); }
834
940
  else if (!prevQ) { /* disposed in the rows loop */ }
835
941
  continue;
836
942
  }
@@ -840,7 +946,7 @@ export class BatchScheduler {
840
946
  // introduced in 859572d; 2026-07-07 review fix). The view is taken
841
947
  // AFTER the branch, on the paths that actually consume it.
842
948
  const soloC = p.solo[layer] as KVCache | RotatingKVCache;
843
- if (this.#kinds[layer] === "rot" && p.solo[layer] instanceof RotatingQuantizedKVCache) {
949
+ if (this.#kinds[layer] === "rot" && isRotatingQuantizedCache(p.solo[layer])) {
844
950
  // Milestone 2 — QUANTIZED rotating layer: the solo row converted at
845
951
  // the serial boundaries (#quantizeSolo), so its ring bytes are the
846
952
  // serial oracle's; this branch re-arranges temporal triples across
@@ -854,21 +960,22 @@ export class BatchScheduler {
854
960
  t.values.packed.dispose(); t.values.scales.dispose(); t.values.biases.dispose();
855
961
  };
856
962
  const prevC = prev?.[layer];
857
- if (prevC instanceof BatchedRotatingQuantCache) {
858
- const [k0, v0] = prevC.temporalView(); // [B,H,valid,*] triples, temporal
963
+ if (prevC && isRowBatchCache(prevC) && isRotatingQuantizedCache(prevC)) {
964
+ const batched = prevC as BatchedRotatingQuantCache;
965
+ const [k0, v0] = batched.temporalView(); // [B,H,valid,*] triples, temporal
859
966
  const valid = k0.packed.shape[2]!;
860
967
  for (let b = 0; b < B; b++) {
861
- const pad = Math.max(0, prevC.leftPad[b]!);
968
+ const pad = Math.max(0, batched.leftPad[b]!);
862
969
  const cutRow = (t: typeof k0): typeof k0 => ({
863
970
  packed: t.packed.slice([b, 0, pad, 0], [b + 1, t.packed.shape[1]!, valid, t.packed.shape[3]!]),
864
971
  scales: t.scales.slice([b, 0, pad, 0], [b + 1, t.scales.shape[1]!, valid, t.scales.shape[3]!]),
865
972
  biases: t.biases.slice([b, 0, pad, 0], [b + 1, t.biases.shape[1]!, valid, t.biases.shape[3]!]),
866
973
  });
867
974
  rows.push({ keys: cutRow(k0), values: cutRow(v0) });
868
- offsets.push(prevC.offsetArr[b]!);
975
+ offsets.push(batched.offsetArr[b]!);
869
976
  }
870
977
  for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
871
- } else if (prevC instanceof RotatingQuantizedKVCache) {
978
+ } else if (isRotatingQuantizedCache(prevC)) {
872
979
  // Adopted lone row (Phase 3.2 adopt): its chronological triples
873
980
  // are the merge's first row, pad 0 by definition.
874
981
  const [k0, v0] = prevC.temporalView();
@@ -889,7 +996,7 @@ export class BatchScheduler {
889
996
  const rows: Row1[] = [];
890
997
  const offsets: number[] = [];
891
998
  const prevC = prev?.[layer];
892
- if (prevC instanceof RotatingKVCache) {
999
+ if (isRotatingPlainCache(prevC) && !isRowBatchCache(prevC)) {
893
1000
  // Adopted lone row (Phase 3.2): a plain serial rotating cache —
894
1001
  // its chronological view is the merge's first row, same as a
895
1002
  // fresh solo (pad 0 by definition).
@@ -897,7 +1004,16 @@ export class BatchScheduler {
897
1004
  rows.push({ keys: k0, values: v0 });
898
1005
  offsets.push(prevC.offset);
899
1006
  }
900
- const prevRot = prevC instanceof BatchedRotatingCache ? prevC : undefined;
1007
+ // Capability-only: BatchedRotatingCache has NO signature() override
1008
+ // (cacheSignature → "unknown"), so any signature-based conjunct here
1009
+ // silently drops the running batch's cache from the merge — joiners
1010
+ // then build a B<max batched ring while forwards arrive at full B
1011
+ // (grow-path concatenate crash, whole-batch drop; 2026-08-22 agg×4
1012
+ // regression from 443f333). The quant family is routed by its own
1013
+ // branch above (its subclass inherits a real signature).
1014
+ const prevRot = prevC && isRowBatchCache(prevC)
1015
+ ? prevC as unknown as BatchedRotatingCache
1016
+ : undefined;
901
1017
  if (prevRot) {
902
1018
  const [k0, v0] = prevRot.temporalView(); // [B,H,valid,D]
903
1019
  const [, H, valid, D] = k0.shape as [number, number, number, number];
@@ -918,7 +1034,7 @@ export class BatchScheduler {
918
1034
  for (const r of rows) { r.keys.dispose(); r.values.dispose(); }
919
1035
  } else {
920
1036
  const prevFull = prev?.[layer] as KVCache | undefined;
921
- if (prevFull && process.env.MLX_BUN_BATCH_EXTEND !== "0") {
1037
+ if (prevFull && runtimeValue("MLX_BUN_BATCH_EXTEND") !== "0") {
922
1038
  // extend-join (mlx-lm BatchKVCache.extend semantics, P0): append
923
1039
  // the new right-justified row to the running buffer in ONE pad +
924
1040
  // ONE concat — no per-row extraction. Existing pads grow, never
@@ -1050,7 +1166,7 @@ export class BatchScheduler {
1050
1166
  // A filtered-to-one BATCHED rot-quant cache subclasses the serial
1051
1167
  // class (so supports() passes) but carries batched ring state —
1052
1168
  // exclude it; only truly-adopted serial caches replay compiled.
1053
- !inners.some((c) => c instanceof BatchedRotatingQuantCache) &&
1169
+ !inners.some((c) => isRowBatchCache(c) && isRotatingQuantizedCache(c)) &&
1054
1170
  CompiledDecode.supports(inners as Cache[])
1055
1171
  ) {
1056
1172
  let cur = this.#pendingToks;
@@ -1078,10 +1194,9 @@ export class BatchScheduler {
1078
1194
  try {
1079
1195
  if (!lg) {
1080
1196
  fwd = inners.map((c) =>
1081
- c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1082
- c instanceof SSMCache || isBatchableCache(c) || unpadded
1197
+ isRowBatchCache(c) || isBatchableCache(c) || unpadded
1083
1198
  ? c
1084
- : c instanceof QuantizedKVCache
1199
+ : isQuantizedKvCache(c)
1085
1200
  ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1086
1201
  : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1087
1202
  );
@@ -1103,7 +1218,7 @@ export class BatchScheduler {
1103
1218
  // harmless (the slot is filtered before it is ever emitted; its only
1104
1219
  // use is one KV write on the row's own, about-to-evict row).
1105
1220
  const vecOk =
1106
- process.env.MLX_BUN_BATCH_VEC_SAMPLE !== "0" &&
1221
+ runtimeValue("MLX_BUN_BATCH_VEC_SAMPLE") !== "0" &&
1107
1222
  rows.every((r) => r.sampled >= r.req.maxTokens || r.req.plainGreedy);
1108
1223
  // Doomed slots hold placeholders (vec path: a real argmax value,
1109
1224
  // equally not part of the row's stream) — flag them so the fed
@@ -1228,7 +1343,7 @@ export class BatchScheduler {
1228
1343
  const prev = this.#pendingToks;
1229
1344
  const prevVals: number[] =
1230
1345
  prev ? prev.toIntTokens() : [];
1231
- if (process.env.MLX_BUN_GRAMMAR_DEBUG === "1")
1346
+ if (runtimeValue("MLX_BUN_GRAMMAR_DEBUG") === "1")
1232
1347
  console.log(`[sg] B=${B} prevVals=${JSON.stringify(prevVals)} current=${JSON.stringify(rows.map((r) => r.current))} sampled=${JSON.stringify(rows.map((r) => r.sampled))}`);
1233
1348
 
1234
1349
  // (2) accept() per live grammar row — fires that row's async bitmask fill.
@@ -1253,10 +1368,9 @@ export class BatchScheduler {
1253
1368
  // Unpadded fast path — same rule as #step (bare caches == serial graph).
1254
1369
  const unpadded = this.#fullLeftPad.every((p) => p === 0);
1255
1370
  const fwd: Cache[] = inners.map((c) =>
1256
- c instanceof BatchedRotatingCache || c instanceof BatchedRotatingQuantCache ||
1257
- c instanceof SSMCache || isBatchableCache(c) || unpadded
1371
+ isRowBatchCache(c) || isBatchableCache(c) || unpadded
1258
1372
  ? c
1259
- : c instanceof QuantizedKVCache
1373
+ : isQuantizedKvCache(c)
1260
1374
  ? new BatchedQuantDecodeMaskCache(c, B, this.#fullLeftPad)
1261
1375
  : new BatchedDecodeMaskCache(c, B, this.#fullLeftPad, null),
1262
1376
  );
@@ -1442,22 +1556,25 @@ export class BatchScheduler {
1442
1556
  for (const inner of this.#inners!) {
1443
1557
  let c: Cache | null;
1444
1558
  if (isBatchableCache(inner)) c = inner.extractRow(b);
1445
- else if (inner instanceof BatchedRotatingQuantCache) c = inner.extractRow(b);
1446
- else if (inner instanceof BatchedRotatingCache) c = inner.extractRow(b);
1447
- else if (inner instanceof SSMCache)
1559
+ else if (isRowBatchCache(inner) && cacheSignature(inner) === "ssm")
1448
1560
  // Coverage gate: recurrent state is UNTRIMMABLE, so an entry is only
1449
1561
  // valid when the row's own advance count equals the [promptIds+fed]
1450
1562
  // key EXACTLY (a mismatched entry would silently corrupt every future
1451
1563
  // exact-hit). Defensive — a miss here means the fed accounting broke.
1452
- c = inner.conv && inner.rowOffset(b) === expectTokens ? inner.extractRow(b) : null;
1564
+ c = (inner as SSMCache).conv && (inner as SSMCache).rowOffset(b) === expectTokens
1565
+ ? inner.extractRow(b)
1566
+ : null;
1567
+ else if (isRowBatchCache(inner)) c = inner.extractRow(b);
1453
1568
  else if (
1454
- inner instanceof RotatingQuantizedKVCache || // adopted serial state never
1455
- inner instanceof RotatingKVCache // coexists with a merged row (defensive)
1569
+ isRotatingQuantizedCache(inner) || // adopted serial state never
1570
+ isRotatingPlainCache(inner) // coexists with a merged row (defensive)
1456
1571
  )
1457
1572
  c = null;
1458
- else if (inner instanceof QuantizedKVCache)
1573
+ else if (isQuantizedKvCache(inner))
1459
1574
  c = inner.keys ? extractQuantRow(inner, this.#fullLeftPad[b]!, b) : null;
1460
- else c = inner.keys ? extractKVRow(inner, this.#fullLeftPad[b]!, b) : null;
1575
+ else if (isPlainKvCache(inner))
1576
+ c = inner.keys ? extractKVRow(inner, this.#fullLeftPad[b]!, b) : null;
1577
+ else c = null;
1461
1578
  if (!c) {
1462
1579
  for (const d of out) d.dispose();
1463
1580
  return null;
@@ -1506,20 +1623,14 @@ export class BatchScheduler {
1506
1623
  if (isBatchableCache(inner)) {
1507
1624
  inner.filterRows(keep);
1508
1625
  out.push(inner);
1509
- } else if (inner instanceof BatchedRotatingCache) {
1510
- inner.filter(keep); // in-place (mutates + drops rows + reduces padding)
1511
- out.push(inner);
1512
- } else if (inner instanceof BatchedRotatingQuantCache) {
1513
- inner.filter(keep); // in-place B-axis take on all six components
1626
+ } else if (isRowBatchCache(inner)) {
1627
+ inner.filterRows(keep);
1514
1628
  out.push(inner);
1515
- } else if (inner instanceof RotatingQuantizedKVCache || inner instanceof RotatingKVCache) {
1629
+ } else if (isRotatingQuantizedCache(inner) || isRotatingPlainCache(inner)) {
1516
1630
  // Adopted lone-row state exists only at B=1, where the only filter
1517
1631
  // is the keep=[] dispose-all handled above — unreachable.
1518
1632
  throw new Error("applyFilter: adopted serial rotating cache cannot be row-filtered");
1519
- } else if (inner instanceof SSMCache) {
1520
- inner.filter(keep); // in-place B-axis take on both state slots
1521
- out.push(inner);
1522
- } else if (inner instanceof QuantizedKVCache) {
1633
+ } else if (isQuantizedKvCache(inner)) {
1523
1634
  const [k0, v0] = inner.temporalView();
1524
1635
  const f = filterQuantRows(k0, v0, keep);
1525
1636
  for (const t of [k0, v0]) { t.packed.dispose(); t.scales.dispose(); t.biases.dispose(); }
@@ -1527,7 +1638,7 @@ export class BatchScheduler {
1527
1638
  c.restoreState(f.keys, f.values, inner.offset);
1528
1639
  out.push(c);
1529
1640
  inner.dispose();
1530
- } else {
1641
+ } else if (isPlainKvCache(inner)) {
1531
1642
  const [k0, v0] = inner.temporalView();
1532
1643
  const f = filterKVRows(k0, v0, keep);
1533
1644
  k0.dispose(); v0.dispose();
@@ -1535,6 +1646,8 @@ export class BatchScheduler {
1535
1646
  c.restoreState(f.keys, f.values, inner.offset);
1536
1647
  out.push(c);
1537
1648
  inner.dispose();
1649
+ } else {
1650
+ throw new Error(`applyFilter: unsupported cache signature ${cacheSignature(inner)}`);
1538
1651
  }
1539
1652
  }
1540
1653
  this.#inners = out;