diff --git a/tests/test_policy_decide.py b/tests/test_policy_decide.py index ca95cf4..346243d 100644 --- a/tests/test_policy_decide.py +++ b/tests/test_policy_decide.py @@ -1,7 +1,7 @@ """ tests/test_policy_decide.py -Round-trip tests for POST /api/policy/decide introduced in Issue #83 Step 2. +Round-trip tests for POST /api/policy/decide (Issue #83 Steps 2 + 4). Coverage: - Health endpoint sanity @@ -11,6 +11,10 @@ Coverage: - Fallback path: no state_vector → vector_source="encoder" - Fallback path: wrong-length state_vector → vector_source="encoder" - GameSession field name aliases accepted without 422 + - Step 4: real Forge board stats (creaturesOnField, landCount, + totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField, + enchantmentsOnField) flow through without 422 and reach the encoder + - Step 4: resolver fallbacks when Forge fields absent - 503 returned when model not loaded - /api/policy/stats tracks hit/fallback counters - Phase string variants (MAIN1, main_1, COMBAT) round-trip cleanly @@ -20,9 +24,8 @@ All tests use a MockPolicyInferenceService — no real checkpoint required. from __future__ import annotations import math -import types from typing import Any, Dict, List, Optional -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import numpy as np import pytest @@ -92,15 +95,13 @@ class MockPolicyInferenceService: self._build_mock_model() def _build_mock_model(self): - """Build a callable mock that records input shapes and returns logits.""" captured = self.captured_shapes import torch def fake_forward(tensor): captured.append(tuple(tensor.shape)) - # Return uniform logits favouring action 0 logits = torch.zeros(tensor.shape[0], NUM_ACTIONS) - logits[0, 0] = 2.0 # cast_creature wins softmax + logits[0, 0] = 2.0 return logits self.model = MagicMock(side_effect=fake_forward) @@ -113,7 +114,6 @@ class MockPolicyInferenceService: state_vec = self.encoder.encode(decision_snapshot, playstyle) state_tensor = torch.from_numpy(state_vec.astype(np.float32)).unsqueeze(0) self.captured_shapes.append(tuple(state_tensor.shape)) - # Always return cast_creature return { "action": IDX_TO_ACTION[0].value, "action_index": 0, @@ -128,12 +128,10 @@ class MockPolicyInferenceService: # App factory # --------------------------------------------------------------------------- -def _make_app(loaded: bool = True) -> tuple[FastAPI, MockPolicyInferenceService]: - """Create a fresh FastAPI app with the policy router registered.""" +def _make_app(loaded: bool = True): from routes.policy import register_policy_routes import routes.policy as policy_mod - # Reset module-level state between tests policy_mod._policy_service = None policy_mod._online_store = None policy_mod._session_stats = { @@ -154,60 +152,75 @@ def _make_app(loaded: bool = True) -> tuple[FastAPI, MockPolicyInferenceService] # --------------------------------------------------------------------------- -# Fixture: a realistic GameSession snapshot +# Snapshot builder # --------------------------------------------------------------------------- def _game_session_snapshot( state_vector: Optional[List[float]] = None, - include_aliases: bool = True, + board_stats: Optional[Dict] = None, ) -> Dict[str, Any]: """ - Mimics GameSession.buildStateSnapshot() output at turn 3, MAIN1. - Includes the state_vector field added in Step 1. + Builds a realistic GameSession.buildStateSnapshot() payload. + + board_stats: optional dict of Forge board stat overrides for players[0]. + e.g. {"creaturesOnField": 3, "landCount": 5, "totalPowerOnBoard": 9} """ sv = state_vector if state_vector is not None else _valid_state_vector() - players = [ - { - "seat": 0, - "name": "Human", - "isAI": False, - "deckName": "Atraxa Superfriends", - "life": 37, - "poison": 0, - "commanderTax": 0, - "handCount": 6, - "hand": ["Doubling Season", "Deepglow Skate", "Sol Ring", - "Atraxa, Praetors' Voice", "Swamp", "Forest"], - "battlefield": ["Sol Ring", "Atraxa, Praetors' Voice"], - "graveyard": [], - "commandZone": ["Atraxa, Praetors' Voice"], - "manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}, - }, - { - "seat": 1, - "name": "AI-1", - "isAI": True, - "deckName": "Ur-Dragon", - "life": 40, - "poison": 0, - "commanderTax": 2, - "handCount": 7, - "hand": [], - "battlefield": ["Dragon's Hoard", "Savage Ventmaw"], - "graveyard": ["Swords to Plowshares"], - "commandZone": ["The Ur-Dragon"], - "manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}, - }, - ] - snap: Dict[str, Any] = { + p0: Dict[str, Any] = { + "seat": 0, + "name": "Human", + "life": 37, + "poison": 0, + "commanderTax": 0, + "handCount": 6, + "hand": ["Doubling Season", "Deepglow Skate", "Sol Ring", + "Atraxa, Praetors' Voice", "Swamp", "Forest"], + "battlefield": ["Sol Ring", "Atraxa, Praetors' Voice"], + "graveyard": [], + "commandZone": ["Atraxa, Praetors' Voice"], + "manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}, + # Step 4: real Forge board stats + "creaturesOnField": 1, + "landCount": 4, + "totalPowerOnBoard": 4, + "totalToughnessOnBoard": 4, + "artifactsOnField": 1, + "enchantmentsOnField": 0, + "manaAvailable": 4, + } + if board_stats: + p0.update(board_stats) + + p1: Dict[str, Any] = { + "seat": 1, + "name": "AI-1", + "life": 40, + "poison": 0, + "commanderTax": 2, + "handCount": 7, + "hand": [], + "battlefield": ["Dragon's Hoard", "Savage Ventmaw"], + "graveyard": ["Swords to Plowshares"], + "commandZone": ["The Ur-Dragon"], + "manaPool": {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}, + "creaturesOnField": 1, + "landCount": 5, + "totalPowerOnBoard": 5, + "totalToughnessOnBoard": 4, + "artifactsOnField": 1, + "enchantmentsOnField": 0, + "manaAvailable": 5, + } + + return { "schema": "1.1.0", "phase": "MAIN1", "turnNumber": 3, "activePlayer": 0, "priorityPlayer": 0, "awaitingInput": True, - "players": players, + "players": [p0, p1], "stack": [], "legalActions": [ {"type": "PASS_PRIORITY", "label": "Pass Priority"}, @@ -218,26 +231,22 @@ def _game_session_snapshot( "state_vector": sv, "state_vector_dim": len(sv), } - return snap def _valid_state_vector() -> List[float]: """A plausible float[29] global scalar vector from buildStateVector(seat=0).""" - # life=37/40, everything else normalised appropriately sv = [0.0] * 29 - sv[0] = 37 / 40.0 # self life - sv[3] = 0.0 # cmdr_tax - sv[4] = 6 / 15.0 # cards_in_hand - sv[6] = 2 / 30.0 # creatures - sv[7] = 2 / 100.0 # power proxy - sv[8] = 1.0 # is_active_player - sv[9] = 1.0 # phase_onehot[main1] - # opponent block: indices 14..27 - sv[14] = 40 / 40.0 # opp life - sv[17] = 2 / 10.0 # opp cmdr_tax - sv[18] = 7 / 15.0 # opp hand - sv[20] = 2 / 30.0 # opp creatures - # turn + sv[0] = 37 / 40.0 + sv[3] = 0.0 + sv[4] = 6 / 15.0 + sv[6] = 2 / 30.0 + sv[7] = 2 / 100.0 + sv[8] = 1.0 + sv[9] = 1.0 + sv[14] = 40 / 40.0 + sv[17] = 2 / 10.0 + sv[18] = 7 / 15.0 + sv[20] = 2 / 30.0 sv[28] = 3 / 25.0 return sv @@ -248,7 +257,7 @@ def _valid_state_vector() -> List[float]: class TestPolicyHealth: def test_health_model_loaded(self): - app, _ = _make_app(loaded=True) + app, _ = _make_app() client = TestClient(app) resp = client.get("/api/policy/health") assert resp.status_code == 200 @@ -268,88 +277,70 @@ class TestPolicyHealth: class TestDecideFastPath: - """Precomputed state_vector present → vector_source=precomputed.""" - def test_precomputed_path_used(self): app, svc = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200, resp.text - data = resp.json() - assert data["vector_source"] == "precomputed" + assert resp.json()["vector_source"] == "precomputed" def test_action_is_valid_macro_action(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 assert resp.json()["action"] in VALID_MACRO_ACTIONS def test_action_index_in_range(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 assert 0 <= resp.json()["action_index"] < NUM_ACTIONS def test_confidence_is_probability(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 - conf = resp.json()["confidence"] - assert 0.0 <= conf <= 1.0 + assert 0.0 <= resp.json()["confidence"] <= 1.0 def test_log_prob_is_negative(self): - """log(p) for p in (0,1] must be <= 0.""" - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 assert resp.json()["log_prob"] <= 0.0 def test_tensor_shape_is_6177(self): - """The mock model must receive a (1, 6177) tensor.""" app, svc = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - client.post("/api/policy/decide", json=snap) + client.post("/api/policy/decide", json=_game_session_snapshot()) assert len(svc.captured_shapes) > 0 - shape = svc.captured_shapes[-1] - assert shape == (1, EXPECTED_STATE_DIM), ( - f"Expected (1, {EXPECTED_STATE_DIM}), got {shape}" + assert svc.captured_shapes[-1] == (1, EXPECTED_STATE_DIM), ( + f"Expected (1, {EXPECTED_STATE_DIM}), got {svc.captured_shapes[-1]}" ) def test_probabilities_sum_to_one(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 probs = resp.json().get("probabilities", {}) if probs: - total = sum(probs.values()) - assert abs(total - 1.0) < 0.01, f"Probs sum to {total}" + assert abs(sum(probs.values()) - 1.0) < 0.01 def test_inference_ms_is_positive(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 200 assert resp.json()["inference_ms"] >= 0.0 class TestDecideFallbackPath: - """No / wrong state_vector → falls back to full encoder.""" - def test_fallback_when_no_state_vector(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap.pop("state_vector", None) @@ -359,8 +350,7 @@ class TestDecideFallbackPath: assert resp.json()["vector_source"] == "encoder" def test_fallback_when_wrong_length_vector(self): - """A 10-float vector is too short — must fall back.""" - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot(state_vector=[0.5] * 10) resp = client.post("/api/policy/decide", json=snap) @@ -368,7 +358,7 @@ class TestDecideFallbackPath: assert resp.json()["vector_source"] == "encoder" def test_fallback_still_returns_valid_action(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap.pop("state_vector", None) @@ -377,63 +367,191 @@ class TestDecideFallbackPath: assert resp.json()["action"] in VALID_MACRO_ACTIONS -class TestGameSessionAliases: - """GameSession field name aliases must not cause 422 validation errors.""" +class TestBoardStatsWiring: + """Step 4: real Forge board stats flow through PlayerZoneState without 422.""" + def test_creatures_on_field_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"creaturesOnField": 5}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_land_count_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"landCount": 8}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_total_power_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"totalPowerOnBoard": 15}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_total_toughness_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"totalToughnessOnBoard": 12}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_artifacts_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"artifactsOnField": 3}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_enchantments_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"enchantmentsOnField": 2}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_mana_available_accepted(self): + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={"manaAvailable": 7}) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + + def test_all_board_stats_together(self): + """Full Forge board stat block round-trips cleanly.""" + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={ + "creaturesOnField": 4, + "landCount": 7, + "totalPowerOnBoard": 16, + "totalToughnessOnBoard": 14, + "artifactsOnField": 2, + "enchantmentsOnField": 1, + "manaAvailable": 7, + }) + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + assert resp.json()["action"] in VALID_MACRO_ACTIONS + + def test_board_stats_reach_fallback_encoder(self): + """Board stats survive through _build_encoder_snapshot on fallback path.""" + app, _ = _make_app() + client = TestClient(app) + snap = _game_session_snapshot(board_stats={ + "creaturesOnField": 6, + "landCount": 9, + "totalPowerOnBoard": 24, + }) + snap.pop("state_vector", None) # force fallback encoder path + resp = client.post("/api/policy/decide", json=snap) + assert resp.status_code == 200, resp.text + assert resp.json()["vector_source"] == "encoder" + + +class TestBoardStatsFallback: + """Resolver fallbacks when Forge board stat fields are absent.""" + + def test_creatures_inferred_from_battlefield_names(self): + """When creaturesOnField=0, battlefield name count is the fallback.""" + from routes.policy import PlayerZoneState + p = PlayerZoneState( + seat=0, + battlefield=["Atraxa, Praetors' Voice", "Sol Ring"], + creaturesOnField=0, + ) + # Should fall back to len(battlefield names) = 2 + assert p.resolved_creatures() == 2 + + def test_creatures_uses_real_value_when_present(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState( + seat=0, + battlefield=["Atraxa, Praetors' Voice"], # 1 name + creaturesOnField=3, # 3 actual creatures (tokens not in list) + ) + assert p.resolved_creatures() == 3 + + def test_lands_default_zero_when_absent(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0) + assert p.resolved_lands() == 0 + + def test_lands_uses_real_value(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0, landCount=6) + assert p.resolved_lands() == 6 + + def test_total_power_fallback_is_creatures_times_3(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0, creaturesOnField=4, totalPowerOnBoard=0) + assert p.resolved_total_power() == 12 # 4 * 3 + + def test_total_power_uses_real_value(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0, creaturesOnField=4, totalPowerOnBoard=19) + assert p.resolved_total_power() == 19 + + def test_mana_resolves_mana_available_alias(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0, manaAvailable=6) + assert p.resolved_mana() == 6 + + def test_mana_resolves_snake_case_alias(self): + from routes.policy import PlayerZoneState + p = PlayerZoneState(seat=0, mana_available=5) + assert p.resolved_mana() == 5 + + +class TestGameSessionAliases: def test_turnNumber_alias(self): app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["turnNumber"] = 5 snap.pop("turn", None) - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 def test_activePlayer_alias(self): app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["activePlayer"] = 0 - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 def test_commanderTax_in_player(self): app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["players"][1]["commanderTax"] = 4 - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 def test_commandZone_alias(self): app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["players"][0]["commandZone"] = ["Atraxa, Praetors' Voice"] - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 def test_legalActions_alias(self): app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["legalActions"] = snap.pop("legal_actions", snap.get("legalActions", [])) - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 def test_schema_110_accepted(self): - """schema=1.1.0 (GameSession Step 1 bump) is accepted without error.""" + """'schema' JSON key accepted via Field(alias='schema') on snapshot_schema.""" app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap["schema"] = "1.1.0" - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, resp.text + assert client.post("/api/policy/decide", json=snap).status_code == 200 class TestPhaseVariants: - """Both Forge-style (MAIN1) and ML-style (main_1) phase strings work.""" - @pytest.mark.parametrize("phase", [ "MAIN1", "main_1", "MAIN2", "main_2", "BEGIN_COMBAT", "combat", "END", "end", "CLEANUP", @@ -443,38 +561,33 @@ class TestPhaseVariants: client = TestClient(app) snap = _game_session_snapshot() snap["phase"] = phase - resp = client.post("/api/policy/decide", json=snap) - assert resp.status_code == 200, f"Phase '{phase}' failed: {resp.text}" + assert client.post("/api/policy/decide", json=snap).status_code == 200, phase class TestDecideModelNotLoaded: def test_503_when_not_loaded(self): app, _ = _make_app(loaded=False) client = TestClient(app) - snap = _game_session_snapshot() - resp = client.post("/api/policy/decide", json=snap) + resp = client.post("/api/policy/decide", json=_game_session_snapshot()) assert resp.status_code == 503 assert "not loaded" in resp.json()["detail"].lower() class TestStats: def test_precomputed_hits_incremented(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - # Fire 3 precomputed requests for _ in range(3): - client.post("/api/policy/decide", json=snap) + client.post("/api/policy/decide", json=_game_session_snapshot()) stats = client.get("/api/policy/stats").json() assert stats["precomputed_vector_hits"] == 3 assert stats["total_decisions"] == 3 def test_encoder_fallbacks_incremented(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) snap = _game_session_snapshot() snap.pop("state_vector", None) - # Fire 2 fallback requests for _ in range(2): client.post("/api/policy/decide", json=snap) stats = client.get("/api/policy/stats").json() @@ -482,11 +595,11 @@ class TestStats: assert stats["precomputed_vector_hits"] == 0 def test_mixed_hits_and_fallbacks(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap_pre = _game_session_snapshot() # has state_vector + snap_pre = _game_session_snapshot() snap_enc = _game_session_snapshot() - snap_enc.pop("state_vector", None) # no state_vector + snap_enc.pop("state_vector", None) client.post("/api/policy/decide", json=snap_pre) client.post("/api/policy/decide", json=snap_enc) client.post("/api/policy/decide", json=snap_pre) @@ -496,35 +609,29 @@ class TestStats: assert stats["total_decisions"] == 3 def test_decisions_per_second_non_negative(self): - app, svc = _make_app() + app, _ = _make_app() client = TestClient(app) - snap = _game_session_snapshot() - client.post("/api/policy/decide", json=snap) + client.post("/api/policy/decide", json=_game_session_snapshot()) stats = client.get("/api/policy/stats").json() assert stats["decisions_per_second"] >= 0.0 class TestStateVectorDimension: - """Explicit dim assertions on the precomputed encoding path.""" - def test_global_block_length_is_29(self): - sv = _valid_state_vector() - assert len(sv) == EXPECTED_GLOBAL_DIM + assert len(_valid_state_vector()) == EXPECTED_GLOBAL_DIM def test_life_normalisation(self): sv = _valid_state_vector() - assert 0.0 <= sv[0] <= 1.0, "life not normalised" + assert 0.0 <= sv[0] <= 1.0 def test_turn_normalisation(self): sv = _valid_state_vector() - assert 0.0 <= sv[28] <= 1.0, "turn not normalised" + assert 0.0 <= sv[28] <= 1.0 def test_is_active_player_binary(self): sv = _valid_state_vector() - assert sv[8] in (0.0, 1.0), "is_active_player must be 0 or 1" + assert sv[8] in (0.0, 1.0) def test_phase_onehot_at_most_one_hot(self): - """Indices 9-12 are the phase one-hot: at most one should be 1.""" sv = _valid_state_vector() - phase_bits = sv[9:13] - assert sum(phase_bits) <= 1.0, f"Multiple phase bits set: {phase_bits}" + assert sum(sv[9:13]) <= 1.0, f"Multiple phase bits: {sv[9:13]}"