Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>SupplyMind Experiment Analysis</title> | |
| <style> | |
| :root { | |
| --bg:#f4f7fb; --ink:#172033; --muted:#667085; --panel:#ffffff; --line:#d8e0ea; | |
| --nav:#102236; --blue:#2563eb; --teal:#0f766e; --red:#c2410c; --amber:#b7791f; | |
| --green:#047857; --grid:#e8edf3; | |
| } | |
| * { box-sizing:border-box; } | |
| body { margin:0; font-family:Inter, Segoe UI, Arial, sans-serif; background:var(--bg); color:var(--ink); } | |
| header { background:var(--nav); color:white; padding:18px 22px; display:flex; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; } | |
| h1 { margin:0; font-size:22px; font-weight:750; letter-spacing:0; } | |
| h2 { margin:0; font-size:15px; font-weight:700; } | |
| main { max-width:1440px; margin:0 auto; padding:16px; display:grid; gap:14px; } | |
| section { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:14px; } | |
| button, a.button { height:34px; border-radius:6px; border:0; background:var(--blue); color:white; padding:8px 12px; cursor:pointer; text-decoration:none; font:inherit; display:inline-flex; align-items:center; } | |
| a.link { color:#dbeafe; text-decoration:none; } | |
| .muted { color:var(--muted); } | |
| .subtle { color:#cbd5e1; font-size:13px; margin-top:4px; } | |
| .status { display:flex; gap:9px; align-items:center; flex-wrap:wrap; } | |
| .pill { border-radius:999px; border:1px solid #bfdbfe; background:#eff6ff; color:#1d4ed8; padding:4px 10px; font-size:12px; white-space:nowrap; } | |
| .summary { display:grid; grid-template-columns:repeat(5, minmax(0, 1fr)); gap:10px; } | |
| .metric { border:1px solid var(--line); border-radius:8px; padding:11px 12px; background:#fbfdff; min-height:82px; } | |
| .metric span { display:block; color:var(--muted); font-size:12px; } | |
| .metric strong { display:block; margin-top:8px; font-size:24px; line-height:1; } | |
| .metric small { display:block; margin-top:7px; color:var(--muted); font-size:12px; } | |
| .verdicts { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; margin-bottom:12px; } | |
| .verdict { border:1px solid var(--line); border-left:4px solid var(--muted); border-radius:8px; padding:10px 12px; background:#fbfdff; } | |
| .verdict.good { border-left-color:var(--green); } | |
| .verdict.bad { border-left-color:var(--red); } | |
| .verdict.warn { border-left-color:var(--amber); } | |
| .verdict strong { display:block; font-size:15px; } | |
| .verdict span { display:block; color:var(--muted); margin-top:5px; font-size:12px; line-height:1.35; } | |
| .chart-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; } | |
| .wide { grid-column:1 / -1; } | |
| .chart-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; margin-bottom:8px; } | |
| .legend { display:flex; gap:12px; flex-wrap:wrap; font-size:12px; color:var(--muted); justify-content:flex-end; } | |
| .swatch { width:10px; height:10px; border-radius:2px; display:inline-block; margin-right:5px; } | |
| canvas { display:block; width:100%; height:320px; } | |
| .wide canvas { height:360px; } | |
| table { width:100%; border-collapse:collapse; font-size:13px; } | |
| th, td { padding:9px 8px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; } | |
| th { background:#f8fafc; color:#42536a; font-weight:700; } | |
| td.number { font-variant-numeric:tabular-nums; } | |
| .delta.good { color:var(--green); font-weight:700; } | |
| .delta.bad { color:var(--red); font-weight:700; } | |
| .empty { color:var(--muted); padding:24px; text-align:center; border:1px dashed var(--line); border-radius:8px; background:#fbfdff; } | |
| @media (max-width:1100px) { | |
| .summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } | |
| .chart-grid { grid-template-columns:1fr; } | |
| .wide { grid-column:auto; } | |
| .verdicts { grid-template-columns:1fr; } | |
| } | |
| @media (max-width:640px) { | |
| header { align-items:flex-start; } | |
| main { padding:10px; } | |
| .summary { grid-template-columns:1fr; } | |
| canvas, .wide canvas { height:280px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div> | |
| <h1>SupplyMind Experiment Analysis</h1> | |
| <div class="subtle">Training curves, reward quality, and held-out adapter comparisons</div> | |
| </div> | |
| <div class="status"> | |
| <a class="link" href="/v2/ui">Episode UI</a> | |
| <button id="refresh">Refresh</button> | |
| <span id="loaded" class="pill">loading</span> | |
| </div> | |
| </header> | |
| <main> | |
| <section class="summary" id="summary"></section> | |
| <div class="chart-grid"> | |
| <section class="wide"> | |
| <div class="chart-head"> | |
| <h2>Loss Over Step</h2> | |
| <div class="legend" id="lossLegend"></div> | |
| </div> | |
| <canvas id="lossPlot" width="1200" height="360"></canvas> | |
| </section> | |
| <section> | |
| <div class="chart-head"> | |
| <h2>Reward Over Step</h2> | |
| <div class="legend" id="rewardLegend"></div> | |
| </div> | |
| <canvas id="rewardPlot" width="760" height="320"></canvas> | |
| </section> | |
| <section> | |
| <div class="chart-head"> | |
| <h2>Invalid Payload / Action Batches</h2> | |
| <div class="legend" id="invalidLegend"></div> | |
| </div> | |
| <canvas id="invalidPlot" width="760" height="320"></canvas> | |
| </section> | |
| <section> | |
| <div class="chart-head"> | |
| <h2>Completion Length</h2> | |
| <div class="legend" id="lengthLegend"></div> | |
| </div> | |
| <canvas id="lengthPlot" width="760" height="320"></canvas> | |
| </section> | |
| <section> | |
| <div class="chart-head"> | |
| <h2>Clipped Ratio</h2> | |
| <div class="legend" id="clipLegend"></div> | |
| </div> | |
| <canvas id="clipPlot" width="760" height="320"></canvas> | |
| </section> | |
| </div> | |
| <section> | |
| <div class="chart-head"> | |
| <h2>Held-out Comparison</h2> | |
| <div class="muted" id="comparisonNote"></div> | |
| </div> | |
| <div class="verdicts" id="verdicts"></div> | |
| <canvas id="heldoutPlot" width="1200" height="320"></canvas> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Role</th> | |
| <th>Variant</th> | |
| <th>Base Global</th> | |
| <th>Variant Global</th> | |
| <th>Global Delta</th> | |
| <th>Base Role</th> | |
| <th>Variant Role</th> | |
| <th>Role Delta</th> | |
| <th>Invalids</th> | |
| <th>Raw Reward Delta</th> | |
| </tr> | |
| </thead> | |
| <tbody id="heldout"></tbody> | |
| </table> | |
| </section> | |
| </main> | |
| <script> | |
| const $ = id => document.getElementById(id); | |
| const trainingDefs = [ | |
| { key:"center_sft", role:"center", phase:"SFT", label:"center SFT", color:"#2563eb" }, | |
| { key:"center_grpo", role:"center", phase:"GRPO", label:"center GRPO", color:"#7c3aed" }, | |
| { key:"warehouse_sft", role:"warehouse", phase:"SFT", label:"warehouse SFT", color:"#0f766e" }, | |
| { key:"warehouse_grpo", role:"warehouse", phase:"GRPO", label:"warehouse GRPO", color:"#047857" }, | |
| { key:"center", role:"center", phase:null, label:"center", color:"#1d4ed8" }, | |
| { key:"warehouse", role:"warehouse", phase:null, label:"warehouse", color:"#0f766e" }, | |
| ]; | |
| const compareRoles = [ | |
| { key:"center", label:"center" }, | |
| { key:"warehouse", label:"warehouse" }, | |
| ]; | |
| const fmt = (value, digits = 3) => { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "pending"; | |
| if (Math.abs(n) >= 100) return n.toFixed(1); | |
| if (Math.abs(n) >= 10) return n.toFixed(2); | |
| return n.toFixed(digits); | |
| }; | |
| const roleScoreKey = role => role === "center" ? "mean_center_role_score" : "mean_warehouse_role_score"; | |
| const val = (row, keys) => { | |
| for (const key of keys) { | |
| if (row && row[key] !== undefined && row[key] !== null && Number.isFinite(Number(row[key]))) return Number(row[key]); | |
| } | |
| return null; | |
| }; | |
| async function load() { | |
| try { | |
| const res = await fetch("/v2/training-results?ts=" + Date.now()); | |
| if (!res.ok) throw new Error("HTTP " + res.status); | |
| const data = await res.json(); | |
| $("loaded").textContent = data.updated_at || data.status || "loaded"; | |
| render(data); | |
| } catch (error) { | |
| $("loaded").textContent = "failed"; | |
| $("summary").innerHTML = `<div class="empty" style="grid-column:1/-1">Could not load /v2/training-results: ${escapeHtml(error.message)}</div>`; | |
| } | |
| } | |
| function render(data) { | |
| const series = data.training_series || {}; | |
| const defs = activeTrainingDefs(series); | |
| renderSummary(data, series); | |
| renderLegend("lossLegend", defs); | |
| renderLegend("rewardLegend", defs); | |
| renderLegend("invalidLegend", [ | |
| ...defs.map(def => ({ label:def.label, color:def.color })), | |
| { label:"payload/action", color:"#c2410c" }, | |
| ]); | |
| renderLegend("lengthLegend", defs); | |
| renderLegend("clipLegend", defs); | |
| drawLineChart("lossPlot", lineSeries(series, defs, ["loss"]), "loss"); | |
| drawLineChart("rewardPlot", lineSeries(series, defs, ["reward", "rewards/reward_completions/mean"]), "reward"); | |
| drawInvalidChart("invalidPlot", invalidSeries(series, defs)); | |
| drawLineChart("lengthPlot", lineSeries(series, defs, ["completions/mean_length", "completion_length", "mean_completion_length"]), "tokens"); | |
| drawLineChart("clipPlot", lineSeries(series, defs, ["completions/clipped_ratio", "clipped_ratio"]), "ratio", { minY:0, maxY:1 }); | |
| renderHeldout(data.comparisons || []); | |
| } | |
| function renderSummary(data, series) { | |
| const summary = data.training_summary || {}; | |
| const defs = activeTrainingDefs(series); | |
| const allSteps = defs.flatMap(def => (series[def.key]?.steps || []).map(step => ({ key:def.key, step }))); | |
| const lastRows = defs.map(def => { | |
| const steps = series[def.key]?.steps || []; | |
| return { key:def.key, row:steps[steps.length - 1], summary:summary[def.key] || {} }; | |
| }); | |
| const bestReward = Math.max(...allSteps.map(item => val(item.step, ["reward", "rewards/reward_completions/mean"]) ?? -Infinity)); | |
| const meanClip = avg(lastRows.map(item => item.summary.mean_clipped_ratio)); | |
| const meanLength = avg(lastRows.map(item => item.summary.mean_completion_length)); | |
| const totalInvalidPayloads = defs.reduce((sum, def) => sum + (series[def.key]?.reward_batches || []).reduce((s, b) => s + Number(b.invalid_payloads || 0), 0), 0); | |
| const totalInvalidActions = defs.reduce((sum, def) => sum + (series[def.key]?.reward_batches || []).reduce((s, b) => s + Number(b.invalid_actions || 0), 0), 0); | |
| $("summary").innerHTML = [ | |
| metric("Status", data.status || "pending", data.summary || ""), | |
| metric("Steps", String(Math.max(0, ...lastRows.map(item => Number(item.summary.steps || item.row?.step || 0)))), data.hardware || ""), | |
| metric("Best reward", bestReward === -Infinity ? "pending" : fmt(bestReward), "highest training step reward"), | |
| metric("Mean length", fmt(meanLength, 1), "completion tokens"), | |
| metric("Invalid batches", `${totalInvalidPayloads} / ${totalInvalidActions}`, "payloads / actions"), | |
| metric("Mean clipped", fmt(meanClip, 3), "across role summaries"), | |
| ].join(""); | |
| } | |
| function metric(label, value, note) { | |
| return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(note || "")}</small></div>`; | |
| } | |
| function activeTrainingDefs(trainingSeries) { | |
| const defs = trainingDefs.filter(def => trainingSeries[def.key]); | |
| return defs.length ? defs : trainingDefs.filter(def => !def.phase); | |
| } | |
| function renderLegend(id, items) { | |
| $(id).innerHTML = items.length ? items.map(item => | |
| `<span><span class="swatch" style="background:${escapeHtml(item.color)}"></span>${escapeHtml(item.label)}</span>` | |
| ).join("") : ""; | |
| } | |
| function lineSeries(trainingSeries, defs, keys) { | |
| return defs.map(def => ({ | |
| label: def.label, | |
| color: def.color, | |
| points: (trainingSeries[def.key]?.steps || []) | |
| .map(row => ({ x:val(row, ["step", "global_step"]), y:val(row, keys) })) | |
| .filter(point => point.x !== null && point.y !== null), | |
| })); | |
| } | |
| function invalidSeries(trainingSeries, defs) { | |
| const out = []; | |
| for (const def of defs) { | |
| for (const kind of [ | |
| { key:"invalid_payloads", label:"payloads", alpha:0.86 }, | |
| { key:"invalid_actions", label:"actions", alpha:0.45 }, | |
| ]) { | |
| out.push({ | |
| label: `${def.label} ${kind.label}`, | |
| color: def.color, | |
| alpha: kind.alpha, | |
| points: (trainingSeries[def.key]?.reward_batches || []) | |
| .map(row => ({ x:val(row, ["call", "step"]), y:val(row, [kind.key]) })) | |
| .filter(point => point.x !== null && point.y !== null), | |
| }); | |
| } | |
| } | |
| return out; | |
| } | |
| function drawLineChart(id, series, yLabel, options = {}) { | |
| const canvas = $(id); | |
| const ctx = prepCanvas(canvas); | |
| const bounds = chartBounds(canvas); | |
| const points = series.flatMap(s => s.points); | |
| if (!points.length) return drawEmpty(ctx, canvas, "No training series found"); | |
| const minX = Math.min(...points.map(p => p.x)); | |
| const maxX = Math.max(...points.map(p => p.x)); | |
| let minY = options.minY ?? Math.min(...points.map(p => p.y)); | |
| let maxY = options.maxY ?? Math.max(...points.map(p => p.y)); | |
| if (minY === maxY) { minY -= 1; maxY += 1; } | |
| drawAxes(ctx, bounds, minX, maxX, minY, maxY, yLabel); | |
| for (const item of series) { | |
| drawPath(ctx, bounds, item.points, minX, maxX, minY, maxY, item.color); | |
| } | |
| } | |
| function drawInvalidChart(id, series) { | |
| const canvas = $(id); | |
| const ctx = prepCanvas(canvas); | |
| const bounds = chartBounds(canvas); | |
| const points = series.flatMap(s => s.points); | |
| if (!points.length) return drawEmpty(ctx, canvas, "No reward batch diagnostics found"); | |
| const minX = Math.min(...points.map(p => p.x)); | |
| const maxX = Math.max(...points.map(p => p.x)); | |
| const maxY = Math.max(1, ...points.map(p => p.y)); | |
| drawAxes(ctx, bounds, minX, maxX, 0, maxY, "count"); | |
| series.forEach((item, index) => { | |
| const offset = (index - (series.length - 1) / 2) * 5; | |
| drawBars(ctx, bounds, item.points, minX, maxX, 0, maxY, item.color, offset, item.alpha); | |
| }); | |
| } | |
| function prepCanvas(canvas) { | |
| const ratio = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = Math.max(320, Math.round(rect.width * ratio)); | |
| canvas.height = Math.max(220, Math.round(rect.height * ratio)); | |
| const ctx = canvas.getContext("2d"); | |
| ctx.setTransform(ratio, 0, 0, ratio, 0, 0); | |
| ctx.clearRect(0, 0, rect.width, rect.height); | |
| ctx.font = "12px Segoe UI, Arial"; | |
| ctx.lineCap = "round"; | |
| ctx.lineJoin = "round"; | |
| return ctx; | |
| } | |
| function chartBounds(canvas) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { left:52, top:18, right:rect.width - 18, bottom:rect.height - 34, width:rect.width, height:rect.height }; | |
| } | |
| function drawAxes(ctx, b, minX, maxX, minY, maxY, yLabel) { | |
| ctx.strokeStyle = "#d8e0ea"; | |
| ctx.lineWidth = 1; | |
| ctx.fillStyle = "#667085"; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = b.bottom - ((b.bottom - b.top) * i / 4); | |
| const value = minY + ((maxY - minY) * i / 4); | |
| ctx.beginPath(); | |
| ctx.moveTo(b.left, y); | |
| ctx.lineTo(b.right, y); | |
| ctx.stroke(); | |
| ctx.fillText(fmt(value), 8, y + 4); | |
| } | |
| ctx.fillText(yLabel, b.left, 11); | |
| ctx.fillText(String(minX), b.left, b.height - 12); | |
| ctx.fillText(String(maxX), b.right - 22, b.height - 12); | |
| } | |
| function drawPath(ctx, b, points, minX, maxX, minY, maxY, color) { | |
| if (!points.length) return; | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2.4; | |
| ctx.beginPath(); | |
| points.forEach((point, index) => { | |
| const x = scale(point.x, minX, maxX, b.left, b.right); | |
| const y = scale(point.y, minY, maxY, b.bottom, b.top); | |
| if (index === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| }); | |
| ctx.stroke(); | |
| ctx.fillStyle = color; | |
| points.forEach(point => { | |
| const x = scale(point.x, minX, maxX, b.left, b.right); | |
| const y = scale(point.y, minY, maxY, b.bottom, b.top); | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 2.7, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }); | |
| } | |
| function drawBars(ctx, b, points, minX, maxX, minY, maxY, color, offset, alpha = 0.82) { | |
| ctx.fillStyle = color; | |
| const width = Math.max(3, Math.min(10, (b.right - b.left) / Math.max(12, points.length * 4))); | |
| for (const point of points) { | |
| const x = scale(point.x, minX, maxX, b.left, b.right) + offset; | |
| const y = scale(point.y, minY, maxY, b.bottom, b.top); | |
| ctx.globalAlpha = alpha; | |
| ctx.fillRect(x - width / 2, y, width, b.bottom - y); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function renderHeldout(comparisons) { | |
| const rows = []; | |
| for (const role of compareRoles) { | |
| const base = comparisons.find(row => row.role === role.key && row.label === "base"); | |
| const variants = comparisons.filter(row => row.role === role.key && row.label !== "base"); | |
| for (const variant of variants) rows.push({ role:role.key, base, variant }); | |
| } | |
| $("comparisonNote").textContent = rows.length ? "base vs SFT / GRPO variants" : "no held-out eval rows"; | |
| renderVerdicts(comparisons); | |
| drawHeldoutBars("heldoutPlot", comparisons); | |
| $("heldout").innerHTML = rows.length ? rows.map(row => heldoutRow(row)).join("") : `<tr><td colspan="10"><div class="empty">No held-out comparisons available.</div></td></tr>`; | |
| } | |
| function renderVerdicts(comparisons) { | |
| const cards = compareRoles.map(role => { | |
| const base = comparisons.find(row => row.role === role.key && row.label === "base"); | |
| const sft = comparisons.find(row => row.role === role.key && row.label === "sft"); | |
| const grpo = comparisons.find(row => row.role === role.key && row.label === "grpo"); | |
| if (!base) return verdictCard(role.label, "pending", "No held-out base run yet.", "warn"); | |
| const roleKey = roleScoreKey(role.key); | |
| const sftDelta = sft ? Number(sft[roleKey]) - Number(base[roleKey]) : null; | |
| const grpoDelta = grpo && sft ? Number(grpo[roleKey]) - Number(sft[roleKey]) : null; | |
| const invalidNote = [sft, grpo].filter(Boolean).map(row => `${row.label}: ${Number(row.invalid_payloads || 0)} invalid payloads`).join(" · "); | |
| if (sftDelta === null) return verdictCard(role.label, "waiting for SFT eval", invalidNote || "SFT result not available yet.", "warn"); | |
| if (sftDelta > 0.01 && (grpoDelta === null || grpoDelta >= -0.005)) { | |
| return verdictCard(role.label, "format/scaffold improved", `SFT role score ${signed(sftDelta)} vs base. ${invalidNote}`, "good"); | |
| } | |
| if (grpoDelta !== null && grpoDelta > 0.01) { | |
| return verdictCard(role.label, "RL improved after SFT", `GRPO role score ${signed(grpoDelta)} vs SFT. ${invalidNote}`, "good"); | |
| } | |
| if (sftDelta <= 0 && (grpoDelta === null || grpoDelta <= 0)) { | |
| return verdictCard(role.label, "no held-out lift yet", `SFT ${signed(sftDelta)} vs base${grpoDelta === null ? "" : `, GRPO ${signed(grpoDelta)} vs SFT`}. ${invalidNote}`, "bad"); | |
| } | |
| return verdictCard(role.label, "mixed result", `SFT ${signed(sftDelta)} vs base${grpoDelta === null ? "" : `, GRPO ${signed(grpoDelta)} vs SFT`}. ${invalidNote}`, "warn"); | |
| }); | |
| $("verdicts").innerHTML = cards.join(""); | |
| } | |
| function verdictCard(role, title, note, status) { | |
| return `<div class="verdict ${escapeHtml(status)}"><strong>${escapeHtml(role)}: ${escapeHtml(title)}</strong><span>${escapeHtml(note)}</span></div>`; | |
| } | |
| function drawHeldoutBars(id, comparisons) { | |
| const canvas = $(id); | |
| const ctx = prepCanvas(canvas); | |
| const b = chartBounds(canvas); | |
| const groups = []; | |
| for (const role of compareRoles) { | |
| for (const metric of [ | |
| { key:"mean_global_score", label:"global" }, | |
| { key:roleScoreKey(role.key), label:"role" }, | |
| ]) { | |
| const rows = ["base", "sft", "grpo"].map(label => comparisons.find(row => row.role === role.key && row.label === label)); | |
| if (rows.some(Boolean)) groups.push({ role:role.key, metric:metric.label, rows, key:metric.key }); | |
| } | |
| } | |
| if (!groups.length) return drawEmpty(ctx, canvas, "No held-out comparison rows yet"); | |
| drawAxes(ctx, b, 0, groups.length, 0, 1, "score"); | |
| const colors = { base:"#64748b", sft:"#2563eb", grpo:"#7c3aed" }; | |
| const labels = ["base", "sft", "grpo"]; | |
| const groupWidth = (b.right - b.left) / groups.length; | |
| groups.forEach((group, groupIndex) => { | |
| labels.forEach((label, labelIndex) => { | |
| const row = group.rows[labelIndex]; | |
| if (!row) return; | |
| const value = Number(row[group.key]); | |
| if (!Number.isFinite(value)) return; | |
| const barW = Math.min(18, groupWidth / 5); | |
| const x = b.left + groupIndex * groupWidth + groupWidth / 2 + (labelIndex - 1) * (barW + 3); | |
| const y = scale(Math.max(0, Math.min(1, value)), 0, 1, b.bottom, b.top); | |
| ctx.fillStyle = colors[label]; | |
| ctx.fillRect(x - barW / 2, y, barW, b.bottom - y); | |
| }); | |
| ctx.fillStyle = "#667085"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText(`${group.role} ${group.metric}`, b.left + groupIndex * groupWidth + groupWidth / 2, b.height - 12); | |
| ctx.textAlign = "left"; | |
| }); | |
| renderLegend("comparisonNote", [ | |
| { label:"base", color:colors.base }, | |
| { label:"SFT", color:colors.sft }, | |
| { label:"GRPO", color:colors.grpo }, | |
| ]); | |
| } | |
| function heldoutRow({ role, base, variant }) { | |
| const roleKey = roleScoreKey(role); | |
| const globalDelta = Number(variant?.mean_global_score) - Number(base?.mean_global_score); | |
| const roleDelta = Number(variant?.[roleKey]) - Number(base?.[roleKey]); | |
| const rawDelta = Number(variant?.mean_raw_reward) - Number(base?.mean_raw_reward); | |
| const invalids = `${Number(variant?.invalid_payloads ?? 0)} / ${Number(variant?.invalid_actions ?? 0)}`; | |
| return ` | |
| <tr> | |
| <td>${escapeHtml(role)}</td> | |
| <td>${escapeHtml(variant?.label || "variant")}</td> | |
| <td class="number">${fmt(base?.mean_global_score)}</td> | |
| <td class="number">${fmt(variant?.mean_global_score)}</td> | |
| <td class="number ${deltaClass(globalDelta)}">${signed(globalDelta)}</td> | |
| <td class="number">${fmt(base?.[roleKey])}</td> | |
| <td class="number">${fmt(variant?.[roleKey])}</td> | |
| <td class="number ${deltaClass(roleDelta)}">${signed(roleDelta)}</td> | |
| <td class="number">${invalids}</td> | |
| <td class="number ${deltaClass(rawDelta)}">${signed(rawDelta, 2)}</td> | |
| </tr>`; | |
| } | |
| function deltaClass(value) { | |
| if (!Number.isFinite(value) || value === 0) return ""; | |
| return value > 0 ? "delta good" : "delta bad"; | |
| } | |
| function signed(value, digits = 3) { | |
| if (!Number.isFinite(value)) return "pending"; | |
| return (value >= 0 ? "+" : "") + fmt(value, digits); | |
| } | |
| function scale(value, minIn, maxIn, minOut, maxOut) { | |
| if (minIn === maxIn) return (minOut + maxOut) / 2; | |
| return minOut + ((value - minIn) / (maxIn - minIn)) * (maxOut - minOut); | |
| } | |
| function avg(values) { | |
| const nums = values.map(Number).filter(Number.isFinite); | |
| return nums.length ? nums.reduce((sum, n) => sum + n, 0) / nums.length : null; | |
| } | |
| function drawEmpty(ctx, canvas, message) { | |
| const rect = canvas.getBoundingClientRect(); | |
| ctx.fillStyle = "#667085"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText(message, rect.width / 2, rect.height / 2); | |
| ctx.textAlign = "left"; | |
| } | |
| function escapeHtml(value) { | |
| return String(value ?? "").replace(/[&<>"']/g, char => ({ | |
| "&":"&", "<":"<", ">":">", '"':""", "'":"'" | |
| })[char]); | |
| } | |
| $("refresh").onclick = load; | |
| window.addEventListener("resize", () => load()); | |
| load(); | |
| </script> | |
| </body> | |
| </html> | |