"""Image input (src/strands_decider/vision.py), on a tiny random-weight Qwen3.5 built here.
Nothing is downloaded: the multimodal checkpoint, the tokeniser and the image processor
are all made in a temp dir. The tokeniser is a real `Qwen3_5Tokenizer` (byte-level BPE
over the 256 byte symbols, no merges, plus the Qwen vision tokens), so it reloads as
itself next to a qwen3_5 config and tokenises every character of a state or question.
The tests pin what the feature promises:
* a text checkpoint loads onto the multimodal torso and answers text exactly as the
text engine does (the adapter lands on the same decoder);
* answers over images through the shared-prefix path equal a plain full forward, which
fails without the explicit M-RoPE positions;
* no position state leaks from an image forward into the next text request;
* the window rules match the text path: `strict_window` refuses, otherwise the state text
loses its end and the images are never cut;
* only the promised image formats are opened, sizes are checked before decoding, and
rotation and transparency are handled;
* a text-only engine refuses images instead of ignoring them, at the engine and the API.
"""
from __future__ import annotations
import base64
import io
import re
from dataclasses import replace
import pytest
transformers = pytest.importorskip("transformers")
PIL = pytest.importorskip("PIL")
if tuple(int(x) for x in re.findall(r"\d+", transformers.__version__)[:2]) < (5, 18):
pytest.skip("image input needs transformers >= 5.18", allow_module_level=True)
import torch # noqa: E402
from PIL import Image # noqa: E402
from strands_decider.infer import EngineConfig, SystemOneEngine, _option_token_index # noqa: E402
from strands_decider.modeling import ( # noqa: E402
StrandsDeciderConfig,
StrandsDeciderModel,
masked_log_softmax,
)
from strands_decider.prompting import render_question # noqa: E402
from strands_decider.schema import ( # noqa: E402
ChoiceQuestion,
NoulQuestion,
ScoreQuestion,
SystemOneRequest,
)
from strands_decider.vision import ( # noqa: E402
IMAGE_PAD,
VISION_END,
VisionDeciderModel,
VisionEngine,
VisionEngineConfig,
decode_image,
expand_image_tokens,
fit_image,
image_tokens_for_grid,
load_vision_engine,
render_image_state,
)
TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj",
"in_proj_qkv", "in_proj_z", "in_proj_a", "in_proj_b", "out_proj"]
STATE = "Help! My payouts have been failing for 3 days."
QUESTIONS = {
"signed": NoulQuestion(instructions="Is the form signed?"),
"button": ChoiceQuestion(instructions="Which control next?",
criteria={"submit": "the blue submit button", "cancel": "the grey cancel link"}),
"sharp": ScoreQuestion(instructions="How sharp?", criteria=["blurred", "soft", "sharp"]),
}
def _tokenizer():
from tokenizers import pre_tokenizers
alphabet = sorted(pre_tokenizers.ByteLevel.alphabet())
vocab = {"<|endoftext|>": 0, **{c: i + 1 for i, c in enumerate(alphabet)}}
tok = transformers.Qwen3_5Tokenizer(vocab=vocab, merges=[])
tok.add_special_tokens({"additional_special_tokens": ["<|vision_start|>", IMAGE_PAD, VISION_END]})
return tok
@pytest.fixture(scope="module")
def base_dir(tmp_path_factory):
"""A tiny Qwen3.5 multimodal checkpoint: 3 Gated DeltaNet + 1 attention layer, 2-layer ViT."""
d = tmp_path_factory.mktemp("tiny-qwen35")
tok = _tokenizer()
ids = {t: tok.convert_tokens_to_ids(t) for t in ("<|vision_start|>", IMAGE_PAD, VISION_END)}
cfg = transformers.Qwen3_5Config(
text_config={
"hidden_size": 64, "num_hidden_layers": 4, "intermediate_size": 128, "head_dim": 64,
"num_attention_heads": 2, "num_key_value_heads": 1, "vocab_size": len(tok),
"layer_types": ["linear_attention"] * 3 + ["full_attention"],
"linear_num_key_heads": 4, "linear_num_value_heads": 4,
"linear_key_head_dim": 16, "linear_value_head_dim": 16, "linear_conv_kernel_dim": 4,
"attn_output_gate": True, "tie_word_embeddings": True, "mtp_num_hidden_layers": 0,
"rope_parameters": {"rope_type": "default", "rope_theta": 1e7, "partial_rotary_factor": 0.25,
"mrope_section": [3, 3, 2], "mrope_interleaved": True},
},
vision_config={"depth": 2, "hidden_size": 64, "num_heads": 4, "intermediate_size": 128,
"out_hidden_size": 64, "patch_size": 16, "spatial_merge_size": 2,
"temporal_patch_size": 2, "num_position_embeddings": 2304},
image_token_id=ids[IMAGE_PAD], vision_start_token_id=ids["<|vision_start|>"],
vision_end_token_id=ids[VISION_END], tie_word_embeddings=True,
)
torch.manual_seed(0)
transformers.Qwen3_5ForConditionalGeneration(cfg).save_pretrained(d)
tok.save_pretrained(d)
transformers.Qwen2VLImageProcessorPil(
patch_size=16, temporal_patch_size=2, merge_size=2,
size={"shortest_edge": 65536, "longest_edge": 16777216},
image_mean=[0.5] * 3, image_std=[0.5] * 3,
).save_pretrained(d)
return str(d)
@pytest.fixture(scope="module")
def ckpt(base_dir, tmp_path_factory):
"""A text Strands Decider checkpoint on that base, with a non-trivial adapter."""
cfg = StrandsDeciderConfig(base_model=base_dir, head_type="pointer", pointer_dim=32,
torch_dtype="float32", max_length=1024, lora_targets=TARGETS,
temperature_by_kind={"noul": 0.9, "choice": 0.7})
model = StrandsDeciderModel.from_pretrained_base(cfg)
with torch.no_grad():
for n, p in model.torso.named_parameters():
if "lora_B" in n:
p.normal_(0, 0.05)
d = tmp_path_factory.mktemp("ckpt")
model.save_pretrained(str(d))
return str(d)
@pytest.fixture(scope="module")
def engines(ckpt):
text = SystemOneEngine(StrandsDeciderModel.load(ckpt), EngineConfig(device="cpu"))
vision = VisionEngine(VisionDeciderModel.load(ckpt), VisionEngineConfig(device="cpu"))
return text, vision
def _png(img: Image.Image, fmt: str = "PNG", **save) -> str:
buf = io.BytesIO()
img.save(buf, format=fmt, **save)
return base64.b64encode(buf.getvalue()).decode()
def _b64(w: int, h: int, seed: int) -> str:
g = torch.Generator().manual_seed(seed)
return _png(Image.fromarray((torch.rand(h, w, 3, generator=g) * 255).to(torch.uint8).numpy()))
# ---- prompt helpers and tokeniser ---------------------------------------------------
def test_image_state_layout():
two = render_image_state("two pages", 2)
assert two == ("\n<|vision_start|><|image_pad|><|vision_end|>\n"
"<|vision_start|><|image_pad|><|vision_end|>\ntwo pages\n\n")
assert render_image_state("", 1) == "\n<|vision_start|><|image_pad|><|vision_end|>\n\n"
assert render_image_state("text", 0) == "\ntext\n\n"
def test_expansion_and_grid():
assert image_tokens_for_grid([[1, 28, 28], [1, 20, 28]], merge_size=2) == [196, 140]
assert expand_image_tokens("a<|image_pad|>b", [3]) == "a" + IMAGE_PAD * 3 + "b"
with pytest.raises(ValueError):
expand_image_tokens("a<|image_pad|>b", [1, 2])
assert fit_image(Image.new("RGB", (900, 300)), 448).size == (448, 149)
assert fit_image(Image.new("RGB", (100, 50)), 448).size == (100, 50) # never upscaled
def test_checkpoint_tokenizer_reads_text(engines):
"""The tokeniser that comes back from the checkpoint keeps every character, so the
tests below exercise real state and question text."""
_, vision = engines
assert type(vision.tok).__name__ == "Qwen3_5Tokenizer"
assert vision.tok.decode(vision.tok(STATE)["input_ids"]) == STATE
# ---- images --------------------------------------------------------------------------
def test_decode_accepts_only_promised_formats_and_sizes():
assert decode_image(_png(Image.new("RGB", (32, 16)), "JPEG")).size == (32, 16)
eps = base64.b64encode(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n").decode()
with pytest.raises(ValueError, match="could not be read"):
decode_image(eps)
with pytest.raises(ValueError, match="pixels"):
decode_image(_png(Image.new("RGB", (64, 64))), max_pixels=1000) # refused from the header
with pytest.raises(ValueError, match="base64"):
decode_image("not base64!")
def test_decode_rotation_and_transparency():
exif = Image.Exif()
exif[0x0112] = 6 # rotated 90 degrees, as phones store it
assert decode_image(_png(Image.new("RGB", (40, 20)), "JPEG", exif=exif)).size == (20, 40)
clear = Image.new("RGBA", (4, 4), (0, 0, 0, 0))
assert decode_image(_png(clear)).getpixel((0, 0)) == (255, 255, 255)
# Malformed EXIF is a caller error (422), not a server error.
for fmt in ("PNG", "WEBP"):
with pytest.raises(ValueError, match="could not be decoded"):
decode_image(_png(Image.new("RGB", (8, 8)), fmt, exif=b"garbage!"))
def test_processor_is_pinned_to_pil(engines):
_, vision = engines
assert type(vision.image_processor).__name__ == "Qwen2VLImageProcessorPil"
# ---- model and answers ------------------------------------------------------------------
def test_adapter_lands_on_the_decoder_and_vision_is_frozen(engines):
_, vision = engines
lora = [n for n, _ in vision.model.torso.named_modules() if n.endswith("lora_A")]
assert lora and all(".language_model." in n for n in lora)
assert not any(p.requires_grad for p in vision.model.torso.base_model.model.visual.parameters())
def test_text_requests_unchanged(engines):
text, vision = engines
req = SystemOneRequest(state=STATE, questions=QUESTIONS)
assert text.evaluate(req).model_dump()["answers"] == vision.evaluate(req).model_dump()["answers"]
def _reference(vision: VisionEngine, state: str, images: list[str], q) -> list[float]:
"""Plain full forward of state + one question, positions left to transformers."""
rq = render_question(q)
pil = [fit_image(decode_image(b), 448) for b in images]
proc = vision.image_processor(images=pil, return_tensors="pt")
counts = image_tokens_for_grid(proc["image_grid_thw"].tolist(), vision.image_processor.merge_size)
text = expand_image_tokens(render_image_state(state, len(images)), counts) + rq.text
enc = vision.tok(text, return_offsets_mapping=True)
opt = _option_token_index(enc["offset_mapping"], rq.option_spans, len(text) - len(rq.text))
ids = torch.tensor([enc["input_ids"]])
with torch.no_grad():
out = vision.model(ids, torch.ones_like(ids), torch.tensor([rq.n_slots]), opt_idx=torch.tensor([opt]),
temperature=vision._temperatures([rq.kind]), pixel_values=proc["pixel_values"],
image_grid_thw=proc["image_grid_thw"])
return masked_log_softmax(out["logits"], torch.tensor([rq.n_slots])).exp()[0, : rq.n_slots].tolist()
@pytest.mark.parametrize("sizes", [[(600, 800)], [(448, 448), (1000, 200)]])
def test_image_answers_match_full_forward(engines, sizes):
_, vision = engines
images = [_b64(w, h, i) for i, (w, h) in enumerate(sizes)]
rendered = [render_question(q) for q in QUESTIONS.values()]
pil = [fit_image(decode_image(b), 448) for b in images]
probs, _ = vision._image_probs("Checkout page after the user tapped pay.", pil, rendered)
for i, q in enumerate(QUESTIONS.values()):
ref = _reference(vision, "Checkout page after the user tapped pay.", images, q)
got = probs[i, : len(ref)].tolist()
assert max(abs(x - y) for x, y in zip(got, ref, strict=True)) < 1e-5
def test_image_forward_leaves_no_position_state(engines):
"""A plain image forward on the model (as in training or evaluation) stores
`rope_deltas` on the torso; the next text request must not read it."""
_, vision = engines
req = SystemOneRequest(state=STATE, questions=QUESTIONS)
before = vision.evaluate(req).model_dump()["answers"]
_reference(vision, "Checkout page.", [_b64(600, 800, 1)], QUESTIONS["signed"])
vision.evaluate(SystemOneRequest(state="", questions=QUESTIONS, images=[_b64(600, 800, 1)]))
assert vision.evaluate(req).model_dump()["answers"] == before
# ---- the window ----------------------------------------------------------------------------
def _small_window(vision: VisionEngine, max_length: int, strict: bool) -> VisionEngine:
model = vision.model
eng = VisionEngine.__new__(VisionEngine)
eng.__dict__.update(vision.__dict__)
eng.cfg = eng.vcfg = replace(vision.vcfg, strict_window=strict)
eng.model = model
model.config.max_length = max_length
return eng
def test_long_state_keeps_its_head_and_the_images(engines):
_, vision = engines
original = vision.model.config.max_length
try:
eng = _small_window(vision, 700, strict=False)
state = "First words stay. " + "filler " * 200 + "Last words go."
img = [fit_image(decode_image(_b64(320, 320, 4)), 448)]
s, _, _, _ = eng._fit_images(state, img, [render_question(QUESTIONS["signed"]).text])
assert s.count(eng.tok.convert_tokens_to_ids(IMAGE_PAD)) == 100 # the image is whole
kept = eng.tok.decode(s)
assert "First words stay." in kept and "Last words go." not in kept
with pytest.raises(ValueError, match="context window"):
_small_window(vision, 700, strict=True)._fit_images(
state, img, [render_question(QUESTIONS["signed"]).text])
finally:
vision.model.config.max_length = original
def test_concurrent_image_and_text_requests_match_serial(engines):
"""Vision engine serves mixed image+text requests through FastAPI's threadpool.
`_fit_images` once wrote offsets onto the engine too; a concurrent request would
overwrite them before `_image_probs` read them back, so the pointer head scored
the wrong tokens. Pins that the image path threads offsets per request as the
text path does."""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
_, vision = engines
img = _b64(160, 160, 7)
reqs = []
for i in range(8):
state = f"{STATE} ({i})"
if i % 2:
reqs.append(SystemOneRequest(state=state, questions=QUESTIONS, images=[img]))
else:
reqs.append(SystemOneRequest(state=state, questions=QUESTIONS))
def answers(resp):
return {n: a.model_dump() for n, a in resp.answers.items()}
with torch.inference_mode():
serial = [answers(vision.evaluate(r)) for r in reqs]
# Hold every thread between tokenising and pointing, so they interleave at the
# race-prone spot in both the text (_fit) and image (_fit_images) paths.
workers = 4
barrier = threading.Barrier(workers, timeout=60)
broke = threading.Event()
# The HF fast tokenizer (Rust) raises "Already borrowed" if two threads reconfigure
# truncation on it at once -- orthogonal to the race under test. Serialise just
# the tokeniser calls; the barrier still interleaves threads at the race-prone spot.
fit_lock = threading.Lock()
def _trip():
try:
barrier.wait()
except threading.BrokenBarrierError:
broke.set()
time.sleep(0.01)
fit = type(vision)._fit
fit_images = type(vision)._fit_images
def slow_fit(self, *a, **kw):
with fit_lock:
out = fit(self, *a, **kw)
_trip()
return out
def slow_fit_images(self, *a, **kw):
with fit_lock:
out = fit_images(self, *a, **kw)
_trip()
return out
try:
VisionEngine._fit = slow_fit # type: ignore[method-assign]
VisionEngine._fit_images = slow_fit_images # type: ignore[method-assign]
def run(r):
with torch.inference_mode():
return answers(vision.evaluate(r))
with ThreadPoolExecutor(max_workers=workers) as pool:
concurrent = list(pool.map(run, reqs))
finally:
VisionEngine._fit = fit # type: ignore[method-assign]
VisionEngine._fit_images = fit_images # type: ignore[method-assign]
assert not broke.is_set(), "barrier broke; threads did not interleave at _fit/_fit_images"
assert concurrent == serial
# ---- engine and API ------------------------------------------------------------------------
def test_image_only_request_and_refusals(engines):
text, vision = engines
req = SystemOneRequest(state="", questions={"signed": QUESTIONS["signed"]},
images=["data:image/png;base64," + _b64(320, 320, 2)])
assert 0.0 <= vision.evaluate(req).answers["signed"].noul <= 1.0
with pytest.raises(ValueError):
text.evaluate(req) # a text-only engine refuses images rather than ignore them
with pytest.raises(ValueError):
vision.evaluate(SystemOneRequest(state="x", questions=QUESTIONS, images=["not base64!"]))
def test_load_vision_engine_keeps_the_server_settings(ckpt):
eng = load_vision_engine(ckpt, EngineConfig(device="cpu", strict_window=True, max_batch=3,
model_name="vd-test"))
assert (eng.cfg.strict_window, eng.cfg.max_batch, eng.cfg.model_name) == (True, 3, "vd-test")
def test_server_routes_images(ckpt):
from fastapi.testclient import TestClient
from strands_decider import server
body = {"state": "Checkout page.", "images": [_b64(320, 320, 3)],
"questions": {"signed": {"type": "noul", "instructions": "Is the form signed?"}}}
vision_app = TestClient(server.create_app(ckpt, device="cpu", vision=True))
r = vision_app.post("/v1/systemone", json=body)
assert r.status_code == 200, r.text
assert 0.0 <= r.json()["answers"]["signed"]["noul"] <= 1.0
assert vision_app.get("/health").json()["vision"] is True
text_app = TestClient(server.create_app(ckpt, device="cpu"))
assert text_app.post("/v1/systemone", json=body).status_code == 422