Files
commander-ai-lab/tests/test_state_encoder.py
T
KoalaTrapLord e77a945805 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"
2026-03-29 05:46:01 -04:00

456 lines
15 KiB
Python

"""
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