Files
TheTomandClaude Opus 4.7 75ad729303 release(refract): v0.3.2.3 — MLX KLD numerical stability + walkthrough fixes
Three findings from the Mac mini PyPI install walkthrough (2026-05-03):

1. `--prompts` was required even though wheel ships them — defaulted to
   bundled refract/prompts/v0.1.jsonl via importlib.resources (v0.3.2.2)

2. macOS system python3 is 3.9, mlx-lm needs >=3.10 — documented in
   QUICKSTART Step 0 with brew install hint

3. MLX run_kld returned mean_kld=NaN on Qwen2.5-1.5B-Instruct-8bit +
   q8_0/q8_0 KV. Root cause: log(softmax + 1e-12) underflows on
   sharply-peaked logits from already-8bit weight models. Rewritten
   in terms of log-softmax (logits - logsumexp) which is stable.
   Non-finite per-position KL values now filtered before aggregation;
   mean computed across valid positions only. If every position is
   non-finite the backend raises BackendCapabilityError with an
   actionable hint instead of silently returning NaN.

Test mock updated for the new mx symbols (logsumexp, exp, isfinite,
where, zeros_like). 416 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: tturney1@gmail.com <tturney1@gmail.com>
2026-05-03 20:17:24 -05:00

389 lines
12 KiB
Python

