metrics page with svg plots and filters

This commit is contained in:
Michael Pilosov 2026-04-21 21:02:58 -06:00
parent 3280410405
commit b2be3d0835
5 changed files with 727 additions and 1 deletions

View File

@ -667,6 +667,34 @@ async def submit(request: Request) -> HTMLResponse:
) )
def _scan_metrics() -> List[Dict[str, Any]]:
"""Read every `*.metrics.json` in FIGS_DIR and return them as a list."""
out: List[Dict[str, Any]] = []
for p in sorted(FIGS_DIR.glob("*.metrics.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
data = json.loads(p.read_text())
except (OSError, json.JSONDecodeError):
continue
data["filename"] = p.name
data["embedding_file"] = p.name.replace(".metrics.json", ".html")
out.append(data)
return out
@app.get("/metrics", response_class=HTMLResponse)
async def metrics_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(
request,
"metrics.html",
{"prefect_api": PREFECT_API},
)
@app.get("/metrics.json")
async def metrics_json() -> JSONResponse:
return JSONResponse(_scan_metrics())
@app.get("/health") @app.get("/health")
async def health() -> JSONResponse: async def health() -> JSONResponse:
async with httpx.AsyncClient(timeout=3.0) as client: async with httpx.AsyncClient(timeout=3.0) as client:

339
app/web/static/metrics.js Normal file
View File

@ -0,0 +1,339 @@
// metrics.js — client-side filtering + hand-rolled SVG line charts for the
// metrics page. No deps. Reads /metrics.json (list of sidecar-JSON contents),
// filters by dataset + algorithm, and overlays every matching run on three
// plots: frame-to-frame travel, vs-initial travel, kNN retention.
const SVG_NS = "http://www.w3.org/2000/svg";
// Qualitative palette — muted, matches the notebook aesthetic. Cycles if
// there are more runs than colours.
const PALETTE = [
"#1f4e5f", // accent teal
"#8a3a2a", // rust
"#a77a2c", // warm amber
"#3a6f3f", // olive
"#5d4a7b", // slate purple
"#7a5c4b", // brown
"#2b5d7a", // deeper blue
"#6b6b3e", // moss
];
const state = {
raw: [],
dataset: "all",
algorithm: "all",
stat: "mean",
// Runs the user has clicked in the legend to isolate. Empty = show all.
selected: new Set(),
};
const shortGen = (p) => (p || "").split(".").pop();
const shortEmb = (p) => (p || "").split(".").pop();
function runLabel(r) {
const m = r.meta || {};
return `${shortEmb(m.embedder)} · ${shortGen(m.generator_path)} · N${m.num_points} T${m.num_timesteps} J${m.jitter_scale} s${m.seed}`;
}
function runKey(r) {
return r.filename;
}
async function init() {
const resp = await fetch("/metrics.json");
state.raw = await resp.json();
populateFilters();
wireEvents();
render();
}
function populateFilters() {
const datasets = new Set();
const algos = new Set();
for (const r of state.raw) {
datasets.add(shortGen(r.meta?.generator_path));
algos.add(shortEmb(r.meta?.embedder));
}
const dsSel = document.getElementById("flt-dataset");
const algoSel = document.getElementById("flt-algo");
for (const d of [...datasets].filter(Boolean).sort()) {
const o = document.createElement("option");
o.value = d; o.textContent = d;
dsSel.appendChild(o);
}
for (const a of [...algos].filter(Boolean).sort()) {
const o = document.createElement("option");
o.value = a; o.textContent = a;
algoSel.appendChild(o);
}
document.getElementById("total-count").textContent = state.raw.length;
}
function wireEvents() {
document.getElementById("flt-dataset").addEventListener("change", (e) => {
state.dataset = e.target.value; render();
});
document.getElementById("flt-algo").addEventListener("change", (e) => {
state.algorithm = e.target.value; render();
});
document.querySelectorAll('input[name="stat"]').forEach((el) => {
el.addEventListener("change", (e) => {
if (e.target.checked) { state.stat = e.target.value; render(); }
});
});
}
function matchesFilters(r) {
const m = r.meta || {};
if (state.dataset !== "all" && shortGen(m.generator_path) !== state.dataset) return false;
if (state.algorithm !== "all" && shortEmb(m.embedder) !== state.algorithm) return false;
return true;
}
function filtered() {
// Legend lists every run passing the dropdown filters. If the user has
// clicked any legend rows, plots restrict to those; legend still shows
// the full filtered set so you can toggle more in/out.
return state.raw.filter(matchesFilters);
}
function plotted(runs) {
if (state.selected.size === 0) return runs;
return runs.filter((r) => state.selected.has(runKey(r)));
}
function render() {
const runs = filtered();
document.getElementById("match-count").textContent = runs.length;
const empty = document.getElementById("empty");
empty.hidden = state.raw.length !== 0;
// Prune selections that no longer match the filters so the plots don't
// stay empty when the user narrows the dataset/algo dropdowns.
const visibleKeys = new Set(runs.map(runKey));
for (const k of state.selected) {
if (!visibleKeys.has(k)) state.selected.delete(k);
}
// Stable colour assignment per legend row (across the full filtered set),
// so toggling selection doesn't shuffle colours.
const colored = runs.map((r, i) => ({
run: r,
color: PALETTE[i % PALETTE.length],
selected: state.selected.has(runKey(r)),
}));
const forPlot = state.selected.size === 0 ? colored : colored.filter((c) => c.selected);
renderLineChart(
document.getElementById("plot-ff"),
forPlot.map(({ run, color }) => ({
label: runLabel(run),
color,
points: (run.travel?.frame_to_frame || []).map((row) => [row.t, row[state.stat]]),
})),
{ yLabel: "distance", xLabel: "t" }
);
renderLineChart(
document.getElementById("plot-vi"),
forPlot.map(({ run, color }) => ({
label: runLabel(run),
color,
points: (run.travel?.vs_initial || []).map((row) => [row.t, row[state.stat]]),
})),
{ yLabel: "distance", xLabel: "t" }
);
renderLineChart(
document.getElementById("plot-knn"),
forPlot.map(({ run, color }) => ({
label: runLabel(run),
color,
points: (run.knn_retention || []).map((row) => [row.t, row.mean]),
})),
{ yLabel: "retention", xLabel: "t", yMin: 0, yMax: 1 }
);
renderLegend(colored);
}
function renderLegend(colored) {
const el = document.getElementById("legend");
el.innerHTML = "";
if (!colored.length) return;
const hasSelection = state.selected.size > 0;
if (hasSelection) {
const clear = document.createElement("button");
clear.type = "button";
clear.className = "legend-clear";
clear.textContent = `clear selection (${state.selected.size})`;
clear.addEventListener("click", () => {
state.selected.clear();
render();
});
el.appendChild(clear);
}
for (const { run, color, selected } of colored) {
const row = document.createElement("button");
row.type = "button";
row.className = "legend-row" + (selected ? " is-selected" : "") +
(hasSelection && !selected ? " is-dim" : "");
row.setAttribute("aria-pressed", selected ? "true" : "false");
row.innerHTML = `
<span class="swatch" style="background:${color}"></span>
<span class="lbl">${runLabel(run)}</span>
<span class="fn">${run.filename}</span>
`;
row.addEventListener("click", () => {
const k = runKey(run);
if (state.selected.has(k)) state.selected.delete(k);
else state.selected.add(k);
render();
});
el.appendChild(row);
}
}
// ---------- SVG line chart -------------------------------------------------
function renderLineChart(container, series, opts = {}) {
container.innerHTML = "";
const W = 720, H = 240;
// left gutter fits (from outer to inner): y-label column (~16px) +
// breathing (~10px) + widest tick label (~30px) + tick gap (6px).
const PAD = { top: 12, right: 16, bottom: 28, left: 62 };
const allPts = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
const hasData = allPts.length > 0;
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("role", "img");
svg.classList.add("chart");
if (!hasData) {
const t = text(W / 2, H / 2, "no data", "empty");
t.setAttribute("text-anchor", "middle");
svg.appendChild(t);
container.appendChild(svg);
return;
}
const xs = allPts.map((p) => p[0]);
const ys = allPts.map((p) => p[1]);
const xMin = Math.min(...xs), xMax = Math.max(...xs);
let yMin = opts.yMin !== undefined ? opts.yMin : Math.min(0, Math.min(...ys));
let yMax = opts.yMax !== undefined ? opts.yMax : Math.max(...ys);
if (yMax === yMin) yMax = yMin + 1;
const xPx = (x) => PAD.left + ((x - xMin) / Math.max(1, xMax - xMin)) * (W - PAD.left - PAD.right);
const yPx = (y) => H - PAD.bottom - ((y - yMin) / (yMax - yMin)) * (H - PAD.top - PAD.bottom);
// gridlines (4 horizontal ticks)
const nTicks = 4;
for (let i = 0; i <= nTicks; i++) {
const yVal = yMin + ((yMax - yMin) * i) / nTicks;
const y = yPx(yVal);
const gl = document.createElementNS(SVG_NS, "line");
gl.setAttribute("x1", PAD.left);
gl.setAttribute("x2", W - PAD.right);
gl.setAttribute("y1", y);
gl.setAttribute("y2", y);
gl.classList.add("grid");
svg.appendChild(gl);
const lbl = text(PAD.left - 6, y + 3.5, fmtTick(yVal), "axis");
lbl.setAttribute("text-anchor", "end");
svg.appendChild(lbl);
}
// x-axis ticks (at integer t, max ~8 labels)
const xSpan = Math.max(1, xMax - xMin);
const everyX = Math.max(1, Math.ceil(xSpan / 8));
for (let x = Math.ceil(xMin); x <= xMax; x += everyX) {
const px = xPx(x);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", px); tick.setAttribute("x2", px);
tick.setAttribute("y1", H - PAD.bottom); tick.setAttribute("y2", H - PAD.bottom + 3);
tick.classList.add("axis-tick");
svg.appendChild(tick);
const lbl = text(px, H - PAD.bottom + 14, String(x), "axis");
lbl.setAttribute("text-anchor", "middle");
svg.appendChild(lbl);
}
// axis lines
const xAxis = document.createElementNS(SVG_NS, "line");
xAxis.setAttribute("x1", PAD.left); xAxis.setAttribute("x2", W - PAD.right);
xAxis.setAttribute("y1", H - PAD.bottom); xAxis.setAttribute("y2", H - PAD.bottom);
xAxis.classList.add("axis"); svg.appendChild(xAxis);
// y/x labels
if (opts.yLabel) {
const cx = 10;
const cy = PAD.top + (H - PAD.top - PAD.bottom) / 2;
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("transform", `translate(${cx} ${cy}) rotate(-90)`);
const lbl = document.createElementNS(SVG_NS, "text");
lbl.setAttribute("text-anchor", "middle");
lbl.setAttribute("dominant-baseline", "middle");
lbl.classList.add("axis-label");
lbl.textContent = opts.yLabel;
g.appendChild(lbl);
svg.appendChild(g);
}
if (opts.xLabel) {
const lbl = text(W - PAD.right, H - 6, opts.xLabel, "axis-label");
lbl.setAttribute("text-anchor", "end");
svg.appendChild(lbl);
}
// lines
for (const s of series) {
const pts = s.points.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (!pts.length) continue;
const d = pts.map((p, i) => `${i === 0 ? "M" : "L"}${xPx(p[0]).toFixed(2)},${yPx(p[1]).toFixed(2)}`).join(" ");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
path.setAttribute("stroke", s.color);
path.setAttribute("fill", "none");
path.setAttribute("stroke-width", "1.5");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("stroke-linecap", "round");
path.classList.add("series");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = s.label;
path.appendChild(title);
svg.appendChild(path);
}
container.appendChild(svg);
}
function text(x, y, str, cls) {
const el = document.createElementNS(SVG_NS, "text");
el.setAttribute("x", x);
el.setAttribute("y", y);
if (cls) el.classList.add(cls);
el.textContent = str;
return el;
}
function fmtTick(v) {
const abs = Math.abs(v);
if (abs === 0) return "0";
if (abs < 0.01) return v.toExponential(1);
if (abs < 1) return v.toFixed(3);
if (abs < 10) return v.toFixed(2);
if (abs < 100) return v.toFixed(1);
return v.toFixed(0);
}
init();

View File

@ -870,3 +870,266 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
.dataset-picker > summary { padding: 0.9rem 1.2rem; } .dataset-picker > summary { padding: 0.9rem 1.2rem; }
.dataset-picker .picker-body { padding: 0.4rem 1.2rem 1.4rem; } .dataset-picker .picker-body { padding: 0.4rem 1.2rem 1.4rem; }
} }
/* ---------- metrics page ----------------------------------------------- */
.masthead-link {
font-family: var(--mono);
color: var(--accent);
border-bottom: 1px solid transparent;
}
.masthead-link:hover { border-bottom-color: var(--accent); }
.masthead .nav-link {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--accent);
border-bottom: 1px solid transparent;
margin-left: 0.8rem;
}
.masthead .nav-link:hover { border-bottom-color: var(--accent); }
.metrics-page {
max-width: 1440px;
margin: 0 auto;
padding: 1.4rem 2.2rem 2rem;
}
.filter-bar {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: end;
gap: 1.4rem 1.8rem;
padding: 0.9rem 0 1rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 1.4rem;
}
.filter-group { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
.filter-group .ctl-label {
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mute);
}
.filter-group select {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: transparent;
border: 0;
border-bottom: 1px solid var(--rule-2);
padding: 3px 18px 4px 2px;
outline: none;
min-width: 12ch;
}
.filter-group select:focus {
border-bottom-color: var(--accent);
background: var(--accent-tint);
}
.filter-group.stat-group { min-width: 18rem; }
.filter-group .segmented.count-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: center;
border-bottom: 1px solid var(--rule-2);
padding-bottom: 3px;
}
.filter-group .segmented label {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--mute);
cursor: pointer;
padding: 3px 2px 4px;
transition: color 120ms ease;
user-select: none;
font-variant-numeric: tabular-nums;
position: relative;
text-align: center;
}
.filter-group .segmented label > span {
display: inline-block;
padding: 0 2px 1px;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease;
}
.filter-group .segmented label:hover { color: var(--ink); }
.filter-group .segmented label:has(input:checked) { color: var(--accent); }
.filter-group .segmented label:has(input:checked) > span {
border-bottom-color: var(--accent);
}
.filter-group .segmented input[type="radio"] {
position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0;
}
.filter-count {
font-family: var(--mono);
font-size: 0.8rem;
color: var(--ink);
text-align: right;
font-variant-numeric: tabular-nums;
}
.filter-count .muted { color: var(--mute); }
.plots {
display: grid;
gap: 1.6rem;
margin-bottom: 1.4rem;
}
@media (min-width: 1100px) {
.plots { grid-template-columns: 1fr 1fr; }
.plots .plot:nth-child(3) { grid-column: 1 / -1; }
}
.plot { margin: 0; }
.plot figcaption {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 0.4rem;
border-bottom: 1px solid var(--rule);
padding-bottom: 0.3rem;
}
.plot .plot-title {
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
}
.plot .plot-sub {
font-family: var(--serif);
font-style: italic;
font-size: 0.76rem;
color: var(--mute);
}
.plot-area svg.chart {
width: 100%;
height: auto;
display: block;
background: var(--panel);
}
.plot-area svg .grid { stroke: var(--rule); stroke-width: 1; }
.plot-area svg .axis { stroke: var(--rule-2); stroke-width: 1; }
.plot-area svg .axis-tick { stroke: var(--rule-2); stroke-width: 1; }
.plot-area svg text.axis {
font-family: var(--mono);
font-size: 10px;
fill: var(--mute);
}
.plot-area svg text.axis-label {
font-family: var(--mono);
font-size: 10px;
fill: var(--faint);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.plot-area svg text.empty {
font-family: var(--serif);
font-style: italic;
font-size: 13px;
fill: var(--faint);
}
.plot-area svg path.series {
opacity: 0.88;
transition: opacity 120ms ease, stroke-width 120ms ease;
}
.plot-area svg path.series:hover { opacity: 1; stroke-width: 2.25; }
.legend {
display: grid;
gap: 0.35rem;
padding: 0.8rem 0 0.4rem;
border-top: 1px solid var(--rule);
}
.legend .legend-row {
display: grid;
grid-template-columns: 10px 1fr auto;
gap: 0.75rem;
align-items: baseline;
padding: 0.22rem 0.4rem;
border: 0;
background: transparent;
color: var(--ink);
cursor: pointer;
text-align: left;
font: inherit;
border-radius: 2px;
transition: background 100ms ease, opacity 120ms ease;
}
.legend .legend-row:hover { background: var(--accent-tint); }
.legend .legend-row.is-selected {
background: var(--accent-tint);
box-shadow: inset 2px 0 0 var(--accent);
}
.legend .legend-row.is-selected .lbl { color: var(--accent); font-weight: 600; }
.legend .legend-row.is-dim { opacity: 0.42; }
.legend .legend-row.is-dim:hover { opacity: 0.85; }
.legend .legend-row:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.legend-clear {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
background: transparent;
border: 1px solid var(--rule-2);
padding: 0.25rem 0.6rem;
border-radius: 2px;
cursor: pointer;
justify-self: start;
margin-bottom: 0.4rem;
transition: background 120ms ease, border-color 120ms ease;
}
.legend-clear:hover { background: var(--accent-tint); border-color: var(--accent); }
.legend .swatch {
width: 10px; height: 10px;
border-radius: 2px;
display: inline-block;
align-self: center;
}
.legend .lbl {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legend .fn {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--faint);
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 30ch;
}
.metrics-page .empty {
border: 1px dashed var(--rule-2);
padding: 1.4rem;
text-align: center;
color: var(--mute);
font-family: var(--serif);
font-style: italic;
font-size: 0.92rem;
margin-top: 1rem;
}
@media (max-width: 780px) {
.metrics-page { padding: 1rem 1.1rem 1.4rem; }
.filter-bar {
grid-template-columns: 1fr 1fr;
gap: 0.9rem 1.1rem;
}
.filter-group.stat-group { grid-column: 1 / -1; min-width: 0; }
.filter-count { grid-column: 1 / -1; text-align: left; }
.legend .legend-row {
grid-template-columns: 10px 1fr;
}
.legend .fn { display: none; }
}

View File

@ -24,7 +24,8 @@
</div> </div>
<div class="meta"> <div class="meta">
<span class="dot {% if not deployment_id %}bad{% endif %}"></span> <span class="dot {% if not deployment_id %}bad{% endif %}"></span>
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}<br/> {% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}
<a href="/metrics" class="nav-link">metrics &rarr;</a><br/>
<span style="color:var(--faint)">{{ prefect_api }}</span> <span style="color:var(--faint)">{{ prefect_api }}</span>
</div> </div>
</header> </header>

