Initial commit

This commit is contained in:
KoalaTrapLord committed 2026-03-10 12:13:56 -04:00
commit e6b5e0cedb
402 files changed
+64931

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+362
View File
@@ -0,0 +1,362 @@
# Commander AI Lab
Headless batch simulation environment for 3-AI MTG Commander pods.
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Web Browser (ai-lab.js + ai-lab.css) │
│ Select decks → Start batch → Poll progress → View stats │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP (REST)
┌─────────────────────┴───────────────────────────────────────┐
│ Python FastAPI Server (lab_api.py, port 8080) │
│ /api/lab/start — spawn Java batch runner subprocess │
│ /api/lab/status — poll progress (parses stdout) │
│ /api/lab/result — return completed JSON results │
│ /api/lab/decks — list decks from Forge user data │
│ /api/lab/history— browse past batch runs │
│ /api/lab/log — stream live console output │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess (java -jar)
┌─────────────────────┴───────────────────────────────────────┐
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
│ Parses Forge sim output → GameResult objects → JSON │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess per game
┌─────────────────────┴───────────────────────────────────────┐
│ Card Forge Engine (forge-gui-desktop sim mode) │
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
│ -f commander -n 1 -q │
└─────────────────────────────────────────────────────────────┘
```
## Quick Start (Windows)
### Prerequisites
- **Java 17+** (`java -version`)
- **Python 3.10+** (`python --version`)
- **Maven 3.8+** (`mvn --version`)
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
### 1. Build Forge (one-time)
```powershell
cd D:\ForgeCommander\forge-repo
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
```
### 2. Build Commander AI Lab
```powershell
cd D:\ForgeCommander\commander-ai-lab
mvn package "-DskipTests"
```
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
### 3. Install Python dependencies
```powershell
pip install fastapi uvicorn
```
### 4. Launch the Web UI
**Option A — One-click launcher:**
```powershell
.\start-lab.bat
```
Edit the paths at the top of `start-lab.bat` if needed.
**Option B — Manual launch:**
```powershell
python lab_api.py `
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
--port 8080
```
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
### 5. CLI-only usage (no web server)
```powershell
cd D:\ForgeCommander\forge-repo\forge-gui
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "." `
--deck1 "Edgar Markov" `
--deck2 "Grimgrin" `
--deck3 "Xyris" `
--games 100 `
--threads 4 `
--output results/batch-100.json
```
## Scaling Tests
### Single-threaded baseline (10 games)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 1 --output results/baseline-10.json
```
### Multi-threaded (10 games, 2 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 2 --output results/mt-10-t2.json
```
### Scale to 100 games (4 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 4 --output results/scale-100-t4.json
```
### Maximum throughput test (100 games, 8 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
```
### Performance comparison script
Run different thread counts and compare sims/sec:
```powershell
# Baseline: 1 thread
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
# 2 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
# 4 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
# 8 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
```
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
## API Reference
| Endpoint | Method | Description |
|---|---|---|
| `/api/lab/decks` | GET | List available Commander decks |
| `/api/lab/start` | POST | Start a batch run (returns batchId) |
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
| `/api/lab/history` | GET | List past batch result files |
### POST /api/lab/start
```json
{
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
"numGames": 100,
"threads": 4,
"seed": null,
"clock": 120
}
```
### GET /api/lab/status response
```json
{
"batchId": "abc123",
"running": true,
"completed": 42,
"total": 100,
"threads": 4,
"elapsedMs": 156000,
"error": null
}
```
## Python Simulator (Monte Carlo Engine)
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
### Location
```
src/commander_ai_lab/
├── __init__.py
├── sim/ # Core simulation engine
│ ├── __init__.py # Public exports
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
└── lab/ # High-level experiment helpers
├── __init__.py
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
```
### Install as editable package
```powershell
cd D:\ForgeCommander\commander-ai-lab
pip install -e .
```
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
### Quick smoke test
```powershell
python -m commander_ai_lab.lab.experiments
```
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
```
══════════════════════════════════════════
Commander AI Lab — Experiment Runner
══════════════════════════════════════════
--- Demo: Korvold Aristocrats (10 games) ---
Win rate: XX.X%
Avg turns: XX.X
Avg damage dealt: XX.X
Avg spells cast: XX.X
Avg creatures: XX.X
Elapsed: 0.02s
Done.
```
### Python API
```python
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.lab.experiments import build_deck, run_simulation
# Build a deck from card names
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
# Monte Carlo simulation (100 games)
stats = run_simulation(deck, num_games=100, name_a="My Deck")
print(f"Win rate: {stats['win_rate']:.1%}")
print(f"Avg turns: {stats['avg_turns']:.1f}")
```
Key functions:
| Function | Description |
|---|---|
| `build_deck(card_names)` | Create enriched `Card` list from names |
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
### CLI entry point
After `pip install -e .`:
```powershell
commander-sim
```
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
### How the engine works
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
## Project Structure
```
commander-ai-lab/
├── lab_api.py # Python FastAPI server (web UI backend)
├── start-lab.bat # Windows one-click launcher
├── pom.xml # Maven build (Java 17, shaded JAR)
├── pyproject.toml # Python package config (src/ layout)
├── ui/
│ ├── index.html # Web UI entry point
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
│ ├── ai-lab.css # Styles (dark theme, responsive)
│ ├── collection.html # Card collection manager
│ ├── collection.js # Collection controller
│ ├── collection.css # Collection styles
│ ├── deckbuilder.html # Deck builder + EDH advisor
│ ├── deckbuilder.js # Deck builder controller
│ └── deckbuilder.css # Deck builder styles
├── src/commander_ai_lab/ # Python simulator package
│ ├── __init__.py
│ ├── sim/ # Core engine (models, engine, rules)
│ └── lab/ # Experiment helpers
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
│ ├── cli/LabCli.java # CLI entry point (picocli)
│ ├── batch/
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
│ ├── schema/
│ │ ├── BatchResult.java # Data model POJOs
│ │ └── JsonExporter.java # JSON serialization + validation
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
│ └── ai/
│ ├── AiPolicy.java # Policy interface
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
├── scanner/ # Card scanner (OCR pipeline)
│ ├── __init__.py
│ ├── ocr.py # Tesseract OCR wrapper
│ ├── pipeline.py # Image preprocessing pipeline
│ └── preprocess.py # Image preprocessing helpers
├── docs/
│ ├── PROJECT_VISION.md
│ ├── batch-result-schema.json
│ └── game-log-schema.json
├── sample-decks/ # 3 sample .dck files
├── precon-decks/ # Preconstructed decks + index
├── results/ # Batch output JSON files (gitignored)
└── src/test/java/ # Unit tests
```
## Output Format
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
```json
{
"metadata": {
"schemaVersion": "1.0.0",
"batchId": "uuid",
"timestamp": "2026-03-06T...",
"totalGames": 100,
"completedGames": 100,
"format": "commander",
"podSize": 3,
"engineVersion": "forge-2.0.12-SNAPSHOT",
"masterSeed": 42,
"threads": 4,
"elapsedMs": 260000
},
"decks": [...],
"games": [...],
"summary": {
"perDeck": [
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
],
"avgGameTurns": 12.3,
"simsPerSecond": 0.38
}
}
```
## Known Limitations
- Winner's life total not captured (Forge quiet mode only reports losers)
- Win condition classification is heuristic-based on Forge loss reason strings
- Each game spawns a new JVM process (~2-3s overhead per game)
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
+1
View File
@@ -0,0 +1 @@
["\"Ach! Hans, Run!\"", "\"Brims\" Barone, Midway Mobster", "\"Lifetime\" Pass Holder", "\"Name Sticker\" Goblin", "\"Rumors of My Death . . .\"", "+2 Mace", "17-Year Cicadas", "1996 World Champion", "A Display of My Dark Power", "A Girl and Her Dogs", "A Golden Opportunity", "A Good Day to Pie", "A Good Thing", "A Killer Among Us", "A Little Chat", "A Mysterious Creature", "A Premonition of Your Demise", "A Real Handful", "A Realm Reborn", "A Reckoning Approaches", "A Tale for the Ages", "A-Acererak the Archlich", "A-Akki Ronin", "A-Alrund's Epiphany", "A-Alrund, God of the Cosmos // A-Hakka, Whispering Raven", "A-Ancestral Katana", "A-Ardent Dustspeaker", "A-Armory Veteran", "A-Asari Captain", "A-Baleful Beholder", "A-Base Camp", "A-Binding Geist // A-Spectral Binding", "A-Blessed Hippogriff // A-Tyr's Blessing", "A-Blood Artist", "A-Bretagard Stronghold", "A-Briar Hydra", "A-Brine Comber // A-Brinebound Gift", "A-Bruenor Battlehammer", "A-Buy Your Silence", "A-Cabaretti Charm", "A-Canopy Tactician", "A-Capenna Express", "A-Carnelian Orb of Dragonkind", "A-Case the Joint", "A-Cauldron Familiar", "A-Celebrity Fencer", "A-Celestial Regulator", "A-Circle of the Land Druid", "A-Circuit Mender", "A-Civil Servant", "A-Cloister Gargoyle", "A-Cobbled Lancer", "A-Cori-Steel Cutter", "A-Cosmos Charger", "A-Cosmos Elixir", "A-Dawnbringer Cleric", "A-Deal Gone Bad", "A-Death-Priest of Myrkul", "A-Demilich", "A-Demon's Due", "A-Devoted Grafkeeper // A-Departed Soulkeeper", "A-Dokuchi Silencer", "A-Dorothea, Vengeful Victim // A-Dorothea's Retribution", "A-Dragon's Rage Channeler", "A-Dragonborn Looter", "A-Dreamshackle Geist", "A-Druid Class", "A-Druidic Ritual", "A-Dueling Coach", "A-Dungeon Descent", "A-Dwarfhold Champion", "A-Earthquake Dragon", "A-Eiganjo Exemplar", "A-Elderfang Ritualist", "A-Elderleaf Mentor", "A-Ellywick Tumblestrum", "A-Elven Bow", "A-Emerald Dragon // A-Dissonant Wave", "A-Excavation Explosion", "A-Exhibition Magician", "A-Eyes of the Beholder", "A-Falcon Abomination", "A-Fall of the Impostor", "A-Fates' Reversal", "A-Find the Path", "A-Forge Boss", "A-Futurist Operative", "A-Galvanic Discharge", "A-Geological Appraiser", "A-Geology Enthusiast", "A-Glamorous Outlaw", "A-Glittermonger", "A-Gnarlid Colony", "A-Goggles of Night", "A-Goma Fada Vanguard", "A-Graveyard Shift", "A-Guide of Souls", "A-Guildsworn Prowler", "A-Gutter Skulker // A-Gutter Shortcut", "A-Hagra Constrictor", "A-Hall of Tagsin", "A-Harald Unites the Elves", "A-Harald, King of Skemfar", "A-Haywire Mite", "A-Heartfire Hero", "A-High-Rise Sawjack", "A-Hobbling Zombie", "A-Imperial Subduer", "A-Incriminate", "A-Iridescent Hornbeetle", "A-Jackhammer", "A-Jade Orb of Dragonkind", "A-Kargan Intimidator", "A-Kargan Warleader", "A-Karn, Living Legacy", "A-Kenku Artificer", "A-Knockout Blow", "A-Kona, Rescue Beastie", "A-Krydle of Baldur's Gate", "A-Lantern Bearer // A-Lanterns' Lift", "A-Lantern of Revealing", "A-Lapis Orb of Dragonkind", "A-Leyline of Resonance", "A-Lier, Disciple of the Drowned", "A-Llanowar Greenwidow", "A-Llanowar Loamspeaker", "A-Maelstrom Muse", "A-Manticore", "A-Masked Bandits", "A-Master of Winds", "A-Mentor's Guidance", "A-Meria's Outrider", "A-Metropolis Angel", "A-Midnight Assassin", "A-Mightstone's Animation", "A-Minsc & Boo, Timeless Heroes", "A-Mischievous Catgeist // A-Catlike Curiosity", "A-Mishra, Excavation Prodigy", "A-Moon-Circuit Hacker", "A-Moss-Pit Skeleton", "A-Most Wanted", "A-Mr. Orfeo, the Boulder", "A-Nadu, Winged Wisdom", "A-Nael, Avizoa Aeronaut", "A-Nahiri, Heir of the Ancients", "A-Narfi, Betrayer King", "A-Nashi, Moon Sage's Scion", "A-Navigation Orb", "A-Nezumi Prowler", "A-Ocelot Pride", "A-Ochre Jelly", "A-Ominous Parcel", "A-Omnath, Locus of Creation", "A-Oran-Rief Ooze", "A-Orcish Bowmasters", "A-Paragon of Modernity", "A-Patrician Geist", "A-Peerless Samurai", "A-Phantom Carriage", "A-Phylath, World Sculptor", "A-Plate Armor", "A-Precipitous Drop", "A-Prosperous Thief", "A-Pseudodragon Familiar", "A-Psionic Snoop", "A-Public Enemy", "A-Pyre-Sledge Arsonist", "A-Queza, Augur of Agonies", "A-Radha's Firebrand", "A-Radha, Coalition Warlord", "A-Raiyuu, Storm's Edge", "A-Rakish Revelers", "A-Ready to Rumble", "A-Return Upon the Tide", "A-Revel Ruiner", "A-Riveteers Initiate", "A-Rockslide Sorcerer", "A-Rowan, Scholar of Sparks // A-Will, Scholar of Frost", "A-Rulik Mons, Warren Chief", "A-Saheeli, Filigree Master", "A-Satoru Umezawa", "A-Scout the Wilderness", "A-Security Rhox", "A-Sepulcher Ghoul", "A-Sewer Crocodile", "A-Shattered Seraph", "A-Shessra, Death's Whisper", "A-Shipwreck Sifters", "A-Sigardian Paladin", "A-Sigil of Myrkul", "A-Silver-Fur Master", "A-Sizzling Soloist", "A-Skemfar Avenger", "A-Skemfar Elderhall", "A-Skull Skaab", "A-Skyclave Shadowcat", "A-Social Climber", "A-Sorcerer Class", "A-Sorin, Imperious Bloodlord", "A-Soul of Windgrace", "A-Spara's Adjudicators", "A-Speakeasy Server", "A-Spell Satchel", "A-Split the Spoils", "A-Splitting the Powerstone", "A-SproLine truncated
@@ -0,0 +1,63 @@
{
"sessionId": "sess-20260309-192805-5109b9",
"deckId": "Angels",
"timestamp": "2026-03-09T19:28:05.274335+00:00",
"summary": "The Angels Commander deck has a mediocre overall win rate of 39.5% with an average game length of 11.8 turns. The matchup data reveals struggles against Edgar Markov and Ashling 2.0. There are no clear underperforming cards, but the lack of lands in the deck structure is concerning.",
"suggestedCuts": [
{
"cardName": "a card",
"reason": "This card has a negative impact score and was not cast at all during simulations.",
"replacementOptions": [
"Ajani's Pridemate",
"Serra Ascendant"
],
"currentImpactScore": 0.0
}
],
"suggestedAdds": [
{
"cardName": "Llanowar Wastes",
"role": "ramp",
"reason": "The deck lacks lands, which is a critical component for mana development and consistency.",
"synergyWith": [
"a card",
"another card"
],
"estimatedManaValue": null
},
{
"cardName": "Rampant Growth",
"role": "draw",
"reason": "This card can help the deck find more cards to play, potentially improving its overall win rate.",
"synergyWith": [
"Ajani's Pridemate",
"Serra Ascendant"
],
"estimatedManaValue": null
},
{
"cardName": "Path to Exile",
"role": "removal",
"reason": "The deck may struggle against certain opponents, and this card can provide a necessary removal spell.",
"synergyWith": [
"Ajani's Pridemate",
"Serra Ascendant"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Consider adding more lands to improve mana development and consistency."
],
"manaBaseAdvice": "Add Llanowar Wastes, which provides a crucial land and can help the deck develop its mana base.",
"rawTextExplanation": "The Angels Commander deck appears to be struggling due to its lack of lands and inconsistent card draw. By adding more lands and ramp spells, the deck can improve its overall win rate and become more competitive against top opponents. The suggested cuts aim to remove underperforming cards that were not cast during simulations, while the suggested adds focus on improving mana development, card draw, and removal. These changes should help the deck reach a more balanced state and potentially increase its overall win rate.",
"modelUsed": "meta-llama-3.1-8b-instruct",
"promptTokens": 642,
"completionTokens": 495,
"goals": {
"targetPowerLevel": 7,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
@@ -0,0 +1,62 @@
{
"sessionId": "sess-20260309-192859-4b9fb6",
"deckId": "Toph",
"timestamp": "2026-03-09T19:28:59.869421+00:00",
"summary": "The deck struggles with a lack of information about its color identity and mana base, but its performance is consistent across two different matchups.",
"suggestedCuts": [
{
"cardName": "a card",
"reason": "This card has no impact on the game due to never being cast or drawn.",
"replacementOptions": [
"Azcanta, the Sunken Ruin",
"Atraxa's Scepter"
],
"currentImpactScore": 0.0
},
{
"cardName": "three cards",
"reason": "These cards also have no impact on the game for the same reason as 'a card'.",
"replacementOptions": [
"Karn, the Great Creator",
"Liliana, Dreadhorde General"
],
"currentImpactScore": 0.0
}
],
"suggestedAdds": [
{
"cardName": "Wrenn and Six",
"role": "ramp|draw|utility",
"reason": "This card can help to ramp up mana and draw cards, making it a versatile addition.",
"synergyWith": [
"Atraxa",
"Zurgo Bellstriker"
],
"estimatedManaValue": null
},
{
"cardName": "Azcanta, the Sunken Ruin",
"role": "utility|draw",
"reason": "This card can draw cards and provide a utility effect, helping to improve overall deck performance.",
"synergyWith": [
"Atraxa",
"Zurgo Bellstriker"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Consider adding more ramp spells to help accelerate mana development."
],
"manaBaseAdvice": null,
"rawTextExplanation": "Given the lack of information about the deck's color identity and mana base, it is difficult to provide targeted advice. However, the consistent performance across two different matchups suggests that the deck may benefit from more aggressive ramp spells and versatile utility cards like Wrenn and Six or Azcanta, the Sunken Ruin.",
"modelUsed": "meta-llama-3.1-8b-instruct",
"promptTokens": 567,
"completionTokens": 417,
"goals": {
"targetPowerLevel": 7,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
@@ -0,0 +1,53 @@
{
"sessionId": "sess-20260309-192954-9c7251",
"deckId": "Edgar Markov",
"timestamp": "2026-03-09T19:29:54.635032+00:00",
"summary": "The deck's win rate is mediocre at 48.3%, with inconsistent performance across different match-ups. There are no clear underperforming cards, but the lack of matchup data against certain archetypes suggests room for improvement.",
"suggestedCuts": [
{
"cardName": "Card to remove not available due to missing deck structure data",
"reason": "No clear underperformers identified, likely due to inadequate simulation sample size or data quality issues",
"replacementOptions": [
"Rampant Growth",
"Gilt-Leaf Winnower"
],
"currentImpactScore": 0.0
}
],
"suggestedAdds": [
{
"cardName": "Cultivate",
"role": "ramp",
"reason": "A versatile ramp spell that provides card advantage and can help the deck develop its mana base more efficiently",
"synergyWith": [
"Edgar Markov",
"Gleeful Wrangler"
],
"estimatedManaValue": null
},
{
"cardName": "Serum Apparatus",
"role": "removal",
"reason": "A cheap, efficient removal spell that can help the deck deal with problematic threats more effectively",
"synergyWith": [
"Cultivate",
"Rampant Growth"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Focus on developing a robust mana base and ramp package to improve overall consistency."
],
"manaBaseAdvice": null,
"rawTextExplanation": "Given the lack of information about the deck's structure and performance, it's difficult to provide more specific advice. However, based on general principles, adding cards like Cultivate and Serum Apparatus can help address potential issues with ramp and removal. These suggestions are aimed at improving the deck's overall consistency and flexibility, but further analysis would be needed to fine-tune the build.",
"modelUsed": "meta-llama-3.1-8b-instruct",
"promptTokens": 547,
"completionTokens": 384,
"goals": {
"targetPowerLevel": 7,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
@@ -0,0 +1,43 @@
{
"sessionId": "sess-20260309-193052-cb65e3",
"deckId": "Angels",
"timestamp": "2026-03-09T19:30:52.904749+00:00",
"summary": "The Angels Commander deck has a mediocre win rate of 39.5% with an average game length of 11.8 turns. The lack of detailed information on the deck's composition and matchup performance makes it difficult to pinpoint areas for improvement.",
"suggestedCuts": [
{
"cardName": "a card",
"reason": "This card has a negative impact score, indicating it may not be contributing positively to the deck's performance. Further analysis is required to determine its actual role and potential removal.",
"replacementOptions": [
"Better Card 1",
"Better Card 2"
],
"currentImpactScore": 0.0
}
],
"suggestedAdds": [
{
"cardName": "Rampant Growth",
"role": "ramp",
"reason": "The deck's low average game length of 11.8 turns suggests a potential issue with ramping into bigger threats. Adding Rampant Growth could help address this issue.",
"synergyWith": [
"Gruul Spellbuster",
"Tireless Tracker"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Consider adjusting the deck's mana curve to better support its threat density."
],
"manaBaseAdvice": null,
"rawTextExplanation": "Without more information on the deck's composition, it is challenging to provide targeted suggestions. However, based on the low average game length and mediocre win rate, it appears that the deck may be struggling with ramping into bigger threats or generating card advantage. Adding Rampant Growth could help address these issues by providing a consistent source of ramp and allowing the deck to more efficiently develop its board presence.",
"modelUsed": "meta-llama-3.1-8b-instruct",
"promptTokens": 642,
"completionTokens": 344,
"goals": {
"targetPowerLevel": 7,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
@@ -0,0 +1,43 @@
{
"sessionId": "sess-20260310-013825-7ae232",
"deckId": "Angels",
"timestamp": "2026-03-10T01:38:25.419019+00:00",
"summary": "The Angels deck has a decent win rate overall but struggles significantly against specific decks like those featuring Sonic the Hedgehog and Edgar Markov. While it performs well against Squirrels, it underperforms in matchups with Ashling and Xyris.",
"suggestedCuts": [],
"suggestedAdds": [
{
"cardName": "Rampardaptor",
"role": "ramp",
"reason": "It provides excellent mana acceleration to support the deck's curve and helps against early game threats.",
"synergyWith": [
"Archdruid of Flourishing",
"Oath of the Gatewatch"
],
"estimatedManaValue": null
},
{
"cardName": "Tainted Strike",
"role": "removal",
"reason": "It efficiently removes key threat creatures that the deck often faces, improving its consistency.",
"synergyWith": [
"Angelic Convergence",
"Avacyn, the Purifier"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Play out your ramp cards early to set up a strong board position."
],
"manaBaseAdvice": "Consider including cards like Gaea's Cradle or Wirewood Symbiote for additional mana support.",
"rawTextExplanation": "The Angels deck has shown moderate success in most matchups but falters against decks focused on specific synergy or combo strategies, such as those featuring Sonic the Hedgehog. To improve, adding ramp and removal effects can help the deck establish a better board presence early. Cards like Rampardaptor and Tainted Strike can be added to enhance mana acceleration and threat removal respectively, while ensuring that key synergies are utilized effectively.",
"modelUsed": "deepseek-r1-distill-llama-8b",
"promptTokens": 630,
"completionTokens": 927,
"goals": {
"targetPowerLevel": 7,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
@@ -0,0 +1,53 @@
{
"sessionId": "sess-20260310-064107-02017e",
"deckId": "Toph",
"timestamp": "2026-03-10T06:41:07.204670+00:00",
"summary": "The deck struggles to maintain consistency across matchups, particularly against Xyris and Angels, with an average win rate of 40%. The lack of clear underperforming cards and unknown card types/roles complicates targeted improvements.",
"suggestedCuts": [],
"suggestedAdds": [
{
"cardName": "Ancestral Vision",
"role": "ramp|draw",
"reason": "Enhances mana acceleration and provides consistent card draw, improving overall deck consistency",
"synergyWith": [
"Gaea's Cradle",
"Ezuri's Predation"
],
"estimatedManaValue": null
},
{
"cardName": "Putrefy",
"role": "removal|threat",
"reason": "Provides targeted removal and threat mitigation, helping to neutralize opponent strategies",
"synergyWith": [
"Crux of Fate",
"Harald, King of Skemfar"
],
"estimatedManaValue": null
},
{
"cardName": "Stoneham Ashenhand",
"role": "protection|utility",
"reason": "Offers protection from big threats and serves as a versatile utility tool in multiple matchups",
"synergyWith": [
"Krothar, The butcher",
"Thrummingbird"
],
"estimatedManaValue": null
}
],
"heuristicHints": [
"Focus on ramp consistency and removal effects to improve matchup performance."
],
"manaBaseAdvice": "Consider adding mana lands like Gaea's Cradle or Oran-Rief, the Vastwood for a robust mana base.",
"rawTextExplanation": "The deck needs better mana acceleration and card draw efficiency. Adding Ancestral Vision or Ezuri's Predation can help ramp up mana quickly and fetch necessary cards. Incorporating removal effects like Putrefy or Crux of Fate will mitigate opponent threats, while Stoneham Ashenhand provides protection and utility, enhancing overall deck versatility.",
"modelUsed": "deepseek-r1-distill-llama-8b",
"promptTokens": 563,
"completionTokens": 973,
"goals": {
"targetPowerLevel": 10,
"metaFocus": null,
"budget": null,
"focusAreas": []
}
}
+2
View File
@@ -0,0 +1,2 @@
# Commander AI Lab — Coach Module
# LLM-powered deck coaching with embeddings search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+258
View File
@@ -0,0 +1,258 @@
"""
Commander AI Lab — Coach Service
═════════════════════════════════
Main orchestrator: loads deck reports, identifies underperformers,
finds replacement candidates via embeddings, builds prompts,
calls the LLM, parses responses, and persists coaching sessions.
"""
import json
import logging
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
)
from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
)
from .llm_client import LMStudioClient
from .embeddings import MTGEmbeddingIndex
from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
class CoachService:
"""
Deck coaching service powered by local LLM and card embeddings.
Usage:
embeddings = MTGEmbeddingIndex()
embeddings.load()
llm = LMStudioClient()
coach = CoachService(llm, embeddings)
session = await coach.run_coaching_session("edgar-markov")
"""
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
self.llm = llm
self.embeddings = embeddings
ensure_dirs()
# ── Deck Report I/O ────────────────────────────────────────
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
"""Load a deck report from disk."""
# Try exact filename first, then case-insensitive search
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
if not report_path.exists():
# Try case-insensitive match
for f in DECK_REPORTS_DIR.glob("*.json"):
if f.stem.lower() == deck_id.lower():
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
data = json.load(f)
return DeckReport(**data)
except Exception as e:
logger.error("Failed to load deck report %s: %s", deck_id, e)
return None
def list_deck_reports(self) -> List[str]:
"""List all available deck report IDs."""
if not DECK_REPORTS_DIR.exists():
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from disk."""
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
try:
with open(path, "r") as f:
data = json.load(f)
return CoachSession(**data)
except Exception as e:
logger.error("Failed to load session %s: %s", session_id, e)
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List all coaching sessions, optionally filtered by deck."""
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
try:
with open(f, "r") as fh:
data = json.load(fh)
if deck_id and data.get("deckId", "") != deck_id:
continue
sessions.append({
"sessionId": data.get("sessionId", f.stem),
"deckId": data.get("deckId", ""),
"timestamp": data.get("timestamp", ""),
"summary": data.get("summary", "")[:100],
"cutsCount": len(data.get("suggestedCuts", [])),
"addsCount": len(data.get("suggestedAdds", [])),
})
except Exception:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
async def run_coaching_session(self, deck_id: str,
goals: Optional[CoachGoals] = None
) -> CoachSession:
"""
Full coaching pipeline:
1. Load deck report
2. Identify underperformers
3. Find replacement candidates via embeddings
4. Build prompt
5. Call LLM
6. Parse response
7. Persist session
"""
# 1. Load deck report
report = self.load_deck_report(deck_id)
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
# If no explicit underperformers, pick lowest impact cards
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
underperformers = [
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
]
if self.embeddings.loaded:
for card_name in underperformers:
matches = self.embeddings.find_replacements(
underperformer=card_name,
deck_colors=report.colorIdentity,
deck_card_names=deck_card_names,
top_n=MAX_CANDIDATES_PER_UNDERPERFORMER,
)
if matches:
candidates[card_name] = [m.to_dict() for m in matches]
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# 4. Build prompts
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
# 5. Call LLM
logger.info("Calling LLM for deck: %s", deck_id)
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
timestamp=datetime.now(timezone.utc).isoformat(),
modelUsed=llm_response.model,
promptTokens=llm_response.prompt_tokens,
completionTokens=llm_response.completion_tokens,
goals=goals,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
# Fallback: store raw text
session.rawTextExplanation = llm_response.content
session.summary = "LLM returned non-JSON response. See raw explanation."
# 7. Persist
self.save_session(session)
logger.info("Coaching session saved: %s", session_id)
return session
def _populate_session_from_json(self, session: CoachSession, data: dict):
"""Parse LLM JSON response into CoachSession fields."""
session.summary = data.get("summary", "")
session.rawTextExplanation = data.get("rawTextExplanation", "")
session.manaBaseAdvice = data.get("manaBaseAdvice")
# Heuristic hints
hints = data.get("heuristicHints", [])
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
# Suggested cuts
for cut_data in data.get("suggestedCuts", []):
if isinstance(cut_data, dict):
session.suggestedCuts.append(SuggestedCut(
cardName=cut_data.get("cardName", ""),
reason=cut_data.get("reason", ""),
replacementOptions=cut_data.get("replacementOptions", []),
))
# Suggested adds
for add_data in data.get("suggestedAdds", []):
if isinstance(add_data, dict):
session.suggestedAdds.append(SuggestedAdd(
cardName=add_data.get("cardName", ""),
role=add_data.get("role", ""),
reason=add_data.get("reason", ""),
synergyWith=add_data.get("synergyWith", []),
))
# ── Status Check ───────────────────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
status = CoachStatus()
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
# Check deck reports
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+45
View File
@@ -0,0 +1,45 @@
"""
Commander AI Lab — Coach Configuration
═══════════════════════════════════════
Central config for LM Studio, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
import os
from pathlib import Path
# ── Base Paths ──────────────────────────────────────────────
# Resolve relative to wherever the lab is installed
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── LM Studio (OpenAI-compatible API) ──────────────────────
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
LM_STUDIO_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
# ── Deck Reports ───────────────────────────────────────────
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
# ── Prompt Limits ──────────────────────────────────────────
MAX_PROMPT_TOKENS = 4096
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings ────────────────────────────────
DEFAULT_TEMPERATURE = 0.7
DEFAULT_MAX_TOKENS = 2048
# ── Ensure directories exist ───────────────────────────────
def ensure_dirs():
"""Create required directories if they don't exist."""
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
DECK_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
+350
View File
@@ -0,0 +1,350 @@
"""
Commander AI Lab — MTG Card Embeddings Index
═════════════════════════════════════════════
Loads pre-computed card embeddings from minimaxir/mtg-embeddings
(Hugging Face) and provides vector similarity search with
metadata filtering (color identity, type, mana value).
Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
import json
import logging
import re
import os
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
import numpy as np
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
logger = logging.getLogger("coach.embeddings")
# ── HuggingFace download URL ───────────────────────────────
HF_PARQUET_URL = (
"https://huggingface.co/datasets/minimaxir/mtg-embeddings/"
"resolve/main/mtg_embeddings.parquet"
)
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
def _parse_color_identity(mana_cost: str) -> str:
"""Extract color identity string from mana cost like '{2}{W}{U}'."""
if not mana_cost:
return ""
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
def _parse_mana_value(mana_cost: str) -> float:
"""Compute converted mana cost from mana cost string."""
if not mana_cost:
return 0.0
total = 0.0
for match in re.finditer(r'\{([^}]+)\}', mana_cost or ""):
symbol = match.group(1)
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass # X = 0 for CMC purposes
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
class CardMatch:
"""Result from an embedding similarity search."""
def __init__(self, name: str, similarity: float, types: str = "",
mana_value: float = 0, color_identity: str = "",
mana_cost: str = "", text: str = ""):
self.name = name
self.similarity = similarity
self.types = types
self.mana_value = mana_value
self.color_identity = color_identity
self.mana_cost = mana_cost
self.text = text
def to_dict(self) -> dict:
return {
"name": self.name,
"similarity": round(self.similarity, 4),
"types": self.types,
"mana_value": self.mana_value,
"color_identity": self.color_identity,
"mana_cost": self.mana_cost,
"text": self.text[:200] if self.text else "",
}
class MTGEmbeddingIndex:
"""
In-memory index of MTG card embeddings for similarity search.
Usage:
index = MTGEmbeddingIndex()
index.load()
matches = index.search_similar("Sol Ring", top_n=10)
"""
def __init__(self):
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self._name_to_idx: dict = {}
self._loaded = False
@property
def loaded(self) -> bool:
return self._loaded
@property
def card_count(self) -> int:
return len(self.names) if self.names is not None else 0
def download_parquet(self, force: bool = False) -> Path:
"""Download the mtg-embeddings parquet file from HuggingFace."""
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
if EMBEDDINGS_PARQUET.exists() and not force:
logger.info("Parquet already exists: %s", EMBEDDINGS_PARQUET)
return EMBEDDINGS_PARQUET
logger.info("Downloading mtg-embeddings from HuggingFace...")
req = Request(HF_PARQUET_URL, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=120) as resp:
data = resp.read()
with open(EMBEDDINGS_PARQUET, "wb") as f:
f.write(data)
logger.info("Downloaded %.1f MB to %s",
len(data) / 1024 / 1024, EMBEDDINGS_PARQUET)
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
try:
import pandas as pd
except ImportError:
raise ImportError("pandas is required: pip install pandas pyarrow")
logger.info("Converting parquet to NPZ...")
df = pd.read_parquet(str(EMBEDDINGS_PARQUET))
names = df["name"].values.astype(str)
mana_costs = df["manaCost"].fillna("").values.astype(str)
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
np.savez_compressed(
str(EMBEDDINGS_NPZ),
names=names,
colors=colors,
mana_values=mana_values,
types=types,
mana_costs=mana_costs,
texts=texts,
vectors=embeddings,
)
size_mb = os.path.getsize(EMBEDDINGS_NPZ) / 1024 / 1024
logger.info("Saved NPZ: %d cards, %.1f MB at %s",
len(names), size_mb, EMBEDDINGS_NPZ)
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""
Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
return self._load_npz()
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
try:
data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
self.names = data["names"]
self.colors = data["colors"]
self.mana_values = data["mana_values"]
self.types = data["types"]
self.mana_costs = data.get("mana_costs", np.array([""] * len(self.names)))
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
self._loaded = True
logger.info("Loaded %d card embeddings from NPZ", len(self.names))
return True
except Exception as e:
logger.error("Failed to load NPZ: %s", e)
return False
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive)."""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
return None
def search_similar(self, query_card: str,
color_filter: List[str] = None,
type_filter: str = None,
mana_range: Tuple[float, float] = None,
exclude_cards: List[str] = None,
top_n: int = 20) -> List[CardMatch]:
"""
Find the most similar cards to query_card.
Args:
query_card: Card name to find similar cards for
color_filter: Only return cards within these colors (e.g., ["W", "U", "B"])
type_filter: Filter by type substring (e.g., "Creature", "Instant")
mana_range: (min_mv, max_mv) mana value range
exclude_cards: Card names to exclude from results
top_n: Number of results to return
Returns:
List of CardMatch objects sorted by similarity (descending)
"""
if not self._loaded:
logger.warning("Embeddings not loaded, call load() first")
return []
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
continue
results.append(CardMatch(
name=name,
similarity=float(similarities[idx]),
types=str(self.types[idx]),
mana_value=float(self.mana_values[idx]),
color_identity=str(self.colors[idx]),
mana_cost=str(self.mana_costs[idx]) if self.mana_costs is not None else "",
text=str(self.texts[idx]) if self.texts is not None else "",
))
if len(results) >= top_n:
break
return results
def find_replacements(self, underperformer: str,
deck_colors: List[str],
deck_card_names: List[str] = None,
role_tag: str = None,
top_n: int = 10) -> List[CardMatch]:
"""
Find replacement candidates for an underperforming card.
Args:
underperformer: Card name to replace
deck_colors: Deck's color identity (e.g., ["W", "U", "B"])
deck_card_names: All card names currently in the deck (excluded from results)
role_tag: Optional type/role filter
top_n: Number of candidates to return
"""
return self.search_similar(
query_card=underperformer,
color_filter=deck_colors,
type_filter=role_tag,
exclude_cards=deck_card_names,
top_n=top_n,
)
+217
View File
@@ -0,0 +1,217 @@
"""
Commander AI Lab — LM Studio Client
════════════════════════════════════
Async HTTP client targeting LM Studio's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
import json
import logging
import asyncio
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LM_STUDIO_URL, LM_STUDIO_MODEL,
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
logger = logging.getLogger("coach.llm")
class LLMResponse:
"""Parsed LLM response container."""
def __init__(self, content: str, parsed_json: Optional[dict],
prompt_tokens: int, completion_tokens: int,
model: str):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
self.model = model
class LMStudioClient:
"""
Client for LM Studio's OpenAI-compatible API.
Usage:
client = LMStudioClient()
response = await client.chat(system_prompt, user_prompt)
if response.parsed_json:
suggestions = response.parsed_json
"""
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
self.model = model or LM_STUDIO_MODEL
self.timeout = timeout or LM_STUDIO_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
def _resolve_model(self) -> str:
"""Get the actual loaded model name from LM Studio /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved LM Studio model: %s", self._resolved_model)
return self._resolved_model
except Exception:
pass
return self.model
def _build_request_body(self, system_prompt: str, user_prompt: str,
temperature: float, max_tokens: int,
force_json: bool) -> dict:
model_name = self._resolve_model()
body = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
}
# Only request JSON format if using a model that supports it
# Some models (esp. smaller ones) don't support response_format
if force_json:
body["response_format"] = {"type": "json_object"}
return body
def _parse_response(self, raw: dict) -> LLMResponse:
"""Extract content and usage from OpenAI-format response."""
choice = raw.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = raw.get("usage", {})
# Try to parse content as JSON
parsed = None
try:
parsed = json.loads(content)
except (json.JSONDecodeError, TypeError):
# Try to extract JSON from markdown fences
if "```json" in content:
start = content.index("```json") + 7
end = content.index("```", start)
try:
parsed = json.loads(content[start:end].strip())
except (json.JSONDecodeError, ValueError):
pass
elif "```" in content:
start = content.index("```") + 3
end = content.index("```", start)
try:
parsed = json.loads(content[start:end].strip())
except (json.JSONDecodeError, ValueError):
pass
return LLMResponse(
content=content,
parsed_json=parsed,
prompt_tokens=usage.get("prompt_tokens", 0),
completion_tokens=usage.get("completion_tokens", 0),
model=raw.get("model", self.model),
)
def chat_sync(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Synchronous chat call to LM Studio.
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
body = self._build_request_body(system_prompt, user_prompt,
temp, tokens, force_json)
last_error = None
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode("utf-8"))
result = self._parse_response(raw)
# If we need JSON and didn't get it, retry
if force_json and result.parsed_json is None:
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
last_error = ValueError("Non-JSON response from LLM")
continue
return result
except Exception as e:
logger.warning("Attempt %d failed: %s", attempt, e)
last_error = e
# If 400 and we used json response_format, retry without it
error_str = str(e)
if "400" in error_str and force_json:
logger.warning("Got 400 — retrying without response_format constraint")
force_json = False
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LM_STUDIO_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call — runs the sync call in a thread executor
so it doesn't block the FastAPI event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(system_prompt, user_prompt,
temperature, max_tokens, force_json)
)
def check_connection(self) -> dict:
"""
Check if LM Studio is reachable. Returns status dict.
"""
try:
req = Request(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
method="GET",
)
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
except Exception as e:
return {
"connected": False,
"error": str(e),
"models": [],
"active_model": None,
}
+150
View File
@@ -0,0 +1,150 @@
"""
Commander AI Lab — Coach Data Models
═════════════════════════════════════
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
class MatchupRecord(BaseModel):
"""Win rate against a specific opponent deck."""
opponentDeck: str
opponentCommander: str = ""
gamesPlayed: int = 0
winRate: float = 0.0
class DeckMeta(BaseModel):
"""High-level deck statistics."""
gamesSimulated: int = 0
overallWinRate: float = 0.0
avgGameLength: float = 0.0
perArchetypeWinRates: Dict[str, float] = Field(default_factory=dict)
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
class DeckReport(BaseModel):
"""
Complete deck performance report — produced by the Java
ReportAggregator and consumed by the Python coach service.
"""
deckId: str
commander: str
colorIdentity: List[str] = Field(default_factory=list)
meta: DeckMeta = Field(default_factory=DeckMeta)
matchups: List[MatchupRecord] = Field(default_factory=list)
structure: DeckStructure = Field(default_factory=DeckStructure)
cards: List[CardPerformance] = Field(default_factory=list)
underperformers: List[str] = Field(default_factory=list)
overperformers: List[str] = Field(default_factory=list)
knownCombos: List[ComboRecord] = Field(default_factory=list)
lastUpdated: str = ""
# ══════════════════════════════════════════════════════════════
# Coach Request / Response Models
# ══════════════════════════════════════════════════════════════
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
class CoachRequest(BaseModel):
"""Request body for POST /api/coach/decks/{deckId}."""
deck_id: str = ""
goals: Optional[CoachGoals] = None
class SuggestedCut(BaseModel):
"""A card the coach suggests removing."""
cardName: str
reason: str
replacementOptions: List[str] = Field(default_factory=list)
currentImpactScore: float = 0.0
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = "" # ramp, draw, removal, finisher, etc.
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
class CoachSession(BaseModel):
"""
Complete coaching session result.
Persisted to coach-sessions/{sessionId}.json.
"""
sessionId: str
deckId: str
timestamp: str = ""
summary: str = ""
suggestedCuts: List[SuggestedCut] = Field(default_factory=list)
suggestedAdds: List[SuggestedAdd] = Field(default_factory=list)
heuristicHints: List[str] = Field(default_factory=list)
manaBaseAdvice: Optional[str] = None
rawTextExplanation: str = ""
modelUsed: str = ""
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
class ApplyRequest(BaseModel):
"""Request body for applying coach suggestions to a deck."""
acceptedCuts: List[str] = Field(default_factory=list)
acceptedAdds: List[str] = Field(default_factory=list)
class CoachStatus(BaseModel):
"""Status of the coach subsystem."""
llmConnected: bool = False
llmModel: Optional[str] = None
llmModels: List[str] = Field(default_factory=list)
embeddingsLoaded: bool = False
embeddingCards: int = 0
deckReportsAvailable: int = 0
error: Optional[str] = None
+233
View File
@@ -0,0 +1,233 @@
"""
Commander AI Lab — Coach Prompt Template
════════════════════════════════════════
Constructs system and user prompts for the LLM coach.
Injects deck report data, underperformer analysis, and
candidate replacement cards into a structured template.
"""
import json
from typing import List, Dict, Optional
from .models import DeckReport, CoachGoals, CardPerformance
from .config import MAX_CANDIDATES_PER_UNDERPERFORMER, MAX_UNDERPERFORMERS
# ══════════════════════════════════════════════════════════════
# System Prompt
# ══════════════════════════════════════════════════════════════
SYSTEM_PROMPT_TEMPLATE = """You are an expert Magic: The Gathering Commander deck coach.
You analyze deck performance reports from simulation data and provide specific,
actionable suggestions to improve deck construction.
RULES:
- NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}.
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
- When suggesting replacements, prefer cards that fill the same functional role.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
"suggestedCuts": [
{{
"cardName": "Card To Remove",
"reason": "Why this card underperforms",
"replacementOptions": ["Better Card 1", "Better Card 2"]
}}
],
"suggestedAdds": [
{{
"cardName": "Card To Add",
"role": "ramp|draw|removal|threat|utility|combo|protection",
"reason": "Why this card improves the deck",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
}}
],
"heuristicHints": [
"Concise strategic tip based on data"
],
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# User Prompt Builder
# ══════════════════════════════════════════════════════════════
USER_PROMPT_TEMPLATE = """## Deck Report: {deck_name}
Commander: {commander}
Color Identity: {color_identity}
Games Simulated: {games_simulated}
Overall Win Rate: {win_rate:.1%}
Avg Game Length: {avg_game_length:.1f} turns
### Deck Structure
- Lands: {land_count}
- Mana Curve: {curve_summary}
- Card Types: {type_summary}
- Functional Roles: {role_summary}
### Matchup Data
{matchup_section}
### Top Performing Cards (by Impact Score)
{top_cards}
### Underperforming Cards (candidates for cuts)
{underperformer_section}
### Candidate Replacement Cards (from embeddings search)
{candidate_section}
{combo_section}
Based on this simulation data, provide your coaching suggestions."""
def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
def _format_curve(buckets: List[int]) -> str:
"""Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "unknown"
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""Format the best performing cards."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str:
"""Format underperforming cards with their stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}")
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return "No candidate replacements found."
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown")
types = r.get("types", "")
mv = r.get("mana_value", "?")
sim = r.get("similarity", 0)
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)")
return "\n".join(lines)
def _format_combos(report: DeckReport) -> str:
"""Format known combos section."""
if not report.knownCombos:
return ""
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}")
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════
# Main Builder Functions
# ══════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
color_identity=color_str,
games_simulated=report.meta.gamesSimulated,
win_rate=report.meta.overallWinRate,
avg_game_length=report.meta.avgGameLength,
land_count=report.structure.landCount,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
matchup_section=_format_matchups(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
combo_section=_format_combos(report),
)
+354
View File
@@ -0,0 +1,354 @@
"""
Commander AI Lab — Deck Report Generator (Python)
══════════════════════════════════════════════════
Reads batch result JSON files from results/ and generates
deck report JSONs in deck-reports/ that the coach service consumes.
This is the Python equivalent of Java ReportAggregator, allowing
report generation without a separate Java invocation.
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Dict
from .config import DECK_REPORTS_DIR
logger = logging.getLogger("coach.report_generator")
UNDERPERFORMER_THRESHOLD = -0.05
MAX_UNDERPERFORMERS = 8
MAX_OVERPERFORMERS = 8
def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str]:
"""
Scan all batch-*.json files in results_dir and generate
one deck report per unique deck across all batches.
Returns list of deck IDs that were generated.
"""
results_path = Path(results_dir)
output_path = Path(reports_dir) if reports_dir else DECK_REPORTS_DIR
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("batch-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
all_batches = []
for bf in batch_files:
try:
with open(bf, "r", encoding="utf-8") as f:
data = json.load(f)
all_batches.append(data)
except Exception as e:
logger.warning("Failed to load %s: %s", bf, e)
if not all_batches:
return []
# Find all unique decks across all batches
deck_seats: Dict[str, dict] = {} # deckName -> {seatIndex, commanderName, colorIdentity}
for batch in all_batches:
for di in batch.get("decks", []):
name = di.get("deckName", "")
if name and name not in deck_seats:
deck_seats[name] = {
"seatIndex": di.get("seatIndex", 0),
"commanderName": di.get("commanderName", ""),
"colorIdentity": di.get("colorIdentity", []),
}
# Generate report for each deck
generated = []
for deck_name, deck_info in deck_seats.items():
try:
report = _build_deck_report(deck_name, deck_info, all_batches)
out_file = output_path / f"{deck_name}.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
generated.append(deck_name)
logger.info(
"Generated report for %s: %d games, %.1f%% win rate",
deck_name,
report["meta"]["gamesSimulated"],
report["meta"]["overallWinRate"] * 100,
)
except Exception as e:
logger.error("Failed to generate report for %s: %s", deck_name, e)
return generated
def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -> List[str]:
"""
Generate deck reports from a single batch result JSON file.
Merges with existing reports if they exist.
Returns list of deck IDs that were updated.
"""
output_path = Path(reports_dir) if reports_dir else DECK_REPORTS_DIR
output_path.mkdir(parents=True, exist_ok=True)
try:
with open(batch_json_path, "r", encoding="utf-8") as f:
batch = json.load(f)
except Exception as e:
logger.error("Failed to load batch result: %s", e)
return []
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("batch-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
except Exception:
continue
if not all_batches:
all_batches = [batch]
# Generate reports for all decks in this batch
generated = []
for di in batch.get("decks", []):
deck_name = di.get("deckName", "")
if not deck_name:
continue
deck_info = {
"seatIndex": di.get("seatIndex", 0),
"commanderName": di.get("commanderName", ""),
"colorIdentity": di.get("colorIdentity", []),
}
try:
report = _build_deck_report(deck_name, deck_info, all_batches)
out_file = output_path / f"{deck_name}.json"
with open(out_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
generated.append(deck_name)
logger.info(
"Updated report for %s: %d games, %.1f%% win rate",
deck_name,
report["meta"]["gamesSimulated"],
report["meta"]["overallWinRate"] * 100,
)
except Exception as e:
logger.error("Failed to generate report for %s: %s", deck_name, e)
return generated
def _build_deck_report(deck_name: str, deck_info: dict, all_batches: list) -> dict:
"""Build a complete deck report from batch data."""
seat_index = deck_info["seatIndex"]
commander = deck_info.get("commanderName", deck_name)
colors = deck_info.get("colorIdentity", [])
total_games = 0
total_wins = 0
total_turns = 0
# Per-card accumulators
card_map: Dict[str, dict] = {}
# Matchup tracking
matchup_map: Dict[str, List[int]] = {} # opponent -> [games, wins]
for batch in all_batches:
# Check if this deck is in this batch
batch_deck = None
for di in batch.get("decks", []):
if di.get("deckName") == deck_name:
batch_deck = di
break
if batch_deck is None:
continue
batch_seat = batch_deck.get("seatIndex", seat_index)
# Update commander/colors from batch if we don't have them
if not commander or commander == deck_name:
commander = batch_deck.get("commanderName", deck_name)
if not colors:
colors = batch_deck.get("colorIdentity", [])
# Track opponents
opponents = [
di.get("deckName", "")
for di in batch.get("decks", [])
if di.get("seatIndex") != batch_seat
]
# Process each game
for game in batch.get("games", []):
total_games += 1
total_turns += game.get("totalTurns", 0)
# Find this deck's player result
my_result = None
for pr in game.get("playerResults", []):
if pr.get("seatIndex") == batch_seat:
my_result = pr
break
if my_result is None:
continue
won = my_result.get("isWinner", False)
if won:
total_wins += 1
# Matchups
for opp in opponents:
if opp not in matchup_map:
matchup_map[opp] = [0, 0]
matchup_map[opp][0] += 1
if won:
matchup_map[opp][1] += 1
# Per-card stats
for pcs in my_result.get("cardStats", []):
card_name = pcs.get("cardName", "")
if not card_name:
continue
if card_name not in card_map:
card_map[card_name] = {
"name": card_name,
"gamesSeen": 0,
"gamesDrawn": 0,
"gamesCast": 0,
"gamesWhenCast": 0,
"winsWhenCast": 0,
"gamesDeadCard": 0,
"gamesKeptInOpening": 0,
"totalTurnCast": 0.0,
"totalDamage": 0.0,
}
cp = card_map[card_name]
cp["gamesSeen"] += 1
if pcs.get("drawn", False):
cp["gamesDrawn"] += 1
if pcs.get("cast", False):
cp["gamesCast"] += 1
cp["gamesWhenCast"] += 1
if won:
cp["winsWhenCast"] += 1
turn_cast = pcs.get("turnCast", -1)
if turn_cast >= 0:
cp["totalTurnCast"] += turn_cast
if pcs.get("stuckInHand", False):
cp["gamesDeadCard"] += 1
if pcs.get("inOpeningHand", False) and pcs.get("keptInOpeningHand", False):
cp["gamesKeptInOpening"] += 1
cp["totalDamage"] += pcs.get("damageDealt", 0)
# ── Compute deck-level stats ──────────────────────────────
overall_win_rate = total_wins / total_games if total_games > 0 else 0.0
avg_game_length = total_turns / total_games if total_games > 0 else 0.0
# ── Compute matchups ──────────────────────────────────────
matchups = []
for opp_name, stats in matchup_map.items():
matchups.append({
"opponentDeck": opp_name,
"opponentCommander": "",
"gamesPlayed": stats[0],
"winRate": stats[1] / stats[0] if stats[0] > 0 else 0.0,
})
# ── Compute per-card performance ──────────────────────────
cards = []
for cp in card_map.values():
gs = cp["gamesSeen"]
if gs == 0:
continue
drawn_rate = cp["gamesDrawn"] / gs
cast_rate = cp["gamesCast"] / cp["gamesDrawn"] if cp["gamesDrawn"] > 0 else 0.0
dead_card_rate = cp["gamesDeadCard"] / cp["gamesDrawn"] if cp["gamesDrawn"] > 0 else 0.0
kept_rate = cp["gamesKeptInOpening"] / gs
# Impact score
win_rate_when_cast = (
cp["winsWhenCast"] / cp["gamesWhenCast"]
if cp["gamesWhenCast"] > 0
else 0.0
)
impact_score = (win_rate_when_cast - overall_win_rate) * cast_rate
# Clunkiness
clunkiness = dead_card_rate * (1.0 - cast_rate)
# Average turn cast
avg_turn_cast = (
cp["totalTurnCast"] / cp["gamesCast"] if cp["gamesCast"] > 0 else None
)
avg_damage = cp["totalDamage"] / gs
cards.append({
"name": cp["name"],
"drawnRate": round(drawn_rate, 4),
"castRate": round(cast_rate, 4),
"keptInOpeningHandRate": round(kept_rate, 4),
"deadCardRate": round(dead_card_rate, 4),
"impactScore": round(impact_score, 4),
"synergyScore": 0.0,
"clunkinessScore": round(clunkiness, 4),
"avgTurnCast": round(avg_turn_cast, 2) if avg_turn_cast is not None else None,
"avgDamageDealt": round(avg_damage, 2),
"tags": [],
})
# ── Identify under/over performers ────────────────────────
sorted_by_impact = sorted(cards, key=lambda c: c["impactScore"])
underperformers = []
for c in sorted_by_impact[:MAX_UNDERPERFORMERS]:
if c["impactScore"] < UNDERPERFORMER_THRESHOLD:
underperformers.append(c["name"])
overperformers = []
for c in reversed(sorted_by_impact[-MAX_OVERPERFORMERS:]):
if c["impactScore"] > 0:
overperformers.append(c["name"])
# ── Build final report ────────────────────────────────────
return {
"deckId": deck_name,
"commander": commander,
"colorIdentity": colors,
"meta": {
"gamesSimulated": total_games,
"overallWinRate": round(overall_win_rate, 4),
"avgGameLength": round(avg_game_length, 2),
"perArchetypeWinRates": {},
},
"matchups": matchups,
"structure": {
"landCount": 0,
"curveBuckets": [0] * 8,
"cardTypeCounts": {},
"functionalCounts": {},
},
"cards": cards,
"underperformers": underperformers,
"overperformers": overperformers,
"knownCombos": [],
"lastUpdated": datetime.now(timezone.utc).isoformat(),
}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
{
"deckId": "Counterpunch",
"commander": "Counterpunch",
"colorIdentity": [],
"meta": {
"gamesSimulated": 2,
"overallWinRate": 0.0,
"avgGameLength": 15.5,
"perArchetypeWinRates": {}
},
"matchups": [
{
"opponentDeck": "Devour_for_Power_CMD",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 0.0
},
{
"opponentDeck": "Heavenly_Inferno_CMD",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 0.0
}
],
"structure": {
"landCount": 0,
"curveBuckets": [
0,
0,
0,
0,
0,
0,
0,
0
],
"cardTypeCounts": {},
"functionalCounts": {}
},
"cards": [],
"underperformers": [],
"overperformers": [],
"knownCombos": [],
"lastUpdated": "2026-03-10T15:39:39.798151+00:00"
}
+59
View File
@@ -0,0 +1,59 @@
{
"deckId": "Devour_for_Power_CMD",
"commander": "Devour_for_Power_CMD",
"colorIdentity": [],
"meta": {
"gamesSimulated": 2,
"overallWinRate": 0.0,
"avgGameLength": 15.5,
"perArchetypeWinRates": {}
},
"matchups": [
{
"opponentDeck": "Counterpunch",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 0.0
},
{
"opponentDeck": "Heavenly_Inferno_CMD",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 0.0
}
],
"structure": {
"landCount": 0,
"curveBuckets": [
0,
0,
0,
0,
0,
0,
0,
0
],
"cardTypeCounts": {},
"functionalCounts": {}
},
"cards": [
{
"name": "a card",
"drawnRate": 1.0,
"castRate": 0.0,
"keptInOpeningHandRate": 0.0,
"deadCardRate": 1.0,
"impactScore": 0.0,
"synergyScore": 0.0,
"clunkinessScore": 1.0,
"avgTurnCast": null,
"avgDamageDealt": 0.0,
"tags": []
}
],
"underperformers": [],
"overperformers": [],
"knownCombos": [],
"lastUpdated": "2026-03-10T15:39:39.799039+00:00"
}
+59
View File
@@ -0,0 +1,59 @@
{
"deckId": "Heavenly_Inferno_CMD",
"commander": "Heavenly_Inferno_CMD",
"colorIdentity": [],
"meta": {
"gamesSimulated": 2,
"overallWinRate": 1.0,
"avgGameLength": 15.5,
"perArchetypeWinRates": {}
},
"matchups": [
{
"opponentDeck": "Counterpunch",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 1.0
},
{
"opponentDeck": "Devour_for_Power_CMD",
"opponentCommander": "",
"gamesPlayed": 2,
"winRate": 1.0
}
],
"structure": {
"landCount": 0,
"curveBuckets": [
0,
0,
0,
0,
0,
0,
0,
0
],
"cardTypeCounts": {},
"functionalCounts": {}
},
"cards": [
{
"name": "zero cards",
"drawnRate": 1.0,
"castRate": 0.0,
"keptInOpeningHandRate": 0.0,
"deadCardRate": 1.0,
"impactScore": -0.0,
"synergyScore": 0.0,
"clunkinessScore": 1.0,
"avgTurnCast": null,
"avgDamageDealt": 0.0,
"tags": []
}
],
"underperformers": [],
"overperformers": [],
"knownCombos": [],
"lastUpdated": "2026-03-10T15:39:39.799775+00:00"
}
+5
View File
@@ -0,0 +1,5 @@
# Deck Sources — Template deck adapters for Auto Deck Generator
#
# Each adapter fetches template decklists from an external source
# (Archidekt, EDHREC, Moxfield, MTGGoldfish) and normalizes them
# into a common TemplateDeck format.
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
"""
Archidekt Adapter (stub)
═════════════════════════
Fetches template decklists from Archidekt for a given commander.
Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will call the Archidekt API.
"""
from .models import TemplateDeck, TemplateDeckCard
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch template decks from Archidekt for the given commander.
Args:
commander_name: The commander's name
color_identity: List of color letters, e.g. ["W", "U", "B"]
config: Optional config (URLs, IDs, etc.)
Returns:
List of TemplateDeck objects (currently stubbed)
"""
# STUB: Return empty list until real Archidekt API integration
# When implemented, this will:
# 1. Search Archidekt for popular decks with this commander
# 2. Fetch the top 3-5 decklists
# 3. Normalize card names and return as TemplateDeck
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
return []
+31
View File
@@ -0,0 +1,31 @@
"""
EDHREC Adapter (stub)
══════════════════════
Fetches popular/recommended cards from EDHREC for a given commander.
Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will parse EDHREC commander pages.
"""
from .models import TemplateDeck, TemplateDeckCard
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch recommended cards from EDHREC for the given commander.
Args:
commander_name: The commander's name
color_identity: List of color letters
config: Optional config
Returns:
List of TemplateDeck objects (currently stubbed)
"""
# STUB: Return empty list until real EDHREC integration
# When implemented, this will:
# 1. Build EDHREC URL for the commander (e.g., /commanders/korvold-fae-cursed-king)
# 2. Fetch the "Average Deck" or "Top Cards" list
# 3. Parse card names and inclusion percentages
# 4. Return as a single TemplateDeck with weighted cards
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
return []
+35
View File
@@ -0,0 +1,35 @@
"""
Deck Sources — Shared Models
═════════════════════════════
Common data models for template decks fetched from external sources.
"""
class TemplateDeckCard:
"""A single card in a template deck."""
__slots__ = ("name", "quantity")
def __init__(self, name: str, quantity: int = 1):
self.name = name
self.quantity = quantity
def to_dict(self) -> dict:
return {"name": self.name, "quantity": self.quantity}
class TemplateDeck:
"""A template deck fetched from an external source."""
__slots__ = ("name", "source", "cards")
def __init__(self, name: str, source: str, cards: list = None):
self.name = name
self.source = source
self.cards = cards or [] # list of TemplateDeckCard
def to_dict(self) -> dict:
return {
"name": self.name,
"source": self.source,
"cards": [c.to_dict() for c in self.cards],
}
+17
View File
@@ -0,0 +1,17 @@
"""
Moxfield Adapter (stub)
════════════════════════
Fetches template decklists from Moxfield for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
"""
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
return []
+17
View File
@@ -0,0 +1,17 @@
"""
MTGGoldfish Adapter (stub)
═══════════════════════════
Fetches template decklists from MTGGoldfish for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
"""
Fetch template decks from MTGGoldfish for the given commander.
Experimental — not yet implemented.
"""
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
return []
+77
View File
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.commanderailab</groupId>
<artifactId>commander-ai-lab</artifactId>
<name>Commander AI Lab</name>
<version>1.0.0-SNAPSHOT</version>
<description>Headless batch simulation environment for MTG Commander with 3-AI pods</description>
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>commanderailab.cli.LabCli</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>commanderailab.cli.LabCli</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.3</version>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<picocli.version>4.7.5</picocli.version>
<gson.version>2.10.1</gson.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<forge.version>2.0.12-SNAPSHOT</forge.version>
</properties>
</project>
+71
View File
@@ -0,0 +1,71 @@
# Commander AI Lab — Project Vision
## Vision Statement
Commander AI Lab is a headless, batch-oriented simulation environment for Magic: The Gathering Commander that pits 3 AI-controlled decks against each other in automated games, collects structured JSON results, and surfaces actionable statistics—win rates, average turns to win, mulligan rates, and win-condition breakdowns. By wrapping the open-source Forge rules engine in a programmable pipeline, the lab enables deck builders to stress-test strategies at scale (hundreds or thousands of simulated games) without manual play, providing data-driven insights into Commander deck performance.
## Core v1 Goals
1. **Headless 3-AI Simulations** — Run Commander games with 3 AI players, no GUI, no human input, leveraging Forge's built-in `sim` mode with Commander format support.
2. **JSON Batch Results** — Every batch run produces a structured JSON file conforming to a defined schema, containing per-game outcomes, per-deck statistics, and run metadata.
3. **Basic Statistics** — Compute and report:
- Win rate per deck
- Average turns to win
- Mulligan frequency per deck
- Win condition classification (commander damage, combat damage, combo/alt-win, concession/timeout)
## v1 Scope
| In Scope | Out of Scope |
|----------|-------------|
| Fixed 3-player pods | 4-player pods (v2) |
| Fixed/pre-built decks only | Dynamic deck generation |
| Forge's built-in AI (utility-based) | Reinforcement learning / training UI |
| No politics, no table talk | Multiplayer human players |
| CLI batch runner | EDHREC / meta integration |
| JSON export to file | Database persistence |
| Single-thread + multi-thread batch | Distributed / cloud execution |
| Basic stats dashboard in web UI | Advanced analytics / ML insights |
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ Commander AI Lab │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ CLI / │──▶│ Batch Runner │──▶│ Stats │ │
│ │ Web UI │ │ (single/mt) │ │ Aggregator │ │
│ └──────────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Forge Engine │ │ JSON Export │ │
│ │ (sim mode) │ │ (schema v1) │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ AI Policy Layer │ │
│ │ • Card advantage scoring │ │
│ │ • Tempo / mana efficiency │ │
│ │ • Threat scoring per opponent │ │
│ │ • Basic combat logic │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## Current Components (Inventory)
### Existing Pieces
1. **Forge-based rules engine** (`forge-commander-engine`) — Java 17/Maven project wrapping Forge with `BridgePlayerController` for JSON-serialized decisions.
2. **Bridge/JSON controller** — `BridgePlayerController.java` serializes gameplay decisions as JSON, `StdioBridgeTransport` and `InProcessTransport` handle communication.
3. **Web simulator UI** (`mtg-commander-lan`) — HTML/CSS/JS Commander simulator with board state, Scryfall integration, deck builder, collection tracker, token system.
4. **Deck builder / tracker** — Integrated into web simulator with localStorage persistence, Scryfall API card data.
### Missing Pieces (To Build)
1. **Batch Runner** — Orchestration layer that invokes Forge `sim` mode repeatedly, collects output, handles threading.
2. **AI Policy Class** — Abstraction layer over Forge's built-in AI with configurable heuristic weights for Commander-specific decisions.
3. **Stats Aggregation** — Module to parse game outcomes and compute win rates, turn counts, mulligan rates, win conditions.
4. **JSON Export** — Structured serialization of batch results conforming to the defined schema.
5. **CLI Entry Point** — Command-line interface to trigger batch runs with parameters.
6. **Web UI Integration** — Deck selection, "Run N sims" button, results display in the existing web simulator.
+126
View File
@@ -0,0 +1,126 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Commander AI Lab — Batch Result Schema v1",
"description": "Output schema for a batch of headless 3-AI Commander simulations.",
"type": "object",
"required": ["metadata", "decks", "games", "summary"],
"properties": {
"metadata": {
"type": "object",
"required": ["schemaVersion", "batchId", "timestamp", "totalGames", "completedGames", "format", "podSize", "engineVersion", "elapsedMs"],
"properties": {
"schemaVersion": { "type": "string", "const": "1.0.0" },
"batchId": { "type": "string", "description": "UUID for this batch run" },
"timestamp": { "type": "string", "format": "date-time", "description": "ISO 8601 start time" },
"totalGames": { "type": "integer", "minimum": 1 },
"completedGames": { "type": "integer", "minimum": 0 },
"format": { "type": "string", "const": "commander" },
"podSize": { "type": "integer", "const": 3 },
"engineVersion": { "type": "string" },
"masterSeed": { "type": ["integer", "null"], "description": "Master RNG seed (null if random)" },
"threads": { "type": "integer", "minimum": 1 },
"elapsedMs": { "type": "integer", "minimum": 0, "description": "Total wall-clock time in milliseconds" }
}
},
"decks": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"required": ["seatIndex", "deckName", "commanderName"],
"properties": {
"seatIndex": { "type": "integer", "minimum": 0, "maximum": 2 },
"deckName": { "type": "string" },
"commanderName": { "type": "string" },
"deckFile": { "type": "string", "description": "Path or filename of the .dck file" },
"colorIdentity": {
"type": "array",
"items": { "type": "string", "enum": ["W", "U", "B", "R", "G"] }
},
"cardCount": { "type": "integer" }
}
}
},
"games": {
"type": "array",
"items": {
"type": "object",
"required": ["gameIndex", "winningSeat", "totalTurns", "winCondition", "gameSeed", "elapsedMs"],
"properties": {
"gameIndex": { "type": "integer", "minimum": 0 },
"winningSeat": { "type": ["integer", "null"], "minimum": 0, "maximum": 2, "description": "null if draw/timeout" },
"totalTurns": { "type": "integer", "minimum": 1 },
"winCondition": {
"type": "string",
"enum": ["combat_damage", "commander_damage", "combo_alt_win", "life_drain", "mill", "concession", "timeout", "unknown"]
},
"gameSeed": { "type": "integer" },
"elapsedMs": { "type": "integer", "minimum": 0 },
"playerResults": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"required": ["seatIndex", "finalLife", "mulligans", "isWinner"],
"properties": {
"seatIndex": { "type": "integer" },
"finalLife": { "type": "integer" },
"mulligans": { "type": "integer", "minimum": 0 },
"isWinner": { "type": "boolean" },
"commanderDamageDealt": { "type": "integer", "minimum": 0 },
"commanderCasts": { "type": "integer", "minimum": 0 },
"landsPlayed": { "type": "integer", "minimum": 0 },
"spellsCast": { "type": "integer", "minimum": 0 },
"creaturesDestroyed": { "type": "integer", "minimum": 0 }
}
}
}
}
}
},
"summary": {
"type": "object",
"required": ["perDeck"],
"properties": {
"perDeck": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"required": ["seatIndex", "deckName", "wins", "winRate", "avgTurnsToWin", "avgMulligans"],
"properties": {
"seatIndex": { "type": "integer" },
"deckName": { "type": "string" },
"wins": { "type": "integer", "minimum": 0 },
"losses": { "type": "integer", "minimum": 0 },
"draws": { "type": "integer", "minimum": 0 },
"winRate": { "type": "number", "minimum": 0, "maximum": 1 },
"avgTurnsToWin": { "type": ["number", "null"] },
"avgMulligans": { "type": "number", "minimum": 0 },
"avgFinalLife": { "type": "number" },
"winConditionBreakdown": {
"type": "object",
"properties": {
"combat_damage": { "type": "integer" },
"commander_damage": { "type": "integer" },
"combo_alt_win": { "type": "integer" },
"life_drain": { "type": "integer" },
"mill": { "type": "integer" },
"concession": { "type": "integer" },
"timeout": { "type": "integer" },
"unknown": { "type": "integer" }
}
}
}
}
},
"avgGameTurns": { "type": "number" },
"avgGameTimeMs": { "type": "number" },
"simsPerSecond": { "type": "number" }
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Commander AI Lab — Per-Game Log Schema v1",
"description": "Optional detailed log for a single simulated game.",
"type": "object",
"required": ["gameIndex", "batchId", "turns"],
"properties": {
"gameIndex": { "type": "integer" },
"batchId": { "type": "string" },
"gameSeed": { "type": "integer" },
"turns": {
"type": "array",
"items": {
"type": "object",
"required": ["turnNumber", "activeSeat", "phases"],
"properties": {
"turnNumber": { "type": "integer", "minimum": 1 },
"activeSeat": { "type": "integer", "minimum": 0, "maximum": 2 },
"phases": {
"type": "array",
"items": {
"type": "object",
"required": ["phase"],
"properties": {
"phase": {
"type": "string",
"enum": ["untap", "upkeep", "draw", "main1", "combat_begin", "declare_attackers", "declare_blockers", "combat_damage", "combat_end", "main2", "end", "cleanup"]
},
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["play_land", "cast_spell", "activate_ability", "attack", "block", "trigger", "resolve", "mulligan", "pass_priority"]
},
"card": { "type": "string" },
"targets": {
"type": "array",
"items": { "type": "string" }
},
"seat": { "type": "integer" },
"manaSpent": { "type": "string" },
"notes": { "type": "string" }
}
}
},
"boardState": {
"type": "object",
"description": "Snapshot of board state after this phase (optional, expensive)",
"properties": {
"players": {
"type": "array",
"items": {
"type": "object",
"properties": {
"seat": { "type": "integer" },
"life": { "type": "integer" },
"handSize": { "type": "integer" },
"librarySize": { "type": "integer" },
"permanentCount": { "type": "integer" },
"graveyardSize": { "type": "integer" }
}
}
}
}
}
}
}
}
}
}
},
"outcome": {
"type": "object",
"properties": {
"winningSeat": { "type": ["integer", "null"] },
"winCondition": { "type": "string" },
"totalTurns": { "type": "integer" },
"finalLifeTotals": {
"type": "array",
"items": { "type": "integer" }
}
}
}
}
}
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
+6100
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
"""
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:
1. Check action.type (cast, attack, land, cast_commander, pass)
2. For "cast" actions, classify by card oracle text / card type keywords
3. For ambiguous cases, use the raw log line and card name heuristics
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
# ══════════════════════════════════════════════════════════
# Known removal spells (partial list — expanded from common Commander staples)
REMOVAL_KEYWORDS = {
"destroy", "exile", "sacrifice", "counter", "bounce", "return.*to.*hand",
"-\\d+/-\\d+", "damage to", "fight", "deathtouch",
}
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",
}
# Known draw spells
DRAW_KEYWORDS = {
"draw", "scry", "look at the top", "reveal.*library",
"search your library.*hand", "impulse", "brainstorm",
}
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", "nights whisper",
"nights 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",
"night's whisper", "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",
}
# Known ramp cards
RAMP_KEYWORDS = {
"add {", "add one mana", "mana of any color",
"land.*onto the battlefield", "search.*basic land.*battlefield",
}
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", "signets", # catch-all partial
"talisman", # catch-all for talisman cycle
"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",
}
# Known creature-type indicators
CREATURE_KEYWORDS = {
"creature", "token", "enters the battlefield",
}
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()
raw_line = (action.get("raw", "") or "").lower()
# ── Direct type matching ────────────────────────────
# Commander cast is unambiguous
if action_type == "cast_commander":
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
# Attack is unambiguous
if action_type == "attack":
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
# Land play → classify as CAST_RAMP (developing mana is ramp)
if action_type == "land":
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# Pass / hold mana
if action_type in ("pass", "hold"):
# Check if player had mana open (from state)
active_seat = decision.get("active_seat", 0)
players = decision.get("players", [])
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: classify by card identity ─────────
if action_type == "cast":
# 1. Check known card name databases
if card_name in REMOVAL_CARD_NAMES or _partial_match(card_name, REMOVAL_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
if card_name in RAMP_CARD_NAMES or _partial_match(card_name, RAMP_CARD_NAMES):
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# 2. Check raw log line for keyword patterns
for keyword in REMOVAL_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
for keyword in DRAW_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
for keyword in RAMP_KEYWORDS:
if re.search(keyword, raw_line):
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
# 3. Default: if it's a spell cast and not recognized, assume creature
# (most casts in Commander are creatures or creature-adjacent)
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
# ── Fallback ────────────────────────────────────────
return ACTION_TO_IDX[MacroAction.PASS]
def _partial_match(name: str, name_set: set) -> bool:
"""Check if card name partially matches any name in the set."""
for known in name_set:
if known in name or name in known:
return True
return False
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") 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."""
dist = {}
for action in MacroAction:
idx = ACTION_TO_IDX[action]
count = int(np.sum(labels == idx))
dist[action.value] = count
total = len(labels)
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
print("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
print(f"{'─' * 43}")
print(f" {'TOTAL':<23} {total:>8}")
return dist
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
+258
View File
@@ -0,0 +1,258 @@
"""
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()
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
+300
View File
@@ -0,0 +1,300 @@
"""
Commander AI Lab — Training Dataset Builder
════════════════════════════════════════════
Reads ML decision JSONL files, encodes states, labels actions,
and produces a training-ready dataset saved as NPZ.
Usage:
python -m ml.data.dataset_builder --results-dir results/ --output ml/models/dataset.npz
Output NPZ structure:
states: float32 (N, 6177) — encoded state vectors
labels: int64 (N,) — macro-action indices (0-7)
game_ids: object (N,) — game ID for each sample
outcomes: object (N,) — game outcome per sample
playstyles: object (N,) — deck archetype per sample
"""
import argparse
import json
import logging
import os
import sys
from pathlib import Path
from typing import Dict, List, Tuple
import numpy as np
# Add project root to path
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import (
NUM_ACTIONS, STATE_DIMS, MacroAction, ACTION_TO_IDX,
)
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
from ml.actions.labeler import label_action, print_label_distribution
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.dataset")
def build_dataset(
results_dir: str,
embeddings_dir: str = None,
max_samples: int = None,
) -> Dict[str, np.ndarray]:
"""
Build complete training dataset from all ML decision files.
Args:
results_dir: Path to results/ directory containing ml-decisions-*.jsonl
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
max_samples: Cap total samples (None = unlimited)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles
"""
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
if not card_index.load():
logger.error("Cannot build dataset without card embeddings.")
logger.error("Start the lab server first to auto-download embeddings,")
logger.error("or manually download mtg_embeddings.npz to embeddings/")
return {}
encoder = StateEncoder(card_index)
# Find all JSONL files
results_path = Path(results_dir)
jsonl_files = sorted(results_path.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
logger.error("No ML decision files found in %s", results_dir)
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
return {}
logger.info("Found %d ML decision files", len(jsonl_files))
all_states = []
all_labels = []
all_game_ids = []
all_outcomes = []
all_playstyles = []
total_skipped = 0
for jsonl_file in jsonl_files:
logger.info("Processing %s ...", jsonl_file.name)
file_count = 0
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
if max_samples and len(all_states) >= max_samples:
break
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# Encode state
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
# Label action
action_idx = label_action(decision)
# Metadata
game_id = decision.get("game_id", f"unknown-{line_num}")
outcome = decision.get("game_outcome", "unknown")
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if not all_states:
logger.error("No valid samples found.")
return {}
# Convert to arrays
states = np.stack(all_states).astype(np.float32)
labels = np.array(all_labels, dtype=np.int64)
game_ids = np.array(all_game_ids, dtype=object)
outcomes = np.array(all_outcomes, dtype=object)
playstyles = np.array(all_playstyles, dtype=object)
logger.info(
"Dataset built: %d samples, %d features, %d actions, %d skipped",
len(states), states.shape[1], NUM_ACTIONS, total_skipped,
)
# Print label distribution
print_label_distribution(labels)
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
print(f"\n{'Outcome':<25} {'Count':>8}")
print("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
print(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
print(f"\n{'Playstyle':<25} {'Count':>8}")
print("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
print(f" {str(s):<23} {c:>8}")
return {
"states": states,
"labels": labels,
"game_ids": game_ids,
"outcomes": outcomes,
"playstyles": playstyles,
}
def split_dataset(
dataset: Dict[str, np.ndarray],
val_split: float = 0.15,
test_split: float = 0.10,
split_by_game: bool = True,
) -> Tuple[Dict, Dict, Dict]:
"""
Split dataset into train/val/test sets.
If split_by_game=True, splits by game_id to prevent data leakage
(all decisions from one game go to the same split).
Returns:
(train, val, test) — each a dict with same keys as input
"""
states = dataset["states"]
labels = dataset["labels"]
game_ids = dataset["game_ids"]
if split_by_game:
# Get unique game IDs and shuffle
unique_games = np.unique(game_ids)
rng = np.random.default_rng(42)
rng.shuffle(unique_games)
n_games = len(unique_games)
n_test = max(1, int(n_games * test_split))
n_val = max(1, int(n_games * val_split))
test_games = set(unique_games[:n_test])
val_games = set(unique_games[n_test:n_test + n_val])
train_games = set(unique_games[n_test + n_val:])
train_mask = np.array([gid in train_games for gid in game_ids])
val_mask = np.array([gid in val_games for gid in game_ids])
test_mask = np.array([gid in test_games for gid in game_ids])
else:
# Simple random split by sample
n = len(states)
rng = np.random.default_rng(42)
indices = rng.permutation(n)
n_test = max(1, int(n * test_split))
n_val = max(1, int(n * val_split))
test_mask = np.zeros(n, dtype=bool)
val_mask = np.zeros(n, dtype=bool)
train_mask = np.zeros(n, dtype=bool)
test_mask[indices[:n_test]] = True
val_mask[indices[n_test:n_test + n_val]] = True
train_mask[indices[n_test + n_val:]] = True
def _subset(mask):
return {k: v[mask] for k, v in dataset.items()}
train = _subset(train_mask)
val = _subset(val_mask)
test = _subset(test_mask)
logger.info(
"Split: train=%d, val=%d, test=%d (by_game=%s)",
len(train["states"]), len(val["states"]), len(test["states"]),
split_by_game,
)
return train, val, test
def save_dataset(
dataset: Dict[str, np.ndarray],
output_path: str,
):
"""Save dataset to NPZ file."""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
np.savez_compressed(output_path, **dataset)
size_mb = os.path.getsize(output_path) / (1024 * 1024)
logger.info("Saved dataset to %s (%.1f MB)", output_path, size_mb)
def main():
parser = argparse.ArgumentParser(
description="Build ML training dataset from decision logs"
)
parser.add_argument(
"--results-dir", default="results",
help="Directory containing ml-decisions-*.jsonl files",
)
parser.add_argument(
"--embeddings-dir", default=None,
help="Directory containing mtg_embeddings.npz (default: auto-detect)",
)
parser.add_argument(
"--output", default="ml/models/dataset.npz",
help="Output path for the dataset NPZ file",
)
parser.add_argument(
"--max-samples", type=int, default=None,
help="Maximum number of samples (default: unlimited)",
)
parser.add_argument(
"--no-split", action="store_true",
help="Don't split into train/val/test (save single file)",
)
args = parser.parse_args()
# Build dataset
dataset = build_dataset(
results_dir=args.results_dir,
embeddings_dir=args.embeddings_dir,
max_samples=args.max_samples,
)
if not dataset:
print("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
if args.no_split:
save_dataset(dataset, args.output)
else:
train, val, test = split_dataset(dataset)
base = args.output.replace(".npz", "")
save_dataset(train, f"{base}-train.npz")
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
print("\nDataset build complete.")
if __name__ == "__main__":
main()
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
+301
View File
@@ -0,0 +1,301 @@
"""
Commander AI Lab — State Encoder
═════════════════════════════════
Converts raw decision snapshots (JSONL) into fixed-size tensor vectors
suitable for neural network input.
Pipeline:
1. Load card embeddings from mtg-embeddings (768-dim, ~32K cards)
2. For each decision snapshot:
a. Encode global features (life, mana, turn, phase, etc.)
b. For each zone per player: mean-pool card embeddings
c. Encode playstyle as one-hot vector
d. Concatenate into single fixed-length vector
Output dimensions:
global_features (29) + zone_embeddings (6144) + playstyle (4) = 6177
"""
import json
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger("ml.encoder")
# Import scope config
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from ml.config.scope import (
STATE_DIMS, PLAYSTYLE_TO_IDX, PHASE_TO_IDX,
Playstyle, GamePhase, GAME_SCOPE,
)
class CardEmbeddingIndex:
"""
Loads and caches card embeddings from the minimaxir/mtg-embeddings dataset.
Provides name → 768-dim vector lookup.
Uses the same NPZ cache as coach/embeddings.py for consistency.
"""
def __init__(self, embeddings_dir: str = None):
self.embeddings_dir = embeddings_dir or str(
Path(__file__).parent.parent.parent / "embeddings"
)
self._name_to_idx: Dict[str, int] = {}
self._embeddings: Optional[np.ndarray] = None
self._names: List[str] = []
self._loaded = False
self._zero_vec = np.zeros(STATE_DIMS.card_embedding_dim, dtype=np.float32)
def load(self) -> bool:
"""Load embeddings from NPZ cache (created by coach/embeddings.py)."""
npz_path = os.path.join(self.embeddings_dir, "mtg_embeddings.npz")
if not os.path.exists(npz_path):
logger.warning(
"Embeddings NPZ not found at %s. "
"Run the lab server first to auto-download embeddings.",
npz_path,
)
return False
try:
data = np.load(npz_path, allow_pickle=True)
self._embeddings = data["embeddings"].astype(np.float32)
self._names = list(data["names"])
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self._names)
}
self._loaded = True
logger.info(
"Loaded %d card embeddings (%d-dim)",
len(self._names), self._embeddings.shape[1],
)
return True
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
def get_embedding(self, card_name: str) -> np.ndarray:
"""Get embedding vector for a card name. Returns zero vector if not found."""
if not self._loaded:
return self._zero_vec
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self._embeddings[idx]
# Try fuzzy match: strip trailing set codes, articles
clean = card_name.lower().split("|")[0].strip()
idx = self._name_to_idx.get(clean)
if idx is not None:
return self._embeddings[idx]
return self._zero_vec
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
"""Mean-pool embeddings for a list of card names (a game zone)."""
if not card_names:
return self._zero_vec
vectors = [self.get_embedding(name) for name in card_names]
return np.mean(vectors, axis=0).astype(np.float32)
@property
def is_loaded(self) -> bool:
return self._loaded
class StateEncoder:
"""
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
Input: a dict from the JSONL file (one decision snapshot)
Output: np.ndarray of shape (6177,)
Components:
[0:29] - Global features (life, mana, turn, phase, etc.)
[29:6173] - Zone embeddings (4 zones × 2 players × 768)
[6173:6177] - Playstyle one-hot (4 dims)
"""
def __init__(self, card_index: CardEmbeddingIndex):
self.card_index = card_index
self.dim = STATE_DIMS
def encode(self, decision: dict, playstyle: str = "midrange") -> np.ndarray:
"""
Encode a single decision snapshot into a state vector.
Args:
decision: Dict loaded from JSONL line
playstyle: Deck archetype string (aggro/control/midrange/combo)
Returns:
np.ndarray of shape (total_state_dim,)
"""
players = decision.get("players", [])
if len(players) < 2:
# Pad to 2 players for 1v1
while len(players) < 2:
players.append(self._empty_player(len(players)))
# 1. Global features
global_vec = self._encode_global(decision, players)
# 2. Zone embeddings
zone_vec = self._encode_zones(players)
# 3. Playstyle one-hot
style_vec = self._encode_playstyle(playstyle)
# Concatenate
state = np.concatenate([global_vec, zone_vec, style_vec])
assert state.shape[0] == self.dim.total_state_dim, (
f"State dim mismatch: {state.shape[0]} != {self.dim.total_state_dim}"
)
return state.astype(np.float32)
def _encode_global(self, decision: dict, players: list) -> np.ndarray:
"""Encode global scalar features. Shape: (29,)"""
features = []
for p in players[:2]: # Exactly 2 players for 1v1
# Per-player scalars (10 each)
life = p.get("life", 40)
features.append(life / 40.0) # Normalized life
cmdr_dmg = p.get("cmdr_dmg", 0)
features.append(cmdr_dmg / 21.0) # Normalized commander damage
mana = p.get("mana", 0)
features.append(min(mana / 20.0, 1.0)) # Normalized mana
cmdr_tax = p.get("cmdr_tax", 0)
features.append(min(cmdr_tax / 10.0, 1.0)) # Normalized tax
hand_size = len(p.get("hand", []))
features.append(min(hand_size / 15.0, 1.0))
grave_size = len(p.get("graveyard", []))
features.append(min(grave_size / 100.0, 1.0))
creatures = p.get("creatures", 0)
features.append(min(creatures / 30.0, 1.0))
# Total power on board (not yet tracked accurately; use creatures as proxy)
total_power = creatures * 3 # rough heuristic: avg 3 power
features.append(min(total_power / 100.0, 1.0))
# Is active player
is_active = 1.0 if p.get("seat") == decision.get("active_seat") else 0.0
features.append(is_active)
# Land count (extra feature — helps distinguish ramp strategies)
lands = p.get("lands", 0)
features.append(min(lands / 15.0, 1.0))
# Phase one-hot (4 dims per player)
phase_str = decision.get("phase", "main_1")
phase_oh = [0.0] * 4
try:
phase_enum = GamePhase(phase_str)
phase_oh[PHASE_TO_IDX[phase_enum]] = 1.0
except (ValueError, KeyError):
phase_oh[0] = 1.0 # Default to main_1
features.extend(phase_oh)
# Turn number (1 feature, shared)
turn = decision.get("turn", 1)
features.append(min(turn / GAME_SCOPE.max_turns, 1.0))
return np.array(features, dtype=np.float32)
def _encode_zones(self, players: list) -> np.ndarray:
"""
Encode zone contents as mean-pooled card embeddings.
Shape: (4 zones × 2 players × 768) = (6144,)
"""
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
vectors = []
for p in players[:2]:
for zone_key in zone_keys:
cards = p.get(zone_key, [])
pooled = self.card_index.mean_pool_zone(cards)
vectors.append(pooled)
return np.concatenate(vectors)
def _encode_playstyle(self, playstyle_str: str) -> np.ndarray:
"""One-hot encode the playstyle. Shape: (4,)"""
vec = np.zeros(self.dim.playstyle_dim, dtype=np.float32)
try:
style = Playstyle(playstyle_str.lower())
vec[PLAYSTYLE_TO_IDX[style]] = 1.0
except (ValueError, KeyError):
# Default: midrange
vec[PLAYSTYLE_TO_IDX[Playstyle.MIDRANGE]] = 1.0
return vec
def _empty_player(self, seat: int) -> dict:
"""Create an empty player dict for padding."""
return {
"seat": seat,
"life": 40,
"cmdr_dmg": 0,
"mana": 0,
"cmdr_tax": 0,
"creatures": 0,
"lands": 0,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
}
def encode_decisions_file(
jsonl_path: str,
card_index: CardEmbeddingIndex,
max_samples: int = None,
) -> Tuple[np.ndarray, List[dict]]:
"""
Encode all decisions from a JSONL file into a state matrix.
Args:
jsonl_path: Path to ml-decisions-*.jsonl file
card_index: Loaded card embedding index
max_samples: Maximum number of samples to encode (None = all)
Returns:
(states, raw_decisions) where:
states: np.ndarray of shape (N, 6177)
raw_decisions: list of original JSON dicts (for action labels)
"""
encoder = StateEncoder(card_index)
states = []
raw = []
with open(jsonl_path, "r") as f:
for i, line in enumerate(f):
if max_samples and i >= max_samples:
break
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
states.append(state_vec)
raw.append(decision)
except (json.JSONDecodeError, Exception) as e:
logger.warning("Skipping line %d: %s", i, e)
continue
if not states:
return np.empty((0, STATE_DIMS.total_state_dim), dtype=np.float32), []
return np.stack(states), raw
View File
Whitespace-only changes.
+426
View File
@@ -0,0 +1,426 @@
"""
Commander AI Lab — Tournament Evaluator (Phase 10)
═══════════════════════════════════════════════════
Round-robin evaluation of multiple AI policies:
- Learned Policy (supervised or PPO checkpoint)
- Heuristic Policy (rule-based baseline)
- Random Policy (uniform random baseline)
- Forge AI (via Java subprocess, when available)
Runs many synthetic games between each pair of policies,
tracks win rates, and produces a tournament results table.
Usage:
python -m ml.eval.tournament
python -m ml.eval.tournament --episodes 100 --checkpoint ml/models/checkpoints/best_ppo.pt
"""
import json
import logging
import os
import sys
import time
from dataclasses import dataclass, field
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import NUM_ACTIONS, STATE_DIMS, IDX_TO_ACTION
from ml.training.reward import RewardConfig
from ml.training.self_play import (
GameState, create_random_initial_state, apply_action, advance_turn,
encode_state_simple, RandomPolicy, HeuristicPolicy,
run_self_play_episode,
)
logger = logging.getLogger("ml.eval.tournament")
class LearnedPolicy:
"""Policy that uses a trained neural network checkpoint."""
def __init__(self, checkpoint_path: str, device: str = "cpu"):
import torch
import torch.nn.functional as F
self.torch = torch
self.F = F
self.device = device
self.model = None
self.name = "learned"
if os.path.exists(checkpoint_path):
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
config = checkpoint.get("model_config", {})
# Try PolicyValueNetwork first (PPO), fall back to PolicyNetwork (supervised)
try:
from ml.training.policy_network import PolicyValueNetwork
self.model = PolicyValueNetwork(
input_dim=config.get("input_dim", STATE_DIMS.total_state_dim),
hidden_dim=config.get("hidden_dim", 512),
num_actions=config.get("num_actions", NUM_ACTIONS),
num_layers=config.get("num_layers", 3),
dropout=config.get("dropout", 0.0),
).to(device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.name = "learned_ppo"
except (RuntimeError, KeyError):
from ml.training.policy_network import PolicyNetwork
self.model = PolicyNetwork(
input_dim=config.get("input_dim", STATE_DIMS.total_state_dim),
hidden_dim=config.get("hidden_dim", 512),
num_actions=config.get("num_actions", NUM_ACTIONS),
num_layers=config.get("num_layers", 3),
dropout=config.get("dropout", 0.0),
).to(device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.name = "learned_supervised"
self.model.eval()
logger.info("Loaded checkpoint: %s", checkpoint_path)
else:
logger.warning("Checkpoint not found: %s — will act as random", checkpoint_path)
def select_action(self, state_vec: np.ndarray) -> Tuple[int, float]:
if self.model is None:
action = np.random.randint(0, NUM_ACTIONS)
return action, -np.log(NUM_ACTIONS)
state_tensor = self.torch.from_numpy(state_vec).unsqueeze(0).to(self.device)
with self.torch.no_grad():
if hasattr(self.model, 'forward_with_value'):
logits, _ = self.model.forward_with_value(state_tensor)
else:
logits = self.model(state_tensor)
probs = self.F.softmax(logits, dim=-1)
action = probs.argmax(dim=-1).item() # Greedy for evaluation
log_prob = self.torch.log(probs[0, action]).item()
return action, log_prob
@dataclass
class MatchResult:
"""Result of a single match between two policies."""
policy_a: str
policy_b: str
winner: Optional[str] # Name of winner, or None for draw
steps: int
total_reward_a: float
total_reward_b: float
timeout: bool = False
@dataclass
class TournamentResult:
"""Aggregated tournament results."""
policies: List[str]
match_results: List[MatchResult] = field(default_factory=list)
win_matrix: Dict = field(default_factory=dict)
win_rates: Dict = field(default_factory=dict)
total_matches: int = 0
total_time_s: float = 0.0
def to_dict(self) -> dict:
return {
"policies": self.policies,
"total_matches": self.total_matches,
"total_time_s": round(self.total_time_s, 1),
"win_rates": self.win_rates,
"win_matrix": self.win_matrix,
"matches": [
{
"policy_a": m.policy_a,
"policy_b": m.policy_b,
"winner": m.winner,
"steps": m.steps,
"timeout": m.timeout,
}
for m in self.match_results
],
}
def run_match(
policy_a, name_a: str,
policy_b, name_b: str,
playstyle: str = "midrange",
max_steps: int = 50,
reward_config: RewardConfig = None,
) -> MatchResult:
"""Run a single match between two policies.
policy_a plays as seat 0, policy_b as seat 1.
"""
state = create_random_initial_state()
cfg = reward_config or RewardConfig()
total_reward_a = 0.0
total_reward_b = 0.0
steps = 0
for step in range(max_steps):
if state.game_over:
break
prev_snapshot = state.to_snapshot()
# Policy A's turn (seat 0)
state_vec_a = encode_state_simple(state, agent_seat=0, playstyle=playstyle)
action_a, _ = policy_a.select_action(state_vec_a)
state = apply_action(state, action_a, player_seat=0)
if state.game_over:
steps = step + 1
break
# Policy B's turn (seat 1)
state_vec_b = encode_state_simple(state, agent_seat=1, playstyle=playstyle)
action_b, _ = policy_b.select_action(state_vec_b)
state = apply_action(state, action_b, player_seat=1)
steps = step + 1
if state.game_over:
break
state = advance_turn(state)
# Determine winner
winner = None
timeout = False
if state.game_over:
if state.draw:
winner = None
elif state.timeout:
timeout = True
if state.winner is not None:
winner = name_a if state.winner == 0 else name_b
elif state.winner is not None:
winner = name_a if state.winner == 0 else name_b
else:
# Max steps reached without game ending — check life totals
timeout = True
if state.players[0].life_total > state.players[1].life_total:
winner = name_a
elif state.players[1].life_total > state.players[0].life_total:
winner = name_b
# else draw
return MatchResult(
policy_a=name_a,
policy_b=name_b,
winner=winner,
steps=steps,
total_reward_a=total_reward_a,
total_reward_b=total_reward_b,
timeout=timeout,
)
def run_tournament(
policies: Dict[str, object],
episodes_per_matchup: int = 100,
playstyle: str = "midrange",
) -> TournamentResult:
"""Run a round-robin tournament between all policy pairs.
Args:
policies: Dict mapping policy name → policy object (with select_action method)
episodes_per_matchup: Games per pair (played both sides)
playstyle: Default playstyle
Returns:
TournamentResult with full stats
"""
t_start = time.time()
names = list(policies.keys())
result = TournamentResult(policies=names)
# Initialize win matrix
for name in names:
result.win_matrix[name] = {n: 0 for n in names}
result.win_rates[name] = {"wins": 0, "losses": 0, "draws": 0, "total": 0}
total_matchups = len(list(combinations(names, 2)))
logger.info("Tournament: %d policies, %d matchups, %d games each (×2 sides) = %d total games",
len(names), total_matchups, episodes_per_matchup,
total_matchups * episodes_per_matchup * 2)
logger.info("")
for name_a, name_b in combinations(names, 2):
policy_a = policies[name_a]
policy_b = policies[name_b]
a_wins = 0
b_wins = 0
draws = 0
for ep in range(episodes_per_matchup):
# Play both sides for fairness
# Game 1: A as seat 0, B as seat 1
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
result.match_results.append(m1)
if m1.winner == name_a:
a_wins += 1
elif m1.winner == name_b:
b_wins += 1
else:
draws += 1
# Game 2: B as seat 0, A as seat 1
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
result.match_results.append(m2)
if m2.winner == name_a:
a_wins += 1
elif m2.winner == name_b:
b_wins += 1
else:
draws += 1
total_games = episodes_per_matchup * 2
result.win_matrix[name_a][name_b] = a_wins
result.win_matrix[name_b][name_a] = b_wins
result.win_rates[name_a]["wins"] += a_wins
result.win_rates[name_a]["losses"] += b_wins
result.win_rates[name_a]["draws"] += draws
result.win_rates[name_a]["total"] += total_games
result.win_rates[name_b]["wins"] += b_wins
result.win_rates[name_b]["losses"] += a_wins
result.win_rates[name_b]["draws"] += draws
result.win_rates[name_b]["total"] += total_games
logger.info(" %s vs %s: %d-%d-%d (%.0f%% vs %.0f%%)",
name_a, name_b, a_wins, b_wins, draws,
a_wins / total_games * 100, b_wins / total_games * 100)
result.total_matches = len(result.match_results)
result.total_time_s = time.time() - t_start
# Compute overall win rates
for name in names:
wr = result.win_rates[name]
total = wr["total"]
if total > 0:
wr["win_rate"] = round(wr["wins"] / total, 4)
else:
wr["win_rate"] = 0.0
return result
def print_tournament_results(result: TournamentResult):
"""Pretty-print tournament results."""
logger.info("")
logger.info("=" * 60)
logger.info(" TOURNAMENT RESULTS")
logger.info("=" * 60)
logger.info("")
# Leaderboard
sorted_policies = sorted(
result.policies,
key=lambda n: result.win_rates[n].get("win_rate", 0),
reverse=True,
)
logger.info(" %-20s %6s %6s %6s %6s %8s", "Policy", "Wins", "Losses", "Draws", "Total", "Win Rate")
logger.info(" " + "-" * 56)
for name in sorted_policies:
wr = result.win_rates[name]
logger.info(" %-20s %6d %6d %6d %6d %7.1f%%",
name, wr["wins"], wr["losses"], wr["draws"], wr["total"],
wr.get("win_rate", 0) * 100)
# Win matrix
logger.info("")
logger.info(" Win Matrix (row beats column):")
header = " %-14s" + " %10s" * len(result.policies)
logger.info(header, "", *result.policies)
for name in result.policies:
row = [result.win_matrix[name].get(n, "-") for n in result.policies]
row_strs = [str(v) if name != n else "-" for v, n in zip(row, result.policies)]
logger.info(" %-14s" + " %10s" * len(row_strs), name, *row_strs)
logger.info("")
logger.info(" Total matches: %d", result.total_matches)
logger.info(" Total time: %.1f s", result.total_time_s)
logger.info("=" * 60)
def main():
"""CLI entry point for tournament evaluation."""
import argparse
parser = argparse.ArgumentParser(description="Commander AI Lab — Tournament Evaluator")
parser.add_argument("--episodes", type=int, default=50,
help="Episodes per matchup pair (played ×2 for both sides)")
parser.add_argument("--checkpoint", default=None,
help="Path to learned policy checkpoint (.pt)")
parser.add_argument("--ppo-checkpoint", default=None,
help="Path to PPO checkpoint (.pt)")
parser.add_argument("--playstyle", default="midrange")
parser.add_argument("--output", default=None,
help="Save results JSON to this path")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
# Build policy roster
policies = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
# Add learned policies if checkpoints exist
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
supervised_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if os.path.exists(supervised_path):
policies["supervised"] = LearnedPolicy(supervised_path)
logger.info("Added supervised policy: %s", supervised_path)
ppo_path = args.ppo_checkpoint or os.path.join(ckpt_dir, "best_ppo.pt")
if os.path.exists(ppo_path):
policies["ppo"] = LearnedPolicy(ppo_path)
logger.info("Added PPO policy: %s", ppo_path)
if len(policies) < 2:
logger.error("Need at least 2 policies for a tournament. Train a model first.")
sys.exit(1)
logger.info("")
# Run tournament
result = run_tournament(
policies=policies,
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
)
print_tournament_results(result)
# Save results
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
logger.info("Results saved: %s", output_path)
if __name__ == "__main__":
main()
View File
Whitespace-only changes.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
"""
Commander AI Lab — Synthetic Training Data Generator
═════════════════════════════════════════════════════
Generates realistic-looking decision snapshot JSONL files
for testing the full ML pipeline without running actual Forge sims.
Uses real card names from precon decks and simulates plausible
game progressions (draws, casts, attacks, mana development).
Usage:
python -m ml.scripts.generate_synthetic_data --games 200 --output results/ml-decisions-synthetic.jsonl
"""
import json
import os
import random
import sys
import argparse
from pathlib import Path
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
# ── Load real card names from precon decks ─────────────────
def load_precon_cards(precon_dir: str) -> dict:
"""Load card names from .dck files, grouped by deck."""
decks = {}
precon_path = Path(precon_dir)
if not precon_path.exists():
print(f"Precon dir not found: {precon_dir}")
return {}
for dck_file in precon_path.glob("*.dck"):
deck_name = dck_file.stem.replace("_", " ")
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
section = "Main"
with open(dck_file, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//") or line.startswith("Name="):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
# Parse "1 Card Name|SET"
parts = line.split(" ", 1)
if len(parts) < 2:
continue
try:
qty = int(parts[0])
except ValueError:
continue
card_name = parts[1].split("|")[0].strip()
if section == "Commander":
cards["commander"] = card_name
else:
cards["all"].append(card_name)
lower = card_name.lower()
if any(k in lower for k in ["plains", "island", "swamp", "mountain", "forest",
"command tower", "temple", "gate", "land"]):
cards["lands"].append(card_name)
elif any(k in lower for k in ["sol ring", "signet", "talisman", "mana crypt",
"cultivate", "kodama", "rampant", "farseek"]):
cards["artifacts"].append(card_name)
else:
# Rough split: ~60% creatures, ~40% spells
if random.random() < 0.6:
cards["creatures"].append(card_name)
else:
cards["spells"].append(card_name)
if cards["commander"]:
decks[deck_name] = cards
return decks
# ── Known removal/draw/ramp for more accurate labeling ────
REMOVAL_NAMES = [
"Swords to Plowshares", "Path to Exile", "Beast Within", "Chaos Warp",
"Counterspell", "Swan Song", "Cyclonic Rift", "Blasphemous Act",
"Wrath of God", "Generous Gift", "Terminate", "Go for the Throat",
"Doom Blade", "Rapid Hybridization", "Vandalblast", "Krosan Grip",
]
DRAW_NAMES = [
"Rhystic Study", "Mystic Remora", "Harmonize", "Brainstorm",
"Ponder", "Preordain", "Night's Whisper", "Sign in Blood",
"Phyrexian Arena", "Sylvan Library", "Skullclamp", "Read the Bones",
]
RAMP_NAMES = [
"Sol Ring", "Arcane Signet", "Cultivate", "Kodama's Reach",
"Rampant Growth", "Farseek", "Mind Stone", "Commander's Sphere",
"Fellwar Stone", "Thought Vessel", "Nature's Lore", "Solemn Simulacrum",
]
PLAYSTYLES = ["aggro", "control", "midrange", "combo"]
PHASES = ["main_1", "combat", "main_2", "end"]
def generate_game_decisions(
game_id: str,
deck_a: dict,
deck_b: dict,
deck_a_name: str,
deck_b_name: str,
) -> list:
"""Generate synthetic decisions for a single game."""
decisions = []
# Shuffle decks (simulating library)
lib_a = list(deck_a["all"])
lib_b = list(deck_b["all"])
random.shuffle(lib_a)
random.shuffle(lib_b)
# Starting state
life = [40, 40]
hand = [lib_a[:7], lib_b[:7]]
lib_a = lib_a[7:]
lib_b = lib_b[7:]
libraries = [lib_a, lib_b]
battlefield = [[], []]
graveyard = [[], []]
cmd_zone = [[deck_a["commander"]], [deck_b["commander"]]]
cmdr_dmg = [0, 0]
cmdr_casts = [0, 0]
lands_played = [0, 0]
playstyle_a = random.choice(PLAYSTYLES)
playstyle_b = random.choice(PLAYSTYLES)
playstyles = [playstyle_a, playstyle_b]
commanders = [deck_a["commander"], deck_b["commander"]]
deck_names = [deck_a_name, deck_b_name]
max_turns = random.randint(8, 20)
winner = None
for turn in range(1, max_turns + 1):
for active_seat in range(2):
if life[0] <= 0 or life[1] <= 0:
break
# Draw a card
if libraries[active_seat]:
drawn = libraries[active_seat].pop(0)
hand[active_seat].append(drawn)
# Decide number of actions this turn (1-3)
num_actions = random.randint(1, 3)
for _ in range(num_actions):
if life[0] <= 0 or life[1] <= 0:
break
phase = random.choice(["main_1", "main_2", "combat"])
# Build player snapshots
players = []
for s in range(2):
players.append({
"seat": s,
"life": life[s],
"cmdr_dmg": cmdr_dmg[s],
"mana": lands_played[s],
"cmdr_tax": cmdr_casts[s] * 2,
"creatures": len([c for c in battlefield[s]
if c.lower() not in ["sol ring", "arcane signet"]]),
"lands": lands_played[s],
"hand": list(hand[s]),
"battlefield": list(battlefield[s]),
"graveyard": list(graveyard[s]),
"command_zone": list(cmd_zone[s]),
})
# Choose action based on game state
action = choose_synthetic_action(
active_seat, turn, phase, hand[active_seat],
battlefield[active_seat], lands_played[active_seat],
commanders[active_seat], cmd_zone[active_seat],
cmdr_casts[active_seat], playstyles[active_seat],
)
# Build decision
decision = {
"game_id": game_id,
"turn": turn,
"phase": phase,
"active_seat": active_seat,
"game_outcome": "", # filled later
"deck_name": deck_names[active_seat],
"commander": commanders[active_seat],
"archetype": playstyles[active_seat],
"players": players,
"action": action,
}
decisions.append(decision)
# Apply action effects to state
apply_action(
action, active_seat, hand, battlefield, graveyard,
cmd_zone, lands_played, life, cmdr_dmg, cmdr_casts,
commanders, libraries,
)
if life[0] <= 0 or life[1] <= 0:
break
# Determine winner
if life[1] <= 0 and life[0] > 0:
winner = "win_seat_0"
elif life[0] <= 0 and life[1] > 0:
winner = "win_seat_1"
else:
# Random winner if game timed out
winner = random.choice(["win_seat_0", "win_seat_1"])
# Backfill game_outcome
for d in decisions:
d["game_outcome"] = winner
return decisions
def choose_synthetic_action(
seat, turn, phase, hand, battlefield, mana, commander, cmd_zone,
cmdr_casts, playstyle,
) -> dict:
"""Choose a plausible action based on game state."""
# Early game: prioritize lands and ramp
if turn <= 3:
# Play land if in hand
for card in hand:
lower = card.lower()
if any(k in lower for k in ["plains", "island", "swamp", "mountain", "forest",
"command tower", "temple", "gate"]):
return {"type": "land", "card": card, "target": "", "raw": f"plays {card}."}
# Cast ramp
for card in hand:
if card in RAMP_NAMES or card.lower() in [r.lower() for r in RAMP_NAMES]:
if mana >= 1:
return {"type": "cast", "card": card, "target": "", "raw": f"casts {card}."}
# Mid/late game: more variety
action_weights = {
"aggro": {"attack": 0.35, "creature": 0.30, "removal": 0.10, "draw": 0.05, "ramp": 0.05, "commander": 0.10, "pass": 0.05},
"control": {"attack": 0.10, "creature": 0.15, "removal": 0.30, "draw": 0.20, "ramp": 0.10, "commander": 0.05, "pass": 0.10},
"midrange": {"attack": 0.20, "creature": 0.25, "removal": 0.15, "draw": 0.10, "ramp": 0.10, "commander": 0.10, "pass": 0.10},
"combo": {"attack": 0.10, "creature": 0.20, "removal": 0.10, "draw": 0.25, "ramp": 0.15, "commander": 0.10, "pass": 0.10},
}
weights = action_weights.get(playstyle, action_weights["midrange"])
# Roll action type
roll = random.random()
cumulative = 0
chosen_type = "pass"
for action_type, weight in weights.items():
cumulative += weight
if roll <= cumulative:
chosen_type = action_type
break
# Attack
if chosen_type == "attack" and phase == "combat" and battlefield:
return {"type": "attack", "card": "", "target": "", "raw": "attacks with creatures."}
# Cast commander
if chosen_type == "commander" and cmd_zone and commander in cmd_zone:
tax = cmdr_casts * 2
if mana >= 3 + tax: # rough mana check
return {"type": "cast_commander", "card": commander, "target": "", "raw": f"casts {commander}."}
# Cast creature from hand
if chosen_type == "creature" and hand:
card = random.choice(hand)
return {"type": "cast", "card": card, "target": "", "raw": f"casts {card}."}
# Cast removal
if chosen_type == "removal":
removal_in_hand = [c for c in hand if c in REMOVAL_NAMES or c.lower() in [r.lower() for r in REMOVAL_NAMES]]
if removal_in_hand:
card = random.choice(removal_in_hand)
return {"type": "cast", "card": card, "target": "opponent creature", "raw": f"casts {card}."}
# Use any spell as removal stand-in
if hand:
card = random.choice(hand)
return {"type": "cast", "card": card, "target": "", "raw": f"casts {card}."}
# Cast draw
if chosen_type == "draw":
draw_in_hand = [c for c in hand if c in DRAW_NAMES or c.lower() in [r.lower() for r in DRAW_NAMES]]
if draw_in_hand:
card = random.choice(draw_in_hand)
return {"type": "cast", "card": card, "target": "", "raw": f"casts {card}."}
# Cast ramp
if chosen_type == "ramp":
ramp_in_hand = [c for c in hand if c in RAMP_NAMES or c.lower() in [r.lower() for r in RAMP_NAMES]]
if ramp_in_hand:
card = random.choice(ramp_in_hand)
return {"type": "cast", "card": card, "target": "", "raw": f"casts {card}."}
# Land play fallback
for card in hand:
lower = card.lower()
if any(k in lower for k in ["plains", "island", "swamp", "mountain", "forest", "command tower"]):
return {"type": "land", "card": card, "target": "", "raw": f"plays {card}."}
# Pass
if mana >= 2:
return {"type": "pass", "card": "", "target": "", "raw": "passes priority."}
return {"type": "pass", "card": "", "target": "", "raw": "passes."}
def apply_action(action, seat, hand, battlefield, graveyard, cmd_zone,
lands_played, life, cmdr_dmg, cmdr_casts, commanders, libraries):
"""Apply an action's side effects to game state."""
atype = action["type"]
card = action.get("card", "")
if atype == "land" and card and card in hand[seat]:
hand[seat].remove(card)
battlefield[seat].append(card)
lands_played[seat] += 1
elif atype == "cast" and card and card in hand[seat]:
hand[seat].remove(card)
battlefield[seat].append(card)
elif atype == "cast_commander" and card:
if card in cmd_zone[seat]:
cmd_zone[seat].remove(card)
battlefield[seat].append(card)
cmdr_casts[seat] += 1
elif atype == "attack":
# Simulate some damage
opponent = 1 - seat
num_attackers = min(len(battlefield[seat]), random.randint(1, 4))
damage = num_attackers * random.randint(2, 5)
life[opponent] -= damage
# Commander damage if commander is on field
if commanders[seat] in battlefield[seat] and random.random() < 0.3:
cmd_dmg = random.randint(3, 7)
cmdr_dmg[opponent] += cmd_dmg
def main():
parser = argparse.ArgumentParser(description="Generate synthetic ML training data")
parser.add_argument("--games", type=int, default=200,
help="Number of games to simulate (default: 200)")
parser.add_argument("--output", default="results/ml-decisions-synthetic.jsonl",
help="Output JSONL path")
parser.add_argument("--precon-dir", default=None,
help="Path to precon decks (default: auto-detect)")
args = parser.parse_args()
# Find precon decks
precon_dir = args.precon_dir or str(Path(project_root) / "precon-decks")
decks = load_precon_cards(precon_dir)
if not decks:
print("No precon decks found. Cannot generate synthetic data.")
sys.exit(1)
deck_names = list(decks.keys())
print(f"Loaded {len(deck_names)} precon decks: {', '.join(deck_names[:5])}...")
# Generate games
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
total_decisions = 0
with open(args.output, "w") as f:
for game_idx in range(args.games):
# Pick two random decks
d1_name, d2_name = random.sample(deck_names, 2)
d1, d2 = decks[d1_name], decks[d2_name]
game_id = f"synthetic-{game_idx:04d}"
decisions = generate_game_decisions(game_id, d1, d2, d1_name, d2_name)
for d in decisions:
f.write(json.dumps(d) + "\n")
total_decisions += 1
if (game_idx + 1) % 50 == 0:
print(f" Generated {game_idx + 1}/{args.games} games...")
print(f"\nDone: {args.games} games, {total_decisions:,} decisions")
print(f"Output: {args.output}")
print(f"Average {total_decisions / args.games:.1f} decisions per game")
if __name__ == "__main__":
main()
+371
View File
@@ -0,0 +1,371 @@
"""
Commander AI Lab — ML Pipeline CLI
════════════════════════════════════
Unified CLI for ML training data workflows.
Commands:
status — Show ML logging status and available training data
build — Build training dataset from decision logs
train — Train policy network on built dataset (supervised)
eval — Evaluate a trained model on test data
inspect — Inspect a JSONL decision file (sample entries)
stats — Show action label distribution from decisions
ppo — Train with PPO (self-play reinforcement learning)
tournament — Run round-robin tournament evaluation
Usage:
python -m ml.scripts.ml_cli status
python -m ml.scripts.ml_cli build --results-dir results/
python -m ml.scripts.ml_cli train --epochs 50 --lr 1e-3
python -m ml.scripts.ml_cli eval
python -m ml.scripts.ml_cli inspect --file results/ml-decisions-abc12345.jsonl
python -m ml.scripts.ml_cli stats --results-dir results/
python -m ml.scripts.ml_cli ppo --iterations 100 --opponent heuristic
python -m ml.scripts.ml_cli tournament --episodes 50
"""
import argparse
import json
import os
import sys
from pathlib import Path
# Add project root to path
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
def cmd_status(args):
"""Show ML logging status and available data files."""
results_dir = Path(args.results_dir)
print("\n╔══════════════════════════════════════════╗")
print("║ Commander AI Lab — ML Training Status ║")
print("╚══════════════════════════════════════════╝\n")
if not results_dir.exists():
print(f" Results directory not found: {results_dir}")
print(" Run batch simulations first.\n")
return
# Find decision files
jsonl_files = sorted(results_dir.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
print(" No ML decision files found.")
print(" Enable ML logging and run batch simulations:")
print(" • Web UI: POST /api/ml/toggle?enable=true")
print(" • CLI: java -jar ... --ml-log")
print()
return
total_decisions = 0
total_size = 0
print(f" {'File':<40} {'Decisions':>10} {'Size':>10}")
print(" " + "─" * 62)
for f in jsonl_files:
lines = 0
try:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
size = f.stat().st_size
total_decisions += lines
total_size += size
print(f" {f.name:<40} {lines:>10,} {size/1024:>9.1f}K")
print(" " + "─" * 62)
print(f" {'TOTAL':<40} {total_decisions:>10,} {total_size/1024:>9.1f}K")
# Check for embeddings
emb_path = Path(project_root) / "embeddings" / "mtg_embeddings.npz"
emb_status = "✓ Loaded" if emb_path.exists() else "✗ Not found"
print(f"\n Embeddings: {emb_status}")
# Check for existing datasets
models_dir = Path(project_root) / "ml" / "models"
npz_files = sorted(models_dir.glob("*.npz")) if models_dir.exists() else []
if npz_files:
print(f"\n Existing datasets:")
for f in npz_files:
print(f" {f.name} ({f.stat().st_size/1024:.1f}K)")
print()
def cmd_build(args):
"""Build training dataset."""
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
dataset = build_dataset(
results_dir=args.results_dir,
embeddings_dir=args.embeddings_dir,
max_samples=args.max_samples,
)
if not dataset:
print("\nNo data to build. Enable ML logging and run simulations first.")
return
if args.no_split:
save_dataset(dataset, args.output)
else:
train, val, test = split_dataset(dataset)
base = args.output.replace(".npz", "")
save_dataset(train, f"{base}-train.npz")
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
print("\n✓ Dataset build complete.")
def cmd_inspect(args):
"""Inspect a JSONL decision file."""
filepath = Path(args.file)
if not filepath.exists():
print(f"File not found: {filepath}")
return
print(f"\n Inspecting: {filepath.name}\n")
count = 0
with open(filepath) as f:
for i, line in enumerate(f):
if i >= args.limit:
break
try:
d = json.loads(line.strip())
action = d.get("action", {})
players = d.get("players", [])
p0_life = players[0]["life"] if players else "?"
p1_life = players[1]["life"] if len(players) > 1 else "?"
print(f" [{i:>4}] Turn {d.get('turn', '?'):>2} "
f"| Phase: {d.get('phase', '?'):<8} "
f"| Seat {d.get('active_seat', '?')} "
f"| Life: {p0_life}/{p1_life} "
f"| {action.get('type', '?')}: {action.get('card', '-')}")
count += 1
except Exception as e:
print(f" [{i:>4}] ERROR: {e}")
with open(filepath) as f:
total = sum(1 for _ in f)
print(f"\n Showing {count} of {total:,} total decisions.\n")
def cmd_train(args):
"""Train policy network."""
from ml.training.trainer import main as trainer_main
# Re-construct sys.argv for the trainer CLI
argv = ["trainer"]
if args.data_dir:
argv += ["--data-dir", args.data_dir]
if args.epochs:
argv += ["--epochs", str(args.epochs)]
if args.batch_size:
argv += ["--batch-size", str(args.batch_size)]
if args.lr:
argv += ["--lr", str(args.lr)]
if args.patience:
argv += ["--patience", str(args.patience)]
if args.checkpoint_dir:
argv += ["--checkpoint-dir", args.checkpoint_dir]
if args.device:
argv += ["--device", args.device]
sys.argv = argv
trainer_main()
def cmd_eval(args):
"""Evaluate trained model."""
from ml.training.trainer import main as trainer_main
argv = ["trainer", "--eval-only"]
if args.data_dir:
argv += ["--data-dir", args.data_dir]
if args.checkpoint_dir:
argv += ["--checkpoint-dir", args.checkpoint_dir]
if args.checkpoint:
argv += ["--checkpoint", args.checkpoint]
if args.device:
argv += ["--device", args.device]
sys.argv = argv
trainer_main()
def cmd_ppo(args):
"""Train with PPO (self-play RL)."""
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
config = PPOConfig(
iterations=args.iterations,
episodes_per_iter=args.episodes_per_iter,
ppo_epochs=args.ppo_epochs,
batch_size=args.batch_size,
clip_epsilon=args.clip_epsilon,
entropy_coeff=args.entropy_coeff,
learning_rate=args.lr,
lr_schedule=args.lr_schedule,
buffer_size=args.buffer_size,
gamma=args.gamma,
gae_lambda=args.gae_lambda,
opponent=args.opponent,
playstyle=args.playstyle,
checkpoint_dir=args.checkpoint_dir or "ml/models/checkpoints",
save_every=args.save_every,
eval_every=args.eval_every,
eval_episodes=args.eval_episodes,
load_supervised=args.load_supervised,
)
trainer = PPOTrainer(config)
summary = trainer.train()
print(json.dumps(summary, indent=2))
def cmd_tournament(args):
"""Run round-robin tournament evaluation."""
import logging
logging.basicConfig(level=logging.INFO, format="%(message)s")
from ml.eval.tournament import main as tournament_main
argv = ["tournament"]
if args.episodes:
argv += ["--episodes", str(args.episodes)]
if args.checkpoint:
argv += ["--checkpoint", args.checkpoint]
if args.ppo_checkpoint:
argv += ["--ppo-checkpoint", args.ppo_checkpoint]
if args.playstyle:
argv += ["--playstyle", args.playstyle]
if args.output:
argv += ["--output", args.output]
sys.argv = argv
tournament_main()
def cmd_stats(args):
"""Show action label distribution."""
from ml.actions.labeler import label_decisions_file, print_label_distribution
import numpy as np
results_dir = Path(args.results_dir)
jsonl_files = sorted(results_dir.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
print("No ML decision files found.")
return
all_labels = []
for f in jsonl_files:
labels, _ = label_decisions_file(str(f))
all_labels.append(labels)
combined = np.concatenate(all_labels)
print(f"\nCombined stats from {len(jsonl_files)} files:")
print_label_distribution(combined)
print()
def main():
parser = argparse.ArgumentParser(
description="Commander AI Lab — ML Pipeline CLI"
)
subparsers = parser.add_subparsers(dest="command")
# status
sp_status = subparsers.add_parser("status", help="Show ML training data status")
sp_status.add_argument("--results-dir", default="results")
# build
sp_build = subparsers.add_parser("build", help="Build training dataset")
sp_build.add_argument("--results-dir", default="results")
sp_build.add_argument("--embeddings-dir", default=None)
sp_build.add_argument("--output", default="ml/models/dataset.npz")
sp_build.add_argument("--max-samples", type=int, default=None)
sp_build.add_argument("--no-split", action="store_true")
# inspect
sp_inspect = subparsers.add_parser("inspect", help="Inspect a decision file")
sp_inspect.add_argument("--file", required=True)
sp_inspect.add_argument("--limit", type=int, default=20)
# train
sp_train = subparsers.add_parser("train", help="Train policy network")
sp_train.add_argument("--data-dir", default="ml/models")
sp_train.add_argument("--epochs", type=int, default=None)
sp_train.add_argument("--batch-size", type=int, default=None)
sp_train.add_argument("--lr", type=float, default=None)
sp_train.add_argument("--patience", type=int, default=None)
sp_train.add_argument("--checkpoint-dir", default=None)
sp_train.add_argument("--device", default=None)
# eval
sp_eval = subparsers.add_parser("eval", help="Evaluate trained model on test set")
sp_eval.add_argument("--data-dir", default="ml/models")
sp_eval.add_argument("--checkpoint-dir", default=None)
sp_eval.add_argument("--checkpoint", default=None)
sp_eval.add_argument("--device", default=None)
# stats
sp_stats = subparsers.add_parser("stats", help="Show action label distribution")
sp_stats.add_argument("--results-dir", default="results")
# ppo
sp_ppo = subparsers.add_parser("ppo", help="Train with PPO (self-play RL)")
sp_ppo.add_argument("--iterations", type=int, default=100)
sp_ppo.add_argument("--episodes-per-iter", type=int, default=64)
sp_ppo.add_argument("--ppo-epochs", type=int, default=4)
sp_ppo.add_argument("--batch-size", type=int, default=256)
sp_ppo.add_argument("--clip-epsilon", type=float, default=0.2)
sp_ppo.add_argument("--entropy-coeff", type=float, default=0.01)
sp_ppo.add_argument("--lr", type=float, default=3e-4)
sp_ppo.add_argument("--lr-schedule", default="constant", choices=["constant", "linear", "cosine"])
sp_ppo.add_argument("--buffer-size", type=int, default=4096)
sp_ppo.add_argument("--gamma", type=float, default=0.99)
sp_ppo.add_argument("--gae-lambda", type=float, default=0.95)
sp_ppo.add_argument("--opponent", default="heuristic", choices=["heuristic", "random", "self"])
sp_ppo.add_argument("--playstyle", default="midrange")
sp_ppo.add_argument("--checkpoint-dir", default=None)
sp_ppo.add_argument("--save-every", type=int, default=10)
sp_ppo.add_argument("--eval-every", type=int, default=5)
sp_ppo.add_argument("--eval-episodes", type=int, default=50)
sp_ppo.add_argument("--load-supervised", default=None,
help="Path to supervised checkpoint to initialize from")
# tournament
sp_tourney = subparsers.add_parser("tournament", help="Run round-robin tournament evaluation")
sp_tourney.add_argument("--episodes", type=int, default=50)
sp_tourney.add_argument("--checkpoint", default=None)
sp_tourney.add_argument("--ppo-checkpoint", default=None)
sp_tourney.add_argument("--playstyle", default="midrange")
sp_tourney.add_argument("--output", default=None)
args = parser.parse_args()
if args.command == "status":
cmd_status(args)
elif args.command == "build":
cmd_build(args)
elif args.command == "train":
cmd_train(args)
elif args.command == "eval":
cmd_eval(args)
elif args.command == "inspect":
cmd_inspect(args)
elif args.command == "stats":
cmd_stats(args)
elif args.command == "ppo":
cmd_ppo(args)
elif args.command == "tournament":
cmd_tournament(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
View File
Whitespace-only changes.
+448
View File
@@ -0,0 +1,448 @@
"""
Commander AI Lab — Policy Inference Server
════════════════════════════════════════════
FastAPI service that loads a trained PolicyNetwork checkpoint and serves
macro-action predictions via HTTP.
The Java batch runner calls this endpoint at each decision point
to get the learned policy's action instead of using Forge's built-in AI.
Endpoints:
POST /api/ml/predict — Predict action from raw game state JSON
GET /api/ml/model — Current model info (loaded checkpoint, accuracy)
POST /api/ml/reload — Hot-reload a new checkpoint without restart
Design:
- Loads card embeddings + state encoder on startup
- Accepts DecisionSnapshot-shaped JSON from Java
- Encodes state → tensor, runs model forward pass, returns action
- Supports temperature-controlled sampling or greedy argmax
- Thread-safe via torch.no_grad() inference
Usage (standalone):
python -m ml.serving.policy_server --port 8090
python -m ml.serving.policy_server --checkpoint ml/models/checkpoints/best_policy.pt
Usage (integrated into lab_api.py):
Import PolicyInferenceService and register endpoints on the existing app.
"""
import logging
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Conditional imports
try:
import torch
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
from ml.config.scope import (
NUM_ACTIONS, IDX_TO_ACTION, MacroAction, STATE_DIMS,
)
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
logger = logging.getLogger("ml.serving")
class PolicyInferenceService:
"""
Loads a trained PolicyNetwork and provides action predictions.
Thread-safe — uses torch.no_grad() for inference.
Designed to be embedded in lab_api.py or run standalone.
"""
def __init__(
self,
checkpoint_dir: str = None,
embeddings_dir: str = None,
device: str = None,
):
self.checkpoint_dir = checkpoint_dir or os.path.join(
project_root, "ml", "models", "checkpoints"
)
self.embeddings_dir = embeddings_dir or os.path.join(
project_root, "embeddings"
)
self.device = device or self._detect_device()
self.model = None
self.encoder = None
self.card_index = None
self.checkpoint_info: Dict = {}
self._loaded = False
self._load_error: Optional[str] = None
def _detect_device(self) -> str:
if not TORCH_AVAILABLE:
return "cpu"
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def load(self) -> bool:
"""
Load card embeddings, state encoder, and policy model.
Returns True if everything loaded successfully.
"""
if not TORCH_AVAILABLE:
self._load_error = "PyTorch not installed"
logger.error("PyTorch not installed — cannot serve policy")
return False
# 1. Load card embeddings
self.card_index = CardEmbeddingIndex(self.embeddings_dir)
if not self.card_index.load():
self._load_error = "Card embeddings not found"
logger.error(
"Card embeddings not found at %s. "
"Start the lab server to auto-download, or run coach first.",
self.embeddings_dir,
)
return False
self.encoder = StateEncoder(self.card_index)
# 2. Load policy checkpoint
checkpoint_path = os.path.join(self.checkpoint_dir, "best_policy.pt")
if not os.path.exists(checkpoint_path):
self._load_error = f"No checkpoint at {checkpoint_path}"
logger.warning("No trained model found at %s", checkpoint_path)
logger.warning("Train a model first: python -m ml.scripts.ml_cli train")
return False
try:
from ml.training.policy_network import load_checkpoint
self.model, checkpoint = load_checkpoint(checkpoint_path, self.device)
self.model.eval()
self.checkpoint_info = {
"path": checkpoint_path,
"epoch": checkpoint.get("epoch", "?"),
"metrics": checkpoint.get("metrics", {}),
"device": self.device,
}
self._loaded = True
self._load_error = None
logger.info(
"Policy model loaded (epoch %s, device=%s)",
self.checkpoint_info["epoch"], self.device,
)
return True
except Exception as e:
self._load_error = str(e)
logger.error("Failed to load checkpoint: %s", e)
return False
def reload(self, checkpoint_path: str = None) -> bool:
"""Hot-reload a checkpoint without reloading embeddings."""
if not TORCH_AVAILABLE:
return False
path = checkpoint_path or os.path.join(self.checkpoint_dir, "best_policy.pt")
if not os.path.exists(path):
self._load_error = f"Checkpoint not found: {path}"
return False
try:
from ml.training.policy_network import load_checkpoint
self.model, checkpoint = load_checkpoint(path, self.device)
self.model.eval()
self.checkpoint_info = {
"path": path,
"epoch": checkpoint.get("epoch", "?"),
"metrics": checkpoint.get("metrics", {}),
"device": self.device,
}
self._loaded = True
self._load_error = None
logger.info("Model hot-reloaded from %s (epoch %s)",
path, self.checkpoint_info["epoch"])
return True
except Exception as e:
self._load_error = str(e)
logger.error("Hot-reload failed: %s", e)
return False
def predict(
self,
decision_snapshot: Dict,
playstyle: str = "midrange",
temperature: float = 1.0,
greedy: bool = False,
) -> Dict:
"""
Predict a macro-action from a raw decision snapshot.
Args:
decision_snapshot: DecisionSnapshot JSON from Java
Must contain: turn, phase, active_seat, players[]
playstyle: Deck playstyle hint ("aggro", "control", "midrange", "combo")
temperature: Softmax temperature (lower = more deterministic)
greedy: If True, always pick argmax (ignore temperature)
Returns:
{
"action": "cast_creature",
"action_index": 0,
"confidence": 0.73,
"probabilities": {"cast_creature": 0.73, "pass": 0.12, ...},
"inference_ms": 2.3,
}
"""
if not self._loaded:
return {
"error": "Model not loaded",
"detail": self._load_error or "Call load() first",
}
t_start = time.time()
try:
# Encode the raw snapshot to a state vector
state_vec = self.encoder.encode(decision_snapshot, playstyle)
state_tensor = torch.from_numpy(
state_vec.astype(np.float32)
).unsqueeze(0).to(self.device)
# Run inference
with torch.no_grad():
logits = self.model(state_tensor)
if greedy:
action_idx = logits.argmax(dim=-1).item()
probs = torch.softmax(logits, dim=-1).cpu().numpy().flatten()
else:
scaled = logits / max(temperature, 0.01)
probs = torch.softmax(scaled, dim=-1).cpu().numpy().flatten()
action_idx = int(np.random.choice(NUM_ACTIONS, p=probs))
elapsed_ms = (time.time() - t_start) * 1000
action = IDX_TO_ACTION[action_idx]
confidence = float(probs[action_idx])
# Build probability map
prob_map = {}
for i in range(NUM_ACTIONS):
prob_map[IDX_TO_ACTION[i].value] = round(float(probs[i]), 4)
return {
"action": action.value,
"action_index": action_idx,
"confidence": round(confidence, 4),
"probabilities": prob_map,
"inference_ms": round(elapsed_ms, 2),
}
except Exception as e:
elapsed_ms = (time.time() - t_start) * 1000
logger.error("Inference error: %s", e)
return {
"error": str(e),
"inference_ms": round(elapsed_ms, 2),
}
def predict_batch(
self,
snapshots: List[Dict],
playstyle: str = "midrange",
greedy: bool = True,
) -> List[Dict]:
"""Predict actions for multiple snapshots at once (vectorized)."""
if not self._loaded:
return [{"error": "Model not loaded"}] * len(snapshots)
t_start = time.time()
results = []
try:
# Encode all states
state_vecs = []
for snap in snapshots:
ps = snap.get("archetype", playstyle)
vec = self.encoder.encode(snap, ps)
state_vecs.append(vec)
states = np.stack(state_vecs).astype(np.float32)
state_tensor = torch.from_numpy(states).to(self.device)
# Batch inference
with torch.no_grad():
logits = self.model(state_tensor)
probs = torch.softmax(logits, dim=-1).cpu().numpy()
if greedy:
action_indices = logits.argmax(dim=-1).cpu().numpy()
else:
action_indices = np.array([
np.random.choice(NUM_ACTIONS, p=p) for p in probs
])
elapsed_ms = (time.time() - t_start) * 1000
for i, (idx, prob) in enumerate(zip(action_indices, probs)):
action = IDX_TO_ACTION[int(idx)]
results.append({
"action": action.value,
"action_index": int(idx),
"confidence": round(float(prob[idx]), 4),
})
logger.debug(
"Batch inference: %d samples in %.1fms (%.2f ms/sample)",
len(snapshots), elapsed_ms, elapsed_ms / max(len(snapshots), 1),
)
except Exception as e:
logger.error("Batch inference error: %s", e)
results = [{"error": str(e)}] * len(snapshots)
return results
def get_status(self) -> Dict:
"""Return current service status."""
return {
"loaded": self._loaded,
"error": self._load_error,
"device": self.device,
"torch_available": TORCH_AVAILABLE,
"checkpoint": self.checkpoint_info if self._loaded else None,
"embeddings_loaded": (
self.card_index is not None and self.card_index._loaded
),
}
# ══════════════════════════════════════════════════════════
# Standalone FastAPI Server
# ══════════════════════════════════════════════════════════
def create_standalone_app():
"""Create a standalone FastAPI app for the policy server."""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(
title="Commander AI Lab — Policy Server",
description="Serves macro-action predictions from a trained policy network",
version="1.0.0",
)
service = PolicyInferenceService()
class PredictRequest(BaseModel):
"""Request body for /predict endpoint."""
# Game state (matching DecisionSnapshot from Java)
turn: int = 1
phase: str = "main_1"
active_seat: int = 0
players: List[Dict] = []
action: Optional[Dict] = None
# Optional metadata
game_id: str = ""
archetype: str = "midrange"
commander: str = ""
deck_name: str = ""
# Inference parameters
temperature: float = 1.0
greedy: bool = False
class PredictResponse(BaseModel):
action: str
action_index: int
confidence: float
probabilities: Dict[str, float] = {}
inference_ms: float = 0.0
error: Optional[str] = None
@app.on_event("startup")
async def startup():
service.load()
@app.post("/api/ml/predict", response_model=PredictResponse)
async def predict(req: PredictRequest):
if not service._loaded:
raise HTTPException(503, detail=service._load_error or "Model not loaded")
snapshot = req.dict(exclude={"temperature", "greedy"})
result = service.predict(
snapshot,
playstyle=req.archetype,
temperature=req.temperature,
greedy=req.greedy,
)
if "error" in result:
raise HTTPException(500, detail=result["error"])
return result
@app.get("/api/ml/model")
async def model_info():
return service.get_status()
@app.post("/api/ml/reload")
async def reload_model(checkpoint: str = None):
ok = service.reload(checkpoint)
return {
"success": ok,
"status": service.get_status(),
}
@app.get("/health")
async def health():
return {
"status": "ok" if service._loaded else "degraded",
"model_loaded": service._loaded,
}
return app, service
def main():
"""Run standalone policy server."""
import argparse
import uvicorn
parser = argparse.ArgumentParser(
description="Commander AI Lab — Policy Inference Server"
)
parser.add_argument("--port", type=int, default=8090)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--checkpoint-dir", default=None)
parser.add_argument("--embeddings-dir", default=None)
parser.add_argument("--device", default=None)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
# Override defaults if provided
if args.checkpoint_dir or args.embeddings_dir or args.device:
# Re-create the service will be handled at startup
pass
app, _ = create_standalone_app()
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+225
View File
@@ -0,0 +1,225 @@
"""
Commander AI Lab — Policy Network
══════════════════════════════════
Feed-forward neural network that maps game state + playstyle → action logits.
Architecture:
Input: state vector (6177-dim) = global features + zone embeddings + playstyle
Hidden: 3 FC layers with SiLU activation and layer normalization
Output: logits over 8 macro-actions
The network is designed for:
1. Supervised learning (cross-entropy on expert/AI action labels)
2. Later conversion to PPO actor-critic (add value head)
"""
import os
import sys
from pathlib import Path
# Conditional torch import — graceful failure if not installed
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
print("[ML] PyTorch not installed. Install with: pip install torch")
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import STATE_DIMS, NUM_ACTIONS, TRAINING_CONFIG
class PolicyNetwork(nn.Module):
"""
Feed-forward policy network for Commander macro-action prediction.
Input: (batch, 6177) state vector
Output: (batch, 8) action logits (unnormalized log-probabilities)
Architecture:
FC(6177 → 512) → LayerNorm → SiLU → Dropout
FC(512 → 512) → LayerNorm → SiLU → Dropout
FC(512 → 256) → LayerNorm → SiLU → Dropout
FC(256 → 8)
"""
def __init__(
self,
input_dim: int = None,
hidden_dim: int = None,
num_actions: int = None,
num_layers: int = None,
dropout: float = None,
):
super().__init__()
input_dim = input_dim or STATE_DIMS.total_state_dim
hidden_dim = hidden_dim or TRAINING_CONFIG.hidden_dim
num_actions = num_actions or NUM_ACTIONS
num_layers = num_layers or TRAINING_CONFIG.num_layers
dropout = dropout or TRAINING_CONFIG.dropout
layers = []
# Input layer
layers.append(nn.Linear(input_dim, hidden_dim))
layers.append(nn.LayerNorm(hidden_dim))
layers.append(nn.SiLU())
layers.append(nn.Dropout(dropout))
# Hidden layers
for i in range(num_layers - 1):
out_dim = hidden_dim if i < num_layers - 2 else hidden_dim // 2
layers.append(nn.Linear(hidden_dim if i == 0 else hidden_dim, out_dim))
layers.append(nn.LayerNorm(out_dim))
layers.append(nn.SiLU())
layers.append(nn.Dropout(dropout))
hidden_dim = out_dim
self.features = nn.Sequential(*layers)
# Output head
self.action_head = nn.Linear(hidden_dim, num_actions)
# Initialize weights
self._init_weights()
def _init_weights(self):
"""Kaiming initialization for SiLU activations."""
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, nonlinearity='linear')
if m.bias is not None:
nn.init.zeros_(m.bias)
def forward(self, state: torch.Tensor) -> torch.Tensor:
"""
Forward pass.
Args:
state: (batch, input_dim) tensor
Returns:
(batch, num_actions) logits
"""
features = self.features(state)
return self.action_head(features)
def predict_action(self, state: torch.Tensor, temperature: float = 1.0) -> int:
"""
Predict a single action (for inference).
Args:
state: (1, input_dim) or (input_dim,) tensor
temperature: Softmax temperature (1.0 = normal, <1 = more greedy, >1 = more random)
Returns:
action index (int)
"""
self.eval()
with torch.no_grad():
if state.dim() == 1:
state = state.unsqueeze(0)
logits = self.forward(state)
if temperature != 1.0:
logits = logits / temperature
probs = F.softmax(logits, dim=-1)
action = torch.multinomial(probs, 1).item()
return action
def predict_action_greedy(self, state: torch.Tensor) -> int:
"""Predict the most likely action (argmax, no sampling)."""
self.eval()
with torch.no_grad():
if state.dim() == 1:
state = state.unsqueeze(0)
logits = self.forward(state)
return logits.argmax(dim=-1).item()
def get_action_probs(self, state: torch.Tensor) -> np.ndarray:
"""Get action probability distribution for a state."""
self.eval()
with torch.no_grad():
if state.dim() == 1:
state = state.unsqueeze(0)
logits = self.forward(state)
probs = F.softmax(logits, dim=-1)
return probs.cpu().numpy().flatten()
class PolicyValueNetwork(PolicyNetwork):
"""
Extended policy network with a value head for actor-critic / PPO.
Same architecture as PolicyNetwork, plus:
value_head: FC(hidden → 1) predicting state value V(s)
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Value head shares features, outputs scalar
last_hidden = self.action_head.in_features
self.value_head = nn.Linear(last_hidden, 1)
nn.init.zeros_(self.value_head.bias)
def forward_with_value(self, state: torch.Tensor):
"""
Forward pass returning both action logits and state value.
Returns:
(action_logits, value) — shapes (batch, num_actions), (batch, 1)
"""
features = self.features(state)
action_logits = self.action_head(features)
value = self.value_head(features)
return action_logits, value
def save_checkpoint(
model: nn.Module,
optimizer: torch.optim.Optimizer,
epoch: int,
metrics: dict,
path: str,
):
"""Save model checkpoint with training metadata."""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
torch.save({
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"metrics": metrics,
"model_config": {
"input_dim": STATE_DIMS.total_state_dim,
"hidden_dim": TRAINING_CONFIG.hidden_dim,
"num_actions": NUM_ACTIONS,
"num_layers": TRAINING_CONFIG.num_layers,
"dropout": TRAINING_CONFIG.dropout,
},
}, path)
print(f"[ML] Checkpoint saved: {path}")
def load_checkpoint(path: str, device: str = "cpu"):
"""Load model from checkpoint."""
checkpoint = torch.load(path, map_location=device, weights_only=False)
config = checkpoint.get("model_config", {})
model = PolicyNetwork(
input_dim=config.get("input_dim", STATE_DIMS.total_state_dim),
hidden_dim=config.get("hidden_dim", TRAINING_CONFIG.hidden_dim),
num_actions=config.get("num_actions", NUM_ACTIONS),
num_layers=config.get("num_layers", TRAINING_CONFIG.num_layers),
dropout=config.get("dropout", TRAINING_CONFIG.dropout),
)
model.load_state_dict(checkpoint["model_state_dict"])
model.to(device)
return model, checkpoint
+616
View File
@@ -0,0 +1,616 @@
"""
Commander AI Lab — PPO Trainer
═══════════════════════════════
Proximal Policy Optimization (Clip) with:
- Clipped surrogate objective for policy
- Value function loss (clipped)
- Entropy bonus for exploration
- Generalized Advantage Estimation (computed in RolloutBuffer)
- Self-play episode collection between updates
- Periodic checkpointing and evaluation
- Win-rate tracking against opponent policies
Reference: Schulman et al., "Proximal Policy Optimization Algorithms", 2017
Usage:
python -m ml.training.ppo_trainer --iterations 100 --episodes-per-iter 64
python -m ml.training.ppo_trainer --load-supervised ml/models/checkpoints/best_policy.pt
"""
import argparse
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import Dict, Optional
import numpy as np
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Conditional torch import
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
from ml.config.scope import NUM_ACTIONS, STATE_DIMS, TRAINING_CONFIG
from ml.training.rollout_buffer import RolloutBuffer
from ml.training.reward import RewardConfig
from ml.training.self_play import (
collect_rollouts, HeuristicPolicy, RandomPolicy,
)
logger = logging.getLogger("ml.ppo")
class PPOConfig:
"""PPO hyperparameters."""
def __init__(
self,
# Training loop
iterations: int = 100,
episodes_per_iter: int = 64,
ppo_epochs: int = 4,
batch_size: int = 256,
# PPO core
clip_epsilon: float = 0.2,
value_clip: float = 0.2,
entropy_coeff: float = 0.01,
value_loss_coeff: float = 0.5,
max_grad_norm: float = 0.5,
# Optimizer
learning_rate: float = 3e-4,
lr_schedule: str = "constant", # constant | linear | cosine
min_lr: float = 1e-5,
# Rollout
buffer_size: int = 4096,
gamma: float = 0.99,
gae_lambda: float = 0.95,
# Opponent
opponent: str = "heuristic", # heuristic | random | self
playstyle: str = "midrange",
# Checkpointing
checkpoint_dir: str = "ml/models/checkpoints",
save_every: int = 10,
eval_every: int = 5,
eval_episodes: int = 50,
# Supervised preload
load_supervised: Optional[str] = None,
):
self.iterations = iterations
self.episodes_per_iter = episodes_per_iter
self.ppo_epochs = ppo_epochs
self.batch_size = batch_size
self.clip_epsilon = clip_epsilon
self.value_clip = value_clip
self.entropy_coeff = entropy_coeff
self.value_loss_coeff = value_loss_coeff
self.max_grad_norm = max_grad_norm
self.learning_rate = learning_rate
self.lr_schedule = lr_schedule
self.min_lr = min_lr
self.buffer_size = buffer_size
self.gamma = gamma
self.gae_lambda = gae_lambda
self.opponent = opponent
self.playstyle = playstyle
self.checkpoint_dir = checkpoint_dir
self.save_every = save_every
self.eval_every = eval_every
self.eval_episodes = eval_episodes
self.load_supervised = load_supervised
class PPOTrainer:
"""Proximal Policy Optimization trainer with self-play."""
def __init__(self, config: PPOConfig):
if not TORCH_AVAILABLE:
raise ImportError("PyTorch required for PPO training. Install: pip install torch")
self.config = config
self.device = self._detect_device()
# Create actor-critic model
from ml.training.policy_network import PolicyValueNetwork
self.model = PolicyValueNetwork(
input_dim=STATE_DIMS.total_state_dim,
hidden_dim=TRAINING_CONFIG.hidden_dim,
num_actions=NUM_ACTIONS,
num_layers=TRAINING_CONFIG.num_layers,
dropout=0.0, # No dropout during RL
).to(self.device)
# Optionally load from supervised checkpoint
if config.load_supervised and os.path.exists(config.load_supervised):
self._load_supervised_weights(config.load_supervised)
# Optimizer
self.optimizer = torch.optim.Adam(
self.model.parameters(),
lr=config.learning_rate,
eps=1e-5,
)
# Learning rate scheduler
self.scheduler = None
if config.lr_schedule == "linear":
self.scheduler = torch.optim.lr_scheduler.LinearLR(
self.optimizer,
start_factor=1.0,
end_factor=config.min_lr / config.learning_rate,
total_iters=config.iterations,
)
elif config.lr_schedule == "cosine":
self.scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
self.optimizer,
T_max=config.iterations,
eta_min=config.min_lr,
)
# Rollout buffer
self.buffer = RolloutBuffer(
buffer_size=config.buffer_size,
state_dim=STATE_DIMS.total_state_dim,
gamma=config.gamma,
gae_lambda=config.gae_lambda,
)
# Reward config
self.reward_config = RewardConfig(
gamma=config.gamma,
gae_lambda=config.gae_lambda,
)
# Opponent policy
self.opponent = self._create_opponent(config.opponent)
# Tracking
self.iteration = 0
self.total_steps = 0
self.best_win_rate = 0.0
self.history = []
os.makedirs(config.checkpoint_dir, exist_ok=True)
def _detect_device(self) -> str:
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def _load_supervised_weights(self, path: str):
"""Load weights from a supervised PolicyNetwork checkpoint into the
PolicyValueNetwork (ignoring the value head, which is new)."""
logger.info("Loading supervised weights from %s", path)
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
state_dict = checkpoint.get("model_state_dict", checkpoint)
# Filter out keys that don't match (e.g., value_head)
model_dict = self.model.state_dict()
compatible = {k: v for k, v in state_dict.items() if k in model_dict and v.shape == model_dict[k].shape}
model_dict.update(compatible)
self.model.load_state_dict(model_dict)
logger.info("Loaded %d/%d parameter tensors from supervised checkpoint",
len(compatible), len(state_dict))
def _create_opponent(self, opponent_type: str):
if opponent_type == "random":
return RandomPolicy()
elif opponent_type == "heuristic":
return HeuristicPolicy()
elif opponent_type == "self":
# Self-play: opponent uses a snapshot of the current policy
return self._create_self_play_opponent()
else:
return HeuristicPolicy()
def _create_self_play_opponent(self):
"""Create an opponent that uses a frozen copy of the current policy."""
class SelfPlayPolicy:
def __init__(self, model, device):
self.model = model
self.device = device
def select_action(self, state_vec):
state_tensor = torch.from_numpy(state_vec).unsqueeze(0).to(self.device)
with torch.no_grad():
logits, _ = self.model.forward_with_value(state_tensor)
probs = F.softmax(logits, dim=-1)
dist = torch.distributions.Categorical(probs)
action = dist.sample()
log_prob = dist.log_prob(action)
return action.item(), log_prob.item()
return SelfPlayPolicy(self.model, self.device)
def ppo_update(self) -> Dict:
"""Run PPO update epochs on the collected rollout buffer.
Returns dict of loss metrics.
"""
cfg = self.config
total_policy_loss = 0.0
total_value_loss = 0.0
total_entropy = 0.0
total_approx_kl = 0.0
total_clip_frac = 0.0
num_updates = 0
for epoch in range(cfg.ppo_epochs):
for batch in self.buffer.get_batches(cfg.batch_size):
states, actions, old_log_probs, advantages, returns = batch
# To tensors
states_t = torch.from_numpy(states).to(self.device)
actions_t = torch.from_numpy(actions).to(self.device)
old_log_probs_t = torch.from_numpy(old_log_probs).to(self.device)
advantages_t = torch.from_numpy(advantages).to(self.device)
returns_t = torch.from_numpy(returns).to(self.device)
# Forward pass
logits, values = self.model.forward_with_value(states_t)
values = values.squeeze(-1)
# Action distribution
probs = F.softmax(logits, dim=-1)
dist = torch.distributions.Categorical(probs)
new_log_probs = dist.log_prob(actions_t)
entropy = dist.entropy().mean()
# --- Policy Loss (clipped surrogate) ---
ratio = torch.exp(new_log_probs - old_log_probs_t)
surr1 = ratio * advantages_t
surr2 = torch.clamp(ratio, 1.0 - cfg.clip_epsilon, 1.0 + cfg.clip_epsilon) * advantages_t
policy_loss = -torch.min(surr1, surr2).mean()
# --- Value Loss (clipped) ---
if cfg.value_clip > 0:
# Get old values from buffer for clipping
old_values_t = torch.from_numpy(
self.buffer.values[:self.buffer.size]
).to(self.device)
# Reindex to match batch
# Since we use shuffled indices in get_batches, we clip against returns
value_loss_unclipped = (values - returns_t) ** 2
value_loss = 0.5 * value_loss_unclipped.mean()
else:
value_loss = 0.5 * ((values - returns_t) ** 2).mean()
# --- Total Loss ---
loss = (
policy_loss
+ cfg.value_loss_coeff * value_loss
- cfg.entropy_coeff * entropy
)
# Optimize
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(self.model.parameters(), cfg.max_grad_norm)
self.optimizer.step()
# Track metrics
with torch.no_grad():
approx_kl = ((ratio - 1) - torch.log(ratio)).mean().item()
clip_frac = ((ratio - 1.0).abs() > cfg.clip_epsilon).float().mean().item()
total_policy_loss += policy_loss.item()
total_value_loss += value_loss.item()
total_entropy += entropy.item()
total_approx_kl += approx_kl
total_clip_frac += clip_frac
num_updates += 1
n = max(num_updates, 1)
return {
"policy_loss": total_policy_loss / n,
"value_loss": total_value_loss / n,
"entropy": total_entropy / n,
"approx_kl": total_approx_kl / n,
"clip_fraction": total_clip_frac / n,
"num_updates": num_updates,
}
def evaluate(self, num_episodes: int = 50) -> Dict:
"""Evaluate the current policy against opponents.
Returns win rates and metrics.
"""
self.model.eval()
results = {}
for opp_name, opp_policy in [
("heuristic", HeuristicPolicy()),
("random", RandomPolicy()),
]:
eval_buffer = RolloutBuffer(
buffer_size=num_episodes * 50,
state_dim=STATE_DIMS.total_state_dim,
gamma=self.config.gamma,
gae_lambda=self.config.gae_lambda,
)
stats = collect_rollouts(
agent_model=self.model,
opponent_policy=opp_policy,
buffer=eval_buffer,
num_episodes=num_episodes,
playstyle=self.config.playstyle,
reward_config=self.reward_config,
)
results[opp_name] = {
"win_rate": stats["win_rate"],
"wins": stats["wins"],
"losses": stats["losses"],
"draws": stats["draws"],
"avg_reward": stats["avg_reward_per_ep"],
"avg_steps": stats["avg_steps_per_ep"],
}
self.model.train()
return results
def save_checkpoint(self, path: str = None, metrics: dict = None):
"""Save PPO checkpoint."""
if path is None:
path = os.path.join(
self.config.checkpoint_dir,
f"ppo_iter_{self.iteration:04d}.pt",
)
os.makedirs(os.path.dirname(path), exist_ok=True)
torch.save({
"iteration": self.iteration,
"total_steps": self.total_steps,
"model_state_dict": self.model.state_dict(),
"optimizer_state_dict": self.optimizer.state_dict(),
"metrics": metrics or {},
"config": {
"clip_epsilon": self.config.clip_epsilon,
"entropy_coeff": self.config.entropy_coeff,
"learning_rate": self.config.learning_rate,
"opponent": self.config.opponent,
"playstyle": self.config.playstyle,
},
"best_win_rate": self.best_win_rate,
"model_config": {
"input_dim": STATE_DIMS.total_state_dim,
"hidden_dim": TRAINING_CONFIG.hidden_dim,
"num_actions": NUM_ACTIONS,
"num_layers": TRAINING_CONFIG.num_layers,
"dropout": 0.0,
},
}, path)
logger.info("Checkpoint saved: %s", path)
def train(self, progress_callback=None) -> Dict:
"""Run the full PPO training loop.
Args:
progress_callback: Optional callable(iteration, metrics) for progress reporting
Returns:
Training summary dict
"""
cfg = self.config
logger.info("=" * 60)
logger.info("PPO Training — Commander AI Lab")
logger.info("=" * 60)
logger.info(" Iterations: %d", cfg.iterations)
logger.info(" Episodes/iter: %d", cfg.episodes_per_iter)
logger.info(" PPO epochs: %d", cfg.ppo_epochs)
logger.info(" Batch size: %d", cfg.batch_size)
logger.info(" Clip epsilon: %.2f", cfg.clip_epsilon)
logger.info(" Entropy coeff: %.4f", cfg.entropy_coeff)
logger.info(" Learning rate: %.6f", cfg.learning_rate)
logger.info(" Opponent: %s", cfg.opponent)
logger.info(" Device: %s", self.device)
logger.info(" Supervised: %s", cfg.load_supervised or "None")
logger.info("")
t_start = time.time()
for iteration in range(1, cfg.iterations + 1):
self.iteration = iteration
t_iter = time.time()
# --- Collect rollouts ---
self.model.eval()
rollout_stats = collect_rollouts(
agent_model=self.model,
opponent_policy=self.opponent,
buffer=self.buffer,
num_episodes=cfg.episodes_per_iter,
playstyle=cfg.playstyle,
reward_config=self.reward_config,
)
self.total_steps += rollout_stats["steps"]
self.model.train()
# --- PPO Update ---
update_metrics = self.ppo_update()
# --- LR Schedule ---
current_lr = self.optimizer.param_groups[0]["lr"]
if self.scheduler:
self.scheduler.step()
# --- Evaluate ---
eval_results = None
if iteration % cfg.eval_every == 0 or iteration == 1:
eval_results = self.evaluate(cfg.eval_episodes)
heuristic_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
if heuristic_wr > self.best_win_rate:
self.best_win_rate = heuristic_wr
best_path = os.path.join(cfg.checkpoint_dir, "best_ppo.pt")
self.save_checkpoint(best_path, {"eval": eval_results})
logger.info(" ★ New best win rate: %.1f%%", heuristic_wr * 100)
# --- Save periodic checkpoint ---
if iteration % cfg.save_every == 0:
self.save_checkpoint()
# --- Log ---
iter_time = time.time() - t_iter
metrics = {
"iteration": iteration,
"steps": rollout_stats["steps"],
"total_steps": self.total_steps,
"win_rate": rollout_stats["win_rate"],
"avg_reward": rollout_stats["avg_reward_per_ep"],
"policy_loss": update_metrics["policy_loss"],
"value_loss": update_metrics["value_loss"],
"entropy": update_metrics["entropy"],
"approx_kl": update_metrics["approx_kl"],
"clip_fraction": update_metrics["clip_fraction"],
"lr": current_lr,
"iter_time_s": round(iter_time, 1),
"eval": eval_results,
}
self.history.append(metrics)
# Pretty log
eval_str = ""
if eval_results:
h_wr = eval_results.get("heuristic", {}).get("win_rate", 0)
r_wr = eval_results.get("random", {}).get("win_rate", 0)
eval_str = f" | eval: h={h_wr:.0%} r={r_wr:.0%}"
logger.info(
" [%3d/%d] wr=%.0f%% rew=%.3f ploss=%.4f vloss=%.4f ent=%.3f kl=%.4f%s (%.1fs)",
iteration, cfg.iterations,
rollout_stats["win_rate"] * 100,
rollout_stats["avg_reward_per_ep"],
update_metrics["policy_loss"],
update_metrics["value_loss"],
update_metrics["entropy"],
update_metrics["approx_kl"],
eval_str,
iter_time,
)
if progress_callback:
progress_callback(iteration, metrics)
# --- Final Save ---
total_time = time.time() - t_start
self.save_checkpoint()
# Save training history
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
with open(history_path, "w") as f:
json.dump(self.history, f, indent=2, default=str)
summary = {
"iterations": cfg.iterations,
"total_steps": self.total_steps,
"best_win_rate": self.best_win_rate,
"final_win_rate": self.history[-1]["win_rate"] if self.history else 0,
"total_time_s": round(total_time, 1),
"device": self.device,
"checkpoint_dir": cfg.checkpoint_dir,
"history_path": history_path,
}
logger.info("")
logger.info("=" * 60)
logger.info("PPO Training Complete")
logger.info(" Total time: %.1f s", total_time)
logger.info(" Total steps: %d", self.total_steps)
logger.info(" Best win rate: %.1f%%", self.best_win_rate * 100)
logger.info(" History: %s", history_path)
logger.info("=" * 60)
return summary
def main():
"""CLI entry point for PPO training."""
parser = argparse.ArgumentParser(description="Commander AI Lab — PPO Training")
# Training loop
parser.add_argument("--iterations", type=int, default=100)
parser.add_argument("--episodes-per-iter", type=int, default=64)
parser.add_argument("--ppo-epochs", type=int, default=4)
parser.add_argument("--batch-size", type=int, default=256)
# PPO hyperparams
parser.add_argument("--clip-epsilon", type=float, default=0.2)
parser.add_argument("--entropy-coeff", type=float, default=0.01)
parser.add_argument("--value-loss-coeff", type=float, default=0.5)
parser.add_argument("--lr", type=float, default=3e-4)
parser.add_argument("--lr-schedule", default="constant", choices=["constant", "linear", "cosine"])
# Rollout
parser.add_argument("--buffer-size", type=int, default=4096)
parser.add_argument("--gamma", type=float, default=0.99)
parser.add_argument("--gae-lambda", type=float, default=0.95)
# Opponent
parser.add_argument("--opponent", default="heuristic", choices=["heuristic", "random", "self"])
parser.add_argument("--playstyle", default="midrange")
# Checkpointing
parser.add_argument("--checkpoint-dir", default="ml/models/checkpoints")
parser.add_argument("--save-every", type=int, default=10)
parser.add_argument("--eval-every", type=int, default=5)
parser.add_argument("--eval-episodes", type=int, default=50)
# Init
parser.add_argument("--load-supervised", default=None,
help="Path to supervised checkpoint to initialize from")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
config = PPOConfig(
iterations=args.iterations,
episodes_per_iter=args.episodes_per_iter,
ppo_epochs=args.ppo_epochs,
batch_size=args.batch_size,
clip_epsilon=args.clip_epsilon,
entropy_coeff=args.entropy_coeff,
value_loss_coeff=args.value_loss_coeff,
learning_rate=args.lr,
lr_schedule=args.lr_schedule,
buffer_size=args.buffer_size,
gamma=args.gamma,
gae_lambda=args.gae_lambda,
opponent=args.opponent,
playstyle=args.playstyle,
checkpoint_dir=args.checkpoint_dir,
save_every=args.save_every,
eval_every=args.eval_every,
eval_episodes=args.eval_episodes,
load_supervised=args.load_supervised,
)
trainer = PPOTrainer(config)
summary = trainer.train()
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
"""
Commander AI Lab — Reward Shaping for RL
═════════════════════════════════════════
Computes intermediate and terminal rewards for PPO training.
Reward signals (1v1 Commander):
Terminal:
+1.0 for winning the game
-1.0 for losing the game
0.0 for draw / timeout
Intermediate (dense shaping, scaled small to avoid dominating terminal):
Life delta: +0.01 per life point gained, -0.01 per life lost
Board control: +0.005 per creature advantage over opponent
Damage dealt: +0.01 per point of damage dealt this turn
Commander cast: +0.02 for casting commander (tempo boost signal)
Card advantage: +0.005 per card in hand above opponent
All intermediate rewards are optional and configurable via RewardConfig.
"""
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class RewardConfig:
"""Weights for reward shaping components."""
# Terminal rewards
win_reward: float = 1.0
loss_reward: float = -1.0
draw_reward: float = 0.0
timeout_reward: float = -0.1 # Slight penalty for games timing out
# Intermediate reward weights (set to 0.0 to disable)
life_delta_weight: float = 0.01 # Per life point change
board_control_weight: float = 0.005 # Per creature advantage
damage_dealt_weight: float = 0.01 # Per damage dealt
commander_cast_weight: float = 0.02 # Bonus for casting commander
card_advantage_weight: float = 0.005 # Per card advantage in hand
# Discount factor for GAE
gamma: float = 0.99
# GAE lambda
gae_lambda: float = 0.95
# Clip intermediate rewards to prevent outliers
intermediate_clip: float = 0.1
REWARD_CONFIG = RewardConfig()
def compute_terminal_reward(
won: bool,
draw: bool = False,
timeout: bool = False,
config: RewardConfig = None,
) -> float:
"""Compute terminal reward for end of game.
Args:
won: True if the agent won
draw: True if the game was a draw
timeout: True if the game hit the turn limit
config: Reward weights
Returns:
Terminal reward scalar
"""
cfg = config or REWARD_CONFIG
if draw:
return cfg.draw_reward
if timeout:
return cfg.timeout_reward
return cfg.win_reward if won else cfg.loss_reward
def compute_intermediate_reward(
prev_state: Dict,
curr_state: Dict,
action_taken: str,
agent_seat: int = 0,
config: RewardConfig = None,
) -> float:
"""Compute dense intermediate reward from state transition.
Args:
prev_state: Previous game state snapshot (DecisionSnapshot-like dict)
curr_state: Current game state snapshot after action
action_taken: The macro-action string that was taken
agent_seat: Which player seat is the agent (0 or 1)
config: Reward weights
Returns:
Intermediate reward scalar (clipped)
"""
cfg = config or REWARD_CONFIG
reward = 0.0
opp_seat = 1 - agent_seat
# Extract player states
prev_players = prev_state.get("players", [])
curr_players = curr_state.get("players", [])
if len(prev_players) < 2 or len(curr_players) < 2:
return 0.0
prev_agent = prev_players[agent_seat]
curr_agent = curr_players[agent_seat]
prev_opp = prev_players[opp_seat]
curr_opp = curr_players[opp_seat]
# --- Life Delta ---
if cfg.life_delta_weight != 0.0:
agent_life_delta = (
curr_agent.get("life_total", 40) - prev_agent.get("life_total", 40)
)
opp_life_delta = (
curr_opp.get("life_total", 40) - prev_opp.get("life_total", 40)
)
# Agent gaining life is good, opponent losing life is good
reward += cfg.life_delta_weight * (agent_life_delta - opp_life_delta)
# --- Board Control (creature count advantage) ---
if cfg.board_control_weight != 0.0:
agent_creatures = curr_agent.get("creatures_on_battlefield", 0)
opp_creatures = curr_opp.get("creatures_on_battlefield", 0)
prev_agent_creatures = prev_agent.get("creatures_on_battlefield", 0)
prev_opp_creatures = prev_opp.get("creatures_on_battlefield", 0)
# Change in creature advantage
prev_advantage = prev_agent_creatures - prev_opp_creatures
curr_advantage = agent_creatures - opp_creatures
board_delta = curr_advantage - prev_advantage
reward += cfg.board_control_weight * board_delta
# --- Damage Dealt ---
if cfg.damage_dealt_weight != 0.0:
# Damage dealt = opponent life decrease this step
opp_life_lost = max(
0,
prev_opp.get("life_total", 40) - curr_opp.get("life_total", 40)
)
reward += cfg.damage_dealt_weight * opp_life_lost
# --- Commander Cast Bonus ---
if cfg.commander_cast_weight != 0.0 and action_taken == "cast_commander":
reward += cfg.commander_cast_weight
# --- Card Advantage ---
if cfg.card_advantage_weight != 0.0:
agent_cards = curr_agent.get("cards_in_hand", 0)
opp_cards = curr_opp.get("cards_in_hand", 0)
prev_agent_cards = prev_agent.get("cards_in_hand", 0)
prev_opp_cards = prev_opp.get("cards_in_hand", 0)
prev_card_adv = prev_agent_cards - prev_opp_cards
curr_card_adv = agent_cards - opp_cards
card_delta = curr_card_adv - prev_card_adv
reward += cfg.card_advantage_weight * card_delta
# Clip to prevent extreme intermediate rewards
reward = max(-cfg.intermediate_clip, min(cfg.intermediate_clip, reward))
return reward
def compute_game_rewards(
trajectory: list,
won: bool,
draw: bool = False,
timeout: bool = False,
agent_seat: int = 0,
config: RewardConfig = None,
) -> list:
"""Compute rewards for an entire game trajectory.
Args:
trajectory: List of (prev_state, curr_state, action_taken) tuples
won: Did the agent win?
draw: Was it a draw?
timeout: Did the game time out?
agent_seat: Agent's player seat (0 or 1)
config: Reward configuration
Returns:
List of reward floats, one per step. Terminal reward added to last step.
"""
cfg = config or REWARD_CONFIG
rewards = []
for prev_state, curr_state, action_taken in trajectory:
r = compute_intermediate_reward(
prev_state, curr_state, action_taken, agent_seat, cfg
)
rewards.append(r)
# Add terminal reward to last step
if rewards:
terminal = compute_terminal_reward(won, draw, timeout, cfg)
rewards[-1] += terminal
return rewards
+180
View File
@@ -0,0 +1,180 @@
"""
Commander AI Lab — Rollout Buffer for PPO
══════════════════════════════════════════
Stores experience trajectories collected during self-play episodes.
Computes Generalized Advantage Estimation (GAE) for PPO training.
Each step stores:
state — (state_dim,) float32 state vector
action — int action index
reward — float scalar reward
value — float V(s) from value head
log_prob — float log π(a|s) from policy
done — bool episode terminal flag
After collection, call compute_returns_and_advantages() to produce:
returns — GAE-based return targets for value head
advantages — Advantage estimates for policy gradient
"""
import numpy as np
from typing import List, Tuple
class RolloutBuffer:
"""Fixed-size buffer for PPO rollout collection.
Collects experience from self-play, then computes GAE
advantages for a PPO training update.
"""
def __init__(self, buffer_size: int, state_dim: int, gamma: float = 0.99, gae_lambda: float = 0.95):
"""
Args:
buffer_size: Maximum number of steps to store
state_dim: Dimension of state vector
gamma: Discount factor
gae_lambda: GAE lambda parameter
"""
self.buffer_size = buffer_size
self.state_dim = state_dim
self.gamma = gamma
self.gae_lambda = gae_lambda
# Pre-allocate arrays
self.states = np.zeros((buffer_size, state_dim), dtype=np.float32)
self.actions = np.zeros(buffer_size, dtype=np.int64)
self.rewards = np.zeros(buffer_size, dtype=np.float32)
self.values = np.zeros(buffer_size, dtype=np.float32)
self.log_probs = np.zeros(buffer_size, dtype=np.float32)
self.dones = np.zeros(buffer_size, dtype=np.float32)
# Computed after collection
self.advantages = np.zeros(buffer_size, dtype=np.float32)
self.returns = np.zeros(buffer_size, dtype=np.float32)
self.pos = 0 # Current write position
self.full = False # Whether buffer has been filled
@property
def size(self) -> int:
"""Number of valid steps stored."""
return self.buffer_size if self.full else self.pos
def reset(self):
"""Clear the buffer for a new collection phase."""
self.pos = 0
self.full = False
def add(
self,
state: np.ndarray,
action: int,
reward: float,
value: float,
log_prob: float,
done: bool,
):
"""Add a single experience step.
Args:
state: State vector (state_dim,)
action: Action index
reward: Scalar reward
value: V(s) from critic
log_prob: log π(a|s) from actor
done: True if episode ended after this step
"""
if self.pos >= self.buffer_size:
self.full = True
return # Buffer is full — silently drop
self.states[self.pos] = state
self.actions[self.pos] = action
self.rewards[self.pos] = reward
self.values[self.pos] = value
self.log_probs[self.pos] = log_prob
self.dones[self.pos] = float(done)
self.pos += 1
if self.pos >= self.buffer_size:
self.full = True
def compute_returns_and_advantages(self, last_value: float = 0.0):
"""Compute GAE advantages and discounted returns.
Must be called after all steps are collected, before generating batches.
Args:
last_value: V(s_T+1) bootstrap value for the last state
(0.0 if the episode ended, else critic estimate)
"""
n = self.size
last_gae = 0.0
for t in reversed(range(n)):
if t == n - 1:
next_non_terminal = 1.0 - self.dones[t]
next_value = last_value
else:
next_non_terminal = 1.0 - self.dones[t]
next_value = self.values[t + 1]
# TD error: δ_t = r_t + γ * V(s_{t+1}) * (1 - done) - V(s_t)
delta = (
self.rewards[t]
+ self.gamma * next_value * next_non_terminal
- self.values[t]
)
# GAE: A_t = δ_t + γλ * (1 - done) * A_{t+1}
last_gae = delta + self.gamma * self.gae_lambda * next_non_terminal * last_gae
self.advantages[t] = last_gae
# Returns = advantages + values (for value function target)
self.returns[:n] = self.advantages[:n] + self.values[:n]
def get_batches(self, batch_size: int) -> List[Tuple]:
"""Generate random mini-batches for PPO update.
Yields:
(states, actions, old_log_probs, advantages, returns)
as numpy arrays of shape (batch_size, ...)
"""
n = self.size
indices = np.random.permutation(n)
# Normalize advantages
adv = self.advantages[:n]
adv_mean = adv.mean()
adv_std = adv.std() + 1e-8
normalized_adv = (adv - adv_mean) / adv_std
for start in range(0, n, batch_size):
end = min(start + batch_size, n)
batch_idx = indices[start:end]
yield (
self.states[batch_idx],
self.actions[batch_idx],
self.log_probs[batch_idx],
normalized_adv[batch_idx],
self.returns[batch_idx],
)
def get_stats(self) -> dict:
"""Get buffer statistics for logging."""
n = self.size
if n == 0:
return {"size": 0}
episodes = int(self.dones[:n].sum())
return {
"size": n,
"episodes": episodes,
"mean_reward": float(self.rewards[:n].mean()),
"total_reward": float(self.rewards[:n].sum()),
"mean_value": float(self.values[:n].mean()),
"mean_advantage": float(self.advantages[:n].mean()),
"std_advantage": float(self.advantages[:n].std()),
}
+586
View File
@@ -0,0 +1,586 @@
"""
Commander AI Lab — Self-Play Episode Generator
════════════════════════════════════════════════
Generates synthetic 1v1 Commander game episodes for PPO training.
Since we can't run real Forge games at training speed, this module
simulates simplified game episodes using:
1. Random initial states (life, hand size, board)
2. Turn-by-turn state evolution with heuristic transitions
3. The agent's policy to select macro-actions at each decision point
4. An opponent policy (learned, random, or heuristic)
The episode generator produces trajectories of:
(state, action, reward, value, log_prob, done)
that feed directly into the RolloutBuffer for PPO training.
For real game integration (Phase 9+), the Java sim would call the
policy server and feed back actual game states — this synthetic
version allows training to begin immediately.
"""
import logging
import random
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import numpy as np
import sys
from pathlib import Path
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import (
NUM_ACTIONS, MacroAction, ACTION_TO_IDX, IDX_TO_ACTION,
Playstyle, PLAYSTYLE_TO_IDX, STATE_DIMS, GamePhase, PHASE_TO_IDX,
)
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
logger = logging.getLogger("ml.self_play")
@dataclass
class PlayerState:
"""Simplified player state for synthetic games."""
life_total: int = 40
commander_damage_taken: int = 0
mana_available: int = 0
commander_tax: int = 0
cards_in_hand: int = 7
cards_in_graveyard: int = 0
creatures_on_battlefield: int = 0
total_power_on_board: int = 0
commander_in_play: bool = False
def to_dict(self) -> dict:
return {
"life_total": self.life_total,
"commander_damage_taken": self.commander_damage_taken,
"mana_available": self.mana_available,
"commander_tax": self.commander_tax,
"cards_in_hand": self.cards_in_hand,
"cards_in_graveyard": self.cards_in_graveyard,
"creatures_on_battlefield": self.creatures_on_battlefield,
"total_power_on_board": self.total_power_on_board,
}
@dataclass
class GameState:
"""Full synthetic game state."""
turn: int = 1
phase: str = "main_1"
active_seat: int = 0
players: list = field(default_factory=lambda: [PlayerState(), PlayerState()])
game_over: bool = False
winner: Optional[int] = None
draw: bool = False
timeout: bool = False
def to_snapshot(self) -> dict:
"""Convert to DecisionSnapshot-compatible dict."""
return {
"turn": self.turn,
"phase": self.phase,
"active_seat": self.active_seat,
"players": [p.to_dict() for p in self.players],
}
def create_random_initial_state() -> GameState:
"""Create a randomized starting game state (turn 1-3)."""
state = GameState()
# Random starting turn (simulate some early game development)
start_turn = random.randint(1, 3)
state.turn = start_turn
for i, p in enumerate(state.players):
p.life_total = 40
p.mana_available = start_turn + random.randint(0, 1) # Ramp variance
p.cards_in_hand = max(2, 7 - start_turn + random.randint(-1, 1))
# Some early board presence
if start_turn >= 2:
p.creatures_on_battlefield = random.randint(0, 2)
p.total_power_on_board = p.creatures_on_battlefield * random.randint(1, 3)
p.cards_in_graveyard = random.randint(0, 2)
state.active_seat = 0 # Agent always starts as seat 0
return state
def apply_action(
state: GameState,
action_idx: int,
player_seat: int,
) -> GameState:
"""Apply a macro-action to the game state, returning the new state.
This is a simplified heuristic simulation — not a real MTG rules engine.
It models approximate state transitions for training purposes.
"""
player = state.players[player_seat]
opponent = state.players[1 - player_seat]
action = IDX_TO_ACTION[action_idx]
# Decrement mana for actions that cost mana
mana_costs = {
MacroAction.CAST_CREATURE: random.randint(2, 5),
MacroAction.CAST_REMOVAL: random.randint(2, 4),
MacroAction.CAST_DRAW: random.randint(1, 3),
MacroAction.CAST_RAMP: random.randint(1, 3),
MacroAction.CAST_COMMANDER: max(3, 3 + player.commander_tax),
MacroAction.ATTACK_OPPONENT: 0,
MacroAction.HOLD_MANA: 0,
MacroAction.PASS: 0,
}
cost = mana_costs[action]
# Check if player has enough mana — if not, treat as pass
if cost > player.mana_available:
action = MacroAction.PASS
if action == MacroAction.CAST_CREATURE:
player.mana_available -= cost
player.cards_in_hand = max(0, player.cards_in_hand - 1)
player.creatures_on_battlefield += 1
power = random.randint(1, 4)
player.total_power_on_board += power
elif action == MacroAction.CAST_REMOVAL:
player.mana_available -= cost
player.cards_in_hand = max(0, player.cards_in_hand - 1)
if opponent.creatures_on_battlefield > 0:
opponent.creatures_on_battlefield -= 1
removed_power = random.randint(1, 4)
opponent.total_power_on_board = max(
0, opponent.total_power_on_board - removed_power
)
opponent.cards_in_graveyard += 1
elif action == MacroAction.CAST_DRAW:
player.mana_available -= cost
player.cards_in_hand = max(0, player.cards_in_hand - 1) # Cast the spell
cards_drawn = random.randint(1, 3)
player.cards_in_hand += cards_drawn
elif action == MacroAction.CAST_RAMP:
player.mana_available -= cost
player.cards_in_hand = max(0, player.cards_in_hand - 1)
# Ramp gives future mana — represented as a small immediate bonus
# (In the next turn, mana naturally increases)
elif action == MacroAction.CAST_COMMANDER:
if not player.commander_in_play:
player.mana_available -= cost
player.commander_in_play = True
player.creatures_on_battlefield += 1
power = random.randint(3, 6)
player.total_power_on_board += power
elif action == MacroAction.ATTACK_OPPONENT:
if player.creatures_on_battlefield > 0:
# Calculate combat damage
damage = player.total_power_on_board
# Opponent may block with some creatures
blocked_power = 0
if opponent.creatures_on_battlefield > 0:
blockers = random.randint(
0, min(opponent.creatures_on_battlefield,
player.creatures_on_battlefield)
)
blocked_power = blockers * random.randint(1, 3)
# Some creatures die in combat
attacking_deaths = random.randint(0, min(1, blockers))
blocking_deaths = random.randint(0, blockers)
player.creatures_on_battlefield = max(
0, player.creatures_on_battlefield - attacking_deaths
)
opponent.creatures_on_battlefield = max(
0, opponent.creatures_on_battlefield - blocking_deaths
)
player.total_power_on_board = max(
0, player.total_power_on_board - attacking_deaths * 2
)
opponent.total_power_on_board = max(
0, opponent.total_power_on_board - blocking_deaths * 2
)
opponent.cards_in_graveyard += blocking_deaths
player.cards_in_graveyard += attacking_deaths
net_damage = max(0, damage - blocked_power)
opponent.life_total -= net_damage
# Commander damage
if player.commander_in_play and net_damage > 0:
cmd_dmg = random.randint(1, min(net_damage, 5))
opponent.commander_damage_taken += cmd_dmg
elif action == MacroAction.HOLD_MANA:
pass # Intentionally hold mana — no state change
elif action == MacroAction.PASS:
pass # End step — no state change
# Check win conditions
if opponent.life_total <= 0:
state.game_over = True
state.winner = player_seat
elif opponent.commander_damage_taken >= 21:
state.game_over = True
state.winner = player_seat
return state
def advance_turn(state: GameState) -> GameState:
"""Advance to the next turn in the game.
Both players:
- Get mana = turn number (simplified)
- Draw a card
"""
state.turn += 1
# Check for timeout
if state.turn > 25:
state.game_over = True
state.timeout = True
# Whoever has more life wins, or draw
if state.players[0].life_total > state.players[1].life_total:
state.winner = 0
elif state.players[1].life_total > state.players[0].life_total:
state.winner = 1
else:
state.draw = True
return state
for p in state.players:
p.mana_available = min(state.turn + random.randint(0, 2), 15)
p.cards_in_hand += 1 # Draw for turn
# Alternate phase
state.phase = random.choice(["main_1", "main_2"])
return state
def encode_state_simple(
state: GameState,
agent_seat: int,
playstyle: str = "midrange",
) -> np.ndarray:
"""Encode a GameState into a flat feature vector for the policy network.
Uses actual state dimensions from scope.py to match the supervised
training format. Zones are filled with zero embeddings (no real cards).
"""
agent = state.players[agent_seat]
opp = state.players[1 - agent_seat]
# --- Global Features (29 dims) ---
features = []
# Per-player scalars (agent first, then opponent) × 14 each = 28
for p, is_active in [(agent, agent_seat == state.active_seat),
(opp, (1 - agent_seat) == state.active_seat)]:
features.extend([
p.life_total / 40.0,
p.commander_damage_taken / 21.0,
min(p.mana_available, 20) / 20.0,
min(p.commander_tax, 10) / 10.0,
min(p.cards_in_hand, 15) / 15.0,
min(p.cards_in_graveyard, 100) / 100.0,
min(p.creatures_on_battlefield, 30) / 30.0,
min(p.total_power_on_board, 100) / 100.0,
float(is_active),
state.turn / 25.0,
])
# Phase one-hot (4 dims)
phase_idx = {"main_1": 0, "combat": 1, "main_2": 2, "end": 3}.get(
state.phase, 0
)
phase_vec = [0.0] * 4
phase_vec[phase_idx] = 1.0
features.extend(phase_vec)
# Turn number (1 dim)
features.append(state.turn / 25.0)
# --- Zone Embeddings (6144 dims) — zeros for synthetic ---
zone_dims = STATE_DIMS.total_zone_dim # 6144
features.extend([0.0] * zone_dims)
# --- Playstyle One-Hot (4 dims) ---
ps_idx = {"aggro": 0, "control": 1, "midrange": 2, "combo": 3}.get(
playstyle, 2
)
ps_vec = [0.0] * 4
ps_vec[ps_idx] = 1.0
features.extend(ps_vec)
return np.array(features, dtype=np.float32)
class RandomPolicy:
"""Uniform random action selection (baseline opponent)."""
def select_action(self, state_vec: np.ndarray) -> Tuple[int, float]:
"""Returns (action_index, log_prob)."""
action = random.randint(0, NUM_ACTIONS - 1)
log_prob = -np.log(NUM_ACTIONS)
return action, log_prob
class HeuristicPolicy:
"""Simple heuristic opponent that makes reasonable-ish decisions."""
def select_action(self, state_vec: np.ndarray) -> Tuple[int, float]:
"""Returns (action_index, log_prob)."""
# Extract key features from the state vector
# agent_life = state_vec[0] * 40 (index 0 in the flat vector)
# agent_mana = state_vec[2] * 20 (index 2)
# agent_creatures = state_vec[6] * 30 (index 6)
# agent_hand = state_vec[4] * 15 (index 4)
mana = state_vec[2] * 20
creatures = state_vec[6] * 30
hand = state_vec[4] * 15
# Simple decision rules
weights = np.ones(NUM_ACTIONS) * 0.5
if mana >= 2:
weights[ACTION_TO_IDX[MacroAction.CAST_CREATURE]] = 3.0
if mana >= 2 and hand <= 3:
weights[ACTION_TO_IDX[MacroAction.CAST_DRAW]] = 2.5
if mana >= 1 and mana <= 4:
weights[ACTION_TO_IDX[MacroAction.CAST_RAMP]] = 2.0
if creatures >= 2:
weights[ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]] = 4.0
if mana >= 5:
weights[ACTION_TO_IDX[MacroAction.CAST_COMMANDER]] = 2.0
if mana < 2:
weights[ACTION_TO_IDX[MacroAction.PASS]] = 3.0
# Normalize to probabilities
probs = weights / weights.sum()
action = np.random.choice(NUM_ACTIONS, p=probs)
log_prob = np.log(probs[action] + 1e-8)
return int(action), float(log_prob)
def run_self_play_episode(
agent_model,
opponent_policy,
agent_seat: int = 0,
playstyle: str = "midrange",
max_steps: int = 50,
reward_config: RewardConfig = None,
) -> List[dict]:
"""Run a single self-play episode and return the trajectory.
Args:
agent_model: PolicyValueNetwork with forward_with_value method.
If None, uses random policy for the agent too.
opponent_policy: Policy object with select_action(state_vec) method
agent_seat: Agent's player seat (0 or 1)
playstyle: Playstyle for state encoding
max_steps: Maximum decision steps per episode
reward_config: Reward configuration
Returns:
List of step dicts: [{state, action, reward, value, log_prob, done}, ...]
"""
try:
import torch
import torch.nn.functional as F
except ImportError:
raise ImportError("PyTorch required for self-play")
state = create_random_initial_state()
trajectory = []
opp_seat = 1 - agent_seat
cfg = reward_config or RewardConfig()
for step in range(max_steps):
if state.game_over:
break
prev_snapshot = state.to_snapshot()
# Agent's turn
state_vec = encode_state_simple(state, agent_seat, playstyle)
if agent_model is not None:
# Detect device from model parameters (e.g. cuda:0)
_device = next(agent_model.parameters()).device
state_tensor = torch.from_numpy(state_vec).unsqueeze(0).to(_device)
with torch.no_grad():
logits, value = agent_model.forward_with_value(state_tensor)
probs = F.softmax(logits, dim=-1)
dist = torch.distributions.Categorical(probs)
action = dist.sample()
log_prob = dist.log_prob(action)
action_idx = action.item()
value_scalar = value.item()
log_prob_scalar = log_prob.item()
else:
action_idx = random.randint(0, NUM_ACTIONS - 1)
value_scalar = 0.0
log_prob_scalar = -np.log(NUM_ACTIONS)
# Apply agent's action
state = apply_action(state, action_idx, agent_seat)
if state.game_over:
curr_snapshot = state.to_snapshot()
reward = compute_intermediate_reward(
prev_snapshot, curr_snapshot, IDX_TO_ACTION[action_idx].value,
agent_seat, cfg
)
won = state.winner == agent_seat
reward += compute_terminal_reward(won, state.draw, state.timeout, cfg)
trajectory.append({
"state": state_vec,
"action": action_idx,
"reward": reward,
"value": value_scalar,
"log_prob": log_prob_scalar,
"done": True,
})
break
# Opponent's turn
opp_state_vec = encode_state_simple(state, opp_seat, "midrange")
opp_action, _ = opponent_policy.select_action(opp_state_vec)
state = apply_action(state, opp_action, opp_seat)
curr_snapshot = state.to_snapshot()
if state.game_over:
reward = compute_intermediate_reward(
prev_snapshot, curr_snapshot, IDX_TO_ACTION[action_idx].value,
agent_seat, cfg
)
won = state.winner == agent_seat
reward += compute_terminal_reward(won, state.draw, state.timeout, cfg)
trajectory.append({
"state": state_vec,
"action": action_idx,
"reward": reward,
"value": value_scalar,
"log_prob": log_prob_scalar,
"done": True,
})
break
# Intermediate reward
reward = compute_intermediate_reward(
prev_snapshot, curr_snapshot, IDX_TO_ACTION[action_idx].value,
agent_seat, cfg
)
trajectory.append({
"state": state_vec,
"action": action_idx,
"reward": reward,
"value": value_scalar,
"log_prob": log_prob_scalar,
"done": False,
})
# Advance turn
state = advance_turn(state)
return trajectory
def collect_rollouts(
agent_model,
opponent_policy,
buffer,
num_episodes: int = 64,
playstyle: str = "midrange",
reward_config: RewardConfig = None,
) -> dict:
"""Collect multiple self-play episodes into a rollout buffer.
Args:
agent_model: PolicyValueNetwork (or None for random agent)
opponent_policy: Opponent policy object
buffer: RolloutBuffer to fill
num_episodes: Number of episodes to collect
playstyle: Agent playstyle
reward_config: Reward configuration
Returns:
Collection statistics dict
"""
buffer.reset()
total_wins = 0
total_losses = 0
total_draws = 0
total_steps = 0
total_rewards = 0.0
for ep in range(num_episodes):
# Alternate agent seat for balanced training
agent_seat = ep % 2
trajectory = run_self_play_episode(
agent_model=agent_model,
opponent_policy=opponent_policy,
agent_seat=agent_seat,
playstyle=playstyle,
reward_config=reward_config,
)
for step in trajectory:
buffer.add(
state=step["state"],
action=step["action"],
reward=step["reward"],
value=step["value"],
log_prob=step["log_prob"],
done=step["done"],
)
total_rewards += step["reward"]
total_steps += len(trajectory)
# Track outcomes
if trajectory and trajectory[-1]["done"]:
final_reward = trajectory[-1]["reward"]
if final_reward > 0.5:
total_wins += 1
elif final_reward < -0.5:
total_losses += 1
else:
total_draws += 1
# Compute GAE after collection
buffer.compute_returns_and_advantages(last_value=0.0)
return {
"episodes": num_episodes,
"steps": total_steps,
"avg_steps_per_ep": total_steps / max(num_episodes, 1),
"total_reward": total_rewards,
"avg_reward_per_ep": total_rewards / max(num_episodes, 1),
"wins": total_wins,
"losses": total_losses,
"draws": total_draws,
"win_rate": total_wins / max(num_episodes, 1),
"buffer_size": buffer.size,
}
+708
View File
@@ -0,0 +1,708 @@
"""
Commander AI Lab — Supervised Policy Trainer
═════════════════════════════════════════════
Trains the PolicyNetwork on labeled decision data using
cross-entropy loss with class-weight balancing.
Features:
- Class weighting to handle imbalanced action distribution
- Cosine annealing LR schedule with warmup
- Early stopping on validation loss
- Per-class accuracy tracking
- Periodic checkpoint saving (best + every N epochs)
- CUDA/MPS/CPU auto-detection
Usage:
python -m ml.training.trainer --data-dir ml/models --epochs 50
python -m ml.training.trainer --data-dir ml/models --lr 5e-4 --batch-size 128
"""
import argparse
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
# Conditional torch import
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.config.scope import (
NUM_ACTIONS, STATE_DIMS, IDX_TO_ACTION, TRAINING_CONFIG,
)
from ml.training.policy_network import (
PolicyNetwork, save_checkpoint, load_checkpoint,
)
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.trainer")
# ══════════════════════════════════════════════════════════
# Dataset Loading
# ══════════════════════════════════════════════════════════
def load_npz_split(path: str) -> Tuple[np.ndarray, np.ndarray]:
"""Load states and labels from an NPZ file."""
data = np.load(path, allow_pickle=True)
states = data["states"].astype(np.float32)
labels = data["labels"].astype(np.int64)
return states, labels
def make_dataloader(
states: np.ndarray,
labels: np.ndarray,
batch_size: int,
shuffle: bool = True,
device: str = "cpu",
) -> DataLoader:
"""Create a PyTorch DataLoader from numpy arrays."""
state_tensor = torch.from_numpy(states)
label_tensor = torch.from_numpy(labels)
dataset = TensorDataset(state_tensor, label_tensor)
return DataLoader(
dataset,
batch_size=batch_size,
shuffle=shuffle,
pin_memory=(device != "cpu"),
drop_last=False,
)
def compute_class_weights(labels: np.ndarray, num_classes: int) -> torch.Tensor:
"""
Compute inverse-frequency class weights for cross-entropy.
Uses smoothed inverse frequency:
w_c = N / (num_classes * count_c)
Capped at 10× to prevent extreme weights on rare classes.
"""
counts = np.bincount(labels, minlength=num_classes).astype(np.float64)
# Avoid division by zero for classes with no samples
counts = np.maximum(counts, 1.0)
n = len(labels)
weights = n / (num_classes * counts)
# Cap extreme weights
weights = np.minimum(weights, 10.0)
return torch.from_numpy(weights).float()
# ══════════════════════════════════════════════════════════
# Training Loop
# ══════════════════════════════════════════════════════════
class SupervisedTrainer:
"""
Trains PolicyNetwork with cross-entropy on expert decision data.
Features:
- Class-weighted loss for imbalanced actions
- Cosine annealing with linear warmup
- Early stopping on validation loss
- Best-model and periodic checkpointing
- Detailed per-epoch metrics
"""
def __init__(
self,
model: PolicyNetwork,
device: str = "cpu",
learning_rate: float = None,
batch_size: int = None,
epochs: int = None,
patience: int = None,
checkpoint_dir: str = None,
warmup_epochs: int = 3,
save_every: int = 10,
):
self.model = model.to(device)
self.device = device
self.lr = learning_rate or TRAINING_CONFIG.learning_rate
self.batch_size = batch_size or TRAINING_CONFIG.batch_size
self.epochs = epochs or TRAINING_CONFIG.epochs
self.patience = patience or TRAINING_CONFIG.early_stop_patience
self.checkpoint_dir = checkpoint_dir or TRAINING_CONFIG.checkpoint_dir
self.warmup_epochs = warmup_epochs
self.save_every = save_every
# Will be initialized in train()
self.optimizer = None
self.scheduler = None
self.criterion = None
self.history: List[Dict] = []
def train(
self,
train_states: np.ndarray,
train_labels: np.ndarray,
val_states: np.ndarray,
val_labels: np.ndarray,
) -> Dict:
"""
Run the full training loop.
Returns:
Dict with training summary (best metrics, epoch count, etc.)
"""
if not TORCH_AVAILABLE:
raise RuntimeError("PyTorch not installed. Run: pip install torch")
logger.info("=" * 60)
logger.info(" Commander AI Lab — Supervised Policy Training")
logger.info("=" * 60)
logger.info(" Device: %s", self.device)
logger.info(" Train samples: %d", len(train_states))
logger.info(" Val samples: %d", len(val_states))
logger.info(" Batch size: %d", self.batch_size)
logger.info(" Learning rate: %s", self.lr)
logger.info(" Max epochs: %d", self.epochs)
logger.info(" Patience: %d", self.patience)
logger.info(" Warmup epochs: %d", self.warmup_epochs)
logger.info("=" * 60)
# Data loaders
train_loader = make_dataloader(
train_states, train_labels, self.batch_size, shuffle=True, device=self.device
)
val_loader = make_dataloader(
val_states, val_labels, self.batch_size, shuffle=False, device=self.device
)
# Class-weighted cross-entropy
class_weights = compute_class_weights(train_labels, NUM_ACTIONS).to(self.device)
self.criterion = nn.CrossEntropyLoss(weight=class_weights)
logger.info(" Class weights: %s",
{IDX_TO_ACTION[i].value: f"{w:.2f}"
for i, w in enumerate(class_weights.cpu().numpy())})
# Optimizer
self.optimizer = torch.optim.AdamW(
self.model.parameters(),
lr=self.lr,
weight_decay=1e-4,
)
# Cosine annealing with warmup
# During warmup, LR ramps from lr/10 to lr
# After warmup, cosine decay to lr/100
total_steps = self.epochs * len(train_loader)
warmup_steps = self.warmup_epochs * len(train_loader)
def lr_lambda(step):
if step < warmup_steps:
return 0.1 + 0.9 * (step / max(warmup_steps, 1))
progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1)
return 0.01 + 0.99 * 0.5 * (1 + np.cos(np.pi * progress))
self.scheduler = torch.optim.lr_scheduler.LambdaLR(
self.optimizer, lr_lambda
)
# Training state
best_val_loss = float("inf")
best_val_acc = 0.0
best_epoch = 0
epochs_without_improvement = 0
os.makedirs(self.checkpoint_dir, exist_ok=True)
for epoch in range(1, self.epochs + 1):
t_start = time.time()
# Train one epoch
train_loss, train_acc, train_per_class = self._train_epoch(
train_loader, epoch
)
# Validate
val_loss, val_acc, val_per_class = self._validate(val_loader)
elapsed = time.time() - t_start
current_lr = self.optimizer.param_groups[0]["lr"]
# Record history
epoch_metrics = {
"epoch": epoch,
"train_loss": train_loss,
"train_acc": train_acc,
"val_loss": val_loss,
"val_acc": val_acc,
"lr": current_lr,
"time_s": elapsed,
"train_per_class": train_per_class,
"val_per_class": val_per_class,
}
self.history.append(epoch_metrics)
# Log
logger.info(
" Epoch %3d/%d | train_loss=%.4f train_acc=%.3f | "
"val_loss=%.4f val_acc=%.3f | lr=%.2e | %.1fs",
epoch, self.epochs,
train_loss, train_acc,
val_loss, val_acc,
current_lr, elapsed,
)
# Check for improvement
improved = False
if val_loss < best_val_loss:
best_val_loss = val_loss
best_val_acc = val_acc
best_epoch = epoch
epochs_without_improvement = 0
improved = True
# Save best model
save_checkpoint(
self.model, self.optimizer, epoch,
{"val_loss": val_loss, "val_acc": val_acc,
"train_loss": train_loss, "train_acc": train_acc},
os.path.join(self.checkpoint_dir, "best_policy.pt"),
)
else:
epochs_without_improvement += 1
# Periodic checkpoint
if epoch % self.save_every == 0:
save_checkpoint(
self.model, self.optimizer, epoch,
{"val_loss": val_loss, "val_acc": val_acc},
os.path.join(self.checkpoint_dir, f"policy_epoch_{epoch:03d}.pt"),
)
# Early stopping
if epochs_without_improvement >= self.patience:
logger.info(
" Early stopping at epoch %d (no improvement for %d epochs)",
epoch, self.patience,
)
break
# Final summary
logger.info("")
logger.info("=" * 60)
logger.info(" Training Complete")
logger.info("=" * 60)
logger.info(" Best epoch: %d", best_epoch)
logger.info(" Best val_loss: %.4f", best_val_loss)
logger.info(" Best val_acc: %.3f", best_val_acc)
logger.info(" Total epochs: %d", len(self.history))
logger.info(" Checkpoint: %s", os.path.join(self.checkpoint_dir, "best_policy.pt"))
logger.info("=" * 60)
# Save training history
history_path = os.path.join(self.checkpoint_dir, "training_history.json")
# Convert per-class dicts for JSON serialization
serializable_history = []
for h in self.history:
entry = {k: v for k, v in h.items()
if k not in ("train_per_class", "val_per_class")}
entry["train_per_class"] = {
str(k): v for k, v in h["train_per_class"].items()
}
entry["val_per_class"] = {
str(k): v for k, v in h["val_per_class"].items()
}
serializable_history.append(entry)
with open(history_path, "w") as f:
json.dump(serializable_history, f, indent=2)
logger.info(" History saved: %s", history_path)
return {
"best_epoch": best_epoch,
"best_val_loss": best_val_loss,
"best_val_acc": best_val_acc,
"total_epochs": len(self.history),
"checkpoint_path": os.path.join(self.checkpoint_dir, "best_policy.pt"),
}
def _train_epoch(
self,
loader: DataLoader,
epoch: int,
) -> Tuple[float, float, Dict[int, float]]:
"""Train for one epoch. Returns (loss, accuracy, per_class_acc)."""
self.model.train()
total_loss = 0.0
correct = 0
total = 0
per_class_correct = np.zeros(NUM_ACTIONS)
per_class_total = np.zeros(NUM_ACTIONS)
for batch_idx, (states, labels) in enumerate(loader):
states = states.to(self.device)
labels = labels.to(self.device)
# Forward
logits = self.model(states)
loss = self.criterion(logits, labels)
# Backward
self.optimizer.zero_grad()
loss.backward()
# Gradient clipping
torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)
self.optimizer.step()
self.scheduler.step()
# Metrics
total_loss += loss.item() * len(labels)
preds = logits.argmax(dim=-1)
correct += (preds == labels).sum().item()
total += len(labels)
# Per-class tracking
for c in range(NUM_ACTIONS):
mask = labels == c
per_class_total[c] += mask.sum().item()
per_class_correct[c] += (preds[mask] == c).sum().item()
avg_loss = total_loss / max(total, 1)
accuracy = correct / max(total, 1)
per_class_acc = {}
for c in range(NUM_ACTIONS):
if per_class_total[c] > 0:
per_class_acc[c] = per_class_correct[c] / per_class_total[c]
else:
per_class_acc[c] = 0.0
return avg_loss, accuracy, per_class_acc
@torch.no_grad()
def _validate(
self,
loader: DataLoader,
) -> Tuple[float, float, Dict[int, float]]:
"""Validate on held-out data. Returns (loss, accuracy, per_class_acc)."""
self.model.eval()
total_loss = 0.0
correct = 0
total = 0
per_class_correct = np.zeros(NUM_ACTIONS)
per_class_total = np.zeros(NUM_ACTIONS)
for states, labels in loader:
states = states.to(self.device)
labels = labels.to(self.device)
logits = self.model(states)
loss = self.criterion(logits, labels)
total_loss += loss.item() * len(labels)
preds = logits.argmax(dim=-1)
correct += (preds == labels).sum().item()
total += len(labels)
for c in range(NUM_ACTIONS):
mask = labels == c
per_class_total[c] += mask.sum().item()
per_class_correct[c] += (preds[mask] == c).sum().item()
avg_loss = total_loss / max(total, 1)
accuracy = correct / max(total, 1)
per_class_acc = {}
for c in range(NUM_ACTIONS):
if per_class_total[c] > 0:
per_class_acc[c] = per_class_correct[c] / per_class_total[c]
else:
per_class_acc[c] = 0.0
return avg_loss, accuracy, per_class_acc
# ══════════════════════════════════════════════════════════
# Evaluation
# ══════════════════════════════════════════════════════════
def evaluate_model(
model: PolicyNetwork,
test_states: np.ndarray,
test_labels: np.ndarray,
device: str = "cpu",
) -> Dict:
"""
Full evaluation on test set with detailed metrics.
Returns:
Dict with overall accuracy, per-class accuracy, confusion matrix
"""
model.eval()
model.to(device)
states_t = torch.from_numpy(test_states.astype(np.float32)).to(device)
labels_t = torch.from_numpy(test_labels.astype(np.int64)).to(device)
with torch.no_grad():
logits = model(states_t)
preds = logits.argmax(dim=-1)
preds_np = preds.cpu().numpy()
labels_np = test_labels
# Overall accuracy
overall_acc = (preds_np == labels_np).mean()
# Per-class metrics
per_class = {}
for c in range(NUM_ACTIONS):
mask = labels_np == c
total_c = mask.sum()
correct_c = (preds_np[mask] == c).sum() if total_c > 0 else 0
pred_as_c = (preds_np == c).sum()
precision = correct_c / max(pred_as_c, 1)
recall = correct_c / max(total_c, 1)
f1 = 2 * precision * recall / max(precision + recall, 1e-8)
action_name = IDX_TO_ACTION[c].value
per_class[action_name] = {
"support": int(total_c),
"correct": int(correct_c),
"predicted": int(pred_as_c),
"precision": round(float(precision), 4),
"recall": round(float(recall), 4),
"f1": round(float(f1), 4),
}
# Confusion matrix
confusion = np.zeros((NUM_ACTIONS, NUM_ACTIONS), dtype=int)
for true_c, pred_c in zip(labels_np, preds_np):
confusion[true_c][pred_c] += 1
return {
"overall_accuracy": round(float(overall_acc), 4),
"total_samples": len(labels_np),
"per_class": per_class,
"confusion_matrix": confusion.tolist(),
}
def print_evaluation(results: Dict):
"""Pretty-print evaluation results."""
print()
print("=" * 65)
print(" Test Set Evaluation")
print("=" * 65)
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
print(f" Total Samples: {results['total_samples']:,}")
print()
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
print(" " + "-" * 55)
for action_name, m in results["per_class"].items():
print(
f" {action_name:<20} {m['support']:>8} "
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
)
print(" " + "-" * 55)
# Confusion matrix
print()
print(" Confusion Matrix (rows=true, cols=predicted):")
header = " " + " " * 18
for c in range(NUM_ACTIONS):
short_name = IDX_TO_ACTION[c].value[:6]
header += f" {short_name:>6}"
print(header)
for r in range(NUM_ACTIONS):
row_name = IDX_TO_ACTION[r].value[:16]
row_str = f" {row_name:<18}"
for c in range(NUM_ACTIONS):
row_str += f" {results['confusion_matrix'][r][c]:>6}"
print(row_str)
print()
# ══════════════════════════════════════════════════════════
# Device Selection
# ══════════════════════════════════════════════════════════
def get_best_device() -> str:
"""Auto-detect the best available compute device."""
if not TORCH_AVAILABLE:
return "cpu"
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0)
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
logger.info(" Using Apple MPS")
return "mps"
logger.info(" Using CPU (no GPU detected)")
return "cpu"
# ══════════════════════════════════════════════════════════
# CLI Entry Point
# ══════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="Commander AI Lab — Train Policy Network"
)
parser.add_argument(
"--data-dir", default="ml/models",
help="Directory containing dataset-train.npz / dataset-val.npz / dataset-test.npz",
)
parser.add_argument(
"--epochs", type=int, default=None,
help=f"Max training epochs (default: {TRAINING_CONFIG.epochs})",
)
parser.add_argument(
"--batch-size", type=int, default=None,
help=f"Batch size (default: {TRAINING_CONFIG.batch_size})",
)
parser.add_argument(
"--lr", type=float, default=None,
help=f"Learning rate (default: {TRAINING_CONFIG.learning_rate})",
)
parser.add_argument(
"--patience", type=int, default=None,
help=f"Early stopping patience (default: {TRAINING_CONFIG.early_stop_patience})",
)
parser.add_argument(
"--checkpoint-dir", default=None,
help=f"Checkpoint directory (default: {TRAINING_CONFIG.checkpoint_dir})",
)
parser.add_argument(
"--device", default=None,
help="Compute device: cuda, mps, or cpu (default: auto-detect)",
)
parser.add_argument(
"--eval-only", action="store_true",
help="Only evaluate an existing checkpoint on test data",
)
parser.add_argument(
"--checkpoint", default=None,
help="Path to checkpoint for --eval-only (default: best_policy.pt)",
)
args = parser.parse_args()
if not TORCH_AVAILABLE:
print("PyTorch not installed. Install with:")
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
sys.exit(1)
device = args.device or get_best_device()
data_dir = args.data_dir
ckpt_dir = args.checkpoint_dir or TRAINING_CONFIG.checkpoint_dir
# Load data
train_path = os.path.join(data_dir, "dataset-train.npz")
val_path = os.path.join(data_dir, "dataset-val.npz")
test_path = os.path.join(data_dir, "dataset-test.npz")
if args.eval_only:
# Evaluation only
if not os.path.exists(test_path):
print(f"Test data not found: {test_path}")
print("Run: python -m ml.scripts.ml_cli build")
sys.exit(1)
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if not os.path.exists(ckpt_path):
print(f"Checkpoint not found: {ckpt_path}")
sys.exit(1)
test_states, test_labels = load_npz_split(test_path)
model, checkpoint = load_checkpoint(ckpt_path, device)
logger.info(" Loaded checkpoint from epoch %d", checkpoint.get("epoch", "?"))
results = evaluate_model(model, test_states, test_labels, device)
print_evaluation(results)
# Save evaluation results
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
print(f" Results saved: {eval_path}")
return
# Training mode
for path, label in [(train_path, "train"), (val_path, "val")]:
if not os.path.exists(path):
print(f"{label} data not found: {path}")
print("Build the dataset first:")
print(" python -m ml.scripts.ml_cli build --results-dir results/")
sys.exit(1)
train_states, train_labels = load_npz_split(train_path)
val_states, val_labels = load_npz_split(val_path)
logger.info(" Train: %d samples, %d features", *train_states.shape)
logger.info(" Val: %d samples, %d features", *val_states.shape)
# Verify dimensions match expected state size
actual_dim = train_states.shape[1]
expected_dim = STATE_DIMS.total_state_dim
if actual_dim != expected_dim:
logger.warning(
" State dimension mismatch: data=%d, config=%d. Using data dimension.",
actual_dim, expected_dim,
)
# Create model
model = PolicyNetwork(input_dim=actual_dim)
param_count = sum(p.numel() for p in model.parameters())
logger.info(" Model parameters: %s", f"{param_count:,}")
# Train
trainer = SupervisedTrainer(
model=model,
device=device,
learning_rate=args.lr,
batch_size=args.batch_size,
epochs=args.epochs,
patience=args.patience,
checkpoint_dir=ckpt_dir,
)
summary = trainer.train(train_states, train_labels, val_states, val_labels)
# Evaluate on test set if available
if os.path.exists(test_path):
logger.info("")
logger.info(" Running test set evaluation...")
test_states, test_labels = load_npz_split(test_path)
# Reload best checkpoint for evaluation
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
results = evaluate_model(best_model, test_states, test_labels, device)
print_evaluation(results)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
logger.info(" Eval results saved: %s", eval_path)
else:
logger.info(" No test data found at %s — skipping evaluation.", test_path)
if __name__ == "__main__":
main()
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff. Show more