"""Tests for MLXBackend methods using mocked mlx + mlx_lm modules.
mlx-lm doesn't have to be installed for these to run; we inject fake
modules so the backend's adapter logic gets exercised. Tests focus on
the dispatch glue (chat template, KV translation, token streaming,
softmax-based KLD) — the real ML primitives are out of scope.
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
from unittest import mock
import pytest
import refract.backends.mlx as mlx_mod
from refract.backends.base import (
BackendCapabilityError,
CompletionResult,
KLDResult,
TrajectoryResult,
)
from refract.backends.mlx import (
MLXBackend,
_apply_chat_template,
_load_model,
_translate_kv_to_mlx,
)
# --- _apply_chat_template (no mlx required) ------------------------------
class _FakeTokenizer:
def __init__(self, ids=None):
self._ids = ids or [1, 2, 3]
self.last_messages = None
self.last_kw = None
def apply_chat_template(self, messages, **kw):
self.last_messages = messages
self.last_kw = kw
return "RENDERED:" + " | ".join(m["content"] for m in messages)
def encode(self, text, **kw):
return list(self._ids)
def test_apply_chat_template_user_only():
tok = _FakeTokenizer()
out = _apply_chat_template(tok, "hi", None)
assert out == "RENDERED:hi"
assert len(tok.last_messages) == 1
assert tok.last_messages[0]["role"] == "user"
def test_apply_chat_template_with_system():
tok = _FakeTokenizer()
out = _apply_chat_template(tok, "question", "you are helpful")
assert "you are helpful" in out
assert "question" in out
assert tok.last_messages[0]["role"] == "system"
assert tok.last_messages[1]["role"] == "user"
# --- mock the mlx + mlx_lm imports the backend uses ---------------------
@pytest.fixture
def fake_mlx(monkeypatch):
"""Inject fake mlx + mlx_lm modules so MLXBackend methods can run.
Returns (mx_module, mlx_lm_module, cache_module) so tests can wire up
per-test behaviour.
"""
# mx.random.seed + mx.array + mx.softmax + mx.log + mx.sum + mx.mean
mx = types.SimpleNamespace()
mx.random = types.SimpleNamespace(seed=mock.MagicMock())
class _MxArray:
def __init__(self, data):
self.data = data
def __getitem__(self, idx):
return self
def item(self):
# Used to coerce mx scalars to python floats. Return a small
# non-zero value so the v0.3.2.3 KLD validity-counting path
# (n_valid = mx.sum(finite_mask).item()) treats the chunk as
# contributing rather than skipping every chunk as non-finite.
return 1.0
mx.array = lambda data: _MxArray(data)
mx.softmax = lambda a, axis=-1: a
mx.log = lambda a: a
mx.sum = lambda *a, **kw: _MxArray("sum")
mx.mean = lambda a: _MxArray("mean")
# Numerically-stable KL stubs added in v0.3.2.3.
mx.logsumexp = lambda a, axis=-1, keepdims=False: a
mx.exp = lambda a: a
mx.isfinite = lambda a: a
mx.where = lambda mask, a, b: a
mx.zeros_like = lambda a: a
# mlx_lm.generate / stream_generate / load
mlx_lm = types.SimpleNamespace()
def _generate(m, tok, **kw):
return "fake completion text<|im_end|>"
mlx_lm.generate = _generate
def _stream_generate(m, tok, **kw):
# Yield 3 records each carrying a token ID.
for tid in (10, 20, 30):
yield types.SimpleNamespace(token=tid)
mlx_lm.stream_generate = _stream_generate
fake_tok = _FakeTokenizer()
fake_model = mock.MagicMock(name="model")
fake_model.return_value = _MxArray("logits") # m(inp, cache=...) → logits
def _load(path):
return fake_model, fake_tok
mlx_lm.load = _load
# cache module
cache_mod = types.SimpleNamespace()
cache_mod.make_prompt_cache = lambda m: ["fake_cache_obj"]
cache_mod.maybe_quantize_kv_cache = mock.MagicMock()
mlx_lm.models = types.SimpleNamespace(cache=cache_mod)
# _require_mlx returns this triple — patch the function directly so
# tests don't have to wrestle with sys.modules + lazy imports.
monkeypatch.setattr(mlx_mod, "_require_mlx",
lambda: (mx, mlx_lm, cache_mod))
# Also expose mlx_lm via the import inside _load_model.
monkeypatch.setitem(sys.modules, "mlx_lm", mlx_lm)
# Reset the model cache between tests.
mlx_mod._MODEL_CACHE.clear()
return mx, mlx_lm, cache_mod
# --- _load_model caches across calls ------------------------------------
def test_load_model_caches(fake_mlx):
p = Path("/m.gguf")
a = _load_model(p)
b = _load_model(p)
assert a is b # same cached tuple
# --- MLXBackend.run_completion ------------------------------------------
def test_mlx_run_completion_strips_im_end_marker(fake_mlx):
bk = MLXBackend()
res = bk.run_completion(
model=Path("/m"), prompt="hi",
kv_config_str="ctk=q8_0,ctv=q8_0",
n_predict=10,
)
assert isinstance(res, CompletionResult)
assert "fake completion text" in res.text
assert "<|im_end|>" not in res.text
# KV bits propagated through metadata.
assert res.metadata["kv_kwargs"]["kv_bits"] == 8
def test_mlx_run_completion_without_chat_template(fake_mlx):
bk = MLXBackend()
res = bk.run_completion(
model=Path("/m"), prompt="raw prompt",
kv_config_str="ctk=f16,ctv=f16",
apply_chat_template=False,
)
assert "fake completion" in res.text
# kv_bits=None for f16
assert res.metadata["kv_kwargs"]["kv_bits"] is None
def test_mlx_run_completion_asymmetric_raises(fake_mlx):
bk = MLXBackend()
with pytest.raises(BackendCapabilityError):
bk.run_completion(
model=Path("/m"), prompt="x",
kv_config_str="ctk=q8_0,ctv=q4_0",
)
# --- MLXBackend.run_completion_trajectory --------------------------------
def test_mlx_trajectory_collects_token_ids(fake_mlx):
bk = MLXBackend()
res = bk.run_completion_trajectory(
model=Path("/m"), prompt="x",
kv_config_str="ctk=q8_0,ctv=q8_0",
)
assert isinstance(res, TrajectoryResult)
assert res.token_ids == [10, 20, 30]
assert res.metadata["n_tokens"] == 3
def test_mlx_trajectory_handles_mx_array_token_scalar(fake_mlx, monkeypatch):
"""`record.token` is sometimes an mx.array scalar — int(tid) raises
TypeError, so the code falls back to tid.item()."""
class _MxScalar:
def __init__(self, v):
self._v = v
def __int__(self):
raise TypeError("not directly convertible")
def item(self):
return self._v
def _stream_with_scalars(m, tok, **kw):
for v in (7, 8):
yield types.SimpleNamespace(token=_MxScalar(v))
monkeypatch.setattr(fake_mlx[1], "stream_generate", _stream_with_scalars)
bk = MLXBackend()
res = bk.run_completion_trajectory(
model=Path("/m"), prompt="x",
kv_config_str="ctk=f16,ctv=f16",
)
assert res.token_ids == [7, 8]
def test_mlx_trajectory_skips_records_without_token(fake_mlx, monkeypatch):
def _stream_with_none(m, tok, **kw):
yield types.SimpleNamespace(token=None)
yield types.SimpleNamespace(token=99)
monkeypatch.setattr(fake_mlx[1], "stream_generate", _stream_with_none)
bk = MLXBackend()
res = bk.run_completion_trajectory(
model=Path("/m"), prompt="x",
kv_config_str="ctk=f16,ctv=f16",
)
assert res.token_ids == [99]
# --- MLXBackend.run_kld --------------------------------------------------
def test_mlx_run_kld_basic(fake_mlx, tmp_path, monkeypatch):
"""Native KLD path: tokenize corpus, two forwards per chunk, accumulate."""
corpus = tmp_path / "c.txt"
corpus.write_text("text " * 200) # need ≥ ctx tokens
# Make the fake tokenizer return enough tokens for at least 1 ctx-sized chunk.
fake_tok = _FakeTokenizer(ids=list(range(1024)))
def _load(path):
m = mock.MagicMock()
m.return_value = _FakeMxLogits()
return m, fake_tok
monkeypatch.setattr(fake_mlx[1], "load", _load)
mlx_mod._MODEL_CACHE.clear()
bk = MLXBackend()
res = bk.run_kld(
model=tmp_path / "m.gguf", corpus=corpus,
ref_kv_str="ctk=f16,ctv=f16",
cand_kv_str="ctk=q8_0,ctv=q8_0",
chunks=2, ctx=64,
)
assert isinstance(res, KLDResult)
# mean_kld is 0.0 from our fake softmax (returns logits unchanged).
assert res.chunks <= 2
assert res.ctx == 64
class _FakeMxLogits:
"""Stand-in for mx.array logits — supports indexing + arithmetic enough
for the KLD math in MLXBackend.run_kld."""
def __getitem__(self, idx):
return self
def __add__(self, other):
return self
def __sub__(self, other):
return self
def __mul__(self, other):
return self
def __rmul__(self, other):
return self
def item(self):
return 0.0
def test_mlx_run_kld_corpus_too_short_raises(fake_mlx, tmp_path, monkeypatch):
corpus = tmp_path / "c.txt"
corpus.write_text("tiny")
fake_tok = _FakeTokenizer(ids=[1, 2, 3]) # only 3 tokens
def _load(path):
return mock.MagicMock(), fake_tok
monkeypatch.setattr(fake_mlx[1], "load", _load)
mlx_mod._MODEL_CACHE.clear()
bk = MLXBackend()
with pytest.raises(BackendCapabilityError, match="too short"):
bk.run_kld(
model=tmp_path / "m.gguf", corpus=corpus,
ref_kv_str="ctk=f16,ctv=f16",
cand_kv_str="ctk=f16,ctv=f16",
chunks=4, ctx=128,
)
def test_mlx_run_kld_zero_chunks_raises(fake_mlx, tmp_path, monkeypatch):
"""If chunks calculation yields 0, raise rather than divide by zero."""
corpus = tmp_path / "c.txt"
corpus.write_text("x")
# Tokenizer returns exactly ctx tokens — len // ctx = 1, but if chunks=0
# parameter is passed, min(0, 1) = 0 → should raise.
fake_tok = _FakeTokenizer(ids=list(range(64)))
def _load(path):
return mock.MagicMock(), fake_tok
monkeypatch.setattr(fake_mlx[1], "load", _load)
mlx_mod._MODEL_CACHE.clear()
bk = MLXBackend()
with pytest.raises(BackendCapabilityError, match="zero chunks"):
bk.run_kld(
model=tmp_path / "m.gguf", corpus=corpus,
ref_kv_str="ctk=f16,ctv=f16",
cand_kv_str="ctk=f16,ctv=f16",
chunks=0, ctx=64,
)
# --- MLXBackend.tokenize_to_ids -----------------------------------------
def test_mlx_tokenize_empty_text_returns_empty(fake_mlx):
bk = MLXBackend()
assert bk.tokenize_to_ids(model=Path("/m"), text="") == []
def test_mlx_tokenize_text_returns_list(fake_mlx, monkeypatch):
fake_tok = _FakeTokenizer(ids=[5, 6, 7])
def _load(path):
return mock.MagicMock(), fake_tok
monkeypatch.setattr(fake_mlx[1], "load", _load)
mlx_mod._MODEL_CACHE.clear()
bk = MLXBackend()
out = bk.tokenize_to_ids(model=Path("/m"), text="hello")
assert out == [5, 6, 7]
# --- MLXBackend.model_metadata ------------------------------------------
def test_mlx_model_metadata_includes_versions(fake_mlx, monkeypatch):
"""When importlib.metadata can resolve mlx + mlx-lm versions, they're
included in the metadata dict."""
import importlib.metadata as md
monkeypatch.setattr(md, "version", lambda name: "9.9.9")
bk = MLXBackend()
info = bk.model_metadata(model=Path("/m"))
assert info["backend"] == "mlx"
assert info["mlx_lm_version"] == "9.9.9"
assert info["mlx_version"] == "9.9.9"
def test_mlx_model_metadata_swallows_version_error(fake_mlx, monkeypatch):
"""If the version probe raises, metadata still returns backend + path."""
import importlib.metadata as md
def boom(name):
raise RuntimeError("no metadata")
monkeypatch.setattr(md, "version", boom)
bk = MLXBackend()
info = bk.model_metadata(model=Path("/m"))
assert info["backend"] == "mlx"
assert "mlx_lm_version" not in info