ReMe/tests/unit/test_mobilemem_benchmark.py
WQS 363e497ae9
Some checks are pending
CI / Python packages / Build and verify distributions (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python quality / GitHub Actions (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Python tests / Unit Tests - py3.14 (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
CI and Release / Docker / Build and test / amd64 (push) Waiting to run
CI and Release / Docker / Build and test / arm64 (push) Waiting to run
CI and Release / Docker / Publish multi-platform tags (push) Blocked by required conditions
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
feat: add ReMe Image evaluation on MobileMem-Omni (#641)
* feat(benchmark): add MobileMem reproduction

* fix(benchmark): declare Pillow for MobileMem images

* fix(benchmark): preserve MobileMem usage and failure diagnostics

* fix(benchmark): redact private keys in MobileMem diagnostics

* fix(mobilemem): apply image defaults and consolidate answer receipts

* fix(mobilemem): preserve response schemas during diagnostic redaction

* fix(mobilemem): preserve judge parameter names in snapshots

* refactor(mobilemem): share task cleanup and use persisted results

---------

Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
2026-10-26 15:20:23 +08:00

407 lines
19 KiB
Python

"""Focused offline benchmark contracts; no external services or experiment writes."""
# pylint: disable=missing-function-docstring,protected-access
import base64
from importlib import import_module
from importlib.metadata import EntryPoint
from io import BytesIO
import json
from pathlib import Path
import tomllib
from types import SimpleNamespace
from unittest.mock import AsyncMock
from agentscope.formatter import OpenAIChatFormatter
from agentscope.message import AssistantMsg, Base64Source, DataBlock, Msg, TextBlock, ToolResultBlock, ToolResultState
from PIL import Image
import pytest
from reme.components import R
from reme.components.agent_wrapper.as_agent_wrapper import AsAgentWrapper
from reme.components.component_registry import create_application_registry
from reme.components.file_store import LocalFileStore
from reme.plugin import PluginManager, _load_plugin
from reme.schema import ApplicationConfig, Response
from reme.steps.evolve.auto_memory import _sanitize_msg_for_save
from reme.steps.file_io.read_image import ReadImageStep
from benchmark.mobilemem import run as mobilemem
def test_candidate_declares_required_image_dependency():
manifest = Path(__file__).resolve().parents[2] / "plugins/mobilemem/pyproject.toml"
project = tomllib.loads(manifest.read_text(encoding="utf-8"))["project"]
assert "pillow>=10.0.0" in project["dependencies"]
@pytest.fixture(name="mobile_scene")
def fixture_mobile_scene(tmp_path):
folder = tmp_path / "image/uid0/camera_photos"
folder.mkdir(parents=True)
Image.new("RGB", (3, 2), (12, 34, 56)).save(folder / "photo.png")
sessions = [
{
"session_id": f"0_{index}",
"event_start_time": f"2025-0{index}-01 09:00:00",
"event_end_time": f"2025-0{index}-01 10:00:00",
"dialogue": [
{"turn": 1, "role": "user", "content_type": "text", "content": f"Original turn {index}"},
{"turn": 2, "role": "user", "content_type": "image", "image_inline": "image/uid0/event/photo.png"},
{"turn": 3, "role": "assistant", "content_type": "text", "content": "Original reply"},
],
"dialogue_summary": "HIDDEN_SUMMARY",
"memory_points": [{"memory_content": "HIDDEN_MEMORY"}],
}
for index in (2, 1)
]
question = {
"question_id": "0_q_1",
"question": "What happened?",
"answer": "HIDDEN_GOLD",
"question_format": "open_ended",
"question_type": "visual_reasoning",
"source_session_ids": ["0_1"],
"image_refs": ["HIDDEN_IMAGE"],
"evidence": [{"explanation": "HIDDEN_EVIDENCE"}],
}
(tmp_path / "data.jsonl").write_text(json.dumps({"uuid": 0, "language": "en", "sessions": sessions}) + "\n")
(tmp_path / "questions.jsonl").write_text(json.dumps({"uuid": 0, "questions": [question]}) + "\n")
return tmp_path, mobilemem.load_mobilemem(tmp_path)[0]
def test_mobilemem_complete_history_ids_and_no_gold(mobile_scene):
root, scene = mobile_scene
assert [session["session_id"] for session in scene["sessions"]] == ["0_1", "0_2"]
assert scene["questions"][0]["question_type"] == "visual_reasoning"
converted = mobilemem.session_messages(scene["sessions"][0], scene["image_root"])
assert "HIDDEN" not in json.dumps(converted)
original = root / "image/uid0/camera_photos/photo.png"
before = original.read_bytes()
assert converted[1]["content"][1]["text"] == "[Image ID: image/uid0/event/photo.png]"
assert converted[1]["content"][2]["type"] == "data"
assert base64.b64decode(converted[1]["content"][2]["source"]["data"]) == before
assert original.read_bytes() == before
with pytest.raises(ValueError, match="Invalid MobileMem image path"):
mobilemem.resolve_image(root / "image", "../outside.png", {})
original.unlink()
with pytest.raises(ValueError, match="Missing or ambiguous MobileMem image"):
mobilemem.session_messages(scene["sessions"][0], scene["image_root"])
def test_official_query_and_overlap_metrics():
assert mobilemem.query_prompt("QUESTION", "CONTEXT") == (
"Question:\nQUESTION\nPlease answer the question based on the following memories:\nCONTEXT\n"
+ mobilemem.ANSWER_RULES
)
score = mobilemem.score_answer("北京 北京", "北京")
assert score["f1"] == pytest.approx(2 / 3)
assert score["bleu_1"] == pytest.approx(0.36787944117144233)
@pytest.fixture(name="candidate")
def fixture_candidate(tmp_path, monkeypatch):
name = "mobilemem"
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "plugins" / name / "src"))
monkeypatch.chdir(tmp_path)
memory = import_module(f"reme_{name}.auto_memory")
answer = import_module(f"reme_{name}.agentic_answer")
prefix = "MobileMem"
app = SimpleNamespace(
registry=R,
metadata={},
app_config=ApplicationConfig(workspace_dir=str(tmp_path)),
jobs={},
components={},
)
wrapper = AsAgentWrapper(backend="agentscope", as_llm="", app_context=app)
wrapper.reply = AsyncMock(return_value={"result": "answer", "session_id": "new", "usage": {"input_tokens": 7}})
store = LocalFileStore(embedding_store="", app_context=app)
return name, memory, answer, prefix, app, wrapper, store, tmp_path
def test_candidate_registration_is_application_local(candidate):
name, memory, answer, prefix, *_ = candidate
plugin = _load_plugin(name, EntryPoint(name=name, value=f"reme_{name}", group="reme.plugins"))
registry = create_application_registry()
PluginManager([plugin]).register(registry)
assert registry.get("step", f"{name}_auto_memory_step") is getattr(memory, f"{prefix}AutoMemoryStep")
assert registry.get("step", f"{name}_agentic_answer_step") is getattr(answer, f"{prefix}AgenticAnswerStep")
@pytest.mark.asyncio
async def test_candidate_and_judge_start_with_native_settings(tmp_path, monkeypatch, mobile_scene):
from reme import Application
root = Path(__file__).resolve().parents[2] / "plugins"
for package in ("mobilemem", "mobilemem-judge"):
monkeypatch.syspath_prepend(str(root / package / "src"))
entries = [
EntryPoint(name="mobilemem", value="reme_mobilemem", group="reme.plugins"),
EntryPoint(name="mobilemem-judge", value="judge_mobilemem", group="reme.plugins"),
EntryPoint(name="mobilemem-benchmark", value="reme_mobilemem:BENCHMARK_CONFIG", group="reme.configs"),
]
def installed(group, name):
return [entry for entry in entries if entry.group == group and entry.name == name]
monkeypatch.setattr("reme.plugin.find_entry_points", installed)
monkeypatch.setattr("reme.config.config_parser.find_entry_points", installed)
monkeypatch.setenv("LLM_API_KEY", "offline")
monkeypatch.setenv("LLM_BASE_URL", "https://provider.invalid/v1")
monkeypatch.setenv("JUDGE_MODEL_NAME", "offline-judge-override")
candidate_app = Application(**mobilemem.candidate_config(tmp_path / "candidate", "candidate-model"))
judge_app = Application(**mobilemem.judge_config(tmp_path / "judge"))
try:
await candidate_app.start()
await judge_app.start()
assert set(judge_app.context.jobs) == {"answer_judge"}
assert set(judge_app.context.components) == {"as_llm"}
audit = mobilemem.judge_settings(judge_app)
assert audit["model"] == "offline-judge-override"
assert audit["parameters"] == {"max_tokens": 4096, "temperature": 0, "thinking_enable": False}
with monkeypatch.context() as environment:
for field in ("temperature", "max_tokens", "thinking_enable"):
environment.setenv("TEST_PRIVATE_KEY", field)
assert mobilemem.judge_settings(judge_app)["parameters"] == audit["parameters"]
assert audit["protocol"] == "mobilemem-omni-membase-default-judge-v1"
assert "credential" not in json.dumps(audit) and "provider.invalid" not in json.dumps(audit)
models = candidate_app.context.components["as_llm"]
for component in models.values():
assert component.kwargs["model"] == "candidate-model"
assert component.kwargs["stream"] is False and component.kwargs["context_size"] == 1000000
assert component.kwargs["parameters"] == {"max_tokens": 65536, "thinking_enable": True, "temperature": 0}
assert component.kwargs["max_retries"] == 3 and component.kwargs["retry_delay"] == 60
assert component.kwargs["client_kwargs"] == {"timeout": 600, "max_retries": 0}
assert candidate_app.config.components["file_store"]["default"].model_dump()["embedding_store"] is None
assert candidate_app.config.jobs["index_update"].model_dump()["watch_dirs"] == [
"daily_dir",
"digest_dir",
"session_dir/dialog",
]
from reme_mobilemem.auto_memory import MobileMemAutoMemoryStep
monkeypatch.setattr(MobileMemAutoMemoryStep, "_list_session_note", AsyncMock(return_value=None))
wrappers = candidate_app.context.components["agent_wrapper"]
for wrapper in wrappers.values():
wrapper.reply = AsyncMock(return_value={"result": "answer"})
_, scene = mobile_scene
messages = mobilemem.session_messages(scene["sessions"][0], scene["image_root"])
for images, kwargs in ((True, {}), (False, {"include_images": False})):
answer = await candidate_app.run_job("agentic_answer", query="Question", **kwargs)
assert answer.success and answer.metadata["include_images"] is images
toolkit = wrappers["bench"].reply.call_args.kwargs["toolkit"]
assert ((await toolkit.get_tool("read_image")) is not None) is images
memory = await candidate_app.run_job("auto_memory", messages=messages, session_id=f"s-{images}", **kwargs)
assert memory.success
assert isinstance(wrappers["default"].reply.call_args.args[0], Msg) is images
assert (tmp_path / "candidate/session/images" / f"s-{images}").exists() is images
finally:
await judge_app.close()
await candidate_app.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("sources", [True, False])
async def test_memory_saves_originals_and_native_image_links(candidate, sources):
_, memory, _, prefix, app, wrapper, store, workspace = candidate
step = getattr(memory, f"{prefix}AutoMemoryStep")(app_context=app, agent_wrapper=wrapper, file_store=store)
step._list_session_note = AsyncMock(return_value=None)
picture = BytesIO()
Image.new("RGB", (1, 1), (12, 34, 56)).save(picture, format="PNG")
image_bytes = picture.getvalue()
image_data = base64.b64encode(image_bytes).decode()
late = Msg.model_validate(
{
"id": "late",
"name": "Alice",
"role": "user",
"created_at": "2026-01-02T10:00:00",
"content": [
TextBlock(text="Image ID: image-7"),
DataBlock(source=Base64Source(media_type="image/png", data=image_data)),
TextBlock(text="Before.\u2028\u2028After.\u2029End.\u0085Done."),
],
},
)
early = late.model_copy(update={"id": "early", "created_at": "2026-01-01T10:00:00"}, deep=True)
before = late.model_dump()
await step(messages=[late, early], session_id="one", include_images=True, include_source_sessions=sources)
saved = workspace / "session/dialog/one.jsonl"
assert saved.read_text() == "".join(_sanitize_msg_for_save(msg).model_dump_json() + "\n" for msg in [early, late])
inputs = wrapper.reply.call_args.args[0]
assert [block.type for block in inputs.content] == ["text", "data", "text", "data", "text"]
assert [block.source.data for block in inputs.content if isinstance(block, DataBlock)] == [image_data] * 2
text = inputs.get_text_content()
assert "Image ID: image-7" in text and "Before.\u2028\u2028After.\u2029End.\u0085Done." in text
assert ("[L2 | Alice" in text) is sources
files = sorted((workspace / "session/images/one").glob("*"))
assert len(files) == 2 and all(path.read_bytes() == image_bytes for path in files)
assert all(f"[[{path.relative_to(workspace).as_posix()}]]" in text for path in files)
assert late.model_dump() == before
@pytest.mark.asyncio
async def test_read_image_returns_real_formatter_image_and_rejects_escape(candidate):
_, _, answer, prefix, app, wrapper, store, workspace = candidate
path = workspace / "session/images/one/photo.png"
path.parent.mkdir(parents=True)
Image.new("RGB", (2, 2), (12, 34, 56)).save(path)
async def native_read(**kwargs):
step = ReadImageStep(app_context=app)
await step(**kwargs)
return step.context.response
app.jobs["read_image"] = AsyncMock(side_effect=native_read)
step = getattr(answer, f"{prefix}AgenticAnswerStep")(app_context=app, agent_wrapper=wrapper, file_store=store)
tool = answer.image_reader(step)
result = await tool.call(path="session/images/one/photo.png")
assert result.state == ToolResultState.SUCCESS and isinstance(result.content[1], DataBlock)
assert base64.b64decode(result.content[1].source.data) == path.read_bytes()
formatted = await OpenAIChatFormatter().format(
[
AssistantMsg(name="tool", content=[ToolResultBlock(id="call", name="read_image", output=result.content)]),
],
)
assert any(
block["type"] == "image_url"
for msg in formatted
if isinstance(msg.get("content"), list)
for block in msg["content"]
)
assert (await tool.call(path="../other-scene.png")).state == ToolResultState.ERROR
assert (await tool.call(path="mem_session/private.png")).state == ToolResultState.ERROR
app.jobs["read_image"].assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("sources", [True, False])
async def test_answer_has_fresh_state_bm25_scope_and_no_gold(candidate, sources):
_, _, answer, prefix, app, wrapper, store, workspace = candidate
for relative in ["daily/note.md", "digest/note.md", "session/dialog/one.jsonl", "mem_session/private.md"]:
path = workspace / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("evidence")
assert answer.evidence_paths(workspace, "daily", "digest", "session", False) == ["daily/note.md", "digest/note.md"]
calls = []
async def search(**kwargs):
calls.append(kwargs)
return Response(answer="evidence")
job = AsyncMock(side_effect=search)
job.name, job.description, job.parameters = (
"search",
"search",
{
"type": "object",
"properties": {"query": {"type": "string"}},
},
)
read = AsyncMock(return_value=Response(answer="evidence"))
read.name, read.description, read.parameters = "read", "read", {"type": "object", "properties": {}}
app.jobs = {"search": job, "read": read}
step = getattr(answer, f"{prefix}AgenticAnswerStep")(app_context=app, agent_wrapper=wrapper, file_store=store)
sessions, contexts = [], []
for _ in range(2):
response = await step(query="Official question", include_source_sessions=sources)
options = wrapper.reply.call_args.kwargs
assert wrapper.reply.call_args.args[0].get_text_content() == "Official question"
assert options["resume"] == "" and options["builtin_tools"] is False and options["job_tools"] == []
assert options["react_config"] == {"max_iters": 30}
sessions.append(options["session_id"])
await (await options["toolkit"].get_tool("search")).call(query="evidence")
assert calls[-1]["vector_weight"] == 0.0
assert ("session/dialog/one.jsonl" in calls[-1]["search_filter"]["paths"]) is sources
assert "mem_session/private.md" not in calls[-1]["_allowed_paths"]
contexts.append(calls[-1]["tool_context_id"])
assert response.metadata["usage"] == {"input_tokens": 7}
assert len(set(sessions)) == len(set(contexts)) == 2
@pytest.mark.asyncio
async def test_image_only_pipeline_keeps_all_sessions_and_gold_is_judge_only(mobile_scene, tmp_path, monkeypatch):
from benchmark.mobilemem.run import normalized_usage, observe_completion
_, scene = mobile_scene
calls, apps = [], []
class Candidate:
"""Offline Application stand-in preserving the native Job call boundary."""
def __init__(self, **config):
self.config = config
self.closed = False
self.context = SimpleNamespace(
components={
"agent_wrapper": {name: SimpleNamespace(session_path=tmp_path) for name in ("default", "bench")},
},
)
apps.append(self)
async def start(self):
pass
async def close(self):
self.closed = True
async def run_job(self, name, **kwargs):
calls.append((name, kwargs))
return Response(answer="Retained answer", metadata={"agent_session_id": "new-agent"})
judge = SimpleNamespace(
run_job=AsyncMock(
return_value=Response(
metadata={"llm_judge_score": 1, "parse_default_zero": False},
),
),
)
monkeypatch.setattr(mobilemem, "Application", Candidate)
def configured(workspace, model):
return {"workspace": workspace, "model": model}
monkeypatch.setattr(mobilemem, "candidate_config", configured)
monkeypatch.setattr(mobilemem, "observe_usage", lambda _: None)
monkeypatch.setattr(mobilemem, "observe_completion", lambda _: None)
await mobilemem.evaluate_user(
scene,
tmp_path / "new-user",
"candidate",
judge,
__import__("asyncio").Semaphore(2),
)
assert [name for name, _ in calls] == ["auto_memory", "auto_memory", "index_update", "agentic_answer"]
assert [kw["session_id"] for name, kw in calls if name == "auto_memory"] == ["0_1", "0_2"]
for name, kwargs in calls:
assert "HIDDEN" not in json.dumps(kwargs)
if name in {"auto_memory", "agentic_answer"}:
assert kwargs["include_images"] is kwargs["include_source_sessions"] is True
answer = calls[-1][1]
assert answer["max_iters"] == 30
assert answer["query"] == mobilemem.query_prompt(
scene["questions"][0]["question"],
"Use the available evidence tools.",
)
assert "category" not in answer and "question_type" not in answer
judge_inputs = judge.run_job.call_args.kwargs
assert judge_inputs["reference"] == "HIDDEN_GOLD"
assert judge_inputs["evidence"] == ["HIDDEN_EVIDENCE"]
result = json.loads((tmp_path / "new-user/questions/0000/result.json").read_text(encoding="utf-8"))
assert result["metrics"]["llm_judge"] == 1 and apps[0].closed
assert normalized_usage({"prompt_tokens": 100, "completion_tokens": 20})["cached_input_tokens"] is None
assert (
normalized_usage({"prompt_tokens": 100, "prompt_tokens_details": {"cached_tokens": 30}})[
"noncached_input_tokens"
]
== 70
)
raw = {"last_message": {"finished_reason": "exceed_max_iters"}, "result": "native answer"}
native = AsyncMock(return_value=raw)
wrapper = SimpleNamespace(reply=native)
observe_completion(SimpleNamespace(context=SimpleNamespace(components={"agent_wrapper": {"bench": wrapper}})))
assert await wrapper.reply("unchanged input", max_iters=30) is raw
native.assert_awaited_once_with("unchanged input", max_iters=30)
native.return_value = {"last_message": {"finished_reason": "interrupted"}}
with pytest.raises(RuntimeError, match="interrupted"):
await wrapper.reply("unchanged input")