Repository navigation
feat(speculative): LFM2 / LFM2.5 DSpark drafter on the DFlash loop - #1751
Conversation
Implementation Review SummaryIntent
Verified against the issue, line by line
Short-conv rollback
|
A LiquidAI DSpark drafter is a 5-layer DFlash backbone fed five of the target's residual streams plus a rank-256 Markov token-transition head that chains the block's parallel logits into a sequential proposal. mlxcel already served `lfm2` and `lfm2_moe` and already had the DFlash round loop, but had no LFM2 target adapter, no Markov head, no DSpark config handling and no short-conv rollback, so `--draft-model <dspark-dir>` fell through the Qwen-only burst gate to classic decode.
- `DFlashConfig` learns `markov_rank`, `rope_is_neox_style`, `runtime_block_size` and `enable_confidence_head`, lifts `num_target_layers` out of the nested `dflash_config`, and reports `verify_width` as `block_size + 1` for DSpark, whose `block_size` counts proposals rather than verify rows.
- The drafter's RoPE pairing now follows the checkpoint, and the DSpark draft step builds `[anchor, mask, ...]`, keeps every position's logits and walks them through `VanillaMarkovHead` as one lazy graph with a single host sync per round.
- `Lfm2Model` gains the verify forward with residual-stream capture, the partial-accept rollback (attention KV trim plus a short-conv state recomputed from the block's gated input, which cannot be trimmed), the block-versus-chain exactness probe and the `SpeculativeTarget` impl. `Lfm2VlModel` delegates to it for text-only requests.
- The server's `Qwen35DFlashTarget` becomes `DFlashTargetModel`, carrying `make_dflash_caches`, the first-draft prompt-row policy and the exactness gate, so both DFlash bursts drive any family through one driver.
- DSpark is greedy-only: a sampling request declines to classic decode with one named warning.
Two LFM2 loader gaps the bf16 originals exposed are fixed alongside, because the pairing cannot load without them: `rope_theta` nested under `rope_parameters` was read as the 1e6 default, and unstacked per-expert `feed_forward.experts.{e}.w1/w2/w3` tensors were never renamed, so MoE expert stacking never fired.
Refs #1339
…after Addresses the review findings on PR #1751. The MEDIUM: an LFM2 target paired with a plain Qwen 3.5 DFlash drafter had no gate. `Drafter::validate_target_compat` cannot make that call, because a DFlash drafter reads the target as a `LanguageModel` and sees no architecture string, so it returns `Ok`; and `DFlashDraftModel::forward` does not check its `fc` input width. Past that point the mismatch lands as an MLX shape throw inside the drafter forward, and an MLX C++ exception crossing the cxx bridge aborts the process rather than failing the request. Before this branch the pairing declined at the burst's variant gate, which no longer rejects LFM2, so this is a regression the branch introduced rather than a pre-existing gap. The target is the side that knows its own family, so the policy goes on `DFlashTargetModel` as `requires_dspark_drafter()`, `true` for `Lfm2Model` and `Lfm2VlModel` and the permissive default everywhere else, read by both run arms before any forward and answered with an operator-facing message naming `--model-draft` and the published pairings. Qwen 3.5 keeps the permissive default: widening it there would change which DFlash pairings that family accepts, which is a separate call. Two checkpoint-supplied numbers reached a buffer index or a round size unbounded, and the DSpark pairing gate now bounds both. `mask_token_id` indexes the target's embedding table (a DSpark drafter ships none of its own) and MLX range-checks no positive gather index, so an id past the last row read whatever followed the table in the buffer and fed it to the logits; the target half of the same gate already pins the target's vocabulary to `vocab_size`, so bounding the id against `vocab_size` bounds the gather. A `runtime_verify_width()` below two rows proposes nothing and would emit one token per burst. `verify_width()` also saturates instead of wrapping, since `usize::MAX` there panics in a debug build and wraps to a zero-row width in a release one. The Markov head's two factors are now measured against the config that sizes them before either is built, because neither shape was checked anywhere downstream: `markov_w1` is gathered at a token id once per chain step, so a table with fewer rows than the vocabulary the chain draws from read past its own buffer, and a `markov_w2` of the wrong width threw inside MLX, which crosses the bridge as a process abort rather than a load error. A quantized factor bit-packs `rank` along its last axis only, so the rank check is skipped for a packed table and the row check is not. Three doc corrections the review found, no behavior change: `ShortConv::forward_with_capture` claimed the snapshot costs no copy while the code takes an explicit one (it has to, since `conv_state` is reassigned at the end of the call); `configured_block_size` implied a DFlash round-loop block-size policy that does not exist, when the only readers are the MTP generator and its batched round loop and a DSpark run gets its width from `resolve_draft_block_size` before the drafter is loaded; and the `dflash_target` module doc claimed a new family costs one `impl` block plus a match arm, when it is three arms in `speculative_burst` and one of them still carries the LFM2 B = 1 decline as per-family policy outside the trait. Tests: `dspark_config_pairing_gate_bounds_the_mask_id_and_the_verify_width` and `an_lfm2_target_declines_a_drafter_that_is_not_dspark`. `-p mlxcel-core drafter::dflash` 70 passed, `--lib` over `models::lfm2` / `server::batch::speculative_burst` / `server::batch::dflash_target` / `cli::speculative_args` / `models::detection` 181 passed, clippy and fmt clean.
Section 8 was written while every finding was still open. It now splits into what was fixed on the branch (the LFM2 pairing gate, the two unbounded checkpoint-supplied numbers, the Markov factor shape checks, three false doc statements) and what is left open with the reason, including the corrected count of match arms a new family touches: three, not four, because `model_variant_label` is the project-wide label table rather than a DFlash arm. Section 9 carries the post-rebase diff stats and the test counts the fixes moved.
2ac255c to
e1585c6
Compare
…ulary tables Addresses the security review on PR #1751. One HIGH, three MEDIUM, one LOW, all in the same class: a downloaded drafter checkpoint's `config.json` and weight shapes are attacker-controlled the moment a user points `--model-draft` at a repository, and MLX range-checks no positive gather index while an MLX C++ exception crossing the cxx bridge aborts the process rather than failing the request. HIGH. The verify width was bounded below and not above, and this branch is what made it a control input: `resolve_draft_block_size` peeks the drafter config through `peek_dspark_configured_block_size` and hands `runtime_verify_width()` to the scheduler as the server-wide block size. A config declaring `block_size` and `runtime_block_size` together escapes the eight-row default, and the first LFM2 request then runs the block-versus-chain exactness probe at that width, which is one single-token target forward per row, three draws, on the scheduler thread, memoized. At a million rows that is a hang rather than a slow request, and it happens before the pairing gate that would otherwise refuse the config. `runtime_verify_width()` now clamps at `DSPARK_MAX_VERIFY_WIDTH` (32, against the published 8 to 10 and the flat DFlash 16), which is what holds ahead of the probe, and the gate refuses a config whose `requested_verify_width()` is above the ceiling so the operator is told rather than quietly served at 32. MEDIUM. The `mask_token_id` bound added in the previous commit argued its soundness from the target half of the pairing gate pinning the target's vocabulary. That holds only on the lazy-bind path. A DSpark checkpoint shipping its own `embed_tokens` takes the other arm of `bind` and its table was never compared to anything, so an eight-row table under a declared `vocab_size` of 128000 still gathered row 125017. `from_weights` now measures the row count of a self-contained DSpark `embed_tokens` and `lm_head` against `vocab_size`; a plain DFlash checkpoint is deliberately left alone, since it has no Markov head and no mask id indexing a borrowed table. MEDIUM. The Markov rank check was skipped rather than narrowed for a quantized factor, and every mlx-community conversion of these drafters is quantized, so the skipped case is the common one. MLX packs the last axis u32-wise, so the stored width is `rank * bits / 32`; the check now accepts a packed width when some supported bit depth explains it, which bounds it without trusting the loader's declared `bits` against a per-tensor override. MEDIUM. `first_hidden_for` deep-copied the whole prompt hidden under the every-row policy, because `mlxcel_core::copy` is a real MLX `Copy` primitive and the signature took a borrow: a second `[1, S, 5 * hidden]` slab per request, 168 MB at an 8k prompt on LFM2.5-2.6B and 671 MB at 32k, for nothing. It now takes the array by value and returns it, which also ends the caller's retention of the original across the whole round loop. MEDIUM. The short-conv rollback snapshots were captured on the prompt prefill, where nothing reads them: a prefill is never rolled back. Each snapshot holds the layer's gated input at the forward's own width, and LFM2 is conv-dominant, so at prompt length that pinned roughly one prompt-sized buffer per conv layer alive through the eval that materializes the prefill, about 670 MB at 8k on a 30-layer 2.6B checkpoint and about 2.7 GB at 32k. `SpeculativeTarget` gains `prefill_forward_with_capture_layers`, defaulting to the verify hook so every other family is unaffected, and LFM2 overrides it to skip the capture. `forward_speculative` takes the capture as a flag; it changes what is kept and never what is computed, which the new test pins on the logits, the captured hidden and the cache offsets at once. LOW. `run_dflash_on_target` and its batched sibling now refuse an empty prompt (and an empty window) as a request error. Both were inherited unchanged from the Qwen arms, where a negative slice start would reach MLX and abort the process; neither is reachable from the scheduler today. Left as-is with the reason recorded in the technical report: `sample_block_array`'s B = 1 invariant stays a `debug_assert`, since the batched path already refuses a DSpark drafter and a release panic in a request handler is itself a denial of service; and the mismatched-pairing decline stays `BurstOutcome::Error` rather than a fallback to classic decode, matching the `validate_target_compat` arm directly above it, because a silent fallback hides an operator misconfiguration. Tests: four new in `-p mlxcel-core drafter::dflash` (74 passed, was 70) and three new under `--lib` (184 passed, was 181), covering the width ceiling including the `usize::MAX` saturating case, the self-contained vocabulary tables, the packed rank check, and the conv-capture flag. `cargo check`, `cargo clippy -- -D warnings` and `cargo fmt --all -- --check` clean for both the root package and `-p mlxcel-core`.
A new section 8b covers the pass that looked specifically at what a downloaded drafter checkpoint controls: the verify-width ceiling and why the fix sits in two places (the clamp is what holds ahead of the exactness probe, the gate is what tells the operator), the two vocabulary-sized tables a self-contained DSpark checkpoint could ship unmeasured, the packed Markov rank check, the prompt-sized copy and the prompt-sized rollback snapshots that were being kept for nothing, and the two findings left as-is with the argument for leaving them. Section 9 carries the new stats and records that checking `--lib --tests` at the workspace root never compiles `mlxcel-core`'s test target, which is how two test-only compile errors got as far as a suite run.
…lit-mtp Review follow-ups on the GLM-4.7-Flash MTP drafter. `glm4_moe_lite` is a pool-cached family, so the scheduler upgrades its caches inside `CachePool` after allocation (`apply_kv_cache_mode_to`) and the model never needed a mode table. The MTP slot this branch added is built by the model instead, from an unconditional `KVCache::new()`, and `Glm4MoeLiteModel` took the no-op `set_kv_cache_layer_modes` default, so nothing carried the resolved per-layer table to the caches an engaged MTP session actually runs on. It is the first pool-cached family to acquire a model-owned slot; Gemma 4 and Qwen 3.5 are model-owned throughout and both implement the hook. The model now holds a `KvCacheLayerModes` table, implements both trait methods, and every slot install and both arms of the block-vs-chain probe build through `make_configured_caches`, so the probe measures the arithmetic the session runs rather than an FP16 stand-in. The offline path injects the same resolved table before dispatch, since it builds no `GenerationConfig` and nothing else on it would reach the slot; that injection is scoped to this family because correcting the other MTP families' identical gap changes what their offline runs measure. No configuration reaches the mismatch today: `resolve_kv_cache_mode_for_model` (#1350) resolves every quantized mode to fp16 for the MLA-latent families, `glm4_moe_lite` among them, on both the `--kv-cache-mode` and the `--kv-bits` route, so the pool caches an MTP request runs beside are fp16 as well. The fix closes the structural gap and holds the invariant if this family ever gains a calibrated quantized mode. `split-mtp` gains two refusals. `--block-size` is bounded at 16: the recorded value becomes the server's default verify width, this family materializes one query row per verify position, and an unchecked `--block-size 20000` produced a directory that loaded fine and then built roughly a million graph nodes per round with the scheduler tick held. The `--force` guard now screens on whatever makes a directory a checkpoint rather than on `model.safetensors` alone, so an `--output` pointed at a sharded checkpoint no longer silently replaces its config and tokenizer files while orphaning the shards, and `--force` clears a stale index that would otherwise send the loader to those shards. Smaller: `decompose_kv_b_proj` converts its four geometry fields with `i32::try_from` and range-checks the head-dim product, because `as i32` could wrap past the shape check that exists to keep a malformed config from reaching `reshape` and aborting MLX across the bridge, and that helper is now on the target loader's path too. `num_nextn_predict_layers + 1` is saturating in both places, and a config missing both `block_size` and `num_nextn_predict_layers` is told which field is missing instead of being blamed for a `block_size` nobody wrote. `prefill_from_target_hidden` clears runtime state before the empty-prompt return so a recycled drafter cannot inherit the previous session's seed. `peek_drafter_model_type` gets the `Used by:` note. The hooks module doc records why the MoE stays batched while the attention does not, and `docs/supported-models.md` states that automatic prefix caching and MTP do not combine on this family. Rebased onto #1751 (LFM2 / LFM2.5 DSpark on the DFlash loop), which landed in the same files: the two block-size peeks key on different `DrafterKind`s and both stay, the drafter error enum carries both new variants, `load_drafter` in the burst resolves to the binary-crate wrapper this branch needs while `sampler_is_greedy` keeps coming from core, and the model-name match carries both families' arms. Re-validated on `models/glm-4.7-flash-4bit` with the 4-bit drafter: 128 greedy tokens are id-identical across classic and MTP, fused MoE on and off (md5 `f22d2a4b`), mean accepted length 1.753 over 73 rounds, unchanged from before these commits. Narrow suites (surgery 160, root `models::glm4_moe_lite` 33, `mlxcel-core` `drafter::` 179 and `mla::` 41, bin `commands::split_mtp` 2), clippy `-D warnings` on lib, tests and bins for all three crates, and `cargo fmt --check` pass. Refs #1326
Bilingual report for PR #1753 (issue #1326), committed into the branch ahead of the merge because `TECHNICAL_REPORTS/.keep-reports` is present. Covers the `split-mtp` surgery op and every refusal it grew, the stateful drafter and the post-final-norm hidden tap the acceptance measurement settled on, the two width-sensitive pieces of the verify forward decided in opposite directions, the review finding that the model-owned MTP slot never saw the resolved KV cache mode table, the documented prefix-caching and MTP non-combination, the rebase onto #1751, and the validation the branch actually ran alongside what it does not establish. Refs #1326
GLM-4.7-Flash keeps its one next-token-prediction layer as `model.layers.47.*` and the community 4-bit conversions drop it, so the drafter has to be split out of the raw checkpoint into its own directory. This adds `mlxcel split-mtp` (a `mlxcel-surgery` op that writes a standalone `glm4_moe_lite_mtp` directory and refuses a checkpoint whose index names no nextn layer), the stateful `Glm4MoeLiteMtpDraftModel` built on the decoder's own `TransformerBlock`, and an `MtpTarget` adapter for `Glm4MoeLiteModel` with an absorbed-MLA verify forward, trim-based rollback on a model-owned per-sequence cache slot, and the block-vs-chain exactness probe behind the shared gate. The offline CLI, the server burst, the tick slice and the speculative bench admit the family; requests that adopted a prompt-cache prefix decline to classic because the MTP slot cannot see the pool cache. Two decisions to keep when reviewing: the verify attention runs one materialized query row at a time, because an unmaterialized slice of the batched query put MLX on a different kernel and the rows drifted from the decode chain data-dependently; and the drafter is fed the post-final-norm hidden, which measured higher acceptance than the pre-norm residual (below, and in the drafter's module docs). The MoE is deliberately left batched: `SwitchGLU` switches to the gather-sort path at `n_tokens * top_k >= 64`, so the default `block_size = 2` takes the same reduction as the decode chain while a wide `--draft-block-size` crosses the threshold and is left for the probe to decline. The drafter is built in the binary crate through `models::drafter_loader::load_drafter` because it reuses the `glm4_moe_lite` block; core refuses the model_type by name. ## Changes during review - **The KV cache mode now reaches the MTP slot.** `glm4_moe_lite` is a pool-cached family, so the scheduler upgrades its caches inside `CachePool` after allocation and the model never needed a mode table; the MTP slot this PR adds is built by the model instead, and `Glm4MoeLiteModel` took the no-op `set_kv_cache_layer_modes` default. It is the first pool-cached family to acquire a model-owned slot. The model now holds a `KvCacheLayerModes` table, implements both trait methods, and every slot install plus both arms of the block-vs-chain probe build through `make_configured_caches`, so the probe measures the arithmetic the session runs. The offline path injects the same resolved table before dispatch, scoped to this family. No configuration reaches the mismatch today: `resolve_kv_cache_mode_for_model` (#1350) already resolves every quantized mode to fp16 for the MLA-latent families on both the `--kv-cache-mode` and the `--kv-bits` route, so this closes the structural gap rather than a live regression, and holds the invariant if the family ever gains a calibrated quantized mode. - **`split-mtp --block-size` is bounded at 16.** The recorded value becomes the server's default verify width and this family materializes one query row per verify position, so an unchecked `--block-size 20000` produced a directory that loaded fine and then built roughly a million graph nodes per round with the scheduler tick held. - **The `--force` guard screens on whatever makes a directory a checkpoint**, not on `model.safetensors` alone, so an `--output` pointed at a sharded checkpoint no longer silently replaces its config and tokenizer files while orphaning the shards; `--force` also clears a stale index that would otherwise send the loader to those shards. - **`decompose_kv_b_proj` converts its geometry fields with `i32::try_from`** and range-checks the head-dim product. `as i32` could wrap past the shape check that exists to keep a malformed config from reaching `reshape` and aborting MLX across the bridge, and that helper is now on the target loader's path too. - Smaller: `num_nextn_predict_layers + 1` is saturating in both places; a config missing both `block_size` and `num_nextn_predict_layers` is told which field is missing; `prefill_from_target_hidden` clears runtime state before the empty-prompt return so a recycled drafter cannot inherit the previous session's seed; `peek_drafter_model_type` gets its `Used by:` note. - **Documented**: automatic prefix caching and MTP do not combine on this family. An MTP-served request's target K/V lives on the model-owned slot rather than in `CachePool`, so it donates nothing and a later turn finds no prefix to adopt. Confirmed safe (no partial entry can reach the store on either the dense or the paged arm); the cost is the lost optimization, and `docs/supported-models.md` now says so rather than implying adoption normally works. Rebased onto #1751 (LFM2 / LFM2.5 DSpark on the DFlash loop), which landed in the same files: the two block-size peeks key on different `DrafterKind`s and both stay, the drafter error enum carries both new variants, `load_drafter` in the burst resolves to the binary-crate wrapper this branch needs while `sampler_is_greedy` keeps coming from core, and the model-name match carries both families' arms. ## Validation Real checkpoints: target `models/glm-4.7-flash-4bit` (16 GB), drafter `models/glm-4.7-flash-mtp-4bit` produced by `./target/release/mlxcel split-mtp --model models/glm-4.7-flash-bf16 --output models/glm-4.7-flash-mtp-4bit --q-bits 4` (705 MB, `block_size: 2`). Apple M5 Max, release binary rebuilt from this tree after the rebase. Every CLI run: `mlxcel generate -m models/glm-4.7-flash-4bit -p "Explain in two sentences why the sky is blue." -n 128 --temp 0 --show-reasoning` with `MLXCEL_PRINT_TOKEN_IDS=1`, ids compared with `cmp`, `--draft-model models/glm-4.7-flash-mtp-4bit` on the MTP arms. - Four arms under `MLXCEL_QMV_WIDE=0` (classic and MTP, `MLXCEL_FUSED_MOE` unset and `=0`) are id-identical over all 128 tokens, md5 `f22d2a4b`. `MLXCEL_FUSED_MOE` changes nothing in any arm because `glm4_moe_lite` never dispatches the fused single-token MoE kernel. Re-run three times: before the review fixes, after them, and after the rebase, with the same md5 each time. - Mean accepted length 1.753 (55 of 73 proposals), unchanged across all three runs. The pre-norm tap (`MLXCEL_GLM_MTP_HIDDEN_TAP=pre`) measured 1.716 (53 of 74); post-final-norm stays the default and both taps emit the same ids. - A classic run with the default wide `qmv` kernel differs from every other arm at one near-tie position; that arm also differs from the narrow classic run, so the divergence is the #1199 kernel-selection effect the exactness gate exists for (the gate logs `MTP exactness probe failed under qmv_wide ... passed without it. Disabling qmv_wide for this process`), not the MTP path. - Opt-in gate `MLXCEL_TEST_GLM_MTP_TARGET=models/glm-4.7-flash-4bit cargo test ... real_checkpoint_verify_block_matches_decode_chain -- --nocapture`: 128 positions, 0 byte mismatches, worst abs diff 0.0, 0 argmax flips. - Server on port 19326, both sides under `MLXCEL_QMV_WIDE=0`: `mlxcel-server -m models/glm-4.7-flash-4bit --model-draft models/glm-4.7-flash-mtp-4bit` versus the same server with no drafter, `POST /completion` with the chat-templated prompt, `temperature: 0`, `n_predict: 128`, `return_tokens: true`. Both stop at EOS after 36 ids and the two id streams are identical. Startup logs `MTP exactness probe passed: verify block is byte-identical to the single-token chain block_size=2`; the request served through the tick slice at 18 rounds, 16 accepted, `emitted_per_verify` 1.889, and `/v1/internal/mtp-policy` reports `profiling` with `acceptance_rate: 0.889`. Both servers stopped afterwards. - `--kv-cache-mode int8` on this checkpoint: classic and MTP are id-identical over 64 tokens and the banner reports `fp16 (requested int8; effective fp16; applied to 47 of 47 layers)`, which is #1350's MLA-latent substitution and the reason the slot mismatch was not operator-visible. - Tests: `cargo test --profile test-fast -p mlxcel-surgery` (160), `--lib models::glm4_moe_lite` (33), `-p mlxcel-core drafter::` (179) and `mla::` (41), `--bin mlxcel commands::split_mtp` (2). `cargo clippy --lib --tests --features metal,accelerate -- -D warnings` at root and for both member crates, plus `cargo clippy --bins`, all clean; `cargo fmt --all -- --check` clean. Not run: the full `--workspace` gate (machine shared with other agents; narrow scopes only). Batched B > 1 windows decline to classic by design and were not exercised. Closes #1326
Bilingual report for the Muse Glimmer DFlash assistant drafter: what the published checkpoint carries and what it omits, why the drafter's bidirectional absolute-position mask rules out a rotating cache for its own context, the measurement that made the verify width adaptive, the server contract change that PR #1751 deferred to this family, and the pre-merge review round with the three findings fixed and the six left as reported.
… loop (#1762) Adds the Muse Glimmer assistant drafter (`meta-models/Muse-Glimmer-30B-assistant`, `model_type: muse_glimmer_assistant`) to the existing DFlash round loop, with the Muse target adapter and the server dispatch, so `mlxcel-server -m <Muse-Glimmer-30B> --model-draft <assistant>` serves text-only requests speculatively at B = 1. ## What changed - `mlxcel-core` `drafter::dflash::muse`: config (flat; `num_target_layers` and `vocab_size` are `Option`s checked against the bound target, because the published `config.json` carries neither), five sliding-attention layers with per-head q/k norms under a bidirectional sliding mask over absolute positions (`|q - k| <= 2048`), `encoder.fc` plus `encoder.output_norm_enc`, and a `Drafter` adapter that binds the target's raw embedding table and untied `lm_head`. The context cache is a temporal window rather than a `RotatingKVCache`: a wrapped ring returns physical slot order and the mask needs absolute key positions. `muse_glimmer_assistant` resolves to `DrafterKind::Dflash`; its markers join the standalone-model rejection. - Muse target (`src/models/muse_glimmer_speculative.rs`): residual-stream capture before the final norm, trim rollback over the mixed caches, sliding caches armed with `clamp(block * 8, 32, 128)` rows of speculative buffer floored at the block size, the block-versus-chain exactness probe as the gate. `MuseGlimmerTextWrapper` and the VLM wrapper hand out `embed_tokens_module` (raw table, not the `embed_norm` lookup) and `lm_head_module` (no softcap). `prefill_forward_with_capture_layers` keeps the trait default on purpose: a Muse verify output carries no rollback state (the trim reads cache offsets), so there is nothing prompt-sized to skip. - Round loop: `DFlashGenerator` reads `Drafter::configured_block_size` / `prefer_requested_block_size` and, for a drafter that declares a depth below the requested width without insisting on it, runs the MTP loop's `BlockThroughputController` between the two. The Muse assistant starts at 4 rows under the checkpoint's 16-row ceiling (measurement below). Qwen 3.5 DFlash (no depth) and DSpark (prefers the requested width) keep a fixed width; pinned by `dflash_round_loop_starts_at_the_configured_depth`. - Server (the PR #1751 review item): `DFlashTargetModel::supports_batched()` replaces the hardcoded LFM2 arm in the batched variant gate, `required_drafter_family()` replaces the DSpark boolean and refuses a mismatched pairing by name for both LFM2 and Muse, and the module doc no longer describes per-family policy living outside the trait. `run_dflash_burst`'s three arms and `model_variant_label` cover `MuseGlimmerVLM`. The Muse startup guard admits exactly the assistant drafter (`--draft-kind dflash` or unset), still rejecting adapters, KV modes, TP/PP and draft flags without a drafter. - Docs: `docs/supported-models.md` (DFlash row for Muse, family paragraph, the "not a speculative target" sentence replaced), `docs/speculative-acceptance.md`, and the README speculative bullet. ## Pre-merge review round (commit `796604f1`) Three findings were fixed in this PR; the rest are listed below as reported and not fixed. - **HIGH, fixed. The exactness gate probed a width the loop does not run at.** The verify width became adaptive in this change, so a Muse run warms up at the drafter's declared depth of four rows and widens to the requested ceiling only when a measurement window pays for it, while the burst gate called `exactness_allows(block_size)` with the ceiling alone. The forward width selects which quantized-matmul kernel MLX dispatches, so a pass at the ceiling is not evidence about the depth: `docs/benchmarks.md` records the same comparison reading 20.6 percent disagreement at width 8 and 0.0 percent at width 32. `probed_verify_widths` now names the widths a run can settle on and `dflash_exactness_allows_every_width` requires all of them. On this host the probe declines at width 4 as well (162404 of 404096 logit bytes differ, against 187123 at width 16), so the gate had been reporting a verdict for a width the loop would have left after the warm-up. Pinned by `probed_widths_cover_the_warm_up_depth_and_the_ceiling`. - **HIGH, fixed. The speculative buffer could be narrower than the block it buffers.** `speculative_buffer_size` was the Gemma 4 rule verbatim, `clamp(block * 8, 32, 128)`, and the buffer is the slack past `max_size` that lets a verify block be appended across the ring boundary and rewound. `--draft-block-size` is not bounded above anywhere on the way in, so at 129 rows and above the cap handed the block less slack than it needed: the append overwrites still-visible window rows, `RotatingKVCache::trim` rewinds the offsets without restoring them, and the sequence continues from a window with holes in it with no error anywhere. The rule now floors at the block size, a no-op at every width a real run reaches. Pinned by `speculative_buffer_never_falls_below_the_block_it_buffers`. The rollback also names a short rewind through `tracing::error!` rather than only a `debug_assert`, and a verify that captured no residual stream for a drafter slot now says so instead of handing over a zero slab that reads as a residual stream. - **MEDIUM, fixed. Config dimensions were unbounded and the encoder projected rows it was about to discard.** `MuseAssistantConfig::validate` now bounds every dimension it hands MLX as an `i32`, so a malformed `config.json` naming one past the wrap point fails as a load message rather than reaching MLX as a negative extent, which crosses the cxx bridge as an abort (`config_refuses_dimensions_that_would_wrap_an_i32`). And the drafter's `forward` drops the rows past the sliding window before the encoder runs rather than after: `encoder.fc` and `output_norm_enc` are both per-row so the context is unchanged, but at `-c 16384` the incoming residual slab is 1.1 GB in f16 and seven eighths of it was being cast and projected only to be sliced away. Reported and not fixed: - **MEDIUM.** `--draft-block-size` has no upper bound. `resolve_draft_block_size` returns an override verbatim ("no further validation here; concrete generators enforce their own minimums"), which is family-wide and predates this change. The buffer floor above removes the silent-corruption consequence for Muse, but an absurd width still allocates an absurd verify block. A shared clamp belongs in `resolve_draft_block_size` or the server startup validation and would touch every DFlash and MTP family. - **MEDIUM.** The burst's decline line names the requested ceiling (`block_size=16`) while the probe verdict immediately above it names the width that actually failed (`block_size=4`). The burst message is shared by all DFlash families and it points the reader at the verdict line, so it is left alone rather than made family-specific. - **MEDIUM.** `MuseAssistantContextCache::append` rebuilds the whole window every round (concatenate, slice, `contiguous`, then two `ffi::copy`), so a steady-state round copies roughly 2 x 2048 x 8 x 128 x 2 bytes per drafter layer. A ring with a rotate-on-read, or `RotatingKVCache` taught to report absolute key positions, would drop it. It is a fraction of one target verify forward and does not move the measurements below. - **LOW.** `MuseAssistantDrafter::bind` does not re-run `validate_target_compat` the way `Glm4MoeLiteMtpDrafter::bind` does. Every current caller (`run_dflash_on_target`, the batched arm, `speculative_bench`, the scheduler hook) validates first, and putting it in `bind` would repeat a full-vocabulary probe matmul on every burst. - **LOW.** `MuseAssistantModel::forward` guards its cache-count invariant with `debug_assert_eq!` and `zip`, which truncates silently in release. The only constructor of that vector is `make_caches`. - **LOW.** The `mtp_exactness_gate` probe key is `(block_size, hidden_size, num_hidden_layers)`, which cannot tell two Muse checkpoints of the same shape apart within one process. Pre-existing and shared with the MTP and DSpark arms. ## Real-checkpoint validation (M5 Max, GPU generation 17; `models/muse-glimmer-30b-4bit` with `models/muse-glimmer-30b-assistant-bf16`, port 19343) Re-run at `796604f1` against a `mlxcel-server` rebuilt after the rebase onto `e3ca8afc` and after the review fixes; no source file is newer than the binary. Driver: `validate_muse.py` starts `target/release/mlxcel-server -m models/muse-glimmer-30b-4bit --port 19343 -c 16384 --parallel 1 [--model-draft models/muse-glimmer-30b-assistant-bf16]`, sends `POST /v1/chat/completions` with `temperature: 0, max_tokens: 256, logprobs: true`, and compares `logprobs.content[].token` between arms. Three text prompts: a 66-token tides prompt, a 1968-token log prompt whose 256-token answer crosses the 2048-token sliding window during decode, and a 2660-token log prompt whose caches wrap during the prefill; plus one `image_url` request. 1. Shipped default (no env override): the probe declines on this host at the warm-up width (`MTP declined: verify block position 0 differs from the single-token chain in 162404 of 404096 logit bytes. Disabling qmv_wide did not make it exact either. block_size=4`), the burst logs `DFlash speculative dispatch declined for seq ...: the block-versus-chain exactness probe did not pass at block_size=16` once per request and serves classically. All three prompts: **IDENTICAL, 256 of 256 tokens** against the same server without `--model-draft`. This is the documented generation-15-and-newer condition, the same as LFM2 DSpark on this host. 2. `MLXCEL_MTP_ALLOW_INEXACT=1`: cross-window-decode **IDENTICAL** (256 tokens, 47 rounds, 209 of 213 proposals accepted, mean accepted 4.45, 60.5 tok/s decode against 22.5 tok/s classic end to end); wrapped-in-prefill **IDENTICAL** (256 tokens, 47 rounds, 209 of 214 accepted, mean 4.45, 60.6 tok/s against 20.1); tides **DIVERGES at token 59** (`Earth's rotation` against `centrifugal force`, 109 rounds, 146 of 375 accepted, mean accepted 1.34, 38.4 tok/s decode against 31.6 classic end to end). The divergence is the byte-identity the probe declined, observed after overriding it, which is why the override is not the default. 3. Image request under `--model-draft`: one `WARN ... DFlash speculative dispatch declined for seq seq-4: multimodal VLM request detected ...` line, served classically, HTTP 200. 4. `timings` on every speculative response carry `draft_kind: dflash`, `draft_rounds`, `draft_n`, `draft_n_accepted`; mean accepted length is above 1 on every prompt (1.34 to 4.45) and every speculative arm decodes faster than the classic arm of the same prompt. 5. Verify-width measurement that set the 4-row start (an earlier sweep on the same pairing, all under the override, three natural prompts: tides, a code request, a history question): fixed 16 rows 13.0 / 18.1 / 12.4 tok/s (1.87 / 3.40 / 1.80 accepted per round) against 18.1 tok/s classic; 8 rows 24.9 / 30.5 / 21.7; 4 rows 33.6 / 41.3 / 31.6; 2 rows 30.0 / 33.1 / 31.7 (first proposal accepted 74 to 82 percent of rounds). The 2660-token log prompt accepts 14 of 15 at 16 rows, which is what the comparator widens for; on the natural prompts its 16-row challenge windows measured 0.012 to 0.024 tokens per ms against 0.030 to 0.037 at 4 rows and were not adopted. Two alternatives were measured and rejected: capturing the residual stream one layer earlier (HF `hidden_states[i]` indexing) changed nothing (1.83 / 3.47 / 1.50), and feeding the drafter the `embed_norm`-wrapped embedding halved acceptance (1.28 / 1.35 / 0.90). ## Not in this PR - Batched (B > 1) Muse windows decline through `supports_batched()`; multimodal requests decline; `draft_window_size` is accepted and unused (the issue's out-of-scope list). - No `initial_block_size` on `DFlashGenerator::new` and no `dflash_round_loop_initial_block_size_four` test: the drafter-declared depth plus the throughput comparator covers the first-round narrowing the issue asked for, and is what the measurement supports. - The registry `drafters` entry for LFM2 was already absent before this change and is left as is. ## Checks `cargo fmt --all -- --check`; `cargo clippy --lib --tests` and `cargo clippy -p mlxcel-core --lib --tests` with `-D warnings`, both under `--features metal,accelerate`. Tests under `--profile test-fast --features metal,accelerate`: `-p mlxcel-core drafter::dflash` (86 passed) and the root crate filtered on `muse_glimmer` (109), `models::muse_glimmer` (21), `server::batch::speculative_burst` (67), `server::startup` (80), `models::detection` (63), `cli::speculative_args` (22), `server::batch::dflash_target` (4). The `--workspace` gate was not run locally because the machine is shared; the narrow scopes plus CI cover it. Closes #1343
Bilingual pre-merge report for the settings-schema roster and drafter-refusal drift repair, committed into the branch because the repository carries TECHNICAL_REPORTS/.keep-reports. The analytical content is why a per-PR test scope cannot see cross-file contract drift. A scope derived from a diff excludes exactly the files that assert about what the diff changed, and neither defect existed on its author's branch: #1759 was correct against the roster it was written against and #1751's assertion was correct against the message on its base, so the drift is a property of the merged state alone. The two misses have different shapes, one a module-filter miss inside the same --lib target and one a target miss on an integration binary --lib never builds, and the workspace gate run on merged main is the only layer where the merged state and the full target set meet. The report also records why the third gate failure (#1769, the deterministic mxfp8 tolerance from #1742) is a host toolchain result rather than drift, and therefore keeps its own PR and its own standard of evidence. Refs #1322, #1339, #1343, #1769.
## Summary The orchestrator-side workspace gate (`cargo test --workspace --profile test-fast --features metal,accelerate`) on main `58dc9693`, after the twelve merges of 2026-09-10, reported 10981 passed and 3 failed. Two of the failures are cross-file drift that the per-PR narrow test scopes could not see; this PR repairs both. The third (`fp8_block_requantize_matches_direct_path`, deterministic on this M5 Max under macOS 27) predates these merges and is tracked in #1769. ## What changed - `src/server/runtime_settings.rs`: `video_max_frames` (Int) and `video_fps` (Float) from #1759 join `CLASSIFIED_SERVER_CONFIG_FIELDS` in `ServerConfig` source order, with read-only values and the model-side startup reason, so the management API schema lists them. - `src/server/runtime_settings_tests.rs`: the roster test is renamed to `server_config_schema_classifies_all_107_fields` and its three counts move from 105 to 107. - `tests/speculative_dispatch.rs`: the `-m <drafter>` refusal assertion matches the wording #1751 introduced (`DFlash-family speculative drafter`), the same substring `detection_tests.rs` already asserts. ## Validation `cargo test --profile test-fast --features metal,accelerate --lib server::runtime_settings` (8 passed) and `--test speculative_dispatch` (22 passed), `cargo clippy --lib --tests --features metal,accelerate -- -D warnings`, `cargo fmt --all -- --check`, all clean. The full workspace gate is re-run on main after this merges; with #1769 still open it is expected to report exactly that one failure. Refs #1322, #1339, #1343, #1769
Main's DFlash server path moved to the `DFlashTargetModel` trait in `src/server/batch/dflash_target.rs` (#1751, #1762) with a per-family exactness gate and drafter-family pairing check, and the drafter refusal message grew a family list (#1770). This merge drops the branch's own `DFlashBurstTarget` trait in favor of main's design and integrates Laguna into it: `LagunaWrapper` implements `DFlashTargetModel` (every prompt row as the first hidden, `block_size` rows of speculative slack on the rotating caches, no batched burst, a `Laguna` drafter family that the target requires and the Laguna drafter self-reports through `Drafter::is_laguna_dflash`), `LagunaVerifyOutput` implements `DFlashVerifyOutput`, and the burst's variant gate, `drive!` match and batched gate gain Laguna arms. `LagunaModel::dflash_exactness_allows` runs the same measured block-versus-chain probe LFM2 and Muse Glimmer run, memoized through `mtp_exactness_gate` with the `MLXCEL_MTP_ALLOW_INEXACT` override; both the server burst and the offline `mlxcel generate --draft-kind dflash` arm fail closed on it. The DFlash probe recognizes `DFlashLagunaForCausalLM` next to the DSpark marker, `load_drafter` checks the Muse assistant, then Laguna, then the default, and the standalone-model refusal names the Laguna drafter. Refs #1351
## Summary Adds the Poolside Laguna DFlash drafter (`poolside/Laguna-*-DFlash`) as `mlxcel_core::drafter::laguna_dflash`, implements `SpeculativeTarget` on the Laguna target, routes `model_type: laguna` drafters through `load_drafter`, integrates Laguna into the server's `DFlashTargetModel` design (#1751, #1762) as its own drafter family, and wires the pairing into both `mlxcel-server` and offline `mlxcel generate --draft-kind dflash` (the offline DFlash arm did not exist for any target before; it is Laguna-only here). Like the LFM2 and Muse Glimmer arms, the pairing runs behind the measured block-versus-chain exactness gate (`LagunaModel::dflash_exactness_allows`, `MLXCEL_MTP_ALLOW_INEXACT` override) on both the server and the offline arm. ## Where the landed code and the issue text disagree - The forward follows vLLM's `laguna_dflash.py` (vllm-project/vllm#46853), which is what the published checkpoint is served with: every drafter layer passes the projected context through its own `input_layernorm` before the K/V projection (the issue has `ctx_qkv = qkv_proj(ctx_i)`), and the sliding window is a per-query limit over `[context | block]` rather than a fixed 511-row context view. With the checkpoint's norm weights all exactly 1.0 the first difference is numerically a no-op today; the second only matters past 512 context tokens. - The drafter's initial context is every captured prompt row (vLLM's DFlash proposer; `FirstHiddenRows::EveryPromptRow`), not the last row the Qwen 3.5 port uses. - `src/server/speculative_dispatch.rs` needed no Laguna entry: dispatch is by drafter kind; the target arm lives in `dflash_target.rs` and `speculative_burst.rs`. - The sliding target caches are armed with `enable_speculative_buffer(block_size)` so rollback is a tail trim; the drafter's context cache is a temporal buffer capped at `window - 1`, not a ring. ## Measurements (GB10 sm_121, NVFP4 target, post-rebase on main `0c21aed2` with #1795, `MLXCEL_MTP_ALLOW_INEXACT=1`) **Exactness probe, re-run after #1795:** still declines this host at block 16 (107246 of 200704 logit bytes differ at the first verify position) and at block 8 (107466); `qmv_wide` off does not change it. Neither #1795 fix applies to this pairing: the Laguna loader already kept the drafter in bf16 (it now goes through the shared `apply_drafter_load_dtype_policy`), and Laguna has no gated-delta layers. Without the override the offline arm exits with the verdict and the server burst declines to classic decode, so the feature ships inert here by default. **Throughput A/B, same binary, feature off (no `--draft-model`) versus on per block size.** Raw code prompts, no chat template, 200 tokens, GPU lock held for the whole sweep (GPU exclusive, 0 percent at start); the host was not otherwise idle: another session's `cargo build` / `rustc` ran at full CPU throughout (load 2.6 to 3.9), which is the same for both arms. Decode tok/s as the CLI reports it; n runs, min to max; per-round device time is the synchronized verify (drafter plus target) and the drafter's host-side graph build is listed separately. | Configuration | n | tok/s mean (min to max) | Mean accepted | Device ms/round | Drafter build ms/round | vs off | |---|---|---|---|---|---|---| | code 0 (`retry_with_backoff`), off | 5 | 31.18 (30.41 to 31.98) | | | | | | code 0, block 2 | 3 | 16.19 (16.12 to 16.25) | 0.84 | 80 | 30 | 0.52x | | code 0, block 3 | 3 | 21.61 (21.52 to 21.77) | 1.52 | 83 | 30 | 0.69x | | code 0, block 4 | 3 | 27.05 (26.61 to 27.53) | 2.21 | 85 | 31 | 0.87x | | code 0, block 5 | 3 | 28.33 (26.21 to 29.51) | 2.55 | 89 | 34 | 0.91x | | code 0, block 6 | 3 | 30.46 (30.05 to 31.26) | 2.90 | 92 | 33 | 0.98x | | code 0, block 7 | 3 | 30.24 (30.12 to 30.47) | 3.06 | 97 | 34 | 0.97x | | code 0, block 8 | 3 | 31.26 (31.07 to 31.36) | 3.33 | 101 | 34 | 1.00x | | code 0, block 9 | 3 | 30.58 (30.37 to 30.75) | 3.33 | 106 | 33 | 0.98x | | code 0, block 10 | 3 | 30.02 (29.68 to 30.19) | 3.42 | 109 | 35 | 0.96x | | code 0, block 12 | 3 | 28.94 (28.66 to 29.41) | 3.52 | 116 | 37 | 0.93x | | code 0, block 16 (default) | 3 | 27.41 (26.90 to 27.90) | 3.55 | 127 | 35 | 0.88x | | code 1 (`lru_get`), off | 3 | 31.80 (31.50 to 32.38) | | | | | | code 1, block 4 | 3 | 26.10 (25.74 to 26.47) | 2.08 | 83 | 31 | 0.82x | | code 1, block 6 | 3 | 30.42 (30.11 to 30.64) | 2.92 | 91 | 34 | 0.96x | | code 1, block 8 | 3 | 29.95 (29.60 to 30.56) | 3.17 | 102 | 34 | 0.94x | | code 1, block 10 | 3 | 30.16 (29.88 to 30.58) | 3.44 | 109 | 35 | 0.95x | **No configuration is a net win on this host, before or after #1795.** The best width is block 8 at 1.00x on code 0 (31.26 versus 31.18, its whole range inside the off arm's 30.41 to 31.98) and 0.94x on code 1; the default block 16 is 0.88x. The pre-rebase sweep on an idle host gave the same picture (block 8 at 0.97x, block 16 at 0.86x). There is no sub-8 cliff on this NVFP4 path (5, 6, 7 sit on a smooth curve) and no crossover past 8. The default stays at 16 (`DEFAULT_DFLASH_BLOCK_SIZE`); if a served width is ever retuned for Laguna, block 8 is the least-bad on GB10 and that change belongs to #1797, not this PR. **Why the ceiling is where it is.** The device time of a verify round is a fixed 77 ms plus about 3.1 ms per row (80 ms at 2 rows, 127 ms at 16) against a 31 to 32 ms classic token, 2.5x a single-token step even at 2 rows, plus about 30 to 35 ms of host-side drafter graph construction per round, one classic token's worth. The classic step is graph-replayed while the multi-row verify runs eagerly and launch-bound; that is a runtime property of the CUDA backend on this host, unchanged by #1795 for this pairing, and out of scope for a drafter port. Even perfect acceptance at block 8 would reach about 17 ms per token; at the measured 3.3 to 3.6 accepted, 0.88x to 1.00x. Thinking-channel prompts (1.1 to 1.8 accepted) are about 0.4x. Every greedy-id divergence from classic decode was checked with the block-versus-chain probe in `laguna_real_checkpoint_probe`: the chain arm's top-2 margin is 0.0 or 0.125 (one bf16 ulp) at each, so it is the `M >= 2` versus `M = 1` quantized-kernel property the exactness gate measures, not cache or mask logic; the tiny f32 model is token-exact with forced accept lengths 0, 1, 2 and full across the window wrap. The drafter's per-position accuracy along the reference path is 0.88, 1.00, 0.75, 0.62 for d_0 to d_3 on code and 0.88, 0.50, 0.25 inside `<think>`. Server path: same counters as offline (`rounds=60 proposed=835 accepted=67` on the issue's chat prompt). ## Acceptance criteria - Drafter directory loads through `load_drafter` and is refused as a standalone model: verified (detection test plus real runs). - Greedy speculative output token-identical to non-speculative: **not met on this host**, unchanged after #1795: the probe declines by default (107246 of 200704 bytes differ at block 16, 107466 at block 8); with the override, identical on 1 of 6 prompts and differing at one or two bf16 tie positions per 128 tokens on the other 5. Documented in `docs/supported-models.md` and the report. - Rollback trims full and sliding target caches and the drafter context caches: verified by `greedy_invariant_with_forced_rejections` (oracle drafter forcing accept lengths 0, 1, 2, full over a window of 6) and `greedy_invariant_with_real_drafter`. - Mean accepted length above 3 on the code prompt: 1.12 on the issue's chat prompt (the model answers inside `<think>`); 3.55 at block 16 and 3.33 at block 8 on the raw code body (n=3, post-rebase). - Higher decode throughput: **not met on this host** at any block size 2 to 16, before or after #1795 (best 1.00x at block 8, n=3, range inside the off arm's; 0.88x at the default block 16), for the launch-bound reason above; the feature is gated off here by the exactness probe regardless. - `docs/supported-models.md` and the detection test: done. - `cargo test --workspace --profile test-fast --features metal,accelerate`: not runnable on this Linux/CUDA host; unrun. Ran instead on the merged tree: core `drafter::laguna_dflash`, `drafter::dflash`, `drafter::tests` (123 passed); binary `models::laguna_dflash_tests`, `models::detection_tests`, `models::laguna_tests`, `server::batch::dflash_target`, `server::speculative_dispatch_tests` (27 passed, 1 ignored real-checkpoint probe). - `cargo clippy --profile test-fast --features cuda --lib --bins --tests -- -D warnings` (both crates) and `cargo fmt --all -- --check`: clean. ## Changes during review - Merged `origin/main` (nine PRs, including the `DFlashTargetModel` server design from #1751 and #1762 and the drafter-refusal wording from #1770); the branch's own burst trait was dropped in favor of main's and Laguna became a fourth drafter family with the shared measured exactness gate. Merged again at `0c21aed2` to pick up #1795; the Laguna loader now uses the shared `apply_drafter_load_dtype_policy`. - `from_weights` validates the `qkv_proj`, `o_proj`, `g_proj` and `fc` row counts against the config so a mismatched checkpoint is a load error rather than an MLX reshape throw inside the first draft, and the per-round `copy` of the captured hidden input was removed. - Implementation review: the offline arm now refuses a non-Laguna DFlash drafter by name (the pairing gate the server already ran) and refuses sampling requests (the round loop is greedy-only); the drafter prefers its one trained block width so a wider `--draft-block-size` cannot engage the adaptive width controller at an unprobed width; context rows older than the window are dropped before `aux_hidden_norms` and `fc` instead of after; `concatenate_many` replaces the pairwise fold; the rotating caches take the Muse Glimmer slack rule and warn instead of panicking; a short rollback trim logs an error in release builds; `load_drafter` routes on the same Laguna predicate detection uses; `attention_bias: true` is rejected and `intermediate_size` is checked at load; `MLXCEL_PRINT_TOKEN_IDS` keeps its single stderr contract with a prompt-ids line added; two tests pin context-offset continuity and the fused q/k/v row order. - Security and performance review: `greedy_only` is true so the server burst declines sampling requests to classic decode instead of silently returning greedy output; `dflash_config.block_size` (2..=512) and the sliding windows (2..=2^20) are bounded at config load because the drafter's `config.json` is untrusted and sizes allocations and `i32` mask arithmetic; the sanitizer checks split q/k/v shapes before fusing and refuses prefixed-plus-unprefixed duplicates; projection input widths and norm lengths are checked at load; the context K/V comes from a row-sliced K/V-only projection on plain checkpoints, the context cache exposes its buffer instead of returning a copy, and `combine_hidden` builds one `concatenate_many` node; the offline arm refuses a token bias. The regression check after each fix commit reproduced the same 128 ids and `accepted=98` on the code prompt. Closes #1351
--draft-model <dspark-dir>on anlfm2/lfm2_moetarget now runs the DFlash round loop with the greedy exact-match verify rule. A DSpark drafter is a 5-layer DFlash backbone fed five of the target's residual streams plus a rank-256 Markov token-transition head, so this adds the head, the DSpark config fields (markov_rank,rope_is_neox_style,runtime_block_size,enable_confidence_head, andnum_target_layerslifted out of the nesteddflash_config), the checkpoint's traditional RoPE pairing, the LFM2 verify forward with residual-stream capture, and the partial-accept rollback. Attention layers trim their KV cache; a short-conv state cannot be trimmed, so it is recomputed from the block's gated input at the committed prefix.Qwen35DFlashTargetgeneralizes intoDFlashTargetModel, carryingmake_dflash_caches, the first-draft prompt-row policy (Qwen keeps the last position, DSpark wants every prompt row) and the exactness gate, so one driver serves both families and #1343 can add a third with oneimpl.Two LFM2 loader gaps that the bf16 originals expose are fixed alongside, because the pairing cannot load without them:
rope_thetanested underrope_parameterswas read as the 1e6 default, and unstacked per-expertfeed_forward.experts.{e}.w1/w2/w3tensors were never renamed, so MoE expert stacking never fired.DSpark is greedy-only: a request with
temperature > 0andtop_k != 1is served by classic decode with one named warning, because the Markov head proposes a chain trained against greedy targets and there is no stochastic acceptance rule.Real-checkpoint validation
Apple M5 Max (GPU generation 17), port 19339, against the release binaries built from this tree. The machine served other work during the session, so each throughput figure is one sample; the acceptance counters are deterministic and reproduced byte for byte across repeat runs and across a rebuild.
The baseline is the same request against a server started without
--model-draft; identity is compared over the 256logprobs.content[].tokenentries, both arms finishing onlengthat exactly 256 tokens.The block-versus-chain probe declines on this host, for both pairs. That is the documented generation-15-and-newer condition and the shipped default: the burst falls back to classic decode, emits no
timings, and serves the baseline's tokens (verified on the MoE pair with the drafter configured and the gate honored, 256 of 256 identical). The runs below therefore usedMLXCEL_MTP_ALLOW_INEXACT=1, the existing research override.models/lfm2.5-2.6b-bf16+models/lfm2.5-2.6b-dspark-bf16): all 256 token ids identical to the baseline at the default verify width 8 and again at--draft-block-size 10. Mean accepted length 3.59 at width 8 (draft_n489,draft_n_accepted184,draft_rounds71) and 3.75 at width 10 (599 / 187 / 68). 203.3 and 212.1 tok/s against 72.3 tok/s classic (256 tokens in 3594 ms of request wall clock including a 66 ms prefill), so about 2.8x.models/lfm2.5-8b-a1b-bf16+models/lfm2.5-8b-a1b-dspark-bf16,MLXCEL_FUSED_MOE=0for the reference diff): mean accepted length 2.61 at width 8 (674 / 158 / 98) and 2.88 at width 10; 115.5 tok/s against 104.3 tok/s classic (2525 ms wall, 81 ms prefill), so about 1.11x, and the fused default measured the same classic figure (2532 ms). Token ids match for 153 of 256 and then diverge on a tie the two arms resolve differently: the baseline takes':'at logprob -1.0312 and the block path' formula'at -1.0000, and on the issue's shorter prompt both candidates read -1.4375. That is the byte-identity the probe declined, not a semantic difference; it reproduces at the same position across both widths and across runs, while the fused and unfused classic arms agree with each other."temperature": 0.7on the dense pair is served by classic decode, with notimingsand exactly one log line:DFlash speculative dispatch declined for seq seq-2: the drafter is greedy-only (DSpark) and the request samples with temperature 0.7 / top_k 50; serving it with classic decode.Re-validated after the review and security fixes, against release binaries rebuilt from the final tree (no source file newer than the binary). The dense pair is 128 of 128 token ids identical to the same server without
--model-draft, and the acceptance counters reproduce the pre-fix run digit for digit: 34 rounds, 234 proposed, 94 accepted, acceptance 0.402, 3.735 emitted per verify. That is the point of the re-run: the two allocation fixes below change what is kept, never what is computed, and identical counters are what says so. The temperature-0.7 decline reproduces with its one log line, and the mismatched pairing (--model-draft models/qwen3.5-4b-dflashon an LFM2 target, the case the new gate exists for) is refused by name at warmup and on the request, with the server still alive afterwards rather than aborted.Review fixes
The implementation review found no CRITICAL and no HIGH. One MEDIUM was a regression this branch introduced and is fixed here, along with two unbounded checkpoint-supplied numbers and two unchecked weight shapes found while fixing it, and three doc statements the review showed to be false.
An LFM2 target paired with a non-DSpark DFlash drafter had no gate.
Drafter::validate_target_compatcannot make that call: it reads the target as aLanguageModel, which carries no architecture string, so a plain DFlash drafter has no way to tell an LFM2 target from the Qwen 3.5 one it was published for, and returnsOk.DFlashDraftModel::forwarddoes not check itsfcinput width either, so the mismatch reached MLX as a shape throw, and an MLX C++ exception crossing the cxx bridge aborts the process rather than failing the request. Before this branch the pairing declined at the burst's variant gate, which no longer rejects LFM2. The target is the side that knows its own family, so the policy is nowDFlashTargetModel::requires_dspark_drafter(),trueforLfm2ModelandLfm2VlModeland the permissive default everywhere else, read by both run arms before any forward and answered with one operator-facing message naming--model-draftand the published pairings. Qwen 3.5 keeps the default; widening it there would change which DFlash pairings that family accepts, which is a separate call.Two checkpoint-supplied numbers reached a buffer index or a round size unbounded, and are now in the DSpark pairing gate beside the
fc-width andtarget_layer_idschecks.mask_token_idindexes the target's embedding table, because a DSpark drafter ships none of its own, and MLX range-checks no positive gather index, so an id past the last row read whatever followed the table in the buffer and fed it to the logits; the target half of the same gate already pins the target's vocabulary, so bounding the id againstvocab_sizebounds the gather. Aruntime_verify_width()below two rows proposes nothing and would emit one token per burst.verify_width()saturates rather than wrapping, sinceusize::MAXthere panics in a debug build and wraps to a zero-row width in a release one.The Markov head's two factors were never measured against the config that sizes them.
markov_w1is gathered at a token id once per chain step, so a table with fewer rows than the vocabulary the chain draws from read past its own buffer for the same reason; amarkov_w2of the wrong width threw inside MLX, which crosses the bridge as a process abort rather than a load error.VanillaMarkovHead::from_weightsnow takes the vocabulary as well as the rank and checks both factors before either becomes a layer, skipping the rank check for a quantized factor (which bit-packsrankalong its last axis only, so its stored width is a function of the bit depth) and not the row check.Doc corrections, no behavior change.
ShortConv::forward_with_captureclaimed the snapshot costs no copy while the code takes an explicit one, which it has to, sinceconv_stateis reassigned at the end of the call.configured_block_sizeimplied a DFlash round-loop block-size policy that does not exist: the only readers are the MTP generator and its batched round loop, and a DSpark run gets its width fromresolve_draft_block_sizebefore the drafter is loaded. Thedflash_targetmodule doc claimed a new family costs oneimplblock plus a match arm, when it is three arms inspeculative_burstand one of them still carries the LFM2 B = 1 decline as per-family policy outside the trait. TheDrafter::is_dsparkimpl also delegates to the inherent method by qualified name rather than repeating its body, so the two cannot drift.The review's second MEDIUM, a
supports_batched()trait method to move that last hardcoded arm into the trait, is left for #1343, which is the family that needs it. The LOW findings (the probe measures one width,ProbeKeyhas no family discriminator, the two block-size hooks are declarative, the benchmark numbers stayed in this body) are recorded in the technical report with the reasons they stay open.Security fixes
A second review pass looked specifically at what a downloaded checkpoint controls. Pointing
--model-draftat a repository makes every field of itsconfig.jsonand every weight shape in it a runtime input; MLX range-checks no positive gather index, and an MLX C++ exception crossing the cxx bridge aborts the process rather than failing the request. One HIGH, four MEDIUM and three LOW; six fixed, two left with the reason recorded in the technical report.HIGH: the verify width was bounded below and not above, and this branch is what made it a control input. Before it the DFlash runtime block size came only from
--draft-block-sizeor the flat constant 16, both operator-supplied.resolve_draft_block_sizenow peeks the drafter checkpoint and handsruntime_verify_width()to the scheduler as the server-wide block size, and a config settingblock_sizeandruntime_block_sizetogether escapes the eight-row default entirely. What that reaches first is the block-versus-chain exactness probe, which runs one single-token target forward per row, three draws over, on the scheduler thread, memoizes its verdict, and runs before the pairing gate that would otherwise refuse the config: at an absurd width that is a hang, not a slow request.runtime_verify_width()now clamps atDSPARK_MAX_VERIFY_WIDTH(32, against 8 to 10 on the published checkpoints), which is the bound that holds ahead of the probe because every caller goes through it; the gate separately refuses a config asking for more, so the operator is told rather than quietly served at 32.MEDIUM: the
mask_token_idbound was vacuous for a self-contained drafter. Its soundness argument was that the target half of the pairing gate pins the target's vocabulary, which holds only on the lazy-bind path every published DSpark checkpoint uses. A checkpoint shipping its ownembed_tokenstakes the other arm ofbindand its table was never compared to anything.LmHead::Ownhad the same gap one step later, against the Markov head'svocab_size-shaped transition logits. Both are now measured at load for a DSpark config; a plain DFlash checkpoint is deliberately untouched.MEDIUM: the Markov rank check was skipped, not narrowed, for a quantized factor, and every mlx-community conversion of these drafters is quantized, so the skipped case is the common one. The packed width is
rank * bits / 32, so it is checkable: the check now accepts a width some supported bit depth explains, rather than trusting a declaredbitsa per-tensor override can contradict.MEDIUM: two prompt-sized allocations that nothing read.
first_hidden_fordeep-copied the whole prompt hidden under the every-row policy, becausemlxcel_core::copyis a real MLXCopyprimitive and the signature took a borrow (168 MB at an 8k prompt on LFM2.5-2.6B, 671 MB at 32k); it now takes the array by value, which also ends the caller's retention of the original across the round loop. Separately, the short-conv rollback snapshots were captured on the prompt prefill, which is never rolled back, pinning roughly one prompt-sized buffer per conv layer alive through the eval that materialises the prefill (about 670 MB at 8k, about 2.7 GB at 32k on a 30-layer 2.6B checkpoint).SpeculativeTargetgainsprefill_forward_with_capture_layers, defaulting to the verify hook so no other family changes, and LFM2 overrides it to skip the capture. The flag changes what is kept and never what is computed, which the new test pins on the logits, the captured hidden and the cache offsets at once.LOW: an empty prompt or an empty window reached a negative slice start (a process abort) and an index panic respectively. Both were inherited unchanged from the Qwen arms and neither is reachable from the scheduler today; both are now request errors.
Test plan
cargo test --profile test-fast --features metal,accelerate:-p mlxcel-core drafter::dflash74 passed, and one--librun overmodels::lfm2,server::batch::speculative_burst,server::batch::dflash_target,cli::speculative_argsandmodels::detection184 passed. Seven of those are new here: the verify-width ceiling including theusize::MAXsaturating case, the self-contained DSpark vocabulary tables, the packed Markov rank check, the conv-capture flag, the mask-id and low-side width bounds, and the LFM2 non-DSpark pairing decline.cargo checkandcargo clippy -- -D warningsclean for the root package AND for-p mlxcel-coreseparately, pluscargo fmt --all -- --check. Checking--lib --testsat the workspace root resolves to-p mlxceland never compilesmlxcel-core's test target, which is how two test-only compile errors reached a suite run; both packages are checked from here on.--bin mlxcel offline_draft_model4 passed.Closes #1339