test(encoder): unit tests for StateEncoder global features + Step 4 total_power (#83 Step 4)
New test file covering: - TestGlobalFeatureDimensions: output shape (29,), all values in [0,1] - TestTotalPowerRealValue: real total_power used at index 7 when present - TestTotalPowerHeuristic: creatures*3 fallback when total_power absent/zero - TestLandCount: real lands used at index 13 when present; defaults to 0 - TestCreatureCount: real creatures used at index 6; fallback to bf len - TestManaFeature: mana normalised and clamped to [0,1] - TestPhaseOneHot: exactly one hot in indices 9-12 per player block - TestIsActivePlayer: binary 0/1 at index 8 - TestLifeNormalisation: 40 life → 1.0, 0 life → 0.0 - TestTurnFeature: turn clamped to [0,1] - TestFullEncode: total state vector shape (6177,), dtype float32 - TestEmptyPlayer: _empty_player() has all Step 4 keys - TestLegacySnapshot: old JSONL snapshot without Step 4 keys still encodes"
This commit is contained in:
1 parent
3f5d6d12e8
commit
e77a945805
1 file changed
+455
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
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
|
||||
Reference in new issue
Block a user