Files
commander-ai-lab/tests/test_phase5_followup.py
d795e1a8fd feat: Phase 5 architecture follow-up — zone boundary, WAL store, Forge-only defaults (#87)
Implements the three approved Phase 5 architecture decisions:

1. Zone embedding boundary: add explicit documentation in StateDimensions
   and StateEncoder._encode_zones making the 6144-dim zero-padded zone
   contract visible to future maintainers.

2. SQLite WAL online learning store: new OnlineLearningStore class backed
   by WAL-mode SQLite for concurrent read/write on single-machine baseline.
   New /api/policy/decide endpoint collects decisions during inference.
   Stats endpoint at /api/policy/decide/stats.

3. Forge-only default weights: change ppo_weight default from 0.5 to 0.0
   across scope.py, DistillationConfig, DatasetConfig, and CLI defaults.
   Add MIXED_MODE_PRESETS dict with forge_only, forge_90_10, forge_80_20
   presets. Distill/start endpoint now accepts a "preset" parameter.

27 new tests covering all three decisions (all passing).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:41:33 -04:00

292 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Phase 5 Architecture Follow-Up — Unit Tests
============================================
Tests for the three approved decisions:
1. Zone embedding boundary is explicit (docs + code + assertion)
2. SQLite WAL-backed online learning store
3. Forge-only default weights + mixed-mode presets
Run with: pytest tests/test_phase5_followup.py -v
"""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import numpy as np
import pytest
# ====================================================================
# Decision 1: Zone Embedding Boundary
# ====================================================================
class TestZoneEmbeddingBoundary:
"""Verify that the zone embedding contract is explicit and enforced."""
def test_state_dims_total_zone_dim(self):
from ml.config.scope import STATE_DIMS
# 4 zones × 2 players × 768 = 6144
assert STATE_DIMS.total_zone_dim == 4 * 2 * 768 == 6144
def test_state_dims_total_state_dim(self):
from ml.config.scope import STATE_DIMS
# 29 global + 6144 zone + 4 playstyle = 6177
assert STATE_DIMS.total_state_dim == 6177
def test_zone_pool_dim_matches_card_embedding_dim(self):
from ml.config.scope import STATE_DIMS
# Zone pool output is same size as card embedding input
assert STATE_DIMS.zone_pool_dim == STATE_DIMS.card_embedding_dim == 768
def test_empty_zone_produces_zero_vector(self):
"""Empty zone must produce a zero vector of exactly zone_pool_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex
idx = CardEmbeddingIndex()
# Even without loading, mean_pool_zone returns a zero vec
vec = idx.mean_pool_zone([])
assert vec.shape == (STATE_DIMS.zone_pool_dim,)
assert np.all(vec == 0.0)
def test_encode_zones_shape_with_empty_players(self):
"""_encode_zones must always return exactly total_zone_dim floats."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
# Two players with empty zones
players = [
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
]
zone_vec = enc._encode_zones(players)
assert zone_vec.shape == (STATE_DIMS.total_zone_dim,)
# All zeros because all zones are empty
assert np.all(zone_vec == 0.0)
def test_full_encode_shape(self):
"""Full state vector must be exactly total_state_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
decision = {
"turn": 3,
"phase": "main_1",
"active_seat": 0,
"players": [
{
"seat": 0, "life": 40, "cmdr_dmg": 0, "mana": 4,
"cmdr_tax": 0, "creatures": 2, "lands": 4,
"hand": ["Island", "Counterspell"],
"battlefield": ["Tundra"],
"graveyard": [],
"command_zone": [],
},
{
"seat": 1, "life": 38, "cmdr_dmg": 0, "mana": 3,
"cmdr_tax": 0, "creatures": 1, "lands": 3,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
},
],
}
state = enc.encode(decision, playstyle="control")
assert state.shape == (STATE_DIMS.total_state_dim,)
assert state.dtype == np.float32
def test_boundary_docstring_present(self):
"""StateDimensions must document the zone embedding boundary."""
from ml.config.scope import StateDimensions
assert "zone embedding boundary" in StateDimensions.__doc__.lower()
# ====================================================================
# Decision 2: SQLite WAL Online Learning Store
# ====================================================================
class TestOnlineLearningStore:
"""Verify the SQLite WAL-backed online learning store."""
@pytest.fixture
def store(self, tmp_path):
from ml.serving.online_learning_store import OnlineLearningStore
db_path = str(tmp_path / "test_online.db")
s = OnlineLearningStore(db_path=db_path)
s.init_db()
return s
def test_init_creates_db(self, store):
assert os.path.exists(store.db_path)
def test_wal_mode_enabled(self, store):
conn = store._get_conn()
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == "wal"
def test_record_and_fetch(self, store):
snapshot = {"turn": 1, "phase": "main_1", "players": []}
row_id = store.record_decision(
snapshot=snapshot,
action_idx=3,
confidence=0.85,
playstyle="control",
temperature=0.8,
greedy=False,
)
assert row_id >= 1
decisions = store.fetch_decisions()
assert len(decisions) == 1
d = decisions[0]
assert d["action_idx"] == 3
assert d["confidence"] == pytest.approx(0.85)
assert d["playstyle"] == "control"
assert d["temperature"] == pytest.approx(0.8)
assert d["greedy"] == 0
assert d["snapshot"]["turn"] == 1
def test_count(self, store):
assert store.count() == 0
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
assert store.count() == 2
assert store.count(only_unexported=True) == 2
def test_mark_exported(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
store.record_decision({"t": 3}, 2, 0.7)
# Mark first two as exported
updated = store.mark_exported(up_to_rowid=2)
assert updated == 2
assert store.count(only_unexported=True) == 1
# Fetch only unexported
remaining = store.fetch_decisions(only_unexported=True)
assert len(remaining) == 1
assert remaining[0]["action_idx"] == 2
def test_fetch_since_rowid(self, store):
ids = []
for i in range(5):
ids.append(store.record_decision({"t": i}, i, 0.5))
# Fetch only rows after id 3
results = store.fetch_decisions(since_rowid=ids[2], only_unexported=False)
assert len(results) == 2 # ids[3] and ids[4]
assert results[0]["action_idx"] == 3
def test_fetch_limit(self, store):
for i in range(10):
store.record_decision({"t": i}, i % 8, 0.5)
results = store.fetch_decisions(limit=3)
assert len(results) == 3
def test_close_and_reopen(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.close()
# Re-open by calling _get_conn again
assert store.count() == 1
def test_snapshot_stored_as_compact_json(self, store):
"""Snapshots should be stored as compact JSON (no extra whitespace)."""
store.record_decision({"key": "value", "num": 42}, 0, 0.5)
conn = store._get_conn()
raw = conn.execute(
"SELECT snapshot FROM online_decisions WHERE id=1"
).fetchone()[0]
# Compact JSON has no spaces after separators
assert " " not in raw
assert json.loads(raw) == {"key": "value", "num": 42}
# ====================================================================
# Decision 3: Forge-Only Defaults + Mixed-Mode Presets
# ====================================================================
class TestForgeOnlyDefaults:
"""Verify default weights are Forge-only (ppo_weight=0.0)."""
def test_distillation_defaults_forge_only(self):
from ml.config.scope import DISTILLATION_DEFAULTS
assert DISTILLATION_DEFAULTS.forge_weight == 1.0
assert DISTILLATION_DEFAULTS.ppo_weight == 0.0
def test_distillation_config_forge_only(self):
from ml.training.distillation_loop import DistillationConfig
cfg = DistillationConfig()
assert cfg.forge_weight == 1.0
assert cfg.ppo_weight == 0.0
def test_dataset_config_forge_only(self):
from ml.data.dataset_builder import DatasetConfig
cfg = DatasetConfig()
assert cfg.source_weights["forge"] == 1.0
assert cfg.source_weights["ppo"] == 0.0
class TestMixedModePresets:
"""Verify mixed-mode weight presets exist and are correct."""
def test_presets_dict_exists(self):
from ml.config.scope import MIXED_MODE_PRESETS
assert isinstance(MIXED_MODE_PRESETS, dict)
assert len(MIXED_MODE_PRESETS) == 3
def test_forge_only_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_only"]
assert p["forge_weight"] == 1.0
assert p["ppo_weight"] == 0.0
def test_forge_90_10_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_90_10"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.11 gives approximately 10% PPO in the mix
assert 0.1 <= p["ppo_weight"] <= 0.15
def test_forge_80_20_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_80_20"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.25 gives approximately 20% PPO
assert 0.2 <= p["ppo_weight"] <= 0.3
def test_get_preset_weights_valid(self):
from ml.config.scope import get_preset_weights
w = get_preset_weights("forge_90_10")
assert "forge_weight" in w
assert "ppo_weight" in w
def test_get_preset_weights_returns_copy(self):
"""get_preset_weights must return a copy, not the original dict."""
from ml.config.scope import get_preset_weights, MIXED_MODE_PRESETS
w = get_preset_weights("forge_only")
w["forge_weight"] = 999
assert MIXED_MODE_PRESETS["forge_only"]["forge_weight"] == 1.0
def test_get_preset_weights_invalid(self):
from ml.config.scope import get_preset_weights
with pytest.raises(KeyError, match="Unknown preset"):
get_preset_weights("nonexistent")
def test_all_presets_have_both_keys(self):
from ml.config.scope import MIXED_MODE_PRESETS
for name, preset in MIXED_MODE_PRESETS.items():
assert "forge_weight" in preset, f"Preset '{name}' missing forge_weight"
assert "ppo_weight" in preset, f"Preset '{name}' missing ppo_weight"