Skip to content

feat(speculative): LFM2 / LFM2.5 DSpark drafter on the DFlash loop - #1751

Merged
inureyes merged 6 commits into
mainfrom
feature/issue-1339-lfm2-dspark-drafter
Sep 10, 2026
Merged

inureyes merged 6 commits into
mainfrom
feature/issue-1339-lfm2-dspark-drafter

Conversation

@inureyes

@inureyes inureyes commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

--draft-model <dspark-dir> on an lfm2 / lfm2_moe target 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, and num_target_layers lifted out of the nested dflash_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. Qwen35DFlashTarget generalizes into DFlashTargetModel, carrying make_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 one impl.

Two LFM2 loader gaps that the bf16 originals expose 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.

DSpark is greedy-only: a request with temperature > 0 and top_k != 1 is 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.

./target/release/mlxcel-server -m models/lfm2.5-2.6b-bf16 --model-draft models/lfm2.5-2.6b-dspark-bf16 --port 19339
curl -s localhost:19339/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"m","messages":[{"role":"user","content":"Write a detailed technical explanation of how speculative decoding works in large language model inference, covering the drafter, the verification step, and the acceptance rule."}],"max_tokens":256,"temperature":0,"logprobs":true}'

The baseline is the same request against a server started without --model-draft; identity is compared over the 256 logprobs.content[].token entries, both arms finishing on length at 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 used MLXCEL_MTP_ALLOW_INEXACT=1, the existing research override.

  • Dense pair (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_n 489, draft_n_accepted 184, draft_rounds 71) 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.
  • MoE pair (models/lfm2.5-8b-a1b-bf16 + models/lfm2.5-8b-a1b-dspark-bf16, MLXCEL_FUSED_MOE=0 for 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. "temperature": 0.7 on the dense pair is served by classic decode, with no timings and 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-dflash on 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_compat cannot make that call: it reads the target as a LanguageModel, 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 returns Ok. DFlashDraftModel::forward does not check its fc input 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 now DFlashTargetModel::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 one operator-facing message naming --model-draft and 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 and target_layer_ids checks. mask_token_id indexes 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 against vocab_size bounds the gather. A runtime_verify_width() below two rows proposes nothing and would emit one token per burst. verify_width() saturates rather than 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 were never measured against the config that sizes them. 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 for the same reason; a markov_w2 of the wrong width threw inside MLX, which crosses the bridge as a process abort rather than a load error. VanillaMarkovHead::from_weights now 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-packs rank along 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_capture claimed the snapshot costs no copy while the code takes an explicit one, which 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: 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. 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. The Drafter::is_dspark impl 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, ProbeKey has 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-draft at a repository makes every field of its config.json and 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-size or the flat constant 16, both operator-supplied. resolve_draft_block_size now peeks the drafter checkpoint and hands runtime_verify_width() to the scheduler as the server-wide block size, and a config setting block_size and runtime_block_size together 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 at DSPARK_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_id bound 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 own embed_tokens takes the other arm of bind and its table was never compared to anything. LmHead::Own had the same gap one step later, against the Markov head's vocab_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 declared bits a per-tensor override can contradict.

MEDIUM: two prompt-sized allocations that nothing read. 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 (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). SpeculativeTarget gains prefill_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::dflash 74 passed, and one --lib run over models::lfm2, server::batch::speculative_burst, server::batch::dflash_target, cli::speculative_args and models::detection 184 passed. Seven of those are new here: the verify-width ceiling including the usize::MAX saturating 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 check and cargo clippy -- -D warnings clean for the root package AND for -p mlxcel-core separately, plus cargo fmt --all -- --check. Checking --lib --tests at the workspace root resolves to -p mlxcel and never compiles mlxcel-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_model 4 passed.

Closes #1339

@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:low Low priority area:models Model architectures, weights, loading, metadata area:docs User and developer documentation area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:inference Generation, sampling, decoding (incl. speculative, DRY) area:cli Command-line interface / CLI flags modeltype:text Text-only language model arch:moe Sparse mixture-of-experts decoder arch:hybrid Hybrid attention stack (linear/sliding/full or attention+SSM interleave) status:review Under review modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests labels Sep 10, 2026
inureyes added a commit that referenced this pull request Sep 10, 2026
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

--draft-model <dspark-dir> on an lfm2 / lfm2_moe target runs the existing DFlash round loop with the greedy exact-match verify rule at temperature 0 only, emitting output token-identical to classic decode.

Verified against the issue, line by line

  • block_size 9 is read as gamma and verify_width() as block_size + 1; runtime_verify_width() caps at the eight-row default and --draft-block-size 10 restores the trained width. resolve_draft_block_size peeks the drafter config so the flat DFlash 16 never reaches a 9-proposal head, and returns None for a plain DFlash checkpoint so the Qwen default is untouched.
  • The DSpark draft input is [anchor] ++ mask * (gamma - 1) at gamma rows and every one of the gamma logit positions is consumed, position 0 included. dspark_draft_block_uses_all_positions asserts that directly rather than by shape.
  • The Markov chain is seeded with the anchor and feeds each step's argmax into the next step's gather, built as one lazy graph with a single host sync per round. markov_chain_feeds_previous_token_into_next_step is the test that a chain ignoring its own output would fail.
  • rope_is_neox_style: false reaches all three fast_rope call sites as traditional = true, and the test asserts the attention output changes rather than just the struct field.
  • Context K/V only in the drafter cache with proposal K/V concatenated after, mask = None, append-only and never trimmed: unchanged pre-existing DFlashAttention / DFlashKVCache behavior, correctly left alone.
  • prefer_requested_block_size() == true and the target's tied embed_tokens borrowed as both input embedding and LM head at bind.

Short-conv rollback

n = accepted + 1, attention KVCache::trim(block_size - n), short-conv recomputed as the last L_cache - 1 rows of concat(prev_state_or_zeros, bx_block[:, :n]). Two things I checked specifically: prev_state is mlx::core::copy (a real copy node, confirmed at mlx_cxx_bridge.cpp:1078) taken after bx is computed and before *conv_state is assigned, so it cannot be the post-block state; and the zeros fallback is [bx.shape[0], L_cache - 1, bx.shape[2]] in bx's dtype, matching conv_padding()'s causal (l_cache - 1, 0). conv_rollback_matches_committed_prefix compares offset, state and the next decode step's logits against a genuine one-pass reference, which is the right shape of test for this.

DFlashTargetModel readiness for #1343

Ready. Every added hook defaults to the Qwen 3.5 behavior (first_hidden_rows to LastPromptPosition, enable_speculative_buffers to a no-op, exactness_allows to true), concat_captured_hidden and first_hidden_for(LastPromptPosition, ..) are equivalent to the code they replaced rather than merely similar, and no inherent method shadows a new trait method (Qwen35Model has mtp_exactness_allows, not exactness_allows, so the Qwen DFlash path does not begin gating on a probe it never gated on). enable_speculative_buffers is present and called by both drivers before the prefill, which is what the Muse Glimmer rotating cache needs; rotating-cache rollback is already per-family through SpeculativeTarget::rollback_partial.

Findings, all left open

No CRITICAL and no HIGH. Nothing was changed on the branch except the technical report.

  • MEDIUM. A mismatched non-DSpark pairing on an LFM2 target has no gate: validate_target_compat returns Ok immediately when the drafter is not DSpark and DFlashDraftModel::forward does not check its fc input width, so an LFM2 target with a genuine Qwen 3.5 DFlash drafter reaches an MLX shape throw through a non-Result cxx shim. Pre-existing in kind (a 4B Qwen target with a 27B DFlash drafter fails identically), but LFM2 targets used to decline at the variant gate. Narrow fix: decline in the burst gate when the target is LFM2 and the drafter is not DSpark. Broad fix: an fc-width check for every DFlash drafter, which changes which Qwen pairings are accepted and so is a maintainer call.
  • MEDIUM. A third family touches four match arms, not the "one impl block plus a match arm" the module doc claims: the exactness gate, the drive! dispatch, the batched gate (which hardcodes the LFM2 B = 1 decline), and model_variant_label. A supports_batched() trait method would move the third into the trait; worth doing when feat(speculative): Muse Glimmer DFlash assistant drafter (muse_glimmer_assistant, 5 sliding-attention layers, bidirectional sliding mask) on the existing DFlash round loop #1343 lands.
  • LOW. The probe measures one width. dflash_exactness_allows(block_size) probes at the configured verify width, but the round loop narrows bs near the token budget, and this project's benchmark guidance records that block-versus-chain disagreement varies strongly with width. Same shape as the MTP arms, so not a regression.
  • LOW. ProbeKey is {block_size, hidden_size, num_hidden_layers} with no family discriminator and a docstring that assumes one target model per process. Router mode does not. Pre-existing, widened by a third family.
  • LOW. configured_block_size() and prefer_requested_block_size() are implemented as the issue asked but only the MTP generator reads them; the DFlash round loop uses its constructed block_size directly. The no-backoff requirement holds vacuously, and the comment at drafter.rs:459 implies a DFlash block-size policy that does not exist.
  • LOW. ShortConv::forward_with_capture's doc says "the state is replaced, never mutated in place, so holding the previous handle costs no copy" while the code takes a copy. The copy is correct and cheap; the comment contradicts it.
  • LOW. The issue's Validation section asked for tok/s at both widths in docs/benchmark_results/; they are in the PR body instead. Given the host served other work and each throughput figure is one sample, keeping single-sample numbers out of the benchmark corpus is the right call, but the deviation is worth stating.

Verification

  • All stated requirements implemented
  • No placeholder/mock code remaining (no todo!, unimplemented!, FIXME or stub in the diff)
  • Integrated into project code flow (burst gate, both generic drivers, CLI block-size resolution, offline rejection, detection)
  • Project conventions followed (no shared hotspot in layers.rs / utils.rs / switch_layers.rs / gated_delta.rs touched, no local references/... path, docs/supported-models.md and docs/speculative-acceptance.md updated)
  • Existing modules reused (UnifiedEmbedding / UnifiedLinear for the Markov head, mtp_exactness_gate for the probe, materialize_argmax_i32_vec, create_causal_mask, slice_axis, concatenate_many)
  • No unintended structural changes (the Qwen35DFlashTarget generalization is behavior-preserving)
  • Tests pass

Reproduced locally at review time: cargo check --lib --tests, cargo clippy --lib --tests -- -D warnings and cargo fmt --all -- --check clean; -p mlxcel-core drafter::dflash 69 passed, --lib models::lfm2 29, --lib server::batch::speculative_burst 67, --lib server::batch::dflash_target 2, --lib cli::speculative_args 22, --lib models::detection 60 (the body says 58). Every count matches the recorded test plan.

A bilingual technical report is committed on the branch at TECHNICAL_REPORTS/1751-lfm2-dspark-drafter-20260910.{en,ko}.md.

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.
@inureyes
inureyes force-pushed the feature/issue-1339-lfm2-dspark-drafter branch from 2ac255c to e1585c6 Compare September 10, 2026 11:23
…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.
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 10, 2026
@inureyes
inureyes merged commit 7fae0b5 into main Sep 10, 2026
13 checks passed
@inureyes
inureyes deleted the feature/issue-1339-lfm2-dspark-drafter branch September 10, 2026 12:14
inureyes added a commit that referenced this pull request Sep 10, 2026
…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
inureyes added a commit that referenced this pull request Sep 10, 2026
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
inureyes added a commit that referenced this pull request Sep 10, 2026
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
inureyes added a commit that referenced this pull request Sep 10, 2026
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.
inureyes added a commit that referenced this pull request Sep 10, 2026
… 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
inureyes added a commit that referenced this pull request Sep 10, 2026
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.
inureyes added a commit that referenced this pull request Sep 10, 2026
## 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
inureyes added a commit that referenced this pull request Sep 10, 2026
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
inureyes added a commit that referenced this pull request Sep 11, 2026
## 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch:hybrid Hybrid attention stack (linear/sliding/full or attention+SSM interleave) arch:moe Sparse mixture-of-experts decoder area:cli Command-line interface / CLI flags area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests modeltype:text Text-only language model priority:low Low priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

1 participant