Files
commander-ai-lab/ml/actions/labeler.py
T
rgriffithandClaude Opus 4.6 9b79d18201 feat(lan-client): triggers panel + CMD DMG score badges + fix encoding project-wide
Left action panel revised to show triggers list + Create Token/Auto-Arrange.
CMD DMG badges added to center life-panel score areas (click opens matrix).
Removed renderEnhancedPanels calls that split the battlefield on damage adjust.
Added encoding='utf-8' to all bare open() calls across 17 Python files to
prevent cp1252 crashes on MTG card text with smart quotes/em-dashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:14:56 -04:00

373 lines
15 KiB
Python

"""
Commander AI Lab — Action Labeler
═══════════════════════════════════
Maps raw Forge action records (from DecisionSnapshot JSONL)
to one of 8 macro-action labels for supervised policy learning.
Classification logic (updated):
1. Check action.type for unambiguous types (attack, land, pass, hold,
cast_commander)
2. For "cast" actions:
a. If action.card is present, match directly against name databases
b. If action.card is absent (Forge omits it), scan the active
player's hand against name databases to infer the cast type.
Priority: removal > ramp > draw > commander > creature (default)
3. Fallback: PASS
Macro-actions (from ml.config.scope):
0: CAST_CREATURE — Play a creature
1: CAST_REMOVAL — Cast removal / interaction
2: CAST_DRAW — Cast card-draw spell
3: CAST_RAMP — Cast ramp spell / mana rock
4: CAST_COMMANDER — Cast your commander
5: ATTACK_OPPONENT — Attack declaration
6: HOLD_MANA — Pass priority with mana open
7: PASS — Pass with nothing to do
"""
import json
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from ml.config.scope import (
MacroAction, ACTION_TO_IDX, NUM_ACTIONS, ACTION_KEYWORDS,
)
logger = logging.getLogger("ml.labeler")
# ══════════════════════════════════════════════════════════
# Card Type Keyword Databases
# ══════════════════════════════════════════════════════════
REMOVAL_CARD_NAMES = {
"swords to plowshares", "path to exile", "beast within", "chaos warp",
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
"doom blade", "go for the throat", "terminate", "anguished unmaking",
"vindicate", "generous gift", "rapid hybridization", "pongify",
"cyclonic rift", "farewell", "toxic deluge", "blasphemous act",
"wrath of god", "damnation", "supreme verdict", "vandalblast",
"krosan grip", "nature's claim", "return to dust", "despark",
"assassin's trophy", "abrupt decay", "force of will",
"mana drain", "force of negation", "deflecting swat",
"fierce guardianship", "deadly rollick", "snuff out",
"murderous rider", "shriekmaw", "ravenous chupacabra",
"bane of progress", "austere command", "merciless eviction",
"decree of pain", "in garruk's wake", "plague wind",
"fire covenant", "imprisoned in the moon", "darksteel mutation",
"kenrith's transformation", "song of the dryads",
"abrade", "disenchant", "naturalize", "wear", "tear",
"shattering spree", "shatter", "smash to smithereens",
"lightning bolt", "lightning helix", "condemn", "oust",
"pongify", "reality shift", "turn to frog", "polymorph",
"boros charm", "utter end", "anguished unmaking", "mortify",
"putrefy", "maelstrom pulse", "void rend", "render silent",
"overwhelming denial", "test of talents", "dispel", "spell pierce",
"flusterstorm", "mental misstep", "pact of negation",
"opposition agent", "hullbreacher", "narset parter of veils",
"grafdigger's cage", "rest in peace", "leyline of the void",
"tormod's crypt", "relic of progenitus",
}
DRAW_CARD_NAMES = {
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
"harmonize", "blue sun's zenith", "pull from tomorrow",
"windfall", "wheel of fortune", "timetwister", "brainstorm",
"ponder", "preordain", "opt", "consider", "treasure cruise",
"dig through time", "fact or fiction", "night's whisper",
"sign in blood", "read the bones", "painful truths",
"ambition's cost", "promise of power", "necropotence",
"dark confidant", "toski bearer of secrets", "beast whisperer",
"guardian project", "the great henge", "skullclamp",
"esper sentinel", "archivist of oghma", "mind's eye",
"consecrated sphinx", "ancient craving", "faithless looting",
"cathartic reunion", "thrill of possibility", "jeska's will",
"valakut awakening", "stinging study", "notion thief",
"tymna the weaver", "shamanic revelation", "rishkar's expertise",
"return of the wildspeaker", "garruk's uprising", "elemental bond",
"kindred discovery", "coastal piracy", "reconnaissance mission",
"bident of thassa", "distant melody", "slate of ancestry",
"wheel of fate", "reforge the soul", "magus of the wheel",
"river's rebuke", "prosperity", "intellectual offering",
"peer into the abyss", "mind spring", "stroke of genius",
"jace's ingenuity", "tidings", "concentrate", "divination",
"inspiration", "counsel of the soratami", "accumulated knowledge",
"predict", "pressure point", "quicken", "serum visions",
"gitaxian probe", "mental note", "thought scour",
}
RAMP_CARD_NAMES = {
"sol ring", "mana crypt", "mana vault", "arcane signet",
"commander's sphere", "chromatic lantern", "fellwar stone",
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
"gilded lotus", "hedron archive", "everflowing chalice",
"coalition relic", "prismatic lens",
"cultivate", "kodama's reach", "rampant growth", "farseek",
"nature's lore", "three visits", "skyshroud claim",
"explosive vegetation", "migration path", "circuitous route",
"tempt with discovery", "hour of promise", "boundless realms",
"sakura-tribe elder", "wood elves", "farhaven elf",
"solemn simulacrum", "burnished hart", "knight of the white orchid",
"dockside extortionist", "smothering tithe", "black market",
"cabal coffers", "nykthos shrine to nyx", "ancient tomb",
"birds of paradise", "llanowar elves", "elvish mystic",
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
"bloom tender", "priest of titania", "selvala heart of the wilds",
"boreal druid", "green sun's zenith", "eternal witness",
"reclamation sage", "mystic snake", "fierce empath",
"signal the clans", "crop rotation", "harrow", "kodama's reach",
"traverse the ulvenwald", "search for tomorrow", "primal growth",
"land grant", "untamed wilds", "rangers path", "rangers' path",
"nissa's pilgrimage", "forest grove", "grove of the burnwillows",
"cabal stronghold", "urborg tomb of yawgmoth", "urborg, tomb of yawgmoth",
"bojuka bog", "ghost quarter", "field of ruin", "strip mine",
"wasteland", "tectonic edge", "dust bowl",
"worn powerstone", "basalt monolith", "grim monolith",
"mana geode", "darksteel ingot", "manalith", "opaline unicorn",
"vessel of endless rest", "corrupted grafstone", "dreamstone hedron",
"sisay's ring", "ur-golem's eye", "blinkmoth urn",
"astral cornucopia", "druids' repository", "black market",
"cryptolith rite", "earthcraft", "citanul hierophants",
"zendikar resurgent", "mirari's wake", "mana reflection",
"vorinclex voice of hunger", "vorinclex, voice of hunger",
}
# Partial-match prefixes (for cycles like Signets, Talismans)
RAMP_PARTIAL_PREFIXES = {
"talisman", "signet", "locket", "banner", "obelisk",
"keyrune", "cluestone", "monolith", "medallion",
}
DRAW_PARTIAL_PREFIXES = {
"scroll", "tome", "journal",
}
# ══════════════════════════════════════════════════════════
# Core classification helpers
# ══════════════════════════════════════════════════════════
def _classify_card_name(name: str) -> Optional[MacroAction]:
"""
Classify a single card name into a MacroAction, or None if unknown.
Checks exact matches first, then partial prefix matches.
Returns None for unrecognised cards (caller handles the default).
"""
n = name.lower().strip()
if not n:
return None
if n in REMOVAL_CARD_NAMES:
return MacroAction.CAST_REMOVAL
if n in DRAW_CARD_NAMES:
return MacroAction.CAST_DRAW
if n in RAMP_CARD_NAMES:
return MacroAction.CAST_RAMP
# Partial prefix match for cycles (Talismans, Signets, etc.)
for prefix in RAMP_PARTIAL_PREFIXES:
if n.startswith(prefix):
return MacroAction.CAST_RAMP
for prefix in DRAW_PARTIAL_PREFIXES:
if n.startswith(prefix):
return MacroAction.CAST_DRAW
# Substring match (catches "Doom Blade" inside longer names etc.)
for known in REMOVAL_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_REMOVAL
for known in DRAW_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_DRAW
for known in RAMP_CARD_NAMES:
if known in n or n in known:
return MacroAction.CAST_RAMP
return None # Unknown → caller defaults to CAST_CREATURE
def _classify_from_hand(
players: list,
active_seat: int,
commander_names: Optional[set] = None,
) -> MacroAction:
"""
Infer the cast action type by scanning the active player's hand.
When action.card is absent (Forge omits it), the hand snapshot
is the best available signal. We scan all hand cards and return
the highest-priority match:
removal > ramp > draw > commander > creature (default)
Priority rationale: Forge's AI plays the highest-impact available
action, so if any removal/ramp/draw spell is in hand during a cast
decision, it's the most likely card played.
"""
if active_seat >= len(players):
return MacroAction.CAST_CREATURE
hand: List[str] = players[active_seat].get("hand", [])
command_zone: List[str] = players[active_seat].get("command_zone", [])
# Build commander name set from command_zone if not provided
if commander_names is None:
commander_names = {c.lower().strip() for c in command_zone if c}
found_removal = False
found_ramp = False
found_draw = False
found_commander = False
for card in hand:
card_lower = card.lower().strip()
# Commander check: hand card matches a command zone card
if card_lower in commander_names:
found_commander = True
continue
result = _classify_card_name(card_lower)
if result == MacroAction.CAST_REMOVAL:
found_removal = True
elif result == MacroAction.CAST_RAMP:
found_ramp = True
elif result == MacroAction.CAST_DRAW:
found_draw = True
# Return highest-priority found
if found_removal:
return MacroAction.CAST_REMOVAL
if found_ramp:
return MacroAction.CAST_RAMP
if found_draw:
return MacroAction.CAST_DRAW
if found_commander:
return MacroAction.CAST_COMMANDER
return MacroAction.CAST_CREATURE
# ══════════════════════════════════════════════════════════
# Main label_action entry point
# ══════════════════════════════════════════════════════════
def label_action(decision: dict) -> int:
"""
Classify a single decision snapshot's action into a macro-action index.
Args:
decision: Dict loaded from JSONL line
Returns:
Integer index into MacroAction enum (0-7)
"""
action = decision.get("action", {})
action_type = action.get("type", "").lower()
card_name = (action.get("card", "") or "").lower().strip()
players = decision.get("players", [])
active_seat = decision.get("active_seat", 0)
# ── Unambiguous direct-type matches ──────────────────────
if action_type == "cast_commander":
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
if action_type == "attack":
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
# Land play → developing mana base = ramp
if action_type == "land":
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# Pass / hold mana — check available mana to distinguish
if action_type in ("pass", "hold"):
if active_seat < len(players):
mana = players[active_seat].get("mana", 0)
if mana >= 2:
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
return ACTION_TO_IDX[MacroAction.PASS]
# ── Cast actions ──────────────────────────────────────
if action_type == "cast":
# Path A: action.card is present — classify it directly
if card_name:
result = _classify_card_name(card_name)
if result is not None:
return ACTION_TO_IDX[result]
# Unrecognised named card → default creature
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
# Path B: action.card absent (Forge omits it) — infer from hand
inferred = _classify_from_hand(players, active_seat)
return ACTION_TO_IDX[inferred]
# ── Fallback ──────────────────────────────────────────
return ACTION_TO_IDX[MacroAction.PASS]
# ══════════════════════════════════════════════════════════
# File-level helpers
# ══════════════════════════════════════════════════════════
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
"""
Label all decisions in a JSONL file.
Returns:
(labels, raw_decisions) where:
labels: np.ndarray of shape (N,) with int action indices
raw_decisions: list of original JSON dicts
"""
labels = []
raw = []
with open(jsonl_path, "r", encoding="utf-8") as f:
for i, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
label = label_action(decision)
labels.append(label)
raw.append(decision)
except (json.JSONDecodeError, Exception) as e:
logger.warning("Skipping line %d: %s", i, e)
continue
return np.array(labels, dtype=np.int64), raw
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
"""Print and return the distribution of action labels.
Iterates MacroAction enum in index order so the display always
matches ACTION_TO_IDX regardless of dict insertion order.
"""
dist = {}
for action in MacroAction:
idx = ACTION_TO_IDX[action]
count = int(np.sum(labels == idx))
dist[action.value] = count
total = len(labels)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
# Sort descending by count for readability
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
logger.info("─" * 43)
logger.info(f" {'TOTAL':<23} {total:>8}")
return dist