Phase 6: ELO Tracking & Model Tournament (#70)

- Create ml/eval/elo_tracker.py:
  - EloTracker class with standard ELO formula (K=32, start=1200)
  - EloResult dataclass for tournament output
  - EloHistory class for JSON persistence (data/elo_history.json)
  - run_elo_tournament(): round-robin ELO-rated matches using
    existing run_match() from tournament.py
  - run_generation_tournament(): discovers gen-*/best_policy.pt
    checkpoints and runs cross-generation ELO tournament
  - CLI entry point: python -m ml.eval.elo_tracker

- Add ELO API endpoints to routes/ml.py:
  - POST /api/ml/elo/tournament — start background ELO tournament
  - GET  /api/ml/elo/tournament/status — poll progress
  - GET  /api/ml/elo/history — load ELO history from disk

- Integrate ELO convergence in distillation_loop.py:
  - New config fields: elo_convergence_enabled (default False),
    elo_convergence_threshold (20.0), elo_tournament_episodes (30)
  - New field: GenerationRecord.elo_rating
  - _run_elo_mini_tournament() runs after quality gate pass
  - _check_convergence() checks ELO delta when enabled

- Add ELO UI to distillation page:
  - ELO Ratings panel with canvas multi-line chart showing per-model
    ELO progression across generations
  - Run Tournament button with progress indicator
  - ELO leaderboard table with rank, rating, and visual bars
  - Responsive chart resize handling

Closes #70
This commit is contained in:
rgriffith committed 2026-03-22 00:31:02 +00:00
1 parent dd2e945214
commit e843528d8b
6 files changed
+953 -1

No files matched your search

+407
View File
@@ -0,0 +1,407 @@
"""
Commander AI Lab — ELO Tracker & Cross-Generation Tournament
═══════════════════════════════════════════════════════════════
Phase 6 (Issue #70): Quantify improvement across distillation
generations using ELO ratings.
- Runs round-robin tournaments between generation checkpoints
and baselines (heuristic, random)
- Computes ELO ratings using the standard formula
- Persists ELO history to `data/elo_history.json`
- Provides ELO delta for convergence detection
Reuses `run_match`, `LearnedPolicy`, `HeuristicPolicy`,
`RandomPolicy` from `ml/eval/tournament.py`.
Usage:
python -m ml.eval.elo_tracker
python -m ml.eval.elo_tracker --episodes 50 --output data/elo_history.json
"""
import json
import logging
import math
import os
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from pathlib import Path
from typing import Dict, List, Optional, Tuple
project_root = str(Path(__file__).parent.parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from ml.eval.tournament import (
run_match, LearnedPolicy, HeuristicPolicy, RandomPolicy,
)
logger = logging.getLogger("ml.eval.elo")
# ═══════════════════════════════════════════════════════════
# ELO Rating Engine
# ═══════════════════════════════════════════════════════════
DEFAULT_K = 32
DEFAULT_START_ELO = 1200.0
class EloTracker:
"""Standard ELO rating tracker."""
def __init__(self, k_factor: float = DEFAULT_K, start_elo: float = DEFAULT_START_ELO):
self.k = k_factor
self.start_elo = start_elo
self.ratings: Dict[str, float] = {}
def ensure_player(self, name: str):
if name not in self.ratings:
self.ratings[name] = self.start_elo
def expected_score(self, rating_a: float, rating_b: float) -> float:
return 1.0 / (1.0 + math.pow(10, (rating_b - rating_a) / 400.0))
def update(self, winner: str, loser: str):
"""Update ratings after a decisive match."""
self.ensure_player(winner)
self.ensure_player(loser)
e_win = self.expected_score(self.ratings[winner], self.ratings[loser])
e_lose = self.expected_score(self.ratings[loser], self.ratings[winner])
self.ratings[winner] += self.k * (1.0 - e_win)
self.ratings[loser] += self.k * (0.0 - e_lose)
def update_draw(self, player_a: str, player_b: str):
"""Update ratings after a draw."""
self.ensure_player(player_a)
self.ensure_player(player_b)
e_a = self.expected_score(self.ratings[player_a], self.ratings[player_b])
e_b = self.expected_score(self.ratings[player_b], self.ratings[player_a])
self.ratings[player_a] += self.k * (0.5 - e_a)
self.ratings[player_b] += self.k * (0.5 - e_b)
def get_ratings(self) -> Dict[str, float]:
"""Return a copy of current ratings, rounded to 1 decimal."""
return {k: round(v, 1) for k, v in self.ratings.items()}
# ═══════════════════════════════════════════════════════════
# ELO Result
# ═══════════════════════════════════════════════════════════
@dataclass
class EloResult:
"""Result of an ELO-rated tournament."""
ratings: Dict[str, float] = field(default_factory=dict)
match_count: int = 0
total_time_s: float = 0.0
policies: List[str] = field(default_factory=list)
rating_progression: List[Dict[str, float]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"ratings": self.ratings,
"match_count": self.match_count,
"total_time_s": round(self.total_time_s, 1),
"policies": self.policies,
"rating_progression": self.rating_progression,
}
# ═══════════════════════════════════════════════════════════
# ELO History (persistence)
# ═══════════════════════════════════════════════════════════
class EloHistory:
"""Persists ELO ratings across tournament runs."""
def __init__(self, path: str = "data/elo_history.json"):
self.path = path
self.entries: List[dict] = []
self._load()
def _load(self):
if os.path.exists(self.path):
try:
with open(self.path, "r") as f:
data = json.load(f)
self.entries = data.get("entries", [])
except Exception:
self.entries = []
def save(self):
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
with open(self.path, "w") as f:
json.dump({"entries": self.entries}, f, indent=2)
def append(self, generation: int, ratings: Dict[str, float]):
"""Add a new ELO snapshot after a tournament."""
self.entries.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"generation": generation,
"ratings": {k: round(v, 1) for k, v in ratings.items()},
})
self.save()
def get_elo_delta(self, window: int = 2) -> Optional[float]:
"""
Compute average ELO gain of the latest generation model
over the last `window` entries.
Returns None if insufficient data.
"""
if len(self.entries) < window:
return None
recent = self.entries[-window:]
deltas = []
for i in range(1, len(recent)):
prev_ratings = recent[i - 1]["ratings"]
curr_ratings = recent[i]["ratings"]
# Find the latest generation model name in current entry
gen_models = [k for k in curr_ratings if k.startswith("gen-")]
if not gen_models:
continue
latest_gen = sorted(gen_models)[-1]
curr_elo = curr_ratings.get(latest_gen, DEFAULT_START_ELO)
# Compare to previous entry's latest gen model, or heuristic
prev_gen_models = [k for k in prev_ratings if k.startswith("gen-")]
if prev_gen_models:
prev_latest = sorted(prev_gen_models)[-1]
prev_elo = prev_ratings.get(prev_latest, DEFAULT_START_ELO)
else:
prev_elo = prev_ratings.get("heuristic", DEFAULT_START_ELO)
deltas.append(curr_elo - prev_elo)
if not deltas:
return None
return sum(deltas) / len(deltas)
def to_dict(self) -> dict:
return {"entries": self.entries}
@classmethod
def from_dict(cls, data: dict, path: str = "data/elo_history.json") -> "EloHistory":
h = cls.__new__(cls)
h.path = path
h.entries = data.get("entries", [])
return h
# ═══════════════════════════════════════════════════════════
# Tournament Functions
# ═══════════════════════════════════════════════════════════
def run_elo_tournament(
policies: Dict[str, object],
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
) -> EloResult:
"""
Run a round-robin ELO-rated tournament.
Uses `run_match` from tournament.py for match execution.
Args:
policies: Dict mapping name → policy object (with select_action method)
episodes_per_matchup: Games per pair (played both sides)
playstyle: Game playstyle
k_factor: ELO K-factor
Returns:
EloResult with final ratings, match count, and progression
"""
t_start = time.time()
names = list(policies.keys())
tracker = EloTracker(k_factor=k_factor)
# Initialize all players
for name in names:
tracker.ensure_player(name)
progression = [tracker.get_ratings().copy()]
total_matches = 0
logger.info("ELO Tournament: %d policies, %d games/matchup", len(names), episodes_per_matchup)
for name_a, name_b in combinations(names, 2):
policy_a = policies[name_a]
policy_b = policies[name_b]
for ep in range(episodes_per_matchup):
# Game 1: A as seat 0
m1 = run_match(policy_a, name_a, policy_b, name_b, playstyle=playstyle)
total_matches += 1
if m1.winner == name_a:
tracker.update(name_a, name_b)
elif m1.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Game 2: B as seat 0
m2 = run_match(policy_b, name_b, policy_a, name_a, playstyle=playstyle)
total_matches += 1
if m2.winner == name_a:
tracker.update(name_a, name_b)
elif m2.winner == name_b:
tracker.update(name_b, name_a)
else:
tracker.update_draw(name_a, name_b)
# Record progression after each matchup
progression.append(tracker.get_ratings().copy())
logger.info(
" %s (%.0f) vs %s (%.0f)",
name_a, tracker.ratings[name_a],
name_b, tracker.ratings[name_b],
)
return EloResult(
ratings=tracker.get_ratings(),
match_count=total_matches,
total_time_s=time.time() - t_start,
policies=names,
rating_progression=progression,
)
def run_generation_tournament(
checkpoint_dir: str = "ml/models/checkpoints",
episodes_per_matchup: int = 50,
playstyle: str = "midrange",
k_factor: float = DEFAULT_K,
) -> EloResult:
"""
Discover generation checkpoints and run an ELO tournament.
Looks for `gen-*/best_policy.pt` under checkpoint_dir.
Also includes heuristic and random baselines.
"""
policies: Dict[str, object] = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
ckpt_path = Path(checkpoint_dir)
if ckpt_path.exists():
for gen_dir in sorted(ckpt_path.glob("gen-*")):
model_path = gen_dir / "best_policy.pt"
if model_path.exists():
name = gen_dir.name # e.g., "gen-001"
policies[name] = LearnedPolicy(str(model_path))
logger.info("Loaded %s: %s", name, model_path)
# Also check standard checkpoints
sup_path = ckpt_path / "best_policy.pt"
if sup_path.exists() and "supervised" not in policies:
policies["supervised"] = LearnedPolicy(str(sup_path))
ppo_path = ckpt_path / "best_ppo.pt"
if ppo_path.exists() and "ppo" not in policies:
policies["ppo"] = LearnedPolicy(str(ppo_path))
if len(policies) < 2:
logger.warning("Need at least 2 policies for ELO tournament")
return EloResult(
ratings={k: DEFAULT_START_ELO for k in policies},
policies=list(policies.keys()),
)
logger.info("Running ELO tournament with %d policies: %s", len(policies), list(policies.keys()))
return run_elo_tournament(
policies=policies,
episodes_per_matchup=episodes_per_matchup,
playstyle=playstyle,
k_factor=k_factor,
)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def main():
"""CLI entry point for ELO tournament."""
import argparse
parser = argparse.ArgumentParser(
description="Commander AI Lab — ELO Tournament"
)
parser.add_argument(
"--episodes", type=int, default=50,
help="Episodes per matchup (default: 50)",
)
parser.add_argument(
"--checkpoint-dir", default="ml/models/checkpoints",
help="Checkpoint directory (default: ml/models/checkpoints)",
)
parser.add_argument(
"--playstyle", default="midrange",
help="Playstyle (default: midrange)",
)
parser.add_argument(
"--output", default="data/elo_history.json",
help="Output path for ELO history (default: data/elo_history.json)",
)
parser.add_argument(
"--k-factor", type=float, default=32,
help="ELO K-factor (default: 32)",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
result = run_generation_tournament(
checkpoint_dir=args.checkpoint_dir,
episodes_per_matchup=args.episodes,
playstyle=args.playstyle,
k_factor=args.k_factor,
)
# Print results
logger.info("")
logger.info("=" * 50)
logger.info(" ELO RATINGS")
logger.info("=" * 50)
sorted_ratings = sorted(result.ratings.items(), key=lambda x: x[1], reverse=True)
for name, elo in sorted_ratings:
logger.info(" %-20s %7.1f", name, elo)
logger.info("")
logger.info(" %d matches in %.1f s", result.match_count, result.total_time_s)
logger.info("=" * 50)
# Save to history
history = EloHistory(path=args.output)
# Use the latest generation number, or 0 if no gen models
gen_models = [k for k in result.ratings if k.startswith("gen-")]
gen_num = 0
if gen_models:
try:
gen_num = max(int(g.split("-")[1]) for g in gen_models)
except (ValueError, IndexError):
pass
history.append(gen_num, result.ratings)
logger.info("ELO history saved: %s", args.output)
print(json.dumps(result.to_dict(), indent=2))
if __name__ == "__main__":
main()
+92 -1
View File
@@ -99,6 +99,11 @@ class DistillationConfig:
retry_lr_factor: float = 0.5 # Halve PPO LR on retry
retry_entropy_boost: float = 0.005 # Increase entropy coeff on retry
# --- ELO convergence (Phase 6) ---
elo_convergence_enabled: bool = False # Off by default
elo_convergence_threshold: float = 20.0 # Stop if ELO gain < this
elo_tournament_episodes: int = 30 # Games per matchup for ELO mini-tournament
# ═══════════════════════════════════════════════════════════
# Generation metadata
@@ -137,6 +142,7 @@ class GenerationRecord:
retries: int = 0
error: str = ""
elo_rating: float = 0.0
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items()}
@@ -393,6 +399,15 @@ class DistillationLoop:
"in next generation's dataset build."
)
record.status = "passed"
# ─── ELO mini-tournament (Phase 6) ────────────────
if cfg.elo_convergence_enabled and gen_num > 1:
elo = self._run_elo_mini_tournament(
gen_checkpoint_dir, gen_num
)
if elo is not None:
record.elo_rating = elo
logger.info(" ELO rating: %.1f", elo)
else:
record.status = "failed"
@@ -611,6 +626,66 @@ class DistillationLoop:
},
)
# ------------------------------------------------------------------
# ELO mini-tournament (Phase 6)
# ------------------------------------------------------------------
def _run_elo_mini_tournament(
self, gen_checkpoint_dir: str, gen_num: int
) -> Optional[float]:
"""
Run a small ELO tournament between current gen, previous gen,
and heuristic baseline. Returns the current gen's ELO rating.
"""
cfg = self.config
try:
from ml.eval.elo_tracker import (
run_elo_tournament, EloHistory, HeuristicPolicy, LearnedPolicy,
)
policies = {"heuristic": HeuristicPolicy()}
# Current generation checkpoint
curr_path = os.path.join(gen_checkpoint_dir, "best_policy.pt")
curr_name = f"gen-{gen_num:03d}"
if os.path.exists(curr_path):
policies[curr_name] = LearnedPolicy(curr_path)
else:
logger.warning(" ELO: current gen checkpoint not found: %s", curr_path)
return None
# Previous generation checkpoint
if gen_num > 1:
prev_tag = f"gen-{gen_num - 1:03d}"
prev_dir = os.path.join(cfg.checkpoint_dir, prev_tag)
prev_path = os.path.join(prev_dir, "best_policy.pt")
if os.path.exists(prev_path):
policies[prev_tag] = LearnedPolicy(prev_path)
logger.info(
" [ELO] Running mini-tournament: %s (%d episodes/matchup)",
list(policies.keys()), cfg.elo_tournament_episodes,
)
result = run_elo_tournament(
policies=policies,
episodes_per_matchup=cfg.elo_tournament_episodes,
playstyle=cfg.playstyle,
)
# Save to ELO history
history_path = os.path.join(
os.path.dirname(cfg.history_dir), "elo_history.json"
) if cfg.history_dir else "data/elo_history.json"
history = EloHistory(path=history_path)
history.append(gen_num, result.ratings)
return result.ratings.get(curr_name, 0.0)
except Exception as e:
logger.warning(" ELO mini-tournament failed: %s", e)
return None
# ------------------------------------------------------------------
# Convergence detection
# ------------------------------------------------------------------
@@ -618,6 +693,7 @@ class DistillationLoop:
def _check_convergence(self) -> bool:
"""
Check if win rate has plateaued over recent generations.
Also checks ELO delta when elo_convergence_enabled is True.
Returns True if the loop should stop.
"""
@@ -642,7 +718,22 @@ class DistillationLoop:
cfg.convergence_threshold,
)
return improvement < cfg.convergence_threshold
win_rate_converged = improvement < cfg.convergence_threshold
# ELO convergence check (Phase 6)
if cfg.elo_convergence_enabled and len(passed) >= 2:
elo_ratings = [g.elo_rating for g in passed[-2:] if g.elo_rating > 0]
if len(elo_ratings) >= 2:
elo_delta = elo_ratings[-1] - elo_ratings[-2]
logger.info(
" ELO convergence check: delta = %.1f (threshold = %.1f)",
elo_delta, cfg.elo_convergence_threshold,
)
if elo_delta < cfg.elo_convergence_threshold:
logger.info(" ELO delta below threshold — converged.")
return True
return win_rate_converged
# ------------------------------------------------------------------
# Device detection
+129
View File
@@ -945,3 +945,132 @@ async def ml_distillation_history():
pass
return result
# ==============================================================
# ELO Tournament Endpoints
# ==============================================================
@dataclasses.dataclass
class _EloTournamentState:
running: bool = False
phase: str = "idle"
message: str = ""
result: object = None
error: object = None
def snapshot(self) -> dict:
return {
"running": self.running,
"phase": self.phase,
"message": self.message,
"result": self.result,
"error": self.error,
}
_elo_tournament_state = _EloTournamentState()
_elo_tournament_lock = threading.Lock()
def _run_elo_tournament_pipeline(episodes: int, playstyle: str):
"""Run ELO tournament in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _elo_tournament_lock:
_elo_tournament_state.running = True
_elo_tournament_state.phase = "running"
_elo_tournament_state.message = "Running ELO tournament..."
_elo_tournament_state.error = None
_elo_tournament_state.result = None
from ml.eval.elo_tracker import (
run_generation_tournament, EloHistory,
)
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
result = run_generation_tournament(
checkpoint_dir=ckpt_dir,
episodes_per_matchup=episodes,
playstyle=playstyle,
)
# Save to ELO history
history_path = os.path.join(project_root, "data", "elo_history.json")
history = EloHistory(path=history_path)
gen_models = [k for k in result.ratings if k.startswith("gen-")]
gen_num = 0
if gen_models:
try:
gen_num = max(int(g.split("-")[1]) for g in gen_models)
except (ValueError, IndexError):
pass
history.append(gen_num, result.ratings)
with _elo_tournament_lock:
_elo_tournament_state.phase = "done"
_elo_tournament_state.running = False
_elo_tournament_state.result = result.to_dict()
_elo_tournament_state.message = (
f"ELO tournament complete! {result.match_count} matches in {result.total_time_s:.1f}s"
)
except Exception as e:
import traceback
with _elo_tournament_lock:
_elo_tournament_state.phase = "error"
_elo_tournament_state.running = False
_elo_tournament_state.error = str(e)
_elo_tournament_state.message = f"ELO tournament failed: {e}"
traceback.print_exc()
@router.post("/api/ml/elo/tournament")
async def ml_start_elo_tournament(request: FastAPIRequest):
"""Start an ELO-rated tournament across all generation checkpoints."""
if _elo_tournament_state.running:
raise HTTPException(409, "ELO tournament already in progress")
body = await request.json() if await request.body() else {}
episodes = body.get("episodes", 50)
playstyle = body.get("playstyle", "midrange")
with _elo_tournament_lock:
_elo_tournament_state.running = True
_elo_tournament_state.phase = "starting"
_elo_tournament_state.message = "Initializing ELO tournament..."
_elo_tournament_state.result = None
_elo_tournament_state.error = None
thread = threading.Thread(
target=_run_elo_tournament_pipeline,
args=(episodes, playstyle),
daemon=True,
)
thread.start()
return {"status": "started", "episodes": episodes}
@router.get("/api/ml/elo/tournament/status")
async def ml_elo_tournament_status():
"""Get ELO tournament status."""
with _elo_tournament_lock:
return _elo_tournament_state.snapshot()
@router.get("/api/ml/elo/history")
async def ml_elo_history():
"""Get ELO rating history."""
project_root = Path(__file__).resolve().parent.parent
history_path = project_root / "data" / "elo_history.json"
if history_path.exists():
try:
with open(history_path, "r") as f:
return json.load(f)
except Exception:
pass
return {"entries": []}
+15
View File
@@ -187,6 +187,21 @@
}
}
/* ── ELO Panel ─────────────────────────────────────────── */
.elo-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.elo-table {
margin-top: var(--sp-3);
}
.elo-table td {
vertical-align: middle;
}
/* Refresh button spin animation */
@keyframes spin {
from { transform: rotate(0deg); }
+26
View File
@@ -161,6 +161,32 @@
</div>
</section>
<!-- ELO Rating Chart -->
<section class="panel" id="elo-panel">
<div class="panel-header elo-panel-header">
<h2>ELO Ratings</h2>
<button id="btn-run-elo" class="btn btn-sm btn-secondary" onclick="runEloTournament()">
<span class="btn-icon">&#127942;</span> Run Tournament
</button>
</div>
<div id="elo-chart" class="chart-container">
<canvas id="elo-canvas"></canvas>
<div id="elo-empty" class="empty-state">
<p>No ELO data yet. Run a tournament or enable ELO tracking in distillation settings.</p>
</div>
</div>
<div id="elo-progress" class="train-progress hidden">
<div class="progress-header">
<span id="elo-progress-phase" class="phase-badge running">RUNNING</span>
<span id="elo-progress-message">Running ELO tournament...</span>
</div>
<div class="progress-bar-container progress-track">
<div class="progress-bar progress-fill" id="elo-progress-bar" style="width:50%"></div>
</div>
</div>
<div id="elo-leaderboard" class="mt-4"></div>
</section>
<!-- Generation History Table -->
<section class="panel" id="history-panel">
<div class="panel-header">
+284
View File
@@ -30,6 +30,7 @@ async function refreshAll() {
try {
await refreshStatus();
await loadHistory();
await loadEloHistory();
} finally {
btn.disabled = false;
icon.style.animation = '';
@@ -627,6 +628,286 @@ function renderHistoryTable(generations) {
container.innerHTML = html;
}
// ═══════════════════════════════════════════
// ELO Tournament
// ═══════════════════════════════════════════
let eloPollingInterval = null;
let eloHistoryData = null;
async function runEloTournament() {
const btn = document.getElementById('btn-run-elo');
btn.disabled = true;
btn.innerHTML = '<span class="btn-icon">&#8987;</span> Running...';
try {
const res = await fetch(`${API_BASE}/api/ml/elo/tournament`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ episodes: 50, playstyle: 'midrange' }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || `HTTP ${res.status}`);
}
document.getElementById('elo-progress').classList.remove('hidden');
startEloPolling();
} catch (err) {
alert('Failed to start ELO tournament: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<span class="btn-icon">&#127942;</span> Run Tournament';
}
}
function startEloPolling() {
if (eloPollingInterval) clearInterval(eloPollingInterval);
eloPollingInterval = setInterval(pollEloStatus, 3000);
}
async function pollEloStatus() {
try {
const res = await fetch(`${API_BASE}/api/ml/elo/tournament/status`);
const data = await res.json();
const phase = data.phase || 'idle';
const phaseEl = document.getElementById('elo-progress-phase');
phaseEl.textContent = phase.toUpperCase();
phaseEl.className = 'phase-badge ' + phase;
document.getElementById('elo-progress-message').textContent = data.message || '';
const bar = document.getElementById('elo-progress-bar');
if (phase === 'done') {
bar.style.width = '100%';
bar.className = 'progress-bar progress-fill done';
} else if (phase === 'error') {
bar.style.width = '100%';
bar.className = 'progress-bar progress-fill error';
}
if (!data.running && phase !== 'idle') {
clearInterval(eloPollingInterval);
eloPollingInterval = null;
const btn = document.getElementById('btn-run-elo');
btn.disabled = false;
btn.innerHTML = '<span class="btn-icon">&#127942;</span> Run Tournament';
if (phase === 'done' && data.result) {
renderEloLeaderboard(data.result.ratings);
}
// Hide progress after a moment, reload history
setTimeout(() => {
document.getElementById('elo-progress').classList.add('hidden');
loadEloHistory();
}, 2000);
}
} catch (err) {
console.error('ELO poll error:', err);
}
}
async function loadEloHistory() {
try {
const res = await fetch(`${API_BASE}/api/ml/elo/history`);
if (!res.ok) return;
const data = await res.json();
if (data.entries && data.entries.length > 0) {
eloHistoryData = data.entries;
renderEloChart(data.entries);
// Render leaderboard from latest entry
const latest = data.entries[data.entries.length - 1];
if (latest.ratings) {
renderEloLeaderboard(latest.ratings);
}
}
} catch (err) {
console.error('Failed to load ELO history:', err);
}
}
function renderEloChart(entries) {
const canvas = document.getElementById('elo-canvas');
const empty = document.getElementById('elo-empty');
if (!entries || entries.length === 0) {
canvas.style.display = 'none';
empty.style.display = '';
return;
}
canvas.style.display = 'block';
empty.style.display = 'none';
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.parentElement.getBoundingClientRect();
const w = rect.width;
const h = 280;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
const pad = { top: 30, right: 30, bottom: 40, left: 55 };
const cw = w - pad.left - pad.right;
const ch = h - pad.top - pad.bottom;
// Collect all model names across entries
const allModels = new Set();
entries.forEach(e => Object.keys(e.ratings).forEach(k => allModels.add(k)));
const modelNames = Array.from(allModels).sort();
// Color palette for models
const colors = {
'heuristic': '#fbbf24',
'random': '#8b90a0',
'supervised': '#5b9ef0',
'ppo': '#a78bfa',
};
const genColors = ['#4ade80', '#f472b6', '#22d3ee', '#fb923c', '#e879f9', '#34d399', '#f87171', '#60a5fa'];
let genColorIdx = 0;
modelNames.forEach(name => {
if (!colors[name]) {
colors[name] = genColors[genColorIdx % genColors.length];
genColorIdx++;
}
});
// Find ELO range
let minElo = Infinity, maxElo = -Infinity;
entries.forEach(e => {
Object.values(e.ratings).forEach(v => {
minElo = Math.min(minElo, v);
maxElo = Math.max(maxElo, v);
});
});
const eloPad = 50;
minElo = Math.floor((minElo - eloPad) / 50) * 50;
maxElo = Math.ceil((maxElo + eloPad) / 50) * 50;
const eloRange = maxElo - minElo || 100;
// Grid lines
ctx.strokeStyle = '#222533';
ctx.lineWidth = 1;
const ySteps = 5;
for (let i = 0; i <= ySteps; i++) {
const y = pad.top + (ch / ySteps) * i;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(pad.left + cw, y);
ctx.stroke();
const val = maxElo - (eloRange * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(Math.round(val).toString(), pad.left - 8, y + 4);
}
// X axis labels
ctx.textAlign = 'center';
ctx.fillStyle = '#8b90a0';
for (let i = 0; i < entries.length; i++) {
const x = pad.left + (cw / Math.max(1, entries.length - 1)) * i;
ctx.fillText('G' + entries[i].generation, x, h - 8);
}
// Draw lines for each model
modelNames.forEach(name => {
const points = [];
for (let i = 0; i < entries.length; i++) {
if (entries[i].ratings[name] !== undefined) {
const x = entries.length === 1
? pad.left + cw / 2
: pad.left + (cw / (entries.length - 1)) * i;
const y = pad.top + ch - ((entries[i].ratings[name] - minElo) / eloRange) * ch;
points.push({ x, y });
}
}
if (points.length === 0) return;
ctx.strokeStyle = colors[name] || '#5b9ef0';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
if (points.length === 1) {
ctx.fillStyle = colors[name] || '#5b9ef0';
ctx.beginPath();
ctx.arc(points[0].x, points[0].y, 4, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.beginPath();
points.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
// Points
points.forEach(p => {
ctx.fillStyle = colors[name] || '#5b9ef0';
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
});
}
});
// Legend
ctx.font = '11px Inter, sans-serif';
let legendX = pad.left + 10;
const legendY = pad.top + 12;
modelNames.forEach(name => {
ctx.fillStyle = colors[name] || '#5b9ef0';
ctx.fillRect(legendX, legendY - 5, 10, 10);
ctx.fillStyle = '#8b90a0';
ctx.textAlign = 'left';
const label = name.length > 10 ? name.substring(0, 10) : name;
ctx.fillText(label, legendX + 14, legendY + 4);
legendX += ctx.measureText(label).width + 28;
});
}
function renderEloLeaderboard(ratings) {
const container = document.getElementById('elo-leaderboard');
if (!ratings || Object.keys(ratings).length === 0) {
container.innerHTML = '';
return;
}
const sorted = Object.entries(ratings).sort((a, b) => b[1] - a[1]);
let html = '<table class="data-table elo-table">';
html += '<thead><tr><th>Rank</th><th>Policy</th><th>ELO Rating</th><th></th></tr></thead><tbody>';
sorted.forEach(([name, elo], i) => {
const medal = i === 0 ? '&#129351;' : i === 1 ? '&#129352;' : i === 2 ? '&#129353;' : (i + 1);
const barPct = Math.max(0, Math.min(100, ((elo - 1000) / 400) * 100));
const barColor = elo >= 1250 ? 'var(--success)' : elo >= 1150 ? 'var(--accent)' : elo >= 1050 ? 'var(--warning)' : 'var(--error)';
html += `<tr>
<td style="text-align:center;">${medal}</td>
<td style="color:var(--accent);font-weight:600;">${name}</td>
<td style="font-weight:700;font-variant-numeric:tabular-nums;">${elo.toFixed(1)}</td>
<td style="width:30%;">
<div class="acc-bar-bg" style="height:6px;background:var(--surface-3);border-radius:3px;overflow:hidden;">
<div style="width:${barPct}%;height:100%;background:${barColor};border-radius:3px;"></div>
</div>
</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ═══════════════════════════════════════════
// Window Resize Handler (redraw charts)
// ═══════════════════════════════════════════
@@ -639,5 +920,8 @@ window.addEventListener('resize', () => {
renderWinRateChart(generationsData);
renderCompositionChart(generationsData);
}
if (eloHistoryData && eloHistoryData.length > 0) {
renderEloChart(eloHistoryData);
}
}, 200);
});