""" Tests for the RefereeEnvironment three-agent state machine (in-process). Covers: * turn interleaving (fraudster -> investigator -> fraudster -> ... -> audit) * dynamic queue growth (Fraudster proposals reach Investigator) * reactive signal (Fraudster observation reflects Investigator's verdicts) * phase guards (role-mismatched actions raise PermissionError) * three end paths: commit_final, investigator_done, max_rounds * grader_score is populated exactly when phase == "done" """ from __future__ import annotations import pytest from counterfeint.models import ( AdReviewAction, AuditorAction, FraudsterAction, RefereeState, ) from counterfeint.scripted import ( HeuristicAuditor, ReactiveFraudster, ScriptedInvestigator, ) from counterfeint.server.referee import RefereeEnvironment # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- def make_referee(**reset_kwargs): env = RefereeEnvironment() reset_kwargs.setdefault("task_id", "task_1") reset_kwargs.setdefault("seed", 42) env.reset_match(**reset_kwargs) return env def a_propose(category: str = "fake_giveaway", *, copy: str = "Free iPhone - tap now!"): return FraudsterAction( action_type="propose_ad", ad_copy=copy, category=category, landing_page_blurb="limited-time giveaway details", targeting_summary="adults 18-45", ) def a_end_turn(): return FraudsterAction(action_type="end_turn") def a_commit(): return FraudsterAction(action_type="commit_final") def a_investigate(ad_id: str, target: str = "landing_page"): return AdReviewAction( action_type="investigate", ad_id=ad_id, investigation_target=target ) def a_verdict(ad_id: str, verdict: str = "reject", conf: float = 0.8): return AdReviewAction( action_type="verdict", ad_id=ad_id, verdict=verdict, confidence=conf, rationale=f"Verdict for {ad_id}: {verdict} (confidence {conf})", ) def a_submit_audit(): return AuditorAction( action_type="submit_audit_report", audit_report={ "track_a_flags": [], "track_b_flags": [], "investigator_audit_score": 1.0, "fraudster_plausibility_score": 1.0, "notes": "test", }, ) # --------------------------------------------------------------------------- # Turn interleaving + dynamic queue # --------------------------------------------------------------------------- class TestTurnInterleaving: def test_starts_in_fraudster_turn_round_1(self): env = make_referee() assert env.phase == "fraudster_turn" assert env.state.round_number == 1 assert env.state.proposals_used == 0 def test_fraudster_end_turn_flips_to_investigator(self): env = make_referee() obs = env.step_as_fraudster(a_end_turn()) assert env.phase == "investigator_turn" assert obs.done is False def test_fraudster_action_cap_auto_ends_turn(self): env = make_referee(max_fraudster_actions_per_turn=2, max_proposals=5) env.step_as_fraudster(a_propose("fake_giveaway", copy="ad one")) assert env.phase == "fraudster_turn" env.step_as_fraudster(a_propose("fake_crypto", copy="ad two")) assert env.phase == "investigator_turn" def test_investigator_action_cap_flips_to_fraudster_next_round(self): env = make_referee( max_fraudster_actions_per_turn=3, max_investigator_actions_per_turn=3, ) env.step_as_fraudster(a_end_turn()) assert env.phase == "investigator_turn" available = env.build_investigator_observation().available_ads for ad_id in available[:3]: env.step_as_investigator(a_verdict(ad_id)) assert env.phase == "fraudster_turn" assert env.state.round_number == 2 def test_fraudster_proposal_reaches_investigator_queue(self): env = make_referee() before = env.build_investigator_observation().available_ads env.step_as_fraudster(a_propose("fake_giveaway")) env.step_as_fraudster(a_end_turn()) after = env.build_investigator_observation().available_ads assert len(after) == len(before) + 1 # --------------------------------------------------------------------------- # Reactive signal — Fraudster sees Investigator's verdicts # --------------------------------------------------------------------------- class TestReactiveSignal: def test_fraudster_observation_reflects_investigator_verdicts(self): env = make_referee( max_fraudster_actions_per_turn=3, max_investigator_actions_per_turn=3, ) env.step_as_fraudster(a_propose("fake_giveaway", copy="suspicious")) proposed_ad_id = env._proposal_slot_to_ad_id[0] env.step_as_fraudster(a_end_turn()) env.step_as_investigator(a_verdict(proposed_ad_id, verdict="reject", conf=0.9)) remaining = [ ad_id for ad_id in env.build_investigator_observation().available_ads if ad_id != proposed_ad_id ] for ad_id in remaining[:2]: env.step_as_investigator(a_verdict(ad_id, verdict="approve", conf=0.7)) # expected phase flip back to fraudster_turn after action cap assert env.phase == "fraudster_turn" fraud_obs = env.build_fraudster_observation() verdict_map = {v["ad_id"]: v for v in fraud_obs.prior_verdicts} assert proposed_ad_id in verdict_map assert verdict_map[proposed_ad_id]["verdict"] == "reject" assert verdict_map[proposed_ad_id].get("was_my_proposal") is True assert any(v["verdict"] == "approve" for v in fraud_obs.prior_verdicts) def test_investigation_targets_used_are_visible_to_fraudster(self): env = make_referee( max_fraudster_actions_per_turn=3, max_investigator_actions_per_turn=3, ) env.step_as_fraudster(a_end_turn()) target_ad = env.build_investigator_observation().available_ads[0] env.step_as_investigator(a_investigate(target_ad, "landing_page")) env.step_as_investigator(a_verdict(target_ad, verdict="reject", conf=0.9)) env.step_as_investigator(a_verdict( env.build_investigator_observation().available_ads[0], verdict="approve", conf=0.7, )) assert env.phase == "fraudster_turn" fraud_obs = env.build_fraudster_observation() assert target_ad in fraud_obs.investigation_targets_used assert "landing_page" in fraud_obs.investigation_targets_used[target_ad] # --------------------------------------------------------------------------- # Phase guards # --------------------------------------------------------------------------- class TestPhaseGuards: def test_investigator_during_fraudster_turn_raises(self): env = make_referee() with pytest.raises(PermissionError): env.step_as_investigator(a_verdict("ad_001")) def test_fraudster_during_investigator_turn_raises(self): env = make_referee() env.step_as_fraudster(a_end_turn()) assert env.phase == "investigator_turn" with pytest.raises(PermissionError): env.step_as_fraudster(a_propose()) def test_auditor_during_fraudster_turn_raises(self): env = make_referee() with pytest.raises(PermissionError): env.step_as_auditor(a_submit_audit()) # --------------------------------------------------------------------------- # End paths # --------------------------------------------------------------------------- class TestEndPaths: def _advance_to_audit(self, env: RefereeEnvironment) -> None: loops = 0 while env.phase not in ("audit_phase", "done"): if loops > 200: raise AssertionError("episode failed to advance after 200 steps") loops += 1 if env.phase == "fraudster_turn": obs = env.build_fraudster_observation() policy = ReactiveFraudster(seed=1) action = policy.act(obs.model_dump()) env.step_as_fraudster(action) elif env.phase == "investigator_turn": obs = env.build_investigator_observation() policy = ScriptedInvestigator() action = policy.act(obs.model_dump()) env.step_as_investigator(action) else: break def test_commit_final_jumps_to_audit(self): env = make_referee() env.step_as_fraudster(a_commit()) assert env.phase == "audit_phase" assert env.state.fraudster_committed is True assert env.state.end_reason == "commit_final" def test_investigator_done_jumps_to_audit(self): env = make_referee( max_fraudster_actions_per_turn=1, max_proposals=0, max_investigator_actions_per_turn=10, max_rounds=10, ) env.step_as_fraudster(a_end_turn()) for ad_id in list(env.build_investigator_observation().available_ads): env.step_as_investigator(a_verdict(ad_id)) assert env.phase == "audit_phase" assert env.state.end_reason in ("investigator_done", "all_decided") def test_max_rounds_jumps_to_audit(self): env = make_referee( max_rounds=1, max_fraudster_actions_per_turn=1, max_investigator_actions_per_turn=2, ) env.step_as_fraudster(a_end_turn()) available = env.build_investigator_observation().available_ads for ad_id in available[:2]: env.step_as_investigator(a_verdict(ad_id)) assert env.phase == "audit_phase" assert env.state.end_reason in ("max_rounds", "investigator_done", "all_decided") def test_audit_submit_flips_to_done_and_sets_grader_score(self): env = make_referee() env.step_as_fraudster(a_commit()) assert env.phase == "audit_phase" obs = env.step_as_auditor(a_submit_audit()) assert env.phase == "done" assert obs.done is True state = env.state assert state.grader_score is not None assert 0.0 <= state.grader_score <= 1.0 # --------------------------------------------------------------------------- # Full scripted episode (sanity) # --------------------------------------------------------------------------- class TestScriptedFullRun: def test_full_episode_terminates_cleanly(self): env = make_referee(max_rounds=3) fraud = ReactiveFraudster(seed=5) inv = ScriptedInvestigator() aud = HeuristicAuditor() loops = 0 while env.phase != "done": loops += 1 assert loops <= 400, "episode did not terminate in a reasonable number of steps" if env.phase == "fraudster_turn": obs = env.build_fraudster_observation().model_dump() env.step_as_fraudster(fraud.act(obs)) elif env.phase == "investigator_turn": obs = env.build_investigator_observation().model_dump() env.step_as_investigator(inv.act(obs)) elif env.phase == "audit_phase": obs = env.build_auditor_observation().model_dump() env.step_as_auditor(aud.act(obs)) else: raise AssertionError(f"unexpected phase {env.phase}") state: RefereeState = env.state assert state.grader_score is not None assert state.audit_report is not None assert state.phase == "done" assert state.end_reason in ( "commit_final", "all_decided", "max_rounds", "investigator_done", ) class TestTaskConfigCurriculum: """Verify TaskConfig knobs flow into the Referee as the default curriculum.""" def test_task_1_uses_novice_fraudster_budget(self): env = RefereeEnvironment() env.reset_match(task_id="task_1", seed=42) assert env.state.max_rounds == 4 # Task 1 was lowered from 5 → 3 max_proposals during T-24h iteration: # the queue was structurally over-saturated (5 base + 5 proposed = 10 # ads vs 25 action budget = 2.5 actions/ad), so the Investigator # physically could not verdict everything. Lowering the cap to 3 # keeps the queue at most 5+3=8 ads (~3 actions/ad) and gives the # 1.5B baseline a chance at >=3 verdicts before steps run out. assert env.state.max_proposals == 3 allowed = env.build_fraudster_observation().allowed_categories assert "fake_giveaway" in allowed assert "miracle_cure" in allowed assert "counterfeit_goods" not in allowed, ( "Task 1 should restrict the Fraudster to easy fraud templates" ) assert "network_crypto" not in allowed def test_task_2_adds_mid_tier_categories(self): env = RefereeEnvironment() env.reset_match(task_id="task_2", seed=42) assert env.state.max_proposals == 6 allowed = env.build_fraudster_observation().allowed_categories assert "counterfeit_goods" in allowed assert "fake_crypto" in allowed assert "clone_brand" in allowed assert "network_crypto" not in allowed, ( "Task 2 should not yet allow ring-level categories" ) def test_task_3_opens_full_palette(self): env = RefereeEnvironment() env.reset_match(task_id="task_3", seed=42) assert env.state.max_rounds == 5 assert env.state.max_proposals == 7 assert env._max_investigator_actions_per_turn == 7 # not surfaced in RefereeState allowed = env.build_fraudster_observation().allowed_categories assert "network_crypto" in allowed assert "network_ecommerce" in allowed def test_explicit_kwarg_still_overrides_task_config(self): env = RefereeEnvironment() env.reset_match(task_id="task_3", seed=42, max_proposals=2) assert env.state.max_proposals == 2, ( "Explicit reset_match kwargs must still trump the task curriculum" )