mlx-bun 0.4.0 → 0.5.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 (158) hide show
  1. package/README.md +57 -32
  2. package/docs/README.md +55 -42
  3. package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
  4. package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
  5. package/docs/design/batching.md +220 -7
  6. package/docs/design/decode-speed-program.md +121 -66
  7. package/docs/design/generic-model-support.md +116 -0
  8. package/docs/design/kv-cache.md +380 -211
  9. package/docs/design/speculative-decoding.md +165 -26
  10. package/docs/design/turboquant.md +20 -7
  11. package/docs/design/unified-engine-frontier-plan.md +120 -13
  12. package/docs/planning/PRODUCT_ROADMAP.md +5 -6
  13. package/docs/planning/public-readiness-handoff.md +244 -0
  14. package/docs/planning/public-readiness.md +149 -0
  15. package/docs/reference/benchmarks.md +3463 -3489
  16. package/docs/reference/cli.md +62 -1
  17. package/docs/reference/distribution.md +29 -4
  18. package/docs/reference/environment.md +104 -8
  19. package/docs/reference/library-api.md +18 -4
  20. package/docs/reference/memory.md +1 -1
  21. package/docs/reference/models.md +17 -5
  22. package/docs/reference/server-api.md +181 -11
  23. package/docs/reference/server-config.md +232 -105
  24. package/package.json +2 -2
  25. package/src/audio/audiotoolbox.ts +112 -0
  26. package/src/audio/conformer.ts +8 -0
  27. package/src/audio/mic-capture.ts +98 -0
  28. package/src/audio/silero-vad.ts +379 -0
  29. package/src/audio/whisper-decode.ts +774 -0
  30. package/src/audio/whisper-fast.ts +732 -0
  31. package/src/audio/whisper-format.ts +73 -0
  32. package/src/audio/whisper-mel.ts +185 -0
  33. package/src/audio/whisper-timing.ts +274 -0
  34. package/src/audio/whisper-tokenizer.ts +239 -0
  35. package/src/audio/whisper-transcribe.ts +410 -0
  36. package/src/backends/mlx/autoregressive.ts +22 -1
  37. package/src/backends/mlx/batch-group.ts +111 -27
  38. package/src/backends/mlx/cache-layout.ts +3 -3
  39. package/src/backends/mlx/continuation-persistence.ts +5 -4
  40. package/src/backends/mlx/encoder-cache.ts +25 -0
  41. package/src/backends/mlx/fill-append.ts +21 -8
  42. package/src/backends/mlx/fill-group.ts +497 -0
  43. package/src/backends/mlx/gateway-binding.ts +56 -12
  44. package/src/backends/mlx/kv-maintenance.ts +30 -2
  45. package/src/backends/mlx/mixed-iteration.ts +62 -0
  46. package/src/backends/mlx/model-prompt.ts +46 -32
  47. package/src/backends/mlx/model-serving.ts +4 -3
  48. package/src/backends/mlx/paged-cache-codec.ts +27 -0
  49. package/src/backends/mlx/prefill-cohort.ts +15 -4
  50. package/src/backends/mlx/prefill-policy.ts +26 -0
  51. package/src/backends/mlx/prefill-rows.ts +57 -13
  52. package/src/backends/mlx/prompt-input.ts +29 -0
  53. package/src/backends/mlx/qwen-prompt-input.ts +40 -0
  54. package/src/backends/mlx/request-state-policy.ts +18 -5
  55. package/src/backends/mlx/rollback.ts +6 -3
  56. package/src/backends/mlx/serial-executor.ts +16 -7
  57. package/src/backends/mlx/speculative-group.ts +105 -26
  58. package/src/backends/mlx/speculative.ts +24 -7
  59. package/src/backends/mlx/target-layout-capability.ts +1 -1
  60. package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
  61. package/src/cli.ts +322 -0
  62. package/src/contracts/execution.ts +10 -0
  63. package/src/contracts/object-cache.ts +8 -0
  64. package/src/contracts/pi-provider.ts +1 -1
  65. package/src/contracts/pi.ts +3 -1
  66. package/src/contracts/prefix-cache.ts +13 -3
  67. package/src/contracts/scheduling.ts +9 -0
  68. package/src/engine/execution-plan.ts +23 -12
  69. package/src/engine/execution-tasks.ts +44 -0
  70. package/src/engine/namespaced-cache.ts +13 -0
  71. package/src/engine/scheduler.ts +22 -2
  72. package/src/fill/fill-session.ts +14 -3
  73. package/src/generate.ts +28 -22
  74. package/src/grammar.ts +13 -0
  75. package/src/inference/prefill.ts +6 -0
  76. package/src/kv-scheme.ts +22 -0
  77. package/src/kv-store.ts +115 -79
  78. package/src/lab/README.md +5 -1
  79. package/src/lab/paged-kv/paged-attention.ts +117 -0
  80. package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
  81. package/src/lab/paged-kv/paged-kv.ts +108 -42
  82. package/src/md-modules.d.ts +5 -0
  83. package/src/mlx/array.ts +37 -0
  84. package/src/mlx/ffi.ts +19 -0
  85. package/src/mlx/normalized-argmax.ts +116 -0
  86. package/src/mlx/ops.ts +97 -10
  87. package/src/model/batched-turboquant-kv.ts +6 -5
  88. package/src/model/delayed-quantized-kv.ts +3 -3
  89. package/src/model/delayed-rotating-quantized-kv.ts +1 -1
  90. package/src/model/delayed-turboquant-kv.ts +6 -4
  91. package/src/model/factory.ts +26 -0
  92. package/src/model/fingerprint.ts +12 -0
  93. package/src/model/full-prefill-row.ts +1 -1
  94. package/src/model/gemma4-base.ts +25 -6
  95. package/src/model/gemma4.ts +82 -2
  96. package/src/model/kv-attention-view.ts +4 -1
  97. package/src/model/profile.ts +29 -4
  98. package/src/model/quantized-append-attention.ts +31 -0
  99. package/src/model/qwen3-mrope.ts +7 -9
  100. package/src/model/qwen3_5.ts +99 -37
  101. package/src/model/support.ts +8 -0
  102. package/src/model/token-groups.ts +66 -0
  103. package/src/model/trellis-linear.ts +2 -2
  104. package/src/model/turboquant-codec.ts +6 -0
  105. package/src/model/whisper.ts +435 -0
  106. package/src/native/mic_capture.swift +92 -0
  107. package/src/native-pack.ts +6 -5
  108. package/src/pi-web.ts +9 -1
  109. package/src/prompt-cache.ts +265 -43
  110. package/src/runtime-config.ts +1 -5
  111. package/src/safetensors.ts +9 -2
  112. package/src/sampler.ts +5 -4
  113. package/src/serve/audio-routes.ts +323 -0
  114. package/src/serve/chat-request.ts +2 -0
  115. package/src/serve/chat-stage.ts +17 -12
  116. package/src/serve/completion-executor.ts +9 -1
  117. package/src/serve/discovery-routes.ts +17 -1
  118. package/src/serve/generated-token-history.ts +57 -0
  119. package/src/serve/generation-gateway.ts +52 -11
  120. package/src/serve/model-binding.ts +7 -1
  121. package/src/serve/preparation.ts +3 -3
  122. package/src/serve/prompt-response-trace.ts +14 -0
  123. package/src/serve/request-plan.ts +2 -0
  124. package/src/serve/request-prep.ts +38 -21
  125. package/src/serve/transcription-server.ts +64 -0
  126. package/src/serve/transcription-service.ts +502 -0
  127. package/src/server.ts +132 -164
  128. package/src/spec/assistant-rows.ts +1 -0
  129. package/src/spec/deepspec-rows.ts +1 -1
  130. package/src/spec/dflash-source.ts +1 -1
  131. package/src/spec/glm52-mtp-source.ts +1 -0
  132. package/src/spec/ngram-source.ts +27 -5
  133. package/src/spec/projected-draft-rows.ts +2 -2
  134. package/src/spec/qwen-mtp-rows.ts +11 -0
  135. package/src/spec/qwen-mtp-source.ts +3 -0
  136. package/src/spec/serve-loop.ts +5 -3
  137. package/src/spec/source.ts +14 -1
  138. package/src/spec/two-model-rows.ts +1 -0
  139. package/src/spec/two-model.ts +2 -2
  140. package/src/ssd-cache.ts +76 -17
  141. package/src/ssd-durability.ts +20 -22
  142. package/src/storage/host-buffer.ts +24 -0
  143. package/src/storage/kv-writer.ts +83 -0
  144. package/src/storage/kv-writer.worker.js +264 -0
  145. package/src/storage/retention-policy.ts +37 -0
  146. package/src/tiered-prompt-cache.ts +45 -0
  147. package/src/vision/embedder.ts +8 -0
  148. package/src/vision/prompt.ts +101 -65
  149. package/src/vision/qwen3vl-prompt.ts +101 -64
  150. package/src/vision/qwen3vl-tower.ts +11 -0
  151. package/src/vision/siglip.ts +8 -0
  152. package/src/web/app.html +12 -0
  153. package/src/web/app.js +160 -0
  154. package/src/web/src/chat.ts +4 -1
  155. package/src/web/src/composer.ts +3 -0
  156. package/src/web/src/voice.ts +141 -0
  157. package/src/web/sw.js +1 -1
  158. /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/server.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { ContinuationPersistence } from "./backends/mlx/continuation-persistence";
