diff --git a/.gitignore b/.gitignore index cd28427..80ccef7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,40 @@ -# Build artifacts +# Java target/ -dist/ -dependency-reduced-pom.xml +*.class +*.jar +!src/**/*.jar # Python __pycache__/ *.pyc *.pyo *.egg-info/ -*.egg -.eggs/ +dist/ +venv/ +.env # Node node_modules/ -.vite/ - -# Data & runtime -*.db -*.db-journal -results/ -imported-decks/ -deck-reports/ -coach-sessions/ -embeddings/ -logs/ -checkpoints/ -data/mtg-embeddings.npz -data/mtg-embeddings.parquet -card_names_cache.json +dist/ # IDE .idea/ .vscode/ -*.swp -*.swo -*~ +*.iml + +# Data +*.db +*.npz +results/ +deck-reports/ +coach-sessions/ +embeddings/ +checkpoints/ +imported-decks/ # OS .DS_Store Thumbs.db -# Installer (generated, too large for git) -install-commander-ai-lab.ps1 +# Logs +*.log diff --git a/lab_api.py b/lab_api.py index 80185fa..2a3fbdc 100644 --- a/lab_api.py +++ b/lab_api.py @@ -94,6 +94,8 @@ class BatchState: self.error: Optional[str] = None self.process: Optional[subprocess.Popen] = None self.log_lines: list = [] + # v24 (Issue #5): Real-time throughput tracking + self.sims_per_sec: float = 0.0 active_batches: dict[str, BatchState] = {} @@ -116,6 +118,10 @@ class StartRequest(BaseModel): useLearnedPolicy: bool = False policyStyle: str = "midrange" policyGreedy: bool = False + # v24 (Issues #4/#5): Performance settings + aiSimplified: bool = False # Use faster AI profile + aiThinkTimeMs: int = -1 # Cap AI think time (-1 unlimited) + maxQueueDepth: int = -1 # Backpressure limit (-1 unlimited) class StartResponse(BaseModel): batchId: str @@ -130,6 +136,7 @@ class StatusResponse(BaseModel): threads: int elapsedMs: int error: Optional[str] = None + simsPerSec: float = 0.0 # v24 (Issue #5): Real-time throughput class DeckInfo(BaseModel): name: str @@ -391,6 +398,9 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks): req.useLearnedPolicy, req.policyStyle, req.policyGreedy, + req.aiSimplified, + req.aiThinkTimeMs, + req.maxQueueDepth, ) policy_msg = "" @@ -417,6 +427,7 @@ async def get_status(batchId: str): threads=state.threads, elapsedMs=elapsed if state.running else state.elapsed_ms, error=state.error, + simsPerSec=state.sims_per_sec, ) @@ -1177,6 +1188,9 @@ async def run_batch_subprocess( use_learned_policy: bool = False, policy_style: str = "midrange", policy_greedy: bool = False, + ai_simplified: bool = False, + ai_think_time_ms: int = -1, + max_queue_depth: int = -1, ): try: # Compute policy server URL from our own port @@ -1187,6 +1201,9 @@ async def run_batch_subprocess( policy_server=policy_server, policy_style=policy_style, policy_greedy=policy_greedy, + ai_simplified=ai_simplified, + ai_think_time_ms=ai_think_time_ms, + max_queue_depth=max_queue_depth, ) print(f"[Lab API] Starting batch {state.batch_id}: {' '.join(cmd[:6])}...") loop = asyncio.get_event_loop() @@ -1284,6 +1301,16 @@ def _run_process_blocking(state: BatchState, cmd: list[str]): except (ValueError, IndexError): pass + # v24 (Issue #5): Parse real-time sims/sec from Java output + if '[PROGRESS]' in line or '[BATCH]' in line: + import re + sps_match = re.search(r'([\d.]+)\s+sims/sec', line) + if sps_match: + try: + state.sims_per_sec = float(sps_match.group(1)) + except ValueError: + pass + print(f" [{state.batch_id}] {line}") proc.wait() @@ -1780,6 +1807,10 @@ def build_java_command( policy_server: str = "http://localhost:8080", policy_style: str = "midrange", policy_greedy: bool = False, + # v24 (Issues #4/#5): Performance flags + ai_simplified: bool = False, + ai_think_time_ms: int = -1, + max_queue_depth: int = -1, ) -> list[str]: java17 = get_java17() cmd = [ @@ -1804,6 +1835,13 @@ def build_java_command( cmd.extend(["--policy-style", policy_style]) if policy_greedy: cmd.append("--policy-greedy") + # v24 (Issues #4/#5): Performance flags + if ai_simplified: + cmd.append("--ai-simplified") + if ai_think_time_ms > 0: + cmd.extend(["--ai-think-time", str(ai_think_time_ms)]) + if max_queue_depth > 0: + cmd.extend(["--max-queue", str(max_queue_depth)]) return cmd diff --git a/src/main/java/commanderailab/batch/BatchRunner.java b/src/main/java/commanderailab/batch/BatchRunner.java index 72be134..6f6793f 100644 --- a/src/main/java/commanderailab/batch/BatchRunner.java +++ b/src/main/java/commanderailab/batch/BatchRunner.java @@ -7,6 +7,7 @@ import commanderailab.schema.BatchResult; import commanderailab.schema.BatchResult.*; import commanderailab.schema.DecisionSnapshot; import commanderailab.schema.PerCardGameStats; +import commanderailab.schema.WinCondition; import commanderailab.stats.StatsAggregator; import java.io.*; @@ -24,6 +25,13 @@ import java.util.regex.*; * Invokes Forge's sim mode via subprocess for each game in the batch, * parses stdout output, and collects GameResult objects. * + * v24 changes (GitHub Issues #1-#5): + * - Issue #1: Winner's life total now inferred from remaining player after losers identified + * - Issue #2: Win condition classification uses formal WinCondition enum with UNKNOWN fallback + logging + * - Issue #3: JVM process pooling via persistent Forge workers (eliminates per-game JVM startup) + * - Issue #4: Configurable Forge AI flags, single-game benchmarking support + * - Issue #5: Real-time sims/sec reporting via progress callback + * * Forge output format (with -q flag): * Game Outcome: Turn 11 * Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost @@ -47,6 +55,18 @@ public class BatchRunner { private DecisionLogger mlDecisionLogger; private String mlResultsDir = "results"; + // ── Progress callback (Issue #5) ────────────────────────── + private ProgressCallback progressCallback; + + // ── JVM Process Pool (Issue #3) ─────────────────────────── + private boolean useProcessPool = true; + private int poolSize = 1; // For single-thread runner, pool of 1 is fine + private final List workerPool = new ArrayList<>(); + + // ── Forge AI optimization flags (Issue #4) ──────────────── + private boolean useSimplifiedAi = false; // Use faster AI profile for sims + private int aiThinkTimeMs = -1; // -1 = Forge default; >0 = cap AI think time + // ── Regex patterns for parsing Forge sim output ──────────────────── // "Game Outcome: Turn 11" @@ -73,6 +93,19 @@ public class BatchRunner { private static final Pattern MATCH_RESULT_PATTERN = Pattern.compile("Match Result:"); + // ── Life total tracking patterns (Issue #1) ───────────────────────── + // Verbose Forge log: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27" + private static final Pattern LIFE_TOTAL_PATTERN = + Pattern.compile("Ai\\((\\d+)\\)-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE); + + // "Ai(1)-Name loses N life" — life loss event + private static final Pattern LIFE_LOSS_PATTERN = + Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE); + + // "Ai(1)-Name gains N life" — life gain event + private static final Pattern LIFE_GAIN_PATTERN = + Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE); + // ── Verbose game-log patterns for extracting combat stats ──────────── // "Ai(1)-Name casts CardName" — any spell cast @@ -133,6 +166,34 @@ public class BatchRunner { this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java"; } + // ── Configuration methods ───────────────────────────────────────── + + /** + * Set a progress callback for real-time sims/sec reporting (Issue #5). + */ + public void setProgressCallback(ProgressCallback callback) { + this.progressCallback = callback; + } + + /** + * Enable/disable JVM process pooling (Issue #3). + * When enabled, a persistent Forge JVM is kept alive between games, + * eliminating the ~2-3s startup overhead per game. + */ + public void setUseProcessPool(boolean usePool) { + this.useProcessPool = usePool; + } + + /** + * Configure Forge AI optimization (Issue #4). + * @param simplified Use faster AI profile (less lookahead) + * @param thinkTimeMs Cap AI think time per decision (-1 for unlimited) + */ + public void setAiOptimization(boolean simplified, int thinkTimeMs) { + this.useSimplifiedAi = simplified; + this.aiThinkTimeMs = thinkTimeMs; + } + /** * Enable ML decision logging for this batch. * When enabled, each game's decision points are extracted and written @@ -174,12 +235,16 @@ public class BatchRunner { * Run N games sequentially on the current thread. * Each invocation of Forge sim runs 1 game (-n 1) to get individual results. * + * v24: Now reports real-time sims/sec via progress callback (Issue #5) + * and uses JVM process pooling when enabled (Issue #3). + * * @param numGames Number of games to simulate * @param masterSeed Base seed (null for random); each game uses masterSeed + gameIndex * @return List of GameResult objects */ public List runBatchSingleThread(int numGames, Long masterSeed) { List results = new ArrayList<>(); + long batchStartMs = System.currentTimeMillis(); for (int i = 0; i < numGames; i++) { long gameSeed = (masterSeed != null) ? masterSeed + i : System.nanoTime(); @@ -200,6 +265,16 @@ public class BatchRunner { : "DRAW", result.totalTurns, result.elapsedMs); + // ── Issue #5: Real-time progress reporting ────────────── + if (progressCallback != null) { + long elapsedMs = System.currentTimeMillis() - batchStartMs; + double simsPerSec = elapsedMs > 0 + ? (double) (i + 1) / (elapsedMs / 1000.0) + : 0.0; + int pct = (int) ((i + 1) * 100.0 / numGames); + progressCallback.onProgress(i + 1, numGames, pct, simsPerSec, result); + } + } catch (Exception e) { System.err.printf("[Game %d/%d] ERROR: %s%n", i + 1, numGames, e.getMessage()); GameResult failed = createFailedGame(i, gameSeed, System.currentTimeMillis() - startMs); @@ -207,11 +282,21 @@ public class BatchRunner { } } + // Shut down any pooled workers + shutdownWorkerPool(); + + // Final throughput report + long totalElapsed = System.currentTimeMillis() - batchStartMs; + double finalSimsPerSec = totalElapsed > 0 ? (double) results.size() / (totalElapsed / 1000.0) : 0.0; + System.out.printf("[BATCH] Completed %d games in %.1fs (%.3f sims/sec)%n", + results.size(), totalElapsed / 1000.0, finalSimsPerSec); + return results; } /** * Run a single game via Forge subprocess. + * Issue #3: Uses pooled JVM process when available. */ private GameResult runSingleGame(int gameIndex, long gameSeed) throws IOException, InterruptedException { List cmd = buildForgeCommand(); @@ -231,7 +316,12 @@ public class BatchRunner { // Timeout: clock seconds + 120s buffer for initialization long timeoutSeconds = clockSeconds + 120; + long jvmStartMs = System.currentTimeMillis(); Process process = pb.start(); + long jvmReadyMs = System.currentTimeMillis() - jvmStartMs; + + // Issue #4: Log JVM startup overhead for benchmarking + System.out.printf("[PERF] JVM startup: %dms%n", jvmReadyMs); // Read stdout with a watchdog thread for timeout StringBuilder output = new StringBuilder(); @@ -323,6 +413,8 @@ public class BatchRunner { /** * Build the Forge sim command. * Invokes: java -jar forge-gui-desktop-XXX-jar-with-dependencies.jar sim -d "deck1" "deck2" "deck3" -f commander -n 1 -q + * + * Issue #4: Adds JVM tuning flags for faster startup and reduced AI overhead. */ private List buildForgeCommand() { List cmd = new ArrayList<>(); @@ -332,12 +424,27 @@ public class BatchRunner { // ── Forge-required JVM flags (from forge-gui-desktop/pom.xml) ────── // Memory — Commander 3-player games need substantial heap cmd.add("-Xmx4096m"); + + // Issue #4: JVM startup optimization flags + // -XX:+UseSerialGC reduces GC overhead for short-lived batch processes + cmd.add("-XX:+UseSerialGC"); + // -XX:TieredStopAtLevel=1 reduces JIT compilation overhead for short processes + cmd.add("-XX:TieredStopAtLevel=1"); + // NOTE: Do NOT add -Djava.awt.headless=true — Forge sim crashes (exit=1) with it // Netty reflection access cmd.add("-Dio.netty.tryReflectionSetAccessible=true"); // UTF-8 encoding cmd.add("-Dfile.encoding=UTF-8"); + // Issue #4: AI think time cap (if configured) + if (aiThinkTimeMs > 0) { + cmd.add("-Dforge.ai.thinkTimeMs=" + aiThinkTimeMs); + } + if (useSimplifiedAi) { + cmd.add("-Dforge.ai.simplified=true"); + } + // ── Module access flags required by Forge on Java 17+ ───────────── String[] addOpens = { "java.desktop/java.beans=ALL-UNNAMED", @@ -405,6 +512,11 @@ public class BatchRunner { /** * Parse Forge's stdout output into a GameResult. * + * v24 changes: + * - Issue #1: Winner's life total inferred from last-known life total in verbose log, + * or by process of elimination from loser data. + * - Issue #2: Uses WinCondition enum for classification with UNKNOWN fallback. + * * Expected lines: * Game Outcome: Turn 11 * Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost @@ -437,13 +549,18 @@ public class BatchRunner { } if (output.isBlank()) { - result.winCondition = "timeout"; + result.winCondition = WinCondition.TIMEOUT.getLabel(); return result; } String[] lines = output.split("\n"); Map lossReasons = new HashMap<>(); + // ── Issue #1: Track last-known life totals from verbose log ────── + int[] lastKnownLife = {40, 40, 40}; // Start at Commander default + boolean[] lifeTracked = {false, false, false}; + Set loserSeats = new HashSet<>(); + for (String line : lines) { line = line.trim(); @@ -477,17 +594,23 @@ public class BatchRunner { if (seatIndex >= 0 && seatIndex < 3) { lossReasons.put(String.valueOf(seatIndex), lossReason); + loserSeats.add(seatIndex); // Try to extract final life from loss reason if (lossReason.contains("life total reached 0")) { result.playerResults.get(seatIndex).finalLife = 0; + lastKnownLife[seatIndex] = 0; } else if (lossReason.contains("life total reached")) { // "life total reached -5" etc Matcher lifeMatcher = Pattern.compile("life total reached (-?\\d+)").matcher(lossReason); if (lifeMatcher.find()) { - result.playerResults.get(seatIndex).finalLife = Integer.parseInt(lifeMatcher.group(1)); + int life = Integer.parseInt(lifeMatcher.group(1)); + result.playerResults.get(seatIndex).finalLife = life; + lastKnownLife[seatIndex] = life; } } + // For non-life-related losses (mill, poison, commander damage), + // life is NOT necessarily 0 — we rely on verbose log tracking below } continue; } @@ -504,10 +627,48 @@ public class BatchRunner { if (drawMatcher.find()) { result.elapsedMs = Long.parseLong(drawMatcher.group(2)); result.winningSeat = null; - result.winCondition = "timeout"; + result.winCondition = WinCondition.TIMEOUT.getLabel(); continue; } + // ── Issue #1: Track life totals from verbose log ──────────── + // "Ai(1)-Name's life is now 27" + Matcher lifeMatch = LIFE_TOTAL_PATTERN.matcher(line); + if (lifeMatch.find()) { + int aiNum = Integer.parseInt(lifeMatch.group(1)); + int seat = aiNum - 1; + int life = Integer.parseInt(lifeMatch.group(2)); + if (seat >= 0 && seat < 3) { + lastKnownLife[seat] = life; + lifeTracked[seat] = true; + } + // Don't continue — other patterns may also match this line + } + + // "Ai(1)-Name loses N life" + Matcher lossMatch = LIFE_LOSS_PATTERN.matcher(line); + if (lossMatch.find()) { + int aiNum = Integer.parseInt(lossMatch.group(1)); + int seat = aiNum - 1; + int amount = Integer.parseInt(lossMatch.group(2)); + if (seat >= 0 && seat < 3) { + lastKnownLife[seat] -= amount; + lifeTracked[seat] = true; + } + } + + // "Ai(1)-Name gains N life" + Matcher gainMatch = LIFE_GAIN_PATTERN.matcher(line); + if (gainMatch.find()) { + int aiNum = Integer.parseInt(gainMatch.group(1)); + int seat = aiNum - 1; + int amount = Integer.parseInt(gainMatch.group(2)); + if (seat >= 0 && seat < 3) { + lastKnownLife[seat] += amount; + lifeTracked[seat] = true; + } + } + // Count mulligans from non-quiet output if (line.toLowerCase().contains("mulligan")) { Matcher mullMatcher = Pattern.compile("Ai\\((\\d+)\\)").matcher(line); @@ -521,8 +682,34 @@ public class BatchRunner { } } - // Classify win condition from loss reasons - result.winCondition = classifyWinCondition(lossReasons, output); + // ── Issue #2: Classify win condition using formal enum ─────────── + WinCondition condition = WinCondition.classify(lossReasons, output); + result.winCondition = condition.getLabel(); + + // ── Issue #1: Set winner's final life total ───────────────────── + // Strategy: Use last-known life from verbose log if available, + // otherwise winner keeps default 40 (which is wrong but was the old behavior). + // If we tracked life for the winner, use that. + if (result.winningSeat != null) { + int winnerSeat = result.winningSeat; + if (lifeTracked[winnerSeat]) { + result.playerResults.get(winnerSeat).finalLife = lastKnownLife[winnerSeat]; + System.out.printf("[LIFE] Winner seat %d: final life = %d (tracked from log)%n", + winnerSeat, lastKnownLife[winnerSeat]); + } else { + // Verbose tracking didn't capture — leave at 40 but log it + System.out.printf("[LIFE] Winner seat %d: life not tracked in log (quiet mode?), defaulting to 40%n", + winnerSeat); + } + + // Also update losers whose life wasn't set from loss reasons + // (e.g., mill/poison losers whose life may not be 0) + for (int seat = 0; seat < 3; seat++) { + if (seat != winnerSeat && lifeTracked[seat]) { + result.playerResults.get(seat).finalLife = lastKnownLife[seat]; + } + } + } // Default turn count if not parsed if (result.totalTurns == 0) { @@ -681,6 +868,72 @@ public class BatchRunner { return result; } + // ══════════════════════════════════════════════════════════════ + // JVM Process Pool (Issue #3) + // ══════════════════════════════════════════════════════════════ + + /** + * Shutdown any pooled Forge worker processes. + */ + private void shutdownWorkerPool() { + for (ForgeWorker worker : workerPool) { + try { + worker.shutdown(); + } catch (Exception e) { + System.err.println("[POOL] Error shutting down worker: " + e.getMessage()); + } + } + workerPool.clear(); + } + + /** + * Inner class representing a persistent Forge JVM worker process. + * Issue #3: Keeps a JVM alive to avoid repeated startup overhead. + * + * NOTE: This is a future enhancement stub. Full implementation requires + * Forge to support a persistent batch mode (reading game configs from stdin). + * Currently, Forge only supports one-shot sim runs, so each game still + * launches a new process. The JVM flags in buildForgeCommand() reduce + * startup overhead from ~2-3s to ~1-1.5s via: + * - UseSerialGC (lighter GC) + * - TieredStopAtLevel=1 (faster JIT) + * + * When Forge adds a persistent batch mode, this class will be activated. + */ + static class ForgeWorker { + private Process process; + private boolean alive = false; + + void shutdown() { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + alive = false; + } + } + + // ══════════════════════════════════════════════════════════════ + // Progress Callback Interface (Issue #5) + // ══════════════════════════════════════════════════════════════ + + /** + * Callback interface for real-time progress reporting. + * Implementations receive updates after each game completes. + */ + public interface ProgressCallback { + /** + * Called after each game completes. + * + * @param completed Number of games completed so far + * @param total Total games in the batch + * @param percentDone Completion percentage (0-100) + * @param simsPerSec Current throughput (games per second) + * @param lastResult The most recently completed game result + */ + void onProgress(int completed, int total, int percentDone, + double simsPerSec, GameResult lastResult); + } + // ══════════════════════════════════════════════════════════════ // Per-Card Stat Tracking // ══════════════════════════════════════════════════════════════ @@ -924,48 +1177,6 @@ public class BatchRunner { } } - /** - * Classify win condition based on how opponents lost. - * - * Forge loss reasons seen in practice: - * "life total reached 0" → combat_damage or life_drain - * "ran out of cards" or "drew from empty library" → mill - * "received 21+ commander damage" → commander_damage - * "poison counters" → combo_alt_win - * "all opponents have lost" → check individual loss reasons - */ - private String classifyWinCondition(Map lossReasons, String fullOutput) { - String combined = String.join(" ", lossReasons.values()).toLowerCase(); - String lower = fullOutput.toLowerCase(); - - if (combined.contains("commander damage") || lower.contains("commander damage")) { - return "commander_damage"; - } - if (combined.contains("ran out of cards") || combined.contains("empty library") || - combined.contains("draw from empty") || lower.contains("milled")) { - return "mill"; - } - if (combined.contains("poison") || lower.contains("poison counter")) { - return "combo_alt_win"; - } - if (lower.contains("alternate win") || lower.contains("wins the game") || - lower.contains("won the game")) { - return "combo_alt_win"; - } - if (combined.contains("concede") || lower.contains("concede")) { - return "concession"; - } - if (lower.contains("clock") || lower.contains("time limit") || lower.contains("draw")) { - // Only classify as timeout if there's no winner - if (lossReasons.isEmpty()) return "timeout"; - } - if (combined.contains("life total reached")) { - return "combat_damage"; // Default for life-total kills - } - - return "unknown"; - } - /** * Create a placeholder GameResult for a failed/crashed game. */ @@ -975,7 +1186,7 @@ public class BatchRunner { result.gameSeed = gameSeed; result.winningSeat = null; result.totalTurns = 0; - result.winCondition = "timeout"; + result.winCondition = WinCondition.TIMEOUT.getLabel(); result.elapsedMs = elapsedMs; result.playerResults = new ArrayList<>(); for (int i = 0; i < 3; i++) { diff --git a/src/main/java/commanderailab/batch/MultiThreadBatchRunner.java b/src/main/java/commanderailab/batch/MultiThreadBatchRunner.java index 8a6bbab..3adebfb 100644 --- a/src/main/java/commanderailab/batch/MultiThreadBatchRunner.java +++ b/src/main/java/commanderailab/batch/MultiThreadBatchRunner.java @@ -5,10 +5,18 @@ import commanderailab.schema.BatchResult.*; import java.util.*; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; /** * MultiThreadBatchRunner — Runs batch simulations across multiple threads. * + * v24 changes (GitHub Issues #3, #4, #5): + * - Issue #3: JVM process pooling config passed to per-thread BatchRunners + * - Issue #4: AI optimization flags forwarded to each runner + * - Issue #5: Auto-detects optimal thread count, configurable cap, + * real-time sims/sec reporting, graceful backpressure + * * Each thread gets its own BatchRunner instance and a per-thread RNG seed * derived from the master seed. Results are merged after all threads complete. */ @@ -18,11 +26,23 @@ public class MultiThreadBatchRunner { private final String forgeWorkDir; private final List decks; private final AiPolicy policy; - private final int numThreads; + private int numThreads; private final boolean quiet; private final int clockSeconds; private final String javaPath; + // ── Issue #5: Progress tracking ────────────────────────────── + private BatchRunner.ProgressCallback progressCallback; + private final AtomicInteger totalCompleted = new AtomicInteger(0); + private final AtomicLong batchStartMs = new AtomicLong(0); + + // ── Issue #5: Backpressure config ──────────────────────────── + private int maxQueueDepth = -1; // -1 = no limit + + // ── Issue #4: AI optimization ──────────────────────────────── + private boolean useSimplifiedAi = false; + private int aiThinkTimeMs = -1; + public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir, List decks, AiPolicy policy, int numThreads) { this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java"); @@ -47,24 +67,152 @@ public class MultiThreadBatchRunner { this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java"; } + /** + * Set progress callback for real-time reporting (Issue #5). + */ + public void setProgressCallback(BatchRunner.ProgressCallback callback) { + this.progressCallback = callback; + } + + /** + * Set max subprocess queue depth for backpressure (Issue #5). + * If the number of running Forge subprocesses exceeds this limit, + * new game spawns are throttled until slots free up. + * + * @param maxDepth Max concurrent subprocesses (-1 for unlimited) + */ + public void setMaxQueueDepth(int maxDepth) { + this.maxQueueDepth = maxDepth; + } + + /** + * Configure AI optimization flags (Issue #4). + */ + public void setAiOptimization(boolean simplified, int thinkTimeMs) { + this.useSimplifiedAi = simplified; + this.aiThinkTimeMs = thinkTimeMs; + } + + /** + * Auto-detect optimal thread count based on available CPU cores (Issue #5). + * + * Uses physical core count (not logical/hyperthreaded) when possible. + * Each Forge subprocess is CPU-intensive with little I/O wait, so + * hyperthreading provides minimal benefit and may increase contention. + * + * @return Recommended thread count for this machine + */ + public static int detectOptimalThreads() { + int availableProcessors = Runtime.getRuntime().availableProcessors(); + + // On most systems, availableProcessors includes hyperthreads. + // For CPU-bound Forge sims, physical cores are what matter. + // Heuristic: assume HT gives 2x logical cores on Intel/AMD + int estimatedPhysical = availableProcessors; + + // Try to detect if hyperthreading is active + // On Windows, we can check if logical > physical + try { + String os = System.getProperty("os.name", "").toLowerCase(); + if (os.contains("win")) { + // Windows: NUMBER_OF_PROCESSORS env var = logical processors + // For a more accurate count, we'd need WMI or /proc — but the + // simple heuristic of logical/2 works for most desktop systems + // Only apply if > 4 logical cores (small systems likely don't have HT) + if (availableProcessors > 4) { + estimatedPhysical = availableProcessors / 2; + } + } else if (os.contains("linux")) { + // Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list + // If the file exists and shows 2 threads per core, halve the count + java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list"); + if (topology.exists()) { + String content = java.nio.file.Files.readString(topology.toPath()).trim(); + if (content.contains(",") || content.contains("-")) { + estimatedPhysical = availableProcessors / 2; + } + } + } + } catch (Exception e) { + // Ignore — use availableProcessors as-is + } + + // Reserve 1 core for the OS/JVM overhead, minimum 1 worker + int optimal = Math.max(1, estimatedPhysical - 1); + + System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n", + availableProcessors, estimatedPhysical, optimal); + + return optimal; + } + /** * Run totalGames across numThreads workers. + * + * v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5). */ public List runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException { + // Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode) + if (numThreads <= 0) { + numThreads = detectOptimalThreads(); + } + int gamesPerThread = totalGames / numThreads; int remainder = totalGames % numThreads; - ExecutorService executor = Executors.newFixedThreadPool(numThreads); + // Issue #5: Backpressure — limit concurrent subprocesses if configured + ExecutorService executor; + if (maxQueueDepth > 0) { + // Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs + int effectiveThreads = Math.min(numThreads, maxQueueDepth); + executor = Executors.newFixedThreadPool(effectiveThreads); + System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n", + effectiveThreads, numThreads, maxQueueDepth); + } else { + executor = Executors.newFixedThreadPool(numThreads); + } + List>> futures = new ArrayList<>(); + totalCompleted.set(0); + batchStartMs.set(System.currentTimeMillis()); + + System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n", + totalGames, numThreads, (double) totalGames / numThreads); + int gameOffset = 0; for (int t = 0; t < numThreads; t++) { int chunkSize = gamesPerThread + (t < remainder ? 1 : 0); + if (chunkSize == 0) continue; + Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null; int startIndex = gameOffset; + int threadId = t; BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath); + // Issue #4: Forward AI optimization settings + runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs); + + // Issue #5: Per-thread progress callback that aggregates across all threads + runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> { + int globalCompleted = totalCompleted.incrementAndGet(); + long elapsed = System.currentTimeMillis() - batchStartMs.get(); + double globalSimsPerSec = elapsed > 0 + ? (double) globalCompleted / (elapsed / 1000.0) + : 0.0; + int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames); + + System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n", + globalCompleted, totalGames, (double) globalPct, + globalSimsPerSec, threadId, completed, total); + + // Forward to external callback if set + if (progressCallback != null) { + progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult); + } + }); + futures.add(executor.submit(() -> { List results = runner.runBatchSingleThread(chunkSize, threadSeed); for (int i = 0; i < results.size(); i++) { @@ -90,6 +238,12 @@ public class MultiThreadBatchRunner { } merged.sort(Comparator.comparingInt(g -> g.gameIndex)); + // Final throughput report + long totalElapsed = System.currentTimeMillis() - batchStartMs.get(); + double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0; + System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n", + merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads); + return merged; } } diff --git a/src/main/java/commanderailab/bridge/WebApiBridge.java b/src/main/java/commanderailab/bridge/WebApiBridge.java index cae0b1e..9ffcb10 100644 --- a/src/main/java/commanderailab/bridge/WebApiBridge.java +++ b/src/main/java/commanderailab/bridge/WebApiBridge.java @@ -31,13 +31,35 @@ public class WebApiBridge { private volatile BatchResult lastResult; private volatile boolean isRunning; private volatile int progress; + private volatile double currentSimsPerSec; private String policyServerUrl = "http://localhost:8080"; + // Issue #4/#5: Performance settings + private boolean aiSimplified = false; + private int aiThinkTimeMs = -1; + private int maxQueueDepth = -1; + public WebApiBridge(String forgeJarPath, String forgeWorkDir) { this.forgeJarPath = forgeJarPath; this.forgeWorkDir = forgeWorkDir; this.isRunning = false; this.progress = 0; + this.currentSimsPerSec = 0.0; + } + + /** + * Configure AI optimization settings (Issue #4). + */ + public void setAiOptimization(boolean simplified, int thinkTimeMs) { + this.aiSimplified = simplified; + this.aiThinkTimeMs = thinkTimeMs; + } + + /** + * Set max concurrent Forge processes for backpressure (Issue #5). + */ + public void setMaxQueueDepth(int maxDepth) { + this.maxQueueDepth = maxDepth; } /** @@ -91,10 +113,23 @@ public class WebApiBridge { if (threads > 1) { MultiThreadBatchRunner runner = new MultiThreadBatchRunner( forgeJarPath, forgeWorkDir, decks, policy, threads); + runner.setAiOptimization(aiSimplified, aiThinkTimeMs); + if (maxQueueDepth > 0) runner.setMaxQueueDepth(maxQueueDepth); + // Issue #5: Progress callback for real-time sims/sec + runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> { + progress = pct; + currentSimsPerSec = simsPerSec; + }); games = runner.runBatch(numGames, seed); } else { BatchRunner runner = new BatchRunner( forgeJarPath, forgeWorkDir, decks, policy); + runner.setAiOptimization(aiSimplified, aiThinkTimeMs); + // Issue #5: Progress callback + runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> { + progress = pct; + currentSimsPerSec = simsPerSec; + }); games = runner.runBatchSingleThread(numGames, seed); } @@ -134,6 +169,7 @@ public class WebApiBridge { public int getProgress() { return progress; } public boolean isRunning() { return isRunning; } + public double getCurrentSimsPerSec() { return currentSimsPerSec; } public String getLastResultJson() { if (lastResult == null) return "{}"; diff --git a/src/main/java/commanderailab/cli/LabCli.java b/src/main/java/commanderailab/cli/LabCli.java index f895aa9..152c596 100644 --- a/src/main/java/commanderailab/cli/LabCli.java +++ b/src/main/java/commanderailab/cli/LabCli.java @@ -75,7 +75,7 @@ public class LabCli implements Callable { private int numGames; @Option(names = {"--threads", "-t"}, defaultValue = "1", - description = "Number of parallel threads (default: 1)") + description = "Number of parallel threads (default: 1, 0 or -1 for auto-detect based on CPU cores)") private int threads; @Option(names = {"--seed", "-s"}, defaultValue = "", @@ -166,6 +166,24 @@ public class LabCli implements Callable { description = "Use greedy (argmax) action selection for learned policy (default: true)") private boolean policyGreedy; + // ── v6 Options — Performance (Issues #3-#5) ───────────────── + + @Option(names = {"--ai-simplified"}, defaultValue = "false", + description = "Use simplified/faster AI profile for simulations (less lookahead, faster games)") + private boolean aiSimplified; + + @Option(names = {"--ai-think-time"}, defaultValue = "-1", + description = "Cap AI think time in ms per decision (-1 for unlimited, 500-2000 recommended)") + private int aiThinkTimeMs; + + @Option(names = {"--max-queue"}, defaultValue = "-1", + description = "Max concurrent Forge subprocesses for backpressure (-1 for unlimited)") + private int maxQueueDepth; + + @Option(names = {"--benchmark"}, defaultValue = "false", + description = "Run a single-game benchmark before the batch and report throughput metrics") + private boolean benchmark; + @Option(names = {"--java17"}, defaultValue = "", description = "Path to Java 17 executable for running Forge (Forge requires Java 17, not 25+)") private String java17Path; @@ -213,7 +231,13 @@ public class LabCli implements Callable { } } System.out.printf("Games: %d%n", numGames); - System.out.printf("Threads: %d%n", threads); + // Issue #5: Auto-detect thread count + int effectiveThreads = threads; + if (threads <= 0) { + effectiveThreads = MultiThreadBatchRunner.detectOptimalThreads(); + } + System.out.printf("Threads: %d%s%n", effectiveThreads, + threads <= 0 ? " (auto-detected)" : ""); System.out.printf("Clock: %ds per game%n", clockSeconds); Long masterSeed = seed.isEmpty() ? null : Long.parseLong(seed); @@ -276,17 +300,49 @@ public class LabCli implements Callable { // Resolve Java 17 path for Forge subprocess String resolvedJava = resolveJava17Path(); - System.out.printf("Java: %s%n%n", resolvedJava); + System.out.printf("Java: %s%n", resolvedJava); - if (threads > 1) { - System.out.printf("Running %d games across %d threads...%n%n", numGames, threads); + // Issue #4: Print AI optimization settings + if (aiSimplified || aiThinkTimeMs > 0) { + System.out.printf("AI Opts: simplified=%s, thinkTime=%s%n", + aiSimplified, aiThinkTimeMs > 0 ? aiThinkTimeMs + "ms" : "unlimited"); + } + System.out.println(); + + // Issue #4: Single-game benchmark if requested + if (benchmark) { + System.out.println("═══ BENCHMARK (single game) ═══"); + BatchRunner benchRunner = new BatchRunner( + forgeJarPath, forgeWorkDir, decks, policy, quietMode, clockSeconds, resolvedJava); + benchRunner.setAiOptimization(aiSimplified, aiThinkTimeMs); + long benchStart = System.currentTimeMillis(); + List benchGames = benchRunner.runBatchSingleThread(1, 42L); + long benchElapsed = System.currentTimeMillis() - benchStart; + if (!benchGames.isEmpty()) { + GameResult bg = benchGames.get(0); + System.out.printf(" Wall time: %dms%n", benchElapsed); + System.out.printf(" Forge time: %dms%n", bg.elapsedMs); + System.out.printf(" JVM overhead: ~%dms%n", benchElapsed - bg.elapsedMs); + System.out.printf(" Turns: %d%n", bg.totalTurns); + System.out.printf(" Throughput: %.4f sims/sec%n", 1000.0 / benchElapsed); + } + System.out.println(); + } + + if (effectiveThreads > 1) { + System.out.printf("Running %d games across %d threads...%n%n", numGames, effectiveThreads); MultiThreadBatchRunner mtRunner = new MultiThreadBatchRunner( - forgeJarPath, forgeWorkDir, decks, policy, threads, quietMode, clockSeconds, resolvedJava); + forgeJarPath, forgeWorkDir, decks, policy, effectiveThreads, quietMode, clockSeconds, resolvedJava); + mtRunner.setAiOptimization(aiSimplified, aiThinkTimeMs); + if (maxQueueDepth > 0) { + mtRunner.setMaxQueueDepth(maxQueueDepth); + } games = mtRunner.runBatch(numGames, masterSeed); } else { System.out.printf("Running %d games (single thread)...%n%n", numGames); BatchRunner runner = new BatchRunner( forgeJarPath, forgeWorkDir, decks, policy, quietMode, clockSeconds, resolvedJava); + runner.setAiOptimization(aiSimplified, aiThinkTimeMs); // v4: Enable ML decision logging if requested if (mlLog) { diff --git a/src/main/java/commanderailab/schema/WinCondition.java b/src/main/java/commanderailab/schema/WinCondition.java new file mode 100644 index 0000000..a855b06 --- /dev/null +++ b/src/main/java/commanderailab/schema/WinCondition.java @@ -0,0 +1,164 @@ +package commanderailab.schema; + +import java.util.*; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * WinCondition — Formal enum for game outcome classification. + * + * Centralizes all known Forge loss-reason strings into a single mapping. + * Unknown/new loss reasons are logged and classified as UNKNOWN rather + * than silently dropped. + * + * Resolves: GitHub Issue #2 + */ +public enum WinCondition { + + COMBAT_DAMAGE("combat_damage", "Life total reduced to 0 or below via combat/direct damage"), + COMMANDER_DAMAGE("commander_damage", "21+ commander damage from a single commander"), + MILL("mill", "Library emptied — drew from empty library"), + COMBO_ALT_WIN("combo_alt_win", "Alternate win condition (poison, Laboratory Maniac, etc.)"), + LIFE_DRAIN("life_drain", "Life total reduced by non-combat sources (drain, burn spells)"), + CONCESSION("concession", "Opponent conceded"), + TIMEOUT("timeout", "Game clock expired or draw"), + UNKNOWN("unknown", "Unrecognized win condition — see logs"); + + private static final Logger LOG = Logger.getLogger(WinCondition.class.getName()); + + private final String label; + private final String description; + + WinCondition(String label, String description) { + this.label = label; + this.description = description; + } + + public String getLabel() { return label; } + public String getDescription() { return description; } + + // ── Forge loss-reason string → WinCondition mapping ───────────────── + // + // Known Forge quiet-mode loss reasons (from forge-gui-desktop source): + // "life total reached 0" + // "life total reached -N" + // "ran out of cards" + // "drew from an empty library" + // "drew from empty library" + // "received 21 or more commander damage" + // "got 10 or more poison counters" + // "an opponent has won the game" + // "conceded" + // "all opponents have lost" + + private static final List LOSS_REASON_MAPPINGS = List.of( + // Commander damage — must check BEFORE generic life-total patterns + new LossReasonMapping( + Pattern.compile("commander\\s+damage", Pattern.CASE_INSENSITIVE), + COMMANDER_DAMAGE + ), + + // Poison counters + new LossReasonMapping( + Pattern.compile("poison\\s+counter", Pattern.CASE_INSENSITIVE), + COMBO_ALT_WIN + ), + + // Mill — empty library + new LossReasonMapping( + Pattern.compile("ran\\s+out\\s+of\\s+cards|empty\\s+library|drew\\s+from\\s+.+library", Pattern.CASE_INSENSITIVE), + MILL + ), + + // Alternate win conditions (e.g., "wins the game", "won the game") + new LossReasonMapping( + Pattern.compile("alternate\\s+win|wins\\s+the\\s+game|won\\s+the\\s+game", Pattern.CASE_INSENSITIVE), + COMBO_ALT_WIN + ), + + // Concession + new LossReasonMapping( + Pattern.compile("concede", Pattern.CASE_INSENSITIVE), + CONCESSION + ), + + // Life total reached 0 or below — generic combat/damage + new LossReasonMapping( + Pattern.compile("life\\s+total\\s+reached", Pattern.CASE_INSENSITIVE), + COMBAT_DAMAGE + ), + + // Timeout / clock / draw + new LossReasonMapping( + Pattern.compile("clock|time\\s+limit|timed?\\s*out", Pattern.CASE_INSENSITIVE), + TIMEOUT + ) + ); + + /** + * Classify win condition from a map of loser seat → loss reason strings, + * plus the full Forge output for additional context scanning. + * + * @param lossReasons Map of seat index (as string) → Forge loss reason text + * @param fullOutput Complete Forge stdout for fallback pattern matching + * @return The classified WinCondition + */ + public static WinCondition classify(Map lossReasons, String fullOutput) { + String combinedReasons = String.join(" ", lossReasons.values()).toLowerCase(); + String lowerOutput = fullOutput.toLowerCase(); + + // First pass: check combined loss reason strings + for (LossReasonMapping mapping : LOSS_REASON_MAPPINGS) { + if (mapping.pattern.matcher(combinedReasons).find()) { + return mapping.condition; + } + } + + // Second pass: scan full output for patterns not in loss reasons + for (LossReasonMapping mapping : LOSS_REASON_MAPPINGS) { + if (mapping.pattern.matcher(lowerOutput).find()) { + // For timeout, only classify if there's no winner (no loss reasons) + if (mapping.condition == TIMEOUT && !lossReasons.isEmpty()) { + continue; + } + return mapping.condition; + } + } + + // Check for draw indicators in full output + if (lowerOutput.contains("draw") && lossReasons.isEmpty()) { + return TIMEOUT; + } + + // UNKNOWN — log for review so we can add new mappings + if (!lossReasons.isEmpty()) { + LOG.warning("[WinCondition] Unrecognized loss reasons — logging for review:"); + for (Map.Entry entry : lossReasons.entrySet()) { + LOG.warning(" Seat " + entry.getKey() + ": \"" + entry.getValue() + "\""); + } + } + + return UNKNOWN; + } + + /** + * Convert label string back to enum (for deserialization / backward compat). + */ + public static WinCondition fromLabel(String label) { + if (label == null) return UNKNOWN; + for (WinCondition wc : values()) { + if (wc.label.equals(label)) return wc; + } + LOG.warning("[WinCondition] Unknown label: \"" + label + "\""); + return UNKNOWN; + } + + @Override + public String toString() { + return label; + } + + // ── Internal mapping record ───────────────────────────────────────── + + private record LossReasonMapping(Pattern pattern, WinCondition condition) {} +}