# SPDX-License-Identifier: Apache-2.0 """Tests for SpecPrefill (attention-based sparse prefill).""" import math import pytest try: import mlx.core as mx HAS_MLX = True except ImportError: HAS_MLX = False pytestmark = pytest.mark.skipif(not HAS_MLX, reason="MLX not available") class TestSelectChunks: """Tests for select_chunks() — chunk-based top-K% selection.""" def test_basic_selection(self): from omlx.patches.specprefill import select_chunks # 128 tokens, importance peaks in the first 32 tokens importance = mx.zeros(128) importance = importance.at[:32].add(1.0) selected = select_chunks(importance, keep_pct=0.25, chunk_size=32) # Should keep 1 chunk (25% of 4 chunks) assert selected.shape[0] == 32 # Should be the first chunk (indices 0-31) assert selected[0].item() == 0 assert selected[-1].item() == 31 def test_keep_100_percent(self): from omlx.patches.specprefill import select_chunks importance = mx.ones(64) selected = select_chunks(importance, keep_pct=1.0, chunk_size=32) assert selected.shape[0] == 64 def test_sorted_output(self): from omlx.patches.specprefill import select_chunks # Make middle and end chunks important importance = mx.zeros(128) importance = importance.at[32:64].add(2.0) importance = importance.at[96:128].add(1.0) selected = select_chunks(importance, keep_pct=0.5, chunk_size=32) # Should select 2 chunks, sorted by position indices = selected.tolist() assert indices == sorted(indices) assert 32 in indices assert 96 in indices def test_single_chunk(self): from omlx.patches.specprefill import select_chunks importance = mx.ones(16) selected = select_chunks(importance, keep_pct=0.5, chunk_size=32) # Single chunk, 50% → keep at least 1 chunk assert selected.shape[0] == 16 def test_non_divisible_chunks(self): from omlx.patches.specprefill import select_chunks # 100 tokens with chunk_size=32 → 4 chunks (last has 4 tokens) importance = mx.ones(100) selected = select_chunks(importance, keep_pct=0.5, chunk_size=32) n_chunks = math.ceil(100 / 32) keep_n = math.ceil(n_chunks * 0.5) expected_tokens = min(keep_n * 32, 100) # Allow for last chunk being smaller assert selected.shape[0] <= expected_tokens + 32 class TestManualRoPE: """Tests for manual_rope() at arbitrary positions.""" def test_contiguous_matches_standard(self): from omlx.patches.specprefill import manual_rope # Contiguous positions should produce same result as standard RoPE B, n_heads, L, head_dim = 1, 4, 8, 64 x = mx.random.normal((B, n_heads, L, head_dim)) positions = mx.arange(L) result = manual_rope(x, positions, dims=head_dim) assert result.shape == x.shape def test_non_contiguous_positions(self): from omlx.patches.specprefill import manual_rope B, n_heads, L, head_dim = 1, 4, 3, 64 x = mx.random.normal((B, n_heads, L, head_dim)) positions = mx.array([0, 5, 10]) result = manual_rope(x, positions, dims=head_dim) assert result.shape == x.shape # Results should differ from contiguous [0,1,2] contiguous = manual_rope(x, mx.arange(L), dims=head_dim) assert not mx.allclose(result, contiguous) def test_partial_rotation(self): from omlx.patches.specprefill import manual_rope B, n_heads, L, head_dim = 1, 2, 4, 128 dims = 64 # Only rotate first 64 dims x = mx.random.normal((B, n_heads, L, head_dim)) positions = mx.arange(L) result = manual_rope(x, positions, dims=dims) # Unrotated portion should be unchanged assert mx.allclose(result[..., dims:], x[..., dims:]) class TestAvgPool1d: """Tests for _avg_pool1d helper.""" def test_identity_kernel_1(self): from omlx.patches.specprefill import _avg_pool1d x = mx.array([1.0, 2.0, 3.0, 4.0, 5.0]) result = _avg_pool1d(x, 1) assert mx.allclose(result, x) def test_smoothing(self): from omlx.patches.specprefill import _avg_pool1d x = mx.array([0.0, 0.0, 1.0, 0.0, 0.0]) result = _avg_pool1d(x, 3) mx.eval(result) # Center value should be smoothed assert result[2].item() < 1.0 assert result[2].item() > 0.0 class TestKeepRatePresets: """Tests for keep rate preset constants.""" def test_presets_exist(self): from omlx.patches.specprefill import ( DEFAULT_KEEP_RATE, DEFAULT_THRESHOLD, KEEP_RATE_PRESETS, ) assert DEFAULT_KEEP_RATE == 0.20 assert DEFAULT_THRESHOLD == 8192 assert 0.10 in KEEP_RATE_PRESETS assert 0.20 in KEEP_RATE_PRESETS assert 0.30 in KEEP_RATE_PRESETS assert 0.50 in KEEP_RATE_PRESETS class TestModelTopologyHelpers: """Tests for model topology detection helpers.""" def test_find_attention_layers_empty(self): from unittest.mock import MagicMock from omlx.patches.specprefill import _find_attention_layers model = MagicMock(spec=[]) model.layers = [] assert _find_attention_layers(model) == [] def test_get_attn_module_self_attn(self): from unittest.mock import MagicMock from omlx.patches.specprefill import _get_attn_module layer = MagicMock() layer.self_attn = "attn_module" assert _get_attn_module(layer) == "attn_module" def test_detect_query_extractor_qwen35(self): from types import SimpleNamespace from omlx.patches.specprefill import ( _detect_query_extractor, _qwen35_extract_queries, ) attn = SimpleNamespace( q_norm=object(), num_attention_heads=4, head_dim=8, q_proj=SimpleNamespace(weight=mx.zeros((64, 32))), o_proj=SimpleNamespace(weight=mx.zeros((32, 32))), rope=object(), ) assert _detect_query_extractor(attn) is _qwen35_extract_queries def test_detect_query_extractor_llama(self): from unittest.mock import MagicMock from omlx.patches.specprefill import ( _detect_query_extractor, _llama_extract_queries, ) attn = MagicMock(spec=["rope", "q_proj"]) assert _detect_query_extractor(attn) is _llama_extract_queries def test_detect_query_extractor_gemma_with_q_norm(self): from omlx.patches.specprefill import ( _detect_query_extractor, _gemma4_extract_queries, ) class FakeGemmaAttention: n_heads = 8 head_dim = 16 q_norm = object() rope = object() q_proj = type("QProj", (), {"weight": mx.zeros((128, 64))})() o_proj = type("OProj", (), {"weight": mx.zeros((64, 128))})() def __call__(self, x, mask=None, cache=None, shared_kv=None, offset=None): return x assert _detect_query_extractor(FakeGemmaAttention()) is _gemma4_extract_queries def test_detect_query_extractor_non_gated_q_norm_model(self): from types import SimpleNamespace from omlx.patches.specprefill import ( _detect_query_extractor, _llama_extract_queries, ) attn = SimpleNamespace( q_norm=object(), num_attention_heads=4, head_dim=8, q_proj=SimpleNamespace(weight=mx.zeros((32, 32))), o_proj=SimpleNamespace(weight=mx.zeros((32, 32))), rope=object(), ) assert _detect_query_extractor(attn) is _llama_extract_queries def test_detect_query_extractor_qwen36_moe(self): """Qwen3.6 MoE: non-gated q_proj + per-head q_norm routes to qwen36.""" from types import SimpleNamespace from omlx.patches.specprefill import ( _detect_query_extractor, _qwen36_extract_queries, ) attn = SimpleNamespace( q_norm=SimpleNamespace(weight=mx.zeros((16,))), # RMSNorm(head_dim=16) n_heads=8, q_proj=SimpleNamespace(weight=mx.zeros((128, 64))), # 8 * 16 = 128 rope=object(), ) assert _detect_query_extractor(attn) is _qwen36_extract_queries def test_detect_query_extractor_flat_q_norm_stays_llama(self): """Olmo-style: q_norm on flat n_heads*head_dim must not match qwen36.""" from types import SimpleNamespace from omlx.patches.specprefill import ( _detect_query_extractor, _llama_extract_queries, ) attn = SimpleNamespace( q_norm=SimpleNamespace(weight=mx.zeros((128,))), # flat n_heads*head_dim n_heads=8, head_dim=16, q_proj=SimpleNamespace(weight=mx.zeros((128, 64))), rope=object(), ) # q_norm_dim=128, n_heads*q_norm_dim=1024 != q_out=128 → no qwen36 match assert _detect_query_extractor(attn) is _llama_extract_queries def test_attention_capture_forwards_extra_kwargs(self): from unittest.mock import MagicMock from omlx.patches.specprefill import _AttentionCapture captured = [] extractor_calls = [] def _extractor(attn, x, cache=None, **kwargs): extractor_calls.append((cache, kwargs)) return "queries" original = MagicMock(return_value="result") wrapper = _AttentionCapture(original, 0, [captured], _extractor) out = wrapper("x", mask="m", cache="c", shared_kv="skv", offset=7) assert out == "result" assert captured == ["queries"] assert extractor_calls == [("c", {"shared_kv": "skv", "offset": 7})] original.assert_called_once_with( "x", mask="m", cache="c", shared_kv="skv", offset=7 ) def test_attention_capture_supports_legacy_extractor_signature(self): from unittest.mock import MagicMock from omlx.patches.specprefill import _AttentionCapture captured = [] def _extractor(attn, x, cache=None): return "queries" original = MagicMock(return_value="result") wrapper = _AttentionCapture(original, 0, [captured], _extractor) out = wrapper("x", mask="m", cache="c", shared_kv="skv", offset=7) assert out == "result" assert captured == ["queries"] original.assert_called_once_with( "x", mask="m", cache="c", shared_kv="skv", offset=7 ) def test_gemma4_extract_queries_applies_q_norm(self): """Gemma4: q_norm runs on per-head queries before RoPE.""" from omlx.patches.specprefill import _gemma4_extract_queries call_log = [] class FakeAttn: n_heads = 4 def q_proj(self, x): return x def q_norm(self, q): call_log.append(("q_norm", q.shape)) return q * 2.0 # distinguishable transform def rope(self, q, offset=0): call_log.append(("rope", q.shape, offset)) return q head_dim = 8 x = mx.ones((1, 3, 4 * head_dim)) out = _gemma4_extract_queries(FakeAttn(), x, cache=None, offset=11) # q_norm got (B, L, n_heads, head_dim) — reshape happened before norm assert call_log[0] == ("q_norm", (1, 3, 4, head_dim)) # rope got (B, n_heads, L, head_dim) — transpose happened after norm assert call_log[1] == ("rope", (1, 4, 3, head_dim), 11) # and q_norm's scaling survived into the output assert mx.allclose(out, mx.ones_like(out) * 2.0).item() def test_qwen36_extract_queries_applies_q_norm(self): """Qwen3.6: q_norm runs on per-head queries before RoPE, no gate split.""" from omlx.patches.specprefill import _qwen36_extract_queries call_log = [] class FakeAttn: n_heads = 4 def q_proj(self, x): return x def q_norm(self, q): call_log.append(("q_norm", q.shape)) return q * 3.0 def rope(self, q, offset=0): call_log.append(("rope", q.shape, offset)) return q head_dim = 8 x = mx.ones((1, 3, 4 * head_dim)) cache = type("C", (), {"offset": 5})() out = _qwen36_extract_queries(FakeAttn(), x, cache=cache) # q_norm gets (B, L, n_heads, head_dim) — reshape before norm, no split assert call_log[0] == ("q_norm", (1, 3, 4, head_dim)) # rope gets (B, n_heads, L, head_dim) with cache.offset assert call_log[1] == ("rope", (1, 4, 3, head_dim), 5) assert mx.allclose(out, mx.ones_like(out) * 3.0).item() def test_llama_extract_queries_without_q_norm(self): """Plain Llama/Mistral: no q_norm attr, fall through unchanged.""" from omlx.patches.specprefill import _llama_extract_queries class FakeAttn: n_heads = 4 def q_proj(self, x): return x def rope(self, q, offset=0): return q x = mx.ones((1, 3, 4 * 8)) out = _llama_extract_queries(FakeAttn(), x, cache=None) assert out.shape == (1, 4, 3, 8) def test_build_layer_to_cache_map_gemma_shared_kv_vlm(self): """VLM Gemma4: previous_kvs lives at .language_model.model.""" from types import SimpleNamespace from omlx.patches.specprefill import _build_layer_to_cache_map previous_kvs = [0, 1, 2, 2, 3] model = SimpleNamespace( layers=[object() for _ in previous_kvs], language_model=SimpleNamespace( model=SimpleNamespace(previous_kvs=previous_kvs) ), ) assert _build_layer_to_cache_map(model) == { 0: 0, 1: 1, 2: 2, 3: 2, 4: 3, } def test_build_layer_to_cache_map_gemma_shared_kv_text(self): """Text-only Gemma4: previous_kvs lives at .model (Gemma4TextModel).""" from types import SimpleNamespace from omlx.patches.specprefill import _build_layer_to_cache_map previous_kvs = [0, 1, 2, 2, 3] model = SimpleNamespace( layers=[object() for _ in previous_kvs], model=SimpleNamespace(previous_kvs=previous_kvs), ) assert _build_layer_to_cache_map(model) == { 0: 0, 1: 1, 2: 2, 3: 2, 4: 3, } class TestRoPEWrappers: """Tests for _PositionMappedRoPE and _OffsetAdjustedRoPE.""" def test_position_mapped_rope_accepts_mx_array_offset(self): """Gemma4 wraps cache.offset in mx.array before calling RoPE.""" from omlx.patches.specprefill import _PositionMappedRoPE class FakeRoPE: dims = 64 base = 10000.0 scale = 1.0 def __call__(self, x, offset=0): return x positions = mx.arange(10, dtype=mx.int32) wrapper = _PositionMappedRoPE(FakeRoPE(), positions, cache_start=0) x = mx.zeros((1, 4, 3, 64)) result = wrapper(x, offset=mx.array(2)) assert result.shape == x.shape def test_offset_adjusted_rope_adds_offset(self): from omlx.patches.specprefill import _OffsetAdjustedRoPE call_log = [] class FakeRoPE: def __call__(self, x, offset=0): call_log.append(offset) return x original = FakeRoPE() adjusted = _OffsetAdjustedRoPE(original, adjustment=100) x = mx.zeros((1, 4, 1, 64)) adjusted(x, offset=5) assert call_log[-1] == 105 # 5 + 100 def test_cleanup_rope_restores_original(self): from unittest.mock import MagicMock from omlx.patches.specprefill import ( _OffsetAdjustedRoPE, cleanup_rope, ) original_rope = MagicMock() adjusted = _OffsetAdjustedRoPE(original_rope, adjustment=50) model = MagicMock() layer = MagicMock() layer.self_attn = MagicMock() layer.self_attn.rope = adjusted model.layers = [layer] cleanup_rope(model) assert layer.self_attn.rope is original_rope class TestModelSettings: """Tests for SpecPrefill fields in ModelSettings.""" def test_specprefill_defaults(self): from omlx.model_settings import ModelSettings s = ModelSettings() assert s.specprefill_enabled is False assert s.specprefill_draft_model is None assert s.specprefill_keep_pct is None assert s.specprefill_threshold is None def test_specprefill_roundtrip(self): from omlx.model_settings import ModelSettings s = ModelSettings( specprefill_enabled=True, specprefill_draft_model="/path/to/draft", specprefill_keep_pct=0.2, specprefill_threshold=8192, ) d = s.to_dict() assert d["specprefill_enabled"] is True assert d["specprefill_draft_model"] == "/path/to/draft" assert d["specprefill_keep_pct"] == 0.2 restored = ModelSettings.from_dict(d) assert restored.specprefill_enabled is True assert restored.specprefill_draft_model == "/path/to/draft" class TestRequestFields: """Tests for SpecPrefill fields in Request.""" def test_specprefill_defaults(self): from omlx.request import Request, SamplingParams r = Request( request_id="test", prompt="hello", sampling_params=SamplingParams(), ) assert r.specprefill_indices is None assert r.specprefill_total_tokens == 0 assert r.specprefill_position_offset == 0 class TestEngineCorePropagation: """Tests for SpecPrefill param propagation through AsyncEngineCore.add_request.""" def _make_engine_core(self, draft_model=None): """Create a minimal EngineCore for testing add_request propagation.""" from unittest.mock import AsyncMock, MagicMock from omlx.engine_core import EngineCore core = object.__new__(EngineCore) core._output_collectors = {} core._active_requests = {} core._stream_states = {} core._finished_events = {} mock_scheduler = MagicMock(spec=[]) mock_scheduler._specprefill_draft_model = draft_model core.scheduler = mock_scheduler mock_config = MagicMock(spec=[]) mock_config.stream_interval = 0 core.config = mock_config # _mlx_executor=None makes run_in_executor use the default pool core._mlx_executor = None # scheduler.add_request is a no-op for this test mock_scheduler.add_request = MagicMock() return core @pytest.mark.asyncio async def test_threshold_propagated_to_request(self): """specprefill_threshold should be set on request._specprefill_threshold.""" from omlx.request import SamplingParams core = self._make_engine_core(draft_model="/some/draft") await core.add_request( prompt=[1, 2, 3], sampling_params=SamplingParams(), specprefill_threshold=4096, specprefill_keep_pct=0.3, ) # Retrieve the request passed to scheduler.add_request req = core.scheduler.add_request.call_args[0][0] assert req._specprefill_threshold == 4096 assert req._specprefill_keep_pct == 0.3 assert req._specprefill_enabled is True @pytest.mark.asyncio async def test_threshold_not_set_when_none(self): """When specprefill_threshold is None, _specprefill_threshold should not exist.""" from omlx.request import SamplingParams core = self._make_engine_core(draft_model=None) await core.add_request( prompt=[1, 2, 3], sampling_params=SamplingParams(), ) req = core.scheduler.add_request.call_args[0][0] assert not hasattr(req, "_specprefill_threshold") assert not hasattr(req, "_specprefill_keep_pct")