2
2
  import { modelServingBinding } from "./backends/mlx/model-serving";
3
+ import { handleAudioRoute } from "./serve/audio-routes";
4
+ import { TranscriptionService, defaultWhisperRecord } from "./serve/transcription-service";
3
5
  import type { ServingContext } from "./serve/model-host";
4
6
  export { generationCheckpointKey } from "./serve/checkpoint-identity";
5
7
  // OpenAI-compatible HTTP server: /v1/chat/completions (+ SSE streaming)
@@ -26,7 +28,7 @@ import {
26
28
  type GenerateOptions,
27
29
  } from "./generate";
28
30
  import { createPreparationExecutor } from "./serve/preparation";
29
- import { cloneKvCaches, legacyCacheCodecs, SpillQueue } from "./kv-store";
31
+ import { cloneKvCaches, legacyCacheCodecs } from "./kv-store";
30
32
  import type { Cache } from "./model/gemma4";
31
33
  import { resolveKvScheme } from "./kv-scheme";
32
34
  import { runtimeValue } from "./runtime-config";
@@ -49,10 +51,11 @@ import { handleLabRoute } from "./serve/lab-routes";
49
51
  import { handleModelAdminRoute } from "./serve/model-admin-routes";
50
52
  import { handleStaticRoute } from "./serve/static-routes";
51
53
 
52
- import { PromptCache, cacheBytes } from "./prompt-cache";
54
+ import { PromptCache } from "./prompt-cache";
55
+ import { CostSizeRetention } from "./storage/retention-policy";
56
+ import { TieredPromptCache } from "./tiered-prompt-cache";
53
57
  import { SsdCacheStore } from "./ssd-cache";
54
58
  import {
55
- SsdDurabilityCoordinator,
56
59
  type DurabilityFlushResult,
57
60
  type DurabilitySnapshotStats,
58
61
  } from "./ssd-durability";
@@ -67,7 +70,7 @@ import {
67
70
  type ResponsesRequest,
68
71
  } from "./responses";
69
72
  import { fit } from "./fit";
70
- import { setMemoryLimit } from "./mlx/ffi";
73
+ import { activeMemory, maxRecommendedWorkingSetSize, setMemoryLimit } from "./mlx/ffi";
71
74
  import { makePiWsHandler, type PiWsData } from "./pi-web";
72
75
  import { ChatStage } from "./serve/chat-stage";
73
76
  import { TextCompletionStage } from "./serve/text-completion-stage";
@@ -77,6 +80,7 @@ import {
77
80
  chatCompletionJson, chatCompletionStream, textCompletionJson, textCompletionStream,
78
81
  } from "./serve/openai-wire";
