test(policy): add Step 4 board-stats tests + fix schema alias (#83 Step 4)

- TestBoardStatsWiring: verifies creaturesOnField, landCount,
  totalPowerOnBoard, totalToughnessOnBoard, artifactsOnField,
  enchantmentsOnField are accepted without 422 and survive
  a round-trip through the fallback encoder path
- TestBoardStatsFallback: verifies resolver fallbacks when Forge
  fields are absent (creatures inferred from bf length,
  total_power falls back to creatures * 3 heuristic,
  lands defaults to 0)
- TestSchemaAlias: verifies snapshot_schema Field(alias='schema')
  still accepts JSON key 'schema' without 422
- Removes now-irrelevant UserWarning (schema shadow fixed)
This commit is contained in:
KoalaTrapLord committed 2026-03-29 04:51:47 -04:00
1 parent bff7b3a81e
commit 73d8e8d220
1 file changed
+256 -149
+256 -149
View File
@@ -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]}"