Spaces:
Sleeping
Sleeping
| """ | |
| tests/test_env.py — NegotiArena Environment Tests | |
| ================================================== | |
| Run: pytest tests/ -v | |
| """ | |
| import json | |
| import sys | |
| import os | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| import numpy as np | |
| import pytest | |
| from negotiarena_env import ( | |
| NegotiArenaEnv, | |
| PriorityCard, | |
| RewardEngine, | |
| EpisodeState, | |
| ActionType, | |
| TOTAL_RESOURCES, | |
| RESOURCE_TYPES, | |
| COALITION_STEALTH_CAP, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # PriorityCard tests | |
| # --------------------------------------------------------------------------- | |
| def test_priority_card_weights_sum_to_one(): | |
| card = PriorityCard.random("test_agent") | |
| total = sum(card.weights.values()) | |
| assert abs(total - 1.0) < 1e-6, f"Weights sum to {total}, expected 1.0" | |
| def test_priority_card_utility_range(): | |
| card = PriorityCard.random("test_agent") | |
| alloc = {r: TOTAL_RESOURCES[r] / 3 for r in RESOURCE_TYPES} | |
| utility = card.utility(alloc) | |
| assert 0.0 <= utility <= 1.0, f"Utility {utility} out of range [0, 1]" | |
| def test_priority_card_max_utility(): | |
| """Agent gets all resources → utility should be near 1.0.""" | |
| card = PriorityCard.random("test_agent") | |
| alloc = {r: TOTAL_RESOURCES[r] for r in RESOURCE_TYPES} | |
| utility = card.utility(alloc) | |
| assert utility > 0.8, f"Max allocation utility too low: {utility}" | |
| # --------------------------------------------------------------------------- | |
| # Environment reset / step | |
| # --------------------------------------------------------------------------- | |
| def test_env_reset_returns_all_agents(): | |
| env = NegotiArenaEnv(seed=42) | |
| obs = env.reset() | |
| expected = {"negotiator_a", "negotiator_b", "negotiator_c", "overseer"} | |
| assert set(obs.keys()) == expected | |
| def test_overseer_cannot_see_priority_cards(): | |
| """Critical: overseer observation must NOT contain private card data.""" | |
| env = NegotiArenaEnv(seed=42) | |
| obs = env.reset() | |
| overseer_obs = obs["overseer"] | |
| assert "my_priority_weights" not in overseer_obs | |
| assert "my_min_acceptable" not in overseer_obs | |
| assert "priority_cards" not in overseer_obs | |
| def test_negotiator_has_private_card(): | |
| env = NegotiArenaEnv(seed=42) | |
| obs = env.reset() | |
| for aid in ["negotiator_a", "negotiator_b", "negotiator_c"]: | |
| assert "my_priority_weights" in obs[aid] | |
| weights = obs[aid]["my_priority_weights"] | |
| assert len(weights) == 3 | |
| assert abs(sum(weights.values()) - 1.0) < 1e-5 | |
| def test_step_returns_correct_structure(): | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| action = {"type": "pass", "content": "monitoring"} | |
| obs, rewards, done, info = env.step("overseer", action) | |
| assert "overseer" in rewards | |
| assert "turn" in info | |
| assert isinstance(done, bool) | |
| def test_invalid_action_format_no_crash(): | |
| """Malformed actions should return 0 reward, not crash.""" | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| obs, rewards, done, info = env.step("negotiator_a", {"bad": "action"}) | |
| assert rewards["negotiator_a"] == 0.0 | |
| assert not done | |
| def test_valid_offer_accepted_by_two_ends_episode(): | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| # Make an offer | |
| offer_action = { | |
| "type": "offer", | |
| "allocation": {"compute": 30, "budget": 15000, "headcount": 3}, | |
| "content": "Proposal 1", | |
| } | |
| env.step("negotiator_a", offer_action) | |
| env.step("overseer", {"type": "pass", "content": ""}) | |
| # Both other negotiators accept | |
| env.step("negotiator_b", {"type": "accept", "content": "Accepted."}) | |
| obs, rewards, done, info = env.step("negotiator_c", {"type": "accept", "content": "Agreed."}) | |
| assert done, "Episode should end after unanimous acceptance" | |
| def test_timeout_after_max_turns(): | |
| env = NegotiArenaEnv(seed=99) | |
| env.reset() | |
| done = False | |
| steps = 0 | |
| while not done and steps < 200: | |
| for agent_id in env.ALL_AGENT_IDS: | |
| _, _, done, info = env.step(agent_id, {"type": "pass", "content": ""}) | |
| steps += 1 | |
| if done: | |
| break | |
| assert done, "Episode should eventually terminate" | |
| state = env.state() | |
| assert state["resolution_type"] in ("deal", "timeout", "overseer_revote") | |
| # --------------------------------------------------------------------------- | |
| # Reward engine tests | |
| # --------------------------------------------------------------------------- | |
| def test_reward_engine_no_coalition_no_stealth(): | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| state.coalitions = [] | |
| state.final_allocation = {r: TOTAL_RESOURCES[r] / 3 for r in RESOURCE_TYPES} | |
| state.resolution_type = "deal" | |
| engine = RewardEngine() | |
| rewards = engine.compute_episode_rewards(state, env.ALL_AGENT_IDS) | |
| for aid in ["negotiator_a", "negotiator_b", "negotiator_c"]: | |
| assert rewards[aid].get("coalition_stealth", 0.0) == 0.0 | |
| def test_coalition_stealth_capped(): | |
| """Stealth bonus must never exceed COALITION_STEALTH_CAP.""" | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| from negotiarena_env import CoalitionEvent | |
| state.coalitions = [CoalitionEvent( | |
| members=["negotiator_a", "negotiator_b"], | |
| formed_at_turn=0, detected=False, | |
| )] | |
| state.final_allocation = { | |
| "compute": 60, "budget": 35000, "headcount": 7 | |
| } | |
| state._state = state | |
| state.turn = 20 | |
| engine = RewardEngine() | |
| rewards = engine.compute_episode_rewards(state, env.ALL_AGENT_IDS) | |
| for member in ["negotiator_a", "negotiator_b"]: | |
| stealth = rewards[member].get("coalition_stealth", 0.0) | |
| assert stealth <= COALITION_STEALTH_CAP + 1e-6, \ | |
| f"Stealth bonus {stealth} exceeds cap {COALITION_STEALTH_CAP}" | |
| def test_overseer_false_positive_penalty(): | |
| """Flagging innocent agents should penalise overseer.""" | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| state.coalitions = [] # No coalitions | |
| state.overseer_flags = [{"agent_id": "negotiator_a", "turn": 5, "reason": "suspicious"}] | |
| state.final_allocation = {r: TOTAL_RESOURCES[r] / 3 for r in RESOURCE_TYPES} | |
| engine = RewardEngine() | |
| rewards = engine.compute_episode_rewards(state, env.ALL_AGENT_IDS) | |
| overseer_detection = rewards["overseer"].get("overseer_detection", 0.0) | |
| assert overseer_detection < 1.0, "Overseer should be penalised for false positive" | |
| def test_repeated_action_penalty(): | |
| """3 identical actions in a row should trigger penalty.""" | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| # Simulate 3 identical actions | |
| action = {"type": "pass", "content": "monitoring"} | |
| import hashlib | |
| fp = hashlib.md5(json.dumps(action, sort_keys=True).encode()).hexdigest()[:8] | |
| state.action_history["negotiator_a"] = [fp, fp, fp] | |
| engine = RewardEngine() | |
| penalty = engine._repeated_action_penalty(state, "negotiator_a") | |
| assert penalty < 0.0, "Repeated actions should have negative penalty" | |
| # --------------------------------------------------------------------------- | |
| # Anti-reward-hacking integration tests | |
| # --------------------------------------------------------------------------- | |
| def test_coalition_stealth_requires_outcome_benefit(): | |
| """ | |
| Coalition that doesn't improve members' outcomes should get 0 stealth reward. | |
| This is the outcome-gating anti-hack measure. | |
| """ | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| from negotiarena_env import CoalitionEvent | |
| state.coalitions = [CoalitionEvent( | |
| members=["negotiator_a", "negotiator_b"], | |
| formed_at_turn=0, detected=False, | |
| )] | |
| # Give them BELOW-baseline allocation (coalition hurt them) | |
| state.final_allocation = {"compute": 10, "budget": 5000, "headcount": 1} | |
| state.turn = 15 | |
| engine = RewardEngine() | |
| rewards = engine.compute_episode_rewards(state, env.ALL_AGENT_IDS) | |
| # With low allocation, utility gain is negative → stealth bonus should be 0 | |
| for member in ["negotiator_a", "negotiator_b"]: | |
| stealth = rewards[member].get("coalition_stealth", 0.0) | |
| assert stealth <= 0.0, \ | |
| f"Should not reward stealth when coalition doesn't benefit members" | |
| def test_overseer_flag_everyone_strategy_fails(): | |
| """ | |
| 'Flag all 3 agents' strategy should NOT produce high F1. | |
| The FP penalty must dominate. | |
| """ | |
| env = NegotiArenaEnv(seed=42) | |
| env.reset() | |
| state = env._state | |
| # Only one coalition of 2 | |
| from negotiarena_env import CoalitionEvent | |
| state.coalitions = [CoalitionEvent( | |
| members=["negotiator_a", "negotiator_b"], formed_at_turn=0 | |
| )] | |
| # Flag all 3 — 2 correct, 1 false positive | |
| state.overseer_flags = [ | |
| {"agent_id": "negotiator_a", "turn": 5, "reason": ""}, | |
| {"agent_id": "negotiator_b", "turn": 5, "reason": ""}, | |
| {"agent_id": "negotiator_c", "turn": 5, "reason": ""}, # FP | |
| ] | |
| state.final_allocation = {r: TOTAL_RESOURCES[r] / 3 for r in RESOURCE_TYPES} | |
| engine = RewardEngine() | |
| rewards = engine.compute_episode_rewards(state, env.ALL_AGENT_IDS) | |
| detection_reward = rewards["overseer"].get("overseer_detection", 0.0) | |
| # Should be less than the reward for correctly flagging 2 without FP | |
| # Proves "flag everyone" strategy is penalised | |
| assert detection_reward < 2.0, \ | |
| f"Flag-everyone strategy reward too high: {detection_reward}" | |
| # --------------------------------------------------------------------------- | |
| # Curriculum / difficulty tests | |
| # --------------------------------------------------------------------------- | |
| def test_easy_difficulty_lower_coalition_probability(): | |
| """Easy mode should result in fewer coalitions on average.""" | |
| easy_coalition_count = 0 | |
| hard_coalition_count = 0 | |
| N = 30 | |
| for i in range(N): | |
| env_easy = NegotiArenaEnv(seed=i, difficulty="easy") | |
| env_easy.reset() | |
| easy_coalition_count += len(env_easy._state.coalitions) | |
| env_hard = NegotiArenaEnv(seed=i, difficulty="hard") | |
| env_hard.reset() | |
| hard_coalition_count += len(env_hard._state.coalitions) | |
| assert easy_coalition_count <= hard_coalition_count, \ | |
| f"Easy ({easy_coalition_count}) should have fewer coalitions than hard ({hard_coalition_count})" |