494 lines
17 KiB
Python
494 lines
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Unit tests for accuracy benchmark orchestration."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from omlx.admin.accuracy_benchmark import (
|
|
VALID_BENCHMARKS,
|
|
AccuracyBenchmarkRequest,
|
|
AccuracyBenchmarkRun,
|
|
_accumulated_results,
|
|
add_to_queue,
|
|
cleanup_old_runs,
|
|
create_run,
|
|
get_accumulated_results,
|
|
get_queue_status,
|
|
get_run,
|
|
reset_accumulated_results,
|
|
run_accuracy_benchmark,
|
|
)
|
|
from omlx.model_settings import ModelSettings
|
|
|
|
|
|
class TestAccuracyBenchmarkRequest:
|
|
def test_valid_request(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 300, "gsm8k": 100},
|
|
)
|
|
assert req.model_id == "test-model"
|
|
assert "mmlu" in req.benchmarks
|
|
assert req.benchmarks["gsm8k"] == 100
|
|
|
|
def test_full_dataset_size_zero(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 0},
|
|
)
|
|
assert req.benchmarks["mmlu"] == 0
|
|
|
|
def test_empty_benchmarks_rejected(self):
|
|
with pytest.raises(Exception):
|
|
AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={},
|
|
)
|
|
|
|
def test_invalid_benchmark_rejected(self):
|
|
with pytest.raises(Exception):
|
|
AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"invalid_bench": 100},
|
|
)
|
|
|
|
def test_all_valid_benchmarks(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={b: 100 for b in VALID_BENCHMARKS},
|
|
)
|
|
assert len(req.benchmarks) == len(VALID_BENCHMARKS)
|
|
|
|
def test_enable_thinking_default_false(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
assert req.enable_thinking is False
|
|
|
|
def test_enable_thinking_true(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
enable_thinking=True,
|
|
)
|
|
assert req.enable_thinking is True
|
|
|
|
def test_sampling_profile_default_deterministic(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
assert req.sampling_profile == "deterministic"
|
|
|
|
def test_sampling_profile_model_settings_accepted(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
sampling_profile="model_settings",
|
|
)
|
|
assert req.sampling_profile == "model_settings"
|
|
|
|
def test_sampling_profile_invalid_rejected(self):
|
|
with pytest.raises(Exception):
|
|
AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
sampling_profile="wild",
|
|
)
|
|
|
|
|
|
class TestQueueAndResults:
|
|
def setup_method(self):
|
|
from omlx.admin.accuracy_benchmark import _queue
|
|
_queue.clear()
|
|
reset_accumulated_results()
|
|
|
|
def test_add_to_queue(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="model-a",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
add_to_queue(req)
|
|
status = get_queue_status()
|
|
assert len(status["queue"]) == 1
|
|
assert status["queue"][0]["model_id"] == "model-a"
|
|
|
|
def test_queue_status_empty(self):
|
|
status = get_queue_status()
|
|
assert status["running"] is False
|
|
assert len(status["queue"]) == 0
|
|
|
|
def test_accumulated_results(self):
|
|
_accumulated_results.append({"model_id": "m1", "benchmark": "mmlu", "accuracy": 0.5})
|
|
results = get_accumulated_results()
|
|
assert len(results) == 1
|
|
assert results[0]["model_id"] == "m1"
|
|
|
|
def test_reset_accumulated_results(self):
|
|
_accumulated_results.append({"model_id": "m1", "benchmark": "mmlu", "accuracy": 0.5})
|
|
reset_accumulated_results()
|
|
assert len(get_accumulated_results()) == 0
|
|
|
|
|
|
class TestRunLifecycle:
|
|
def setup_method(self):
|
|
from omlx.admin.accuracy_benchmark import _accuracy_runs
|
|
_accuracy_runs.clear()
|
|
|
|
def test_create_run(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run = create_run(req)
|
|
assert run.bench_id is not None
|
|
assert run.status == "running"
|
|
assert run.request == req
|
|
|
|
def test_get_run(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run = create_run(req)
|
|
found = get_run(run.bench_id)
|
|
assert found is run
|
|
|
|
def test_get_run_not_found(self):
|
|
assert get_run("nonexistent") is None
|
|
|
|
def test_cleanup_old_runs(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run1 = create_run(req)
|
|
run2 = create_run(req)
|
|
run1.status = "completed"
|
|
run2.status = "running"
|
|
|
|
cleanup_old_runs()
|
|
|
|
assert get_run(run1.bench_id) is None
|
|
assert get_run(run2.bench_id) is run2
|
|
|
|
def test_cleanup_error_runs(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run = create_run(req)
|
|
run.status = "error"
|
|
|
|
cleanup_old_runs()
|
|
assert get_run(run.bench_id) is None
|
|
|
|
|
|
class TestRunAccuracyBenchmark:
|
|
@pytest.mark.asyncio
|
|
async def test_sends_done_event(self):
|
|
"""Verify that a successful run sends a done event."""
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run = create_run(req)
|
|
|
|
# Mock engine_pool
|
|
mock_engine = AsyncMock()
|
|
mock_engine.chat = AsyncMock(return_value=MagicMock(text="A"))
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_loaded_model_ids = MagicMock(return_value=[])
|
|
mock_pool.get_engine = AsyncMock(return_value=mock_engine)
|
|
mock_pool._unload_engine = AsyncMock()
|
|
|
|
# Mock evaluator
|
|
mock_result = MagicMock()
|
|
mock_result.benchmark_name = "mmlu"
|
|
mock_result.accuracy = 0.75
|
|
mock_result.total_questions = 4
|
|
mock_result.correct_count = 3
|
|
mock_result.time_seconds = 1.0
|
|
mock_result.category_scores = None
|
|
mock_result.thinking_used = False
|
|
|
|
mock_evaluator = MagicMock()
|
|
mock_evaluator.load_dataset = AsyncMock(return_value=[{"id": "1"}])
|
|
mock_evaluator.run = AsyncMock(return_value=mock_result)
|
|
|
|
mock_bench_cls = MagicMock(return_value=mock_evaluator)
|
|
|
|
with patch.dict("omlx.eval.BENCHMARKS", {"mmlu": mock_bench_cls}, clear=True):
|
|
await run_accuracy_benchmark(run, mock_pool)
|
|
|
|
# Collect all events from the replay log.
|
|
events = list(run.events)
|
|
|
|
event_types = [e["type"] for e in events]
|
|
assert "done" in event_types
|
|
assert run.status == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation(self):
|
|
"""Verify that cancelling stops the run."""
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 100},
|
|
)
|
|
run = create_run(req)
|
|
run.status = "cancelled" # Pre-cancel
|
|
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_loaded_model_ids = MagicMock(return_value=[])
|
|
mock_pool.get_engine = AsyncMock(return_value=MagicMock())
|
|
mock_pool._unload_engine = AsyncMock()
|
|
|
|
mock_evaluator = MagicMock()
|
|
mock_evaluator.load_dataset = AsyncMock(return_value=[])
|
|
mock_evaluator.run = AsyncMock(return_value=MagicMock(
|
|
benchmark_name="mmlu",
|
|
accuracy=0.0,
|
|
total_questions=0,
|
|
correct_count=0,
|
|
time_seconds=0.0,
|
|
category_scores=None,
|
|
))
|
|
|
|
mock_bench_cls = MagicMock(return_value=mock_evaluator)
|
|
|
|
with patch.dict("omlx.eval.BENCHMARKS", {"mmlu": mock_bench_cls}):
|
|
await run_accuracy_benchmark(run, mock_pool)
|
|
|
|
# Should have stopped early
|
|
assert len(run.results) == 0
|
|
|
|
|
|
class TestSamplingProfile:
|
|
"""sampling_profile gates whether per-model sampling reaches the evaluator.
|
|
|
|
Default "deterministic" must read nothing (reproducible greedy scores);
|
|
"model_settings" must forward the model's configured sampling. See #606 /
|
|
the #1254 deterministic-default request.
|
|
"""
|
|
|
|
def _mock_pool(self, model_settings):
|
|
mock_engine = AsyncMock()
|
|
mock_engine.chat = AsyncMock(return_value=MagicMock(text="A"))
|
|
mock_pool = MagicMock()
|
|
mock_pool.get_loaded_model_ids = MagicMock(return_value=[])
|
|
mock_pool.get_engine = AsyncMock(return_value=mock_engine)
|
|
mock_pool._unload_engine = AsyncMock()
|
|
mock_pool._settings_manager.get_settings = MagicMock(return_value=model_settings)
|
|
return mock_pool
|
|
|
|
async def _captured_sampling_kwargs(self, req, mock_pool):
|
|
run = create_run(req)
|
|
mock_result = MagicMock(
|
|
benchmark_name="mmlu", accuracy=0.5, total_questions=1,
|
|
correct_count=1, time_seconds=0.1, category_scores=None,
|
|
thinking_used=False,
|
|
)
|
|
mock_evaluator = MagicMock()
|
|
mock_evaluator.load_dataset = AsyncMock(return_value=[{"id": "1"}])
|
|
mock_evaluator.run = AsyncMock(return_value=mock_result)
|
|
mock_bench_cls = MagicMock(return_value=mock_evaluator)
|
|
with patch.dict("omlx.eval.BENCHMARKS", {"mmlu": mock_bench_cls}, clear=True):
|
|
await run_accuracy_benchmark(run, mock_pool)
|
|
return mock_evaluator.run.call_args.kwargs["sampling_kwargs"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deterministic_ignores_model_settings(self):
|
|
# Default profile is "deterministic".
|
|
req = AccuracyBenchmarkRequest(model_id="test-model", benchmarks={"mmlu": 1})
|
|
mock_pool = self._mock_pool(ModelSettings(temperature=0.9, top_p=0.95))
|
|
assert await self._captured_sampling_kwargs(req, mock_pool) == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_model_settings_forwards_sampling(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="test-model",
|
|
benchmarks={"mmlu": 1},
|
|
sampling_profile="model_settings",
|
|
)
|
|
mock_pool = self._mock_pool(ModelSettings(temperature=0.9, top_p=0.95))
|
|
sampling_kwargs = await self._captured_sampling_kwargs(req, mock_pool)
|
|
assert sampling_kwargs["temperature"] == 0.9
|
|
assert sampling_kwargs["top_p"] == 0.95
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deterministic_keeps_chat_template_kwargs(self):
|
|
# Template kwargs are prompt construction, not sampling — forwarded
|
|
# even under the deterministic profile.
|
|
req = AccuracyBenchmarkRequest(model_id="test-model", benchmarks={"mmlu": 1})
|
|
mock_pool = self._mock_pool(
|
|
ModelSettings(temperature=0.9, chat_template_kwargs={"custom_flag": True})
|
|
)
|
|
sampling_kwargs = await self._captured_sampling_kwargs(req, mock_pool)
|
|
assert sampling_kwargs == {"chat_template_kwargs": {"custom_flag": True}}
|
|
|
|
|
|
# =============================================================================
|
|
# External endpoint accuracy benchmark tests
|
|
# =============================================================================
|
|
|
|
|
|
def _external_dict():
|
|
return {
|
|
"base_url": "http://localhost:8001/v1",
|
|
"api_key": "sk-test",
|
|
"model": "remote-model",
|
|
}
|
|
|
|
|
|
class TestExternalAccuracyRequest:
|
|
def test_external_accepted(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="remote-model",
|
|
benchmarks={"mmlu": 100},
|
|
external=_external_dict(),
|
|
)
|
|
assert req.external is not None
|
|
assert req.external.model == "remote-model"
|
|
|
|
def test_external_forces_thinking_off(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="remote-model",
|
|
benchmarks={"mmlu": 100},
|
|
enable_thinking=True,
|
|
external=_external_dict(),
|
|
)
|
|
assert req.enable_thinking is False
|
|
|
|
def test_local_keeps_thinking(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="local-model",
|
|
benchmarks={"mmlu": 100},
|
|
enable_thinking=True,
|
|
)
|
|
assert req.enable_thinking is True
|
|
|
|
def test_queue_status_flags_external(self):
|
|
req = AccuracyBenchmarkRequest(
|
|
model_id="remote-model",
|
|
benchmarks={"mmlu": 100},
|
|
external=_external_dict(),
|
|
)
|
|
add_to_queue(req)
|
|
try:
|
|
entry = get_queue_status()["queue"][-1]
|
|
assert entry["external"] is True
|
|
finally:
|
|
from omlx.admin.accuracy_benchmark import _queue
|
|
|
|
_queue.clear()
|
|
|
|
|
|
class TestExternalAccuracyRun:
|
|
def _mock_result(self):
|
|
return MagicMock(
|
|
benchmark_name="mmlu",
|
|
accuracy=0.5,
|
|
total_questions=2,
|
|
correct_count=1,
|
|
time_seconds=0.1,
|
|
category_scores=None,
|
|
thinking_used=False,
|
|
question_results=[],
|
|
)
|
|
|
|
def _mock_evaluator(self):
|
|
mock_evaluator = MagicMock()
|
|
mock_evaluator.load_dataset = AsyncMock(return_value=[{"id": "1"}])
|
|
mock_evaluator.run = AsyncMock(return_value=self._mock_result())
|
|
return mock_evaluator
|
|
|
|
def _external_request(self):
|
|
return AccuracyBenchmarkRequest(
|
|
model_id="remote-model",
|
|
benchmarks={"mmlu": 100},
|
|
batch_size=4,
|
|
external=_external_dict(),
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_run_uses_adapter_and_skips_pool(self):
|
|
run = create_run(self._external_request())
|
|
mock_pool = MagicMock()
|
|
mock_evaluator = self._mock_evaluator()
|
|
mock_bench_cls = MagicMock(return_value=mock_evaluator)
|
|
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.preflight = AsyncMock()
|
|
mock_client = MagicMock()
|
|
mock_client.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch.dict("omlx.eval.BENCHMARKS", {"mmlu": mock_bench_cls}, clear=True),
|
|
patch(
|
|
"omlx.admin.accuracy_benchmark.ExternalAPIClient",
|
|
return_value=mock_client,
|
|
),
|
|
patch(
|
|
"omlx.admin.accuracy_benchmark.ExternalChatAdapter",
|
|
return_value=mock_adapter,
|
|
) as adapter_cls,
|
|
):
|
|
await run_accuracy_benchmark(run, mock_pool)
|
|
|
|
assert run.status == "completed"
|
|
mock_pool.get_engine.assert_not_called()
|
|
mock_pool.get_loaded_model_ids.assert_not_called()
|
|
mock_pool._unload_engine.assert_not_called()
|
|
mock_adapter.preflight.assert_awaited_once()
|
|
adapter_cls.assert_called_once_with(mock_client, "deterministic")
|
|
# Evaluator got the adapter, empty sampling kwargs, thinking off
|
|
call = mock_evaluator.run.call_args
|
|
assert call.args[0] is mock_adapter
|
|
assert call.kwargs["sampling_kwargs"] == {}
|
|
assert call.kwargs["enable_thinking"] is False
|
|
assert call.kwargs["batch_size"] == 4
|
|
# Result carries the external flag and the remote model name
|
|
assert run.results[0]["external"] is True
|
|
assert run.results[0]["model_id"] == "remote-model"
|
|
mock_client.aclose.assert_awaited()
|
|
# Clean up accumulated results this test appended
|
|
reset_accumulated_results()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_preflight_failure_emits_error(self):
|
|
from omlx.admin.external_api import ExternalEndpointError
|
|
|
|
run = create_run(self._external_request())
|
|
mock_pool = MagicMock()
|
|
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.preflight = AsyncMock(
|
|
side_effect=ExternalEndpointError(
|
|
"External endpoint rejected the API key (HTTP 401)"
|
|
)
|
|
)
|
|
mock_client = MagicMock()
|
|
mock_client.aclose = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"omlx.admin.accuracy_benchmark.ExternalAPIClient",
|
|
return_value=mock_client,
|
|
),
|
|
patch(
|
|
"omlx.admin.accuracy_benchmark.ExternalChatAdapter",
|
|
return_value=mock_adapter,
|
|
),
|
|
):
|
|
await run_accuracy_benchmark(run, mock_pool)
|
|
|
|
assert run.status == "error"
|
|
assert "rejected the API key" in run.error_message
|
|
error_events = [e for e in run.events if e["type"] == "error"]
|
|
assert error_events
|
|
mock_client.aclose.assert_awaited()
|