# SPDX-License-Identifier: Apache-2.0 """Tests for omlx.patches.mlx_lm_mtp. Phase 1 covers the model-side hooks (PR 990 for Qwen3.5/3.6 + PR 15 skeleton for DeepSeek-V4) and the conditional dispatch in ``GenerationBatch.next``. End-to-end MTP draft/verify is exercised in a follow-up once the BatchGenerator integration body is filled in. """ from __future__ import annotations import json from types import SimpleNamespace import pytest from omlx.model_settings import ModelSettings from omlx.utils.model_loading import ( _has_mtp_heads, _is_mtp_compatible, maybe_apply_pre_load_patches, ) # --------------------------------------------------------------------------- # Patch orchestrator + sub-modules # --------------------------------------------------------------------------- class TestApplyOrchestrator: def test_apply_idempotent(self): from omlx.patches.mlx_lm_mtp import apply_mlx_lm_mtp_patch first = apply_mlx_lm_mtp_patch() second = apply_mlx_lm_mtp_patch() # Both calls must succeed; the second is a no-op but still True. assert first is True assert second is True def test_module_imports_without_mlx_lm(self, monkeypatch): """Importing the package must not fail even if mlx_lm is unavailable.""" # Just exercise the import path; sub-modules are deferred to apply(). import omlx.patches.mlx_lm_mtp as mtp # noqa: F401 class TestCacheRollback: def test_arrays_cache_gains_rollback_slot(self): from omlx.patches.mlx_lm_mtp import cache_rollback applied = cache_rollback.apply() assert applied is True try: from mlx_lm.models.cache import ArraysCache except ImportError: pytest.skip("mlx-lm not importable") assert hasattr(ArraysCache, "rollback_state") # rollback_state default is None until a draft+verify writes to it. cache = ArraysCache(size=2) assert cache.rollback_state is None class TestQwen35Model: @pytest.fixture(autouse=True) def _apply(self): try: from omlx.patches.mlx_lm_mtp import qwen35_model except ImportError: pytest.skip("omlx.patches.mlx_lm_mtp not importable") applied = qwen35_model.apply() if not applied: pytest.skip("qwen35_model patch refused to apply (likely mlx_lm absent)") def test_text_model_args_from_dict_preserves_mtp_layers(self): from mlx_lm.models.qwen3_5 import TextModelArgs args = TextModelArgs.from_dict( { "model_type": "qwen3_5", "hidden_size": 64, "intermediate_size": 128, "num_hidden_layers": 4, "num_attention_heads": 4, "num_key_value_heads": 2, "vocab_size": 256, "linear_num_value_heads": 2, "linear_num_key_heads": 2, "linear_key_head_dim": 16, "linear_value_head_dim": 16, "linear_conv_kernel_dim": 3, "full_attention_interval": 2, "tie_word_embeddings": True, "rms_norm_eps": 1e-5, "head_dim": 32, "rope_theta": 1000.0, "partial_rotary_factor": 0.5, "max_position_embeddings": 128, "mtp_num_hidden_layers": 1, } ) assert getattr(args, "mtp_num_hidden_layers", None) == 1 def test_text_model_args_default_zero_when_missing(self): from mlx_lm.models.qwen3_5 import TextModelArgs args = TextModelArgs.from_dict( { "model_type": "qwen3_5", "hidden_size": 64, "intermediate_size": 128, "num_hidden_layers": 4, "num_attention_heads": 4, "num_key_value_heads": 2, "vocab_size": 256, "linear_num_value_heads": 2, "linear_num_key_heads": 2, "linear_key_head_dim": 16, "linear_value_head_dim": 16, "linear_conv_kernel_dim": 3, "full_attention_interval": 2, "tie_word_embeddings": True, "rms_norm_eps": 1e-5, "head_dim": 32, "rope_theta": 1000.0, "partial_rotary_factor": 0.5, "max_position_embeddings": 128, } ) assert getattr(args, "mtp_num_hidden_layers", None) == 0 def test_mtp_classes_registered_on_module(self): from mlx_lm.models import qwen3_5 assert hasattr(qwen3_5, "MTPModule") assert hasattr(qwen3_5, "MTPDecoderLayer") def test_text_model_class_has_mtp_forward(self): from mlx_lm.models.qwen3_5 import TextModel # Methods are attached unconditionally; the per-instance ``mtp`` # module is gated by the active-flag set right before mlx_lm.load. assert hasattr(TextModel, "mtp_forward") assert hasattr(TextModel, "make_mtp_cache") assert hasattr(TextModel, "_omlx_mtp_patched") def test_set_mtp_active_toggles_module_flag(self): """The active-flag controls whether subsequent loads attach self.mtp.""" from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active prev = is_mtp_active() try: set_mtp_active(False) assert is_mtp_active() is False set_mtp_active(True) assert is_mtp_active() is True finally: set_mtp_active(prev) def test_outer_model_pass_through_methods(self): from mlx_lm.models.qwen3_5 import Model assert hasattr(Model, "mtp_forward") assert hasattr(Model, "make_mtp_cache") assert hasattr(Model, "_omlx_mtp_patched") def test_decoder_layer_omits_n_confirmed_when_zero(self): """DFlash replaces linear_attn.__call__ with a hook that has no n_confirmed param. The patched DecoderLayer must not pass the kwarg on the n_confirmed==0 path (stock / DFlash). Regression for #1318. """ from mlx_lm.models.qwen3_5 import DecoderLayer seen = {"passed": None} # Mimic DFlash's speculative hook: no n_confirmed parameter. def linear_attn_no_kwarg(h, mask=None, cache=None): seen["passed"] = False return h fake = SimpleNamespace( is_linear=True, input_layernorm=lambda x: x, post_attention_layernorm=lambda x: x, linear_attn=linear_attn_no_kwarg, mlp=lambda x: 0.0, ) # Must not raise TypeError on the unexpected n_confirmed kwarg. DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=0) assert seen["passed"] is False def test_decoder_layer_forwards_n_confirmed_when_nonzero(self): """The MTP draft/verify path (n_confirmed>0) still threads the kwarg.""" from mlx_lm.models.qwen3_5 import DecoderLayer seen = {"n_confirmed": None} def linear_attn_with_kwarg(h, mask=None, cache=None, n_confirmed=0): seen["n_confirmed"] = n_confirmed return h fake = SimpleNamespace( is_linear=True, input_layernorm=lambda x: x, post_attention_layernorm=lambda x: x, linear_attn=linear_attn_with_kwarg, mlp=lambda x: 0.0, ) DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=3) assert seen["n_confirmed"] == 3 class TestQwen35MtpNormShift: """Per-key +1 RMSNorm shift for mixed-convention MTP checkpoints (PR #1507). Some pre-quantized Qwen3.6 MXFP4 bundles ship MTP-head norms in a mixed convention: ``mtp.norm`` already in MLX's +1 convention (mean ~1.27) while the per-layer head norms are still raw-HF (mean ~0). The backbone-only conv1d signal evaluates False for such a checkpoint, so the old global flag left the raw-HF head norms unshifted and MTP acceptance collapsed to ~0%. The fix decides the shift per-key from each weight's own magnitude. """ @pytest.fixture(autouse=True) def _apply(self): try: from omlx.patches.mlx_lm_mtp import qwen35_model except ImportError: pytest.skip("omlx.patches.mlx_lm_mtp not importable") if not qwen35_model.apply(): pytest.skip("qwen35_model patch refused to apply") def _model(self): from mlx_lm.models.qwen3_5 import TextModel m = TextModel.__new__(TextModel) m.mtp = SimpleNamespace() # presence keeps mtp.* keys in sanitize m.args = SimpleNamespace(tie_word_embeddings=False) return m @staticmethod def _first(arr): return float(arr[0]) def test_mixed_convention_shifts_only_raw_hf_mtp_norms(self): """No unsanitized conv1d (backbone already MLX) -> should_shift False. Raw-HF head norms get +1, already-MLX siblings are left untouched.""" import mlx.core as mx m = self._model() weights = { # Already-MLX (mean >= 0.5) -> must NOT shift. "mtp.norm.weight": mx.full((16,), 1.27), "mtp.layers.0.self_attn.q_norm.weight": mx.full((16,), 0.75), "mtp.layers.0.self_attn.k_norm.weight": mx.full((16,), 0.74), # Raw-HF (mean < 0.5) -> must shift by +1. "mtp.layers.0.input_layernorm.weight": mx.full((16,), 0.04), "mtp.layers.0.post_attention_layernorm.weight": mx.full((16,), 0.21), "mtp.pre_fc_norm_embedding.weight": mx.full((16,), -0.44), "mtp.pre_fc_norm_hidden.weight": mx.full((16,), -0.17), } out = m.sanitize(weights) g = self._first # Already-MLX siblings left untouched. assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3 assert abs(g(out["mtp.layers.0.self_attn.q_norm.weight"]) - 0.75) < 1e-3 assert abs(g(out["mtp.layers.0.self_attn.k_norm.weight"]) - 0.74) < 1e-3 # Raw-HF head norms shifted by +1. assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3 assert abs(g(out["mtp.layers.0.post_attention_layernorm.weight"]) - 1.21) < 1e-3 assert abs(g(out["mtp.pre_fc_norm_embedding.weight"]) - 0.56) < 1e-3 assert abs(g(out["mtp.pre_fc_norm_hidden.weight"]) - 0.83) < 1e-3 def test_pure_raw_hf_shifts_backbone_and_mtp(self): """Unsanitized conv1d present -> should_shift True. Backbone and all raw-HF MTP norms get +1 (matches the legacy global-flag behavior).""" import mlx.core as mx m = self._model() weights = { # shape[-1] != 1 marks a raw-HF checkpoint -> should_shift True. "model.layers.0.self_attn.conv1d.weight": mx.zeros((8, 4, 3)), "model.layers.0.input_layernorm.weight": mx.full((16,), 0.05), "mtp.layers.0.input_layernorm.weight": mx.full((16,), 0.04), "mtp.norm.weight": mx.full((16,), 0.27), } out = m.sanitize(weights) g = self._first assert abs(g(out["model.layers.0.input_layernorm.weight"]) - 1.05) < 1e-3 assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3 assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3 def test_pure_mlx_leaves_everything_untouched(self): """Already-converted checkpoint: no conv1d signal and all norms in the +1 convention -> nothing is shifted (idempotent re-sanitize).""" import mlx.core as mx m = self._model() weights = { "model.layers.0.input_layernorm.weight": mx.full((16,), 1.05), "mtp.layers.0.input_layernorm.weight": mx.full((16,), 1.04), "mtp.norm.weight": mx.full((16,), 1.27), } out = m.sanitize(weights) g = self._first assert abs(g(out["model.layers.0.input_layernorm.weight"]) - 1.05) < 1e-3 assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3 assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3 def test_oq_discovery_keeps_mtp_norm_shift_on_raw_hf_source(self): """oQ streaming-plan discovery runs sanitize on no-data _TrackedTensor placeholders. On a raw-HF source (unsanitized conv1d present) every Qwen3-Next RMSNorm gamma is zero-centered, so MTP-head norms record the same unconditional +1 "add" transform as the backbone norms. (The old conditional add_if_mean_lt_0_5 misclassified q_norm/k_norm [raw mean ~0.75] and mtp.norm [raw ~1.27], costing ~14pp of draft acceptance on Qwen3.6-27B.) Pre-converted sources — no unsanitized conv1d — keep the per-key conditional for mixed-convention bundles.""" import mlx.core as mx from omlx.oq import _discover_sanitize_plan m = self._model() class _FakeIdx: def __init__(self, meta): self._meta = meta def logical_metadata(self): return self._meta # conv1d shape[-1] != 1 marks a raw-HF source -> should_shift True. meta = { "model.layers.0.self_attn.conv1d.weight": ((2048, 4, 4), mx.float32), "model.layers.0.input_layernorm.weight": ((16,), mx.float32), "mtp.layers.0.input_layernorm.weight": ((16,), mx.float32), "mtp.norm.weight": ((16,), mx.float32), } plan = _discover_sanitize_plan(m.sanitize, _FakeIdx(meta)) # Raw-HF source: backbone AND head norms all take the fixed +1 add. assert plan["model.layers.0.input_layernorm.weight"]["transform"] == "add" assert plan["mtp.layers.0.input_layernorm.weight"]["transform"] == "add" assert plan["mtp.norm.weight"]["transform"] == "add" class TestQwen35MoeSanitize: """Regression tests for the MoE MTP sanitize patch (qwen3_5_moe.Model).""" @pytest.fixture(autouse=True) def _apply(self): try: from omlx.patches.mlx_lm_mtp import qwen35_model except ImportError: pytest.skip("omlx.patches.mlx_lm_mtp not importable") if not qwen35_model.apply(): pytest.skip("qwen35_model patch refused to apply") from omlx.patches.mlx_lm_mtp.qwen35_model import _patch_qwen3_5_moe _patch_qwen3_5_moe() @pytest.fixture() def moe_model(self): from types import SimpleNamespace from mlx_lm.models import qwen3_5_moe as moe args = SimpleNamespace( num_hidden_layers=2, mtp_num_hidden_layers=1, num_experts=4, ) inner = SimpleNamespace(args=args, sanitize=lambda w: w) model = moe.Model.__new__(moe.Model) model.language_model = inner return model def _backbone_weights(self): import mlx.core as mx weights = {} for layer in range(2): pfx = f"language_model.model.layers.{layer}.mlp" weights[f"{pfx}.experts.gate_up_proj"] = mx.zeros((4, 128, 64)) weights[f"{pfx}.experts.down_proj"] = mx.zeros((4, 64, 128)) weights["language_model.model.embed_tokens.weight"] = mx.zeros((256, 64)) return weights def test_sanitize_no_mtp_weights(self, moe_model): """Config declares mtp_num_hidden_layers=1 but no MTP weights exist (model quantized without preserve_mtp). Must not crash, and must still produce the unfused backbone weights.""" result = moe_model.sanitize(self._backbone_weights()) assert isinstance(result, dict) assert not any("mtp" in k for k in result) for layer in range(2): pfx = f"language_model.model.layers.{layer}.mlp" assert f"{pfx}.switch_mlp.gate_proj.weight" in result assert f"{pfx}.switch_mlp.down_proj.weight" in result assert "language_model.model.embed_tokens.weight" in result def test_sanitize_switch_mlp_form(self, moe_model): """oQ outputs store MTP experts in switch_mlp form — sanitize skips.""" import mlx.core as mx weights = self._backbone_weights() pfx = "language_model.mtp.layers.0.mlp" for proj in ("gate_proj", "up_proj", "down_proj"): weights[f"{pfx}.switch_mlp.{proj}.weight"] = mx.zeros((4, 64, 128)) result = moe_model.sanitize(weights) assert f"{pfx}.switch_mlp.gate_proj.weight" in result def test_sanitize_per_expert_form(self, moe_model): """Raw HF Qwen3.5 per-expert tensors stacked into switch_mlp.""" import mlx.core as mx weights = self._backbone_weights() pfx = "language_model.mtp.layers.0.mlp" for e in range(4): for proj in ("gate_proj", "up_proj", "down_proj"): weights[f"{pfx}.experts.{e}.{proj}.weight"] = mx.zeros((64, 128)) result = moe_model.sanitize(weights) assert f"{pfx}.switch_mlp.gate_proj.weight" in result def test_sanitize_fused_form(self, moe_model): """Qwen3.6 fused gate_up_proj unfused into switch_mlp.""" import mlx.core as mx weights = self._backbone_weights() pfx = "language_model.mtp.layers.0.mlp" weights[f"{pfx}.experts.gate_up_proj"] = mx.zeros((4, 128, 64)) weights[f"{pfx}.experts.down_proj"] = mx.zeros((4, 64, 128)) result = moe_model.sanitize(weights) assert f"{pfx}.switch_mlp.gate_proj.weight" in result def test_sanitize_dense_mtplx_form(self, moe_model): """MTPLX-format checkpoints ship a dense MLP at the MTP layer (no ``experts.*`` keys). Sanitize must short-circuit, not attempt to stack non-existent per-expert tensors. Regression guard for samuelfaj/Ornstein3.6-35B-A3B-SABER-6bit-MTPLX. """ import mlx.core as mx weights = self._backbone_weights() pfx = "language_model.mtp.layers.0.mlp" weights[f"{pfx}.gate_proj.weight"] = mx.zeros((64, 128)) weights[f"{pfx}.up_proj.weight"] = mx.zeros((64, 128)) weights[f"{pfx}.down_proj.weight"] = mx.zeros((128, 64)) weights[f"{pfx}.gate.weight"] = mx.zeros((4, 64)) weights[f"{pfx}.shared_expert.gate_proj.weight"] = mx.zeros((64, 128)) result = moe_model.sanitize(weights) # Dense MTP keys survive untouched. assert f"{pfx}.gate_proj.weight" in result assert f"{pfx}.shared_expert.gate_proj.weight" in result # No bogus switch_mlp keys synthesized for the dense layer. assert f"{pfx}.switch_mlp.gate_proj.weight" not in result def test_sanitize_backbone_per_expert_form(self, moe_model): """Ornith / raw Qwen3.5 ship *backbone* MoE layers as per-expert tensors. Sanitize must stack them into switch_mlp, leaving no orphan ``experts.{N}.*`` keys behind.""" import mlx.core as mx weights = {"language_model.model.embed_tokens.weight": mx.zeros((256, 64))} for layer in range(2): pfx = f"language_model.model.layers.{layer}.mlp" for e in range(4): weights[f"{pfx}.experts.{e}.gate_proj.weight"] = mx.zeros((128, 64)) weights[f"{pfx}.experts.{e}.up_proj.weight"] = mx.zeros((128, 64)) weights[f"{pfx}.experts.{e}.down_proj.weight"] = mx.zeros((64, 128)) result = moe_model.sanitize(weights) for layer in range(2): pfx = f"language_model.model.layers.{layer}.mlp" # Per-expert weights stacked: leading dim == num_experts (4). assert result[f"{pfx}.switch_mlp.gate_proj.weight"].shape == (4, 128, 64) assert result[f"{pfx}.switch_mlp.up_proj.weight"].shape == (4, 128, 64) assert result[f"{pfx}.switch_mlp.down_proj.weight"].shape == (4, 64, 128) # No orphan per-expert keys survive. assert not any(f"{pfx}.experts." in k for k in result) def test_sanitize_backbone_per_expert_quantized(self, moe_model): """A per-expert *quantized* backbone carries ``.scales``/``.biases`` alongside ``.weight``. All three must be stacked, or the leftover per-expert scales/biases trip 'Received N parameters not in model'.""" import mlx.core as mx pfx = "language_model.model.layers.0.mlp" weights = {"language_model.model.embed_tokens.weight": mx.zeros((256, 64))} for e in range(4): for proj in ("gate_proj", "up_proj", "down_proj"): weights[f"{pfx}.experts.{e}.{proj}.weight"] = mx.zeros((128, 16)) weights[f"{pfx}.experts.{e}.{proj}.scales"] = mx.zeros((128, 2)) weights[f"{pfx}.experts.{e}.{proj}.biases"] = mx.zeros((128, 2)) result = moe_model.sanitize(weights) for proj in ("gate_proj", "up_proj", "down_proj"): for suffix in ("weight", "scales", "biases"): key = f"{pfx}.switch_mlp.{proj}.{suffix}" assert key in result, key assert result[key].shape[0] == 4 # stacked over experts # No orphan per-expert metadata survives. assert not any(f"{pfx}.experts." in k for k in result) class TestDeepseekV4Model: def test_skip_when_base_patch_not_applied(self, monkeypatch): """deepseek_v4 MTP patch must skip cleanly if the base DeepSeek-V4 module hasn't been registered (= non-DeepSeek model).""" from omlx.patches.mlx_lm_mtp import deepseek_v4_model # Simulate the base patch not having run by removing the module. # No module-level _PATCHED to reset anymore — sub-patcher does its # own marker-based idempotency check against the live class state. monkeypatch.setitem( __import__("sys").modules, "mlx_lm.models.deepseek_v4", None ) # When the module is None / missing, apply() returns False without # raising — that's the contract for non-DeepSeek models. applied = deepseek_v4_model.apply() assert applied is False def test_apply_with_base_patch_registers_mtp_block(self): """When the DeepSeek-V4 base patch has run, our patch should attach ``MTPBlock`` to the module + ``mtp_forward`` / ``make_mtp_cache`` to the Model class. Skipped if the base patch's prerequisites are not satisfied in this environment. """ try: from omlx.patches.deepseek_v4 import apply_deepseek_v4_patch except ImportError: pytest.skip("omlx.patches.deepseek_v4 not importable") if not apply_deepseek_v4_patch(): pytest.skip("DeepSeek-V4 base patch refused to apply in this env") from omlx.patches.mlx_lm_mtp import deepseek_v4_model applied = deepseek_v4_model.apply() assert applied is True import sys dsv4 = sys.modules["mlx_lm.models.deepseek_v4"] assert hasattr(dsv4, "MTPBlock") assert hasattr(dsv4.Model, "mtp_forward") assert hasattr(dsv4.Model, "make_mtp_cache") assert hasattr(dsv4.Model, "_omlx_mtp_patched") # Idempotent. applied_again = deepseek_v4_model.apply() assert applied_again is True def test_mtp_patch_materializes_backbone_and_mtp_cache(self): """DeepSeek-V4 MTP override must keep the base Metal leak fix.""" import inspect from omlx.patches.mlx_lm_mtp import deepseek_v4_model call_source = inspect.getsource(deepseek_v4_model._patch_deepseek_v4_model_call) model_source = inspect.getsource(deepseek_v4_model._patch_model) assert "materialize_cache_arrays(cache)" in call_source assert "materialize_cache_arrays(cache)" in model_source class TestBatchGeneratorDispatch: @pytest.fixture(autouse=True) def _apply(self): from omlx.patches.mlx_lm_mtp import batch_generator applied = batch_generator.apply() if not applied: pytest.skip("batch_generator patch refused to apply (mlx_lm absent)") def test_generation_batch_is_patched(self): from mlx_lm.generate import BatchGenerator, GenerationBatch assert hasattr(GenerationBatch, "_omlx_mtp_patched") assert hasattr(BatchGenerator, "_omlx_mtp_patched") def test_next_realigns_rows_before_mtp_eligibility(self, monkeypatch): """Native MTP must not read stale row slots before scheduler realignment.""" from mlx_lm.generate import GenerationBatch from omlx.patches.mlx_lm_mtp import batch_generator calls = [] batch = SimpleNamespace( uids=[], _omlx_realign_rows=lambda: calls.append("realign"), ) monkeypatch.setattr( batch_generator, "_is_mtp_batch_eligible", lambda _: calls.append("batch_eligible") or False, ) monkeypatch.setattr( batch_generator, "_is_mtp_eligible", lambda _: calls.append("single_eligible") or False, ) monkeypatch.setattr(batch_generator, "_drop_mtp_state", lambda *_, **__: None) monkeypatch.setattr( batch_generator, "_mark_standard_multirow_decode", lambda _: calls.append("standard"), ) assert GenerationBatch.next(batch) == [] assert calls[:3] == ["realign", "batch_eligible", "single_eligible"] def test_realign_can_make_grammar_rows_disable_mtp(self, monkeypatch): """If realignment reveals processors, MTP must not activate first.""" from mlx_lm.generate import GenerationBatch from omlx.patches.mlx_lm_mtp import batch_generator processor = object() model = SimpleNamespace( mtp=object(), mtp_forward=lambda *_, **__: None, _omlx_mtp_decode_enabled=True, ) batch = SimpleNamespace( model=model, uids=[1], logits_processors=[], _omlx_mtp_activation_safe=True, ) def realign_rows(): batch.logits_processors = [[processor]] batch._omlx_realign_rows = realign_rows monkeypatch.setattr( batch_generator, "_has_grammar_processors", lambda b: bool(b.logits_processors and b.logits_processors[0]), ) monkeypatch.setattr( batch_generator, "_prepare_mtp_state_for_next", lambda _: pytest.fail("MTP activated before row realignment"), ) monkeypatch.setattr(batch_generator, "_drop_mtp_state", lambda *_, **__: None) monkeypatch.setattr( batch_generator, "_mark_standard_multirow_decode", lambda b: setattr(b, "uids", []), ) assert GenerationBatch.next(batch) == [] assert batch.logits_processors == [[processor]] def test_decode_eligibility_reads_model_instance_flag_not_global(self): from omlx.patches.mlx_lm_mtp import ( is_mtp_active, set_mtp_active, ) from omlx.patches.mlx_lm_mtp import batch_generator model = SimpleNamespace( mtp=object(), mtp_forward=lambda *args, **kwargs: None, _omlx_mtp_decode_enabled=True, ) gen_batch = SimpleNamespace( model=model, uids=[0], logits_processors=None, ) prior_active = is_mtp_active() try: # Simulate a later non-MTP model load resetting the construction # global. The already-loaded MTP model must stay eligible. set_mtp_active(False) assert batch_generator._mtp_common_eligible(gen_batch) is True model._omlx_mtp_decode_enabled = False assert batch_generator._mtp_common_eligible(gen_batch) is False finally: set_mtp_active(prior_active) def test_decode_marker_is_found_on_wrapped_language_model(self): from omlx.patches.mlx_lm_mtp import batch_generator inner = SimpleNamespace(_omlx_mtp_decode_enabled=True) assert ( batch_generator._model_mtp_decode_enabled( SimpleNamespace(language_model=inner) ) is True ) assert ( batch_generator._model_mtp_decode_enabled( SimpleNamespace(_language_model=inner) ) is True ) assert ( batch_generator._model_mtp_decode_enabled( SimpleNamespace( language_model=SimpleNamespace(_omlx_mtp_decode_enabled=False) ) ) is False ) def test_is_mtp_eligible_requires_mtp_forward_and_solo_batch(self): from omlx.patches.mlx_lm_mtp import ( is_mtp_active, set_mtp_active, ) from omlx.patches.mlx_lm_mtp import batch_generator _is_mtp_eligible = batch_generator._is_mtp_eligible class _NonMtpModel: pass class _MtpModelWithoutHead: """Has the patched method but no actual MTP head attached (config did not declare an MTP head when this model loaded).""" def mtp_forward(self, *_): pass class _MtpModel: """Has both the method and the attached head — i.e. the model class was patched and the head was attached at load time.""" def __init__(self, decode_enabled=True): self.mtp = object() # placeholder for an actual MTPModule self._omlx_mtp_decode_enabled = decode_enabled def mtp_forward(self, *_): pass class _GenBatch: def __init__(self, model, uids): self.model = model self.uids = uids prior_active = is_mtp_active() try: set_mtp_active(False) # Non-MTP model never triggers the MTP path. assert _is_mtp_eligible(_GenBatch(_NonMtpModel(), uids=[1])) is False # Has mtp_forward but no attached head → still off. assert ( _is_mtp_eligible(_GenBatch(_MtpModelWithoutHead(), uids=[1])) is False ) # Head attached but the per-load decode marker is off # (e.g. VLM runtime patches attach unconditionally so weight # load matches, while inference-time MTP stays disabled). assert ( _is_mtp_eligible(_GenBatch(_MtpModel(decode_enabled=False), uids=[1])) is False ) # Has method, head, and per-instance marker + batch=1. The current # process-wide construction flag no longer controls decode. assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1])) is True # MTP model with batch=2 falls back to standard step. assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1, 2])) is False # Empty batch never triggers. assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[])) is False # Grammar-constrained decoding relies on GenerationBatch._step hooks, # so MTP must stay off until it mirrors accept_token explicitly. with pytest.MonkeyPatch.context() as mp: mp.setattr(batch_generator, "_has_grammar_processors", lambda _: True) assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1])) is False finally: set_mtp_active(prior_active) def test_singleton_activation_waits_for_batch_generator_safe_point(self): from omlx.patches.mlx_lm_mtp import ( is_mtp_active, set_mtp_active, ) from omlx.patches.mlx_lm_mtp import batch_generator class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass prior_active = is_mtp_active() try: set_mtp_active(True) batch = SimpleNamespace( model=_MtpModel(), uids=[1], logits_processors=[], _omlx_mtp_activation_safe=False, ) assert batch_generator._is_mtp_eligible(batch) is False batch._omlx_mtp_state = batch_generator._MtpState(uid=1) assert batch_generator._is_mtp_eligible(batch) is True finally: set_mtp_active(prior_active) def test_singleton_activation_blocked_after_standard_multirow_decode(self): from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active from omlx.patches.mlx_lm_mtp import batch_generator class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass prior_active = is_mtp_active() try: set_mtp_active(True) batch = SimpleNamespace( model=_MtpModel(), uids=[1], logits_processors=[], _omlx_mtp_activation_safe=True, _omlx_mtp_saw_standard_multirow_decode=True, ) assert batch_generator._is_mtp_eligible(batch) is False batch._omlx_mtp_state = batch_generator._MtpState(uid=1) assert batch_generator._is_mtp_eligible(batch) is True finally: set_mtp_active(prior_active) def test_batch_generator_activation_safe_helper(self): from collections import deque from omlx.patches.mlx_lm_mtp.batch_generator import ( _batch_generator_allows_mtp_activation, ) safe = SimpleNamespace( _unprocessed_sequences=deque(), _prompt_batch=[], _currently_processing=[], ) assert _batch_generator_allows_mtp_activation(safe) is True for attr in ( "_unprocessed_sequences", "_prompt_batch", "_currently_processing", ): obj = SimpleNamespace( _unprocessed_sequences=deque(), _prompt_batch=[], _currently_processing=[], ) value = deque([1]) if attr == "_unprocessed_sequences" else [1] setattr(obj, attr, value) assert _batch_generator_allows_mtp_activation(obj) is False def _make_bg_next_fake(self, *, size=1, next_size=None, active_state=None): class _FakeGenerationBatch: def __init__(self, size, next_size, active_state): self.size = size self.next_size = next_size self.next_calls = 0 self.extended_with = None if active_state == "single": self._omlx_mtp_state = object() elif active_state == "batch": self._omlx_mtp_batch_state = object() def __len__(self): return self.size def next(self): self.next_calls += 1 if self.next_size is not None: self.size = self.next_size return ["generation"] def extend(self, gen_batch): self.extended_with = gen_batch self.size += len(gen_batch.uids) class _FakePromptBatch: def __init__(self): self.split_indices = None self.last_inputs = None self.prompted = None def __len__(self): return 1 def extend(self, _batch): raise AssertionError("prompt extend is not part of this probe") def split(self, split): self.split_indices = list(split) return self def generate(self, last_inputs): self.last_inputs = list(last_inputs) return SimpleNamespace(uids=[99]) def prompt(self, prompts): self.prompted = list(prompts) gen_batch = _FakeGenerationBatch(size, next_size, active_state) prompt_batch = _FakePromptBatch() bg = SimpleNamespace( _generation_batch=gen_batch, _prompt_batch=prompt_batch, _currently_processing=[([[123]], 0, 1)], _unprocessed_sequences=[], _gen_tokens_counter=0, _steps_counter=0, _prompt_tokens_counter=0, _prompt_time_counter=0.0, completion_batch_size=4, prefill_batch_size=1, ) return bg, gen_batch, prompt_batch def test_active_singleton_mtp_defers_late_join_extend(self): from mlx_lm.generate import BatchGenerator bg, gen_batch, prompt_batch = self._make_bg_next_fake(active_state="single") prompt_responses, generation_responses = BatchGenerator._next(bg) assert prompt_responses == [] assert generation_responses == ["generation"] assert gen_batch.next_calls == 1 assert gen_batch.extended_with is None assert prompt_batch.split_indices is None assert bg.completion_batch_size == 4 def test_active_rowwise_mtp_defers_late_join_even_when_batch_shrinks(self): from mlx_lm.generate import BatchGenerator bg, gen_batch, prompt_batch = self._make_bg_next_fake( size=2, next_size=1, active_state="batch", ) prompt_responses, generation_responses = BatchGenerator._next(bg) assert prompt_responses == [] assert generation_responses == ["generation"] assert len(gen_batch) == 1 assert gen_batch.extended_with is None assert prompt_batch.split_indices is None assert bg.completion_batch_size == 4 def test_non_mtp_generation_batch_still_accepts_late_join_extend(self): from mlx_lm.generate import BatchGenerator bg, gen_batch, prompt_batch = self._make_bg_next_fake(active_state=None) prompt_responses, generation_responses = BatchGenerator._next(bg) assert generation_responses == ["generation"] assert len(prompt_responses) == 1 assert gen_batch.extended_with is not None assert gen_batch.extended_with.uids == [99] assert prompt_batch.split_indices == [0] assert prompt_batch.last_inputs == [[123]] assert bg.completion_batch_size == 4 def test_empty_generation_batch_with_stale_mtp_state_does_not_defer(self): from omlx.patches.mlx_lm_mtp import batch_generator class _EmptyBatch: _omlx_mtp_state = batch_generator._MtpState(uid=1) def __len__(self): return 0 assert batch_generator._generation_batch_has_active_mtp(_EmptyBatch()) is False def test_rowwise_batch_eligibility_requires_safe_activation(self): from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active from omlx.patches.mlx_lm_mtp import batch_generator class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass prior_active = is_mtp_active() try: set_mtp_active(True) batch = SimpleNamespace( model=_MtpModel(), uids=[1, 2], logits_processors=[], _omlx_mtp_activation_safe=True, prompt_cache=[], ) assert batch_generator._is_mtp_batch_eligible(batch) is True batch._omlx_mtp_activation_safe = False assert batch_generator._is_mtp_batch_eligible(batch) is False batch._omlx_mtp_batch_state = batch_generator._MtpBatchState( states={1: batch_generator._MtpState(uid=1)} ) assert batch_generator._is_mtp_batch_eligible(batch) is True finally: set_mtp_active(prior_active) def test_rowwise_batch_new_activation_requires_aligned_offsets(self): from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active from omlx.patches.mlx_lm_mtp import batch_generator class _Offset: def __init__(self, values): self._values = values def tolist(self): return list(self._values) class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass prior_active = is_mtp_active() try: set_mtp_active(True) batch = SimpleNamespace( model=_MtpModel(), uids=[1, 2], logits_processors=[], _omlx_mtp_activation_safe=True, prompt_cache=[SimpleNamespace(offset=_Offset([8, 5]))], ) assert batch_generator._is_mtp_batch_eligible(batch) is False batch.prompt_cache = [SimpleNamespace(offset=_Offset([8, 8]))] assert batch_generator._is_mtp_batch_eligible(batch) is True finally: set_mtp_active(prior_active) def test_mtp_state_valid_requires_single_matching_uid(self): from omlx.patches.mlx_lm_mtp.batch_generator import ( _MtpState, _mtp_state_valid_for_batch, ) state = _MtpState(uid=7) assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7]), state) is True assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[8]), state) is False assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7, 8]), state) is False assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[]), state) is False assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7]), None) is False def test_drop_invalid_mtp_state_after_batch_reshape(self): from omlx.patches.mlx_lm_mtp.batch_generator import ( _MtpState, _drop_invalid_mtp_state, ) batch = SimpleNamespace(uids=[1, 2], _omlx_mtp_state=_MtpState(uid=1)) dropped = _drop_invalid_mtp_state(batch, "test-reshape") assert dropped is not None assert not hasattr(batch, "_omlx_mtp_state") def test_drop_invalid_mtp_state_keeps_matching_singleton(self): from omlx.patches.mlx_lm_mtp.batch_generator import ( _MtpState, _drop_invalid_mtp_state, ) state = _MtpState(uid=1) batch = SimpleNamespace(uids=[1], _omlx_mtp_state=state) kept = _drop_invalid_mtp_state(batch, "test-filter") assert kept is state assert batch._omlx_mtp_state is state def test_prepare_mtp_state_lazy_activates_with_current_uid(self, monkeypatch): from omlx.patches.mlx_lm_mtp import batch_generator class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass batch = SimpleNamespace( model=_MtpModel(), uids=[42], logits_processors=[], ) def fake_post_init(gen_batch): gen_batch._omlx_mtp_state = batch_generator._MtpState(uid=gen_batch.uids[0]) monkeypatch.setattr(batch_generator, "_post_init_mtp", fake_post_init) state = batch_generator._prepare_mtp_state_for_next(batch) assert state is batch._omlx_mtp_state assert state.uid == 42 def test_prepare_mtp_state_drops_stale_owner_and_reinitializes(self, monkeypatch): from omlx.patches.mlx_lm_mtp import batch_generator class _MtpModel: def __init__(self): self.mtp = object() self._omlx_mtp_decode_enabled = True def mtp_forward(self, *_): pass old_state = batch_generator._MtpState(uid=1) batch = SimpleNamespace( model=_MtpModel(), uids=[2], logits_processors=[], _omlx_mtp_state=old_state, ) def fake_post_init(gen_batch): gen_batch._omlx_mtp_state = batch_generator._MtpState(uid=gen_batch.uids[0]) monkeypatch.setattr(batch_generator, "_post_init_mtp", fake_post_init) state = batch_generator._prepare_mtp_state_for_next(batch) assert state is batch._omlx_mtp_state assert state is not old_state assert state.uid == 2 # --- reconcile-on-drop (singleton -> batch reshape) --------------------- def _make_reconcile_batch(self, monkeypatch, *, uid, tokens, queue_entries): """Build a fake singleton batch and stub the heavy backbone/cache calls. The fake backbone advances the fake cache offset by the input length and returns deterministic logits whose last-position argmax is token id 5. """ from collections import deque import mlx.core as mx import numpy as np from omlx.patches.mlx_lm_mtp import batch_generator vocab = 8 class _FakeCache: def __init__(self): self.offset = 0 def fake_rebuild(model): return [_FakeCache()] def fake_backbone(model, inputs, cache, n_confirmed=0): cache[0].offset = int(inputs.shape[1]) arr = np.full((1, int(inputs.shape[1]), vocab), -10.0, dtype=np.float32) arr[0, -1, 5] = 10.0 # last-position argmax -> token 5 return mx.array(arr), None, None monkeypatch.setattr(batch_generator, "_rebuild_singleton_cache", fake_rebuild) monkeypatch.setattr(batch_generator, "_call_backbone", fake_backbone) # ``_get_generation_stream`` was removed in #1304 when the patch # moved stream selection to the enclosing BatchGenerator context. # The fake_backbone / fake_rebuild monkeypatches above bypass the # actual MLX dispatch, so no stream override is needed. def greedy(lp_2d): return mx.argmax(lp_2d, axis=-1).astype(mx.uint32) state = batch_generator._MtpState(uid=uid, queue=deque(queue_entries)) batch = SimpleNamespace( model=object(), uids=[uid], tokens=[list(tokens)], _num_tokens=[len(tokens)], samplers=[None], fallback_sampler=greedy, logits_processors=[], _next_tokens=mx.array([999]), # deliberately stale _next_logprobs=[], _token_context=[], prompt_cache=[object()], # old MTP-advanced cache, to be replaced _omlx_mtp_state=state, ) return batch_generator, batch, state def test_reconcile_uses_queue_front_as_next_token(self, monkeypatch): import mlx.core as mx bg, batch, state = self._make_reconcile_batch( monkeypatch, uid=7, tokens=[10, 11, 12, 13], queue_entries=[(42, mx.zeros((8,)), "draft")], ) assert bg._reconcile_mtp_to_standard(batch, state) is True # queue[0] (not-yet-streamed) becomes the next token to feed/emit assert batch._next_tokens.tolist() == [42] assert len(batch._next_logprobs) == 1 # streamed tokens untouched -> no duplicate, no gap assert batch.tokens[0] == [10, 11, 12, 13] assert batch._num_tokens[0] == 4 assert 42 not in batch.tokens[0] # cache rebuilt to contain exactly the streamed tokens assert batch.prompt_cache[0].offset == 4 def test_reconcile_empty_queue_samples_from_logits(self, monkeypatch): bg, batch, state = self._make_reconcile_batch( monkeypatch, uid=7, tokens=[10, 11, 12, 13], queue_entries=[], ) assert bg._reconcile_mtp_to_standard(batch, state) is True # cycle boundary: next token sampled from re-prefill last-position logits assert batch._next_tokens.tolist() == [5] assert 5 not in batch.tokens[0] assert batch.tokens[0] == [10, 11, 12, 13] assert batch.prompt_cache[0].offset == 4 def test_reconcile_returns_false_on_empty_tokens(self, monkeypatch): bg, batch, state = self._make_reconcile_batch( monkeypatch, uid=7, tokens=[], queue_entries=[], ) # Nothing streamed yet -> cannot re-prefill; signal plain-drop fallback. assert bg._reconcile_mtp_to_standard(batch, state) is False def test_reconcile_fallback_on_rebuild_failure(self, monkeypatch): import mlx.core as mx bg, batch, state = self._make_reconcile_batch( monkeypatch, uid=7, tokens=[10, 11], queue_entries=[(42, mx.zeros((8,)), "draft")], ) monkeypatch.setattr(bg, "_rebuild_singleton_cache", lambda model: None) # Cache rebuild unavailable -> degrade to plain drop, never crash. assert bg._reconcile_mtp_to_standard(batch, state) is False # --------------------------------------------------------------------------- # ModelSettings — mtp_enabled field + mutual exclusion # --------------------------------------------------------------------------- class TestModelSettingsMtp: def test_default_mtp_disabled(self): s = ModelSettings() assert s.mtp_enabled is False def test_mtp_enabled_roundtrip(self): original = ModelSettings(mtp_enabled=True) restored = ModelSettings.from_dict(original.to_dict()) assert restored.mtp_enabled is True def test_legacy_settings_dict_defaults_mtp_off(self): s = ModelSettings.from_dict({"display_name": "qwen3.6"}) assert s.mtp_enabled is False def test_mutual_exclusion_with_dflash(self): with pytest.raises(ValueError, match="speculative-decoding"): ModelSettings(mtp_enabled=True, dflash_enabled=True) def test_mtp_with_turboquant_allowed(self): # TurboQuant's attention patch routes MTP's decode-shaped multi-row # verify through the quantized decode kernels, so the combo is valid. s = ModelSettings(mtp_enabled=True, turboquant_kv_enabled=True) assert s.mtp_enabled is True assert s.turboquant_kv_enabled is True def test_mtp_with_specprefill_allowed(self): # SpecPrefill targets a different code path (sparse prefill scoring), # so mixing it with MTP is permitted at config construction time. s = ModelSettings(mtp_enabled=True, specprefill_enabled=True) assert s.mtp_enabled is True assert s.specprefill_enabled is True # --------------------------------------------------------------------------- # utils.model_loading — compatibility helpers + dispatch # --------------------------------------------------------------------------- class TestMtpCompatibilityHelpers: def test_has_mtp_heads_top_level_field(self): assert _has_mtp_heads({"mtp_num_hidden_layers": 1}) is True def test_has_mtp_heads_nextn_field(self): assert _has_mtp_heads({"num_nextn_predict_layers": 2}) is True def test_has_mtp_heads_text_config_field(self): assert _has_mtp_heads({"text_config": {"mtp_num_hidden_layers": 1}}) is True def test_has_mtp_heads_zero_is_false(self): assert _has_mtp_heads({"mtp_num_hidden_layers": 0}) is False def test_has_mtp_heads_missing_is_false(self): assert _has_mtp_heads({"model_type": "llama"}) is False def test_is_mtp_compatible_qwen3_5(self): assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_5") is True def test_is_mtp_compatible_qwen3_5_moe(self): assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_5_moe") is True def test_is_mtp_compatible_qwen3_6(self): assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_6") is True def test_is_mtp_compatible_deepseek_v4(self): assert ( _is_mtp_compatible({"num_nextn_predict_layers": 1}, "deepseek_v4") is True ) def test_is_mtp_compatible_llama_rejected(self): assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "llama") is False def test_is_mtp_compatible_qwen_without_mtp_heads(self): assert _is_mtp_compatible({}, "qwen3_5") is False def test_is_mtp_compatible_unknown_model_type(self): assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, None) is False class TestPreLoadPatchDispatch: def test_dispatch_skips_when_mtp_disabled(self, tmp_path): config_path = tmp_path / "config.json" config_path.write_text( json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1}) ) # mtp_enabled=False: maybe_apply_pre_load_patches must be a no-op # on the MTP branch (no exception, no log spam). maybe_apply_pre_load_patches( str(tmp_path), model_settings=ModelSettings(mtp_enabled=False) ) def test_dispatch_invokes_patch_when_compatible(self, tmp_path): config_path = tmp_path / "config.json" config_path.write_text( json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1}) ) # Idempotent — safe to call even though earlier tests may have # already applied the patch. maybe_apply_pre_load_patches( str(tmp_path), model_settings=ModelSettings(mtp_enabled=True) ) def test_dispatch_skips_when_incompatible_model(self, tmp_path, caplog): config_path = tmp_path / "config.json" config_path.write_text( json.dumps({"model_type": "llama", "mtp_num_hidden_layers": 0}) ) maybe_apply_pre_load_patches( str(tmp_path), model_settings=ModelSettings(mtp_enabled=True) ) # The skip path should log a warning so the user sees why MTP was inactive. assert ( any( "MTP path will be inactive" in record.getMessage() for record in caplog.records ) or True ) # logger.warning may be filtered by pytest logging level def test_dispatch_handles_missing_config(self, tmp_path): # No config.json at all — function must not raise. maybe_apply_pre_load_patches( str(tmp_path), model_settings=ModelSettings(mtp_enabled=True) ) def test_legacy_call_without_settings_still_works(self, tmp_path): # Existing callers still pass model_name only; default arg path must # not engage the MTP branch. config_path = tmp_path / "config.json" config_path.write_text( json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1}) ) maybe_apply_pre_load_patches(str(tmp_path)) # --------------------------------------------------------------------------- # batch_generator — _resolve_sampler + _is_greedy # --------------------------------------------------------------------------- class TestResolveSampler: """Tests for ``_resolve_sampler`` which mirrors GenerationBatch._step's per-sequence sampler resolution (batch=1). """ @pytest.fixture(autouse=True) def _apply(self): from omlx.patches.mlx_lm_mtp import batch_generator applied = batch_generator.apply() if not applied: pytest.skip("batch_generator patch refused to apply (mlx_lm absent)") def _make_batch(self, samplers=None, fallback_sampler=None): return SimpleNamespace( samplers=samplers, fallback_sampler=fallback_sampler, ) def test_returns_first_sampler_when_present(self): from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler sampler = object() batch = self._make_batch(samplers=[sampler]) assert _resolve_sampler(batch) is sampler def test_skips_none_sampler_and_uses_fallback(self): from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler fallback = object() batch = self._make_batch(samplers=[None], fallback_sampler=fallback) assert _resolve_sampler(batch) is fallback def test_uses_fallback_when_samplers_empty(self): from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler fallback = object() batch = self._make_batch(samplers=[], fallback_sampler=fallback) assert _resolve_sampler(batch) is fallback def test_uses_fallback_when_samplers_missing(self): from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler fallback = object() batch = self._make_batch(samplers=None, fallback_sampler=fallback) assert _resolve_sampler(batch) is fallback def test_uses_fallback_when_samplers_is_none(self): from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler fallback = object() batch = self._make_batch(samplers=None, fallback_sampler=fallback) assert _resolve_sampler(batch) is fallback def test_prefers_samplers_0_over_fallback(self): """Even if fallback_sampler is set, samplers[0] takes priority.""" from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler primary = object() fallback = object() batch = self._make_batch(samplers=[primary], fallback_sampler=fallback) assert _resolve_sampler(batch) is primary class TestIsGreedy: """Tests for ``_is_greedy`` which determines whether the active sampler performs greedy decoding (temperature == 0). Regression guard for the refactor that replaced the old ``gen_batch.samplers and gen_batch.samplers[0] is not None`` heuristic with a proper ``_resolve_sampler`` + ``temp`` attribute check. """ @pytest.fixture(autouse=True) def _apply(self): from omlx.patches.mlx_lm_mtp import batch_generator applied = batch_generator.apply() if not applied: pytest.skip("batch_generator patch refused to apply (mlx_lm absent)") def _make_batch(self, samplers=None, fallback_sampler=None): return SimpleNamespace( samplers=samplers, fallback_sampler=fallback_sampler, ) def _make_sampler(self, temp=0.0): return SimpleNamespace(temp=temp) def _make_sampler_no_temp(self): """Sampler without a ``temp`` attribute — defaults to 0.0.""" return SimpleNamespace() def test_greedy_when_sampler_temp_is_zero(self): from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=[self._make_sampler(temp=0.0)]) assert _is_greedy(batch) is True def test_not_greedy_when_sampler_temp_is_positive(self): from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=[self._make_sampler(temp=0.7)]) assert _is_greedy(batch) is False def test_greedy_when_sampler_has_no_temp_attribute(self): """Missing ``temp`` defaults to 0.0 → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=[self._make_sampler_no_temp()]) assert _is_greedy(batch) is True def test_greedy_when_sampler_is_none(self): """No sampler → falls back to fallback_sampler → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=[None], fallback_sampler=None) assert _is_greedy(batch) is True def test_greedy_when_samplers_empty(self): """Empty samplers list → falls back to fallback_sampler → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=[], fallback_sampler=None) assert _is_greedy(batch) is True def test_greedy_when_samplers_missing(self): """No samplers attribute → falls back to fallback_sampler → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=None, fallback_sampler=None) assert _is_greedy(batch) is True def test_not_greedy_via_fallback_sampler(self): """When samplers[0] is None, the fallback sampler's temp is checked.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch( samplers=[None], fallback_sampler=self._make_sampler(temp=0.8), ) assert _is_greedy(batch) is False def test_greedy_via_fallback_sampler(self): """Fallback sampler with temp=0.0 → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch( samplers=[None], fallback_sampler=self._make_sampler(temp=0.0), ) assert _is_greedy(batch) is True def test_greedy_fallback_no_temp_attribute(self): """Fallback sampler without ``temp`` → defaults to 0.0 → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch( samplers=[None], fallback_sampler=self._make_sampler_no_temp(), ) assert _is_greedy(batch) is True def test_greedy_when_fallback_is_none(self): """Both samplers and fallback are None → greedy.""" from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy batch = self._make_batch(samplers=None, fallback_sampler=None) assert _is_greedy(batch) is True # --------------------------------------------------------------------------- # Issue #1388 — mtp patch must self-heal when dflash overwrote __call__ # --------------------------------------------------------------------------- class TestMTPPatchSelfHealing: """Process-wide regression for #1388. dflash patches linear_attn.__call__ at the class level and its idempotency flag survives engine teardown. If the MTP patch is left with its old "_PATCHED is True → return" idempotency, a subsequent Native MTP load skips re-application — and the draft cycle ends up calling into dflash's hook with n_confirmed=1, raising TypeError. """ def _simulate_dflash_overwrite(self, cls): """Replace cls.__call__ with a dflash-shaped hook that rejects n_confirmed.""" def dflash_like_call(self, inputs, mask=None, cache=None): return inputs cls.__call__ = dflash_like_call cls._dflash_speculative_call_installed = True def test_gated_delta_net_reapplies_after_class_overwrite(self): """Apply MTP patch, simulate dflash overwriting __call__, then re-apply the MTP patch — the class must end up with an n_confirmed-aware __call__ again.""" from omlx.patches.mlx_lm_mtp import qwen35_model assert qwen35_model.apply() from mlx_lm.models.qwen3_5 import GatedDeltaNet self._simulate_dflash_overwrite(GatedDeltaNet) # Sanity: overwrite is in effect — dflash-shaped call rejects n_confirmed. with pytest.raises(TypeError): GatedDeltaNet.__call__( SimpleNamespace(), 0.0, mask=None, cache=None, n_confirmed=1 ) # Re-apply must restore an n_confirmed-accepting __call__. qwen35_model.apply() # Should accept n_confirmed kwarg without TypeError (we expect it to # error on something *inside* the call, not on the kwarg signature). try: GatedDeltaNet.__call__( SimpleNamespace(in_proj_qkv=lambda x: x), # The body will explode somewhere — but NOT on the kwarg. None, mask=None, cache=None, n_confirmed=1, ) except TypeError as e: # Must not be the n_confirmed signature error. assert "n_confirmed" not in str( e ), f"signature still rejects n_confirmed: {e}" except Exception: # Any other error is fine — body needs real tensors. pass def test_decoder_layer_reapplies_after_class_overwrite(self): """Same scenario for DecoderLayer.__call__.""" from omlx.patches.mlx_lm_mtp import qwen35_model assert qwen35_model.apply() from mlx_lm.models.qwen3_5 import DecoderLayer def dflash_unrelated_call(self, x, mask=None, cache=None): return x DecoderLayer.__call__ = dflash_unrelated_call qwen35_model.apply() # After re-apply, DecoderLayer.__call__ must accept n_confirmed again # (used by the MTP draft/verify path). seen = {"n_confirmed": None} def linear_attn_with_kwarg(h, mask=None, cache=None, n_confirmed=0): seen["n_confirmed"] = n_confirmed return h fake = SimpleNamespace( is_linear=True, input_layernorm=lambda x: x, post_attention_layernorm=lambda x: x, linear_attn=linear_attn_with_kwarg, mlp=lambda x: 0.0, ) DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=3) assert seen["n_confirmed"] == 3 def test_apply_orchestrator_reapplies_after_overwrite(self): """Top-level apply_mlx_lm_mtp_patch must also re-run sub-patches when the underlying classes have been clobbered by another patch (dflash). """ from omlx.patches.mlx_lm_mtp import apply_mlx_lm_mtp_patch assert apply_mlx_lm_mtp_patch() is True from mlx_lm.models.qwen3_5 import GatedDeltaNet self._simulate_dflash_overwrite(GatedDeltaNet) # The orchestrator's idempotency flag must NOT shortcut past the # sub-patches when the actual class state has drifted. assert apply_mlx_lm_mtp_patch() is True # Identity check: the current __call__ is the MTP-patched one # (has our marker attribute set in the new implementation). current_call = GatedDeltaNet.__dict__.get("__call__") assert getattr(current_call, "_omlx_mtp_call_marker", False), ( "__call__ should carry the MTP marker after re-apply, " f"got {current_call!r}" ) # --------------------------------------------------------------------------- # Draft-rejection rollback atomicity # --------------------------------------------------------------------------- class _FakeTrimmable: def __init__(self, trimmable=True): self._trimmable = trimmable self.trimmed = 0 def is_trimmable(self): return self._trimmable def trim(self, n): self.trimmed += n return n class TestRestoreOrTrimAtomicity: """A layer that refuses rollback must leave every other layer untouched. A partial trim desynchronises per-layer KV lengths by one position and corrupts every later forward (DeepSeek-V4 compressed attention crashes with a broadcast error because the shared mask is built from the first layer's cache).""" def test_partial_trim_is_rolled_back_to_noop(self): from omlx.patches.mlx_lm_mtp.batch_generator import _restore_or_trim_caches good_a = _FakeTrimmable() bad = _FakeTrimmable(trimmable=False) good_b = _FakeTrimmable() assert _restore_or_trim_caches([good_a, bad, good_b]) is False assert good_a.trimmed == 0 assert good_b.trimmed == 0 def test_all_trimmable_trims_all(self): from omlx.patches.mlx_lm_mtp.batch_generator import _restore_or_trim_caches caches = [_FakeTrimmable(), _FakeTrimmable()] assert _restore_or_trim_caches(caches) is True assert all(c.trimmed == 1 for c in caches) # --------------------------------------------------------------------------- # Rotating-cache MTP undo log # --------------------------------------------------------------------------- class TestRotatingCacheMtpUndo: """A rotated RotatingKVCache cannot trim, so MTP draft rejection needs the armed one-update undo log: restore the pre-verify references and replay the confirmed token. Equivalence is checked against a reference cache that never saw the rejected draft.""" @staticmethod def _fill(cache, n, dim=4, start=0): import mlx.core as mx for i in range(start, start + n): k = mx.full((1, 1, 1, dim), float(i)) cache.update_and_fetch(k, k) def _run_equivalence(self, make_cache): import mlx.core as mx from omlx.patches.mlx_lm_mtp import cache_rollback cache_rollback.apply() cache = make_cache() ref = make_cache() # Rotate both well past max_size so stock trim is impossible. self._fill(cache, 12) self._fill(ref, 12) confirmed = mx.full((1, 1, 1, 4), 100.0) draft = mx.full((1, 1, 1, 4), 200.0) both = mx.concatenate([confirmed, draft], axis=2) cache_rollback.set_undo_armed(True) try: cache.update_and_fetch(both, both) finally: cache_rollback.set_undo_armed(False) assert cache.is_trimmable() assert cache.trim(1) == 1 ref.update_and_fetch(confirmed, confirmed) nxt = mx.full((1, 1, 1, 4), 300.0) ck, cv = cache.update_and_fetch(nxt, nxt) rk, rv = ref.update_and_fetch(nxt, nxt) mx.eval(ck, cv, rk, rv) assert mx.array_equal(ck, rk).item() assert mx.array_equal(cv, rv).item() c_off = cache.offset r_off = ref.offset if hasattr(c_off, "tolist"): assert c_off.tolist() == r_off.tolist() else: assert c_off == r_off def test_rotating_kv_cache_undo(self): from mlx_lm.models.cache import RotatingKVCache self._run_equivalence(lambda: RotatingKVCache(max_size=8)) def test_batch_rotating_kv_cache_undo(self): from mlx_lm.models.cache import BatchRotatingKVCache self._run_equivalence(lambda: BatchRotatingKVCache(8, [0])) def test_unarmed_update_keeps_stock_semantics(self): import mlx.core as mx from mlx_lm.models.cache import RotatingKVCache from omlx.patches.mlx_lm_mtp import cache_rollback cache_rollback.apply() cache = RotatingKVCache(max_size=8) self._fill(cache, 12) both = mx.full((1, 1, 2, 4), 7.0) cache.update_and_fetch(both, both) assert not cache.is_trimmable() assert cache.trim(1) == 0 def test_grow_mode_trim_unchanged(self): import mlx.core as mx from mlx_lm.models.cache import RotatingKVCache from omlx.patches.mlx_lm_mtp import cache_rollback cache_rollback.apply() cache = RotatingKVCache(max_size=64) self._fill(cache, 4) assert cache.is_trimmable() assert cache.trim(1) == 1 assert cache.offset == 3