""" tests/test_state_encoder.py Unit tests for ml/encoder/state_encoder.py Coverage: - Global feature vector shape and value ranges - Step 4: total_power real value used at index 7 when present - Step 4: creatures*3 heuristic used at index 7 when total_power absent/zero - Step 4: lands real value used at index 13 when present - Step 4: creatures real value used at index 6 when present - Mana normalisation and clamping - Phase one-hot encoding (indices 9-12 per player block) - is_active_player binary (index 8) - Life normalisation (index 0) - Turn feature (index 28) - Full encode() output shape (6177,) and dtype - _empty_player() has all Step 4 keys - Legacy JSONL snapshot (no Step 4 keys) still encodes without error No NPZ file required — MockCardEmbeddingIndex returns zero vectors. """ from __future__ import annotations import numpy as np import pytest from ml.config.scope import STATE_DIMS, GAME_SCOPE # --------------------------------------------------------------------------- # Mock card embedding index (no file I/O) # --------------------------------------------------------------------------- class MockCardEmbeddingIndex: _loaded = True _zero_vec = np.zeros(STATE_DIMS.card_embedding_dim, dtype=np.float32) def get_embedding(self, name: str) -> np.ndarray: return self._zero_vec def mean_pool_zone(self, card_names) -> np.ndarray: return self._zero_vec # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_encoder(): from ml.encoder.state_encoder import StateEncoder return StateEncoder(MockCardEmbeddingIndex()) def _two_player_decision(**p0_overrides) -> dict: """Minimal two-player snapshot. p0_overrides patch player 0's fields.""" p0 = { "seat": 0, "life": 40, "cmdr_dmg": 0, "mana": 0, "cmdr_tax": 0, "creatures": 0, "total_power": 0, "total_toughness": 0, "artifacts": 0, "enchantments": 0, "lands": 0, "hand": [], "battlefield": [], "graveyard": [], "command_zone": [], } p0.update(p0_overrides) p1 = { "seat": 1, "life": 40, "cmdr_dmg": 0, "mana": 0, "cmdr_tax": 0, "creatures": 0, "total_power": 0, "total_toughness": 0, "artifacts": 0, "enchantments": 0, "lands": 0, "hand": [], "battlefield": [], "graveyard": [], "command_zone": [], } return { "turn": 1, "phase": "main_1", "active_seat": 0, "players": [p0, p1], "archetype": "midrange", } def _global_vec(**p0_overrides) -> np.ndarray: enc = _make_encoder() decision = _two_player_decision(**p0_overrides) players = decision["players"] return enc._encode_global(decision, players) # Per-player block is 14 features; player 0 starts at index 0. # Layout: # 0 life # 1 cmdr_dmg # 2 mana # 3 cmdr_tax # 4 hand_size # 5 graveyard_size # 6 creatures # 7 total_power # 8 is_active # 9-12 phase one-hot # 13 lands # Player 1 block starts at index 14. # Index 28: turn. P0_LIFE = 0 P0_CMDR_DMG = 1 P0_MANA = 2 P0_CMDR_TAX = 3 P0_HAND = 4 P0_GRAVEYARD = 5 P0_CREATURES = 6 P0_TOTAL_POWER = 7 P0_IS_ACTIVE = 8 P0_PHASE_START = 9 # indices 9-12 P0_LANDS = 13 P1_LIFE = 14 IDX_TURN = 28 # =========================================================================== # Tests # =========================================================================== class TestGlobalFeatureDimensions: def test_shape_is_29(self): vec = _global_vec() assert vec.shape == (29,), f"Expected (29,), got {vec.shape}" def test_all_values_in_0_1(self): vec = _global_vec(life=37, creatures=5, total_power=15, lands=7, mana=8) assert np.all(vec >= 0.0), f"Negative value: {vec[vec < 0]}" assert np.all(vec <= 1.0), f"Value > 1: {vec[vec > 1]}" def test_dtype_float32(self): vec = _global_vec() assert vec.dtype == np.float32 class TestTotalPowerRealValue: """Index 7 uses real total_power when p['total_power'] > 0.""" def test_real_total_power_used(self): # 4 creatures, real power 19 — NOT 4*3=12 vec = _global_vec(creatures=4, total_power=19) expected = min(19 / 100.0, 1.0) assert abs(vec[P0_TOTAL_POWER] - expected) < 1e-5, ( f"Expected {expected:.4f} (real), got {vec[P0_TOTAL_POWER]:.4f}" ) def test_real_power_differs_from_heuristic(self): # Confirm that 19 != 4*3=12, so the two paths produce different values vec_real = _global_vec(creatures=4, total_power=19) vec_heuristic = _global_vec(creatures=4, total_power=0) assert vec_real[P0_TOTAL_POWER] != vec_heuristic[P0_TOTAL_POWER] def test_large_power_clamped_to_1(self): vec = _global_vec(creatures=30, total_power=200) assert vec[P0_TOTAL_POWER] == pytest.approx(1.0) def test_zero_power_with_zero_creatures_is_zero(self): vec = _global_vec(creatures=0, total_power=0) assert vec[P0_TOTAL_POWER] == pytest.approx(0.0) def test_power_normalisation(self): for raw in [10, 50, 100]: vec = _global_vec(creatures=1, total_power=raw) expected = min(raw / 100.0, 1.0) assert abs(vec[P0_TOTAL_POWER] - expected) < 1e-5, ( f"power={raw}: expected {expected}, got {vec[P0_TOTAL_POWER]}" ) class TestTotalPowerHeuristic: """Index 7 falls back to creatures*3 when total_power is absent or zero.""" def test_heuristic_used_when_total_power_zero(self): vec = _global_vec(creatures=5, total_power=0) expected = min((5 * 3) / 100.0, 1.0) assert abs(vec[P0_TOTAL_POWER] - expected) < 1e-5, ( f"Expected heuristic {expected:.4f}, got {vec[P0_TOTAL_POWER]:.4f}" ) def test_heuristic_with_zero_creatures(self): vec = _global_vec(creatures=0, total_power=0) assert vec[P0_TOTAL_POWER] == pytest.approx(0.0) def test_heuristic_clamped(self): # 40 creatures * 3 = 120, clamped to 100 → 1.0 vec = _global_vec(creatures=40, total_power=0) assert vec[P0_TOTAL_POWER] == pytest.approx(1.0) class TestLandCount: def test_real_land_count_used(self): vec = _global_vec(lands=8) expected = min(8 / 15.0, 1.0) assert abs(vec[P0_LANDS] - expected) < 1e-5 def test_zero_lands_default(self): vec = _global_vec(lands=0) assert vec[P0_LANDS] == pytest.approx(0.0) def test_lands_clamped_at_15(self): vec = _global_vec(lands=15) assert vec[P0_LANDS] == pytest.approx(1.0) def test_lands_over_15_clamped(self): vec = _global_vec(lands=30) assert vec[P0_LANDS] == pytest.approx(1.0) @pytest.mark.parametrize("n", [1, 4, 7, 10, 14]) def test_land_normalisation(self, n): vec = _global_vec(lands=n) assert abs(vec[P0_LANDS] - n / 15.0) < 1e-5 class TestCreatureCount: def test_real_creature_count_at_index_6(self): vec = _global_vec(creatures=7) expected = min(7 / 30.0, 1.0) assert abs(vec[P0_CREATURES] - expected) < 1e-5 def test_zero_creatures(self): vec = _global_vec(creatures=0) assert vec[P0_CREATURES] == pytest.approx(0.0) def test_creatures_clamped(self): vec = _global_vec(creatures=60) assert vec[P0_CREATURES] == pytest.approx(1.0) class TestManaFeature: def test_mana_normalised(self): vec = _global_vec(mana=10) assert abs(vec[P0_MANA] - 10 / 20.0) < 1e-5 def test_mana_clamped(self): vec = _global_vec(mana=100) assert vec[P0_MANA] == pytest.approx(1.0) def test_zero_mana(self): vec = _global_vec(mana=0) assert vec[P0_MANA] == pytest.approx(0.0) class TestLifeNormalisation: def test_full_life(self): vec = _global_vec(life=40) assert abs(vec[P0_LIFE] - 1.0) < 1e-5 def test_zero_life(self): vec = _global_vec(life=0) assert abs(vec[P0_LIFE] - 0.0) < 1e-5 def test_partial_life(self): vec = _global_vec(life=20) assert abs(vec[P0_LIFE] - 0.5) < 1e-5 def test_life_not_clamped_above_40(self): # Life gain can push above 40; encoder should still normalise vec = _global_vec(life=60) assert abs(vec[P0_LIFE] - 60 / 40.0) < 1e-5 class TestIsActivePlayer: def test_active_player_is_1(self): vec = _global_vec() # p0 seat=0, active_seat=0 assert vec[P0_IS_ACTIVE] == pytest.approx(1.0) def test_inactive_player_is_0(self): enc = _make_encoder() decision = _two_player_decision() decision["active_seat"] = 1 # p1 is active vec = enc._encode_global(decision, decision["players"]) assert vec[P0_IS_ACTIVE] == pytest.approx(0.0) assert vec[P1_LIFE + (P0_IS_ACTIVE - P0_LIFE)] == pytest.approx(1.0) class TestPhaseOneHot: @pytest.mark.parametrize("phase,expected_idx", [ ("main_1", 0), ("combat", 1), ("main_2", 2), ("end", 3), ]) def test_correct_bit_set(self, phase, expected_idx): enc = _make_encoder() decision = _two_player_decision() decision["phase"] = phase vec = enc._encode_global(decision, decision["players"]) phase_bits = vec[P0_PHASE_START:P0_PHASE_START + 4] assert phase_bits[expected_idx] == pytest.approx(1.0), ( f"Phase '{phase}': bit {expected_idx} should be 1, got {phase_bits}" ) def test_exactly_one_bit_set(self): for phase in ("main_1", "combat", "main_2", "end"): vec = _global_vec() enc = _make_encoder() decision = _two_player_decision() decision["phase"] = phase vec = enc._encode_global(decision, decision["players"]) phase_bits = vec[P0_PHASE_START:P0_PHASE_START + 4] assert sum(phase_bits) == pytest.approx(1.0), ( f"Phase '{phase}': expected exactly 1 hot, got {phase_bits}" ) def test_unknown_phase_defaults_to_main1(self): enc = _make_encoder() decision = _two_player_decision() decision["phase"] = "UNKNOWN_PHASE" vec = enc._encode_global(decision, decision["players"]) assert vec[P0_PHASE_START] == pytest.approx(1.0) class TestTurnFeature: def test_turn_1_normalised(self): enc = _make_encoder() decision = _two_player_decision() decision["turn"] = 1 vec = enc._encode_global(decision, decision["players"]) assert abs(vec[IDX_TURN] - 1 / GAME_SCOPE.max_turns) < 1e-5 def test_turn_clamped(self): enc = _make_encoder() decision = _two_player_decision() decision["turn"] = GAME_SCOPE.max_turns * 10 vec = enc._encode_global(decision, decision["players"]) assert vec[IDX_TURN] == pytest.approx(1.0) def test_turn_in_0_1(self): for t in [1, 5, 10, 20]: enc = _make_encoder() decision = _two_player_decision() decision["turn"] = t vec = enc._encode_global(decision, decision["players"]) assert 0.0 <= vec[IDX_TURN] <= 1.0 class TestFullEncode: def test_output_shape_6177(self): enc = _make_encoder() decision = _two_player_decision(creatures=3, total_power=9, lands=5) vec = enc.encode(decision, playstyle="midrange") assert vec.shape == (STATE_DIMS.total_state_dim,), ( f"Expected ({STATE_DIMS.total_state_dim},), got {vec.shape}" ) def test_output_dtype_float32(self): enc = _make_encoder() vec = enc.encode(_two_player_decision(), playstyle="aggro") assert vec.dtype == np.float32 def test_all_values_finite(self): enc = _make_encoder() vec = enc.encode(_two_player_decision(life=37, creatures=5, total_power=19, lands=7, mana=8), playstyle="control") assert np.all(np.isfinite(vec)), "Non-finite values in state vector" def test_different_playstyles_differ(self): enc = _make_encoder() decision = _two_player_decision() v_aggro = enc.encode(decision, playstyle="aggro") v_control = enc.encode(decision, playstyle="control") # Playstyle one-hot is at the end — the two vectors must differ there assert not np.allclose(v_aggro[-4:], v_control[-4:]) def test_real_power_vs_heuristic_differs_in_full_vector(self): """Confirm index 7 differs between real-power and heuristic snapshots.""" enc = _make_encoder() v_real = enc.encode(_two_player_decision(creatures=4, total_power=19)) v_heur = enc.encode(_two_player_decision(creatures=4, total_power=0)) assert v_real[P0_TOTAL_POWER] != v_heur[P0_TOTAL_POWER] class TestEmptyPlayer: def test_has_all_step4_keys(self): enc = _make_encoder() p = enc._empty_player(0) for key in ("total_power", "total_toughness", "artifacts", "enchantments", "lands"): assert key in p, f"Missing key '{key}' in _empty_player()" def test_step4_values_default_zero(self): enc = _make_encoder() p = enc._empty_player(0) for key in ("total_power", "total_toughness", "artifacts", "enchantments", "lands"): assert p[key] == 0, f"Expected 0 for '{key}', got {p[key]}" def test_encode_with_padded_player_succeeds(self): """Encoding a 1-player snapshot pads to 2 — should not raise.""" enc = _make_encoder() decision = _two_player_decision() decision["players"] = decision["players"][:1] # strip to 1 player vec = enc.encode(decision) assert vec.shape == (STATE_DIMS.total_state_dim,) class TestLegacySnapshot: """Old JSONL snapshots without Step 4 keys still encode without error.""" def test_missing_total_power_key(self): enc = _make_encoder() decision = _two_player_decision() for p in decision["players"]: p.pop("total_power", None) vec = enc.encode(decision) assert vec.shape == (STATE_DIMS.total_state_dim,) def test_missing_lands_key(self): enc = _make_encoder() decision = _two_player_decision() for p in decision["players"]: p.pop("lands", None) vec = enc.encode(decision) assert vec[P0_LANDS] == pytest.approx(0.0) def test_missing_all_step4_keys(self): enc = _make_encoder() decision = _two_player_decision() for p in decision["players"]: for key in ("total_power", "total_toughness", "artifacts", "enchantments", "lands"): p.pop(key, None) vec = enc.encode(decision) assert vec.shape == (STATE_DIMS.total_state_dim,) assert np.all(np.isfinite(vec)) def test_heuristic_applied_for_legacy_total_power(self): """Legacy snapshot: creatures=6, no total_power → index 7 = 6*3/100 = 0.18""" enc = _make_encoder() decision = _two_player_decision() decision["players"][0]["creatures"] = 6 decision["players"][0].pop("total_power", None) vec = enc.encode(decision) expected = min(6 * 3 / 100.0, 1.0) assert abs(vec[P0_TOTAL_POWER] - expected) < 1e-5