Initial commit
This commit is contained in:
commit
e6b5e0cedb
402 files changed
+64931
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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(),
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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 []
|
||||
@@ -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 []
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
+6100
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Whitespace-only changes.
@@ -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()
|
||||
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.
@@ -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()
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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()
|
||||
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user