finephrase / app /src /content /embeds /seed-variance.html
joelniklaus's picture
joelniklaus HF Staff
added seed variance experiment
ac01179
Raw
History Blame Contribute Delete
27.7 kB
<div class="d3-seed-variance"></div>
<style>
.d3-seed-variance { position: relative; }
.d3-seed-variance .controls {
display: flex; gap: 16px; align-items: flex-start; justify-content: flex-start;
flex-wrap: wrap; margin: 10px 0 0 0;
}
.d3-seed-variance .controls .control-group {
display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
flex: 0 1 auto; min-width: 0;
}
.d3-seed-variance .controls select { max-width: 100%; }
.d3-seed-variance .controls label {
font-size: 12px; font-weight: 700; color: var(--text-color);
}
.d3-seed-variance .controls select {
font-size: 12px; padding: 8px 28px 8px 10px;
border: 1px solid var(--border-color); border-radius: 8px;
background: var(--surface-bg); color: var(--text-color); cursor: pointer;
}
.d3-seed-variance .legend {
display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
margin: 12px 0 0 0;
}
.d3-seed-variance .legend .legend-title {
font-size: 12px; font-weight: 700; color: var(--text-color);
}
.d3-seed-variance .legend .items { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.d3-seed-variance .legend .item {
display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
color: var(--text-color); white-space: nowrap;
}
.d3-seed-variance .legend .swatch {
width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color);
}
.d3-seed-variance .d3-tooltip {
position: absolute; top: 0; left: 0;
transform: translate(-9999px, -9999px);
pointer-events: none; padding: 8px 12px; border-radius: 8px;
font-size: 12px; line-height: 1.45;
border: 1px solid var(--border-color);
background: var(--surface-bg); color: var(--text-color);
box-shadow: 0 4px 24px rgba(0,0,0,.18);
opacity: 0; transition: opacity .12s ease; min-width: 190px;
}
.d3-seed-variance .d3-tooltip .row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.d3-seed-variance .d3-tooltip .row .name { display: inline-flex; align-items: center; gap: 6px; }
.d3-seed-variance .d3-tooltip .row .swatch { width: 10px; height: 10px; border-radius: 2px; }
.d3-seed-variance .axes path,
.d3-seed-variance .axes line { stroke: var(--axis-color); }
.d3-seed-variance .axes text { fill: var(--tick-color); font-size: 12px; }
.d3-seed-variance .grid line { stroke: var(--grid-color); }
.d3-seed-variance .x-label,
.d3-seed-variance .y-label { fill: var(--text-color); font-size: 13px; }
.d3-seed-variance .gap-label { fill: var(--muted-color); font-size: 11px; font-weight: 600; }
</style>
<script>
(() => {
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
s.addEventListener('load', onReady, { once: true });
if (window.d3) onReady();
};
const bootstrap = () => {
const scriptEl = document.currentScript;
let container = scriptEl ? scriptEl.previousElementSibling : null;
if (!(container && container.classList && container.classList.contains('d3-seed-variance'))) {
const cs = Array.from(document.querySelectorAll('.d3-seed-variance'))
.filter(el => !(el.dataset && el.dataset.mounted === 'true'));
container = cs[cs.length - 1] || null;
}
if (!container) return;
if (container.dataset) {
if (container.dataset.mounted === 'true') return;
container.dataset.mounted = 'true';
}
container.style.position = container.style.position || 'relative';
// Two semantic series colors aligned with the rest of the blog: the table
// mix uses the FinePhrase orange, the raw baseline uses the baseline gray.
const CONFIGS = {
mix: { label: 'FineWeb-Edu-HQ + table (50/50)', color: '#EBA937' },
baseline: { label: 'FineWeb-Edu-HQ (baseline)', color: '#8b8b8b' },
};
const SIZES = ['0.5b', '1.7b', '2.9b', '6.2b'];
const sizeLabel = (s) => s.toUpperCase();
// Variance-source colors for the decomposition view; residual is muted to
// read as "unexplained". First two come from the shared palette.
const srcDefs = () => {
const pal = (window.ColorPalettes && window.ColorPalettes.getColors)
? window.ColorPalettes.getColors('categorical', 4)
: ['#4e79a7', '#59a14f', '#e15759', '#bab0ac'];
return [
{ key: 'seed', label: 'Model-init seed', color: pal[0] },
{ key: 'data', label: 'Data order', color: pal[1] },
{ key: 'resid', label: 'Residual / interaction', color: 'var(--muted-color)' },
];
};
// metric key -> { label, col (CSV column), group }. Labels, optgroups, and
// ordering mirror the main experiments chart (d3-benchmark-comparison.html)
// exactly; macro is the default.
const METRICS = {
agg_score_macro: { label: 'Aggregate Score (Macro)', col: 'agg_score_macro', group: 'Aggregate Scores' },
agg_score_micro: { label: 'Aggregate Score (Micro)', col: 'agg_score_micro', group: 'Aggregate Scores' },
agg_score_RC: { label: 'Reading Comprehension', col: 'agg_score_RC', group: 'Aggregate Scores' },
agg_score_GK: { label: 'General Knowledge', col: 'agg_score_GK', group: 'Aggregate Scores' },
agg_score_NLU: { label: 'Natural Language Understanding', col: 'agg_score_NLU', group: 'Aggregate Scores' },
agg_score_MATH: { label: 'Math', col: 'agg_score_MATH', group: 'Aggregate Scores' },
agg_score_TABLE: { label: 'Table Understanding', col: 'agg_score_TABLE', group: 'Aggregate Scores' },
agg_score_RES: { label: 'Reasoning', col: 'agg_score_RES', group: 'Aggregate Scores' },
arc: { label: 'ARC-Easy', col: 'lighteval|arc_cf:easy|3/prob_norm_token', group: 'Individual Benchmarks' },
drop: { label: 'DROP', col: 'lighteval|drop|3/prob_norm_token', group: 'Individual Benchmarks' },
gsm8k: { label: 'GSM8K', col: 'lighteval|gsm8k|3/prob_norm_token', group: 'Individual Benchmarks' },
hellaswag: { label: 'HellaSwag', col: 'lighteval|hellaswag_cf|3/prob_norm_token', group: 'Individual Benchmarks' },
openbookqa: { label: 'OpenBookQA', col: 'lighteval|openbookqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' },
piqa: { label: 'PIQA', col: 'lighteval|piqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' },
squad: { label: 'SQuAD v2', col: 'lighteval|squad_v2|3/prob_norm_token', group: 'Individual Benchmarks' },
treb_qa: { label: 'TriviaQA', col: 'lighteval|treb_qa|3/prob_norm_token', group: 'Individual Benchmarks' },
wikitablequestions: { label: 'WikiTableQuestions', col: 'lighteval|wikitablequestions|3/prob_norm_token', group: 'Individual Benchmarks' },
winogrande: { label: 'Winogrande', col: 'lighteval|winogrande_cf|3/prob_norm_token', group: 'Individual Benchmarks' },
xcsqa: { label: 'XCSQA', col: 'lighteval|xcsqa_cf|3/prob_norm_token', group: 'Individual Benchmarks' },
mmlu: { label: 'MMLU Redux', col: 'lighteval|mmlu_redux_cf:_average|3/prob_norm_token', group: 'Individual Benchmarks' },
};
let currentMetric = 'agg_score_macro';
let currentView = 'dots'; // 'dots' | 'fan' | 'decomp'
let currentSize = '1.7b'; // only used by the fan view
// ---- Controls (below the chart, per authoring directives) -------------
const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block');
const gRoot = svg.append('g');
const controls = document.createElement('div');
controls.className = 'controls';
const mkSelect = (labelText, options, value) => {
const group = document.createElement('div');
group.className = 'control-group';
const label = document.createElement('label');
const id = `sv-${labelText.replace(/\s+/g, '-').toLowerCase()}-${Math.random().toString(36).slice(2, 7)}`;
label.htmlFor = id; label.textContent = labelText;
const select = document.createElement('select');
select.id = id;
options.forEach(o => {
const opt = document.createElement('option');
opt.value = o.value; opt.textContent = o.label;
if (o.value === value) opt.selected = true;
select.appendChild(opt);
});
group.appendChild(label); group.appendChild(select);
return { group, select };
};
const viewCtl = mkSelect('View', [
{ value: 'dots', label: 'By scale' },
{ value: 'fan', label: 'Over training' },
{ value: 'decomp', label: 'Variance source' },
], currentView);
const sizeCtl = mkSelect('Model size', SIZES.map(s => ({ value: s, label: sizeLabel(s) })), currentSize);
// Metric select with optgroups; visible label text must be exactly "Metric".
const metricGroup = document.createElement('div');
metricGroup.className = 'control-group';
const metricLabel = document.createElement('label');
const metricId = `sv-metric-${Math.random().toString(36).slice(2, 7)}`;
metricLabel.htmlFor = metricId; metricLabel.textContent = 'Metric';
const metricSelect = document.createElement('select');
metricSelect.id = metricId;
['Aggregate Scores', 'Individual Benchmarks'].forEach(grp => {
const og = document.createElement('optgroup');
og.label = grp;
Object.entries(METRICS).filter(([, m]) => m.group === grp).forEach(([key, m]) => {
const opt = document.createElement('option');
opt.value = key; opt.textContent = m.label;
if (key === currentMetric) opt.selected = true;
og.appendChild(opt);
});
metricSelect.appendChild(og);
});
metricGroup.appendChild(metricLabel); metricGroup.appendChild(metricSelect);
controls.appendChild(viewCtl.group);
controls.appendChild(sizeCtl.group);
controls.appendChild(metricGroup);
container.appendChild(controls);
// ---- Legend (rebuilt per view) ---------------------------------------
const legend = document.createElement('div');
legend.className = 'legend';
legend.innerHTML = '<div class="legend-title">Legend</div>';
const legendItems = document.createElement('div');
legendItems.className = 'items';
legend.appendChild(legendItems);
container.appendChild(legend);
const setLegend = (entries) => {
legendItems.innerHTML = '';
entries.forEach(c => {
const item = document.createElement('span');
item.className = 'item';
item.innerHTML = `<span class="swatch" style="background:${c.color}"></span>${c.label}`;
legendItems.appendChild(item);
});
};
// ---- Tooltip ---------------------------------------------------------
const tip = document.createElement('div');
tip.className = 'd3-tooltip';
const tipInner = document.createElement('div');
tip.appendChild(tipInner);
container.appendChild(tip);
const showTip = (html, event) => {
tipInner.innerHTML = html;
tip.style.opacity = '1';
const cr = container.getBoundingClientRect();
const mx = event.clientX - cr.left, my = event.clientY - cr.top;
const tw = tip.offsetWidth;
const x = mx + tw + 16 > cr.width ? mx - tw - 12 : mx + 12;
tip.style.transform = `translate(${x}px, ${Math.max(0, my - 40)}px)`;
};
const hideTip = () => { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px,-9999px)'; };
// ---- Data ------------------------------------------------------------
let mountEl = container;
while (mountEl && !mountEl.getAttribute?.('data-datafiles')) mountEl = mountEl.parentElement;
let providedData = null;
try {
const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
if (attr && attr.trim()) providedData = attr.trim().startsWith('[') ? JSON.parse(attr)[0] : attr.trim();
} catch (_) {}
const ensurePrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
const CSV_PATHS = providedData
? [ensurePrefix(providedData)]
: ['/data/benchmark-results.csv', './assets/data/benchmark-results.csv', '../assets/data/benchmark-results.csv'];
const fetchFirstAvailable = async (paths) => {
for (const p of paths) {
try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.text(); } catch (_) {}
}
throw new Error('benchmark-results.csv not found');
};
// Derive config + trained-student size from the run name. The size suffix
// (-0.5b/-2.9b/-6.2b) only appears at the very end; 1.7b has no suffix and
// must not be confused with the "1.7b" inside the rephraser name.
const parseRun = (runname) => {
const config = runname.startsWith('mix-') ? 'mix' : 'baseline';
// The size suffix sits on the run stem, before the "--data-seed=" part;
// 1.7b has no suffix and must not match the "1.7b" inside the rephraser name.
const stem = runname.split('--data-seed=')[0];
let size = '1.7b';
for (const s of ['0.5b', '2.9b', '6.2b']) if (stem.endsWith('-' + s)) size = s;
return { config, size };
};
let records = []; // { config, size, step, seed, raw }
let maxStep = 10000;
const val = (rec, key) => +rec.raw[METRICS[key].col];
const fmt = (v, d = 3) => (v == null || Number.isNaN(v)) ? '-' : v.toFixed(d);
// ---- Rendering -------------------------------------------------------
const margin = { top: 18, right: 26, bottom: 54, left: 66 };
let iw = 0, ih = 0;
function frame() {
const width = container.clientWidth || 820;
const height = Math.max(340, Math.round(width / 2.1));
svg.attr('width', width).attr('height', height);
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
iw = width - margin.left - margin.right;
ih = height - margin.top - margin.bottom;
gRoot.selectAll('*').remove();
}
function drawAxes(x, y, xTicksCfg, xLabel) {
const axesG = gRoot.append('g').attr('class', 'axes');
axesG.append('g').attr('transform', `translate(0,${ih})`).call(xTicksCfg(d3.axisBottom(x)));
axesG.append('g')
.call(d3.axisLeft(y).ticks(6).tickSize(-iw))
.call(g => g.selectAll('.tick line').attr('stroke', 'var(--grid-color)'))
.call(g => g.select('.domain').remove());
gRoot.append('text').attr('class', 'x-label')
.attr('x', iw / 2).attr('y', ih + 42).attr('text-anchor', 'middle').text(xLabel);
gRoot.append('text').attr('class', 'y-label')
.attr('transform', 'rotate(-90)').attr('x', -ih / 2).attr('y', -52)
.attr('text-anchor', 'middle').text(METRICS[currentMetric].label);
}
function renderDots() {
// Final-step mean +/- std per (size, config).
const stats = {};
let lo = Infinity, hi = -Infinity;
SIZES.forEach(size => {
stats[size] = {};
Object.keys(CONFIGS).forEach(cfg => {
const vals = records.filter(r => r.size === size && r.config === cfg && r.step === maxStep).map(r => val(r, currentMetric));
if (!vals.length) return;
const mean = d3.mean(vals), std = d3.deviation(vals) || 0;
stats[size][cfg] = { mean, std, n: vals.length };
lo = Math.min(lo, mean - std); hi = Math.max(hi, mean + std);
});
});
const pad = (hi - lo) * 0.12 || 0.01;
const x = d3.scalePoint().domain(SIZES.map(sizeLabel)).range([0, iw]).padding(0.5);
const y = d3.scaleLinear().domain([Math.max(0, lo - pad), hi + pad]).range([ih, 0]).nice();
gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(6)).join('line')
.attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d));
drawAxes(x, y, (a) => a, 'Trained student size');
// Gap annotations between baseline and mix at each scale.
SIZES.forEach(size => {
const m = stats[size].mix, b = stats[size].baseline;
if (!m || !b) return;
const cx = x(sizeLabel(size));
gRoot.append('line').attr('x1', cx).attr('x2', cx)
.attr('y1', y(b.mean)).attr('y2', y(m.mean))
.attr('stroke', 'var(--muted-color)').attr('stroke-width', 1).attr('stroke-dasharray', '2 3').attr('opacity', 0.7);
gRoot.append('text').attr('class', 'gap-label')
.attr('x', cx + 8).attr('y', y((m.mean + b.mean) / 2) + 4)
.attr('text-anchor', 'start').text(`+${fmt(m.mean - b.mean, 3)}`);
});
// Connecting line + whiskers + dots per config.
Object.entries(CONFIGS).forEach(([cfg, meta]) => {
const pts = SIZES.filter(s => stats[s][cfg]).map(s => ({ size: s, ...stats[s][cfg] }));
const line = d3.line().x(d => x(sizeLabel(d.size))).y(d => y(d.mean));
gRoot.append('path').datum(pts).attr('fill', 'none')
.attr('stroke', meta.color).attr('stroke-width', 2.5).attr('opacity', 0.85).attr('d', line);
pts.forEach(p => {
const cx = x(sizeLabel(p.size));
// whisker
gRoot.append('line').attr('x1', cx).attr('x2', cx).attr('y1', y(p.mean - p.std)).attr('y2', y(p.mean + p.std))
.attr('stroke', meta.color).attr('stroke-width', 2);
[-1, 1].forEach(s => gRoot.append('line').attr('x1', cx - 5).attr('x2', cx + 5)
.attr('y1', y(p.mean + s * p.std)).attr('y2', y(p.mean + s * p.std)).attr('stroke', meta.color).attr('stroke-width', 2));
});
gRoot.append('g').selectAll('circle').data(pts).join('circle')
.attr('cx', d => x(sizeLabel(d.size))).attr('cy', d => y(d.mean)).attr('r', 5)
.attr('fill', meta.color).attr('stroke', 'var(--surface-bg)').attr('stroke-width', 1.5).style('cursor', 'pointer')
.on('mousemove', (event, d) => {
const cv = d.std / d.mean * 100;
showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(d.size)} student</strong></div>
<div class="row"><span class="name"><span class="swatch" style="background:${meta.color}"></span>${meta.label}</span></div>
<div class="row"><span>mean</span><strong>${fmt(d.mean)}</strong></div>
<div class="row"><span>std (n=${d.n})</span><strong>${fmt(d.std, 4)}</strong></div>
<div class="row"><span>CV</span><strong>${cv.toFixed(2)}%</strong></div>`, event);
})
.on('mouseleave', hideTip);
});
}
function renderFan() {
const byCfg = {};
const stepsSet = new Set();
Object.keys(CONFIGS).forEach(cfg => {
const recs = records.filter(r => r.size === currentSize && r.config === cfg);
const bySeed = d3.group(recs, r => r.seed);
const byStep = d3.group(recs, r => r.step);
const band = Array.from(byStep, ([step, rs]) => {
const vs = rs.map(r => val(r, currentMetric));
stepsSet.add(step);
return { step, min: d3.min(vs), max: d3.max(vs), median: d3.median(vs) };
}).sort((a, b) => a.step - b.step);
const seeds = Array.from(bySeed, ([seed, rs]) => ({ seed, pts: rs.slice().sort((a, b) => a.step - b.step) }));
byCfg[cfg] = { band, seeds };
});
const steps = Array.from(stepsSet).sort((a, b) => a - b);
// Fix the y-domain across every model size (not just the selected one) so the
// axis ticks stay identical when switching the size selector.
const allVals = records.map(r => val(r, currentMetric));
const lo = d3.min(allVals), hi = d3.max(allVals);
const pad = (hi - lo) * 0.06 || 0.01;
const x = d3.scaleLinear().domain(d3.extent(steps)).range([0, iw]);
const y = d3.scaleLinear().domain([Math.max(0, lo - pad), hi + pad]).range([ih, 0]).nice();
gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(6)).join('line')
.attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d));
drawAxes(x, y, (a) => a.ticks(6).tickFormat(d3.format('~s')), 'Training step');
const area = d3.area().x(d => x(d.step)).y0(d => y(d.min)).y1(d => y(d.max));
const medLine = d3.line().x(d => x(d.step)).y(d => y(d.median));
const seedLine = d3.line().x(d => x(d.step)).y(d => y(val(d, currentMetric)));
Object.entries(CONFIGS).forEach(([cfg, meta]) => {
const { band, seeds } = byCfg[cfg];
gRoot.append('path').datum(band).attr('fill', meta.color).attr('opacity', 0.14).attr('d', area);
seeds.forEach(s => gRoot.append('path').datum(s.pts).attr('fill', 'none')
.attr('stroke', meta.color).attr('stroke-width', 1).attr('opacity', 0.22).attr('d', seedLine));
gRoot.append('path').datum(band).attr('fill', 'none')
.attr('stroke', meta.color).attr('stroke-width', 2.6).attr('d', medLine);
});
// Hover bisector across both configs.
const overlay = gRoot.append('rect').attr('width', iw).attr('height', ih).attr('fill', 'transparent');
const hoverLine = gRoot.append('line').attr('y1', 0).attr('y2', ih)
.attr('stroke', 'var(--muted-color)').attr('stroke-dasharray', '3 3').style('opacity', 0);
overlay.on('mousemove', (event) => {
const [mx] = d3.pointer(event);
const xv = x.invert(mx);
const nearest = steps.reduce((a, b) => Math.abs(b - xv) < Math.abs(a - xv) ? b : a);
hoverLine.attr('x1', x(nearest)).attr('x2', x(nearest)).style('opacity', 1);
const rows = Object.entries(CONFIGS).map(([cfg, meta]) => {
const d = byCfg[cfg].band.find(p => p.step === nearest);
if (!d) return '';
return `<div class="row"><span class="name"><span class="swatch" style="background:${meta.color}"></span>${meta.label}</span>
<span><strong>${fmt(d.median)}</strong> <span style="color:var(--muted-color)">[${fmt(d.min)}-${fmt(d.max)}]</span></span></div>`;
}).join('');
showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(currentSize)} student, step ${nearest.toLocaleString()}</strong></div>${rows}`, event);
}).on('mouseleave', () => { hoverLine.style('opacity', 0); hideTip(); });
}
// Two-way (seed x data-order) variance decomposition with one observation
// per cell, so the residual term captures interaction plus noise. Shares
// are eta^2 = SS_factor / SS_total, matching the experiment report.
function eta2(cellRecs, metricKey) {
const obs = cellRecs.map(r => ({ s: +r.seed % 100, d: Math.floor(+r.seed / 100), v: val(r, metricKey) }));
const grand = d3.mean(obs, o => o.v);
const sst = d3.sum(obs, o => (o.v - grand) ** 2);
if (!sst) return { seed: 0, data: 0, resid: 0 };
const seeds = Array.from(new Set(obs.map(o => o.s)));
const orders = Array.from(new Set(obs.map(o => o.d)));
let ssSeed = 0, ssData = 0;
seeds.forEach(si => { const m = d3.mean(obs.filter(o => o.s === si), o => o.v); ssSeed += orders.length * (m - grand) ** 2; });
orders.forEach(dj => { const m = d3.mean(obs.filter(o => o.d === dj), o => o.v); ssData += seeds.length * (m - grand) ** 2; });
const ssResid = Math.max(0, sst - ssSeed - ssData);
return { seed: ssSeed / sst * 100, data: ssData / sst * 100, resid: ssResid / sst * 100 };
}
function renderDecomp() {
const SRC = srcDefs();
const cfgKeys = Object.keys(CONFIGS);
const x0 = d3.scaleBand().domain(SIZES.map(sizeLabel)).range([0, iw]).paddingInner(0.28).paddingOuter(0.12);
const x1 = d3.scaleBand().domain(cfgKeys).range([0, x0.bandwidth()]).padding(0.18);
const y = d3.scaleLinear().domain([0, 100]).range([ih, 0]);
gRoot.append('g').attr('class', 'grid').selectAll('line').data(y.ticks(5)).join('line')
.attr('x1', 0).attr('x2', iw).attr('y1', d => y(d)).attr('y2', d => y(d));
gRoot.append('g').attr('class', 'axes')
.call(d3.axisLeft(y).ticks(5).tickFormat(d => d + '%'));
gRoot.append('text').attr('class', 'y-label').attr('transform', 'rotate(-90)')
.attr('x', -ih / 2).attr('y', -52).attr('text-anchor', 'middle').text('Share of run-to-run variance');
SIZES.forEach(size => {
const gx = x0(sizeLabel(size));
gRoot.append('text').attr('class', 'x-label').attr('x', gx + x0.bandwidth() / 2).attr('y', ih + 34)
.attr('text-anchor', 'middle').text(sizeLabel(size));
cfgKeys.forEach(cfg => {
const recs = records.filter(r => r.size === size && r.config === cfg && r.step === maxStep);
if (!recs.length) return;
const e = eta2(recs, currentMetric);
const bx = gx + x1(cfg), bw = x1.bandwidth();
const rows = SRC.map(s => `<div class="row"><span class="name"><span class="swatch" style="background:${s.color}"></span>${s.label}</span><strong>${e[s.key].toFixed(0)}%</strong></div>`).join('');
// Stack bottom-to-top in reverse of SRC so residual sits at the bottom
// and the bar's top-to-bottom order matches the legend/tooltip order.
let acc = 0;
[...SRC].reverse().forEach(src => {
const v = e[src.key];
gRoot.append('rect').attr('x', bx).attr('width', bw)
.attr('y', y(acc + v)).attr('height', Math.max(0, y(acc) - y(acc + v)))
.attr('fill', src.color).style('cursor', 'pointer')
.on('mousemove', (event) => showTip(`<div style="margin-bottom:4px"><strong>${sizeLabel(size)} ${CONFIGS[cfg].label}</strong></div>${rows}`, event))
.on('mouseleave', hideTip);
acc += v;
});
gRoot.append('text').attr('x', bx + bw / 2).attr('y', ih + 15).attr('text-anchor', 'middle')
.attr('fill', 'var(--muted-color)').attr('font-size', 11).text(cfg === 'mix' ? 'mix' : 'base');
});
});
}
function render() {
if (!records.length) return;
sizeCtl.group.style.display = currentView === 'fan' ? '' : 'none';
setLegend(currentView === 'decomp'
? srcDefs().map(s => ({ label: s.label, color: s.color }))
: Object.values(CONFIGS).map(c => ({ label: c.label, color: c.color })));
frame();
if (currentView === 'dots') renderDots();
else if (currentView === 'fan') renderFan();
else renderDecomp();
}
viewCtl.select.addEventListener('change', () => { currentView = viewCtl.select.value; render(); });
sizeCtl.select.addEventListener('change', () => { currentSize = sizeCtl.select.value; render(); });
metricSelect.addEventListener('change', () => { currentMetric = metricSelect.value; render(); });
fetchFirstAvailable(CSV_PATHS).then(text => {
const rows = d3.csvParse(text);
records = rows.filter(r => r.runname.includes('data-seed=')).map(r => {
const { config, size } = parseRun(r.runname);
return { config, size, step: +r.steps, seed: r.seed, raw: r };
});
maxStep = d3.max(records, r => r.step);
render();
}).catch(err => {
const pre = document.createElement('pre');
pre.style.color = 'red';
pre.textContent = `Error loading data: ${err.message}`;
container.appendChild(pre);
});
if (window.ResizeObserver) new ResizeObserver(() => render()).observe(container);
else window.addEventListener('resize', render);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
} else {
ensureD3(bootstrap);
}
})();
</script>