Files
commander-ai-lab/ml/config/scope.py
T
rgriffith ae00dd47e2 feat: Phase 4 — Distillation Loop Orchestrator (#68)
Implements the closed-loop distillation pipeline that automates:
  1. Supervised train on current dataset (Forge + PPO data)
  2. PPO self-play using supervised model as starting policy
  3. Export winning PPO decisions to JSONL
  4. Quality gate check (win rate threshold)
  5. If passed, merge into dataset and repeat
  6. If failed, discard batch, adjust hyperparameters, retry

New files:
  - ml/training/distillation_loop.py — DistillationLoop orchestrator,
    DistillationConfig, GenerationRecord, CLI entry point

Updated files:
  - ml/config/scope.py — DistillationDefaults dataclass with all
    default hyperparameters for the pipeline

Features:
  - Generation tagging: data and models tagged with gen-NNN
  - Convergence detection: stops when win rate plateaus over N gens
  - Automatic retry with LR reduction + entropy boost on gate failure
  - Per-generation history saved to JSON for observability
  - Full CLI with all tunable params
  - progress_callback for UI integration (Phase 5 ready)

Closes #68
2026-03-22 00:11:14 +00:00

305 lines
12 KiB
Python
Raw 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.
"""
Commander AI Lab — RL Scope Configuration
═══════════════════════════════════════════
Defines the simplified game scope, playstyle labels,
macro-action space, and state encoding dimensions for
the first RL prototype.
Design decisions:
- 1v1 Commander (2 players, 40 life, commander damage 21)
- ~8 macro-actions per decision point
- 4 playstyle labels: Aggro, Control, Midrange, Combo
- State = global features + per-zone card embeddings
"""
from dataclasses import dataclass, field
from typing import List, Dict
from enum import Enum
# ══════════════════════════════════════════════════════════
# Game Scope
# ══════════════════════════════════════════════════════════
@dataclass(frozen=True)
class GameScope:
"""Simplified Commander rules for RL prototype."""
pod_size: int = 2 # 1v1
starting_life: int = 40
commander_damage_lethal: int = 21
max_turns: int = 25 # Force game end
mulligan_rule: str = "london"
format: str = "commander"
GAME_SCOPE = GameScope()
# ══════════════════════════════════════════════════════════
# Playstyle Labels
# ══════════════════════════════════════════════════════════
class Playstyle(str, Enum):
"""Deck archetype / playstyle labels.
Each label implies a preferred strategic approach:
AGGRO — Prioritize attacking, fast damage, low curve
CONTROL — Hold up mana, prioritize removal, card advantage
MIDRANGE— Balanced: develop board, then attack when advantageous
COMBO — Protect combo pieces, tutor aggressively, race to assemble
"""
AGGRO = "aggro"
CONTROL = "control"
MIDRANGE = "midrange"
COMBO = "combo"
# Heuristic playstyle assignment rules (applied to deck metadata)
# These map deck characteristics → playstyle label
PLAYSTYLE_HEURISTICS = {
Playstyle.AGGRO: {
"avg_mana_value_max": 3.0, # Low average CMC
"creature_ratio_min": 0.30, # High creature density
"removal_ratio_max": 0.08, # Low removal count
"description": "Low curve, creature-heavy, minimal interaction"
},
Playstyle.CONTROL: {
"avg_mana_value_min": 3.2,
"removal_ratio_min": 0.10, # High removal/interaction
"creature_ratio_max": 0.25,
"description": "High interaction, card advantage, late-game finishers"
},
Playstyle.MIDRANGE: {
"avg_mana_value_min": 2.5,
"avg_mana_value_max": 3.8,
"creature_ratio_min": 0.20,
"creature_ratio_max": 0.35,
"description": "Balanced curve, flexible strategy"
},
Playstyle.COMBO: {
"combo_pieces_min": 2, # Has known combo cards
"tutor_count_min": 2, # Has tutors to find them
"description": "Win via specific card combinations"
},
}
# One-hot encoding dimension for playstyle
PLAYSTYLE_DIM = len(Playstyle) # 4
# Mapping for one-hot encoding
PLAYSTYLE_TO_IDX: Dict[Playstyle, int] = {
Playstyle.AGGRO: 0,
Playstyle.CONTROL: 1,
Playstyle.MIDRANGE: 2,
Playstyle.COMBO: 3,
}
# ══════════════════════════════════════════════════════════
# Macro-Action Space
# ══════════════════════════════════════════════════════════
class MacroAction(str, Enum):
"""High-level actions the RL agent can choose.
Each macro-action maps to one or more concrete Forge actions.
The Java side translates the chosen macro into specific game moves.
8 actions for the initial prototype:
"""
# Proactive plays
CAST_CREATURE = "cast_creature" # Play the best available creature
CAST_REMOVAL = "cast_removal" # Cast removal on strongest enemy threat
CAST_DRAW = "cast_draw" # Cast card-draw or cantrip spell
CAST_RAMP = "cast_ramp" # Cast ramp spell or mana rock
CAST_COMMANDER = "cast_commander" # Cast your commander from command zone
# Combat
ATTACK_OPPONENT = "attack_opponent" # Attack with all profitable attackers
# Reactive / passive
HOLD_MANA = "hold_mana" # Pass priority, keep mana open
PASS = "pass" # Pass with no action (end step, etc.)
# Number of discrete actions
NUM_ACTIONS = len(MacroAction) # 8
# Action index mapping
ACTION_TO_IDX: Dict[MacroAction, int] = {a: i for i, a in enumerate(MacroAction)}
IDX_TO_ACTION: Dict[int, MacroAction] = {i: a for a, i in ACTION_TO_IDX.items()}
# ══════════════════════════════════════════════════════════
# State Encoding Dimensions
# ══════════════════════════════════════════════════════════
@dataclass(frozen=True)
class StateDimensions:
"""Dimensions for the state vector encoding.
State = [global_features | zone_embeddings | playstyle_vector]
Global features (per player × 2 players):
- life_total (normalized 0-1 by /40)
- commander_damage_taken (normalized 0-1 by /21)
- mana_available (0-20 range, normalized)
- commander_tax (0-10 range, normalized)
- cards_in_hand (0-15, normalized)
- cards_in_graveyard (0-100, normalized)
- creatures_on_battlefield (0-30, normalized)
- total_power_on_board (0-100, normalized)
- is_active_player (0 or 1)
- turn_number (normalized 0-1 by /max_turns)
- phase (one-hot: main1, combat, main2, end = 4)
Per player = 10 scalar + 4 phase one-hot = 14 features
2 players = 28 global features
+ turn_number = 1
Total global = 29
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
per_player_scalars: int = 10
phase_onehot: int = 4 # main1, combat, main2, end
per_player_features: int = 14 # 10 + 4
global_features: int = 29 # 14×2 + 1 (turn)
# Playstyle vector
playstyle_dim: int = 4 # One-hot over 4 styles
@property
def total_state_dim(self) -> int:
"""Total dimension of the flattened state vector."""
return self.global_features + self.total_zone_dim + self.playstyle_dim
# 29 + 6144 + 4 = 6177
STATE_DIMS = StateDimensions()
# ══════════════════════════════════════════════════════════
# Game Phase Encoding
# ══════════════════════════════════════════════════════════
class GamePhase(str, Enum):
MAIN_1 = "main_1"
COMBAT = "combat"
MAIN_2 = "main_2"
END = "end"
PHASE_TO_IDX = {
GamePhase.MAIN_1: 0,
GamePhase.COMBAT: 1,
GamePhase.MAIN_2: 2,
GamePhase.END: 3,
}
# ══════════════════════════════════════════════════════════
# Forge Log Action-Label Mapping Keywords
# ══════════════════════════════════════════════════════════
# These keywords in Forge log lines help classify raw actions
# into macro-actions for supervised learning.
# Used by ml/actions/labeler.py
ACTION_KEYWORDS = {
MacroAction.CAST_CREATURE: [
"creature", "token", "summon",
],
MacroAction.CAST_REMOVAL: [
"destroy", "exile", "damage to", "counter",
"-X/-X", "sacrifice", "bounce",
],
MacroAction.CAST_DRAW: [
"draw", "scry", "look at the top", "reveal",
"search your library",
],
MacroAction.CAST_RAMP: [
"add {", "mana", "land onto the battlefield",
"search your library for a basic land",
],
MacroAction.CAST_COMMANDER: [], # Detected by matching commander name
MacroAction.ATTACK_OPPONENT: [
"attacks", "declare attackers", "combat damage",
],
MacroAction.HOLD_MANA: [], # Inferred: priority passed with mana open
MacroAction.PASS: [], # Inferred: priority passed with no mana / no actions
}
# ══════════════════════════════════════════════════════════
# Training Defaults
# ══════════════════════════════════════════════════════════
@dataclass
class TrainingConfig:
"""Default hyperparameters for supervised policy learning."""
batch_size: int = 64
learning_rate: float = 1e-3
epochs: int = 50
hidden_dim: int = 512
num_layers: int = 3
dropout: float = 0.1
activation: str = "silu"
early_stop_patience: int = 5
val_split: float = 0.15
test_split: float = 0.10
checkpoint_dir: str = "ml/models/checkpoints"
log_every: int = 100 # Log every N batches
TRAINING_CONFIG = TrainingConfig()
# ══════════════════════════════════════════════════════════
# Distillation Loop Defaults (Phase 4)
# ══════════════════════════════════════════════════════════
@dataclass
class DistillationDefaults:
"""Default hyperparameters for the closed-loop distillation pipeline.
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
"""
max_iterations: int = 10
convergence_window: int = 3
convergence_threshold: float = 0.01
# PPO self-play per generation
ppo_iterations: int = 50
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing
forge_weight: float = 1.0
ppo_weight: float = 0.5
min_reward_threshold: float = 0.0
# Quality gate thresholds
min_forge_accuracy: float = 0.35
max_accuracy_drop: float = 0.03
min_ppo_win_rate: float = 0.30
# Retry behaviour on gate failure
max_retries_per_generation: int = 2
retry_lr_factor: float = 0.5
retry_entropy_boost: float = 0.005
# Output paths
results_dir: str = "data/results"
history_dir: str = "data/results/distillation-history"
DISTILLATION_DEFAULTS = DistillationDefaults()