v24: Resolve GitHub Issues #1-#5

Issue #1 — Winner's life total now captured:
  - Parse verbose Forge log for life-total-change events
  - Track last-known life per seat via 3 regex patterns
  - Winner's final life set from log data (not left at default 40)
  - Losers who died to mill/poison retain their actual life total

Issue #2 — Win condition classification formalized:
  - New WinCondition enum (schema/WinCondition.java) with 8 categories
  - Centralized Forge loss-reason-string → enum mapping (regex-based)
  - UNKNOWN fallback with logging for unrecognized loss reasons
  - All classification now goes through WinCondition.classify()

Issue #3 — JVM startup overhead reduced:
  - Added -XX:+UseSerialGC (lighter GC for batch processes)
  - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
  - ForgeWorker stub class for future persistent batch mode
  - Estimated reduction: ~2-3s → ~1-1.5s per game startup

Issue #4 — Single-threaded throughput optimization:
  - New CLI flags: --ai-simplified, --ai-think-time, --benchmark
  - JVM flags passed through to Forge subprocess
  - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
  - API support: aiSimplified, aiThinkTimeMs in StartRequest

Issue #5 — Threading, progress, and backpressure:
  - --threads 0 or -1 auto-detects optimal count from CPU topology
  - detectOptimalThreads() estimates physical cores (excludes HT)
  - ProgressCallback interface for real-time sims/sec reporting
  - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
  - --max-queue flag for graceful backpressure (limits concurrent JVMs)
  - Final throughput summary printed at batch completion
  - StatusResponse now includes simsPerSec field

Also: .gitignore restored, lab_api.py updated with all new API fields
This commit is contained in:
KoalaTrapLord committed 2026-03-12 01:26:36 +00:00
1 parent cbedfd1ed2
commit f639703fb4
7 files changed
+736 -81

No files matched your search

+21 -25
View File
@@ -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
+38
View File
@@ -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
@@ -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<ForgeWorker> 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<GameResult> runBatchSingleThread(int numGames, Long masterSeed) {
List<GameResult> 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<String> 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<String> buildForgeCommand() {
List<String> 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<String, String> 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<Integer> 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<String, String> 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++) {
@@ -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<DeckInfo> 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<DeckInfo> 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<GameResult> 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<Future<List<GameResult>>> 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<GameResult> 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;
}
}
@@ -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 "{}";
+62 -6
View File
@@ -75,7 +75,7 @@ public class LabCli implements Callable<Integer> {
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<Integer> {
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<Integer> {
}
}
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<Integer> {
// 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<GameResult> 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) {
@@ -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<LossReasonMapping> 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<String, String> 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<String, String> 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) {}
}