"""Tests for RLVR (RL from Verifiable Rewards) — Part C of v0.25.0.""" import pytest # --------------------------------------------------------------------------- # Math verification reward # --------------------------------------------------------------------------- class TestMathVerifyReward: def test_exact_numeric_match(self): from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "The answer is #### 42"}], [{"role": "assistant", "content": "\\boxed{3.14}"}], ] rewards = math_verify_reward(completions, answer=["42", "3.14"]) assert rewards == [1.0, 1.0] def test_tolerance_exact_match_to_fourth_decimal(self): from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "#### 3.14159"}], ] # Difference = 1e-5, well below default 1e-4 tolerance → full credit rewards = math_verify_reward(completions, answer=["3.14160"]) assert rewards[0] == 1.0 def test_tolerance_partial_credit(self): from soup_cli.trainer.rewards import math_verify_reward # Difference = 0.001, outside 1e-4 tolerance but inside 1e-2 → 0.6 completions = [ [{"role": "assistant", "content": "#### 3.141"}], ] rewards = math_verify_reward(completions, answer=["3.142"]) assert rewards[0] == 0.6 def test_tolerance_far_miss(self): from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "#### 3.0"}], ] rewards = math_verify_reward(completions, answer=["3.14159"]) assert rewards[0] == 0.0 def test_wrong_answer(self): from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "#### 100"}], ] rewards = math_verify_reward(completions, answer=["42"]) assert rewards[0] == 0.0 def test_no_answer_extraction(self): from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "no answer here"}], ] rewards = math_verify_reward(completions, answer=["42"]) assert rewards[0] == 0.0 def test_empty_completions(self): from soup_cli.trainer.rewards import math_verify_reward assert math_verify_reward([], answer=[]) == [] def test_no_eval_on_user_content(self): """Security: math_verify must never call eval() or exec() on model output.""" from soup_cli.trainer.rewards import math_verify_reward completions = [ [{"role": "assistant", "content": "#### __import__('os').system('rm')"}], ] # Should not crash, should not match a numeric answer rewards = math_verify_reward(completions, answer=["42"]) assert rewards[0] == 0.0 # --------------------------------------------------------------------------- # Code execution reward # --------------------------------------------------------------------------- class TestCodeExecReward: def test_correct_code(self): from soup_cli.trainer.rewards import code_exec_reward completions = [ [{"role": "assistant", "content": "print(2 + 2)"}], ] rewards = code_exec_reward(completions, expected=["4"]) assert rewards[0] == 1.0 def test_wrong_output(self): from soup_cli.trainer.rewards import code_exec_reward completions = [ [{"role": "assistant", "content": "print(1 + 1)"}], ] rewards = code_exec_reward(completions, expected=["4"]) assert rewards[0] == 0.0 def test_code_extracted_from_markdown(self): from soup_cli.trainer.rewards import code_exec_reward completions = [ [{"role": "assistant", "content": "```python\nprint(5 * 5)\n```"}], ] rewards = code_exec_reward(completions, expected=["25"]) assert rewards[0] == 1.0 def test_infinite_loop_caught_by_timeout(self): from soup_cli.trainer.rewards import code_exec_reward completions = [ [{"role": "assistant", "content": "while True: pass"}], ] rewards = code_exec_reward(completions, expected=["4"]) assert rewards[0] == 0.0 def test_network_blocked(self): """Security: code_exec must prevent network access.""" from soup_cli.trainer.rewards import code_exec_reward completions = [ [{ "role": "assistant", "content": "import urllib.request\nprint(urllib.request.urlopen('http://example.com'))", }], ] rewards = code_exec_reward(completions, expected=["hello"]) assert rewards[0] == 0.0 def test_output_size_capped(self): """Security: output capped at 10KB.""" from soup_cli.trainer.rewards import MAX_CODE_OUTPUT_BYTES, code_exec_reward # Generate output bigger than cap completions = [ [{"role": "assistant", "content": "print('x' * 50000)"}], ] rewards = code_exec_reward(completions, expected=["x" * 50000]) # Reward is 0 since truncated output won't match assert rewards[0] == 0.0 assert MAX_CODE_OUTPUT_BYTES == 10_000 # --------------------------------------------------------------------------- # JSON schema reward # --------------------------------------------------------------------------- class TestJsonSchemaReward: def test_valid_matching_schema(self): from soup_cli.trainer.rewards import json_schema_reward completions = [ [{"role": "assistant", "content": '{"name": "alice", "age": 30}'}], ] schemas = [{ "type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "age"], }] rewards = json_schema_reward(completions, schema=schemas) assert rewards[0] == 1.0 def test_missing_required_field(self): from soup_cli.trainer.rewards import json_schema_reward completions = [ [{"role": "assistant", "content": '{"name": "alice"}'}], ] schemas = [{ "type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name", "age"], }] rewards = json_schema_reward(completions, schema=schemas) assert rewards[0] < 1.0 def test_invalid_json(self): from soup_cli.trainer.rewards import json_schema_reward completions = [ [{"role": "assistant", "content": "not json at all"}], ] schemas = [{"type": "object", "properties": {}, "required": []}] rewards = json_schema_reward(completions, schema=schemas) assert rewards[0] == 0.0 def test_wrong_type_for_field(self): """Field present but wrong type counts as missing.""" from soup_cli.trainer.rewards import json_schema_reward completions = [ [{"role": "assistant", "content": '{"name": "alice", "age": "thirty"}'}], ] schemas = [{ "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, "required": ["name", "age"], }] rewards = json_schema_reward(completions, schema=schemas) # name matches (0.5), age wrong type (0.0) — score 0.5 assert rewards[0] == 0.5 def test_integer_field_rejects_bool(self): """bool is not a valid int for JSON schema integer fields.""" from soup_cli.trainer.rewards import json_schema_reward completions = [ [{"role": "assistant", "content": '{"enabled": true}'}], ] schemas = [{ "type": "object", "properties": {"enabled": {"type": "integer"}}, "required": ["enabled"], }] rewards = json_schema_reward(completions, schema=schemas) assert rewards[0] == 0.0 # --------------------------------------------------------------------------- # Verifiable routing via config # --------------------------------------------------------------------------- class TestVerifiableRouting: def test_verifiable_math_loads(self): from soup_cli.trainer.rewards import load_reward_fn fn = load_reward_fn("verifiable", verifiable_domain="math") assert callable(fn) def test_verifiable_code_loads(self): from soup_cli.trainer.rewards import load_reward_fn fn = load_reward_fn("verifiable", verifiable_domain="code") assert callable(fn) def test_verifiable_json_schema_loads(self): from soup_cli.trainer.rewards import load_reward_fn fn = load_reward_fn("verifiable", verifiable_domain="json_schema") assert callable(fn) def test_verifiable_unknown_domain_raises(self): from soup_cli.trainer.rewards import load_reward_fn with pytest.raises(ValueError): load_reward_fn("verifiable", verifiable_domain="unknown") # --------------------------------------------------------------------------- # Config validation # --------------------------------------------------------------------------- class TestVerifiableConfig: def test_verifiable_domain_accepted(self): from soup_cli.config.schema import TrainingConfig cfg = TrainingConfig(reward_fn="verifiable", verifiable_domain="math") assert cfg.verifiable_domain == "math" def test_verifiable_domain_invalid_rejected(self): from pydantic import ValidationError from soup_cli.config.schema import TrainingConfig with pytest.raises(ValidationError): TrainingConfig(reward_fn="verifiable", verifiable_domain="hacker") def test_verifiable_requires_domain(self): """reward_fn=verifiable requires verifiable_domain to be set.""" from pydantic import ValidationError from soup_cli.config.schema import TrainingConfig with pytest.raises(ValidationError): TrainingConfig(reward_fn="verifiable", verifiable_domain=None) # --------------------------------------------------------------------------- # Synth data template # --------------------------------------------------------------------------- class TestVerifiableTemplate: def test_build_prompt_math(self): from soup_cli.data.templates.verifiable import build_prompt prompt = build_prompt(count=5, fmt="alpaca", format_spec="{...}", domain="math") assert "5" in prompt assert "math" in prompt.lower() def test_build_prompt_code(self): from soup_cli.data.templates.verifiable import build_prompt prompt = build_prompt(count=3, fmt="alpaca", format_spec="{}", domain="code") assert "3" in prompt def test_build_prompt_json_schema(self): from soup_cli.data.templates.verifiable import build_prompt prompt = build_prompt(count=2, fmt="alpaca", format_spec="{}", domain="json_schema") assert "2" in prompt assert "schema" in prompt.lower() def test_domains_constant(self): from soup_cli.data.templates.verifiable import TEMPLATE_SPEC for key in ("math", "code", "json_schema"): assert key in TEMPLATE_SPEC["domains"] if __name__ == "__main__": pytest.main([__file__, "-v"])