evalstone/evalscope/tests/api/test_task_config_validation.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

244 lines
7.9 KiB
Python

"""Tests for unknown-key rejection and the eval_batch_size default resolution."""
import argparse
import math
from typing import Optional
import pytest
from pydantic import ValidationError
from evalscope.arguments import add_argument
from evalscope.config import (
DEFAULT_API_EVAL_BATCH_SIZE,
DEFAULT_MODEL_ARGS_CHECKPOINT,
REMOTE_API_EVAL_TYPES,
SandboxTaskConfig,
TaskConfig,
parse_task_config,
)
from evalscope.perf.arguments import Arguments
def _cli_config(*argv: str) -> TaskConfig:
parser = argparse.ArgumentParser()
add_argument(parser)
return parse_task_config(parser.parse_args(list(argv)))
def test_hyphenated_key_is_rejected_with_suggestion() -> None:
with pytest.raises(ValueError) as excinfo:
TaskConfig.from_dict({'model': 'x', 'api_url': 'http://localhost:8000/v1', 'eval-batch-size': 10})
message = str(excinfo.value)
assert 'eval-batch-size' in message
assert "'eval-batch-size' -> 'eval_batch_size'" in message
def test_unknown_key_is_rejected_without_suggestion() -> None:
with pytest.raises(ValueError, match='no_such_key_at_all'):
TaskConfig.from_dict({'model': 'x', 'no_such_key_at_all': 1})
def test_unsuggestable_key_keeps_the_pydantic_error_type() -> None:
with pytest.raises(ValidationError):
TaskConfig.from_dict({'model': 'x', 'no_such_key_at_all': 1})
def test_nested_unknown_key_is_not_attributed_to_the_outer_model() -> None:
nested = {'model': 'x', 'judge': {'models': [{'model_id': 'j', 'api_ur': 'oops'}]}}
with pytest.raises(ValidationError) as excinfo:
TaskConfig.from_dict(nested)
message = str(excinfo.value)
assert 'judge.models.0.api_ur' in message
assert 'Did you mean' not in message
def test_perf_arguments_also_reject_unknown_keys() -> None:
with pytest.raises(ValueError, match='parallel'):
Arguments.from_dict({'model': 'x', 'url': 'http://localhost:8000/v1', 'parallell': 4})
def test_legacy_judge_keys_still_migrate() -> None:
legacy = {'model': 'x', 'judge_strategy': 'auto', 'judge_model_args': {'model_id': 'judge-model'}}
config = TaskConfig.from_dict(legacy)
assert [model.model_id for model in config.judge.models] == ['judge-model']
@pytest.mark.parametrize(
'api_url, explicit_batch_size, expected',
[
('http://localhost:8000/v1', None, DEFAULT_API_EVAL_BATCH_SIZE),
(None, None, 1),
('http://localhost:8000/v1', 1, 1),
('http://localhost:8000/v1', 10, 10),
],
)
def test_eval_batch_size_default_depends_on_eval_type(
api_url: Optional[str], explicit_batch_size: Optional[int], expected: int
) -> None:
overrides = {'model': 'x'}
if api_url is not None:
overrides['api_url'] = api_url
if explicit_batch_size is not None:
overrides['eval_batch_size'] = explicit_batch_size
config = TaskConfig.from_dict(overrides)
assert config.eval_batch_size == expected
assert config.generation_config.batch_size == expected
@pytest.mark.parametrize('eval_type', sorted(REMOTE_API_EVAL_TYPES))
def test_every_remote_api_eval_type_gets_the_concurrent_default(eval_type: str) -> None:
config = TaskConfig.from_dict({'model': 'x', 'eval_type': eval_type})
assert config.eval_batch_size == DEFAULT_API_EVAL_BATCH_SIZE
@pytest.mark.parametrize('eval_type', ['llm_ckpt', 'mock_llm', 'text2image'])
def test_local_eval_types_keep_the_serial_default(eval_type: str) -> None:
config = TaskConfig.from_dict({'model': 'x', 'eval_type': eval_type})
assert config.eval_batch_size == 1
@pytest.mark.parametrize('alias, canonical', [('checkpoint', 'llm_ckpt'), ('server', 'openai_api')])
def test_deprecated_eval_type_alias_is_normalized(alias: str, canonical: str) -> None:
config = TaskConfig.from_dict({'model': 'x', 'eval_type': alias})
assert config.eval_type == canonical
def test_server_alias_inherits_the_remote_api_concurrent_default() -> None:
config = TaskConfig.from_dict({'model': 'x', 'eval_type': 'server'})
assert config.eval_batch_size == DEFAULT_API_EVAL_BATCH_SIZE
def test_checkpoint_alias_inherits_the_checkpoint_default_model_args() -> None:
config = TaskConfig.from_dict({'model': '/path/to/model', 'eval_type': 'checkpoint'})
assert config.model_args == DEFAULT_MODEL_ARGS_CHECKPOINT
def test_cli_omitting_the_flag_keeps_the_api_default() -> None:
config = _cli_config('--model', 'x', '--api-url', 'http://localhost:8000/v1', '--datasets', 'gsm8k')
assert config.eval_batch_size == DEFAULT_API_EVAL_BATCH_SIZE
def test_cli_explicit_flag_wins_over_the_api_default() -> None:
config = _cli_config(
'--model', 'x', '--api-url', 'http://localhost:8000/v1', '--datasets', 'gsm8k', '--eval-batch-size', '3'
)
assert config.eval_batch_size == 3
def test_legacy_sandbox_fields_fold_into_nested_sandbox() -> None:
config = TaskConfig.from_dict({'model': 'x', 'sandbox_type': 'volcengine'})
assert config.sandbox is not None
assert config.sandbox.engine == 'volcengine'
def test_nested_sandbox_wins_over_legacy_fields() -> None:
config = TaskConfig.from_dict({
'model': 'x',
'sandbox_type': 'volcengine',
'sandbox': {
'enabled': False,
'engine': 'docker'
},
})
assert config.sandbox.engine == 'docker'
def test_absent_sandbox_defaults_to_disabled() -> None:
config = TaskConfig.from_dict({'model': 'x'})
assert config.sandbox is not None
assert config.sandbox.enabled is False
@pytest.mark.parametrize('falsy', ['false', '0', 0, False])
def test_falsy_use_sandbox_string_does_not_enable_sandbox(falsy: object) -> None:
config = TaskConfig.from_dict({'model': 'x', 'use_sandbox': falsy})
assert config.sandbox.enabled is False
def test_agent_config_is_set_only_on_both_serialization_paths() -> None:
config = TaskConfig.from_dict({'model': 'x', 'api_url': 'u', 'agent_config': {'mode': 'native'}})
assert config.to_dict()['agent_config'] == {'mode': 'native'}
assert config._to_update_dict()['agent_config'] == {'mode': 'native'}
def test_to_dict_round_trip_is_stable() -> None:
config = TaskConfig.from_dict({
'model': 'x',
'api_url': 'u',
'generation_config': {
'temperature': 0.5
},
'agent_config': {
'mode': 'native'
},
})
dumped = config.to_dict()
redumped = TaskConfig.from_dict(dumped).to_dict()
assert dumped == redumped
def test_serialization_paths_render_special_fields_per_purpose() -> None:
config = TaskConfig.from_dict({'model': 'x', 'api_url': 'u', 'generation_config': {'temperature': 0.5}})
yaml_dict = config.to_dict()
update_dict = config._to_update_dict()
assert isinstance(yaml_dict['sandbox'], dict)
assert isinstance(update_dict['sandbox'], SandboxTaskConfig)
assert yaml_dict['generation_config'] == update_dict['generation_config']
def test_update_merges_generation_config_and_recoerces() -> None:
config = TaskConfig.from_dict({'model': 'x', 'api_url': 'u', 'generation_config': {'temperature': 0.5}})
config.update({'generation_config': {'top_p': 0.9}, 'sandbox': {'engine': 'volcengine'}})
merged = config.generation_config.model_dump(exclude_unset=True)
assert merged['temperature'] == 0.5
assert merged['top_p'] == 0.9
assert isinstance(config.sandbox, SandboxTaskConfig)
assert config.sandbox.engine == 'volcengine'
@pytest.mark.parametrize('limit', [2.5, -1, -0.1, math.nan, math.inf])
def test_invalid_limits_are_rejected(limit: float | int) -> None:
with pytest.raises(ValueError, match='limit'):
TaskConfig.from_dict({'model': 'x', 'limit': limit})
@pytest.mark.parametrize(
'limit, expected',
[
(None, None),
(0, None),
(0.5, 0.5),
(1.0, 1),
(2, 2),
],
)
def test_valid_limit_semantics_are_preserved(limit: float | int | None, expected: float | int | None) -> None:
config = TaskConfig.from_dict({'model': 'x', 'limit': limit})
assert config.limit == expected