joelniklaus HF Staff commited on
Commit
1679bb6
Β·
1 Parent(s): 32ef17b

added benchmark contamination audit

Browse files
app/src/content/assets/data/contamination_audit_report.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fdb77082dfbd6be3084a83f6571df3e8f20573240f043d94c4fabbd21f4e0bb5
3
+ size 103453
app/src/content/chapters/4-analyses.mdx CHANGED
@@ -7,7 +7,32 @@ import Sidenote from "../../components/Sidenote.astro";
7
 
8
  ## Analyses
9
 
10
- The experiments tell us *what* works. Now let's zoom out and ask *why*. We look at the cost of running these experiments, whether cheap proxy metrics can replace expensive training runs, whether our proxy model is too small to reveal quality differences, what the rephrased outputs actually look like, and why a messier model sometimes wins.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  ### Is More Compute Worth It?
13
 
 
7
 
8
  ## Analyses
9
 
10
+ The experiments tell us *what* works. Now let's zoom out and ask *why*. We start by checking that the benchmark wins are real and not just contamination leaking from the eval sets, then look at the cost of running these experiments, whether cheap proxy metrics can replace expensive training runs, whether our proxy model is too small to reveal quality differences, what the rephrased outputs actually look like, and why a messier model sometimes wins.
11
+
12
+ ### Did Rephrasing Leak the Benchmarks?
13
+
14
+ Before reading anything into 90 sets of benchmark scores, we should rule out the most boring explanation for a win: the model saw the test set during pretraining. Rephrasing makes this worth checking, since a generator could in principle copy a memorized benchmark question straight into the training corpus. So we audited 56 corpora for overlap with the evaluation benchmarks: the eight format prompts, each rephrased by our six small (1–1.7B parameter) generators, plus the source and baseline datasets they are compared against.[^contam-method]
15
+
16
+ [^contam-method]: We run a 10-gram overlap audit built on DataTrove's decontamination indexer [@datatrove]. We hash every 10-gram from the benchmark answers plus the query/answer boundary, then scan each corpus for exact normalized matches. Query-only n-grams are excluded, since they are mostly prompt-template boilerplate (think "Question: ... Answer:") that floods the index with false positives, and degenerate repeated-token windows are skipped to avoid number-normalization artifacts. Each corpus is subsampled to roughly 5B tokens so the per-document rates are comparable. The [audit entry point](https://github.com/huggingface/finephrase/blob/main/finephrase/cli/audit_contamination.py) and the full per-dataset report are open.
17
+
18
+ <Wide>
19
+ <HtmlEmbed
20
+ id="contamination-audit"
21
+ src="contamination-audit.html"
22
+ data="contamination_audit_report.json"
23
+ desc="Benchmark contamination across the 56 audited corpora: the eight format prompts, each rephrased by our six small (1–1.7B) generators, plus the source and baseline datasets. A document is flagged when it overlaps an evaluation item under 10-gram matching; bars are stacked by which benchmark leaked, and length shows flagged documents per billion tokens (the fair unit, since each corpus is sampled to the same ~5B-token budget). A filled diamond marks a synthetic baseline and a hollow diamond an original (curated-web) baseline; everything else is FinePhrase rephrased data. Hover a row for its per-benchmark breakdown and estimated average document length. Group the corpora by type, prompt, rephrasing model, or individual dataset, and switch between flagged documents per billion tokens and the per-document rate (%)."
24
+ />
25
+ </Wide>
26
+
27
+ **Contamination is negligible, and rephrasing doesn't add any.** Because every corpus is sampled to the same 5B-token budget, we compare flagged documents per billion tokens rather than the per-document rate. On that measure the rephrased corpora average 726, no higher than the FineWeb-Edu-HQ data they were rephrased from (841): pushing web text through a 1B rephraser does not smuggle in more benchmark content than the source already held, if anything slightly less. The one real outlier is a baseline, not a FinePhrase output, the REWIRE rewriting baseline at ~1,900, well clear of everything else. The cleanest corpora are baselines too (Nemotron-HQ-Synth, SYNTH, and DCLM, all under 370). Grouping by rephraser shows only a mild spread, with Llama 3.2 leaking most (864) and Falcon 3 least (625); the ~1.4x range is comparable to the variation across prompts and trivial in absolute terms. Everything else, rephrased or not, sits in the same narrow band: even REWIRE works out to about one flagged document per half-million tokens of training data.
28
+
29
+ <Sidenote>
30
+ We report flagged documents per billion tokens rather than the conventional per-document rate because, with every corpus sampled to the same token budget, the per-document rate simply scales with average document length (longer documents hold more n-grams and so are likelier to be flagged) and would unfairly penalise long-document corpora like DCLM. Counting per token cancels that out.
31
+ </Sidenote>
32
+
33
+ **The little that exists lands on a predictable handful of benchmarks.** Over four fifths of all benchmark matches fall on just three tasks: MMLU-Redux (34%, almost entirely the high-school US-history and statistics subsets), HellaSwag (30%), and WikiTableQuestions (20%), all built from heavily-mirrored web text (Wikipedia tables, WikiHow, and online textbook material), where incidental overlap is most likely. The commonsense benchmarks that several of our findings lean on, HellaSwag and PIQA, look heavy only in the REWIRE baseline (around 1,450 flagged documents per billion tokens each); across the rephrased corpora they stay under ~330, and MMLU-Redux never tops ~535 anywhere. The math and reasoning tasks are essentially pristine: across the 56 audited corpora combined, GSM8K shows just 20 flagged documents, DROP 52, and WinoGrande 104. At these levels, leakage cannot account for the score differences we report.
34
+
35
+ With the scores cleared of contamination, we can take them at face value. The experiments that produced them, though, vary wildly in cost.
36
 
