botanic1-report / causal-variants.html
Jean Ogier du Terrail
Point technical report links and BibTeX at bioRxiv v2 (2026.09.04.749355v2, posted 2026-09-09)
fd66581
Raw History Blame
23.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Botanic1 causal-variant benchmark</title>
<script>(function(){try{var t=new URLSearchParams(location.search).get('__theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700;800&family=Spectral:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<link rel="stylesheet" href="assets/site.css?v=20260908-use-case">
<link rel="stylesheet" href="assets/causal.css?v=20260908-browser">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
</head>
<body>
<header class="topbar"><div class="inner"><a class="lockup" href="index.html"><div class="wordmark" aria-label="BOTANIC-1"><span class="cell">B</span><span class="cell">O</span><span class="cell">T</span><span class="cell">A</span><span class="cell">N</span><span class="cell">I</span><span class="cell">C</span><span class="cell tok" data-state="masked"><span class="probs"><i></i><i class="top"></i><i></i><i></i></span>?</span></div><span class="org"><svg class="living-models-mark" width="18" height="20" fill="currentColor" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 85.90 91.50"><rect x="55.1000" y="2.1000" width="3.2000" height="53.2000" /><rect x="55.1000" y="61.3000" width="3.2000" height="28.1000" /><rect x="68.9000" y="9.2000" width="3.2000" height="15.5000" /><rect x="68.9000" y="30.6000" width="3.2000" height="51.7000" /><rect x="82.7000" y="24.7000" width="3.2000" height="42.2000" /><rect x="41.4000" y="0.0000" width="3.2000" height="11.6000" /><rect x="41.4000" y="17.5000" width="3.2000" height="74.0000" /><rect x="27.6000" y="2.1000" width="3.2000" height="70.7000" /><rect x="27.6000" y="78.7000" width="3.2000" height="10.6000" /><rect x="13.8000" y="9.2000" width="3.2000" height="25.0000" /><rect x="13.8000" y="40.2000" width="3.2000" height="42.2000" /><rect x="0.0000" y="24.7000" width="3.2000" height="42.2000" /></svg>Living Models</span></a><nav aria-label="Pages"><a href="index.html">Overview</a><a href="factory.html">Model factory</a><a href="leaderboard.html">Leaderboard</a><a href="causal-variants.html" aria-current="page">Causal variants</a><a href="sae-features.html">SAE features</a><a href="use-case.html">Use case</a><a href="corpus.html">Corpus</a><a class="ext" href="https://huggingface.co/collections/living-models/botanic1-6a97f4e3c33f3d109a75057d" target="_blank" rel="noopener">Models</a></nav></div></header>
<div class="page">
<main class="prose">
<h1>Zero-shot causal variant prioritisation</h1>
<p class="lede">A new and harder zero-shot causal-variant discovery benchmark based on experimentally validated variants, totalling 545 loci across 14 species. Each task ranks one documented causal SNP among population SNPs in its surrounding 100 kbp locus.</p>
<figure class="locus-browser" aria-labelledby="locus-title">
<div class="locus-heading">
<div><h2 id="locus-title">ABI5 locus</h2><p><i>Arabidopsis thaliana</i> · TAIR10 · Botanic1-XL</p></div>
<div class="locus-tools" role="group" aria-label="Genome browser navigation">
<button type="button" id="locus-out" aria-label="Zoom out">−</button>
<button type="button" id="locus-in" aria-label="Zoom in">+</button>
<button type="button" id="locus-full">100 kbp locus</button>
<button type="button" id="locus-causal">Causal SNP</button>
</div>
</div>
<p class="locus-region" id="locus-region">Loading locus…</p>
<div class="locus-scroll"><div id="locus-plot"></div></div>
<div class="locus-readout" id="locus-readout" aria-live="polite" aria-atomic="true"></div>
<figcaption><span id="locus-help">Hover or tap a SNP to inspect. Drag to pan; + / − to zoom. Arrow keys select SNPs.</span><span>Measured SNP scores · 4,096 bp context · <a href="https://plants.ensembl.org/Arabidopsis_thaliana/Gene/Summary?g=AT2G36270" target="_blank" rel="noopener">Araport11 gene models ↗</a></span></figcaption>
<noscript><p class="locus-fallback">Enable JavaScript to explore the ABI5 locus. <a href="https://www.biorxiv.org/content/10.64898/2026.09.04.749355v2">Read the benchmark in the report ↗</a></p></noscript>
</figure>
<p>Our newly introduced causal-variant benchmark uses experimentally validated functional variants as a source of ground truth, instead of relying on indirect proxies such as allele frequency or variant class. Every language model is scored at min(4,096, trained context) (PlantCaduceus-l32 and GPN are fixed at 512 bp) for each LLR, putting the SNV in the middle of the window. All gLMs are evaluated on the full dataset from the reference genome sequences only, while bioinformatics baselines require either precise annotations for Ensembl VEP or MSA support for conservation scores. As a result, PhyloP and PhastCons, which are built on PlantRegMap, are only scored on a subset of the studies.</p>
<p>Although it should not necessarily be ranked first, we expect a validated causal variant to rank near the top of its locus, within the top 1% (about 60 variants) or even 0.1% (about 6 variants), which is why we choose to assess the performance on this task using the recall at these thresholds. The recalls are tie-aware: if the candidate is within a tie block in a study and the considered top fraction has to cut within the block, then the contribution of that study to the recall is neither 0 nor 1 but the proportion of the tie block that can fit in the shortlist.</p>
<h2 id="sec-curve">Recall against shortlist size</h2>
<p>Fraction of studies whose causal variant falls inside a shortlist of the given size. The dotted line is the random baseline (shuffled ranking). Restrict the studies to one species, or add and remove models, and the curves and the table are recomputed from the per-study ranks.</p>
<div class="controls">
<div class="group"><span class="label">Species</span><select id="species" aria-label="Species"></select></div>
<span class="count" id="count"></span>
</div>
<div class="checks" id="models"></div>
<div class="chart" id="curve"></div>
<p class="chartnote">Hover the plot to read every selected model's recall at that shortlist fraction. Baselines are dashed. Every language model is scored on the same studies; PhyloP and PhastCons cover 471 of the 545 and Ensembl VEP 540.</p>
<h2 id="table">Recall at the top 0.1% and 1%</h2>
<div class="tablewrap"><table id="recall"></table></div>
<p class="meta">R(0.001) corresponds to the recall at the top 0.1% of all scored variants in the 100 kbp locus centred around the documented causal SNP. On average, R(0.001) corresponds to a shortlist of about 6 SNPs, and R(0.01) of about 60. Rows are sorted by the R(0.01) value. The report's Table gives 95% bootstrap confidence intervals and paired differences against Botanic1-XL; on the full cohort, Botanic1-XL's lead over every non-Botanic model excludes zero at both endpoints.</p>
<h2 id="baselines">How the baselines fail</h2>
<p>In this benchmark, non-gLM baselines fail in somewhat unexpected ways. Ensembl VEP is a categorical score, and usually scores the causal variant as "high impact" along with many other variants in the locus (median of 10.4% of all SNVs), so selecting the top 1% or lower is impossible on the whole dataset. Conservation scores also have a ceiling on their possible recall performance, due to the depth of the original MSA and each score's formula. PhyloP is the most sensitive to this effect, assigning a tied score to a median number of 39 top variants in 89 studies, reproducing the weakness of VEP to a smaller extent. PhastCons uses contextual information and is more continuous, and does not saturate in practice.</p>
<p>gLMs present several advantages over the tested baselines. First, they only require a sequence from the reference genome around the variant that one wants to score. Neither annotation nor multiple sequence alignment is needed. Secondly, since all present models are pre-trained on reference genomes only, their scores are not subject to linkage disequilibrium or influenced by the variant's frequency in arbitrary populations.</p>
<h2 id="context">Context length</h2>
<p>All models for which we performed a context sweep gradually improve performance until 4,096 bp, and plateau after. R(0.01) per scoring window, each model on its own matched set of studies:</p>
<div class="tablewrap"><table id="sweep"></table></div>
<h2 id="limits">Simplifications</h2>
<p>This new benchmark also uses some simplifications: the search is limited to a 100 kbp causal region and to a single basic type of mutation, SNPs. Besides, the task relies on the assumption that the documented causal SNP should be ranked among the candidate variants assigned the lowest likelihood by the model, an assumption that could be challenged since the surrounding unlabelled variants may include other functional or phenotypically relevant mutations. This limitation affects the interpretation of the absolute benchmark scores, but not the relative comparison between methods, since all methods are evaluated against the same set of unlabelled variants.</p>
<h2 id="sec-studies">The 545 studies</h2>
<p>Below are 20 example studies from the benchmark. The full set of 545 studies, candidate variants, and curation audit will be published later.</p>
<p class="small">Each row gives the validated causal SNP and its rank within the locus (1 is best). The recall curves above use all 545 studies.</p>
<div class="controls">
<div class="group"><input type="search" id="q" placeholder="Search the 20 examples" aria-label="Search the 20 example studies"></div>
<div class="group"><span class="label">Rank columns</span><select id="rankcols" multiple size="1" style="display:none"></select><span class="small" id="rankcols-note"></span></div>
<span class="count" id="preview-count" role="status"></span>
</div>
<div class="scroller"><table id="studies"></table></div>
</main>
<footer class="site-footer">
<p>Living Models, Paris. Botanic1 is released for research use under the Living Models research licence.</p>
<p>Per-study ranks, cohort and tie-aware recall follow the technical report's causal-variant analysis.</p>
</footer>
</div>
<div class="tip" id="tip" role="tooltip"></div>
<script src="assets/hero.js"></script>
<script src="assets/causal-browser.js?v=20260908-browser" defer></script>
<script>
(async function () {
const data = await (await fetch('data/causal.json')).json();
const S = data.studies, MODELS = data.models;
const css = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;');
const fmt = (v, d = 3) => (v == null || isNaN(v)) ? '' : v.toFixed(d);
const tip = document.getElementById('tip');
const showTip = (html, ev) => { tip.innerHTML = html; tip.style.display = 'block'; const pad = 14; let x = ev.clientX + pad, y = ev.clientY + pad; const r = tip.getBoundingClientRect(); if (x + r.width > innerWidth - 8) x = ev.clientX - r.width - pad; if (y + r.height > innerHeight - 8) y = ev.clientY - r.height - pad; tip.style.left = x + 'px'; tip.style.top = y + 'px'; };
const hideTip = () => { tip.style.display = 'none'; };
// Colour follows the model, matching the report's figures.
const COLOUR = {
XL315: '--ours-deeper', L: '--ours-deep', M315: '--ours', S315: '--ours-light', ABCD: '--ours-pale', BOTANIC: '--prior',
PlantCAD2: '--plantcad', PlantCAD2_512: '--plantcad-3', PlantCAD: '--plantcad-2',
'Evo2-1b': '--evo2', 'Evo2-7b-base': '--evo2-2', 'Evo2-7b': '--evo2-2', 'Evo2-40b': '--evo2-3',
'Carbon-3B': '--carbon-2', 'Carbon-8B': '--carbon', 'NTv3-650M-pre': '--ntv3', 'NTv3-650M-post': '--ntv3-2',
GPN: '--gpn', AgroNT: '--agront', PhyloP: '--baseline', PhastCons: '--baseline-2', VEP_consequence: '--baseline'
};
const colour = (m) => css(COLOUR[m.key] || '--other');
const DASH = { PhyloP: '6 4', PhastCons: '2 4', VEP_consequence: '10 4 2 4', 'Evo2-7b': '4 3', PlantCAD2_512: '4 3', 'NTv3-650M-post': '4 3', 'Carbon-3B': '4 3', 'Evo2-7b-base': '1 3' };
const state = { species: 'all', selected: new Set(['XL315', 'L', 'M315', 'S315', 'PlantCAD2', 'Evo2-1b', 'PhyloP', 'PhastCons', 'VEP_consequence']), q: '' };
// species selector
const spSel = document.getElementById('species');
spSel.innerHTML = '<option value="all">All 14 species</option>' + data.species.map(s => `<option value="${esc(s.name)}"><i>${esc(s.name)}</i> (${s.n})</option>`).join('');
spSel.addEventListener('change', () => { state.species = spSel.value; renderAll(); });
// model checkboxes
const box = document.getElementById('models');
box.innerHTML = MODELS.map(m => `<label><input type="checkbox" data-key="${m.key}" ${state.selected.has(m.key) ? 'checked' : ''}><span class="swatch ${DASH[m.key] ? 'dashed' : ''}" style="${DASH[m.key] ? '' : 'background:' + colour(m) + ';'}color:${colour(m)}"></span>${esc(m.name)}${m.kind !== 'lm' ? ' <span class="small">(baseline)</span>' : ''}</label>`).join('');
box.addEventListener('change', (e) => { const k = e.target.dataset.key; if (e.target.checked) state.selected.add(k); else state.selected.delete(k); renderAll(); });
function studyIdx() {
const idx = [];
S.forEach((s, i) => { if (state.species === 'all' || s.species === state.species) idx.push(i); });
return idx;
}
// Tie-aware hit probability for one study at shortlist size k (see gwas_causal_common.hit_frac).
const hit = (k, nb, nt) => Math.min(Math.max(k - nb, 0), nt) / nt;
function recall(m, idx, alpha) {
let sum = 0, n = 0;
for (const i of idx) { const N = m.n_variants[i]; if (N < 0) continue; sum += hit(Math.floor(alpha * N), m.n_better[i], m.n_tie[i]); n++; }
return n ? { r: sum / n, n } : { r: NaN, n: 0 };
}
const ALPHAS = []; for (let i = 0; i <= 80; i++) ALPHAS.push(Math.pow(10, -4 + 4 * i / 80));
function renderCurve() {
const idx = studyIdx();
const sel = MODELS.filter(m => state.selected.has(m.key));
const W = 960, H = 440, L = 56, R = 24, T = 20, B = 52;
const lx = (a) => L + (Math.log10(a) + 4) / 4 * (W - L - R);
const y = (v) => T + (1 - v) * (H - T - B);
let s = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Recall against shortlist fraction">`;
s += '<g class="grid axis">';
for (let v = 0; v <= 1.0001; v += 0.2) s += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/><text x="${L - 8}" y="${y(v) + 4}" text-anchor="end">${v.toFixed(1)}</text>`;
[1e-4, 1e-3, 1e-2, 1e-1, 1].forEach(a => { s += `<line x1="${lx(a)}" x2="${lx(a)}" y1="${T}" y2="${H - B}"/><text x="${lx(a)}" y="${H - B + 18}" text-anchor="middle">${a >= 0.01 ? (a * 100) + '%' : (a * 100) + '%'}</text>`; });
s += `<text x="${(L + W - R) / 2}" y="${H - 10}" text-anchor="middle">fraction of the locus' SNVs shortlisted</text>`;
s += `<text transform="translate(14 ${(T + H - B) / 2}) rotate(-90)" text-anchor="middle">fraction of studies recovering the causal variant</text>`;
s += '</g>';
s += `<g class="marker"><line x1="${lx(0.001)}" x2="${lx(0.001)}" y1="${T}" y2="${H - B}"/><text x="${lx(0.001) + 5}" y="${T + 12}">top 0.1%</text><line x1="${lx(0.01)}" x2="${lx(0.01)}" y1="${T}" y2="${H - B}"/><text x="${lx(0.01) + 5}" y="${T + 12}">top 1%</text></g>`;
// random baseline
s += `<path d="${ALPHAS.map((a, i) => (i ? 'L' : 'M') + lx(a) + ' ' + y(a)).join(' ')}" fill="none" stroke="var(--ink-3)" stroke-width="1.5" stroke-dasharray="2 4"/>`;
const series = sel.map(m => ({ m, pts: ALPHAS.map(a => recall(m, idx, a).r) })).filter(x => !isNaN(x.pts[40]));
series.forEach(({ m, pts }) => {
const d = ALPHAS.map((a, i) => (i ? 'L' : 'M') + lx(a).toFixed(1) + ' ' + y(pts[i]).toFixed(1)).join(' ');
s += `<path d="${d}" fill="none" stroke="${colour(m)}" stroke-width="2" ${DASH[m.key] ? `stroke-dasharray="${DASH[m.key]}"` : ''} stroke-linejoin="round"/>`;
});
s += `<line id="xh" x1="0" x2="0" y1="${T}" y2="${H - B}" stroke="var(--ink-2)" stroke-width="1" style="display:none"/>`;
s += `<rect class="hit" x="${L}" y="${T}" width="${W - L - R}" height="${H - T - B}" id="curvehit"/>`;
s += '</svg>';
const el = document.getElementById('curve'); el.innerHTML = s;
const svg = el.querySelector('svg'), xh = svg.querySelector('#xh');
const hitr = svg.querySelector('#curvehit');
hitr.addEventListener('mousemove', (ev) => {
const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; const p = pt.matrixTransform(svg.getScreenCTM().inverse());
const a = Math.pow(10, -4 + (p.x - L) / (W - L - R) * 4);
xh.setAttribute('x1', p.x); xh.setAttribute('x2', p.x); xh.style.display = '';
const rows = series.map(({ m }) => ({ m, r: recall(m, idx, a) })).sort((u, v) => v.r.r - u.r.r);
showTip(`<b>top ${(a * 100).toPrecision(2)}%</b> of the locus<br><span style="opacity:.8">≈ ${Math.round(a * medianN(idx))} of the median ${medianN(idx).toLocaleString()} candidates</span><table>` + rows.map(({ m, r }) => `<tr><td><span class="swatch" style="background:${colour(m)}"></span>${esc(m.name)}</td><td class="n">${fmt(r.r)}</td></tr>`).join('') + '</table>', ev);
});
hitr.addEventListener('mouseleave', () => { xh.style.display = 'none'; hideTip(); });
document.getElementById('count').textContent = `${idx.length} studies`;
}
function medianN(idx) {
const xl = MODELS.find(m => m.key === 'XL315');
const ns = idx.map(i => xl.n_variants[i]).filter(n => n > 0).sort((a, b) => a - b);
return ns.length ? ns[Math.floor(ns.length / 2)] : 0;
}
function renderRecall() {
const idx = studyIdx();
const rows = MODELS.filter(m => state.selected.has(m.key)).map(m => { const a = recall(m, idx, 0.001), b = recall(m, idx, 0.01); return { m, a, b }; }).filter(r => r.b.n > 0).sort((u, v) => v.b.r - u.b.r);
let h = '<thead><tr><th>Model or baseline</th><th class="n">R(0.001)</th><th class="n">R(0.01)</th><th class="n">Ctx (bp)</th><th class="n">n</th></tr></thead><tbody>';
rows.forEach(({ m, a, b }) => { h += `<tr class="${m.family === 'botanic1' ? 'ours' : ''}"><td><span class="swatch" style="background:${colour(m)}"></span>${esc(m.name)}</td><td class="n">${fmt(a.r)}</td><td class="n">${fmt(b.r)}</td><td class="n">${esc(m.ctx)}</td><td class="n">${b.n}</td></tr>`; });
h += '</tbody>';
document.getElementById('recall').innerHTML = h;
}
function renderSweep() {
const ctxs = ['512', '1024', '4096', '8192'];
let h = '<thead><tr><th>Model</th>' + ctxs.map(c => `<th class="n">${Number(c).toLocaleString()} bp</th>`).join('') + '<th class="n">n</th></tr></thead><tbody>';
data.sweep.forEach(r => { h += `<tr><td>${esc(r.name)}</td>` + ctxs.map(c => `<td class="n">${r.r_01[c] == null ? '' : fmt(r.r_01[c])}</td>`).join('') + `<td class="n">${r.n}</td></tr>`; });
h += '</tbody>';
document.getElementById('sweep').innerHTML = h;
}
// ---- study browser ----
// Preview loci with substantial candidate sets, independently of model ranks.
// Filter and sort this subset only; the recall curves still use the full cohort.
const previewModel = MODELS.find(m => m.key === 'XL315');
const eligiblePreview = S.map((s, i) => i).filter(i => previewModel.n_variants[i] >= 1000);
const previewBySpecies = [...new Set(eligiblePreview.map(i => S[i].species))].sort().map(species => eligiblePreview.filter(i => S[i].species === species));
const abi5 = eligiblePreview.find(i => S[i].id === 'ABI5');
const previewIdx = abi5 === undefined ? [] : [abi5];
const previewTarget = Math.min(20, eligiblePreview.length);
for (let offset = 0; previewIdx.length < previewTarget; offset++) {
for (const group of previewBySpecies) {
if (group[offset] !== undefined && !previewIdx.includes(group[offset]) && previewIdx.length < previewTarget) previewIdx.push(group[offset]);
}
}
const RANK_KEYS = ['XL315', 'L', 'PlantCAD2', 'PhyloP'];
document.getElementById('rankcols-note').textContent = RANK_KEYS.map(k => MODELS.find(m => m.key === k).name).join(', ');
const sortState = { key: 'species', dir: 1 };
const rankOf = (m, i) => { const nb = m.n_better[i], nt = m.n_tie[i]; if (nb < 0) return null; return { rank: nb + 1, tie: nt }; };
function renderStudies() {
const q = state.q.trim().toLowerCase();
let idx = previewIdx.filter(i => state.species === 'all' || S[i].species === state.species).filter(i => { if (!q) return true; const s = S[i]; return [s.gene, s.id, s.species, s.variant_class].some(v => v && v.toLowerCase().includes(q)); });
const xl = MODELS.find(m => m.key === 'XL315');
const keyf = (i) => { const s = S[i]; switch (sortState.key) { case 'species': return s.species + ' ' + (s.gene || ''); case 'gene': return (s.gene || '').toLowerCase(); case 'class': return s.variant_class || ''; case 'n': return xl.n_variants[i]; default: { const m = MODELS.find(mm => mm.key === sortState.key); const r = rankOf(m, i); return r ? r.rank : 1e9; } } };
idx.sort((a, b) => { const ka = keyf(a), kb = keyf(b); return (ka < kb ? -1 : ka > kb ? 1 : 0) * sortState.dir; });
const th = (k, label, cls = '') => `<th class="${cls} ${sortState.key === k ? 'active' : ''}" data-sort="${k}">${label}${sortState.key === k ? (sortState.dir > 0 ? ' ↑' : ' ↓') : ''}</th>`;
let h = '<thead><tr>' + th('species', 'Species') + th('gene', 'Gene') + th('class', 'Class') + '<th>Locus</th>' + th('n', 'Candidates', 'n') + RANK_KEYS.map(k => th(k, esc(MODELS.find(m => m.key === k).name), 'n')).join('') + '<th>Source</th></tr></thead><tbody>';
idx.forEach(i => {
const s = S[i];
const src = s.paper ? (s.paper.startsWith('PMID:') ? `<a href="https://pubmed.ncbi.nlm.nih.gov/${esc(s.paper.slice(5))}/">${esc(s.paper)}</a>` : s.paper.startsWith('DOI:') ? `<a href="https://doi.org/${esc(s.paper.slice(4))}">${esc(s.paper)}</a>` : esc(s.paper)) : '';
h += `<tr><td><i>${esc(s.species)}</i></td><td>${esc(s.gene || s.id)}</td><td>${esc(s.variant_class || '')}</td><td class="mono" style="font-size:12.5px;white-space:nowrap">${esc(s.chrom)}:${s.pos.toLocaleString()} ${esc(s.ref)}&gt;${esc(s.alt)}</td><td class="n">${xl.n_variants[i] > 0 ? xl.n_variants[i].toLocaleString() : ''}</td>`;
RANK_KEYS.forEach(k => { const r = rankOf(MODELS.find(m => m.key === k), i); h += `<td class="n">${r ? r.rank.toLocaleString() + (r.tie > 1 ? `<span class="small"> (tie ${r.tie})</span>` : '') : '<span class="small">n/a</span>'}</td>`; });
h += `<td class="small">${src}</td></tr>`;
});
if (!idx.length) h += `<tr><td colspan="${6 + RANK_KEYS.length}">No matching studies in this 20-study preview.</td></tr>`;
h += '</tbody>';
const t = document.getElementById('studies'); t.innerHTML = h;
t.querySelectorAll('th[data-sort]').forEach(x => x.addEventListener('click', () => { const k = x.dataset.sort; if (sortState.key === k) sortState.dir *= -1; else { sortState.key = k; sortState.dir = 1; } renderStudies(); }));
document.getElementById('preview-count').textContent = `${idx.length} of ${previewIdx.length} examples`;
}
document.getElementById('q').addEventListener('input', (e) => { state.q = e.target.value; renderStudies(); });
function renderAll() { renderCurve(); renderRecall(); renderStudies(); }
renderAll(); renderSweep();
})();
</script>
</body>
</html>