Solomon / mlx /tests /test_tiny_library.py
kelseyway's picture
Make MLX adapter-only and include the reproducible BF16 converter
5c0a4a8
Raw
History Blame Contribute Delete
6.66 kB
"""End-to-end BF16 library wiring on a synthetic hybrid vision model.
This is an integration test, not evidence about Solomon's trained accuracy.
Only checkpoint hashes and the tokenizer are replaced in the test fixture.
"""
import json
from dataclasses import asdict
from types import SimpleNamespace
from typing import ClassVar
import mlx.core as mx
import numpy as np
from mlx.utils import tree_map_with_path
from mlx_vlm.models.qwen3_5.config import ModelConfig, TextConfig, VisionConfig
from mlx_vlm.models.qwen3_5.qwen3_5 import Model
from mlx_vlm.models.qwen3_vl.processing_qwen3_vl import Qwen3VLImageProcessor, Qwen3VLProcessor
from mlx_vlm.utils import save_weights
from PIL import Image
import solomon_mlx.engine as engine_module
from solomon_mlx import Solomon
from solomon_mlx.artifacts import BASE_REVISION, SOLOMON_REVISION, sha256
from solomon_mlx.prepare import convert_bf16
class Tokenizer:
markers: ClassVar[dict] = {"<|vision_start|>": 251, "<|vision_end|>": 252, "<|image_pad|>": 253}
def apply_chat_template(self, messages, **kwargs):
return "\n".join(m["content"] for m in messages) + "\nAssistant:"
def encode(self, text, **kwargs):
for key, value in self.markers.items():
text = text.replace(key, chr(value))
return list(text.encode("latin1"))
def convert_tokens_to_ids(self, text):
return self.markers[text]
def test_full_library_bf16_text_image_repeated_question(tmp_path, monkeypatch):
mx.random.seed(14)
text = TextConfig(
model_type="qwen3_5_text",
hidden_size=5120,
intermediate_size=64,
linear_num_value_heads=2,
linear_num_key_heads=2,
linear_key_head_dim=32,
linear_value_head_dim=32,
linear_conv_kernel_dim=4,
num_hidden_layers=4,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=32,
rms_norm_eps=1e-6,
vocab_size=256,
max_position_embeddings=4096,
rope_parameters={
"type": "default",
"mrope_section": [2, 1, 1],
"rope_theta": 100000,
"partial_rotary_factor": 0.25,
},
)
vision = VisionConfig(
depth=1,
hidden_size=64,
intermediate_size=64,
out_hidden_size=5120,
num_heads=4,
patch_size=16,
spatial_patch_size=16,
num_position_embeddings=64,
deepstack_visual_indexes=[],
)
config = ModelConfig(
text_config=text,
vision_config=vision,
model_type="qwen3_5",
image_token_id=253,
video_token_id=254,
vision_start_token_id=251,
vision_end_token_id=252,
vocab_size=256,
)
model = Model(config)
model.update(
tree_map_with_path(
lambda k, v: v.astype(mx.float32 if v.ndim == 1 else mx.bfloat16), model.parameters()
)
)
backbone = tmp_path / "backbone"
save_weights(backbone, model, donate_weights=True)
(backbone / "config.json").write_text(json.dumps(asdict(config)))
source = tmp_path / "original"
backbone.rename(source)
convert_bf16(source, backbone)
# Cloud conversion uses the CPU backend. Its output must match the Mac's
# default backend before either is used by the same Metal runtime.
cpu_backbone = tmp_path / "cpu-backbone"
with mx.stream(mx.cpu):
convert_bf16(source, cpu_backbone)
for shard in backbone.glob("*.safetensors"):
gpu_arrays = mx.load(str(shard))
cpu_arrays = mx.load(str(cpu_backbone / shard.name))
assert gpu_arrays.keys() == cpu_arrays.keys()
for key in gpu_arrays:
assert gpu_arrays[key].dtype == cpu_arrays[key].dtype
assert mx.array_equal(gpu_arrays[key], cpu_arrays[key]).item(), key
adapter = tmp_path / "adapter.safetensors"
mx.save_safetensors(
str(adapter),
{
"model.layers.0.mlp.gate_proj.lora_a": mx.full((5120, 64), 0.001, mx.float32),
"model.layers.0.mlp.gate_proj.lora_b": mx.full((64, 64), 0.001, mx.float32),
},
)
keys = [
"boolean/state4",
"entity/state4",
"multilabel/state4",
"ordered/threshold4",
"single/choiceR",
"single/choiceS",
"single/sufficiency3",
"ordered/choiceR",
"ordered/choiceS",
"ordered/sufficiency3",
]
rng = np.random.default_rng(14)
heads = {}
for k in keys:
heads[k + "/weight"] = rng.normal(0, 0.01, (10, 5120)).astype(np.float32)
heads[k + "/bias"] = np.zeros(10, np.float32)
np.savez(tmp_path / "heads.npz", **heads)
binding = {
"profile": "quality",
"schema": "solomon-mlx-binding-v1",
"base_revision": BASE_REVISION,
"solomon_revision": SOLOMON_REVISION,
"dtype": "bfloat16",
"files": {str(p.relative_to(tmp_path)): sha256(p) for p in tmp_path.rglob("*") if p.is_file()},
}
(tmp_path / "binding.json").write_text(json.dumps(binding))
monkeypatch.setattr(engine_module, "ADAPTER_SHA", sha256(adapter))
monkeypatch.setattr(engine_module, "HEADS_SHA", sha256(tmp_path / "heads.npz"))
processor = SimpleNamespace(
tokenizer=Tokenizer(), image_processor=Qwen3VLImageProcessor(min_pixels=1024, max_pixels=16384)
)
monkeypatch.setattr(Qwen3VLProcessor, "from_pretrained", lambda *a, **kw: processor)
port = Solomon.load(tmp_path, chunk_size=128)
page = tmp_path / "page.png"
Image.new("RGB", (128, 128), "white").save(page)
questions = {
"a": "Is Alice certified?",
"b": {"type": "choice", "instructions": "Who?", "options": ["Alice", "Bob"]},
"c": {"type": "score", "instructions": "Level?", "levels": ["low", "high"]},
"d": {"instructions": "Is {candidate} certified?", "candidates": ["Alice", "Bob"]},
"e": {"instructions": "Which labels apply?", "candidates": ["certified", "unavailable"]},
}
with port.prefill([{"text": "Alice is certified."}, {"image": str(page)}, {"text": "End."}]) as state:
assert state._data["counts"] == [16]
first = port.decide(state=state, questions=questions, evidence="none", diagnostics=True)
repeated = port.decide(
state=state, questions={"a": questions["a"]}, evidence="support", diagnostics=True
)
assert first["answers"]["a"]["noul"] == repeated["answers"]["a"]["noul"]
assert repeated["answers"]["a"]["evidence_status"] == "unsupported_page_selector"
assert set(first["answers"]) == set(questions)
assert port.engine.context["start"] is None