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:
1 parent
9bec84e997
commit
106ec716a5
5 files changed
+1963
-729
No files matched your search
+947
-729
File diff suppressed because it is too large.
Load diff
@@ -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); }
|
||||
}
|
||||
@@ -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">↻</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">🔄</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">📈</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">🏆</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">✅</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">▶</span> Start Distillation
|
||||
</button>
|
||||
<button id="btn-stop" class="btn btn-danger" onclick="stopDistillation()" disabled>
|
||||
<span class="btn-icon">■</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>
|
||||
@@ -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">⌛</span> Running...';
|
||||
stopBtn.disabled = false;
|
||||
} else {
|
||||
startBtn.disabled = false;
|
||||
startBtn.innerHTML = '<span class="btn-icon">▶</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">⌛</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">▶</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 ? '✅' : (g.status === 'running' ? '⌛' : '❌');
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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>',
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user