CounterFeint / tests /test_three_agent_episode.py
QuantumTransformer's picture
Upload folder using huggingface_hub
28f702f verified
Raw
History Blame Contribute Delete
14.7 kB
"""
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"
)