joelniklaus HF Staff commited on
Commit
dbee2e0
·
1 Parent(s): ddd533d

add quality score analysis

Browse files
app/src/content/chapters/analyses.mdx CHANGED
@@ -134,4 +134,25 @@ GPU time across our 65 experiments varies by two orders of magnitude: the cheape
134
  />
135
  </Wide>
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </Wide>
 
134
  />
135
  </Wide>
136
 
137
+
138
+
139
+
140
+ ### How Do Quality Scores Shift Through Rephrasing?
141
+
142
+ The correlation analysis above tells us that quality scores are weak predictors of performance, but it does not show *how* scores change through rephrasing. <FigRef target="score-shift" /> visualizes this as a slope chart: each experiment is a line connecting its input score (left), output score (middle), and downstream `agg_score_macro` (right). Toggle between DCLM and edu-score views to see both perspectives.
143
+
144
+ **DCLM scores almost universally increase through rephrasing.** Nearly every experiment shows an upward slope from input to output DCLM score, regardless of prompt type or model. This makes sense: the rephrasing models produce cleaner, more structured text that the DCLM classifier rewards. But the slope from output DCLM score to downstream performance is much flatter and noisier, confirming that a high DCLM score does not guarantee good training data.
145
+
146
+ **Edu-scores tell the opposite story.** Most experiments *decrease* the edu-score through rephrasing, particularly those starting from high-quality sources (FineWeb-Edu-HQ has high baseline edu-scores). The edu-score classifier penalizes format changes like tables, FAQs, and math notation that our best prompts produce. This is a case where the proxy metric actively misleads: the "quality degradation" measured by edu-score corresponds to format transformations that *improve* downstream performance.
147
+
148
+ {/* Seven early runs have incorrect input quality scores due to a scoring pipeline bug and
149
+ are excluded in the chart JS via BROKEN_INPUT_SCORES rather than patched in the JSON:
150
+ article/commentary/discussion/tutorial-1b-hq, tutorial-12b-hq, faq-1b-lq, faq-12b-lq */}
151
+ <Wide>
152
+ <HtmlEmbed
153
+ id="score-shift"
154
+ src="score-shift.html"
155
+ data="rephrasing_metadata.json"
156
+ desc="Slope chart showing how quality scores shift through rephrasing. Each line connects an experiment's input score, output score, and downstream performance. Toggle between DCLM and edu-score views."
157
+ />
158
  </Wide>
app/src/content/chapters/experiments.mdx CHANGED
@@ -7,7 +7,13 @@ import FigRef from "../../components/FigRef.astro";
7
  {/* TODO: mention the currently running finephrase rephrasing with smollm2 */}
8
  {/* TODO: shorten the vllm inference benchmark or put stuff into the appendix */}
9
  {/* TODO: potentially make a widget for data exploration: look at the same few samples generated by different models or transformed with different prompts */}
10
- {/* TODO: Check if we have more information in the rephrasing_metadata that we can use to do analyses */}
 
 
 
 
 
 
11
 
12
  ## Experiments
13
 
 
7
  {/* TODO: mention the currently running finephrase rephrasing with smollm2 */}
8
  {/* TODO: shorten the vllm inference benchmark or put stuff into the appendix */}
9
  {/* TODO: potentially make a widget for data exploration: look at the same few samples generated by different models or transformed with different prompts */}
10
+ {/* TODO: Standardize colors across charts in the blog post more */}
11
+ {/* TODO: Check all the charts again in dark mode */}
12
+ {/* TODO: In analyses make transitions better */}
13
+ {/* TODO: Combine verbosity and compression analysis under one title */}
14
+ {/* TODO: Combine quality score and edu/dclm score analysis under one title */}
15
+ {/* TODO: Move conclusions to after the experiments into conclusions subsection */}
16
+ {/* TODO: put the new analyses into context and update the intro paragraphs of the experiments and analyses accordingly */}
17
 
18
  ## Experiments
19
 
