Phase 5: Distillation UI Integration & Monitoring (#69)

- Add distillation API endpoints to routes/ml.py:
  - POST /api/ml/distill/start — start closed-loop distillation pipeline
  - GET  /api/ml/distill/status — poll loop status and generation data
  - POST /api/ml/distill/stop — graceful stop after current generation
  - GET  /api/ml/distill/history — load persisted generation history

- Add 'Distill' nav entry in ui/nav.js with loop icon

- Create ui/distillation.html:
  - Status overview cards (status, generation, best win rate, gate passes)
  - Control panel with hyperparameter inputs for supervised, PPO, and
    quality gate settings
  - Start/Stop buttons with progress bar and phase badge
  - Win rate trend chart (canvas-based, no dependencies)
  - Data composition stacked bar chart (Forge vs PPO per generation)
  - Generation history table with status, metrics, and gate results

- Create ui/distillation.js:
  - Polling loop for real-time status updates during distillation
  - Canvas chart rendering for win rate trends and data composition
  - History table rendering with color-coded status tags
  - Responsive resize handling for charts

- Create ui/distillation.css:
  - Page-specific styles following training.css patterns
  - Chart container, history table, and responsive grid styles

Closes #69
This commit is contained in:
rgriffith committed 2026-03-22 00:18:43 +00:00
1 parent 9bec84e997
commit 106ec716a5
5 files changed
+1963 -729

No files matched your search

+947 -729
View File
File diff suppressed because it is too large. Load diff
+194
View File
@@ -0,0 +1,194 @@
/* ══════════════════════════════════════════════════════════════
Commander AI Lab — Distillation Loop Dashboard
Page-specific styles only; shared tokens & components in global.css
══════════════════════════════════════════════════════════════ */
/* ── Legacy variable aliases (used by distillation.js inline styles) ── */
:root {
--lab-bg: var(--bg);
--lab-surface: var(--surface-1);
--lab-border: var(--border);
--lab-accent: var(--accent);
--lab-accent-dim: var(--accent-dim);
--lab-text: var(--text);
--lab-text-dim: var(--text-secondary);
--lab-success: var(--success);
--lab-success-dim:var(--success-dim);
--lab-warning: var(--warning);
--lab-warning-dim:var(--warning-dim);
--lab-error: var(--error);
--lab-error-dim: var(--error-dim);
--lab-purple: var(--purple);
--lab-purple-dim: var(--purple-dim);
}
/* ── Status Cards (extend global .stat-card) ───────────────── */
.status-grid {
grid-template-columns: repeat(4, 1fr);
}
.status-card {
text-align: center;
}
.status-card.active {
border-color: var(--accent);
}
.status-card.success {
border-color: var(--success);
}
.status-card.error-state {
border-color: var(--error);
}
.card-icon {
font-size: 24px;
margin-bottom: var(--sp-2);
}
/* ── Panel heading with bottom border ──────────────────────── */
.panel > .panel-header {
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--border);
}
/* ── Training Controls Layout ──────────────────────────────── */
.train-controls {
padding-top: var(--sp-4);
}
.param-grid {
margin-bottom: var(--sp-3);
}
.distill-bottom-params {
grid-template-columns: repeat(2, 1fr);
}
.distill-actions {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
.btn-icon {
font-size: 14px;
}
/* ── Progress Sections ─────────────────────────────────────── */
.train-progress {
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.train-progress.hidden {
display: none;
}
/* ── Phase Badges ──────────────────────────────────────────── */
.phase-badge {
display: inline-block;
padding: 3px 10px;
border-radius: var(--radius-md);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.phase-badge.running { background: var(--accent-dim); color: var(--accent); }
.phase-badge.starting { background: var(--accent-dim); color: var(--accent); }
.phase-badge.done { background: var(--success-dim); color: var(--success); }
.phase-badge.error { background: var(--error-dim); color: var(--error); }
.phase-badge.idle { background: var(--surface-3); color: var(--text-secondary); }
#progress-message {
font-size: 13px;
color: var(--text);
}
/* ── Progress Bar overrides ────────────────────────────────── */
.progress-bar-container {
margin-bottom: var(--sp-2);
}
.progress-bar {
transition: width 0.5s var(--ease);
}
.progress-bar.done {
background: var(--success);
}
.progress-bar.error {
background: var(--error);
}
/* ── Chart Containers ──────────────────────────────────────── */
.chart-container {
position: relative;
min-height: 200px;
}
.chart-container canvas {
width: 100%;
}
.chart-container .empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-6);
}
/* ── History Table ─────────────────────────────────────────── */
.distill-history-table {
font-size: 13px;
}
.distill-history-table .tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
white-space: nowrap;
text-transform: capitalize;
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 768px) {
.status-grid {
grid-template-columns: repeat(2, 1fr);
}
.param-grid {
grid-template-columns: repeat(2, 1fr);
}
.distill-bottom-params {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.status-grid {
grid-template-columns: 1fr;
}
.param-grid {
grid-template-columns: 1fr;
}
}
/* Refresh button spin animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Commander AI Lab — Distillation Loop</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="distillation.css" />
</head>
<body>
<script src="nav.js"></script>
<div class="page">
<div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
<div>
<h1>Closed-Loop Distillation</h1>
<p>Iterative supervised → PPO → quality gate → merge pipeline</p>
</div>
<button id="btn-refresh" class="btn btn-secondary" onclick="refreshAll()" title="Refresh status">
<span class="btn-icon" id="refresh-icon">&#8635;</span> Refresh
</button>
</div>
<!-- Status Overview Cards -->
<div class="status-grid stat-grid">
<div class="status-card stat-card" id="card-status">
<div class="card-icon">&#128260;</div>
<div class="card-label stat-label">Status</div>
<div class="card-value stat-value" id="val-status">Idle</div>
<div class="card-sub stat-sub" id="sub-status">not running</div>
</div>
<div class="status-card stat-card" id="card-generation">
<div class="card-icon">&#128200;</div>
<div class="card-label stat-label">Generation</div>
<div class="card-value stat-value" id="val-generation">--</div>
<div class="card-sub stat-sub" id="sub-generation">of --</div>
</div>
<div class="status-card stat-card" id="card-winrate">
<div class="card-icon">&#127942;</div>
<div class="card-label stat-label">Best Win Rate</div>
<div class="card-value stat-value" id="val-winrate">--</div>
<div class="card-sub stat-sub" id="sub-winrate">across all generations</div>
</div>
<div class="status-card stat-card" id="card-passed">
<div class="card-icon">&#9989;</div>
<div class="card-label stat-label">Passed</div>
<div class="card-value stat-value" id="val-passed">--</div>
<div class="card-sub stat-sub" id="sub-passed">quality gate passes</div>
</div>
</div>
<!-- Distillation Controls Panel -->
<section class="panel" id="control-panel">
<div class="panel-header">
<h2>Distillation Controls</h2>
</div>
<div class="train-controls">
<div class="param-grid form-row">
<div class="form-group">
<label class="form-label" for="param-iterations">Max Generations</label>
<input type="number" id="param-iterations" class="form-input" value="10" min="1" max="100" />
</div>
<div class="form-group">
<label class="form-label" for="param-conv-window">Convergence Window</label>
<input type="number" id="param-conv-window" class="form-input" value="3" min="2" max="10" />
</div>
<div class="form-group">
<label class="form-label" for="param-sup-epochs">Supervised Epochs</label>
<input type="number" id="param-sup-epochs" class="form-input" value="30" min="5" max="200" />
</div>
<div class="form-group">
<label class="form-label" for="param-sup-lr">Supervised LR</label>
<input type="number" id="param-sup-lr" class="form-input" value="0.001" step="0.0001" min="0.00001" max="0.1" />
</div>
</div>
<div class="param-grid form-row">
<div class="form-group">
<label class="form-label" for="param-ppo-iters">PPO Iterations</label>
<input type="number" id="param-ppo-iters" class="form-input" value="50" min="10" max="500" />
</div>
<div class="form-group">
<label class="form-label" for="param-ppo-episodes">PPO Episodes/Iter</label>
<input type="number" id="param-ppo-episodes" class="form-input" value="64" min="8" max="512" step="8" />
</div>
<div class="form-group">
<label class="form-label" for="param-ppo-lr">PPO Learning Rate</label>
<input type="number" id="param-ppo-lr" class="form-input" value="0.0003" step="0.0001" min="0.00001" max="0.01" />
</div>
<div class="form-group">
<label class="form-label" for="param-min-wr">Min Win Rate Gate</label>
<input type="number" id="param-min-wr" class="form-input" value="0.30" step="0.05" min="0.1" max="0.9" />
</div>
</div>
<div class="param-grid form-row distill-bottom-params">
<div class="form-group">
<label class="form-label" for="param-opponent">Opponent</label>
<select id="param-opponent" class="form-select">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
<option value="self">Self-Play</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="param-playstyle">Playstyle</label>
<select id="param-playstyle" class="form-select">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
<option value="control">Control</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
<div class="train-actions distill-actions">
<button id="btn-start" class="btn btn-primary" onclick="startDistillation()">
<span class="btn-icon">&#9654;</span> Start Distillation
</button>
<button id="btn-stop" class="btn btn-danger" onclick="stopDistillation()" disabled>
<span class="btn-icon">&#9632;</span> Stop
</button>
</div>
</div>
<!-- Progress Section (hidden when idle) -->
<div id="distill-progress" class="train-progress hidden">
<div class="progress-header">
<span id="progress-phase" class="phase-badge">IDLE</span>
<span id="progress-message">Waiting...</span>
</div>
<div class="progress-bar-container progress-track">
<div class="progress-bar progress-fill" id="progress-bar"></div>
</div>
<div class="progress-detail" id="progress-detail"></div>
</div>
</section>
<!-- Win Rate Trend Chart -->
<section class="panel" id="chart-panel">
<div class="panel-header">
<h2>Win Rate Trend</h2>
</div>
<div id="winrate-chart" class="chart-container">
<canvas id="winrate-canvas"></canvas>
<div id="winrate-empty" class="empty-state">
<p>No generation data yet. Start a distillation run to see win rate trends.</p>
</div>
</div>
</section>
<!-- Data Composition Chart -->
<section class="panel" id="composition-panel">
<div class="panel-header">
<h2>Data Composition</h2>
</div>
<div id="composition-chart" class="chart-container">
<canvas id="composition-canvas"></canvas>
<div id="composition-empty" class="empty-state">
<p>No generation data yet.</p>
</div>
</div>
</section>
<!-- Generation History Table -->
<section class="panel" id="history-panel">
<div class="panel-header">
<h2>Generation History</h2>
</div>
<div id="history-table-container">
<div class="empty-state" id="history-empty">No generations completed yet.</div>
</div>
</section>
</div>
<script src="distillation.js"></script>
</body>
</html>
+643
View File
@@ -0,0 +1,643 @@
/**
* Commander AI Lab — Distillation Loop Dashboard
* Manages distillation pipeline controls, status polling, and chart rendering.
*/
const API_BASE = ''; // Same origin
// ═══════════════════════════════════════════
// State
// ═══════════════════════════════════════════
let pollingInterval = null;
let isRunning = false;
let generationsData = [];
// ═══════════════════════════════════════════
// Init
// ═══════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
refreshAll();
});
async function refreshAll() {
const btn = document.getElementById('btn-refresh');
const icon = document.getElementById('refresh-icon');
btn.disabled = true;
icon.style.display = 'inline-block';
icon.style.animation = 'spin 0.8s linear infinite';
try {
await refreshStatus();
await loadHistory();
} finally {
btn.disabled = false;
icon.style.animation = '';
}
}
// ═══════════════════════════════════════════
// Status Polling
// ═══════════════════════════════════════════
async function refreshStatus() {
try {
const res = await fetch(`${API_BASE}/api/ml/distill/status`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
updateUI(data);
if (data.running && !pollingInterval) {
startPolling();
}
} catch (err) {
console.error('Failed to fetch distillation status:', err);
}
}
function startPolling() {
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(pollStatus, 3000);
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
async function pollStatus() {
try {
const res = await fetch(`${API_BASE}/api/ml/distill/status`);
const data = await res.json();
updateUI(data);
if (!data.running && data.phase !== 'idle') {
stopPolling();
isRunning = false;
updateButtons(false);
// Refresh history when done
loadHistory();
}
} catch (err) {
console.error('Poll failed:', err);
}
}
// ═══════════════════════════════════════════
// UI Updates
// ═══════════════════════════════════════════
function updateUI(data) {
// Status card
const statusVal = document.getElementById('val-status');
const statusSub = document.getElementById('sub-status');
const statusCard = document.getElementById('card-status');
const phase = data.phase || 'idle';
statusCard.className = 'status-card stat-card';
if (phase === 'running' || phase === 'starting') {
statusVal.textContent = 'Running';
statusSub.textContent = data.current_step || 'in progress';
statusCard.classList.add('active');
} else if (phase === 'done') {
statusVal.textContent = 'Complete';
statusSub.textContent = 'finished';
statusCard.classList.add('success');
} else if (phase === 'error') {
statusVal.textContent = 'Error';
statusSub.textContent = data.error ? data.error.substring(0, 40) : 'failed';
statusCard.classList.add('error-state');
} else {
statusVal.textContent = 'Idle';
statusSub.textContent = 'not running';
}
// Generation card
document.getElementById('val-generation').textContent =
data.generation > 0 ? data.generation : '--';
document.getElementById('sub-generation').textContent =
data.max_iterations > 0 ? `of ${data.max_iterations}` : 'of --';
if (data.generation > 0) {
document.getElementById('card-generation').classList.add('active');
}
// Win rate and passed cards from generations data
const gens = data.generations || [];
if (gens.length > 0) {
const bestWR = Math.max(...gens.map(g => g.ppo_best_win_rate || 0));
document.getElementById('val-winrate').textContent =
bestWR > 0 ? (bestWR * 100).toFixed(1) + '%' : '--';
if (bestWR > 0) {
document.getElementById('card-winrate').classList.add('active');
}
const passed = gens.filter(g => g.status === 'passed').length;
document.getElementById('val-passed').textContent = passed;
document.getElementById('sub-passed').textContent =
`of ${gens.length} generations`;
if (passed > 0) {
document.getElementById('card-passed').classList.add('success');
}
generationsData = gens;
renderWinRateChart(gens);
renderCompositionChart(gens);
renderHistoryTable(gens);
}
// Progress section
const progressEl = document.getElementById('distill-progress');
if (phase !== 'idle') {
progressEl.classList.remove('hidden');
updateProgress(data);
}
// Button states
updateButtons(data.running);
isRunning = data.running;
}
function updateProgress(data) {
const phaseBadge = document.getElementById('progress-phase');
const message = document.getElementById('progress-message');
const bar = document.getElementById('progress-bar');
const detail = document.getElementById('progress-detail');
const phase = data.phase || 'idle';
phaseBadge.textContent = phase.toUpperCase();
phaseBadge.className = 'phase-badge ' + phase;
message.textContent = data.message || '';
bar.className = 'progress-bar progress-fill';
if (phase === 'done') {
bar.style.width = '100%';
bar.classList.add('done');
} else if (phase === 'error') {
bar.style.width = '100%';
bar.classList.add('error');
} else if (data.max_iterations > 0 && data.generation > 0) {
const pct = Math.min(100, (data.generation / data.max_iterations) * 100);
bar.style.width = pct + '%';
} else if (phase === 'starting') {
bar.style.width = '3%';
}
if (phase === 'running' && data.generation > 0) {
detail.textContent = `Generation ${data.generation} / ${data.max_iterations}`;
} else if (phase === 'done' && data.result) {
const r = data.result;
detail.textContent = `${r.generations_run} generations | Best WR: ${(r.best_win_rate * 100).toFixed(1)}% | ${r.converged ? 'Converged' : 'Max reached'} | ${r.total_time_s}s`;
} else if (phase === 'error') {
detail.textContent = data.error || 'Unknown error';
detail.style.color = 'var(--lab-error, var(--error))';
} else {
detail.textContent = '';
detail.style.color = '';
}
}
function updateButtons(running) {
const startBtn = document.getElementById('btn-start');
const stopBtn = document.getElementById('btn-stop');
if (running) {
startBtn.disabled = true;
startBtn.innerHTML = '<span class="btn-icon">&#8987;</span> Running...';
stopBtn.disabled = false;
} else {
startBtn.disabled = false;
startBtn.innerHTML = '<span class="btn-icon">&#9654;</span> Start Distillation';
stopBtn.disabled = true;
}
}
// ═══════════════════════════════════════════
// Controls
// ═══════════════════════════════════════════
async function startDistillation() {
const body = {
maxIterations: parseInt(document.getElementById('param-iterations').value) || 10,
convergenceWindow: parseInt(document.getElementById('param-conv-window').value) || 3,
convergenceThreshold: 0.01,
supervisedEpochs: parseInt(document.getElementById('param-sup-epochs').value) || 30,
supervisedLr: parseFloat(document.getElementById('param-sup-lr').value) || 1e-3,
ppoIterations: parseInt(document.getElementById('param-ppo-iters').value) || 50,
ppoEpisodesPerIter: parseInt(document.getElementById('param-ppo-episodes').value) || 64,
ppoLr: parseFloat(document.getElementById('param-ppo-lr').value) || 3e-4,
opponent: document.getElementById('param-opponent').value,
playstyle: document.getElementById('param-playstyle').value,
minWinRate: parseFloat(document.getElementById('param-min-wr').value) || 0.30,
};
const btn = document.getElementById('btn-start');
btn.disabled = true;
btn.innerHTML = '<span class="btn-icon">&#8987;</span> Starting...';
try {
const res = await fetch(`${API_BASE}/api/ml/distill/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || `HTTP ${res.status}`);
}
isRunning = true;
document.getElementById('distill-progress').classList.remove('hidden');
updateButtons(true);
startPolling();
} catch (err) {
alert('Failed to start distillation: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<span class="btn-icon">&#9654;</span> Start Distillation';
}
}
async function stopDistillation() {
try {
const res = await fetch(`${API_BASE}/api/ml/distill/stop`, { method: 'POST' });
const data = await res.json();
if (data.status === 'stopping') {
document.getElementById('progress-message').textContent =
'Stop signal sent — finishing current generation...';
}
} catch (err) {
alert('Failed to stop: ' + err.message);
}
}
// ═══════════════════════════════════════════
// History Loading
// ═══════════════════════════════════════════
async function loadHistory() {
try {
const res = await fetch(`${API_BASE}/api/ml/distill/history`);
if (!res.ok) return;
const data = await res.json();
if (data.generations && data.generations.length > 0) {
generationsData = data.generations;
renderWinRateChart(data.generations);
renderCompositionChart(data.generations);
renderHistoryTable(data.generations);
// Update summary cards from history
const bestWR = Math.max(...data.generations.map(g => g.ppo_best_win_rate || 0));
if (bestWR > 0) {
document.getElementById('val-winrate').textContent = (bestWR * 100).toFixed(1) + '%';
document.getElementById('card-winrate').classList.add('active');
}
const passed = data.generations.filter(g => g.status === 'passed').length;
if (passed > 0 || data.generations.length > 0) {
document.getElementById('val-passed').textContent = passed;
document.getElementById('sub-passed').textContent =
`of ${data.generations.length} generations`;
}
}
} catch (err) {
console.error('Failed to load history:', err);
}
}
// ═══════════════════════════════════════════
// Chart Rendering (Canvas-based, no deps)
// ═══════════════════════════════════════════
function renderWinRateChart(generations) {
const canvas = document.getElementById('winrate-canvas');
const empty = document.getElementById('winrate-empty');
if (!generations || generations.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 = 260;
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;
const winRates = generations.map(g => (g.ppo_best_win_rate || 0) * 100);
const maxY = Math.max(100, Math.ceil(Math.max(...winRates) / 10) * 10);
const minY = 0;
// 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();
// Y axis labels
const val = maxY - (maxY - minY) * (i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(val.toFixed(0) + '%', pad.left - 8, y + 4);
}
// X axis labels
ctx.textAlign = 'center';
ctx.fillStyle = '#8b90a0';
for (let i = 0; i < generations.length; i++) {
const x = pad.left + (cw / Math.max(1, generations.length - 1)) * i;
ctx.fillText('Gen ' + generations[i].generation, x, h - 8);
}
if (generations.length < 2) {
// Single point
const x = pad.left + cw / 2;
const y = pad.top + ch - (winRates[0] / maxY) * ch;
ctx.fillStyle = '#5b9ef0';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
return;
}
// Line
ctx.strokeStyle = '#5b9ef0';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
for (let i = 0; i < winRates.length; i++) {
const x = pad.left + (cw / (winRates.length - 1)) * i;
const y = pad.top + ch - (winRates[i] / maxY) * ch;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Area fill
ctx.globalAlpha = 0.1;
ctx.fillStyle = '#5b9ef0';
ctx.beginPath();
for (let i = 0; i < winRates.length; i++) {
const x = pad.left + (cw / (winRates.length - 1)) * i;
const y = pad.top + ch - (winRates[i] / maxY) * ch;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(pad.left + cw, pad.top + ch);
ctx.lineTo(pad.left, pad.top + ch);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
// Points
for (let i = 0; i < winRates.length; i++) {
const x = pad.left + (cw / (winRates.length - 1)) * i;
const y = pad.top + ch - (winRates[i] / maxY) * ch;
const gen = generations[i];
// Dot color based on status
ctx.fillStyle = gen.status === 'passed' ? '#4ade80' :
gen.status === 'failed' ? '#f87171' : '#5b9ef0';
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
// White center
ctx.fillStyle = '#12141c';
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
// Legend
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 15;
ctx.fillStyle = '#4ade80';
ctx.beginPath();
ctx.arc(legendX, legendY, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#8b90a0';
ctx.textAlign = 'left';
ctx.fillText('Passed', legendX + 10, legendY + 4);
ctx.fillStyle = '#f87171';
ctx.beginPath();
ctx.arc(legendX + 70, legendY, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#8b90a0';
ctx.fillText('Failed', legendX + 80, legendY + 4);
}
function renderCompositionChart(generations) {
const canvas = document.getElementById('composition-canvas');
const empty = document.getElementById('composition-empty');
if (!generations || generations.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 = 220;
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;
const barWidth = Math.min(40, cw / generations.length - 8);
// Find max dataset size for scaling
const maxDataset = Math.max(...generations.map(g => (g.forge_samples || 0) + (g.ppo_samples || 0)), 1);
// Y axis
ctx.strokeStyle = '#222533';
ctx.lineWidth = 1;
const ySteps = 4;
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 = maxDataset - (maxDataset * i / ySteps);
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(formatNumber(val), pad.left - 8, y + 4);
}
// Bars
for (let i = 0; i < generations.length; i++) {
const g = generations[i];
const forge = g.forge_samples || 0;
const ppo = g.ppo_samples || 0;
const total = forge + ppo;
const x = pad.left + (cw / generations.length) * i + (cw / generations.length - barWidth) / 2;
// Forge bar (bottom)
const forgeH = (forge / maxDataset) * ch;
ctx.fillStyle = '#5b9ef0';
ctx.beginPath();
roundedRect(ctx, x, pad.top + ch - forgeH, barWidth, forgeH, 3);
ctx.fill();
// PPO bar (stacked on top)
const ppoH = (ppo / maxDataset) * ch;
ctx.fillStyle = '#a78bfa';
ctx.beginPath();
roundedRect(ctx, x, pad.top + ch - forgeH - ppoH, barWidth, ppoH, 3);
ctx.fill();
// X label
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('G' + g.generation, x + barWidth / 2, h - 8);
}
// Legend
ctx.font = '11px Inter, sans-serif';
const legendX = pad.left + 10;
const legendY = pad.top + 12;
ctx.fillStyle = '#5b9ef0';
ctx.fillRect(legendX, legendY - 5, 10, 10);
ctx.fillStyle = '#8b90a0';
ctx.textAlign = 'left';
ctx.fillText('Forge', legendX + 16, legendY + 4);
ctx.fillStyle = '#a78bfa';
ctx.fillRect(legendX + 65, legendY - 5, 10, 10);
ctx.fillStyle = '#8b90a0';
ctx.fillText('PPO', legendX + 81, legendY + 4);
}
function roundedRect(ctx, x, y, w, h, r) {
if (h <= 0) return;
r = Math.min(r, h / 2, w / 2);
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
}
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return Math.round(n).toString();
}
// ═══════════════════════════════════════════
// History Table
// ═══════════════════════════════════════════
function renderHistoryTable(generations) {
const container = document.getElementById('history-table-container');
const empty = document.getElementById('history-empty');
if (!generations || generations.length === 0) {
container.innerHTML = '';
container.appendChild(empty);
empty.style.display = '';
return;
}
let html = '<table class="data-table distill-history-table">';
html += '<thead><tr>';
html += '<th>Gen</th>';
html += '<th>Status</th>';
html += '<th>Win Rate</th>';
html += '<th>Supervised Acc</th>';
html += '<th>PPO Decisions</th>';
html += '<th>Gate</th>';
html += '<th>Retries</th>';
html += '</tr></thead><tbody>';
for (const g of generations) {
const statusClass = g.status === 'passed' ? 'tag-green' :
g.status === 'failed' ? 'tag-red' :
g.status === 'running' ? 'tag-blue' : 'tag-yellow';
const wrPct = g.ppo_best_win_rate > 0 ? (g.ppo_best_win_rate * 100).toFixed(1) + '%' : '--';
const supAcc = g.supervised_val_acc > 0 ? (g.supervised_val_acc * 100).toFixed(1) + '%' : '--';
const decisions = g.ppo_decisions_exported > 0 ? g.ppo_decisions_exported.toLocaleString() : '--';
const gateIcon = g.gate_accepted ? '&#9989;' : (g.status === 'running' ? '&#8987;' : '&#10060;');
html += `<tr>
<td style="font-weight:600;color:var(--accent);">${g.generation}</td>
<td><span class="tag ${statusClass}">${g.status}</span></td>
<td style="font-variant-numeric:tabular-nums;">${wrPct}</td>
<td style="font-variant-numeric:tabular-nums;">${supAcc}</td>
<td style="font-variant-numeric:tabular-nums;">${decisions}</td>
<td style="text-align:center;">${gateIcon}</td>
<td style="text-align:center;color:var(--text-secondary);">${g.retries || 0}</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
}
// ═══════════════════════════════════════════
// Window Resize Handler (redraw charts)
// ═══════════════════════════════════════════
let resizeTimeout = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (generationsData.length > 0) {
renderWinRateChart(generationsData);
renderCompositionChart(generationsData);
}
}, 200);
});
+2
View File
@@ -11,6 +11,7 @@
{ href: 'simulator.html', label: 'Simulator', icon: 'play' },
{ href: 'coach.html', label: 'Coach', icon: 'brain' },
{ href: 'training.html', label: 'Training', icon: 'chart' },
{ href: 'distillation.html', label: 'Distill', icon: 'loop' },
];
const ICONS = {
@@ -20,6 +21,7 @@
sparkle: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
brain: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 017 7c0 2.38-1.19 4.47-3 5.74V17a2 2 0 01-2 2h-4a2 2 0 01-2-2v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 017-7z"/><path d="M9 21h6M10 17v4M14 17v4"/></svg>',
loop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 2l4 4-4 4"/><path d="M3 11v-1a4 4 0 014-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v1a4 4 0 01-4 4H3"/></svg>',
chart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>',
};