A build-it-yourself guide. Today the live chart shows exactly one run's train/val curve at a time. This lets you pick several past runs and draw their curves on the same axes — the whole point of a visual tool is seeing hyperparameter choices side by side, not one at a time.
This is almost entirely a frontend change — the backend already
gives you everything. GET /api/runs lists every persisted run, and
GET /api/run/<id> returns that run's full metrics array (the
{epoch, train, val} rows from metrics.jsonl). Comparison is just:
fetch several runs' metrics and draw them together.
runs/<id>/.drawChart in static/graph.js is hardwired
to the two-series CHART_SERIES (val/train) of a single run. Generalize it to draw a
list of named series.drawChart (~line 784)Extract the axis-fitting + line-drawing into a core that accepts an array of
{label, color, points:[{epoch, value}]}. Keep the current live view as one
caller (val + train of the active run); add a second caller for comparison. The min/max
auto-fit already loops over series — widen it to span every series passed in.
// core: draw any number of series on shared auto-fit axes
function drawSeries(seriesList) {
const cv = document.getElementById("chart");
const all = seriesList.flatMap(s => s.points);
if (all.length < 2) { cv.style.display = "none"; return; }
// ...existing ymin/ymax/X/Y fitting, but over `all` instead of CHART_SERIES...
seriesList.forEach(s => { /* existing line + latest-dot draw, per series */ });
// legend: one swatch per series, label = s.label
}
drawChart(metrics) becomes a thin adapter that builds the val/train series and
calls drawSeries. pollRun keeps calling drawChart
unchanged, so you don't disturb live training.refreshRuns / loadRunThe runs <select> is single-pick. Add a lightweight multi-select
(checkboxes, or multiple on the select) plus a "Compare" action. Reuse the run
list you already fetch in refreshRuns.
async function compareRuns(runIds) {
const palette = ["#d8a24a","#5aa9e6","#3fb950","#c98bdb","#c8663c"];
const series = await Promise.all(runIds.map(async (id, i) => {
const j = await (await fetch("/api/run/" + id)).json();
const points = (j.metrics || [])
.filter(m => typeof m.epoch === "number" && typeof m.val === "number")
.map(m => ({ epoch: m.epoch, value: m.val }));
return { label: id, color: palette[i % palette.length], points };
}));
stopPolling(); // comparison is a static view, not a live one
drawSeries(series);
}
The single-run legend prints val/train. For comparison the legend
should name the runs. Truncate long run ids (they're <dataset>_<hex8>)
so the legend fits, and keep the loss · epoch axis hint.
Generate a couple of runs, then compare them.
modulearn demo # in the editor: Train twice with different lr
# then select both runs and hit Compare
# expect: two validation curves on one chart, distinct colors, legend by run id
curl -s localhost:8000/api/run/<id> | python -m json.tool should show the
same metrics rows you see plotted. If a curve is missing, it's a filter/keys issue
in the adapter, not the chart.git add modulearn/static/graph.js
git commit -m "Add run comparison overlay to the live chart"
git push
gh run list --branch main --limit 1 # expect: success
Check the
{area:"features", … title:"Run comparison overlay"} card's box in
docs/roadmap.html (shipped state is stored per-browser).