app/src/content/embeds/score-shift.html ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-score-shift" style="width:100%;margin:10px 0;min-height:420px;"></div>
2
+ <style>
3
+ .d3-score-shift { font-family: system-ui, -apple-system, sans-serif; position: relative; }
4
+ .d3-score-shift .d3-tooltip {
5
+ position: absolute; top: 0; left: 0;
6
+ transform: translate(-9999px, -9999px);
7
+ pointer-events: none;
8
+ padding: 10px 14px; border-radius: 10px;
9
+ font-size: 13px; line-height: 1.4;
10
+ border: 1px solid var(--border-color);
11
+ background: var(--surface-bg); color: var(--text-color);
12
+ box-shadow: 0 6px 24px rgba(0,0,0,.22);
13
+ opacity: 0; transition: opacity .12s ease;
14
+ z-index: 20; max-width: 360px;
15
+ }
16
+ .d3-score-shift .controls {
17
+ display: flex; gap: 16px; align-items: center; justify-content: flex-end; flex-wrap: wrap;
18
+ margin-top: 8px;
19
+ }
20
+ .d3-score-shift .control-group {
21
+ display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
22
+ }
23
+ .d3-score-shift .controls label {
24
+ font-size: 13px; font-weight: 700; color: var(--text-color);
25
+ }
26
+ .d3-score-shift .controls select {
27
+ font-size: 13px; padding: 6px 28px 6px 10px; border: 1px solid var(--border-color);
28
+ border-radius: 8px; background: var(--surface-bg); color: var(--text-color);
29
+ appearance: none; cursor: pointer;
30
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%23888' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
31
+ background-repeat: no-repeat; background-position: right 8px center;
32
+ }
33
+ .d3-score-shift .legend {
34
+ display: flex; flex-direction: column; align-items: flex-start; gap: 6px; margin-top: 8px;
35
+ }
36
+ .d3-score-shift .legend-title { font-size: 13px; font-weight: 700; color: var(--text-color); }
37
+ .d3-score-shift .legend .items { display: flex; flex-wrap: wrap; gap: 6px 14px; }
38
+ .d3-score-shift .legend .item {
39
+ display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
40
+ font-size: 13px; color: var(--text-color); cursor: pointer;
41
+ }
42
+ .d3-score-shift .legend .swatch {
43
+ width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color);
44
+ }
45
+ </style>
46
+ <script>
47
+ (() => {
48
+ const ensureD3 = (cb) => {
49
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
50
+ let s = document.getElementById('d3-cdn-script');
51
+ 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); }
52
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
53
+ s.addEventListener('load', onReady, { once: true });
54
+ if (window.d3) onReady();
55
+ };
56
+
57
+ const bootstrap = () => {
58
+ const scriptEl = document.currentScript;
59
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
60
+ while (container && !(container.classList && container.classList.contains('d3-score-shift'))) {
61
+ container = container.previousElementSibling;
62
+ }
63
+ if (!container) {
64
+ const cs = Array.from(document.querySelectorAll('.d3-score-shift'))
65
+ .filter(el => !(el.dataset && el.dataset.mounted === 'true'));
66
+ container = cs[cs.length - 1] || null;
67
+ }
68
+ if (!container) return;
69
+ if (container.dataset.mounted === 'true') return;
70
+ container.dataset.mounted = 'true';
71
+
72
+ let mountEl = container;
73
+ while (mountEl && !mountEl.getAttribute?.('data-datafiles')) mountEl = mountEl.parentElement;
74
+ const dataAttr = mountEl?.getAttribute?.('data-datafiles');
75
+ const dataPaths = dataAttr
76
+ ? [dataAttr.includes('/') ? dataAttr : `/data/${dataAttr}`]
77
+ : ['/data/rephrasing_metadata.json', './assets/data/rephrasing_metadata.json'];
78
+
79
+ const fetchFirst = async (paths) => {
80
+ for (const p of paths) {
81
+ try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return r.json(); } catch(_) {}
82
+ }
83
+ throw new Error('Data not found');
84
+ };
85
+
86
+ fetchFirst(dataPaths).then(data => buildChart(data)).catch(err => {
87
+ container.innerHTML = `<pre style="color:red;padding:12px;">Error loading data: ${err.message}</pre>`;
88
+ });
89
+
90
+ function buildChart(rawData) {
91
+ const SOURCE_MAP = {
92
+ 'fineweb-edu-hq-20BT': 'FW-Edu HQ', 'fineweb-edu-lq-20BT': 'FW-Edu LQ',
93
+ 'dclm-37BT': 'DCLM', 'cosmopedia-25BT': 'Cosmopedia'
94
+ };
95
+ const PROMPT_LABELS = {
96
+ 'article': 'Article', 'commentary': 'Commentary', 'discussion': 'Discussion',
97
+ 'faq': 'FAQ', 'math': 'Math', 'table': 'Table', 'tutorial': 'Tutorial',
98
+ 'distill': 'Distill', 'diverse_qa_pairs': 'Diverse QA',
99
+ 'extract_knowledge': 'Extract Knowledge', 'knowledge_list': 'Knowledge List',
100
+ 'wikipedia_style_rephrasing': 'Wikipedia Style',
101
+ 'guided_rewrite_improved': 'Guided Rewrite+', 'guided_rewrite_original': 'Guided Rewrite'
102
+ };
103
+
104
+ const getFamily = (m) => {
105
+ const ml = m.toLowerCase();
106
+ if (ml.includes('smollm')) return 'SmolLM2';
107
+ if (ml.includes('gemma')) return 'Gemma';
108
+ if (ml.includes('qwen')) return 'Qwen';
109
+ if (ml.includes('falcon')) return 'Falcon';
110
+ if (ml.includes('granite')) return 'Granite';
111
+ if (ml.includes('llama')) return 'Llama';
112
+ return 'Other';
113
+ };
114
+
115
+ const allPromptKeys = [...new Set(rawData.map(d => d.prompt.split('/')[1].replace('.md', '')))].sort();
116
+ const promptColors = {};
117
+ const cat = window.ColorPalettes ? window.ColorPalettes.getColors('categorical', allPromptKeys.length) : d3.schemeTableau10.concat(d3.schemePastel1);
118
+ allPromptKeys.forEach((k, i) => { promptColors[PROMPT_LABELS[k] || k] = cat[i % cat.length]; });
119
+
120
+ const SCORE_MODES = [
121
+ { key: 'dclm', label: 'DCLM Score', inputKey: 'input_dclm_score', outputKey: 'output_dclm_score' },
122
+ { key: 'edu', label: 'Edu Score', inputKey: 'input_edu_score', outputKey: 'output_edu_score' }
123
+ ];
124
+
125
+ // These early runs have incorrect input quality scores (pipeline bug)
126
+ const BROKEN_INPUT_SCORES = new Set([
127
+ 'format/article-1b-hq', 'format/commentary-1b-hq',
128
+ 'format/discussion-1b-hq', 'format/tutorial-1b-hq',
129
+ 'format/tutorial-12b-hq',
130
+ 'format/faq-1b-lq', 'format/faq-12b-lq'
131
+ ]);
132
+
133
+ const experiments = rawData
134
+ .filter(d => !BROKEN_INPUT_SCORES.has(d.run))
135
+ .map(d => {
136
+ const [, promptFile] = d.prompt.split('/');
137
+ const promptKey = promptFile.replace('.md', '');
138
+ return {
139
+ run: d.run,
140
+ prompt: PROMPT_LABELS[promptKey] || promptKey,
141
+ model: d.model.split('/').pop(),
142
+ source: SOURCE_MAP[d.source_dataset] || d.source_dataset,
143
+ family: getFamily(d.model),
144
+ inputEdu: d.input_edu_score,
145
+ outputEdu: d.output_edu_score,
146
+ inputDclm: d.input_dclm_score,
147
+ outputDclm: d.output_dclm_score,
148
+ eduDiff: d.edu_score_difference,
149
+ dclmDiff: d.dclm_score_difference,
150
+ aggMacro: d.results.agg_score_macro
151
+ };
152
+ });
153
+
154
+ let currentMode = SCORE_MODES[0].key;
155
+
156
+ const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block');
157
+ const gGrid = svg.append('g');
158
+ const gLines = svg.append('g');
159
+ const gDots = svg.append('g');
160
+ const gAxes = svg.append('g');
161
+
162
+ let tip = container.querySelector('.d3-tooltip');
163
+ let tipInner;
164
+ if (!tip) {
165
+ tip = document.createElement('div'); tip.className = 'd3-tooltip';
166
+ tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner';
167
+ tipInner.style.textAlign = 'left';
168
+ tip.appendChild(tipInner); container.appendChild(tip);
169
+ } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
170
+
171
+ const margin = { top: 36, right: 16, bottom: 16, left: 16 };
172
+
173
+ function render() {
174
+ const width = container.clientWidth || 800;
175
+ const height = Math.max(420, Math.round(width / 1.8));
176
+ svg.attr('width', width).attr('height', height);
177
+ const iw = width - margin.left - margin.right;
178
+ const ih = height - margin.top - margin.bottom;
179
+
180
+ const mode = SCORE_MODES.find(m => m.key === currentMode);
181
+ const inputKey = currentMode === 'dclm' ? 'inputDclm' : 'inputEdu';
182
+ const outputKey = currentMode === 'dclm' ? 'outputDclm' : 'outputEdu';
183
+ const diffKey = currentMode === 'dclm' ? 'dclmDiff' : 'eduDiff';
184
+
185
+ // Three column positions
186
+ const colPad = Math.max(50, iw * 0.08);
187
+ const colX = [
188
+ margin.left + colPad,
189
+ margin.left + iw / 2,
190
+ width - margin.right - colPad
191
+ ];
192
+ const colLabels = [
193
+ `Input ${mode.label}`,
194
+ `Output ${mode.label}`,
195
+ 'agg_score_macro'
196
+ ];
197
+
198
+ // Scales for each column (all vertical, higher = better at top)
199
+ const inputVals = experiments.map(d => d[inputKey]);
200
+ const outputVals = experiments.map(d => d[outputKey]);
201
+ const allScoreVals = inputVals.concat(outputVals);
202
+ const scorePad = (d3.max(allScoreVals) - d3.min(allScoreVals)) * 0.06;
203
+ const scoreScale = d3.scaleLinear()
204
+ .domain([d3.min(allScoreVals) - scorePad, d3.max(allScoreVals) + scorePad])
205
+ .range([height - margin.bottom, margin.top]);
206
+
207
+ const macroVals = experiments.map(d => d.aggMacro);
208
+ const macroPad = (d3.max(macroVals) - d3.min(macroVals)) * 0.08;
209
+ const macroScale = d3.scaleLinear()
210
+ .domain([d3.min(macroVals) - macroPad, d3.max(macroVals) + macroPad])
211
+ .range([height - margin.bottom, margin.top]);
212
+
213
+ const scales = [scoreScale, scoreScale, macroScale];
214
+
215
+ const getY = (d, col) => {
216
+ if (col === 0) return scales[0](d[inputKey]);
217
+ if (col === 1) return scales[1](d[outputKey]);
218
+ return scales[2](d.aggMacro);
219
+ };
220
+
221
+ // Grid / axis lines
222
+ gGrid.selectAll('*').remove();
223
+ colX.forEach((cx, ci) => {
224
+ gGrid.append('line')
225
+ .attr('x1', cx).attr('x2', cx)
226
+ .attr('y1', margin.top).attr('y2', height - margin.bottom)
227
+ .attr('stroke', 'var(--axis-color)').attr('stroke-width', 1).attr('opacity', 0.3);
228
+
229
+ // Ticks
230
+ const scale = scales[ci];
231
+ const ticks = scale.ticks(6);
232
+ const fmt = ci === 2 ? d3.format('.3f') : d3.format('.2f');
233
+ ticks.forEach(t => {
234
+ const y = scale(t);
235
+ gGrid.append('line')
236
+ .attr('x1', cx - 4).attr('x2', cx + 4)
237
+ .attr('y1', y).attr('y2', y)
238
+ .attr('stroke', 'var(--tick-color)').attr('stroke-width', 0.8);
239
+ gGrid.append('text')
240
+ .attr('x', cx - 8).attr('y', y)
241
+ .attr('text-anchor', 'end').attr('dominant-baseline', 'central')
242
+ .attr('fill', 'var(--tick-color)').attr('font-size', '12px')
243
+ .text(fmt(t));
244
+ });
245
+
246
+ // Column header
247
+ gGrid.append('text')
248
+ .attr('x', cx).attr('y', margin.top - 12)
249
+ .attr('text-anchor', 'middle').attr('fill', 'var(--text-color)')
250
+ .attr('font-size', '14px').attr('font-weight', '700')
251
+ .text(colLabels[ci]);
252
+ });
253
+
254
+ // Lines connecting the three points per experiment
255
+ const lineGen = (d) => {
256
+ return `M${colX[0]},${getY(d, 0)} L${colX[1]},${getY(d, 1)} L${colX[2]},${getY(d, 2)}`;
257
+ };
258
+
259
+ gLines.selectAll('path').data(experiments, d => d.run).join('path')
260
+ .attr('d', lineGen)
261
+ .attr('fill', 'none')
262
+ .attr('stroke', d => promptColors[d.prompt] || '#999')
263
+ .attr('stroke-width', 1.5)
264
+ .attr('stroke-opacity', 0.35)
265
+ .attr('pointer-events', 'none');
266
+
267
+ // Dots at each column
268
+ const dotData = [];
269
+ experiments.forEach(d => {
270
+ [0, 1, 2].forEach(col => {
271
+ dotData.push({ exp: d, col, x: colX[col], y: getY(d, col) });
272
+ });
273
+ });
274
+
275
+ const rBase = Math.max(4, Math.min(7, width * 0.006));
276
+
277
+ gDots.selectAll('circle').data(dotData, d => d.exp.run + '-' + d.col).join('circle')
278
+ .attr('cx', d => d.x).attr('cy', d => d.y)
279
+ .attr('r', rBase)
280
+ .attr('fill', d => promptColors[d.exp.prompt] || '#999')
281
+ .attr('fill-opacity', 0.7)
282
+ .attr('stroke', d => promptColors[d.exp.prompt] || '#999')
283
+ .attr('stroke-width', 1)
284
+ .attr('stroke-opacity', 0.2)
285
+ .attr('cursor', 'pointer')
286
+ .on('mouseenter', function(ev, d) {
287
+ const exp = d.exp;
288
+ // Highlight this experiment's line and dots
289
+ gLines.selectAll('path')
290
+ .attr('stroke-opacity', p => p.run === exp.run ? 0.9 : 0.06)
291
+ .attr('stroke-width', p => p.run === exp.run ? 3 : 1);
292
+ gDots.selectAll('circle')
293
+ .attr('fill-opacity', dd => dd.exp.run === exp.run ? 1 : 0.08)
294
+ .attr('stroke-opacity', dd => dd.exp.run === exp.run ? 0.8 : 0.04)
295
+ .attr('r', dd => dd.exp.run === exp.run ? rBase * 1.5 : rBase);
296
+
297
+ const fmtSign = (v, p) => (v >= 0 ? '+' : '') + v.toFixed(p || 3);
298
+ const dCol = (v) => v >= 0 ? '#5BC0A4' : '#E889AB';
299
+ tipInner.innerHTML =
300
+ `<div style="font-weight:700;font-size:14px;margin-bottom:4px;">${exp.prompt}</div>` +
301
+ `<div style="font-size:12px;color:var(--muted-color);margin-bottom:6px;">` +
302
+ `${exp.model} · ${exp.source}</div>` +
303
+ `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:13px;">` +
304
+ `<span style="color:var(--muted-color);">DCLM</span>` +
305
+ `<span>${exp.inputDclm.toFixed(3)} → ${exp.outputDclm.toFixed(3)} <b style="color:${dCol(exp.dclmDiff)};">${fmtSign(exp.dclmDiff)}</b></span>` +
306
+ `<span style="color:var(--muted-color);">Edu</span>` +
307
+ `<span>${exp.inputEdu.toFixed(2)} → ${exp.outputEdu.toFixed(2)} <b style="color:${dCol(exp.eduDiff)};">${fmtSign(exp.eduDiff, 2)}</b></span>` +
308
+ `<span style="color:var(--muted-color);">agg_score_macro</span>` +
309
+ `<span style="font-weight:700;">${exp.aggMacro.toFixed(4)}</span>` +
310
+ `</div>`;
311
+ tip.style.opacity = '1';
312
+ })
313
+ .on('mousemove', (ev) => {
314
+ const [mx, my] = d3.pointer(ev, container);
315
+ const bw = tip.offsetWidth || 300;
316
+ const bh = tip.offsetHeight || 160;
317
+ const ox = (mx + bw + 20 > width) ? -(bw + 12) : 14;
318
+ const oy = (my + bh + 20 > (height + 60)) ? -(bh + 12) : 14;
319
+ tip.style.transform = `translate(${Math.round(mx + ox)}px,${Math.round(my + oy)}px)`;
320
+ })
321
+ .on('mouseleave', function() {
322
+ gLines.selectAll('path').attr('stroke-opacity', 0.35).attr('stroke-width', 1.5);
323
+ gDots.selectAll('circle')
324
+ .attr('fill-opacity', 0.7).attr('stroke-opacity', 0.2).attr('r', rBase);
325
+ tip.style.opacity = '0';
326
+ tip.style.transform = 'translate(-9999px,-9999px)';
327
+ });
328
+ }
329
+
330
+ // Controls
331
+ const controls = document.createElement('div'); controls.className = 'controls';
332
+ const cg = document.createElement('div'); cg.className = 'control-group';
333
+ const lbl = document.createElement('label'); lbl.textContent = 'Score Type'; lbl.setAttribute('for', 'ss-score-select');
334
+ const sel = document.createElement('select'); sel.id = 'ss-score-select';
335
+ SCORE_MODES.forEach(m => {
336
+ const opt = document.createElement('option'); opt.value = m.key; opt.textContent = m.label; sel.appendChild(opt);
337
+ });
338
+ sel.value = currentMode;
339
+ sel.addEventListener('change', () => { currentMode = sel.value; render(); });
340
+ cg.appendChild(lbl); cg.appendChild(sel); controls.appendChild(cg); container.appendChild(controls);
341
+
342
+ // Legend
343
+ const legend = document.createElement('div'); legend.className = 'legend';
344
+ const ltitle = document.createElement('div'); ltitle.className = 'legend-title'; ltitle.textContent = 'Legend';
345
+ const items = document.createElement('div'); items.className = 'items';
346
+ const usedPrompts = [...new Set(experiments.map(d => d.prompt))].sort();
347
+ usedPrompts.forEach(p => {
348
+ const el = document.createElement('span'); el.className = 'item';
349
+ const sw = document.createElement('span'); sw.className = 'swatch'; sw.style.background = promptColors[p];
350
+ const txt = document.createElement('span'); txt.textContent = p;
351
+ el.appendChild(sw); el.appendChild(txt); items.appendChild(el);
352
+ el.addEventListener('mouseenter', () => {
353
+ gLines.selectAll('path')
354
+ .attr('stroke-opacity', d => d.prompt === p ? 0.8 : 0.04)
355
+ .attr('stroke-width', d => d.prompt === p ? 2.5 : 1);
356
+ gDots.selectAll('circle')
357
+ .attr('fill-opacity', d => d.exp.prompt === p ? 0.9 : 0.06)
358
+ .attr('stroke-opacity', d => d.exp.prompt === p ? 0.6 : 0.03);
359
+ });
360
+ el.addEventListener('mouseleave', () => {
361
+ gLines.selectAll('path').attr('stroke-opacity', 0.35).attr('stroke-width', 1.5);
362
+ gDots.selectAll('circle').attr('fill-opacity', 0.7).attr('stroke-opacity', 0.2);
363
+ });
364
+ });
365
+ legend.appendChild(ltitle); legend.appendChild(items); container.appendChild(legend);
366
+
367
+ render();
368
+ if (window.ResizeObserver) new ResizeObserver(() => render()).observe(container);
369
+ else window.addEventListener('resize', render);
370
+ }
371
+ };
372
+
373
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
374
+ else ensureD3(bootstrap);
375
+ })();
376
+ </script>