View File

@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>metrics — embedding notebook</title>
<link rel="stylesheet" href="/static/style.css?v=3" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
</head>
<body>
<header class="masthead">
<div>
<h1 class="title">embedding notebook <em>&mdash; stability metrics</em></h1>
</div>
<div class="meta">
<a href="/" class="masthead-link">&larr; runs</a><br/>
<span style="color:var(--faint)">{{ prefect_api }}</span>
</div>
</header>
<section class="metrics-page">
<div class="filter-bar">
<div class="filter-group">
<span class="ctl-label">dataset</span>
<select id="flt-dataset" aria-label="filter by dataset">
<option value="all">all</option>
</select>
</div>
<div class="filter-group">
<span class="ctl-label">algorithm</span>
<select id="flt-algo" aria-label="filter by algorithm">
<option value="all">all</option>
</select>
</div>
<div class="filter-group stat-group">
<span class="ctl-label">travel stat</span>
<div class="segmented count-4" role="radiogroup" aria-label="travel stat">
<label><input type="radio" name="stat" value="mean" checked><span>mean</span></label>
<label><input type="radio" name="stat" value="median"><span>median</span></label>
<label><input type="radio" name="stat" value="p95"><span>p95</span></label>
<label><input type="radio" name="stat" value="max"><span>max</span></label>
</div>
</div>
<div class="filter-count">
<span id="match-count">0</span>&thinsp;/&thinsp;<span id="total-count">0</span> <span class="muted">runs</span>
</div>
</div>
<div class="plots">
<figure class="plot">
<figcaption>
<span class="plot-title">frame-to-frame travel</span>
<span class="plot-sub">&thinsp;y(t) y(t1)&thinsp;&nbsp;·&nbsp; output 2-D space</span>
</figcaption>
<div id="plot-ff" class="plot-area"></div>
</figure>
<figure class="plot">
<figcaption>
<span class="plot-title">vs-initial travel</span>
<span class="plot-sub">&thinsp;y(t) y(0)&thinsp;&nbsp;·&nbsp; drift from first timestep</span>
</figcaption>
<div id="plot-vi" class="plot-area"></div>
</figure>
<figure class="plot">
<figcaption>
<span class="plot-title">kNN retention</span>
<span class="plot-sub">fraction of input-space k-NN preserved in 2-D (higher = more faithful)</span>
</figcaption>
<div id="plot-knn" class="plot-area"></div>
</figure>
</div>
<div class="legend" id="legend"></div>
<div id="empty" class="empty" hidden>
No metrics to show. Dispatch a run from <a href="/">the form</a> — sidecar JSONs appear in <code>figs/</code> after the flow completes.
</div>
</section>
<footer class="colophon">
<span><span class="k">web</span> · metrics · port 8001</span>
</footer>
<script type="module" src="/static/metrics.js?v=3"></script>
</body>
</html>