37
  ### Is More Compute Worth It?
38
 
app/src/content/embeds/contamination-audit.html ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-contam" style="width:100%;margin:6px 0;"></div>
2
+ <style>
3
+ .d3-contam { position: relative; }
4
+ .d3-contam svg { display: block; }
5
+ .d3-contam .axes path,
6
+ .d3-contam .axes line { stroke: var(--axis-color); }
7
+ .d3-contam .axes text { fill: var(--tick-color); }
8
+ .d3-contam .grid line { stroke: var(--grid-color); }
9
+ .d3-contam .row-label { fill: var(--text-color); font-family: system-ui, -apple-system, sans-serif; }
10
+ .d3-contam .row-marker { fill: var(--muted-color); }
11
+ .d3-contam .ref-line { stroke: var(--muted-color); stroke-dasharray: 3,4; stroke-width: 1; }
12
+ .d3-contam .ref-text { fill: var(--muted-color); font-family: system-ui, -apple-system, sans-serif; }
13
+ .d3-contam .seg { stroke: var(--surface-bg); stroke-width: 0.5; }
14
+ .d3-contam .hit { fill: transparent; cursor: default; }
15
+ /* only the transparent hit rects should capture hover, so it tracks the bar and the label */
16
+ .d3-contam .seg,
17
+ .d3-contam .row-label,
18
+ .d3-contam .row-marker,
19
+ .d3-contam .ref-line,
20
+ .d3-contam .ref-text,
21
+ .d3-contam .grid line { pointer-events: none; }
22
+
23
+ .d3-contam .controls {
24
+ display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap;
25
+ justify-content: flex-end; margin: 14px 0 0 0;
26
+ }
27
+ .d3-contam .control-group { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
28
+ .d3-contam .control-group > label,
29
+ .d3-contam .control-group > .label { font-size: 12px; font-weight: 700; color: var(--text-color); }
30
+ .d3-contam .pills { display: flex; gap: 6px; }
31
+ .d3-contam .pill {
32
+ font-size: 12px; padding: 7px 13px; border-radius: 999px;
33
+ border: 1px solid var(--border-color); background: var(--surface-bg);
34
+ color: var(--muted-color); cursor: pointer; user-select: none;
35
+ transition: background .12s ease, color .12s ease, border-color .12s ease;
36
+ }
37
+ .d3-contam .pill:hover { border-color: var(--text-color); }
38
+ .d3-contam .pill.active {
39
+ color: var(--surface-bg); background: var(--text-color); border-color: var(--text-color); font-weight: 600;
40
+ }
41
+ .d3-contam select {
42
+ font-size: 12px; padding: 8px 28px 8px 10px; border: 1px solid var(--border-color);
43
+ border-radius: 8px; background: var(--surface-bg); color: var(--text-color); cursor: pointer;
44
+ }
45
+
46
+ .d3-contam .legend { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; margin-top: 16px; }
47
+ .d3-contam .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); }
48
+ .d3-contam .legend .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
49
+ .d3-contam .legend .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; color: var(--text-color); }
50
+ .d3-contam .legend .swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color); }
51
+ .d3-contam .legend .note { font-size: 11.5px; color: var(--muted-color); margin-top: 2px; }
52
+
53
+ .d3-contam .d3-tooltip {
54
+ position: absolute; top: 0; left: 0; transform: translate(-9999px, -9999px);
55
+ pointer-events: none; padding: 10px 12px; border-radius: 10px; font-size: 12px; line-height: 1.4;
56
+ border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color);
57
+ box-shadow: 0 8px 32px rgba(0,0,0,.22); opacity: 0; transition: opacity .12s ease; z-index: 20; max-width: 320px;
58
+ }
59
+ </style>
60
+ <script>
61
+ (() => {
62
+ const ensureD3 = (cb) => {
63
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
64
+ let s = document.getElementById('d3-cdn-script');
65
+ if (!s) {
66
+ s = document.createElement('script');
67
+ s.id = 'd3-cdn-script';
68
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
69
+ document.head.appendChild(s);
70
+ }
71
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
72
+ s.addEventListener('load', onReady, { once: true });
73
+ if (window.d3) onReady();
74
+ };
75
+
76
+ const bootstrap = () => {
77
+ const scriptEl = document.currentScript;
78
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
79
+ if (!(container && container.classList && container.classList.contains('d3-contam'))) {
80
+ const cs = Array.from(document.querySelectorAll('.d3-contam')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
81
+ container = cs[cs.length - 1] || null;
82
+ }
83
+ if (!container) return;
84
+ if (container.dataset.mounted === 'true') return;
85
+ container.dataset.mounted = 'true';
86
+
87
+ // ─── data path (HtmlEmbed `data` prop β†’ data-datafiles) ───
88
+ let mountEl = container;
89
+ while (mountEl && !mountEl.getAttribute?.('data-datafiles')) mountEl = mountEl.parentElement;
90
+ const dataAttr = mountEl?.getAttribute?.('data-datafiles');
91
+ const dataPaths = dataAttr
92
+ ? [dataAttr.includes('/') ? dataAttr : `/data/${dataAttr}`]
93
+ : ['/data/contamination_audit_report.json', './assets/data/contamination_audit_report.json', '../assets/data/contamination_audit_report.json'];
94
+
95
+ const fetchFirst = async (paths) => {
96
+ for (const p of paths) {
97
+ try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return r.json(); } catch (_) {}
98
+ }
99
+ throw new Error('Data not found');
100
+ };
101
+
102
+ fetchFirst(dataPaths).then(buildChart).catch(err => {
103
+ container.innerHTML = `<pre style="color:red;padding:12px;">Error loading data: ${err.message}</pre>`;
104
+ });
105
+
106
+ function buildChart(report) {
107
+ const datasets = report.datasets;
108
+
109
+ // ─── taxonomy ───
110
+ const PROMPTS = ['article', 'discussion', 'explanation', 'faq', 'math', 'narrative', 'table', 'tutorial'];
111
+ const MODELS = ['smollm2', 'qwen3', 'llama32', 'gemma_1b', 'falcon3', 'granite3'];
112
+ const BASELINES = ['cosmopedia', 'dclm', 'fw_edu_hq', 'fw_edu_lq', 'nemotron_hq_synth', 'rewire', 'synth_query_reasoning_answer', 'ultra_fineweb'];
113
+
114
+ // each corpus is subsampled to roughly this many tokens, so tokens/doc β‰ˆ BUDGET / docs
115
+ const TOKEN_BUDGET = 5e9;
116
+
117
+ const PROMPT_LABEL = {
118
+ article: 'Article', discussion: 'Discussion', explanation: 'Explanation', faq: 'FAQ',
119
+ math: 'Math', narrative: 'Narrative', table: 'Table', tutorial: 'Tutorial'
120
+ };
121
+ const MODEL_LABEL = {
122
+ smollm2: 'SmolLM2', qwen3: 'Qwen3', llama32: 'Llama 3.2',
123
+ gemma_1b: 'Gemma 3', falcon3: 'Falcon 3', granite3: 'Granite 3'
124
+ };
125
+ const BASELINE_LABEL = {
126
+ cosmopedia: 'Cosmopedia', dclm: 'DCLM', fw_edu_hq: 'FineWeb-Edu HQ', fw_edu_lq: 'FineWeb-Edu LQ',
127
+ nemotron_hq_synth: 'Nemotron-HQ-Synth', rewire: 'REWIRE', synth_query_reasoning_answer: 'SYNTH', ultra_fineweb: 'Ultra-FineWeb'
128
+ };
129
+
130
+ // benchmark buckets: 6 biggest by total hits get their own colour, the rest fold into "Other"
131
+ const BENCH_ORDER = ['mmlu_redux_cf', 'hellaswag_cf', 'wikitablequestions', 'squad_v2', 'piqa_cf', 'arc_cf'];
132
+ const OTHER = '__other';
133
+ const BENCH_LABEL = {
134
+ mmlu_redux_cf: 'MMLU-Redux', hellaswag_cf: 'HellaSwag', wikitablequestions: 'WikiTableQuestions',
135
+ squad_v2: 'SQuAD v2', piqa_cf: 'PIQA', arc_cf: 'ARC', [OTHER]: 'Other'
136
+ };
137
+ const SEGMENTS = [...BENCH_ORDER, OTHER];
138
+ const bucketOf = (g) => BENCH_ORDER.includes(g) ? g : OTHER;
139
+
140
+ // colours: categorical for the 6 named benchmarks, muted grey for "Other"
141
+ const fallback = ['#5b9bd5', '#e07b54', '#5BC0A4', '#9a8ec2', '#e06b9e', '#c9a046'];
142
+ const catColors = (window.ColorPalettes?.getColors?.('categorical', 6)) || fallback;
143
+ const COLOR = {};
144
+ BENCH_ORDER.forEach((b, i) => { COLOR[b] = catColors[i] || fallback[i]; });
145
+ COLOR[OTHER] = '#9aa0a6';
146
+
147
+ // ─── row construction ───
148
+ const foldBuckets = (byGroup) => {
149
+ const out = {}; SEGMENTS.forEach(s => out[s] = 0);
150
+ for (const [g, c] of Object.entries(byGroup)) out[bucketOf(g)] += c;
151
+ return out;
152
+ };
153
+ const ORIGINAL = ['dclm', 'fw_edu_hq', 'fw_edu_lq', 'ultra_fineweb']; // curated web
154
+ const SYNTH_BASE = ['cosmopedia', 'nemotron_hq_synth', 'rewire', 'synth_query_reasoning_answer']; // generated
155
+ // hollow diamond = original/curated-web baseline, filled diamond = synthetic baseline, none = rephrased
156
+ const markerFor = (name) => ORIGINAL.includes(name) ? '\u25C7' : (SYNTH_BASE.includes(name) ? '\u25C6' : '');
157
+
158
+ const datasetRow = (name, label, kind) => {
159
+ const d = datasets[name];
160
+ const sub = kind === 'baseline' ? 'External / source dataset' : 'FinePhrase rephrased';
161
+ return { key: name, label, kind, marker: markerFor(name), sub, totalDocs: d.total_docs, contam: d.contaminated_docs, rate: d.contamination_rate, buckets: foldBuckets(d.by_group), nSets: 1, avgLen: TOKEN_BUDGET / d.total_docs };
162
+ };
163
+ // generic pooled aggregate over a set of corpora (used by the prompt/model/type lenses)
164
+ const pooledRow = (key, label, kind, marker, sub, names) => {
165
+ let totalDocs = 0, contam = 0; const buckets = {}; SEGMENTS.forEach(s => buckets[s] = 0);
166
+ for (const n of names) {
167
+ const d = datasets[n]; totalDocs += d.total_docs; contam += d.contaminated_docs;
168
+ const fb = foldBuckets(d.by_group); SEGMENTS.forEach(s => buckets[s] += fb[s]);
169
+ }
170
+ return { key, label, kind, marker, sub, totalDocs, contam, rate: contam / totalDocs, buckets, nSets: names.length, avgLen: (TOKEN_BUDGET * names.length) / totalDocs };
171
+ };
172
+ const promptNames = (p) => MODELS.map(m => `${p}_${m}`).filter(n => datasets[n]);
173
+ const modelNames = (m) => PROMPTS.map(p => `${p}_${m}`).filter(n => datasets[n]);
174
+ const rephrasedNames = PROMPTS.flatMap(p => MODELS.map(m => `${p}_${m}`)).filter(n => datasets[n]);
175
+ const baselineRows = () => BASELINES.map(n => datasetRow(n, BASELINE_LABEL[n], 'baseline'));
176
+
177
+ // ─── one row-set per grouping lens ───
178
+ const ROWSETS = {
179
+ type: [
180
+ pooledRow('type:original', 'Original web', 'baseline', '\u25C7', 'Curated web baselines, pooled', ORIGINAL),
181
+ pooledRow('type:synthbase', 'Synthetic baselines', 'baseline', '\u25C6', 'Synthetic baselines, pooled', SYNTH_BASE),
182
+ pooledRow('type:reph', 'FinePhrase rephrased', 'rephrased', '', 'All 48 rephrased corpora, pooled', rephrasedNames)
183
+ ],
184
+ prompt: [
185
+ ...baselineRows(),
186
+ ...PROMPTS.map(p => pooledRow(`prompt:${p}`, PROMPT_LABEL[p], 'rephrased', '', 'FinePhrase prompt Β· pooled over the 6 rephrasers', promptNames(p)))
187
+ ],
188
+ model: [
189
+ ...baselineRows(),
190
+ ...MODELS.map(m => pooledRow(`model:${m}`, MODEL_LABEL[m], 'rephrased', '', 'FinePhrase rephraser Β· pooled over the 8 prompts', modelNames(m)))
191
+ ],
192
+ dataset: Object.keys(datasets).map(name => {
193
+ if (BASELINES.includes(name)) return datasetRow(name, BASELINE_LABEL[name], 'baseline');
194
+ const model = MODELS.find(m => name.endsWith(`_${m}`));
195
+ const prompt = model ? name.slice(0, name.length - model.length - 1) : name;
196
+ return datasetRow(name, `${PROMPT_LABEL[prompt] || prompt} Β· ${MODEL_LABEL[model] || model}`, 'rephrased');
197
+ })
198
+ };
199
+
200
+ // flagged documents per billion tokens; the corpora are token-matched, so this is
201
+ // the length-fair quantity (the per-document rate scales with mean document length)
202
+ const perBtokOf = (contam, nSets) => contam / (nSets * TOKEN_BUDGET) * 1e9;
203
+
204
+ // pooled reference means for both metrics
205
+ const meanStats = (names) => {
206
+ let t = 0, c = 0; for (const n of names) { t += datasets[n].total_docs; c += datasets[n].contaminated_docs; }
207
+ return { rate: c / t, perbtok: perBtokOf(c, names.length) };
208
+ };
209
+ const refs = [
210
+ { label: 'rephrased avg', ...meanStats(rephrasedNames) },
211
+ { label: 'baseline avg', ...meanStats(BASELINES) }
212
+ ];
213
+
214
+ // ─── state ───
215
+ let groupBy = 'type'; // 'type' | 'prompt' | 'model' | 'dataset'
216
+ let metric = 'perbtok'; // 'perbtok' | 'rate'
217
+
218
+ // ─── tooltip ───
219
+ container.style.position = 'relative';
220
+ const tip = document.createElement('div'); tip.className = 'd3-tooltip';
221
+ const tipInner = document.createElement('div'); tip.appendChild(tipInner); container.appendChild(tip);
222
+
223
+ const fmtInt = d3.format(',');
224
+ const fmtPct = (r) => (r * 100).toFixed(r < 0.001 ? 4 : 3) + '%';
225
+ const fmtPctShort = (r) => (r * 100).toFixed(3) + '%';
226
+ const fmtLen = (v) => fmtInt(Math.round(v / 10) * 10);
227
+ const fmtPerB = (v) => fmtInt(Math.round(v));
228
+
229
+ const buildTip = (row) => {
230
+ const kindText = row.sub;
231
+ const bsum = SEGMENTS.reduce((a, s) => a + row.buckets[s], 0);
232
+ const top = SEGMENTS.map(s => [s, row.buckets[s]]).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]).slice(0, 4);
233
+ const rows = top.map(([s, c]) =>
234
+ `<span style="color:var(--muted-color);">${BENCH_LABEL[s]}</span><span>${fmtInt(c)} <span style="color:var(--muted-color);">(${fmtPct(c / row.totalDocs)})</span></span>`
235
+ ).join('');
236
+ const multiNote = bsum > row.contam * 1.02
237
+ ? `<div style="font-size:10.5px;color:var(--muted-color);margin-top:5px;">Some documents match several benchmarks, so these add up past the flagged total.</div>`
238
+ : '';
239
+ return `<div style="font-weight:800;font-size:13px;">${row.label}</div>` +
240
+ `<div style="font-size:11px;color:var(--muted-color);margin:-1px 0 5px;">${kindText}</div>` +
241
+ `<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:11.5px;padding-bottom:5px;border-bottom:1px solid var(--border-color);">` +
242
+ `<span style="color:var(--muted-color);">Flagged / Btok</span><span><b>${fmtPerB(perBtokOf(row.contam, row.nSets))}</b></span>` +
243
+ `<span style="color:var(--muted-color);">Contam. rate</span><span>${fmtPct(row.rate)}</span>` +
244
+ `<span style="color:var(--muted-color);">Flagged docs</span><span>${fmtInt(row.contam)} of ${fmtInt(row.totalDocs)}</span>` +
245
+ `<span style="color:var(--muted-color);">Avg length</span><span>\u2248 ${fmtLen(row.avgLen)} tokens/doc</span>` +
246
+ `</div>` +
247
+ `<div style="margin-top:5px;display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font-size:11.5px;">${rows}</div>` +
248
+ multiNote;
249
+ };
250
+
251
+ // ─── svg ───
252
+ const svg = d3.select(container).append('svg').attr('width', '100%');
253
+ const gGrid = svg.append('g').attr('class', 'grid');
254
+ const gBars = svg.append('g');
255
+ const gAxis = svg.append('g').attr('class', 'axes');
256
+ const gRefs = svg.append('g');
257
+ const gLabels = svg.append('g');
258
+
259
+ const render = () => {
260
+ const metricVal = (r) => metric === 'rate' ? r.rate : perBtokOf(r.contam, r.nSets);
261
+ const rows = ROWSETS[groupBy].slice().sort((a, b) => metricVal(b) - metricVal(a));
262
+
263
+ const width = container.clientWidth || 820;
264
+ const fontSize = Math.max(11.5, Math.min(14, width / 60));
265
+ const n = rows.length;
266
+ const rowH = n <= 4 ? 46 : (n <= 20 ? Math.max(26, Math.min(34, width / 28)) : 19);
267
+ const barH = rowH * 0.66;
268
+ const labelW = Math.max(120, Math.min(230, width * 0.26));
269
+ const showRefs = groupBy !== 'type'; // in the Type lens the bars already are the aggregates
270
+ const margin = { top: showRefs ? 28 : 12, right: 16, bottom: 46, left: labelW };
271
+ const innerW = Math.max(40, width - margin.left - margin.right);
272
+ const innerH = rows.length * rowH;
273
+ const axisGap = 12; // breathing room so the x-axis clears the lowest bar
274
+ const height = margin.top + innerH + axisGap + margin.bottom;
275
+ svg.attr('width', width).attr('height', height);
276
+
277
+ // A document can match several benchmarks, so the per-group counts sum to a bit
278
+ // more than the flagged-document total. Rescale segments to that true total so the
279
+ // bar length equals the row's metric value (keeping the axis and reference lines honest).
280
+ const valueOf = (row, seg) => {
281
+ const bs = SEGMENTS.reduce((a, s) => a + row.buckets[s], 0);
282
+ return bs > 0 ? metricVal(row) * (row.buckets[seg] / bs) : 0;
283
+ };
284
+ const xMax = d3.max(rows, metricVal) || 1;
285
+ const x = d3.scaleLinear().domain([0, xMax]).nice().range([0, innerW]);
286
+ const y = d3.scaleBand().domain(rows.map(r => r.key)).range([0, innerH]).paddingInner(1 - barH / rowH);
287
+
288
+ const tx = (v) => margin.left + x(v);
289
+ const ty = (k) => margin.top + y(k);
290
+
291
+ // grid + axis
292
+ const ticks = x.ticks(width < 520 ? 4 : 6);
293
+ const axisFmt = metric === 'rate' ? (v => (v * 100).toFixed(v < 0.001 ? 3 : 2) + '%') : d3.format(',d');
294
+ gGrid.attr('transform', `translate(${margin.left},${margin.top})`)
295
+ .selectAll('line').data(ticks).join('line')
296
+ .attr('x1', d => x(d)).attr('x2', d => x(d)).attr('y1', 0).attr('y2', innerH + axisGap);
297
+ gAxis.attr('transform', `translate(${margin.left},${margin.top + innerH + axisGap})`)
298
+ .call(d3.axisBottom(x).tickValues(ticks).tickFormat(axisFmt).tickSizeOuter(0))
299
+ .call(g => g.selectAll('text').attr('font-size', (fontSize * 0.92) + 'px'));
300
+
301
+ // axis title
302
+ gAxis.selectAll('text.axis-title').data([0]).join('text')
303
+ .attr('class', 'axis-title').attr('fill', 'var(--tick-color)')
304
+ .attr('x', innerW / 2).attr('y', 38).attr('text-anchor', 'middle')
305
+ .attr('font-size', (fontSize * 0.95) + 'px').attr('font-weight', 600)
306
+ .attr('font-family', 'system-ui, -apple-system, sans-serif')
307
+ .text(metric === 'rate' ? 'Flagged documents / sampled documents (%)' : 'Flagged documents per billion tokens');
308
+
309
+ // bars (stacked per row); hover targets are tied to the bar and the label only,
310
+ // not the empty space between rows
311
+ const onEnter = function (ev, r) { tipInner.innerHTML = buildTip(r); tip.style.opacity = '1'; };
312
+ const onMove = function (ev) {
313
+ const [mx, my] = d3.pointer(ev, container);
314
+ const cw = container.clientWidth, ch = container.clientHeight;
315
+ const bw = tip.offsetWidth || 260, bh = tip.offsetHeight || 140;
316
+ // prefer below-right of the cursor, flip if it would overflow, then clamp fully
317
+ // inside the container (its height includes the controls/legend, so short charts
318
+ // like the Type lens still have room and the tooltip never clips off the top)
319
+ let x = mx + 14; if (x + bw > cw) x = mx - bw - 14;
320
+ let y = my + 14; if (y + bh > ch) y = my - bh - 14;
321
+ x = Math.max(4, Math.min(x, cw - bw - 4));
322
+ y = Math.max(4, Math.min(y, ch - bh - 4));
323
+ tip.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
324
+ };
325
+ const onLeave = function () { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px,-9999px)'; };
326
+
327
+ const rowG = gBars.selectAll('g.rowg').data(rows, r => r.key);
328
+ rowG.exit().remove();
329
+ const rowGEnter = rowG.enter().append('g').attr('class', 'rowg');
330
+ rowGEnter.append('rect').attr('class', 'hit hit-label');
331
+ rowGEnter.append('rect').attr('class', 'hit hit-bar');
332
+ const rowGAll = rowGEnter.merge(rowG);
333
+
334
+ const barTop = (r) => ty(r.key) + (rowH - barH) / 2;
335
+ rowGAll.select('rect.hit-label')
336
+ .attr('x', 0).attr('y', barTop).attr('width', Math.max(0, margin.left - 2)).attr('height', barH)
337
+ .on('mouseenter', onEnter).on('mousemove', onMove).on('mouseleave', onLeave);
338
+ rowGAll.select('rect.hit-bar')
339
+ .attr('x', margin.left).attr('y', barTop)
340
+ .attr('width', r => Math.max(2, x(metricVal(r)))).attr('height', barH)
341
+ .on('mouseenter', onEnter).on('mousemove', onMove).on('mouseleave', onLeave);
342
+
343
+ const segSel = rowGAll.selectAll('rect.seg').data(r => {
344
+ let acc = 0;
345
+ return SEGMENTS.map(s => {
346
+ const v = valueOf(r, s); const x0 = acc; acc += v;
347
+ return { key: r.key, seg: s, x0, x1: acc };
348
+ }).filter(d => d.x1 > d.x0);
349
+ }, d => d.key + '|' + d.seg);
350
+ segSel.exit().remove();
351
+ segSel.enter().append('rect').attr('class', 'seg')
352
+ .merge(segSel)
353
+ .attr('x', d => tx(d.x0)).attr('y', d => ty(d.key) + (rowH - barH) / 2)
354
+ .attr('width', d => Math.max(0, x(d.x1) - x(d.x0))).attr('height', barH)
355
+ .attr('fill', d => COLOR[d.seg]);
356
+
357
+ // reference lines (rate mode only)
358
+ const refSel = gRefs.selectAll('g.ref').data(showRefs ? refs : [], d => d.label);
359
+ refSel.exit().remove();
360
+ const refEnter = refSel.enter().append('g').attr('class', 'ref');
361
+ refEnter.append('line').attr('class', 'ref-line');
362
+ refEnter.append('text').attr('class', 'ref-text');
363
+ const refAll = refEnter.merge(refSel);
364
+ const refVal = (d) => metric === 'rate' ? d.rate : d.perbtok;
365
+ refAll.select('line').attr('x1', d => tx(refVal(d))).attr('x2', d => tx(refVal(d)))
366
+ .attr('y1', margin.top).attr('y2', margin.top + innerH + axisGap);
367
+ // stagger the two labels above the plot so they never collide or clip
368
+ refAll.select('text').attr('x', d => tx(refVal(d))).attr('y', (d, i) => i === 0 ? 10 : 22)
369
+ .attr('text-anchor', 'middle').attr('font-size', (fontSize * 0.82) + 'px')
370
+ .text(d => metric === 'rate' ? `${d.label} ${fmtPctShort(d.rate)}` : `${d.label} ${fmtPerB(d.perbtok)}/Btok`);
371
+
372
+ // y labels + baseline markers
373
+ const labSel = gLabels.selectAll('g.lab').data(rows, r => r.key);
374
+ labSel.exit().remove();
375
+ const labEnter = labSel.enter().append('g').attr('class', 'lab');
376
+ labEnter.append('text').attr('class', 'row-marker');
377
+ labEnter.append('text').attr('class', 'row-label');
378
+ const labAll = labEnter.merge(labSel);
379
+ const markerSlot = fontSize + 5; // room for the diamond plus a gap before the label
380
+ labAll.select('text.row-marker')
381
+ .attr('x', margin.left - 6).attr('y', r => ty(r.key) + rowH / 2)
382
+ .attr('text-anchor', 'end').attr('dominant-baseline', 'central')
383
+ .attr('font-size', (fontSize * 0.82) + 'px')
384
+ .text(r => r.marker || '');
385
+ labAll.select('text.row-label')
386
+ .attr('x', r => margin.left - 6 - (r.marker ? markerSlot : 0))
387
+ .attr('y', r => ty(r.key) + rowH / 2)
388
+ .attr('text-anchor', 'end').attr('dominant-baseline', 'central')
389
+ .attr('font-size', fontSize + 'px')
390
+ .attr('font-weight', 500)
391
+ .text(r => r.label);
392
+ };
393
+
394
+ // ─── controls (below chart) ───
395
+ const controls = document.createElement('div'); controls.className = 'controls';
396
+ const uid = Math.random().toString(36).slice(2, 7);
397
+
398
+ const viewGroup = document.createElement('div'); viewGroup.className = 'control-group';
399
+ const viewLabel = document.createElement('span'); viewLabel.className = 'label'; viewLabel.textContent = 'Group by';
400
+ const viewPills = document.createElement('div'); viewPills.className = 'pills';
401
+ [['type', 'Type'], ['prompt', 'Prompt'], ['model', 'Model'], ['dataset', 'Dataset']].forEach(([val, txt]) => {
402
+ const p = document.createElement('button'); p.className = 'pill' + (val === groupBy ? ' active' : ''); p.textContent = txt; p.type = 'button';
403
+ p.addEventListener('click', () => {
404
+ groupBy = val; viewPills.querySelectorAll('.pill').forEach(el => el.classList.remove('active')); p.classList.add('active'); render();
405
+ });
406
+ viewPills.appendChild(p);
407
+ });
408
+ viewGroup.appendChild(viewLabel); viewGroup.appendChild(viewPills);
409
+
410
+ const metricGroup = document.createElement('div'); metricGroup.className = 'control-group';
411
+ const metricLabel = document.createElement('label'); metricLabel.setAttribute('for', `metric-${uid}`); metricLabel.textContent = 'Metric';
412
+ const metricSelect = document.createElement('select'); metricSelect.id = `metric-${uid}`;
413
+ [['perbtok', 'Flagged docs / billion tokens'], ['rate', 'Flagged docs / sampled docs (%)']].forEach(([val, txt]) => {
414
+ const o = document.createElement('option'); o.value = val; o.textContent = txt; metricSelect.appendChild(o);
415
+ });
416
+ metricSelect.value = metric;
417
+ metricSelect.addEventListener('change', () => { metric = metricSelect.value; render(); });
418
+ metricGroup.appendChild(metricLabel); metricGroup.appendChild(metricSelect);
419
+
420
+ controls.appendChild(viewGroup); controls.appendChild(metricGroup);
421
+ container.appendChild(controls);
422
+
423
+ // ─── legend ───
424
+ const legend = document.createElement('div'); legend.className = 'legend';
425
+ const lt = document.createElement('div'); lt.className = 'legend-title'; lt.textContent = 'Legend';
426
+ const items = document.createElement('div'); items.className = 'items';
427
+ SEGMENTS.forEach(s => {
428
+ const it = document.createElement('span'); it.className = 'item';
429
+ const sw = document.createElement('span'); sw.className = 'swatch'; sw.style.background = COLOR[s];
430
+ const tx = document.createElement('span'); tx.textContent = BENCH_LABEL[s];
431
+ it.appendChild(sw); it.appendChild(tx); items.appendChild(it);
432
+ });
433
+ const note = document.createElement('div'); note.className = 'note';
434
+ note.innerHTML = '\u25C6 synthetic baseline &nbsp; \u25C7 original / curated-web baseline &nbsp; (no marker = FinePhrase rephrased). Each corpus is subsampled to ~5B tokens for comparability.';
435
+ legend.appendChild(lt); legend.appendChild(items); legend.appendChild(note);
436
+ container.appendChild(legend);
437
+
438
+ render();
439
+ if (window.ResizeObserver) new ResizeObserver(() => render()).observe(container);
440
+ else window.addEventListener('resize', render);
441
+ }
442
+ };
443
+
444
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
445
+ else ensureD3(bootstrap);
446
+ })();
447
+ </script>