79
82
  import { createRequestPrep } from "./serve/request-prep";
83
+ import { GeneratedTokenHistory } from "./serve/generated-token-history";
80
84
  import {
81
85
  detectDraftKind,
82
86
  loadContext,
@@ -163,6 +167,16 @@ export interface ServerOptions {
163
167
  defaultTemperature?: number;
164
168
  defaultTopP?: number;
165
169
  defaultTopK?: number;
170
+ /** Whisper checkpoint directory for /v1/audio/* (`--whisper-model`).
171
+ * Unset ⇒ the first downloaded `whisper` checkpoint is used on demand. */
172
+ whisperModelDir?: string;
173
+ /** Display id for the Whisper checkpoint. */
174
+ whisperModelId?: string;
175
+ /** Seconds idle before the Whisper weights are disposed
176
+ * (`--whisper-idle-unload`, default 300; 0 keeps them resident). */
177
+ whisperIdleUnloadSec?: number;
178
+ /** Keep the Whisper weights resident (`--whisper-resident`). */
179
+ whisperResident?: boolean;
166
180
  /** Completion cap when the request omits max_tokens (`--max-tokens`).
167
181
  * mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
168
182
  * thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
@@ -322,8 +336,7 @@ export function createServer(
322
336
  // The scheduler chooses its B=1 fast path or B=N step from active rows.
323
337
  // Both full-attention (CPM) and
324
338
  // sliding-window (Gemma) models batch — the scheduler assembles each layer's
325
- // cache by attention type. Non-batchable requests (vision / adapters /
326
- // unsupported explicit kv-quant) drain to the serial executor
339
+ // cache by attention type. Unsupported model/method combinations drain to the serial executor
327
340
  // (see GenerationGateway.place). No inference setting is rewritten.
328
341
  // DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
329
342
  // lone request through the batch lane IS the serial engine (adopted
@@ -397,10 +410,9 @@ export function createServer(
397
410
  // quietly (kv-quant: the swap would drop the scheme; speculative
398
411
  // provider composition with paged storage is not implemented).
399
412
  if (serverOptions.pagedKv) {
400
- if (kvScheme.kvBits || kvScheme.kvConfig?.length || kvScheme.turboQuant)
413
+ if (kvScheme.kvConfig?.length || kvScheme.turboQuant)
401
414
  throw new Error(
402
- `--paged-kv is bf16-only in v1 — omit --kv-quant (quantized paged ` +
403
- `blocks are a documented follow-up, docs/design/kv-cache.md).`,
415
+ `--paged-kv supports bf16 and uniform affine KV4/KV8; per-layer and TurboQuant pages are not implemented.`,
404
416
  );
405
417
  if (ctx.draft)
406
418
  throw new Error(
@@ -415,11 +427,7 @@ export function createServer(
415
427
  const bs = serverOptions.pagedKv.blockSize;
416
428
  if (bs !== undefined && (!Number.isInteger(bs) || bs <= 0))
417
429
  throw new Error(`--paged-kv-block-size must be a positive integer (got ${bs})`);
418
- if (serverOptions.ssdCacheDir)
419
- console.warn(
420
- "[paged-kv] --ssd-cache has no effect: paged requests bypass the prompt " +
421
- "cache (v1 non-goal), so nothing reaches the SSD tier.",
422
- );
430
+
423
431
  }
424
432
 
425
433
  // SSD cold tier (docs/design/kv-cache.md): prefix KV survives RAM
@@ -454,6 +462,8 @@ export function createServer(
454
462
  tokenizerHash: Bun.hash(tokJson).toString(16),
455
463
  modelId: ctx.modelId,
456
464
  verify: serverOptions.ssdCacheVerify,
465
+ storage: { layout: runtimeValue("MLX_BUN_SSD_LAYOUT") === "blocks" ? "blocks" : "whole",
466
+ segmented: runtimeValue("MLX_BUN_SSD_SEGMENTED") !== "0" },
457
467
  });
458
468
  const recovered = ssdStore.scan();
459
469
  const capacity = Number.isFinite(ssdStore.maxBytes)
@@ -475,6 +485,15 @@ export function createServer(
475
485
  const h = ssdStore!.find(prompt, ns);
476
486
  return h ? { prefixLen: h.prefixLen, handle: h.entry } : null;
477
487
  },
488
+ findExact: (tokens: number[], ns: string) => {
489
+ const hit = ssdStore!.findExact(tokens, ns);
490
+ return hit ? { prefixLen: hit.prefixLen, handle: hit.entry } : null;
491
+ },
492
+ restoreObjectAsync: async (handle: unknown) => {
493
+ const loaded = await ssdStore!.restoreAsync(handle as import("./ssd-cache").SsdIndexEntry,
494
+ { makeCache: () => [] });
495
+ return loaded ? { ...loaded, retain: () => {} } : null;
496
+ },
478
497
  restore: (handle: unknown) => {
479
498
  const loaded = serving.restore(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
480
499
  if (!loaded) return null;
@@ -484,6 +503,12 @@ export function createServer(
484
503
  // as a no-op so callers' dispose ordering is unchanged.
485
504
  return { tokens: loaded.tokens, caches: loaded.caches, attachments: loaded.attachments, retain: () => {} };
486
505
  },
506
+ ...(serving.restoreAsync && runtimeValue("MLX_BUN_SSD_PREFETCH") !== "0" ? {
507
+ restoreAsync: async (handle: unknown) => {
508
+ const loaded = await serving.restoreAsync!(ssdStore!, handle as import("./ssd-cache").SsdIndexEntry);
509
+ return loaded ? { ...loaded, retain: () => {} } : null;
510
+ },
511
+ } : {}),
487
512
  store: (tokens: number[], caches: import("./model/gemma4").Cache[], ns: string,
488
513
  attachments?: import("./backends/mlx/checkpoint-state").CheckpointAttachment[]) => {
489
514
  if (ssdStore!.hasDurablePrefix(tokens, ns)) return true;
@@ -491,29 +516,13 @@ export function createServer(
491
516
  },
492
517
  }
493
518
  : null;
494
- const promptCache = new PromptCache(
495
- promptCacheCap,
496
- // RAM eviction AND idle demotion spill to the cold tier NON-BLOCKING
497
- // (2026-07-06, same contract as the write-behind snapshot below): the
498
- // cache hands us OWNED zero-copy clones + copied tokens (made under
499
- // the generation lock — microseconds; entries are immutable so the
500
- // clones stay consistent), and the flush goes through the BOUNDED
501
- // SpillQueue -> storeAsync (generation-locked per tensor, see below) ->
502
- // clone disposal on every settle/drop path (that dispose is what
503
- // actually frees the demoted GPU memory; the queue's byte cap keeps
504
- // starved-gate retention bounded — 2026-07-07 review fix). spillQueue
505
- // is declared with the snapshot scheduler below; safe to close over
506
- // here because spills only fire at put()/demoteIdle time, long after
507
- // init.
508
- ssdStore
509
- ? {
510
- spillOwned: (entry) =>
511
- spillQueue!.enqueue({ tokens: entry.tokens, caches: entry.caches, ns: entry.ns, attachments: entry.attachments }),
512
- }
513
- : null,
514
- coldTier,
515
- cloneState,
516
- );
519
+ const promptCache = ssdStore && coldTier
520
+ ? new TieredPromptCache(promptCacheCap, ssdStore, coldTier, cloneState,
521
+ runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0")
522
+ : new PromptCache(promptCacheCap, null, null, cloneState);
523
+ if (runtimeValue("MLX_BUN_CACHE_RETENTION") === "cost-size") promptCache.retention = new CostSizeRetention();
524
+ const spillQueue = promptCache instanceof TieredPromptCache ? promptCache.spillQueue : null;
525
+ const durability = promptCache instanceof TieredPromptCache ? promptCache.durability : null;
517
526
  // Responses-API store for previous_response_id resumption (Phase 11):
518
527
  // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
519
528
  // the prompt cache: a resumed conversation re-renders the same prefix,
@@ -553,59 +562,8 @@ export function createServer(
553
562
  serving.gateway.configureContinuation?.(executionServices);
554
563
  const runGeneration = serving.createSerial(executionServices);
555
564
 
556
- // Write-behind persistence (restart survival — the oMLX boundary-snapshot
557
- // idea at whole-entry granularity, spill-on-evict alone can't survive a
558
- // clean exit): after a request completes, snapshot its still-RAM-resident
559
- // entry to SSD off the request path. Debounced + coalesced per namespace —
560
- // an agent hammering one conversation persists the settled state once
561
- // things go quiet, not every turn. Runs under the gateway lock (byte
562
- // extraction must not race a generation mutating the entry); if the entry
563
- // was evicted (already spilled) or extended (a newer schedule pending)
564
- // meanwhile, findExact misses and nothing is written.
565
- // Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
566
- // must not cancel the pending boundary snapshot, and unrelated prompts of
567
- // equal length must not cancel each other. Exact reschedules still
568
- // coalesce; stale keys self-clean when findExact misses.
569
- //
570
- // NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
571
- // gateway lock is held only for a zero-copy SNAPSHOT (findExact +
572
- // cloneKvCaches — microseconds; entries are immutable so the clones are
573
- // consistent forever). The flush itself runs OFF the lock via
574
- // storeAsync, and writes chain serially so two multi-hundred-MB flushes
575
- // never overlap.
576
- //
577
- // IDLE-GATED (2026-07-07, the decode@ctx fix): "off the lock" was not
578
- // enough — every per-tensor flush step is a blocking GPU sync on the
579
- // SAME stream decode uses (ops.contiguous enqueues a kernel, rawBytesView
580
- // evals) plus a synchronous multi-MB writeSync, and the setImmediate
581
- // pacing interleaved those slices exactly between decode tokens. A ~16k
582
- // entry's flush overlapping the bench's ctx repeats depressed decode@ctx
583
- // ~9% on e4b (mlx-lm runs no equivalent background work). Now every step
584
- // — including the first — runs through gateway.runWhenIdle(), so the
585
- // flush only progresses while NOTHING is generating and pauses when a
586
- // request arrives mid-flush. Tradeoffs, accepted: durability waits for a
587
- // quiet moment (single-user serving quiesces constantly; sustained
588
- // hammering defers the flush AND the spill clones' GPU-memory release —
589
- // bounded by the chain). MLX_BUN_SSD_WRITEBEHIND=0
590
- // disables write-behind snapshots entirely — the paired-A/B lever + kill
591
- // switch (restart survival then degrades to spill-on-evict only).
592
- const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
593
- const ssdFlushStep = <T>(step: () => T): Promise<T> =>
594
- gateway.runWhenIdle(async () => step());
595
- // Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
596
- // kv-store.ts): pending clones pin their entries' GPU buffers while the
597
- // idle gate starves under sustained traffic, so QUEUED bytes are capped —
598
- // over cap the oldest queued spill drops (clone disposed immediately, a
599
- // future cache miss, never a wrong result) instead of accumulating past
600
- // the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
601
- // default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
602
- // coordinator retains a dirty record until the queue confirms the atomic
603
- // store. Explicit flush and graceful shutdown can therefore retry a drop.
604
- // `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
605
- // (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
606
- // never drops the item just enqueued).
607
- // The gateway exists before the spill queue because the SSD writer uses
608
- // its idle boundary to avoid competing with generation.
565
+ // Cache policy owns RAM residency and SSD persistence independently of
566
+ // request scheduling. Published state is immutable.
609
567
  const gateway = new GenerationGateway(serving.gateway, batch, runGeneration, {
610
568
  kvBudgetBytes: serverOptions.kvBudgetBytes,
611
569
  checkpoints: !!(serverOptions.generationCheckpointTokens && ssdStore),
@@ -617,29 +575,21 @@ export function createServer(
617
575
  const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
618
576
  const spillQueueCapBytes =
619
577
  (Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
620
- // Both queues share the configured total: reserve half for checkpoints
621
- // only when that service exists. Each retains the existing soft-cap rule.
622
- const checkpointQueueBytes = ssdStore && serverOptions.generationCheckpointTokens
623
- ? Math.floor(spillQueueCapBytes / 2) : 0;
624
578
  if (ssdStore && serverOptions.generationCheckpointTokens)
625
- checkpointPersistence = new ContinuationPersistence(ssdStore, { maxBytes: checkpointQueueBytes, runStep: ssdFlushStep });
626
- const spillQueue = ssdStore
627
- ? new SpillQueue(
628
- spillQueueCapBytes - checkpointQueueBytes,
629
- cacheBytes,
630
- (item) => ssdStore!.storeAsync(item.tokens, item.caches, item.ns, ssdFlushStep, item.attachments),
631
- (caches) => { for (const c of caches) c.dispose(); },
632
- )
633
- : null;
634
- const durability = ssdStore && spillQueue && writeBehindOn
635
- ? new SsdDurabilityCoordinator(
636
- gateway,
637
- promptCache,
638
- spillQueue,
639
- cloneState,
640
- (tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
641
- )
642
- : null;
579
+ checkpointPersistence = new ContinuationPersistence(ssdStore, {
580
+ maxBytes: spillQueueCapBytes,
581
+ });
582
+ // Keep allocator headroom for the next forward. Only optional cache
583
+ // snapshots are reclaimed; context, batch size and sampling stay intact.
584
+ // Do not count MLX's reusable pool as live state or await an SSD write.
585
+ const cacheWorkingSet = Math.min(maxRecommendedWorkingSetSize(), allocatorLimit ?? Infinity);
586
+ const cacheOverBudget = () => Math.max(activeMemory(),
587
+ ctx.model.weightsBytes + Math.max(promptCache.totalBytes, spillQueue?.pendingBytes ?? 0)) > cacheWorkingSet * 0.85;
588
+ promptCache.pressure = {
589
+ // File-backed weights may not yet appear in MLX's active allocations
590
+ // before the first forward. Account for their known footprint as well.
591
+ overBudget: cacheOverBudget,
592
+ };
643
593
  const durabilityStats = (): DurabilitySnapshotStats => {
644
594
  const stats = durability?.stats ?? {
645
595
  pendingSnapshots: 0,
@@ -668,28 +618,21 @@ export function createServer(
668
618
  elapsedMs: performance.now() - started,
669
619
  };
670
620
  };
671
- // Every put() — serial lane AND batch scheduler — schedules the snapshot
672
- // (Layer 0: batch-lane entries survive restarts too, not just evictions).
673
- promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
621
+ // Remember exact generated IDs independently of cache persistence.
622
+ const tokenHistory = new GeneratedTokenHistory(ctx.tokenizer);
623
+ if (ssdStore) for (const tokens of ssdStore.tokenPrefixes()) tokenHistory.remember(tokens);
624
+ promptCache.onPut = (tokens) => {
625
+ tokenHistory.remember(tokens);
626
+ };
674
627
 
675
- // Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
676
- // spill to SSD and free their GPU memory — the RAM tier drains between
677
- // bursts while every prefix stays reachable (take() restores via
678
- // zero-copy mmap). The exclusive section below covers only the sweep's
679
- // zero-copy clone + dispose (spillOwned above); the SSD write itself
680
- // runs off-lock on ssdWriteChain. Swept only when the engine is TRULY idle: the
681
- // runExclusive below registers as a serial waiter, which would DRAIN a
682
- // running batch, so the activity check guards it (a momentary exclusive
683
- // grab of an idle engine is free). 0 disables.
628
+ // Cache age, not scheduler activity, decides idle demotion. The cache
629
+ // queues any missing SSD copy and retains RAM until that copy commits.
684
630
  const demoteIdleMs = (serverOptions.ssdDemoteIdleSec ?? (ssdStore ? 300 : 0)) * 1000;
685
631
  let demoteTimer: ReturnType<typeof setInterval> | null = null;
686
632
  if (ssdStore && demoteIdleMs > 0) {
687
633
  demoteTimer = setInterval(() => {
688
- if (gateway.busy) return;
689
- void gateway.runExclusive(async () => {
690
- const n = promptCache.demoteIdle(demoteIdleMs);
691
- if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
692
- }).catch(() => {});
634
+ const n = promptCache.demoteIdle(demoteIdleMs);
635
+ if (n > 0) console.log(`[ssd-cache] demoted ${n} idle entr${n === 1 ? "y" : "ies"} to disk`);
693
636
  }, Math.max(30_000, demoteIdleMs / 4));
694
637
  demoteTimer.unref?.();
695
638
  }
@@ -699,7 +642,33 @@ export function createServer(
699
642
 
700
643
  // /library response cache (30 s) — registry + config reads only.
701
644
  const startedAt = Date.now();
702
- const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
645
+ // Speech-to-text companion (docs/reference/server-api.md "Audio
646
+ // transcription"): a Whisper checkpoint served beside the chat model,
647
+ // loaded on the first /v1/audio request and paged out after
648
+ // --whisper-idle-unload seconds. Explicit --whisper-model wins; otherwise
649
+ // the first downloaded `whisper` checkpoint is resolved lazily.
650
+ let transcription: TranscriptionService | null = null;
651
+ let transcriptionResolved = false;
652
+ const transcriptionService = async (): Promise<TranscriptionService | null> => {
653
+ if (transcription || transcriptionResolved) return transcription;
654
+ transcriptionResolved = true;
655
+ const rec = serverOptions.whisperModelDir
656
+ ? { path: serverOptions.whisperModelDir, repoId: serverOptions.whisperModelId ?? serverOptions.whisperModelDir }
657
+ : await defaultWhisperRecord();
658
+ if (!rec) return null;
659
+ transcription = new TranscriptionService({
660
+ modelDir: rec.path, modelId: rec.repoId,
661
+ idleUnloadSec: serverOptions.whisperIdleUnloadSec,
662
+ resident: serverOptions.whisperResident,
663
+ exclusive: (fn, signal) => gateway.runExclusive(fn, undefined, signal),
664
+ });
665
+ return transcription;
666
+ };
667
+ const transcriptionInfo = async () => {
668
+ const svc = await transcriptionService();
669
+ return svc ? { id: svc.modelId, resident: svc.resident } : null;
670
+ };
671
+ const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt, transcriptionInfo);
703
672
 
704
673
  // Captured so the WebSocket handler can resolve the bound (possibly
705
674
  // ephemeral) port lazily for the loopback pi provider.
@@ -729,9 +698,10 @@ export function createServer(
729
698
  // → inferenceStage.admit → inferenceStage.run → InferenceResult → wire
730
699
  // (/v1/completions substitutes textStage; Anthropic and Responses reuse
731
700
  // chatStage with their own wire formats.)
732
- const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens });
701
+ const prep = createRequestPrep({ ctx, serverOptions, kvScheme, defaultGeneratedTokens, tokenHistory });
733
702
  const { templateOptionsFor } = prep;
734
- const preparation = createPreparationExecutor((work, signal) => gateway.runExclusive(work, undefined, signal), batch);
703
+ const preparation = createPreparationExecutor((work, signal) => gateway.runPreparation(work, signal),
704
+ batch, gateway.mediaBatchingEnabled ? batch : 1);
735
705
  const chatStage = new ChatStage(
736
706
  ctx, prep, promptCache, contextLimit, serverOptions.defaultAdapter,
737
707
  preparation, serving.buildPrompt);
@@ -739,6 +709,15 @@ export function createServer(
739
709
  ctx, prep, contextLimit, defaultGeneratedTokens, serverOptions.defaultAdapter);
740
710
  const inferenceStage = new InferenceStage(completionExecutor);
741
711
  const openAiMeta = (id: string) => ({ id, created: Math.floor(Date.now() / 1000), model: ctx.modelId });
712
+ /** Carry application session affinity into the shared cache request fields. */
713
+ const applyCacheSession = (body: ChatRequestParams, request: Request, original: unknown = body) => {
714
+ const fields = original as { session_id?: unknown; prompt_cache_key?: unknown };
715
+ const session = typeof fields.session_id === "string" ? fields.session_id :
716
+ typeof fields.prompt_cache_key === "string" ? fields.prompt_cache_key :
717
+ request.headers.get("x-session-affinity") ?? request.headers.get("session_id") ?? undefined;
718
+ if (runtimeValue("MLX_BUN_SESSION_CACHE") !== "0") body.session_id = session;
719
+ else { delete body.session_id; delete body.prompt_cache_key; }
720
+ };
742
721
  /** Parse the JSON body under the request's trace; a bad body is a 400. */
743
722
  const parseBody = async <T,>(request: Request, trace: PromptResponseTrace | undefined): Promise<T | Response> => {
744
723
  const closeBodyParse = trace?.begin("request.body_parse");
@@ -777,6 +756,7 @@ export function createServer(
777
756
  // capability flags — true if a tower is loaded or loadable (lazy)
778
757
  vision: !!(ctx.vision || ctx.loadVision),
779
758
  audio: !!(ctx.audio || ctx.loadAudio),
759
+ transcription: async () => (await transcriptionService()) !== null,
780
760
  thinking: ctx.template.supportsThinking,
781
761
  // Model-author sampling defaults (generation_config.json, server-CLI
782
762
  // overrides applied) for the sampling popover's per-model recommended
@@ -793,6 +773,12 @@ export function createServer(
793
773
  async fetch(request, server) {
794
774
  const url = new URL(request.url);
795
775
 
776
+ if (url.pathname === "/admin/cache/session/close" && request.method === "POST") {
777
+ const body = await parseBody<{ session_id?: string }>(request, undefined);
778
+ if (body instanceof Response) return body;
779
+ if (typeof body.session_id === "string") promptCache.closeSession(body.session_id);
780
+ return Response.json({ closed: typeof body.session_id === "string" });
781
+ }
796
782
  if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
797
783
  const result = await flushDurability();
798
784
  return Response.json(
@@ -824,6 +810,9 @@ export function createServer(
824
810
  const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
825
811
  if (modelAdminResponse) return modelAdminResponse;
826
812
 
813
+ const audioResponse = await handleAudioRoute(url, request, { service: transcriptionService });
814
+ if (audioResponse) return audioResponse;
815
+
827
816
  if (url.pathname === "/fit" && request.method === "GET") {
828
817
  // Fit assessment for the status page: this-machine report at the
829
818
  // admission ceiling + the Apple SKU matrix at a fixed 32k.
@@ -934,30 +923,6 @@ export function createServer(
934
923
  }
935
924
 
936
925
  if (url.pathname === "/stats" && request.method === "GET") {
937
- // Active KV scheme across ALL layers. Since Phase 9 rotating
938
- // (sliding-window) caches quantize too, so every layer the
939
- // scheme names counts — the old display filtered to
940
- // full_attention and silently undercounted (e.g. 26B showed
941
- // 5/30 quantized when its kv_config.json covers all 30).
942
- const layerTypes = ctx.model.config.text.layerTypes;
943
- const kvLayers: Record<string, number> = {};
944
- let kvMode = "bf16";
945
- if (kvScheme.turboQuant) {
946
- // v1: full-attention layers only (sliding-window stays bf16 —
947
- // docs/design/turboquant.md non-goal).
948
- const fullAttn = layerTypes.filter((l) => l !== "sliding_attention").length;
949
- kvMode = `turbo k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`;
950
- kvLayers[`turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`] = fullAttn;
951
- } else if (kvScheme.kvBits) {
952
- kvMode = `uniform-kv${kvScheme.kvBits}`;
953
- kvLayers[`kv${kvScheme.kvBits}`] = layerTypes.length;
954
- } else if (kvScheme.kvConfig) {
955
- kvMode = "mixed (kv_config.json)";
956
- for (const e of kvScheme.kvConfig)
957
- kvLayers[`kv${e.bits}`] = (kvLayers[`kv${e.bits}`] ?? 0) + 1;
958
- }
959
- const bf16Layers = layerTypes.length - Object.values(kvLayers).reduce((a, b) => a + b, 0);
960
- const slidingLayers = layerTypes.filter((l) => l === "sliding_attention").length;
961
926
  return Response.json({
962
927
  server: {
963
928
  owner: serverOptions.owner ?? "embedded",
@@ -970,6 +935,12 @@ export function createServer(
970
935
  max_bytes: promptCache.maxBytes,
971
936
  hits: promptCache.hits,
972
937
  misses: promptCache.misses,
938
+ session_hits: promptCache.sessionHits,
939
+ session_misses: promptCache.sessionMisses,
940
+ prefix_scans: promptCache.prefixScans,
941
+ object_hits: promptCache.objectHits,
942
+ object_misses: promptCache.objectMisses,
943
+ object_restores: promptCache.objectRestores,
973
944
  },
974
945
  ...(ssdStore ? {
975
946
  ssd_cache: {
@@ -995,14 +966,7 @@ export function createServer(
995
966
  max_bytes: responseStore.maxBytes,
996
967
  ttl_ms: responseStore.ttlMs,
997
968
  },
998
- kv_quant: {
999
- mode: kvMode,
1000
- layers: { ...kvLayers, ...(bf16Layers > 0 ? { bf16: bf16Layers } : {}) },
1001
- attention: {
1002
- global: layerTypes.length - slidingLayers,
1003
- sliding_window: slidingLayers,
1004
- },
1005
- },
969
+ kv_quant: resolvedKvScheme.describe(ctx.model.config),
1006
970
  admission: {
1007
971
  max_safe_context: admission.maxSafeContext,
1008
972
  enforced_context_tokens: contextLimit,
@@ -1146,6 +1110,7 @@ export function createServer(
1146
1110
  });
1147
1111
  const body = await parseBody<ChatRequestParams>(request, trace);
1148
1112
  if (body instanceof Response) return body;
1113
+ applyCacheSession(body as ChatRequestParams, request);
1149
1114
  const meta = openAiMeta(id);
1150
1115
  const a = await admit(
1151
1116
  inferenceStage, () => chatStage.run(new ChatRequest(body), id, request.signal), trace, "chat request");
@@ -1166,6 +1131,7 @@ export function createServer(
1166
1131
  });
1167
1132
  const body = await parseBody<TextCompletionParams>(request, trace);
1168
1133
  if (body instanceof Response) return body;
1134
+ applyCacheSession(body as ChatRequestParams, request);
1169
1135
  const meta = openAiMeta(id);
1170
1136
  const a = await admit(
1171
1137
  inferenceStage, () => textStage.run(new TextCompletionRequest(body), id), trace, "text completion");
@@ -1195,6 +1161,7 @@ export function createServer(
1195
1161
  let chatBody: ChatRequestParams;
1196
1162
  try {
1197
1163
  chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequestParams;
1164
+ applyCacheSession(chatBody, request, anthropicBody);
1198
1165
  } catch (e) {
1199
1166
  return anthropicError(400, (e as Error).message, {});
1200
1167
  }
@@ -1250,6 +1217,7 @@ export function createServer(
1250
1217
  let chatBody: ChatRequestParams;
1251
1218
  try {
1252
1219
  chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequestParams;
1220
+ applyCacheSession(chatBody, request, responsesBody);
1253
1221
  } catch (e) {
1254
1222
  return responsesError(400, (e as Error).message, {});
1255
1223
  }
@@ -86,6 +86,7 @@ class AssistantRows implements DraftPrefillGroup, DraftRowGroup {
86
86
 
87
87
  export function assistantGroups(drafter: GemmaAssistantDrafter, namespace: string): GroupedDraftProvider {
88
88
  return {
89
+ checkpointNamespace: () => namespace,
89
90
  openPrefill: ({target,checkpoints}) => new AssistantRows(target.assistantRows!,drafter,namespace,checkpoints),
90
91
  open: ({target,checkpoints}) => new AssistantRows(target.assistantRows!,drafter,namespace,checkpoints),
91
92
  };
@@ -12,7 +12,7 @@ export function bindDeepspecTarget(target: TargetView, drafter: Pick<DeepspecDra
12
12
  }
13
13
 
14
14
  export function deepspecGroups(drafter: DeepspecDrafter, namespace: string): GroupedDraftProvider {
15
- return projectedDraftGroups(target => {
15
+ return projectedDraftGroups(namespace, target => {
16
16
  bindDeepspecTarget(target, drafter);
17
17
  return {
18
18
  namespace, schema: "deepspec-context-v1",
@@ -48,7 +48,7 @@ export class DflashProvider implements DraftProvider {
48
48
  this.id = id;
49
49
  this.weightsBytes = weightsBytes;
50
50
  this.gamma = drafter.cfg.gamma;
51
- this.grouped = projectedDraftGroups(target => {
51
+ this.grouped = projectedDraftGroups(namespace, target => {
52
52
  if (!target.gemmaTaps) throw new Error("DSpark drafter requires a Gemma4 target");
53
53
  const projection = target.gemmaTaps.projection;
54
54
  const minConf = runtimeValue("MLX_BUN_DSPARK_MINCONF");
@@ -30,6 +30,7 @@ function lastToken(feed: readonly number[]): number {
30
30
 
31
31
  export class Glm52NativeMtpProvider implements DraftProvider {
32
32
  readonly grouped: GroupedDraftProvider = {
33
+ checkpointNamespace: () => "glm52-native-mtp-v1",
33
34
  open: options => this.#openRows(options.target, options.sampling, options.checkpoints),
34
35
  openPrefill: options => this.#openRows(options.target, null, options.checkpoints),
35
36
  };
@@ -38,11 +38,11 @@
38
38
  // speculation is structurally never worse than plain decode by more than the
39
39
  // JS scan (~µs against 30k-token histories).
40
40
 
41
- import { flagOn } from "../runtime-config";
41
+ import { runtimeFlag } from "../runtime-config";
42
42
  import type { MlxArray } from "../mlx/array";
43
43
  import * as ops from "../mlx/ops";
44
44
  import type { CheckpointAttachment } from "../backends/mlx/checkpoint-state";
45
- import type { DraftProvider, DraftSource, DraftRowCheckpoint, DraftRowGroup, DraftPrefillGroup, GroupedDraftProvider } from "./source";
45
+ import type { DraftProvider, DraftSource, DraftRowCheckpoint, DraftRowGroup, DraftPrefillGroup, GroupedDraftProvider, DraftRowConstraints } from "./source";
46
46
  import { applyStateChanges } from "../engine/resources";
47
47
 
48
48
  /** The matching policy is independent of the executor's feed convention. */
@@ -78,7 +78,8 @@ class NgramRows implements DraftRowGroup, DraftPrefillGroup {
78
78
  readonly namespace: string;
79
79
  #histories: number[][] = [];
80
80
  #drafts: number[][] = [];
81
- constructor(readonly max: number, readonly min: number, checkpoints: readonly (DraftRowCheckpoint | null)[]) {
81
+ constructor(readonly max: number, readonly min: number, checkpoints: readonly (DraftRowCheckpoint | null)[],
82
+ readonly constraints?: DraftRowConstraints) {
82
83
  this.namespace = namespace(max, min);
83
84
  this.append(checkpoints);
84
85
  }
@@ -95,7 +96,12 @@ class NgramRows implements DraftRowGroup, DraftPrefillGroup {
95
96
  for (let row = 0; row < this.rowCount; row++) this.#histories[row]!.push(...ids.slice(row * length, (row + 1) * length));
96
97
  }
97
98
  materialize(): void {}
98
- draft(pending: readonly number[], depth: number): number[][] {
99
+ draft(pending: readonly number[], depth: number): number[][] | Promise<number[][]> {
100
+ if (this.constraints) {
101
+ for (const [row, history] of this.#histories.entries()) history.push(pending[row]!);
102
+ return Promise.all(this.#histories.map((_, row) => this.constraints!.propose(row, depth)))
103
+ .then(proposals => this.#drafts = proposals);
104
+ }
99
105
  this.#drafts = this.#histories.map((history, row) => {
100
106
  history.push(pending[row]!);
101
107
  return proposeNgram(history, this.max, this.min, depth);
@@ -126,6 +132,7 @@ export class NgramProvider implements DraftProvider {
126
132
  readonly max: number;
127
133
  readonly min: number;
128
134
  readonly grouped: GroupedDraftProvider = {
135
+ checkpointNamespace: () => namespace(this.max, this.min),
129
136
  supportsTargetAdapters: true,
130
137
  open: options => new NgramRows(this.max, this.min, options.checkpoints),
131
138
  openPrefill: options => new NgramRows(this.max, this.min, options.checkpoints),
@@ -150,7 +157,7 @@ class NgramSource implements DraftSource {
150
157
  readonly weightsBytes = 0;
151
158
  #hist: number[] = [];
152
159
  #lastDrafts: number[] = [];
153
- readonly #tailSplit = flagOn("MLX_BUN_PREFILL_TAIL_SPLIT", true);
160
+ readonly #tailSplit = runtimeFlag("MLX_BUN_PREFILL_TAIL_SPLIT", true);
154
161
  readonly checkpoint: NonNullable<DraftSource["checkpoint"]>;
155
162
 
156
163
  constructor(
@@ -202,3 +209,18 @@ class NgramSource implements DraftSource {
202
209
 
203
210
  dispose(): void {}
204
211
  }
212
+
213
+ /** Constraint candidates share committed-history ownership with prompt lookup.
214
+ * They have no serial draft implementation and never perform ngram lookup. */
215
+ export function constraintDraftProvider(): { id: string; grouped: GroupedDraftProvider } {
216
+ return {
217
+ id: "grammar",
218
+ grouped: {
219
+ checkpointNamespace: () => namespace(3, 1),
220
+ supportsTargetAdapters: true,
221
+ openPrefill: options => new NgramRows(3, 1, options.checkpoints),
222
+ open: options => new NgramRows(3, 1, options.checkpoints,
223
+ options.constraints ?? { propose: async () => [] }),
224
+ },
225
+ };
226
+ }
@@ -65,9 +65,9 @@ class ProjectedDraftRows implements DraftPrefillGroup, DraftRowGroup {
65
65
  dispose(): void { this.context.dispose(); }
66
66
  }
67
67
 
68
- export function projectedDraftGroups(bind: (target: TargetView) => ProjectedDraftMethod): GroupedDraftProvider {
68
+ export function projectedDraftGroups(namespace: string, bind: (target: TargetView) => ProjectedDraftMethod): GroupedDraftProvider {
69
69
  const open = ({ target, checkpoints }: {
70
70
  target: TargetView; checkpoints: readonly (DraftRowCheckpoint | null)[];
71
71
  }) => new ProjectedDraftRows(bind(target), checkpoints);
72
- return { open, openPrefill: open };
72
+ return { checkpointNamespace: () => namespace, open, openPrefill: open };
73
73
  }
@@ -133,6 +133,17 @@ export class QwenMtpRows {
133
133
  if (state.length) ops.evalAll(state);
134
134
  }
135
135
 
136
+ /** Align the companion to verified external continuations through the same
137
+ * true-hidden prefill graph. Different rows retain different prefix lengths. */
138
+ consume(tokens: MlxArray, context: MlxArray, lengths: readonly number[]): void {
139
+ this.#cache.specRoundBegin();
140
+ this.prefill(tokens, context);
141
+ this.#cache.specRoundRollback([...lengths]);
142
+ using positions = ops.fromInt32(lengths.map(length => length - 1), [lengths.length, 1, 1]);
143
+ const hidden = ops.takeAlongAxis(context, positions, 1);
144
+ this.#hidden!.dispose(); this.#hidden = hidden;
145
+ }
146
+
136
147
  /** Nonnegative depth, with every request's first pending token already known.
137
148
  * Build the complete device dependency chain before reading proposal IDs. */
138
149
  draft(pending: readonly number[], depth: number, steps: readonly number[]): number[][] {